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