001/*
002 * Licensed to the Apache Software Foundation (ASF) under one
003 * or more contributor license agreements.  See the NOTICE file
004 * distributed with this work for additional information
005 * regarding copyright ownership.  The ASF licenses this file
006 * to you under the Apache License, Version 2.0 (the
007 * "License"); you may not use this file except in compliance
008 * with the License.  You may obtain a copy of the License at
009 *
010 *   http://www.apache.org/licenses/LICENSE-2.0
011 *
012 * Unless required by applicable law or agreed to in writing,
013 * software distributed under the License is distributed on an
014 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
015 * KIND, either express or implied.  See the License for the
016 * specific language governing permissions and limitations
017 * under the License.
018 */
019package org.apache.maven.doxia.module.fml;
020
021import javax.inject.Named;
022import javax.inject.Singleton;
023import javax.swing.text.html.HTML.Attribute;
024
025import java.io.IOException;
026import java.io.Reader;
027import java.io.StringReader;
028import java.io.StringWriter;
029import java.util.HashMap;
030import java.util.Iterator;
031import java.util.LinkedHashMap;
032import java.util.Map;
033
034import org.apache.commons.io.IOUtils;
035import org.apache.maven.doxia.macro.MacroExecutionException;
036import org.apache.maven.doxia.macro.MacroRequest;
037import org.apache.maven.doxia.macro.manager.MacroNotFoundException;
038import org.apache.maven.doxia.module.fml.model.Faq;
039import org.apache.maven.doxia.module.fml.model.Faqs;
040import org.apache.maven.doxia.module.fml.model.Part;
041import org.apache.maven.doxia.parser.AbstractXmlParser;
042import org.apache.maven.doxia.parser.ParseException;
043import org.apache.maven.doxia.sink.Sink;
044import org.apache.maven.doxia.sink.impl.SinkEventAttributeSet;
045import org.apache.maven.doxia.sink.impl.Xhtml5BaseSink;
046import org.apache.maven.doxia.util.DoxiaStringUtils;
047import org.apache.maven.doxia.util.DoxiaUtils;
048import org.apache.maven.doxia.util.HtmlTools;
049import org.codehaus.plexus.util.xml.pull.XmlPullParser;
050import org.codehaus.plexus.util.xml.pull.XmlPullParserException;
051import org.slf4j.Logger;
052import org.slf4j.LoggerFactory;
053
054/**
055 * Parse a fml model and emit events into the specified doxia Sink.
056 *
057 * @author <a href="mailto:evenisse@codehaus.org">Emmanuel Venisse</a>
058 * @author ltheussl
059 * @since 1.0
060 */
061@Singleton
062@Named("fml")
063public class FmlParser extends AbstractXmlParser implements FmlMarkup {
064    private static final Logger LOGGER = LoggerFactory.getLogger(FmlParser.class);
065
066    /** Collect a faqs model. */
067    private Faqs faqs;
068
069    /** Collect a part. */
070    private Part currentPart;
071
072    /** Collect a single faq. */
073    private Faq currentFaq;
074
075    /** Used to collect text events. */
076    private StringBuilder buffer;
077
078    /** The source content of the input reader. Used to pass into macros. */
079    private String sourceContent;
080
081    /** A macro name. */
082    private String macroName;
083
084    /** The macro parameters. */
085    private Map<String, Object> macroParameters = new LinkedHashMap<>();
086
087    public void parse(Reader source, Sink sink, String reference) throws ParseException {
088        this.faqs = null;
089        this.sourceContent = null;
090        init();
091
092        try (Reader reader = source) {
093            StringWriter contentWriter = new StringWriter();
094            IOUtils.copy(reader, contentWriter);
095            sourceContent = contentWriter.toString();
096        } catch (IOException ex) {
097            throw new ParseException("Error reading the input source", ex);
098        }
099
100        try {
101            Reader tmp = new StringReader(sourceContent);
102
103            this.faqs = new Faqs();
104
105            // this populates faqs
106            super.parse(tmp, sink, reference);
107
108            writeFaqs(getWrappedSink(sink));
109        } finally {
110            this.faqs = null;
111            this.sourceContent = null;
112            setSecondParsing(false);
113            init();
114        }
115    }
116
117    protected void handleStartTag(XmlPullParser parser, Sink sink)
118            throws XmlPullParserException, MacroExecutionException {
119        if (parser.getName().equals(FAQS_TAG.toString())) {
120            String title = parser.getAttributeValue(null, "title");
121
122            if (title != null) {
123                faqs.setTitle(title);
124            }
125
126            String toplink = parser.getAttributeValue(null, "toplink");
127
128            if (toplink != null) {
129                if (toplink.equalsIgnoreCase("true")) {
130                    faqs.setToplink(true);
131                } else {
132                    faqs.setToplink(false);
133                }
134            }
135        } else if (parser.getName().equals(PART_TAG.toString())) {
136            currentPart = new Part();
137
138            currentPart.setId(parser.getAttributeValue(null, Attribute.ID.toString()));
139
140            if (currentPart.getId() == null) {
141                throw new XmlPullParserException("id attribute required for <part> at: (" + parser.getLineNumber() + ":"
142                        + parser.getColumnNumber() + ")");
143            } else if (!DoxiaUtils.isValidId(currentPart.getId())) {
144                String linkAnchor = DoxiaUtils.encodeId(currentPart.getId());
145
146                LOGGER.debug("Modified invalid link '{}' to '{}'", currentPart.getId(), linkAnchor);
147
148                currentPart.setId(linkAnchor);
149            }
150        } else if (parser.getName().equals(TITLE.toString())) {
151            buffer = new StringBuilder();
152            buffer.append(LESS_THAN).append(parser.getName()).append(GREATER_THAN);
153        } else if (parser.getName().equals(FAQ_TAG.toString())) {
154            currentFaq = new Faq();
155
156            currentFaq.setId(parser.getAttributeValue(null, Attribute.ID.toString()));
157
158            if (currentFaq.getId() == null) {
159                throw new XmlPullParserException("id attribute required for <faq> at: (" + parser.getLineNumber() + ":"
160                        + parser.getColumnNumber() + ")");
161            } else if (!DoxiaUtils.isValidId(currentFaq.getId())) {
162                String linkAnchor = DoxiaUtils.encodeId(currentFaq.getId());
163
164                LOGGER.debug("Modified invalid link '{}' to '{}'", currentFaq.getId(), linkAnchor);
165
166                currentFaq.setId(linkAnchor);
167            }
168        } else if (parser.getName().equals(QUESTION_TAG.toString())) {
169            buffer = new StringBuilder();
170            buffer.append(LESS_THAN).append(parser.getName()).append(GREATER_THAN);
171        } else if (parser.getName().equals(ANSWER_TAG.toString())) {
172            buffer = new StringBuilder();
173            buffer.append(LESS_THAN).append(parser.getName()).append(GREATER_THAN);
174
175        }
176
177        // ----------------------------------------------------------------------
178        // Macro
179        // ----------------------------------------------------------------------
180
181        else if (parser.getName().equals(MACRO_TAG.toString())) {
182            handleMacroStart(parser);
183        } else if (parser.getName().equals(PARAM.toString())) {
184            handleParamStart(parser, sink);
185        } else if (buffer != null) {
186            buffer.append(LESS_THAN).append(parser.getName());
187
188            int count = parser.getAttributeCount();
189
190            for (int i = 0; i < count; i++) {
191                buffer.append(SPACE).append(parser.getAttributeName(i));
192
193                buffer.append(EQUAL).append(QUOTE);
194
195                // TODO: why are attribute values HTML-encoded?
196                buffer.append(HtmlTools.escapeHTML(parser.getAttributeValue(i)));
197
198                buffer.append(QUOTE);
199            }
200
201            buffer.append(GREATER_THAN);
202        }
203    }
204
205    protected void handleEndTag(XmlPullParser parser, Sink sink)
206            throws XmlPullParserException, MacroExecutionException {
207        if (parser.getName().equals(FAQS_TAG.toString())) {
208            // Do nothing
209            return;
210        } else if (parser.getName().equals(PART_TAG.toString())) {
211            faqs.addPart(currentPart);
212
213            currentPart = null;
214        } else if (parser.getName().equals(FAQ_TAG.toString())) {
215            if (currentPart == null) {
216                throw new XmlPullParserException(
217                        "Missing <part>  at: (" + parser.getLineNumber() + ":" + parser.getColumnNumber() + ")");
218            }
219
220            currentPart.addFaq(currentFaq);
221
222            currentFaq = null;
223        } else if (parser.getName().equals(QUESTION_TAG.toString())) {
224            if (currentFaq == null) {
225                throw new XmlPullParserException(
226                        "Missing <faq> at: (" + parser.getLineNumber() + ":" + parser.getColumnNumber() + ")");
227            }
228
229            buffer.append(LESS_THAN).append(SLASH).append(parser.getName()).append(GREATER_THAN);
230
231            currentFaq.setQuestion(buffer.toString());
232
233            buffer = null;
234        } else if (parser.getName().equals(ANSWER_TAG.toString())) {
235            if (currentFaq == null) {
236                throw new XmlPullParserException(
237                        "Missing <faq> at: (" + parser.getLineNumber() + ":" + parser.getColumnNumber() + ")");
238            }
239
240            buffer.append(LESS_THAN).append(SLASH).append(parser.getName()).append(GREATER_THAN);
241
242            currentFaq.setAnswer(buffer.toString());
243
244            buffer = null;
245        } else if (parser.getName().equals(TITLE.toString())) {
246            if (currentPart == null) {
247                throw new XmlPullParserException(
248                        "Missing <part> at: (" + parser.getLineNumber() + ":" + parser.getColumnNumber() + ")");
249            }
250
251            buffer.append(LESS_THAN).append(SLASH).append(parser.getName()).append(GREATER_THAN);
252
253            currentPart.setTitle(buffer.toString());
254
255            buffer = null;
256        }
257
258        // ----------------------------------------------------------------------
259        // Macro
260        // ----------------------------------------------------------------------
261
262        else if (parser.getName().equals(MACRO_TAG.toString())) {
263            handleMacroEnd(buffer);
264        } else if (parser.getName().equals(PARAM.toString())) {
265            if (!(macroName != null && !macroName.isEmpty())) {
266                handleUnknown(parser, sink, TAG_TYPE_END);
267            }
268        } else if (buffer != null) {
269            if (buffer.length() > 0 && buffer.charAt(buffer.length() - 1) == SPACE) {
270                buffer.deleteCharAt(buffer.length() - 1);
271            }
272
273            buffer.append(LESS_THAN).append(SLASH).append(parser.getName()).append(GREATER_THAN);
274        }
275    }
276
277    protected void handleText(XmlPullParser parser, Sink sink) throws XmlPullParserException {
278        if (buffer != null) {
279            buffer.append(parser.getText());
280        }
281        // only significant text content in fml files is in <question>, <answer> or <title>
282    }
283
284    protected void handleCdsect(XmlPullParser parser, Sink sink) throws XmlPullParserException {
285        String cdSection = parser.getText();
286
287        if (buffer != null) {
288            buffer.append(LESS_THAN)
289                    .append(BANG)
290                    .append(LEFT_SQUARE_BRACKET)
291                    .append(CDATA)
292                    .append(LEFT_SQUARE_BRACKET)
293                    .append(cdSection)
294                    .append(RIGHT_SQUARE_BRACKET)
295                    .append(RIGHT_SQUARE_BRACKET)
296                    .append(GREATER_THAN);
297        } else {
298            sink.text(cdSection);
299        }
300    }
301
302    protected void handleComment(XmlPullParser parser, Sink sink) throws XmlPullParserException {
303        String comment = parser.getText();
304
305        if (buffer != null) {
306            buffer.append(LESS_THAN)
307                    .append(BANG)
308                    .append(MINUS)
309                    .append(MINUS)
310                    .append(comment)
311                    .append(MINUS)
312                    .append(MINUS)
313                    .append(GREATER_THAN);
314        } else {
315            if (isEmitComments()) {
316                sink.comment(comment);
317            }
318        }
319    }
320
321    protected void handleEntity(XmlPullParser parser, Sink sink) throws XmlPullParserException {
322        if (buffer != null) {
323            if (parser.getText() != null) {
324                String text = parser.getText();
325
326                // parser.getText() returns the entity replacement text
327                // (&lt; -> <), need to re-escape them
328                if (text.length() == 1) {
329                    text = HtmlTools.escapeHTML(text);
330                }
331
332                buffer.append(text);
333            }
334        } else {
335            super.handleEntity(parser, sink);
336        }
337    }
338
339    /**
340     * {@inheritDoc}
341     */
342    protected void init() {
343        super.init();
344
345        this.currentFaq = null;
346        this.currentPart = null;
347        this.buffer = null;
348        this.macroName = null;
349        this.macroParameters = null;
350    }
351
352    /**
353     * TODO import from XdocParser, probably need to be generic.
354     *
355     * @param parser not null
356     * @throws MacroExecutionException if any
357     */
358    private void handleMacroStart(XmlPullParser parser) throws MacroExecutionException {
359        if (!isSecondParsing()) {
360            macroName = parser.getAttributeValue(null, Attribute.NAME.toString());
361
362            if (macroParameters == null) {
363                macroParameters = new HashMap<>();
364            }
365
366            if (macroName == null || macroName.isEmpty()) {
367                throw new MacroExecutionException("The '" + Attribute.NAME.toString() + "' attribute for the '"
368                        + MACRO_TAG.toString() + "' tag is required.");
369            }
370        }
371    }
372
373    /**
374     * TODO import from XdocParser, probably need to be generic.
375     *
376     * @param buffer not null
377     * @throws MacroExecutionException if any
378     */
379    private void handleMacroEnd(StringBuilder buffer) throws MacroExecutionException {
380        if (!isSecondParsing()) {
381            if (macroName != null && !macroName.isEmpty()) {
382                MacroRequest request = new MacroRequest(sourceContent, new FmlParser(), macroParameters, getBasedir());
383
384                try {
385                    StringWriter sw = new StringWriter();
386                    Xhtml5BaseSink sink = new Xhtml5BaseSink(sw);
387                    executeMacro(macroName, request, sink);
388                    sink.close();
389                    buffer.append(sw.toString());
390                } catch (MacroNotFoundException me) {
391                    throw new MacroExecutionException("Macro not found: " + macroName, me);
392                }
393            }
394        }
395
396        // Reinit macro
397        macroName = null;
398        macroParameters = null;
399    }
400
401    /**
402     * TODO import from XdocParser, probably need to be generic.
403     *
404     * @param parser not null
405     * @param sink not null
406     * @throws MacroExecutionException if any
407     */
408    private void handleParamStart(XmlPullParser parser, Sink sink) throws MacroExecutionException {
409        if (!isSecondParsing()) {
410            if (macroName != null && !macroName.isEmpty()) {
411                String paramName = parser.getAttributeValue(null, Attribute.NAME.toString());
412                String paramValue = parser.getAttributeValue(null, Attribute.VALUE.toString());
413
414                if ((paramName == null || paramName.isEmpty()) || (paramValue == null || paramValue.isEmpty())) {
415                    throw new MacroExecutionException("'" + Attribute.NAME.toString()
416                            + "' and '" + Attribute.VALUE.toString() + "' attributes for the '" + PARAM.toString()
417                            + "' tag are required inside the '" + MACRO_TAG.toString() + "' tag.");
418                }
419
420                macroParameters.put(paramName, paramValue);
421            } else {
422                // param tag from non-macro object, see MSITE-288
423                handleUnknown(parser, sink, TAG_TYPE_START);
424            }
425        }
426    }
427
428    /**
429     * Writes the faqs to the specified sink.
430     *
431     * @param sink The sink to consume the event.
432     * @throws ParseException if something goes wrong.
433     */
434    private void writeFaqs(Sink sink) throws ParseException {
435        FmlContentParser xdocParser = new FmlContentParser();
436
437        sink.head();
438        sink.title();
439        sink.text(faqs.getTitle());
440        sink.title_();
441        sink.head_();
442
443        sink.body();
444        sink.section1();
445        sink.anchor("top");
446        sink.anchor_();
447        sink.sectionTitle1();
448        sink.text(faqs.getTitle());
449        sink.sectionTitle1_();
450
451        // ----------------------------------------------------------------------
452        // Write summary
453        // ----------------------------------------------------------------------
454
455        for (Part part : faqs.getParts()) {
456            if (DoxiaStringUtils.isNotEmpty(part.getTitle())) {
457                sink.paragraph();
458                sink.inline(SinkEventAttributeSet.Semantics.BOLD);
459                xdocParser.parse(part.getTitle(), sink);
460                sink.inline_();
461                sink.paragraph_();
462            }
463
464            sink.numberedList(Sink.NUMBERING_DECIMAL);
465
466            for (Faq faq : part.getFaqs()) {
467                sink.numberedListItem();
468                sink.link("#" + faq.getId());
469
470                if (DoxiaStringUtils.isNotEmpty(faq.getQuestion())) {
471                    xdocParser.parse(faq.getQuestion(), sink);
472                } else {
473                    throw new ParseException("Missing <question> for FAQ '" + faq.getId() + "'");
474                }
475
476                sink.link_();
477                sink.numberedListItem_();
478            }
479
480            sink.numberedList_();
481        }
482
483        sink.section1_();
484
485        // ----------------------------------------------------------------------
486        // Write content
487        // ----------------------------------------------------------------------
488
489        for (Part part : faqs.getParts()) {
490            if (DoxiaStringUtils.isNotEmpty(part.getTitle())) {
491                sink.section1();
492                sink.anchor(part.getId());
493                sink.anchor_();
494                sink.sectionTitle1();
495                xdocParser.parse(part.getTitle(), sink);
496                sink.sectionTitle1_();
497            }
498
499            sink.definitionList();
500
501            for (Iterator<Faq> faqIterator = part.getFaqs().iterator(); faqIterator.hasNext(); ) {
502                Faq faq = faqIterator.next();
503
504                sink.anchor(faq.getId());
505                sink.anchor_();
506
507                sink.definedTerm();
508
509                if (DoxiaStringUtils.isNotEmpty(faq.getQuestion())) {
510                    xdocParser.parse(faq.getQuestion(), sink);
511                } else {
512                    throw new ParseException("Missing <question> for FAQ '" + faq.getId() + "'");
513                }
514
515                sink.definedTerm_();
516
517                sink.definition();
518
519                if (DoxiaStringUtils.isNotEmpty(faq.getAnswer())) {
520                    xdocParser.parse(faq.getAnswer(), sink);
521                } else {
522                    throw new ParseException("Missing <answer> for FAQ '" + faq.getId() + "'");
523                }
524
525                if (faqs.isToplink()) {
526                    writeTopLink(sink);
527                }
528
529                if (faqIterator.hasNext()) {
530                    sink.horizontalRule();
531                }
532
533                sink.definition_();
534            }
535
536            sink.definitionList_();
537
538            if (DoxiaStringUtils.isNotEmpty(part.getTitle())) {
539                sink.section1_();
540            }
541        }
542
543        sink.body_();
544    }
545
546    /**
547     * Writes a toplink element.
548     *
549     * @param sink The sink to consume the event.
550     */
551    private void writeTopLink(Sink sink) {
552        SinkEventAttributeSet atts = new SinkEventAttributeSet();
553        atts.addAttribute(SinkEventAttributeSet.STYLE, "text-align: right;");
554        sink.paragraph(atts);
555        sink.link("#top");
556        sink.text("[top]");
557        sink.link_();
558        sink.paragraph_();
559    }
560}