All Downloads are FREE. Search and download functionalities are using the official Maven repository.

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> models=files.stream() .filter(page -> page.path().toString().endsWith(".html")) .map(File::model) .collect(toList()); files.forEach(file -> process(file, models)); return files; } /** * Identifies site resources to be processed. * * @param paths the paths of the site resources to be processed, relative to the {@linkplain Opts#source() source} * site folder * * @return the collection of site files to be generated * * @throws NullPointerException if {@code source} is null */ public Collection scan(final Stream paths) { if ( paths == null ) { throw new NullPointerException("null paths"); } return paths .filter(Objects::nonNull) .filter(Files::isRegularFile) .filter(not(path -> path.getFileName().toString().startsWith("."))) .map(this::source) .map(this::scan) .flatMap(Optional::stream) .map(this::extend) .collect(toList()); } //////////////////////////////////////////////////////////////////////////////////////////////////////////////////// private Optional scan(final Path source) { try { return !isRegularFile(source) || isHidden(source) || isLayout(source) ? Optional.empty() : pipes.stream() .map(factory -> factory.apply(this)) .map(pipe -> { try { return pipe.process(source).filter(not(file -> inplace && file.path().equals(source) // prevent overwriting )); } catch ( final RuntimeException e ) { logger.error(format("%s › %s", pipe.getClass().getSimpleName().toLowerCase(ROOT), MessagePattern.matcher(e.getMessage()).replaceAll("; ") )); return Optional.empty(); } }) .flatMap(Optional::stream) .findFirst(); } catch ( final IOException e ) { throw new UncheckedIOException(e); } } private File extend(final File file) { final Path relative=relative(file.path()); final Map model=new HashMap<>(file.model()); model.put("root", Optional .ofNullable(relative.getParent()) .map(parent -> Root.resolve(parent).relativize(Root)) // ;( must be both absolute .map(Path::toString) .orElse(".") ); model.put("base", Optional .ofNullable(relative.getParent()) .map(Path::toString) .orElse(".") ); model.put("path", relative.toString()); model.putIfAbsent("date", Date); return new File(relative, model, file.process()); } private void process(final File file, final List> models) { try { final Path relative=relative(file.path()); logger.info(relative.toString()); // create the root data model final Map model=new HashMap<>(global); model.put("page", file.model()); model.put("pages", models); // make sure the output folder exists, then process file final Path path=target(relative); createDirectories(path.getParent()); file.process().accept(path, model); } catch ( final IOException e ) { throw new UncheckedIOException(e); } } //////////////////////////////////////////////////////////////////////////////////////////////////////////////////// private Path relative(final Path path) { return path.startsWith(bundle) ? bundle.relativize(path).normalize() : source.relativize(source.resolve(path)).normalize(); } private Path source(final Path path) { final Path absolute=source.resolve(path).toAbsolutePath().normalize(); if ( exists(absolute) ) { // regular source file if ( !absolute.startsWith(source) && !absolute.startsWith(bundle) ) { throw new IllegalArgumentException(format("resource ‹%s› outside source folder", local(path))); } if ( !isRegularFile(absolute) ) { throw new IllegalArgumentException(format("resource is not a regular file ‹%s›", local(path))); } return absolute; } else { // look for bundled asset final Path fallback=assets.get(path); if ( fallback != null ) { return fallback; } else { throw new IllegalArgumentException(format("missing resource ‹%s›", local(path))); } } } private Path target(final Path path) { final Path absolute=target.resolve(path).toAbsolutePath().normalize(); if ( !absolute.startsWith(target) ) { throw new IllegalArgumentException(format("resource ‹%s› outside target folder", local(path))); } if ( exists(absolute) && !isRegularFile(absolute) ) { throw new IllegalArgumentException(format("resource is not a regular file ‹%s›", local(path))); } return absolute; } private Path local(final Path path) { return Work.relativize(path.toAbsolutePath()).normalize(); } }




© 2015 - 2025 Weber Informatics LLC | Privacy Policy