com.metreeca.mark.Mark Maven / Gradle / Ivy
/*
* Copyright © 2019-2023 Metreeca srl
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.metreeca.mark;
import com.metreeca.mark.pipes.*;
import org.apache.maven.plugin.logging.Log;
import java.io.*;
import java.net.URL;
import java.nio.file.*;
import java.time.LocalDate;
import java.util.*;
import java.util.Map.Entry;
import java.util.function.*;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import static java.lang.Math.max;
import static java.lang.String.format;
import static java.nio.file.Files.*;
import static java.nio.file.StandardWatchEventKinds.*;
import static java.time.format.DateTimeFormatter.ISO_LOCAL_DATE;
import static java.util.Arrays.asList;
import static java.util.Locale.ROOT;
import static java.util.Map.entry;
import static java.util.Objects.requireNonNull;
import static java.util.function.Function.identity;
import static java.util.function.Predicate.not;
import static java.util.stream.Collectors.toList;
import static java.util.stream.Collectors.toMap;
/**
* Site generation engine.
*/
public final class Mark implements Opts {
private static final Path Root=Paths.get("/");
private static final Path Base=Paths.get("");
private static final Path Work=Base.toAbsolutePath().normalize();
private static final String Date=ISO_LOCAL_DATE.format(LocalDate.now());
private static final Pattern MessagePattern=Pattern.compile("\n\\s*");
//// Bundled Layout ////////////////////////////////////////////////////////////////////////////////////////////////
private static final Path cache=Paths.get(System.getProperty("java.io.tmpdir"), "mark-bundle");
private static final Path index=Paths.get("index.pug");
private static final Path bundle=asset(index.getFileName()).getParent();
private static final Map assets=Stream
.of(
"index.pug",
"index.js",
"index.less",
"index.svg"
)
.map(Paths::get)
.collect(toMap(identity(), Mark::asset));
private static Path asset(final Path path) {
final URL url=requireNonNull(
Mark.class.getResource(Paths.get("files").resolve(path).toString()),
format("missing asset ‹%s›", path)
);
try {
final Path asset=cache.resolve(path);
if ( !exists(asset) || !isRegularFile(asset) ) {
try ( final InputStream inputStream=url.openStream() ) {
createDirectories(asset.getParent());
copy(inputStream, asset);
}
}
return asset
.toAbsolutePath()
.normalize();
} catch ( final IOException e ) {
throw new UncheckedIOException(e);
}
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
private final Opts opts;
private final Path source;
private final Path target;
private final Path layout;
private final Map global;
private final boolean readme; // readme generation
private final boolean inplace; // in-place processing
private final boolean bundled; // use bundled skin
private final Log logger;
private final String template; // template layout extension
private final Collection> pipes=asList(
None::new,
Md::new,
Less::new,
Any::new
);
/**
* Creates a site generation engine
*
* @param opts the site generation options
*
* @throws NullPointerException if {@code opts} is null or one of its methods returns a null value
*/
public Mark(final Opts opts) {
this.opts=requireNonNull(opts, "null opts");
final Path _source=requireNonNull(opts.source(), "null source path");
final Path _target=requireNonNull(opts.target(), "null target path");
final Path _layout=requireNonNull(opts.layout(), "null layout path");
this.source=_source.toAbsolutePath().normalize();
this.target=_target.equals(Base) ? source : _target.toAbsolutePath().normalize();
this.layout=_layout.equals(Base) ? index : source.relativize(source.resolve(_layout)).normalize();
this.readme=opts.readme();
this.inplace=target.equals(source);
this.bundled=layout.equals(index);
if ( !exists(source) ) {
throw new IllegalArgumentException(format("missing source folder ‹%s›", local(source)));
}
if ( !isDirectory(source) ) {
throw new IllegalArgumentException(format("source path ‹%s› is not a folder", local(source)));
}
if ( exists(target) && !isDirectory(target) ) {
throw new IllegalArgumentException(format("target path ‹%s› is not a folder", local(target)));
}
if ( !inplace && (target.startsWith(source) || source.startsWith(target)) ) {
throw new IllegalArgumentException(
format("partly overlapping source/target folders ‹%s›/‹%s›", local(source), local(target))
);
}
if ( !bundled && !exists(source.resolve(layout)) ) {
throw new IllegalArgumentException(format("missing layout ‹%s›", local(layout)));
}
if ( !bundled && !isRegularFile(source.resolve(layout)) ) {
throw new IllegalArgumentException(format("layout path ‹%s› is not a file", local(layout)));
}
this.global=Map.copyOf(requireNonNull(opts.global(), "null global variables"));
this.logger=requireNonNull(opts.logger(), "null logger");
final String _template=layout.toString();
this.template=_template.substring(max(0, _template.lastIndexOf('.')));
if ( !template.startsWith(".") ) {
throw new IllegalArgumentException(format("layout ‹%s› has no extension", layout));
}
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
@Override public Path source() {
return source;
}
@Override public Path target() {
return target;
}
@Override public Path layout() {
return layout;
}
@Override public boolean readme() {
return readme;
}
@Override public Map global() {
return global;
}
@Override public V option(final String option, final Function mapper) {
return opts.option(option, mapper);
}
@Override public Log logger() {
return logger;
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
/**
* Checks if a path is a layout
*
* @param path the path to be checked
*
* @return {@code true} if {@code path} has the same file extension as the default {@linkplain Opts#layout() layout}
*
* @throws NullPointerException if {@code path} is {@code null}
*/
public boolean isLayout(final Path path) {
if ( path == null ) {
throw new NullPointerException("null path");
}
return path.toString().endsWith(template);
}
/**
* Locates a layout.
*
* @param name the name of the layout to be located
*
* @return the absolute path of the layout identified by {@code name}
*
* @throws NullPointerException if {@code name} is null
* @throws IllegalArgumentException if unable to locate a layout identified by {@code name}
*/
public Path layout(final String name) {
if ( name == null ) {
throw new NullPointerException("null name");
}
return source(name.isEmpty() || name.equals(template)
? layout // ;( handle extension-only paths…
: layout.resolveSibling(name.contains(".") ? name : name+template)
);
}
public Optional target(final Path source, final String to, final String... from) {
if ( source == null ) {
throw new NullPointerException("null source");
}
if ( to == null ) {
throw new NullPointerException("null target extension");
}
if ( !to.startsWith(".") ) {
throw new IllegalArgumentException("missing leading dot in target extension");
}
if ( from == null || Arrays.stream(from).anyMatch(Objects::isNull) ) {
throw new NullPointerException("null source extension");
}
if ( Arrays.stream(from).anyMatch(ext -> !ext.startsWith(".")) ) {
throw new IllegalArgumentException("missing leading dot in source extension");
}
final String path=source.toString();
return Arrays.stream(from)
.map(extension -> path.endsWith(extension)
? source.resolveSibling(path.substring(0, path.length()-extension.length())+to)
: null
)
.filter(Objects::nonNull)
.findFirst();
}
public Optional> index() {
if ( readme ) {
final Path index=source.resolve("index.md").normalize();
if ( !exists(index) ) {
logger.error(format("missing index file <%s>", relative(index)));
}
return Optional.of(entry(index, Work.resolve("README.md").normalize()));
} else {
return Optional.empty();
}
}
public Map assets() {
return bundled ? assets : Map.of();
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
/**
* Executes a site generation task.
*
* @param task the site generation task to be executed
*
* @return this engine
*
* @throws NullPointerException if {@code resource} is null
*/
public Mark exec(final Task task) {
if ( task == null ) {
throw new NullPointerException("null task");
}
final String name=task.getClass().getSimpleName().toLowerCase(ROOT);
logger.info(source.equals(target)
? format("%s %s", name, local(source))
: format("%s %s ›› %s", name, local(source), local(target))
);
task.exec(this);
return this;
}
/**
* Watches site source folder.
*
* @param action an action to be performed on change events; takes as argument the kind of change event and the path
* of the changed file
*
* @return this engine
*
* @throws NullPointerException if {@code action} is null
*/
public Mark watch(final BiConsumer, Path> action) {
if ( action == null ) {
throw new NullPointerException("null action");
}
final Thread thread=new Thread(() -> {
try ( final WatchService service=source.getFileSystem().newWatchService() ) {
final Consumer register=path -> {
try {
path.register(service, ENTRY_CREATE, ENTRY_MODIFY, ENTRY_DELETE);
} catch ( final IOException e ) {
throw new UncheckedIOException(e);
}
};
try ( final Stream sources=walk(source) ) {
sources.filter(Files::isDirectory).forEach(register); // register existing folders
}
for (WatchKey key; (key=service.take()) != null; key.reset()) { // watch changes
for (final WatchEvent> event : key.pollEvents()) {
final WatchEvent.Kind> kind=event.kind();
final Path path=((Path)key.watchable()).resolve((Path)event.context());
if ( kind.equals(OVERFLOW) ) {
logger.error("sync lost ;-(");
} else if ( kind.equals(ENTRY_CREATE) && isDirectory(path) ) { // register new folders
logger.info(source.relativize(path).toString());
register.accept(path);
} else if ( kind.equals(ENTRY_DELETE) || isRegularFile(path) ) {
action.accept(kind, path);
}
}
}
} catch ( final UnsupportedOperationException ignored ) {
} catch ( final InterruptedException e ) {
logger.error("interrupted…");
} catch ( final IOException e ) {
throw new UncheckedIOException(e);
}
});
thread.setDaemon(true);
thread.start();
return this;
}
/**
* Processes site resources.
*
* Generates processed version of site resources in the {@linkplain Opts#target() target} site folder.
*
* @param paths the paths of the site resources to be processed, relative to the {@linkplain Opts#source() source}
* site folder
*
* @return the collection of generated site files
*
* @throws NullPointerException if {@code source} is null
*/
public Collection process(final Stream paths) {
if ( paths == null ) {
throw new NullPointerException("null path stream");
}
final Collection files=scan(paths);
final List
© 2015 - 2025 Weber Informatics LLC | Privacy Policy