diff --git a/pom.xml b/pom.xml index aca2db795..2f6178599 100644 --- a/pom.xml +++ b/pom.xml @@ -80,6 +80,13 @@ 2.1.3.RELEASE 2.1.1.RELEASE 1.1.1 + 1.9 + 1.2.1 + 0.6 + 5.0.4 + 18.0 + 1.3 + 1.5.2 @@ -288,6 +295,7 @@ + commons-io @@ -298,6 +306,12 @@ commons-configuration commons-configuration ${commons.configuration.version} + + + commons-logging + commons-logging + + com.googlecode.json-simple @@ -332,6 +346,12 @@ ${junit.version} test + + org.hamcrest + hamcrest-all + ${hamcrest.version} + test + org.assertj assertj-core @@ -348,6 +368,12 @@ pegdown ${pegdown.version} true + + + org.ow2.asm + * + + org.asciidoctor @@ -391,12 +417,16 @@ slf4j-api ${slf4j.version} - - - org.slf4j - jul-to-slf4j - ${slf4j.version} - + + org.slf4j + jul-to-slf4j + ${slf4j.version} + + + org.slf4j + jcl-over-slf4j + ${slf4j.version} + ch.qos.logback @@ -404,12 +434,49 @@ ${logback.version} true - + + - ch.qos.logback - logback-core - ${logback.version} - true + org.apache.tika + tika-parsers + ${tika.version} + + + * + * + + + + + org.apache.tika + tika-core + ${tika.version} + + + org.ccil.cowan.tagsoup + tagsoup + ${tagsoup.version} + + + org.apache.sis.core + sis-utility + ${sis.version} + + + com.google.guava + guava + ${guava.version} + + org.ow2.asm + asm-debug-all + ${asm.version} + + + com.googlecode.htmlcompressor + htmlcompressor + ${htmlcompressor.version} + + diff --git a/src/main/java/org/jbake/app/ConfigUtil.java b/src/main/java/org/jbake/app/ConfigUtil.java index 0c864a4b2..c97fd7972 100644 --- a/src/main/java/org/jbake/app/ConfigUtil.java +++ b/src/main/java/org/jbake/app/ConfigUtil.java @@ -97,16 +97,57 @@ public static interface Keys { */ static final String DRAFT_SUFFIX = "draft.suffix"; - /** - * Output filename for feed file, is only used when {@link #RENDER_FEED} is true - */ - static final String FEED_FILE = "feed.file"; - - /** - * Output filename for index, is only used when {@link #RENDER_INDEX} is true - */ - static final String INDEX_FILE = "index.file"; - + /** + * Output filename for feed file, is only used when {@link #RENDER_FEED} is true + */ + static final String FEED_FILE = "feed.file"; + + /** + * Flag indicating if index page shows excerpts or full bodies, + * is only used when {@link #RENDER_INDEX} is true + */ + static final String INDEX_SUMMERY = "index.summery"; + + /** + * Flag indicating if feed page shows excerpts or full bodies, + * is only used when {@link #RENDER_INDEX} is true + */ + static final String FEED_SUMMERY = "feed.summery"; + + /** + * The max length of an excerpt used as summary. + * + * By default: -1, i.e. no limit. + */ + static final String SUMMERY_MAX_LENGTH = "summary.max.length"; + + /* + * The unit used to truncate the body, either character, Unicode code point or word. + * + * By default: world + */ + static final String SUMMERY_LENGTH_UNIT = "summary.length.unit"; + + /** + * The ellipsis used with an excerpt shorter than the page/post body content. + * + * By default: ... + */ + static final String SUMMERY_ELLIPSIS = "summary.ellipsis"; + + /** + * The readmore label used with an excerpt shorter than the page/post body content. + * + * If a pattern is provided then it will be interpolated with the post/page link. + * By default: empty string + */ + static final String SUMMERY_READMORE = "summary.readmore"; + + /** + * Output filename for index, is only used when {@link #RENDER_INDEX} is true + */ + static final String INDEX_FILE = "index.file"; + /** * File extension to be used for all output files */ diff --git a/src/main/java/org/jbake/app/Content.java b/src/main/java/org/jbake/app/Content.java new file mode 100644 index 000000000..4116f543b --- /dev/null +++ b/src/main/java/org/jbake/app/Content.java @@ -0,0 +1,178 @@ +package org.jbake.app; + +import static org.jbake.app.ContentTag.*; + +import java.util.HashMap; +import java.util.Map; + +/** + * Content model used by jbake. + */ +public class Content { + + private final Map content; + + public Map getContentAsMap() { + return content; + } + + public Content() { + this.content = new HashMap(); + } + + public Content(final Map contents) { + this.content = new HashMap(contents); + } + + public void putAll(final Content content) { + this.content.putAll(content.content); + } + + public void putAll(final Map content) { + this.content.putAll(content); + } + + public void put(final ContentTag key, final Object value) { + put(key.name(), value); + } + + public void put(final String key, final Object value) { + if (value == null) { + throw new IllegalArgumentException("Content doesn't accept key with a null value, key here is: " + key); + } + + content.put(key, value); + } + + public Object get(final ContentTag key) { + return get(key.name()); + } + + public Object get(final String key) { + return content.get(key); + } + + public boolean containsKey(final ContentTag key) { + return containsKey(key.name()); + } + + public boolean containsKey(final String key) { + return content.containsKey(key); + } + + public String getString(final ContentTag key, final String defaultValue) + { + return getString(key.name(), defaultValue); + } + + public String getString(final String key, final String defaultValue) + { + Object value = content.get(key); + + if (value instanceof String) + { + return (String) value; + } + else if (value == null) + { + return defaultValue; + } + else + { + throw new IllegalArgumentException('\'' + key + "' doesn't map to a String object"); + } + } + + public int getInt(final ContentTag key, final int defaultValue) + { + return getInt(key.name(), defaultValue); + } + + public int getInt(final String key, final int defaultValue) + { + Integer i = getInteger(key, null); + + if (i == null) + { + return defaultValue; + } + + return i.intValue(); + } + + public Integer getInteger(final String key, final Integer defaultValue) + { + Object value = content.get(key); + + if (value == null) + { + return defaultValue; + } + return Integer.valueOf(value.toString()); + } + + public boolean getBoolean(final ContentTag key, final boolean defaultValue) { + return getBoolean(key.name(), defaultValue); + } + + public boolean getBoolean(final String key, final boolean defaultValue) { + Object value = content.get(key); + + if (value == null) + { + return defaultValue; + } + else + return Boolean.valueOf(key); + } + + public void setStatus(final String s) { + put(status, s); + } + + public void setStatus(final ContentStatus s) { + setStatus(s.key()); + } + + public ContentStatus getStatus() { + try { + return ContentStatus.valueOf(getString(status, null)); + } catch (IllegalArgumentException e) { + throw new IllegalStateException("A content status must have a valid value.", e); + } catch (NullPointerException e) { + throw new IllegalStateException("A content status must be provided.", e); + } + } + + public void tryToSetupStatusIfNeededWithDefaultValue(final ContentStatus defaultStatus) { + if (containsKey(status)) { + return; // source is providing a status so no need to use default + } + if (defaultStatus == null) { + return; // the status stays null: it should be catch lately + } + setStatus(defaultStatus); + return; + } + + public boolean isWithValidHeader() { + return get(type) != null && get(status) != null; + } + + @Override + public int hashCode() { + return content.hashCode(); + } + + @Override + public boolean equals(final Object obj) { + return content.equals(obj); + } + + @Override + public String toString() { + return content.toString(); + } + + +} diff --git a/src/main/java/org/jbake/app/ContentStatus.java b/src/main/java/org/jbake/app/ContentStatus.java new file mode 100644 index 000000000..95425ddfc --- /dev/null +++ b/src/main/java/org/jbake/app/ContentStatus.java @@ -0,0 +1,22 @@ +package org.jbake.app; + +public enum ContentStatus { + draft, + published, + publishedDate("published-date"); + + private final String key; + + ContentStatus() { + this.key = this.name(); + } + + ContentStatus(String key) { + this.key = key; + } + + public String key() { + return key; + } + +} diff --git a/src/main/java/org/jbake/app/ContentTag.java b/src/main/java/org/jbake/app/ContentTag.java new file mode 100644 index 000000000..0909d8cd6 --- /dev/null +++ b/src/main/java/org/jbake/app/ContentTag.java @@ -0,0 +1,47 @@ +package org.jbake.app; + +import static org.jbake.app.ConfigUtil.Keys.*; + +/** + * Set of blog model entry keys used by jbake. + * + * When a model {@link Content} entry's default value can be set up in jbake configuration + * {@link ConfigUtil} but with an other key name, then this + * latter name is passed as an argument of the model key constructor. + * + */ +public enum ContentTag { + + status, + type, + date, + tags, + body, + uri, + summary, + summaryLength(SUMMERY_MAX_LENGTH), + summaryUnit(SUMMERY_LENGTH_UNIT), + ellipsis(SUMMERY_ELLIPSIS), + readmore(SUMMERY_READMORE), + summaryForHome, + summaryForFeed, + file, + cached, + rendered, + rootpath, + sha1; + private final String key; + + ContentTag() { + this.key = this.name(); + } + + ContentTag(String key) { + this.key = key; + } + + public String key() { + return key; + } + +} diff --git a/src/main/java/org/jbake/app/Crawler.java b/src/main/java/org/jbake/app/Crawler.java index 49cfa5f2b..258e3fa90 100644 --- a/src/main/java/org/jbake/app/Crawler.java +++ b/src/main/java/org/jbake/app/Crawler.java @@ -1,11 +1,12 @@ package org.jbake.app; -import com.orientechnologies.orient.core.db.document.ODatabaseDocumentTx; +import static org.jbake.app.ContentStatus.*; +import static org.jbake.app.ContentTag.*; + import com.orientechnologies.orient.core.record.impl.ODocument; -import com.orientechnologies.orient.core.sql.query.OSQLSynchQuery; import org.apache.commons.configuration.CompositeConfiguration; -import org.jbake.app.ConfigUtil.Keys; +import org.jbake.app.Parser.PostParsingProcessor; import org.jbake.model.DocumentStatus; import org.jbake.model.DocumentTypes; import org.slf4j.Logger; @@ -17,10 +18,10 @@ import java.util.Date; import java.util.HashSet; import java.util.List; -import java.util.Map; import java.util.Set; import static java.io.File.separator; +import static java.lang.Boolean.FALSE; /** * Crawls a file system looking for content. @@ -61,10 +62,10 @@ public void crawl(File path) { String sha1 = buildHash(sourceFile); String uri = buildURI(sourceFile); boolean process = true; - DocumentStatus status = DocumentStatus.NEW; + DocumentStatus docStatus = DocumentStatus.NEW; for (String docType : DocumentTypes.getDocumentTypes()) { - status = findDocumentStatus(docType, uri, sha1); - switch (status) { + docStatus = findDocumentStatus(docType, uri, sha1); + switch (docStatus) { case UPDATED: sb.append(" : modified "); db.deleteContent(docType, uri); @@ -77,7 +78,7 @@ public void crawl(File path) { break; } } - if (DocumentStatus.NEW == status) { + if (DocumentStatus.NEW == docStatus) { sb.append(" : new "); } if (process) { // new or updated @@ -113,35 +114,44 @@ private String buildURI(final File sourceFile) { } private void crawlSourceFile(final File sourceFile, final String sha1, final String uri) { - Map fileContents = parser.processFile(sourceFile); - if (fileContents != null) { - fileContents.put("rootpath", getPathToRoot(sourceFile)); - fileContents.put("sha1", sha1); - fileContents.put("rendered", false); - if (fileContents.get("tags") != null) { - // store them as a String[] - String[] tags = (String[]) fileContents.get("tags"); - fileContents.put("tags", tags); - } - fileContents.put("file", sourceFile.getPath()); - fileContents.put("uri", uri.substring(0, uri.lastIndexOf(".")) + FileUtil.findExtension(config, fileContents.get("type").toString())); - - String documentType = (String) fileContents.get("type"); - if (fileContents.get("status").equals("published-date")) { - if (fileContents.get("date") != null && (fileContents.get("date") instanceof Date)) { - if (new Date().after((Date) fileContents.get("date"))) { - fileContents.put("status", "published"); - } - } - } - ODocument doc = new ODocument(documentType); - doc.fields(fileContents); - boolean cached = fileContents.get("cached") != null ? Boolean.valueOf((String)fileContents.get("cached")):true; - doc.field("cached", cached); - doc.save(); - } else { + Content fileContents = parser.process(sourceFile, getPostParsingProcessor(sourceFile, sha1, uri)); + if (fileContents == null) { LOGGER.warn("{} has an invalid header, it has been ignored!", sourceFile); + return; } + String documentType = fileContents.getString(type, null); + ODocument doc = new ODocument(documentType); + doc.fields(fileContents.getContentAsMap()); + doc.field("cached", fileContents.getBoolean(cached, true)); + doc.save(); + } + + public PostParsingProcessor getPostParsingProcessor(final File file, final String sourceSha1, final String sourceUri) { + return new PostParsingProcessor() { + @Override + public void doProcess(final Content contents) { + final File sourceFile = file; + contents.put(rootpath, getPathToRoot(sourceFile)); + contents.put(sha1, sourceSha1); + contents.put(rendered, false); + if (contents.get(tags) != null) { + // store them as a String[] + String[] contentTags = (String[]) contents.get(tags); + contents.put(tags, contentTags); + } + contents.put(ContentTag.file, sourceFile.getPath()); + contents.put(uri, sourceUri.substring(0, sourceUri.lastIndexOf(".")) + + FileUtil.findExtension(config, contents.getString(type, null))); + + if (contents.getStatus().equals(publishedDate) + && contents.get(date) != null + && (contents.get(date) instanceof Date) + && new Date().after((Date) contents.get(date))) { + contents.setStatus(published); + } + + } + }; } public String getPathToRoot(File sourceFile) { @@ -173,12 +183,12 @@ public Set getTags() { return result; } - private DocumentStatus findDocumentStatus(String docType, String uri, String sha1) { + private DocumentStatus findDocumentStatus(String docType, String uri, String souceSha1) { List match = db.getDocumentStatus(docType, uri); if (!match.isEmpty()) { ODocument entries = match.get(0); - String oldHash = entries.field("sha1"); - if (!(oldHash.equals(sha1)) || Boolean.FALSE.equals(entries.field("rendered"))) { + String oldHash = entries.field(sha1.name()); + if (!(oldHash.equals(souceSha1)) || FALSE.equals(entries.field("rendered"))) { return DocumentStatus.UPDATED; } else { return DocumentStatus.IDENTICAL; diff --git a/src/main/java/org/jbake/app/Parser.java b/src/main/java/org/jbake/app/Parser.java index 981bd2e6e..4757184cc 100644 --- a/src/main/java/org/jbake/app/Parser.java +++ b/src/main/java/org/jbake/app/Parser.java @@ -1,14 +1,9 @@ package org.jbake.app; -import org.apache.commons.configuration.CompositeConfiguration; -import org.apache.commons.io.IOUtils; -import org.jbake.app.ConfigUtil.Keys; -import org.jbake.parser.Engines; -import org.jbake.parser.MarkupEngine; -import org.jbake.parser.ParserContext; -import org.json.simple.JSONValue; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import static com.google.common.base.Preconditions.*; +import static java.lang.String.format; +import static org.jbake.app.ContentTag.*; +import static org.jbake.app.excerpt.Truncator.*; import java.io.File; import java.io.FileInputStream; @@ -17,11 +12,27 @@ import java.text.DateFormat; import java.text.ParseException; import java.text.SimpleDateFormat; +import java.util.AbstractMap; import java.util.ArrayList; +import java.util.Arrays; import java.util.Date; -import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Map.Entry; + +import org.apache.commons.configuration.CompositeConfiguration; +import org.apache.commons.io.IOUtils; +import org.apache.commons.lang.StringEscapeUtils; +import org.jbake.app.ConfigUtil.Keys; +import org.jbake.app.excerpt.TruncateContentHandler.Unit; +import org.jbake.app.excerpt.Truncator; +import org.jbake.app.excerpt.TruncatorException; +import org.jbake.parser.Engines; +import org.jbake.parser.MarkupEngine; +import org.jbake.parser.ParserContext; +import org.json.simple.JSONValue; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * Parses a File for content. @@ -29,72 +40,134 @@ * @author Jonathan Bullock */ public class Parser { - private final static Logger LOGGER = LoggerFactory.getLogger(Parser.class); + + private enum Readmore { with, without} ; + + public static final String END_OF_HEADER = createStringFilledWith('~', 6); + public static final String EOL = "\n"; + public static final String CONTINUED_LINE_STARTER = createStringFilledWith(' ', 2); + + private final static Logger LOGGER = LoggerFactory.getLogger(Parser.class); private CompositeConfiguration config; private String contentPath; + public static class PostParsingProcessor { + + private final Content content; + + public PostParsingProcessor() { + this.content = new Content(); + } + + /** + * Do the job. + */ + public Content process(final Content content) { + checkArgument(content != null, "The parser post parsing processor requires a content output."); + this.content.putAll(content); + doProcess(content); + return content; + } + + /** + * Define the job. + * + * This method is available to be overridden with access to the input (source) + * and the output (content) of the parsing. + * + * Both arguments are always provided with non null value. + */ + public void doProcess(final Content content) {} + + } + /** * Creates a new instance of Parser. */ - public Parser(CompositeConfiguration config, String contentPath) { + public Parser(final CompositeConfiguration config, final String contentPath) { this.config = config; this.contentPath = contentPath; } /** - * Process the file by parsing the contents. + * Parse a file to extract and format its contents. + * + * Kept to prevent API break. * * @param file * @return The contents of the file + * + * @deprecated see {@link #processSource(File)} */ - public Map processFile(File file) { - Map content = new HashMap(); - InputStream is = null; - List fileContents = null; - try { - is = new FileInputStream(file); - fileContents = IOUtils.readLines(is, config.getString(Keys.RENDER_ENCODING)); - } catch (IOException e) { - LOGGER.error("Error while opening file {}: {}", file, e); + @Deprecated + public Map processFile(final File file) { + Content content = processSource(file); + return content == null ? null : content.getContentAsMap(); + } - return null; - } finally { - IOUtils.closeQuietly(is); - } + + public Content processSource(final File file) { + Exception e; + try { + return process(file, new PostParsingProcessor()); + } catch(IllegalStateException ise) { + e = ise; + } catch(IllegalArgumentException iae) { + e = iae; + } + LOGGER.error(e.getMessage(), e); + return null; + } - boolean hasHeader = hasHeader(fileContents); - ParserContext context = new ParserContext( + /** + * Process the file embedded in the PostParsingProcessor instance provided. + * + * End the job by running the post parsing processor itself. + * By default this processor does nothing. + * + * @param post parsing processor + * @return The contents of the file provided + */ + public Content process(final File file, final PostParsingProcessor postParsingProcessor) { + return doProcess(file, postParsingProcessor); + } + + private Content doProcess(final File file, final PostParsingProcessor postParsingProcessor) { + checkState(postParsingProcessor != null, "The parser requires a post parsing processor."); + + final List fileContents = retrieveContentFromFile(file); + if (fileContents == null) { + return null; + } + + final Content content = new Content(); + + final List header = getHeaderFrom(fileContents); + final ParserContext context = new ParserContext( file, fileContents, config, contentPath, - hasHeader, + header != null, content ); - MarkupEngine engine = Engines.get(FileUtil.fileExt(file)); + final MarkupEngine engine = Engines.get(FileUtil.fileExt(file)); if (engine==null) { LOGGER.warn("Unable to find suitable markup engine for {}",file); return null; } - if (hasHeader) { - // read header from file - processHeader(fileContents, content); - } + // read header from file + processHeader(header, content); + // then read engine specific headers engine.processHeader(context); - if (config.getString(Keys.DEFAULT_STATUS) != null) { - // default status has been set - if (content.get("status") == null) { - // file hasn't got status so use default - content.put("status", config.getString(Keys.DEFAULT_STATUS)); - } - } - - if (content.get("type")==null||content.get("status")==null) { + content.tryToSetupStatusIfNeededWithDefaultValue(getDefaultStatus()); + + if (! content.isWithValidHeader()) { // output error LOGGER.warn("Error parsing meta data from header (missing type or status value) for file {}!", file); return null; @@ -103,138 +176,275 @@ public Map processFile(File file) { // generate default body processBody(fileContents, content); - // eventually process body using specific engine - if (engine.validate(context)) { - engine.processBody(context); - } else { + if (! engine.validate(context)) { LOGGER.warn("Incomplete source file ({}) for markup engine:", file, engine.getClass().getSimpleName()); return null; } + // eventually process body using specific engine + engine.processBody(context); - if (content.get("tags") != null) { - String[] tags = (String[]) content.get("tags"); - for( int i=0; i retrieveContentFromFile(final File file) { + InputStream is = null; + try { + is = new FileInputStream(file); + return IOUtils.readLines(is, config.getString(Keys.RENDER_ENCODING)); + } catch (IOException e) { + LOGGER.error("Error while opening file {}: {}", file, e); + return null; + } finally { + IOUtils.closeQuietly(is); + } + } + + private ContentStatus getDefaultStatus() { + String s = config.getString(Keys.DEFAULT_STATUS, null); + if (s == null) { + return null; + } + try { + return ContentStatus.valueOf(s); + } catch (IllegalArgumentException e) { + throw new IllegalStateException("A content status must have a valid value.", e); + } + } + + private void createSummaryForHomePage(final ParserContext context) { + final Content content = context.getContent(); + String summary; + if (!config.getBoolean(Keys.INDEX_SUMMERY, false)) { + summary = content.getString(body, null); + } else { + try { + summary = getSummary(context, Readmore.with); + } catch (TruncatorException e) { + LOGGER.error("Unable to provide a summary or excerpt for home page."); + summary = content.getString(body, null); } - content.put("tags", tags); } + content.put(summaryForHome, summary); + } + + private void createSummaryForFeedPage(final ParserContext context) { + final Content content = context.getContent(); + String summary; + if (!config.getBoolean(Keys.FEED_SUMMERY, false)) { + summary = content.getString(body, null); + } else { + try { + summary = getSummary(context, Readmore.without); + } catch (Exception e) { + LOGGER.error("Unable to provide a summary or excerpt for feed page."); + summary = content.getString(body, null); + } + } + summary = StringEscapeUtils.escapeXml(summary); + content.put(summaryForFeed, summary); + } + + private String getSummary(final ParserContext context, final Readmore with) throws TruncatorException + { + final Content content = context.getContent(); + + final String contentSummary = content.getString(summary, "").trim(); + // TODO check if the URI can be null outside unit tests + final String contentUri = content.getString(uri, "???"); + final String readmorePattern = content.getString(readmore, config.getString(readmore.key(), DEFAULT_READ_MORE)); + final String contentReadmore = with == Readmore.with ? format(readmorePattern, contentUri) : ""; + if (!contentSummary.isEmpty()) { + // if a summary is provided in the header, it is never truncated + return contentSummary + contentReadmore; + } + + final int excerptMaxLength = content.getInt(summaryLength, config.getInt(summaryLength.key(), NO_LIMIT)); - // TODO: post parsing plugins to hook in here? - - return content; + final String contentbody = content.getString(body, null); + final String contentEllipsis = content.getString(ellipsis, config.getString(ellipsis.key(), DEFAULT_ELLIPSIS)); + + return new Truncator(getDefaultExcerptCounterUnit(config), excerptMaxLength) + .readmore(contentReadmore) + .ellipsis(contentEllipsis) + .source(contentbody) + .run(); + } + + private Unit getDefaultExcerptCounterUnit(CompositeConfiguration config) { + Unit defaultUnit = Unit.word; + if (! config.containsKey(summaryUnit.key())) { + return defaultUnit; + } + String configUnit = config.getString(summaryUnit.key()); + try { + return Unit.valueOf(configUnit); + } catch (IllegalArgumentException e) { + LOGGER.warn("Truncator default unit, wrong value in jbake configuration: {}", configUnit); + } + return defaultUnit; + } + + private void sanitizeTagsInside(final Content content) { + String[] tagValues = (String[]) content.get(tags); + if (tagValues == null) { + return; + } + + for( int i=0; i contents) { - boolean headerValid = false; + private List getHeaderFrom(final List contents) { boolean headerSeparatorFound = false; boolean statusFound = false; boolean typeFound = false; List header = new ArrayList(); - + + StringBuilder buffer = new StringBuilder(); for (String line : contents) { - header.add(line); - if (line.contains("=")) { - if (line.startsWith("type=")) { - typeFound = true; - } - if (line.startsWith("status=")) { - statusFound = true; - } - } - if (line.equals("~~~~~~")) { - headerSeparatorFound = true; - header.remove(line); - break; - } - } - - if (headerSeparatorFound) { - headerValid = true; - for (String headerLine : header) { - if (!headerLine.contains("=")) { - headerValid = false; - break; - } - } - } - - if (!headerValid || !statusFound || !typeFound) { - return false; + if (line.trim().isEmpty()) { + continue; + } + boolean newEntry = ! isContinuedEntry(line); + if (buffer.length() > 0 && newEntry) { + String e = buffer.toString(); + header.add(e); + + if (! isValidEntry(e)) { + return null; + } + statusFound |= isStatusEntry(e); + typeFound |= isTypeEntry(e); + + buffer.setLength(0); + } + if (line.equals(END_OF_HEADER)) { + headerSeparatorFound = true; + break; + } + String e = newEntry ? line : line.substring(CONTINUED_LINE_STARTER.length()); + buffer.append(e); } - return true; + + return headerSeparatorFound && statusFound && typeFound ? header : null; } /** * Process the header of the file. + * + * All the header entries are valid ones, i.e. with the syntax "key = value", without leading spaces * - * @param contents Contents of file + * @param header valid header of the file * @param content */ - private void processHeader(List contents, final Map content) { - for (String line : contents) { - if (line.equals("~~~~~~")) { + private void processHeader(List header, final Content content) { + if (header == null) { + return; + } + for (String line : header) { + if (line.equals(END_OF_HEADER)) { break; - } else { - String[] parts = line.split("=",2); - if (parts.length == 2) { - if (parts[0].equalsIgnoreCase("date")) { - DateFormat df = new SimpleDateFormat(config.getString(Keys.DATE_FORMAT)); - Date date = null; - try { - date = df.parse(parts[1]); - content.put(parts[0], date); - } catch (ParseException e) { - e.printStackTrace(); - } - } else if (parts[0].equalsIgnoreCase("tags")) { - String[] tags = parts[1].split(","); - content.put(parts[0], tags); - } else if (parts[1].startsWith("{") && parts[1].endsWith("}")) { - // Json type - content.put(parts[0], JSONValue.parse(parts[1])); - } else { - content.put(parts[0], parts[1]); - } - } - } + } + + Entry entry = getHeaderEntryFrom(line); + String key = entry.getKey(); + String value = entry.getValue(); + if (key.equalsIgnoreCase(date.name())) { + DateFormat df = new SimpleDateFormat(config.getString(Keys.DATE_FORMAT)); + Date date = null; + try { + date = df.parse(value); + content.put(key, date); + } catch (ParseException e) { + e.printStackTrace(); + } + } else if (key.equalsIgnoreCase(tags.name())) { + String[] tags = value.split(","); + content.put(key, tags); + } else if (value.startsWith("{") && value.endsWith("}")) { + // Json type + content.put(key, JSONValue.parse(value)); + } else { + content.put(key, value); + } + } } + private static Entry getHeaderEntryFrom(final String line) { + String[] parts = line.split("\\s*=\\s*", 2); + return new AbstractMap.SimpleEntry(parts[0], parts[1]); + } + /** * Process the body of the file. * * @param contents Contents of file * @param content */ - private void processBody(List contents, final Map content) { - StringBuilder body = new StringBuilder(); + private void processBody(List contents, final Content content) { + StringBuilder contentBody = new StringBuilder(); boolean inBody = false; for (String line : contents) { if (inBody) { - body.append(line).append("\n"); + contentBody.append(line).append(EOL); } - if (line.equals("~~~~~~")) { + if (line.equals(END_OF_HEADER)) { inBody = true; } } - if (body.length() == 0) { + if (contentBody.length() == 0) { for (String line : contents) { - body.append(line).append("\n"); + contentBody.append(line).append(EOL); } } - content.put("body", body.toString()); + content.put(body, contentBody.toString()); + } + + private static String createStringFilledWith(char c, int length) { + final char[] array = new char[length]; + Arrays.fill(array, c); + return new String(array); + } + + // a la java properties file + private static boolean isContinuedEntry(final String e) { + return e.matches("^" + CONTINUED_LINE_STARTER + ".*"); + } + + private static boolean isStatusEntry(final String e) { + return e.matches("^" + status + "\\s*=.*"); + } + + private static boolean isTypeEntry(final String e) { + return e.matches("^" + type + "\\s*=.*"); + } + + private static boolean isValidEntry(final String e) { + return e.contains("="); } } diff --git a/src/main/java/org/jbake/app/excerpt/CharacterEntityReference.java b/src/main/java/org/jbake/app/excerpt/CharacterEntityReference.java new file mode 100644 index 000000000..223fb5bcc --- /dev/null +++ b/src/main/java/org/jbake/app/excerpt/CharacterEntityReference.java @@ -0,0 +1,26 @@ +package org.jbake.app.excerpt; + +public enum CharacterEntityReference { + + lt('<'), gt('>'), amp('&'), quot('"', true); + private final char character; + private final boolean conditional; + + private CharacterEntityReference(final char ref) { + this(ref, false); + } + + private CharacterEntityReference(final char ref, final boolean conditional) { + this.character = ref; + this.conditional = conditional; + } + + public static CharacterEntityReference findByWithCondition(char ref, boolean condition) { + for (CharacterEntityReference r : CharacterEntityReference.values()) { + if (r.character == ref) + return (!r.conditional || condition) ? r : null; + } + return null; + } + +} diff --git a/src/main/java/org/jbake/app/excerpt/ToXmlContentHandler.java b/src/main/java/org/jbake/app/excerpt/ToXmlContentHandler.java new file mode 100644 index 000000000..344ea58ca --- /dev/null +++ b/src/main/java/org/jbake/app/excerpt/ToXmlContentHandler.java @@ -0,0 +1,271 @@ +package org.jbake.app.excerpt; + +import static java.lang.String.format; + +import java.io.IOException; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.UnsupportedEncodingException; +import java.io.Writer; +import java.nio.charset.Charset; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Map.Entry; + +import org.apache.tika.sax.ToTextContentHandler; +import org.xml.sax.Attributes; +import org.xml.sax.SAXException; + + +/** + * SAX event handler that serializes the XML document to a character stream. + * + * The incoming SAX events are expected to be well-formed (properly nested, + * etc.) and to explicitly include namespace declaration and corresponding + * namespace prefixes in element and attribute names. This class is based on + * {@link org.apache.tika.sax.ToXMLContentHandler} This implementation doesn't + * rely on {@link org.apache.tika.sax.ToTextContentHandler#characters} to write + * in the buffer. So {@link TruncateContentHandler} can be redefined with other + * counting modes (without spaces, by word,...). + */ +public class ToXmlContentHandler extends ToTextContentHandler { + + private final static String XML_ENCODING_PATTERN = "\n"; + private final static String ATTRIBUTE_PATTERN = " %s=\"%s\""; + private final static String CHARS_ENTITY_PATTERN = "%s&%s;"; + + private static class ElementInfo { + + private final ElementInfo parent; + + private final Map namespaces; + + private final String qname; + + public ElementInfo(final ElementInfo parent, final Map namespaces, final String uri, final String localName) + throws SAXException { + this.parent = parent; + this.namespaces = namespaces.isEmpty() ? Collections.emptyMap() + : new HashMap(namespaces); + this.qname = getQName(uri, localName); + } + + public String getPrefix(final String uri) + throws SAXException { + final String prefix = namespaces.get(uri); + String result; + if (prefix != null) { + result = prefix; + } else if (parent != null) { + result = parent.getPrefix(uri); + } else if (uri == null || uri.length() == 0) { + result = ""; + } else { + throw new SAXException(format("Namespace %s not declared", uri)); + } + return result; + } + + public String getQName(final String uri, final String localName) + throws SAXException { + final String prefix = getPrefix(uri); + String result; + if (prefix.length() > 0) { + result = prefix + ":" + localName; + } else { + result = localName; + } + return result; + } + + public String getQName() { + return qname; + } + + } + + private final Writer writer; + private final Charset encoding; + protected final Map namespaces = new HashMap(); + + protected boolean inStartElement = false; + public ElementInfo currentElement = null; + + public ToXmlContentHandler(Writer writer) { + super(writer); + this.writer = writer; + this.encoding = null; + } + + public ToXmlContentHandler(final OutputStream stream, final Charset encoding) + throws UnsupportedEncodingException { + this(new OutputStreamWriter(stream, encoding), encoding); + } + + private ToXmlContentHandler(final Writer writer, final Charset encoding) { + super(writer); + this.writer = writer; + this.encoding = encoding; + } + + protected String getCurrentElementName() { + return currentElement.getQName(); + } + + private void setCurrentElement(final ElementInfo info) { + currentElement = info; + } + + private ElementInfo getCurrentElement() { + return currentElement; + } + + @Override + public void startDocument() + throws SAXException { + if (encoding != null) { + write(format(XML_ENCODING_PATTERN, encoding)); + } + + setCurrentElement(null); + namespaces.clear(); + } + + @Override + public void startPrefixMapping(final String prefix, final String uri) + throws SAXException { + try { + if (getCurrentElement() != null && prefix.equals(getCurrentElement().getPrefix(uri))) { + return; + } + } catch (SAXException ignore) { + } + namespaces.put(uri, prefix); + } + + @Override + public void startElement(final String uri, final String localName, final String qName, final Attributes atts) + throws SAXException { + lazyCloseStartElement(); + + setCurrentElement(new ElementInfo(getCurrentElement(), namespaces, uri, localName)); + + write('<'); + write(getCurrentElement().getQName()); + + for (int i = 0; i < atts.getLength(); i++) { + final String name = getCurrentElement().getQName(atts.getURI(i), atts.getLocalName(i)); + writeAttribute(name, atts.getValue(i)); + } + + for (Entry entry : namespaces.entrySet()) { + final String prefix = entry.getValue(); + final String name = "xmlns" + (prefix.isEmpty() ? "" : (":" + prefix)); + writeAttribute(name, entry.getKey()); + } + namespaces.clear(); + + inStartElement = true; + } + + private void writeAttribute(final String name, final String value) + throws SAXException { + final char[] ch = value.toCharArray(); + final StringBuilder sb = editEscaped(ch, 0, ch.length, true); + write(format(ATTRIBUTE_PATTERN, name, sb)); + } + + @Override + public void endElement(final String uri, final String localName, final String qName) + throws SAXException { + if (! lazyCloseStartElement()) { + write("'); + } + + namespaces.clear(); + + // Reset the position in the tree, to avoid endless stack overflow + // chains (see TIKA-1070) + setCurrentElement(getCurrentElement().parent); + } + + @Override + public void characters(final char[] ch, final int start, final int length) + throws SAXException { + writeWithLazyClosing(ch, start, length); + } + + @Override + public void ignorableWhitespace(final char[] ch, final int start, final int length) + throws SAXException { + writeWithLazyClosing(ch, start, length); + } + + private boolean lazyCloseStartElement() + throws SAXException { + if (inStartElement) { + write('>'); + inStartElement = false; + } + return inStartElement; + } + + private void writeWithLazyClosing(final char[] ch, final int start, final int length) + throws SAXException { + lazyCloseStartElement(); + writeEscaped(ch, start, start + length, false); + } + + protected void write(final char[] ch, final int start, final int length) + throws SAXException { + try { + writer.write(ch, start, length); + } catch (IOException e) { + throw new SAXException(format("Error writing: %s", new String(ch, start, length)), e); + } + } + + protected void write(final char ch) + throws SAXException { + write(new char[] {ch}, 0, 1); + } + + protected void write(final CharSequence string) + throws SAXException { + write(string.toString().toCharArray(), 0, string.length()); + } + + private String editCharsAndEntity(final char[] ch, final int from, final int to, final String entity) { + return format(CHARS_ENTITY_PATTERN, new String(Arrays.copyOfRange(ch, from, to)), entity); + } + + private StringBuilder editEscaped(final char[] ch, final int from, final int to, final boolean attribute) { + final StringBuilder buffer = new StringBuilder(); + int pos = from; + int f = from; + while (pos < to) { + + final CharacterEntityReference ref = CharacterEntityReference.findByWithCondition(ch[pos], attribute); + if (ref != null) { + buffer.append(editCharsAndEntity(ch, f, pos, ref.name())); + pos++; + f = pos; + } else { + pos++; + } + + } + buffer.append(Arrays.copyOfRange(ch, from, to)); + return buffer; + } + + private void writeEscaped(final char[] ch, final int from, final int to, final boolean attribute) + throws SAXException { + write(editEscaped(ch, from, to, attribute)); + } + +} diff --git a/src/main/java/org/jbake/app/excerpt/TruncateContentHandler.java b/src/main/java/org/jbake/app/excerpt/TruncateContentHandler.java new file mode 100644 index 000000000..d5fbf33ce --- /dev/null +++ b/src/main/java/org/jbake/app/excerpt/TruncateContentHandler.java @@ -0,0 +1,393 @@ +package org.jbake.app.excerpt; + +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.Serializable; +import java.io.StringWriter; +import java.io.Writer; +import java.nio.charset.Charset; +import java.util.Arrays; +import java.util.UUID; +import org.apache.tika.sax.ContentHandlerDecorator; +import org.apache.tika.sax.ToTextContentHandler; +import org.xml.sax.ContentHandler; +import org.xml.sax.SAXException; + +/** + * SAX event handler that writes content up to an optional limit in a character stream or other decorated handler. + * + * This class is based on {@link org.apache.tika.sax.WriteOutContentHandler} with some improvements. + * The counter is now accessible by any derived class. It's also possible to chose: + *
  • the type of item to count: characters, Unicode code points or words
  • + *
  • when counting characters or code points, whether or not spaces has to be counted
  • + *
  • when counting characters or code points, whether or not the last word can be splitted
  • + * + * Required {@link popsuite.blog.util.ToXmlContentHandler}: + * it doesn't work with the original {@link org.apache.tika.sax.ToXMLContentHandler}. + */ +public class TruncateContentHandler extends ContentHandlerDecorator { + + public enum Unit { word, codePoint, character }; + + public final static int NO_LIMIT = -1; + private final static int DEFAULT_LIMIT = 100 * 1000; + private final static Unit DEFAULT_UNIT = Unit.character; + + private final static String EMPTY_STRING = ""; + /* SPACE_PATTERN_BASE must be defined in accordance with the method {@link #isSpace} for Unicode Code Point */ + public final static String SPACE_PATTERN_BASE = "\\p{javaWhitespace}\\p{Z}"; + public final static String SPACE_PATTERN = "[" + SPACE_PATTERN_BASE + "]"; + private final static String SPACES_PATTERN = SPACE_PATTERN + "+"; + private final static String NO_SPACE_PATTERN = "[^" + SPACE_PATTERN_BASE + "]"; + private final static String TRUNCATED_LAST_WORD = "(?s)" + SPACES_PATTERN + NO_SPACE_PATTERN + "*$"; + private final static String WITH_DELIMITER_SPLITTER_PATTERN = "(?=(?!^)%1$s)(? + * The internal string buffer is bounded at the given number of characters. + * If this write limit is reached, then a {@link SAXException} is thrown. + * The {@link #isWriteLimitReached(Throwable)} method can be used to detect + * this case. + * + * @param writeLimit + * maximum number of characters to include in the string, or -1 + * to disable the write limit + */ + public TruncateContentHandler(final int writeLimit) { + this(new StringWriter(), writeLimit); + } + + /** + * Creates a content handler that writes character events to an internal + * string buffer. Use the {@link #toString()} method to access the collected + * character content. + *

    + * The internal string buffer is bounded at 100k characters. If this write + * limit is reached, then a {@link SAXException} is thrown. The + * {@link #isWriteLimitReached(Throwable)} method can be used to detect this + * case. + */ + public TruncateContentHandler() { + this(DEFAULT_LIMIT); + } + + protected int getWriteLimit() { + return writeLimit; + } + + protected boolean isWriteLimitReached() { + return writeLimit != NO_LIMIT && writeLimit < writeCount; + } + + public boolean isCountingWithSpaces() { + return withSpaces; + } + + public void setCountingWithSpaces(final boolean withSpaces) { + this.withSpaces = withSpaces; + } + + public boolean isCountingWithAllSpaces() { + return withAllSpaces; + } + + public void setCountingWithAllSpaces(final boolean withSpaces) { + this.withAllSpaces = withSpaces; + } + + public boolean isWithSmartTruncation() { + return withSmartTruncation; + } + + public void setWithSmartTruncation(final boolean smart) { + this.withSmartTruncation = smart; + } + + public Unit getUnit() { + return unit; + } + + public void setUnit(Unit unit) { + this.unit = unit; + } + + /** + * Writes the given characters to the given character stream. + */ + @Override + public void characters(final char[] ch, final int start, final int length) + throws SAXException { + if (unit == Unit.character) { + countingByCharacter(ch, start, length); + } else if (unit == Unit.codePoint) { + countingByCodePoint(ch, start, length); + } else { + countingByWord(ch, start, length); + } + } + + private void countingByCharacter(final char[] ch, final int start, final int length) + throws SAXException { + if (writeLimit == NO_LIMIT) { + super.characters(ch, start, length); + final String origine = new String(Arrays.copyOfRange(ch, start, start + length)); + final String stripped = isCountingWithSpaces() ? origine + : origine.replaceAll(SPACES_PATTERN, EMPTY_STRING); + writeCount += stripped.length(); + return; + } + + boolean reached = false; + int next = start; + for (int i = start; !reached && i < start + length; i++) { + next = i + 1; + if (isCountingWithSpaces() || ! isSpace(ch[i])) { + writeCount++; + if (writeCount >= writeLimit) reached = true; + } + } + final boolean reachedBeforeEnd = reached && (next < start + length); + if (reachedBeforeEnd && isWithSmartTruncation() + && ((!isSpace(ch[next - 1]) && !isSpace(ch[next])) || isSpace(ch[next - 1]))) { + final int smartLentgh = new String( + Arrays.copyOfRange(ch, start, next)).replaceFirst(TRUNCATED_LAST_WORD, + EMPTY_STRING).length(); + next = start + smartLentgh; + } + super.characters(ch, start, next - start); + if (reachedBeforeEnd) + throw new WriteLimitReachedException("Your document contained more than " + writeLimit + + " Unicode unit points (characters), and so your requested limit has been" + + " reached. To receive the full text of the document," + + " increase your limit. (Text up to the limit is" + " however available).", tag); + + } + + private void countingByCodePoint(final char[] ch, final int start, final int length) throws SAXException { + final String origine = new String(Arrays.copyOfRange(ch, start, start + length)); + if (writeLimit == NO_LIMIT || length == 0) { + super.characters(ch, start, length); + final String stripped = isCountingWithSpaces() ? origine + : origine.replaceAll(SPACES_PATTERN, EMPTY_STRING); + writeCount += stripped.codePointCount(0, stripped.length()); + return; + } + + boolean reached = false; + int next = 0; + int current = 0; + for (int i = 0; ! reached && i < origine.codePointCount(0, origine.length()); i++) { + current = origine.offsetByCodePoints(0, i); + final int currentCodePoint = origine.codePointAt(current); + next = current + Character.charCount(currentCodePoint); + if (isCountingWithSpaces() || ! isSpace(currentCodePoint)) { + writeCount++; + if (writeCount >= writeLimit) reached = true; + } + } + final boolean reachedBeforeEnd = reached && (next < length); + if (reachedBeforeEnd + && isWithSmartTruncation() + && ((!isSpace(origine.codePointAt(current)) && !isSpace(origine.codePointAt(next))) + || isSpace(origine.codePointAt(current)))) { + final int smartLentgh = new String( + Arrays.copyOfRange(ch, start, start + next)).replaceFirst(TRUNCATED_LAST_WORD, + EMPTY_STRING).length(); + next = smartLentgh; + } + super.characters(ch, start, next); + if (reachedBeforeEnd) + throw new WriteLimitReachedException("Your document contained more than " + writeLimit + + " Unicode code points (characters), and so your requested limit has been" + + " reached. To receive the full text of the document," + + " increase your limit. (Text up to the limit is" + " however available).", tag); + } + + private void countingByWord(final char[] ch, final int start, final int length) + throws SAXException { + final String origine = new String(Arrays.copyOfRange(ch, start, start + length)); + if (writeLimit == NO_LIMIT || length == 0) { + super.characters(ch, start, length); + final String[] splitted = origine.split(SPACES_PATTERN); + if (splitted.length > 0) { + writeCount += splitted.length + (splitted[0].isEmpty() ? -1 : 0); + } + return; + } + + final String[] splitted = origine.split(String.format(WITH_DELIMITER_SPLITTER_PATTERN, SPACE_PATTERN)); + final StringBuilder result = new StringBuilder(); + boolean reachedBeforeEnd = false; + for (int i = 0; ! reachedBeforeEnd && i < splitted.length; i++) { + final String token = splitted[i]; + final int count = writeCount + (isSpace(token.codePointAt(0)) ? 0 : 1); + if (count > writeLimit) { + reachedBeforeEnd = true; + } else { + writeCount = count; + result.append(token); + } + } + + final int size = !reachedBeforeEnd ? result.length() + : result.toString().replaceFirst(SPACES_TAIL, EMPTY_STRING).length(); + super.characters(ch, start, size); + if (reachedBeforeEnd) + throw new WriteLimitReachedException("Your document contained more than " + writeLimit + + " words, and so your requested limit has been" + + " reached. To receive the full text of the document," + + " increase your limit. (Text up to the limit is" + " however available).", tag); + } + + @Override + public void ignorableWhitespace(final char[] ch, final int start, final int length) + throws SAXException { + final boolean counting = isCountingWithSpaces() && isCountingWithAllSpaces() && (getUnit() != Unit.word); + int writeCountInc = counting ? length : 0; + int next = length; + if (writeLimit == NO_LIMIT || !counting) { + super.ignorableWhitespace(ch, start, next); + writeCount += writeCountInc; + return; + } + final boolean reachedBeforeEnd = writeCount + writeCountInc > writeLimit; + if (reachedBeforeEnd) { + writeCountInc = writeLimit - writeCount; + next = writeCountInc; + } + writeCount += writeCountInc; + super.ignorableWhitespace(ch, start, next); + if (reachedBeforeEnd) + throw new WriteLimitReachedException("Your document contained more than " + writeLimit + + unit.name() + "s, and so your requested limit has been" + + " reached. To receive the full text of the document," + + " increase your limit. (Text up to the limit is" + " however available).", tag); + + } + + /** + * Checks whether the given exception (or any of it's root causes) was + * thrown by this handler as a signal of reaching the write limit. + * + * @param t + * throwable + * @return true if the write limit was reached, + * false otherwise + */ + public boolean isWriteLimitReached(final Throwable t) { + if (t instanceof WriteLimitReachedException) { + return tag.equals(((WriteLimitReachedException) t).tag); + } else { + return (t.getCause() != null) && isWriteLimitReached(t.getCause()); + } + } + + /** + * The exception used as a signal when the write limit has been reached. + */ + private static class WriteLimitReachedException extends SAXException { + + private static final long serialVersionUID = 1L; + + /** Serializable tag of the handler that caused this exception */ + final Serializable tag; + + WriteLimitReachedException(final String message, final Serializable tag) { + super(message); + this.tag = tag; + } + + } + + /* + * is equivalent to the regex [\\p{javaWhitespace}\\p{Z}] defined by SPACE_PATTERN_BASE + * + * \p{Z} will catch the no break spaces, i.e.: NO-BREAK SPACE (U+00A0), + * NARROW NO-BREAK SPACE (U+202F), ... + */ + private static boolean isSpace(final char c) { + return Character.isWhitespace(c) || Character.isSpaceChar(c); + } + + private static boolean isSpace(final int c) { + return Character.isWhitespace(c) || Character.isSpaceChar(c); + } + +} diff --git a/src/main/java/org/jbake/app/excerpt/Truncator.java b/src/main/java/org/jbake/app/excerpt/Truncator.java new file mode 100644 index 000000000..114197525 --- /dev/null +++ b/src/main/java/org/jbake/app/excerpt/Truncator.java @@ -0,0 +1,344 @@ +package org.jbake.app.excerpt; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkState; +import static java.lang.String.format; +import static org.jbake.app.excerpt.TruncateContentHandler.SPACE_PATTERN; +import static org.jbake.app.excerpt.TruncateContentHandler.SPACE_PATTERN_BASE; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.UnsupportedEncodingException; +import java.nio.charset.Charset; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.apache.sis.internal.jdk7.StandardCharsets; //java.nio.charset.StandardCharsets only since JDK 1.7 +import org.apache.tika.exception.TikaException; +import org.apache.tika.metadata.Metadata; +import org.apache.tika.parser.AutoDetectParser; +import org.apache.tika.parser.ParseContext; +import org.jbake.app.excerpt.TruncateContentHandler.Unit; +import org.xml.sax.SAXException; + +/** + * Create an excerpt of a HTML fragment. + * + * Wrap Tika HTML parser (based on Tagsoup) with TruncateContentHandler and + * ToXmlContentHandler. + * + * The generated excerpt is ready to be inserted as valid HTML in any document. + */ +public class Truncator { + + private enum TruncationStatus { + unknown, truncated, unchanged + } + + public final static int NO_LIMIT = TruncateContentHandler.NO_LIMIT; + public final static Unit DEFAULT_UNIT = Unit.word; + public final static String DEFAULT_ELLIPSIS = "..."; + public final static String DEFAULT_READ_MORE = ""; + + private final static String HTML_CONTENT_TYPE = "text/html"; + private final static String READ_MORE_TAG = "@@@readmore***tag@@@"; + private final static int DEFAULT_LIMIT = 60; + private final static Charset DEFAULT_CHARSET = StandardCharsets.UTF_8; + private final static boolean DEFAULT_COUNTING_WITH_SPACES = false; + private final static boolean DEFAULT_SMART_TRUNCATION = true; + private final static TruncationStatus DEFAULT_TRUNCATION_STATUS = TruncationStatus.unknown; + private final static String EMPTY_STRING = ""; + private final static String HTML_TAG_NAME_WITH_PREFIX = "^(?:[^:]+:)?a$"; + private final static String HTML_BODY_TAG_CONTENT = "(?s)^.*(.*)\\s*\\s*$"; + private final static String KEEP_ONLY_THE_SELECTION = "$1"; + private final static String CLOSING_HTML_TAG = ""; + private final static String UNCLOSED_HTML_TAG_WITH_BLANK_HEAD_AND_TAIL = "(?s)" + SPACE_PATTERN + + "*<%s(?:>|\\s[^>]*[^/]>)" + SPACE_PATTERN + "*$"; + public final static String ELISIONABLED = "[j|t|d|l|qu|s|m|n]"; + public final static String FRENCH_ELISION = "['|\\u2019|\\u02bc]"; + + final Unit unit; + final int limit; + final Charset charset; + + String source; + boolean countingWithSpaces = DEFAULT_COUNTING_WITH_SPACES; + boolean smartTruncation = DEFAULT_SMART_TRUNCATION; + String ellipsis = DEFAULT_ELLIPSIS; + String readmore = DEFAULT_READ_MORE; + TruncationStatus truncationStatus = DEFAULT_TRUNCATION_STATUS; + + public Truncator() { + this(DEFAULT_UNIT, DEFAULT_LIMIT, DEFAULT_CHARSET); + } + + public Truncator(final int limit) { + this(DEFAULT_UNIT, limit); + } + + public Truncator(final Unit unit, final int limit) { + this(unit, limit, DEFAULT_CHARSET); + } + + public Truncator(final Unit unit, final int limit, final Charset charset) { + checkArgument(unit != null, "The count type (word, character or code point) must be defined."); + checkArgument(limit >= NO_LIMIT, "Either no limit (-1), or a limit with zero or positive number."); + checkArgument(charset != null, "Charset can't be null."); + + this.unit = unit; + this.limit = limit; + this.charset = charset; + } + + public Truncator source(final String source) { + checkArgument(source != null, "The source can't be null."); + + this.source = source; + truncationStatus = TruncationStatus.unknown; + return this; + } + + public Truncator countingWithSpaces(final boolean countingWithSpaces) { + this.countingWithSpaces = countingWithSpaces; + truncationStatus = TruncationStatus.unknown; + return this; + } + + public Truncator smartTruncation(final boolean smartTruncation) { + this.smartTruncation = smartTruncation; + truncationStatus = TruncationStatus.unknown; + return this; + } + + public Truncator ellipsis(final String ellipsis) { + checkArgument(ellipsis != null, "The ellipsis can't be null."); + + this.ellipsis = ellipsis; + return this; + } + + public Truncator readmore(final String readmore) { + checkArgument(readmore != null, "The 'Read More' message can't be null."); + + this.readmore = readmore; + return this; + } + + public boolean isTroncated() { + checkState(truncationStatus != TruncationStatus.unknown, "Run the truncator before asking."); + + return truncationStatus == TruncationStatus.truncated; + } + + public String run() + throws TruncatorException { + checkState(source != null, "Not ready: a source is required."); + + final TruncatedDoc doc = createFullValidXmlDocFromFragment(); + + byte[] buffer = removeOpenLink(doc.getBuffer(), doc.getCurrentElementName()); + + buffer = closeAllTagsLeftOpenAfterTruncature(buffer); + + return keepOnlyBodyContent(buffer); + } + + private static class TruncatedDoc { + private final String currentElementName; + private final byte[] buffer; + + TruncatedDoc(final byte[] buffer, final String currentElementName) { + super(); + this.currentElementName = currentElementName; + this.buffer = buffer; + } + + public String getCurrentElementName() { + return currentElementName; + } + + public byte[] getBuffer() { + return buffer; + } + } + + /* + * Generate a complete and valid xml document from the fragment then + * truncate it + */ + private TruncatedDoc createFullValidXmlDocFromFragment() + throws TruncatorException { + try { + final InputStream is = new ByteArrayInputStream(source.getBytes(charset)); + final ByteArrayOutputStream os = new ByteArrayOutputStream(); + + final String elementName = parse(is, os); + final byte[] result = os.toByteArray(); + return new TruncatedDoc(result, elementName); + } catch(SAXException e){ + throw new TruncatorException("Truncator: unable to read the source", e); + } catch(UnsupportedEncodingException e){ + throw new TruncatorException("Truncator: unable to read the source", e); + } + } + + private String parse(InputStream is, OutputStream os) + throws TruncatorException, SAXException, UnsupportedEncodingException { + final ToXmlContentHandler textHandler = new ToXmlContentHandler(os, charset); + + // ignore the spaces + final TruncateContentHandler writerhandler = new TruncateContentHandler(textHandler, limit); + writerhandler.setCountingWithSpaces(countingWithSpaces); + writerhandler.setWithSmartTruncation(smartTruncation); + writerhandler.setUnit(unit); + + final AutoDetectParser parser = new AutoDetectParser(); + final Metadata metadata = new Metadata(); + metadata.add(Metadata.CONTENT_TYPE, HTML_CONTENT_TYPE); + truncationStatus = TruncationStatus.unchanged; + try { + parser.parse(is, writerhandler, metadata, new ParseContext()); + return EMPTY_STRING; + } catch (IOException e) { + if (!writerhandler.isWriteLimitReached(e)) + throw new TruncatorException("Truncator: unable to parse the source", e); + } catch (SAXException e) { + if (!writerhandler.isWriteLimitReached(e)) + throw new TruncatorException("Truncator: unable to parse the source", e); + } catch (TikaException e) { + if (!writerhandler.isWriteLimitReached(e)) + throw new TruncatorException("Truncator: unable to parse the source", e); + } + writerhandler.endDocument(); + truncationStatus = TruncationStatus.truncated; + if (textHandler.getCurrentElementName().matches(HTML_TAG_NAME_WITH_PREFIX)) { + return textHandler.getCurrentElementName(); + } + return EMPTY_STRING; + } + + /* + * Keep only the truncated fragment, i.e. the content of the "body" element + * a regex can be used here as there is only one body element and it's a + * valid html document (thanks to tagsoup). + */ + private String keepOnlyBodyContent(final byte[] buffer) { + String result = new String(buffer); + result = result.replaceFirst(HTML_BODY_TAG_CONTENT, KEEP_ONLY_THE_SELECTION); + + if (!isTroncated()) + return result; + + return result.replace(READ_MORE_TAG, readmore); + } + + /* + * Close all the tags left open after the truncature to get a complete and + * valid xml document + */ + private byte[] closeAllTagsLeftOpenAfterTruncature(final byte[] buffer) + throws TruncatorException { + if (!isTroncated()) { + return buffer; + } + + try { + + final InputStream is = new ByteArrayInputStream(buffer); + final ByteArrayOutputStream os = new ByteArrayOutputStream(); + + final ToXmlContentHandler textHandler = new ToXmlContentHandler(os, charset); + + final AutoDetectParser parser = new AutoDetectParser(); + final Metadata metadata = new Metadata(); + metadata.add(Metadata.CONTENT_TYPE, HTML_CONTENT_TYPE); + parser.parse(is, textHandler, metadata, new ParseContext()); + return os.toByteArray(); + + } catch (UnsupportedEncodingException e) { + throw new TruncatorException("Truncator: unable to start writing content", e); + } catch (IOException e) { + throw new TruncatorException("Truncator: unable to parse the source", e); + } catch (SAXException e) { + throw new TruncatorException("Truncator: unable to parse the source", e); + } catch (TikaException e) { + throw new TruncatorException("Truncator: unable to parse the source", e); + } + } + + /* + * Tagsoup doesn't like link with attributes inside the "read more": it + * removes the attributes! So we need here a late substitution. Then you are + * solely responsible for the readmore content. + */ + private byte[] removeOpenLink(final byte[] buffer, final String currentElementName) + throws TruncatorException { + if (!isTroncated()) + return buffer; + + try { + String truncated = new String(buffer, charset.name()); + + if (currentElementName.isEmpty()) { + truncated = addEllipsis(truncated); + truncated = addReadMore(truncated); + } else { + final String unclosedEmptyPattern = + format(UNCLOSED_HTML_TAG_WITH_BLANK_HEAD_AND_TAIL, currentElementName); + final Pattern pattern = Pattern.compile(format(unclosedEmptyPattern, currentElementName)); + final Matcher matcher = pattern.matcher(truncated); + if (matcher.find()) { + final StringBuffer sb = new StringBuffer(); + matcher.appendReplacement(sb, EMPTY_STRING); + truncated = sb.toString(); + truncated = addEllipsis(truncated); + truncated = addReadMore(truncated); + } else { + truncated = addEllipsis(truncated); + truncated += format(CLOSING_HTML_TAG, currentElementName); + truncated = addReadMore(truncated); + } + } + return truncated.getBytes(charset); + } catch (UnsupportedEncodingException e) { + throw new TruncatorException("Truncator: unable to start writing content", e); + } + } + + private String addReadMore(final String text) { + return text + READ_MORE_TAG; + } + + private String addEllipsis(final String text) { + return applySpecialFinalRules(text) + ellipsis; + } + + /* + * ATM only some french rules + * + * TODO localization + */ + public String applySpecialFinalRules(final String text) { + + final StringBuilder psb = new StringBuilder("(?s)" + SPACE_PATTERN + "(?:"); + psb.append("(?:\\u00ab" + SPACE_PATTERN + "?)"); // opening french quotation mark + psb.append("|"); + psb.append("(?:" + ELISIONABLED + FRENCH_ELISION + ")"); // french elision apostrophe + psb.append("|"); + psb.append("(?:[^\\p{C}" + SPACE_PATTERN_BASE + "&&[^ày\\&]])"); // an orphan character but "à", "y" and "&" + psb.append(")$"); + + final Pattern pattern = Pattern.compile(psb.toString()); + final Matcher matcher = pattern.matcher(text); + if (matcher.find()) { + final StringBuffer sb = new StringBuffer(); + matcher.appendReplacement(sb, EMPTY_STRING); + return sb.toString(); + } else { + return text; + } + } +} diff --git a/src/main/java/org/jbake/app/excerpt/TruncatorException.java b/src/main/java/org/jbake/app/excerpt/TruncatorException.java new file mode 100644 index 000000000..822e96eec --- /dev/null +++ b/src/main/java/org/jbake/app/excerpt/TruncatorException.java @@ -0,0 +1,18 @@ +package org.jbake.app.excerpt; + +public class TruncatorException extends Exception { + + private static final long serialVersionUID = 1L; + + public TruncatorException(final Throwable cause) { + super(cause); + } + + public TruncatorException(final String message, final Throwable cause) { + super(message, cause); + } + + public TruncatorException(final String message) { + super(message); + } +} diff --git a/src/main/java/org/jbake/parser/ParserContext.java b/src/main/java/org/jbake/parser/ParserContext.java index ef4a99657..bfb29be9c 100644 --- a/src/main/java/org/jbake/parser/ParserContext.java +++ b/src/main/java/org/jbake/parser/ParserContext.java @@ -1,6 +1,9 @@ package org.jbake.parser; +import static org.jbake.app.ContentTag.*; + import org.apache.commons.configuration.CompositeConfiguration; +import org.jbake.app.Content; import java.io.File; import java.util.List; @@ -12,7 +15,7 @@ public class ParserContext { private final CompositeConfiguration config; private final String contentPath; private final boolean hasHeader; - private final Map contents; + private final Content contents; public ParserContext( File file, @@ -20,7 +23,7 @@ public ParserContext( CompositeConfiguration config, String contentPath, boolean hasHeader, - Map contents) { + Content contents) { this.file = file; this.fileLines = fileLines; this.config = config; @@ -29,6 +32,25 @@ public ParserContext( this.contents = contents; } + /** + * Kept to prevent API break. + */ + @Deprecated + public ParserContext( + File file, + List fileLines, + CompositeConfiguration config, + String contentPath, + boolean hasHeader, + Map contents) { + this.file = file; + this.fileLines = fileLines; + this.config = config; + this.contentPath = contentPath; + this.hasHeader = hasHeader; + this.contents = new Content(contents); + } + public File getFile() { return file; } @@ -45,7 +67,12 @@ public String getContentPath() { return contentPath; } + @Deprecated public Map getContents() { + return contents.getContentAsMap(); + } + + public Content getContent() { return contents; } @@ -55,10 +82,10 @@ public boolean hasHeader() { // short methods for common use public String getBody() { - return contents.get("body").toString(); + return contents.getString(body, null); } public void setBody(String str) { - contents.put("body", str); + contents.put(body, str); } } diff --git a/src/main/resources/default.properties b/src/main/resources/default.properties index 450767fd5..f0c3da4d4 100644 --- a/src/main/resources/default.properties +++ b/src/main/resources/default.properties @@ -28,8 +28,12 @@ asset.folder=assets render.index=true # filename to use for index file index.file=index.html +# excerpt used on index page +#index.summery=true # render feed file? render.feed=true +# excerpt used on feed page +#feed.summery=true # character encoding MIME name used for rendering. # use one of http://www.iana.org/assignments/character-sets/character-sets.xhtml render.encoding=UTF-8 diff --git a/src/test/java/org/jbake/app/GroovyRendererTest.java b/src/test/java/org/jbake/app/GroovyRendererTest.java index 53d4cb264..d3b4a219b 100644 --- a/src/test/java/org/jbake/app/GroovyRendererTest.java +++ b/src/test/java/org/jbake/app/GroovyRendererTest.java @@ -19,6 +19,8 @@ import java.io.IOException; import java.net.URISyntaxException; import java.net.URL; +import java.text.SimpleDateFormat; +import java.util.Date; import java.util.Iterator; import java.util.Map; import java.util.Scanner; @@ -137,8 +139,40 @@ public void renderIndex() throws Exception { // verify String output = FileUtils.readFileToString(outputFile); assertThat(output) - .contains("

    First Post

    ") - .contains("

    Second Post

    "); + .contains("

    First Post

    ") + .contains("

    28/02/2013 -") + .contains("

    Second Post

    ") + .contains("

    27/02/2012 -") + .contains("Lorem ipsum dolor sit amet") + .contains("Aliquam erat volutpat."); + } + + @Test + public void renderIndexWithExcerpt() throws Exception { + //setup + config.setProperty(Keys.INDEX_SUMMERY, true); + config.setProperty(ContentTag.summaryLength.key(), 60); + Crawler crawler = new Crawler(db, sourceFolder, config); + crawler.crawl(new File(sourceFolder.getPath() + File.separator + "content")); + Renderer renderer = new Renderer(db, destinationFolder, templateFolder, config); + //exec + renderer.renderIndex("index.html"); + + //validate + File outputFile = new File(destinationFolder, "index.html"); + Assert.assertTrue(outputFile.exists()); + + // verify + String output = FileUtils.readFileToString(outputFile); + assertThat(output) + .contains("

    First Post

    ") + .contains("

    28/02/2013 -") + .contains("

    Second Post

    ") + .contains("

    27/02/2012 -") + .contains("Lorem ipsum dolor sit amet") + .contains("ultricies a hendrerit quam iaculis") + .doesNotContain("Duis tempor elit sit amet") + .doesNotContain("Aliquam erat volutpat."); } @Test @@ -153,9 +187,34 @@ public void renderFeed() throws Exception { // verify String output = FileUtils.readFileToString(outputFile); assertThat(output) - .contains("My corner of the Internet") - .contains("Second Post") - .contains("First Post"); + .contains("My corner of the Internet") + .contains("Second Post") + .contains("First Post") + .contains("Lorem ipsum dolor sit amet") + .contains("Aliquam erat volutpat."); + } + + @Test + public void renderFeedWithExcerpt() throws Exception { + config.setProperty(Keys.FEED_SUMMERY, true); + config.setProperty(ContentTag.summaryLength.key(), 60); + Crawler crawler = new Crawler(db, sourceFolder, config); + crawler.crawl(new File(sourceFolder.getPath() + File.separator + "content")); + Renderer renderer = new Renderer(db, destinationFolder, templateFolder, config); + renderer.renderFeed("feed.xml"); + File outputFile = new File(destinationFolder, "feed.xml"); + Assert.assertTrue(outputFile.exists()); + + // verify + String output = FileUtils.readFileToString(outputFile); + assertThat(output) + .contains("My corner of the Internet") + .contains("Second Post") + .contains("First Post") + .contains("Lorem ipsum dolor sit amet") + .contains("ultricies a hendrerit quam iaculis") + .doesNotContain("Duis tempor elit sit amet") + .doesNotContain("Aliquam erat volutpat."); } @Test diff --git a/src/test/java/org/jbake/app/ParserWithTruncatorTest.java b/src/test/java/org/jbake/app/ParserWithTruncatorTest.java new file mode 100644 index 000000000..55d85479d --- /dev/null +++ b/src/test/java/org/jbake/app/ParserWithTruncatorTest.java @@ -0,0 +1,151 @@ +package org.jbake.app; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.jbake.app.Parser.END_OF_HEADER; +import static org.jbake.app.Parser.EOL; +import static org.jbake.app.ContentTag.*; +import static org.junit.Assert.*; + +import java.io.File; +import java.io.PrintWriter; + +import org.apache.commons.configuration.CompositeConfiguration; +import org.jbake.app.ConfigUtil.Keys; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import com.googlecode.htmlcompressor.compressor.HtmlCompressor; + +/** + * Just to test if the parser finds out the truncator + * and the jbake default parameters + */ +public class ParserWithTruncatorTest { + + @Rule + public final TemporaryFolder folder = new TemporaryFolder(); + + private CompositeConfiguration config; + private Parser parser; + private File rootPath; + private HtmlCompressor htmlCompressor; + + private File withSummaryFile; + private File withOutSummaryFile; + + private final String baseHeader = "title=This is a Title = This is a valid Title" + EOL + + status + "=draft" + EOL + + type + "=post"+ EOL + + date + "=2013-09-02"+ EOL + + END_OF_HEADER; + private final int length = 3; // by default the three first words + private final String withoutSummaryHeader = summaryLength + "=" + String.valueOf(length) + EOL + + baseHeader; + private final String summaryText = "un petit résumé pour faire les tests"; + private final String escapedSummaryText = "un petit résumé pour faire les tests"; + + private final String withSummaryHeader = summary + "=" + summaryText + EOL + + baseHeader; + private final String body = "Test User " + EOL + + "" + EOL + + "JBake now supports AsciiDoc." + EOL; + + private final String strippedExcerpt = "

    Test User

    JBake

    "; + private final String escapedStrippedExcerpt = "<p>Test User</p> <p>JBake</p>"; + + private final String DEFAULT_ELLIPSIS = "..."; + private final String NEW_ELLIPSIS = "[...]"; + + private final String DEFAULT_READMORE = ""; + private final String NEW_READMORE = " Read More >"; + + @Before + public void createSampleFile() throws Exception { + rootPath = new File(this.getClass().getResource(".").getFile()); + config = ConfigUtil.load(rootPath); + parser = new Parser(config, rootPath.getPath()); + htmlCompressor= new HtmlCompressor(); + + withOutSummaryFile = folder.newFile("withOutSummaryFile.ad"); + PrintWriter out = new PrintWriter(withOutSummaryFile); + out.println(withoutSummaryHeader); + out.println(body); + out.close(); + + withSummaryFile = folder.newFile("withSummaryFile.ad"); + out = new PrintWriter(withSummaryFile); + out.println(withSummaryHeader); + out.println(body); + out.close(); + + } + + @Test + public void ignoreWithSummaryFile() { + Content content = parser.processSource(withSummaryFile); + assertNotNull(content); + assertNotNull(content.get(summary)); + assertThat(content.getString(summaryForHome, null)) + .contains("JBake now supports AsciiDoc."); + assertThat(content.getString(summaryForFeed, null)) + .contains("JBake now supports AsciiDoc."); + } + + @Test + public void ignoreWithExcerptFile() { + Content content = parser.processSource(withOutSummaryFile); + assertNotNull(content); + assertNull(content.get(summary)); + assertThat(content.getString(summaryForHome, null)) + .contains("JBake now supports AsciiDoc."); + assertThat(content.getString(summaryForFeed, null)) + .contains("JBake now supports AsciiDoc."); + } + + @Test + public void parseWithSummaryFile() { + config.setProperty(Keys.INDEX_SUMMERY, true); + config.setProperty(Keys.FEED_SUMMERY, true); + Content content = parser.processSource(withSummaryFile); + assertNotNull(content); + assertNotNull(content.get(summary)); + assertThat(content.get(summary)).isEqualTo(summaryText); + assertThat(htmlCompressor.compress(content.getString(summaryForHome, null))) + .isEqualTo(summaryText); + assertThat(htmlCompressor.compress(content.getString(summaryForFeed, null))) + .isEqualTo(escapedSummaryText); + } + + @Test + public void parseWithExcerptFile() { + config.setProperty(Keys.INDEX_SUMMERY, true); + config.setProperty(Keys.FEED_SUMMERY, true); + Content content = parser.processSource(withOutSummaryFile); + assertNotNull(content); + assertNull(content.get(summary)); + assertThat(htmlCompressor.compress(content.getString(summaryForHome, null))) + .isEqualTo(strippedExcerpt.replaceAll("

    $", DEFAULT_ELLIPSIS + DEFAULT_READMORE + "

    ")); + // The readmore is not used on the feed page + assertThat(htmlCompressor.compress(content.getString(summaryForFeed, null))) + .isEqualTo(escapedStrippedExcerpt.replaceAll("</p>$", DEFAULT_ELLIPSIS + "</p>")); + } + + @Test + public void parseWithExcerptFileAndNewEllipsisAndNewReadMore() { + config.setProperty(Keys.INDEX_SUMMERY, true); + config.setProperty(Keys.FEED_SUMMERY, true); + config.setProperty(Keys.SUMMERY_ELLIPSIS, NEW_ELLIPSIS); + config.setProperty(Keys.SUMMERY_READMORE, NEW_READMORE); + Content content = parser.processSource(withOutSummaryFile); + assertNotNull(content); + assertNull(content.get(summary)); + assertThat(htmlCompressor.compress(content.getString(summaryForHome, null))) + .isEqualTo(strippedExcerpt.replaceAll("

    $", NEW_ELLIPSIS + NEW_READMORE + "

    ")); + // The readmore is not used on the feed page + assertThat(htmlCompressor.compress(content.getString(summaryForFeed, null))) + .isEqualTo(escapedStrippedExcerpt.replaceAll("</p>$", NEW_ELLIPSIS + "</p>")); + } + +} diff --git a/src/test/java/org/jbake/app/RendererTest.java b/src/test/java/org/jbake/app/RendererTest.java index c41546572..baf8f8ed5 100644 --- a/src/test/java/org/jbake/app/RendererTest.java +++ b/src/test/java/org/jbake/app/RendererTest.java @@ -126,8 +126,40 @@ public void renderIndex() throws Exception { // verify String output = FileUtils.readFileToString(outputFile); assertThat(output) - .contains("

    First Post

    ") - .contains("

    Second Post

    "); + .contains("

    First Post

    ") + .contains("

    28/02/2013 -") + .contains("

    Second Post

    ") + .contains("

    27/02/2012 -") + .contains("Lorem ipsum dolor sit amet") + .contains("Aliquam erat volutpat."); + } + + @Test + public void renderIndexWithExcerpt() throws Exception { + //setup + config.setProperty(Keys.INDEX_SUMMERY, true); + config.setProperty(ContentTag.summaryLength.key(), 60); + Crawler crawler = new Crawler(db, sourceFolder, config); + crawler.crawl(new File(sourceFolder.getPath() + File.separator + "content")); + Renderer renderer = new Renderer(db, destinationFolder, templateFolder, config); + //exec + renderer.renderIndex("index.html"); + + //validate + File outputFile = new File(destinationFolder, "index.html"); + Assert.assertTrue(outputFile.exists()); + + // verify + String output = FileUtils.readFileToString(outputFile); + assertThat(output) + .contains("

    First Post

    ") + .contains("

    28/02/2013 -") + .contains("

    Second Post

    ") + .contains("

    27/02/2012 -") + .contains("Lorem ipsum dolor sit amet") + .contains("ultricies a hendrerit quam iaculis") + .doesNotContain("Duis tempor elit sit amet") + .doesNotContain("Aliquam erat volutpat."); } @Test @@ -142,9 +174,34 @@ public void renderFeed() throws Exception { // verify String output = FileUtils.readFileToString(outputFile); assertThat(output) - .contains("My corner of the Internet") - .contains("Second Post") - .contains("First Post"); + .contains("My corner of the Internet") + .contains("Second Post") + .contains("First Post") + .contains("Lorem ipsum dolor sit amet") + .contains("Aliquam erat volutpat."); + } + + @Test + public void renderFeedWithExcerpt() throws Exception { + config.setProperty(Keys.FEED_SUMMERY, true); + config.setProperty(ContentTag.summaryLength.key(), 60); + Crawler crawler = new Crawler(db, sourceFolder, config); + crawler.crawl(new File(sourceFolder.getPath() + File.separator + "content")); + Renderer renderer = new Renderer(db, destinationFolder, templateFolder, config); + renderer.renderFeed("feed.xml"); + File outputFile = new File(destinationFolder, "feed.xml"); + Assert.assertTrue(outputFile.exists()); + + // verify + String output = FileUtils.readFileToString(outputFile); + assertThat(output) + .contains("My corner of the Internet") + .contains("Second Post") + .contains("First Post") + .contains("Lorem ipsum dolor sit amet") + .contains("ultricies a hendrerit quam iaculis") + .doesNotContain("Duis tempor elit sit amet") + .doesNotContain("Aliquam erat volutpat."); } @Test diff --git a/src/test/java/org/jbake/app/ThymeleafRendererTest.java b/src/test/java/org/jbake/app/ThymeleafRendererTest.java index fed656e95..014cb2287 100644 --- a/src/test/java/org/jbake/app/ThymeleafRendererTest.java +++ b/src/test/java/org/jbake/app/ThymeleafRendererTest.java @@ -138,8 +138,40 @@ public void renderIndex() throws Exception { // verify String output = FileUtils.readFileToString(outputFile); assertThat(output) - .contains("

    First Post

    ") - .contains("

    Second Post

    "); + .contains("

    First Post

    ") + .contains("

    28/02/2013 -") + .contains("

    Second Post

    ") + .contains("

    27/02/2012 -") + .contains("Lorem ipsum dolor sit amet") + .contains("Aliquam erat volutpat."); + } + + @Test + public void renderIndexWithExcerpt() throws Exception { + //setup + config.setProperty(Keys.INDEX_SUMMERY, true); + config.setProperty(ContentTag.summaryLength.key(), 60); + Crawler crawler = new Crawler(db, sourceFolder, config); + crawler.crawl(new File(sourceFolder.getPath() + File.separator + "content")); + Renderer renderer = new Renderer(db, destinationFolder, templateFolder, config); + //exec + renderer.renderIndex("index.html"); + + //validate + File outputFile = new File(destinationFolder, "index.html"); + Assert.assertTrue(outputFile.exists()); + + // verify + String output = FileUtils.readFileToString(outputFile); + assertThat(output) + .contains("

    First Post

    ") + .contains("

    28/02/2013 -") + .contains("

    Second Post

    ") + .contains("

    27/02/2012 -") + .contains("Lorem ipsum dolor sit amet") + .contains("ultricies a hendrerit quam iaculis") + .doesNotContain("Duis tempor elit sit amet") + .doesNotContain("Aliquam erat volutpat."); } @Test @@ -154,9 +186,34 @@ public void renderFeed() throws Exception { // verify String output = FileUtils.readFileToString(outputFile); assertThat(output) - .contains("My corner of the Internet") - .contains("Second Post") - .contains("First Post"); + .contains("My corner of the Internet") + .contains("Second Post") + .contains("First Post") + .contains("Lorem ipsum dolor sit amet") + .contains("Aliquam erat volutpat."); + } + + @Test + public void renderFeedWithExcerpt() throws Exception { + config.setProperty(Keys.FEED_SUMMERY, true); + config.setProperty(ContentTag.summaryLength.key(), 60); + Crawler crawler = new Crawler(db, sourceFolder, config); + crawler.crawl(new File(sourceFolder.getPath() + File.separator + "content")); + Renderer renderer = new Renderer(db, destinationFolder, templateFolder, config); + renderer.renderFeed("feed.xml"); + File outputFile = new File(destinationFolder, "feed.xml"); + Assert.assertTrue(outputFile.exists()); + + // verify + String output = FileUtils.readFileToString(outputFile); + assertThat(output) + .contains("My corner of the Internet") + .contains("Second Post") + .contains("First Post") + .contains("Lorem ipsum dolor sit amet") + .contains("ultricies a hendrerit quam iaculis") + .doesNotContain("Duis tempor elit sit amet") + .doesNotContain("Aliquam erat volutpat."); } @Test diff --git a/src/test/java/org/jbake/app/excerpt/TruncatorTest.java b/src/test/java/org/jbake/app/excerpt/TruncatorTest.java new file mode 100644 index 000000000..f08a9b0bf --- /dev/null +++ b/src/test/java/org/jbake/app/excerpt/TruncatorTest.java @@ -0,0 +1,220 @@ +package org.jbake.app.excerpt; + +import static org.apache.commons.lang.StringEscapeUtils.unescapeHtml; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; + +import java.nio.charset.Charset; +import org.apache.sis.internal.jdk7.StandardCharsets; // in place of jdk7's java.nio.charset.StandardCharsets + +import org.junit.Test; + +import com.google.common.base.Joiner; + +import org.jbake.app.excerpt.TruncateContentHandler.Unit; + +public class TruncatorTest { + + private final static String HTML_DOC = Joiner.on(System.getProperty("line.separator")).join( +"

    Faire un blog sur mes pérégrinations informatiques me trottait dans la tête depuis un certain temps. Histoire d’en conserver une trace. Et si en plus cela pouvait servir à d’autres…

    Après avoir installé un site javadoc de démonstration sur Github à l’aide de Maven, la possibilité de créer un blog sur le même principe est devenue incontournable. Le temps de faire le lien avec Jekyll puis JBake !

    Le choix de JBake permet de rester dans un cadre 100% JVM.

    Trois articles ont fourni les bases :

    ", +"

    N’ayant jamais utilisé Gradle, j’ai opté pour Maven avec l’extension jbake-maven-plugin. Yan Bonnel fournit dans son billet toutes les informations pour se lancer. D’autant qu’il met aussi à disposition le code de son propre blog JustAnOtherDevBlog. Et pour rendre à César… Cédric Champeau fait de même pour son Blog .

    Ne reste plus qu’à tester :

    ", +"

    Ce qui aurait dû rester une promenade de santé s’est avéré un parcours du combattant.

    Commençons avec livereload : dès qu’une modification est enregistrée, livereload s’arrête. Pour le moment, il faut travailler avec un fork de la version 0.0.9-SNAPSHOT.

    Et les déboires continuent avec Github site plugin :

    ", +"

    Pourquoi pas de fournir un nom, mais aucune envie d’une adresse email qui soit rendue publique.

    Ne reste donc qu’à remplacer Github site plugin par maven-scm-publish-plugin.

    Ah, quel plaisir d’avoir enfin son petit blog perso. Et cerise sur le gâteau : Github permet de publier un tel site avec son propre nom de domaine.

    Le dépôt Github du blog est disponible ici.

    Dans une série de billets à venir, je donnerai plus de détail pour arriver à ce résultat. À bientôt.

    " + ); + + private final static String SIXTY_WORDS_HTML_DOC = unescapeHtml( + "

    Faire un blog sur mes pérégrinations informatiques me trottait dans la tête depuis un certain temps. Histoire d’en conserver une trace. Et si en plus cela pouvait servir à d’autres…

    Après avoir installé un site javadoc de démonstration sur Github à l’aide de Maven, la possibilité de créer un blog sur le même principe est...

    " + ); + private final static String TWENTY_WORDS_HTML_DOC = unescapeHtml( + "

    Faire un blog sur mes pérégrinations informatiques me trottait dans la tête depuis un certain temps. Histoire d’en conserver...

    " + ); + private final static String TWENTY_SPACES_AND_CHARS_HTML_DOC = unescapeHtml( + "

    Faire un blog sur...

    " + ); + private final static String TWENTY_CHARS_HTML_DOC = unescapeHtml( + "

    Faire un blog sur mes...

    " + ); + private final static String STRICT_TWENTY_SPACES_AND_CHARS_HTML_DOC = unescapeHtml( + "

    Faire un blog sur me...

    " + ); + private final static String STRICT_TWENTY_CHARS_HTML_DOC = unescapeHtml( + "

    Faire un blog sur mes pér...

    " + ); + + private final static Charset DEFAULT_CHARSET = StandardCharsets.UTF_8; + private final static String DEFAULT_ELLIPSIS = "..."; + private final static String NEW_ELLIPSIS = "[...]"; + @SuppressWarnings("unused") + private final static String DEFAULT_READMORE = ""; + private final static String NEW_READMORE = " Read More >"; + + @Test + public void testDefaultConstructor() + throws Exception { + String result = new Truncator().source(HTML_DOC).run(); + result = removeTagSoupArtifacts(result); + assertThat(result, equalTo(SIXTY_WORDS_HTML_DOC)); + } + + /* + * Any space or elision apostrophe are separators. + */ + @Test + public void testWithDefaultWordLimitConstructor() + throws Exception { + final int limit = 20; + String result = new Truncator(limit).source(HTML_DOC).run(); + result = removeTagSoupArtifacts(result); + assertThat(result, equalTo(TWENTY_WORDS_HTML_DOC)); + assertThat(result.split("\\s+|(?<=[j|t|d|l|u|s|n|m])" + Truncator.FRENCH_ELISION).length, equalTo(limit)); + } + + @Test + public void testDefaultEllipsisAndReadMore() + throws Exception { + String result = new Truncator(20).source(HTML_DOC).run(); + result = removeTagSoupArtifacts(result); + result = removeAllHtmlTags(result); + assertThat(result.endsWith(DEFAULT_ELLIPSIS), is(true)); + } + + @Test + public void testNewEllipsisWithDefaultReadMore() + throws Exception { + final Truncator truncator = new Truncator(20).source(HTML_DOC); + String result = truncator.ellipsis(NEW_ELLIPSIS).run(); + result = removeTagSoupArtifacts(result); + result = removeAllHtmlTags(result); + assertThat(result.endsWith(NEW_ELLIPSIS), is(true)); + } + + @Test + public void testDefaultEllipsisWithNewReadMore() + throws Exception { + final Truncator truncator = new Truncator(20).source(HTML_DOC); + String result = truncator.readmore(NEW_READMORE).run(); + result = removeTagSoupArtifacts(result); + result = removeAllHtmlTags(result); + String withoutReadmore = result.substring(0, result.length() - NEW_READMORE.length()); + assertThat(result.endsWith(NEW_READMORE), is(true)); + assertThat(withoutReadmore.endsWith(DEFAULT_ELLIPSIS), is(true)); + } + + @Test + public void testNewReadMoreAndNewEllipsis() + throws Exception { + final Truncator truncator = new Truncator(20).source(HTML_DOC); + String result = truncator.ellipsis(NEW_ELLIPSIS).readmore(NEW_READMORE).run(); + result = removeTagSoupArtifacts(result); + result = removeAllHtmlTags(result); + String withoutReadmore = result.substring(0, result.length() - NEW_READMORE.length()); + assertThat(result.endsWith(NEW_READMORE), is(true)); + assertThat(withoutReadmore.endsWith(NEW_ELLIPSIS), is(true)); + } + + @Test + public void testWithCharLimitConstructor() + throws Exception { + final int limit = 20; + final Truncator truncator = new Truncator(Unit.character, limit, DEFAULT_CHARSET).source(HTML_DOC); + String result = truncator.run(); + result = removeTagSoupArtifacts(result); + assertThat(result, equalTo(TWENTY_CHARS_HTML_DOC)); + result = removeAllSpaces(removeEllipsis(removeAllHtmlTags(result), DEFAULT_ELLIPSIS)); + assertThat(result.length(), lessThanOrEqualTo(limit)); + } + + @Test + public void testWithSpaceAndCharLimitConstructor() + throws Exception { + final int limit = 20; + final Truncator truncator = new Truncator(Unit.character, limit, DEFAULT_CHARSET).source(HTML_DOC); + String result = truncator.countingWithSpaces(true).run(); + result = removeTagSoupArtifacts(result); + assertThat(result, equalTo(TWENTY_SPACES_AND_CHARS_HTML_DOC)); + result = removeEllipsis(removeAllHtmlTags(result), DEFAULT_ELLIPSIS); + assertThat(result.length(), lessThanOrEqualTo(limit)); + } + + @Test + public void testWithStrictCharLimitConstructor() + throws Exception { + final int limit = 20; + final Truncator truncator = new Truncator(Unit.character, limit, DEFAULT_CHARSET).source(HTML_DOC); + String result = truncator.smartTruncation(false).run(); + result = removeTagSoupArtifacts(result); + assertThat(result, equalTo(STRICT_TWENTY_CHARS_HTML_DOC)); + result = removeAllSpaces(removeEllipsis(removeAllHtmlTags(result), DEFAULT_ELLIPSIS)); + assertThat(result.length(), equalTo(limit)); + } + + @Test + public void testWithStrictSpaceAndCharLimitConstructor() + throws Exception { + final int limit = 20; + final Truncator truncator = new Truncator(Unit.character, limit, DEFAULT_CHARSET).source(HTML_DOC); + String result = truncator.smartTruncation(false).countingWithSpaces(true).run(); + result = removeTagSoupArtifacts(result); + assertThat(result, equalTo(STRICT_TWENTY_SPACES_AND_CHARS_HTML_DOC)); + result = removeEllipsis(removeAllHtmlTags(result), DEFAULT_ELLIPSIS); + assertThat(result.length(), equalTo(limit)); + } + + /* + * TODO: check why Tagsoup removes the tags "
    " + */ + @Test + public void testIsTruncated() + throws Exception { + final Truncator truncator = new Truncator(Unit.character, TruncateContentHandler.NO_LIMIT, DEFAULT_CHARSET).source(HTML_DOC); + String result = truncator.run(); + assertThat(truncator.isTroncated(), equalTo(false)); + result = removeTagSoupArtifacts(result); + assertThat(result.replaceAll("\t", ""), equalTo(unescapeHtml(HTML_DOC).replaceAll("
    ", "").replaceAll("\n\\s*", ""))); + } + + private static String removeTagSoupArtifacts(final String o) { + String r = replaceAttributeDelimiters(o); + r = removeShapeAttribute(r); + return removeEndOfLine(r); + } + + private static String replaceAttributeDelimiters(final String o) { + return o.replaceAll("=\"([^\"]*)\"(\\s|>)", "='$1'$2"); + } + + /* + * TagSoup adds shape attribute in a href tag + * https://groups.google.com/forum/#!topic/tagsoup-friends/EfB6i12xBLw + */ + private static String removeShapeAttribute(final String o) { + return o.replaceAll(" shape='rect'", ""); + } + + private static String removeEndOfLine(final String o) { + return o.replaceAll("\\n", ""); + } + + private String removeAllSpaces(final String s) { + return s.replaceAll("\\s", ""); + } + private String removeAllHtmlTags(final String s) { + return s.replaceAll("]+>", ""); + } + + private static String removeEllipsis(final String s, final String e) { + return s.replaceFirst("(?s)" + e +"$", ""); + } + +} diff --git a/src/test/resources/groovyTemplates/feed.gsp b/src/test/resources/groovyTemplates/feed.gsp index 1aaa62f09..428b6be20 100644 --- a/src/test/resources/groovyTemplates/feed.gsp +++ b/src/test/resources/groovyTemplates/feed.gsp @@ -16,7 +16,7 @@ ${post.date.format("EEE, d MMM yyyy HH:mm:ss Z")} ${post.uri} - ${post.body} + ${post.summaryForFeed} <%}%> diff --git a/src/test/resources/groovyTemplates/index.gsp b/src/test/resources/groovyTemplates/index.gsp index 1956c0690..a700fb47e 100644 --- a/src/test/resources/groovyTemplates/index.gsp +++ b/src/test/resources/groovyTemplates/index.gsp @@ -12,7 +12,7 @@
    <%posts[0..<2].each { post ->%>

    ${post.title}

    -

    ${post.date.format("dd MMMM yyyy")} - ${post.body.substring(0, 150)}...

    +

    ${post.date.format("dd/MM/yyyy")} - ${post.summaryForHome}

    <%}%> Archive
    diff --git a/src/test/resources/templates/feed.ftl b/src/test/resources/templates/feed.ftl index 5bba85667..ad31f6304 100644 --- a/src/test/resources/templates/feed.ftl +++ b/src/test/resources/templates/feed.ftl @@ -16,9 +16,7 @@ ${post.date?string("EEE, d MMM yyyy HH:mm:ss Z")} ${post.uri} - <#escape x as x?xml> - ${post.body} - + ${post.summaryForFeed} diff --git a/src/test/resources/templates/index.ftl b/src/test/resources/templates/index.ftl index f390ded95..3c4cf9d58 100644 --- a/src/test/resources/templates/index.ftl +++ b/src/test/resources/templates/index.ftl @@ -12,7 +12,7 @@
    <#list posts as post>

    ${post.title}

    -

    ${post.date?string("dd MMMM yyyy")} - ${post.body?substring(0, 150)}...

    +

    ${post.date?string("dd/MM/yyyy")} - ${post.summaryForHome}

    <#if post_index = 2><#break> Archive diff --git a/src/test/resources/thymeleafTemplates/feed.thyme b/src/test/resources/thymeleafTemplates/feed.thyme index 1121159da..0f0544e4b 100644 --- a/src/test/resources/thymeleafTemplates/feed.thyme +++ b/src/test/resources/thymeleafTemplates/feed.thyme @@ -15,7 +15,7 @@ http://jonathanbullock.com/blog/foo.html 2014-01-30 http://jonathanbullock.com/blog/foo.html - Body of the post + Summary of the post diff --git a/src/test/resources/thymeleafTemplates/index.thyme b/src/test/resources/thymeleafTemplates/index.thyme index 15abdd62c..f90005d64 100644 --- a/src/test/resources/thymeleafTemplates/index.thyme +++ b/src/test/resources/thymeleafTemplates/index.thyme @@ -18,7 +18,7 @@