...
 
Commits (57)
......@@ -12,7 +12,7 @@ pom.xml.asc
.hg/
.idea/
node_modules/
public
.vscode/
.idea/
*.log
vendor
.env
This diff is collapsed.
# top-most EditorConfig file
root = true
[*]
end_of_line = lf
insert_final_newline = true
charset = utf-8
indent_style = space
indent_size = 4
[package.json]
indent_style = space
indent_size = 2
/target
/classes
/checkouts
profiles.clj
pom.xml
pom.xml.asc
*.jar
*.class
/.lein-*
/.nrepl-port
.hgignore
.hg/
.idea/
node_modules/
public/
.vscode/
.idea/
*.log
# search
Simple HTML search page,
based on a [lunr](https://lunrjs.com/) search index.
## Dev server configuration
The two environment variables `HOST` and `PORT` are available to customize both values.
To spin it up, run `yarn start`.
## Building and running in production mode
To create an optimised version of the app, run `yarn build`.
{
"name": "@artemix.org/search",
"version": "1.0.0",
"scripts": {
"build": "rollup -c",
"dev": "rollup -c -w",
"start": "sirv public",
"prod": "yarn && yarn build"
},
"devDependencies": {
"@rollup/plugin-node-resolve": "^6.0.0",
"@types/lunr": "^2.3.2",
"rollup": "^1.12.0",
"rollup-plugin-commonjs": "^10.0.0",
"rollup-plugin-livereload": "^1.0.0",
"rollup-plugin-svelte": "^5.0.3",
"rollup-plugin-terser": "^5.1.2",
"svelte": "^3.0.0"
},
"dependencies": {
"lunr": "^2.3.8",
"sirv-cli": "^0.4.4"
}
}
import svelte from 'rollup-plugin-svelte';
import resolve from '@rollup/plugin-node-resolve';
import commonjs from 'rollup-plugin-commonjs';
import livereload from 'rollup-plugin-livereload';
import { terser } from 'rollup-plugin-terser';
import { join } from 'path';
const root = join(__dirname, '../../');
const production = !process.env.ROLLUP_WATCH;
const outputConfigurations = {
dev: {
sourcemap: true,
file: 'public/search.js',
},
production: {
sourcemap: false,
file: `${root}/public/search.js`
}
};
export default {
input: 'src/main.js',
output: {
...outputConfigurations[production ? 'production' : 'dev'],
format: 'iife',
name: 'app',
},
plugins: [
svelte({
// enable run-time checks when not in production
dev: !production,
}),
resolve({
browser: true,
dedupe: importee => importee === 'svelte' || importee.startsWith('svelte/')
}),
commonjs(),
!production && serve(),
!production && livereload('public'),
production && terser()
],
watch: {
clearScreen: false
}
};
function serve() {
let started = false;
return {
writeBundle() {
if (!started) {
started = true;
require('child_process').spawn('npm', ['run', 'start', '--', '--dev'], {
stdio: ['ignore', 'inherit', 'inherit'],
shell: true
});
}
}
};
}
\ No newline at end of file
<script>
import lunr from 'lunr';
import {database, dataset} from './stores';
import Search from './Search.svelte';
/** @var {URLSearchParams} urlParams */
export let urlParams;
export let databasePath;
let databaseLoad = loadDatabase(databasePath);
async function loadDatabase(path) {
const res = await fetch(path);
if (!res.ok) {
console.error(res);
const text = await res.text();
throw new Error(text);
}
const [set, idx] = await res.json();
database.set(lunr.Index.load(idx));
dataset.set(set);
}
function currentSearch() {
return urlParams.get('q') || '';
}
function updateQueryString(event) {
const query = event.detail.query;
// window.location
const url = new URL(window.location.origin + window.location.pathname);
if ('' !== query) {
url.searchParams.append('q', query);
}
window.history.pushState(null, null, url);
}
</script>
<main>
{#await databaseLoad}
<h1>Loading database index...</h1>
{:then _}
<Search search="{currentSearch()}" on:query={updateQueryString}/>
{:catch _}
<h1>Search load error</h1>
<p>An error occured during load. Unfortunately, that means that the search functionality isn't available right
now.</p>
<p>Meanwhile, you may want to check the <a href="/blog/">article list</a> if you want to find an article.</p>
{/await}
</main>
<script>
import {onMount} from 'svelte';
import {database as searchIndex, dataset as articles} from './stores';
import {createEventDispatcher} from 'svelte';
export let search;
let searchBar;
const dispatch = createEventDispatcher();
function searchForTerm(term) {
// First, update the QueryString URL fragment to match the search...
dispatch('query', {
query: term,
});
// ... then, search
try {
if ('' === term) {
document.title = 'Search an article';
return [];
}
document.title = 'Results for "' + term + '"';
return $searchIndex.search(term).map(res => {
return {
...$articles[res.ref],
slug: res.ref,
};
});
} catch (e) {
console.error(e);
return [];
}
}
$: results = searchForTerm(search);
$: resultCount = results.length;
$: articleLabelPlural = resultCount > 1 ? 's' : '';
onMount(() => {
searchBar.focus();
});
</script>
<h1>Search an article (<a href="https://lunrjs.com/guides/searching.html" target="_blank" rel="noopener">help</a>)</h1>
<section style="display: flex;">
<input placeholder="Search an article.."
bind:this={searchBar}
bind:value={search}
style="flex: 1"/>
</section>
<p>{resultCount} article{articleLabelPlural} found</p>
<section class="mono">
{#each results as result, idx}
{#if idx !== 0}
<hr>
{/if}
<p class="date">{result.date} - <a href="/blog/{result.slug}.html">{result.title}</a></p>
{#if result.cat}
<p>Under category: {result.cat}</p>
{/if}
{/each}
</section>
import App from './App.svelte';
const app = new App({
target: document.getElementById('search-app'),
props: {
databasePath: '/blog/articles.idx',
urlParams: new URLSearchParams(window.location.search),
},
});
export default app;
import { writable } from 'svelte/store';
export const database = writable(null);
export const dataset = writable(null);
This diff is collapsed.
#!/usr/bin/env php
<?php
require __DIR__ . '/vendor/autoload.php';
use App\Runner;
use Symfony\Component\Console\Application;
$config = require_once(__DIR__ . '/config.php');
$app = new Application();
$commands = [
'Minify',
'Create\\Key'
];
(new Runner($config))->cli($app, $commands);
import {FileDescriptor, FileManager} from "./file";
import {Dates} from "./dates";
import {Category, RenderedContent, Renderer} from "./renderer";
export interface Article {
file: FileDescriptor,
date: Dates,
slug: string,
raw: string,
rendered: RenderedContent,
}
export interface ByYear {
year: number,
articles: Article[]
}
export interface ByCategory {
category: Category,
articles: Article[],
}
export const groupByYear = (articles: Article[]): ByYear[] =>
articles.reduce((acc: ByYear[], a) => {
let found = acc.find(year => year.year === a.date.year);
if (!found) {
found = {
year: a.date.year,
articles: []
};
acc.push(found);
}
found.articles.push(a);
return acc;
}, [])
.sort((a, b) => b.year - a.year)
.map(year => {
year.articles.sort((a, b) => Dates.compareFn(b.date, a.date));
return year;
});
export const groupByCategory = (articles: Article[]): ByCategory[] =>
articles
.filter(a => !!a.rendered.category)
.reduce((acc: ByCategory[], a) => {
// Cannot be undefined, filtered beforehand
let found = acc
.find(cat => cat.category.slug === a.rendered.category!.slug);
if (!found) {
found = {
category: a.rendered.category!,
articles: [],
};
acc.push(found);
}
found.articles.push(a);
return acc;
}, [])
.map(category => {
category.articles.sort((a, b) => Dates.compareFn(b.date, a.date));
return category;
});
export class ArticleManager {
private articles: Article[];
private wasLoaded: boolean = false;
constructor(private readonly fm: FileManager,
private readonly renderer: Renderer) {
}
async load(): Promise<void> {
const files = (await this.fm.files('articles', /\d{8}-(.+)\.md/))
.map(file => {
return {
file,
date: new Dates(file.name.slice(0, 8)),
slug: file.name.slice(9, file.name.length - 3),
raw: '',
};
});
for (const file of files) {
file.raw = await this.fm.read(`articles/${file.file.name}`);
}
this.articles = files.map(file => {
return {
...file,
rendered: this.renderer.render(file.raw)
};
});
this.wasLoaded = true;
}
get allArticles(): Article[] {
if (!this.wasLoaded) {
throw new Error('Articles not loaded, but requested');
}
return this.articles;
}
get publishedArticles(): Article[] {
return this.allArticles
.filter(article => article.rendered.published);
}
}
export interface Config {
blog: {
title: string,
author: string,
link: string,
debug: boolean,
},
rss: {
link: (input: string) => string,
editor: string,
},
base: {
img: string,
video: string,
}
}
export interface Env {
root: string,
}
// helpers
const _two = (val: number): string => val.toString().padStart(2, '0');
// type
export class Dates {
public readonly date: Date;
public readonly year: number;
public readonly month: number;
public readonly day: number;
constructor(private readonly _date: string) {
this.year = Number.parseInt(_date.slice(0, 4));
this.month = Number.parseInt(_date.slice(4, 6));
this.day = Number.parseInt(_date.slice(6, 8));
this.date = new Date(this.year, this.month, this.day);
}
private get parts(): string[] {
return [
this.year.toString(),
_two(this.month),
_two(this.day),
];
}
get shortened(): string {
return this.parts.slice(1, 3).join(' / ');
}
get full(): string {
return this.parts.join(' / ');
}
get slug(): string {
return this.parts.join('-');
}
/**
* Compare two dates
* @param first
* @param second
*/
static compareFn(first: Dates, second: Dates): -1 | 0 | 1 {
if (first.date < second.date) {
return -1;
} else if (first.date > second.date) {
return 1;
}
return 0;
}
}
import {FileManager} from "./file";
import {Article} from "./article-manager";
import rss from 'rss';
import {Config} from "./config";
import {Category} from "./renderer";
export class FeedManager {
constructor(private readonly fm: FileManager,
private readonly config: Config) {
}
async generate(articles: Article[], category?: Category): Promise<void> {
if (!category) {
category = {
slug: 'all',
label: 'Articles'
}
}
const feed = new rss({
title: `${category.label} - ${this.config.blog.title}`,
feed_url: this.config.rss.link(category.slug),
site_url: this.config.blog.link,
language: 'en',
managingEditor: this.config.rss.editor,
pubDate: new Date(new Date().toUTCString()),
});
articles.forEach(article => feed.item({
guid: article.slug,
title: article.rendered.title,
description: article.rendered.html,
url: `${this.config.blog.link}blog/${article.slug}.html`,
author: this.config.blog.author,
date: article.date.date,
}));
await this.fm.write(`public/blog/feed/${category.slug}.xml`,
feed.xml({indent: true}));
}
}
import {join} from 'path';
import {lstatSync, promises as fs$} from 'fs';
import * as fse$ from 'fs-extra';
export interface FileDescriptor {
name: string,
path: string,
}
export const exists = (path: string): boolean => {
try {
lstatSync(path);
return true;
} catch (_) {
return false;
}
};
export const isDir = (path: string): boolean => {
try {
return lstatSync(path).isDirectory();
} catch (_) {
return false;
}
};
export class FileManager {
private readonly fileEncoding = 'utf-8';
constructor(private readonly root: string) {
}
/**
* Writes a file at the given path with the given content
* @param path relative path at which to store the file. Relative to the
* constructor-bound root
* @param data data to save
*/
async write(path: string, data: string): Promise<void> {
return fs$.writeFile(this.path(path), data, this.fileEncoding);
}
/**
* Reads from a file at the given path, returns a string.
* No validation is done to check if the file exists
* @param path relative path at which to store the file. Relative to the
* constructor-bound root
*/
async read(path: string): Promise<string> {
return fs$.readFile(this.path(path), this.fileEncoding);
}
/**
* List files in directory, with optional regex filter
* @param path
* @param filter
*/
async files(path: string, filter?: RegExp): Promise<FileDescriptor[]> {
const files: FileDescriptor[] = (await fs$.readdir(this.path(path), {
withFileTypes: true,
}))
.filter(dirent => dirent.isFile())
.map(dirent => {
return {
name: dirent.name,
path: join(this.path(path), dirent.name),
};
});
return undefined !== filter
? files.filter(file => file.name.match(filter))
: files;
}
/**
* Lists folders in the given path
* @param path
* @param filter
*/
async dirs(path: string, filter?: RegExp): Promise<FileDescriptor[]> {
const dirs: FileDescriptor[] = (await fs$.readdir(this.path(path), {
withFileTypes: true
}))
.filter(dirent => dirent.isDirectory())
.map(dirent => {
return {
name: dirent.name,
path: this.path(dirent.name),
};
});
return undefined !== filter
? dirs.filter(dir => dir.name.match(filter))
: dirs;
}
/**
* Builds and returns a path relative to the constructor-bound root
* @param path
*/
path(path: string): string {
return join(this.root, path);
}
/**
* Copies the file or directory, paths relative to the constructor-bound
* root
* @param origin
* @param dest
*/
async copy(origin: string, dest: string): Promise<void> {
return fse$.copy(this.path(origin), this.path(dest));
}
async link(origin: string, dest: string): Promise<void> {
if (exists(this.path(dest))) {
// ignore
return;
}
return fse$.symlink(this.path(origin), this.path(dest));
}
/**
* Remove the folder / file at given path, recursively
* @param path
*/
async remove(path: string): Promise<void> {
return fse$.remove(this.path(path));
}
/**
* If the folder at path doesn't exist, create it
* @param path
*/
async create(path: string): Promise<void> {
path = this.path(path);
try {
await fs$.access(path);
} catch {
await fs$.mkdir(path, {recursive: true});
}
}
}
import matter from 'gray-matter';
import {Config} from './config';
import * as marked from 'marked';
import markdown, {Renderer as MarkedRenderer, Slugger} from 'marked';
import {highlight} from 'highlight.js';
import slugify from 'slug';
import {Debugger, default as debug} from "debug";
export type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
export interface RenderedContent {
metadata: { [key: string]: any },
category?: Category,
content: string,
html: string,
title: string,
published: boolean,
headings: Heading[],
references: Reference[],
}
export interface Category {
slug: string,
label: string,
}
export interface Reference {
id: number,
selector: string,
paragraph: string,
}
export interface Heading {
id: string,
level: HeadingLevel,
text: string,
}
export class NoMatterError extends Error {
private static readonly DEFAULT =
`The document doesn't have any front-matter`;
constructor() {
super(NoMatterError.DEFAULT);
}
}
class CustomRenderer extends MarkedRenderer {
private readonly headings: Heading[] = [];
heading(text: string, level: HeadingLevel, raw: string, slugger: Slugger):
string {
const id = slugger.slug(text);
this.headings.push({id, level, text});
return `<h${level} id="${id}"><a href="#${id}">#</a> ${raw}</h${level}>\n`;
}
link(href: string, title: string, text: string): string {
if (href === text) {
return href;
}
return `<a href="${href}" title="${title || text}">${text}</a>`;
}
highlight(code: string, language: string): string {
if (language.length !== 0 && language !== "nohighlight") {
return highlight(language, code).value;
}
return code;
}
get allHeadings(): Heading[] {
return this.headings;
}
}
const ReferenceBlock = /^\^([\w\-]+): (.+)$/gm;
const ReferenceLink = /\[\^([\w\-]+)]/g;
const getEmptyTokensList = (): marked.TokensList => {
const tokens: any = [];
tokens.links = {};
return tokens;
};
export class Renderer {
private readonly rules: { rule: RegExp, to: string }[];
private readonly renderer: CustomRenderer;
private references: Reference[] = [];
private headings: Heading[];
private readonly log: Debugger;
constructor(config: Config) {
this.rules = [
{
rule: /img:([\w\-\/]+)\[(.+)]/gm,
to: `<img src="${config.base.img}/$1.jpeg" alt="$2" />`,
},
{
rule: /video:([\w\-/]+)/g,
to: `<video controls class="full-width">
<source src="${config.base.video}/$1.webm" type="video/webm">
<source src="${config.base.video}/$1.mp4" type="video/mp4">
<p>
Your browser sadly doesn't support videos. You can directly
download the video
<a href="${config.base.video}/$1.mp4">here</a>
</p>.
</video>`,
}
];
this.renderer = new CustomRenderer();
marked.setOptions({
renderer: this.renderer,
highlight: this.renderer.highlight,
mangle: false,
});
this.log = debug('renderer');
}
private customTags(input: string): string {
for (const rule of this.rules) {
input = input.replace(rule.rule, rule.to);
}
return input;
}
private markdown(input: string): string {
const editedTokens = getEmptyTokensList();
// lexing
{
const tokens = marked.lexer(input);
editedTokens.links = tokens.links;
for (const token of tokens) {
if (token.type !== 'paragraph'
|| !token.text.match(ReferenceBlock)) {
editedTokens.push(token);
continue;
}
let matches: RegExpExecArray | null = null;
while ((matches = ReferenceBlock.exec(token.text)) !== null) {
const selector = matches[1];
this.references.push({
id: this.references.length + 1,
selector,
paragraph:
`${matches[2]} [&larrhk;](#backref:${selector})`,
});
}
}
}
let parsed = marked.parser(editedTokens);
const errors: {
refToUndefinedSelector: string[],
unusedSelector: string[]
} = {
refToUndefinedSelector: [],
unusedSelector: []
};
const usedSelectors: string[] = [];
const transformations: {
mode: 'delete' | 'replace',
startIndex: number,
length: number,
id: number,
selector?: string,
}[] = [];
// parsing
{
let matches: RegExpExecArray | null = null;
while ((matches = ReferenceLink.exec(parsed)) !== null) {
const selector = matches[1];
const reference = this.references
.find(ref => ref.selector === selector);
if (!reference) {
errors.refToUndefinedSelector.push(selector);
transformations.push({
mode: 'delete',
startIndex: matches.index,
length: matches.input.length,
id: 0,
});
continue;
}
usedSelectors.push(selector);
transformations.push({
mode: 'replace',
startIndex: matches.index,
length: matches[0].length,
id: reference.id,
selector: reference.selector,
});
}
}
// post-parsing document rebuild
errors.unusedSelector = this.references
.filter(ref => !usedSelectors
.filter(selector => selector === ref.selector))
.map(ref => ref.selector);
// Inverse-order browse to apply transformations
for (const transformation of transformations
.sort((a, b) => b.id - a.id)) {
const {id, selector} = transformation;
const replacementValue = transformation.mode === 'replace'
? `<sup id="backref:${selector}"><a href="#ref:${selector}">${id}</a></sup>`
: '';
const before = parsed.slice(0, transformation.startIndex);
const after = parsed.slice(transformation.startIndex
+ transformation.length, parsed.length);
parsed = before + replacementValue + after;
}
if (0 !== errors.refToUndefinedSelector.length) {
// TODO pass article name or file descriptor for logging
this.log(`Reference to undefined selector errors in article`);
this.log(errors.refToUndefinedSelector);
}
if (0 !== errors.unusedSelector.length) {
// TODO pass article name or file descriptor for logging
this.log(`Found unused selectors in article`);
this.log(errors.unusedSelector);
}
return parsed;
}
render(raw: string): RenderedContent {
this.references = [];
if (!matter.test(raw)) {
throw new NoMatterError();
}
const frontd = matter(raw);
const res: RenderedContent = {
// Raw, need to convert it to HTML
content: frontd.content,
html: '',
title: frontd.data.title,
metadata: frontd.data,
headings: [],
references: [],
published: !!(frontd.data.published || false),
};
if ('category' in res.metadata) {
res.category = {
slug: slugify(res.metadata.category).toLowerCase(),
label: res.metadata.category,
};
}
this.headings = this.renderer.allHeadings;
res.content = this.customTags(res.content);
res.html = this.markdown(res.content);
res.headings = this.headings;
res.references = this.references
.map(ref => {
ref.paragraph = markdown(ref.paragraph);
return ref;
});
return res;
}
}
declare module 'serve-handler' {
import { IncomingMessage, ServerResponse } from 'http'
export interface IHeader {
key: string
value: string
}
export interface IRedirect {
source: string
destination: string
}
export interface IServeHandlerOptions {
public?: string
cleanUrls?: boolean | string[],
rewrites?: IRedirect[],
redirects?: IRedirect[],
headers?: IHeader[],
directoryListing?: boolean | string[],
unlisted?: string[],
trailingSlash?: boolean,
renderSingle?: boolean,
symlinks?: boolean
}
export default function (
request: IncomingMessage,
response: ServerResponse,
options?: IServeHandlerOptions
): void
}
import * as http from "http";
import {IncomingMessage, ServerResponse} from "http";
import {default as serve} from 'serve-handler';
import * as WebSocket from 'ws';
export class Server {
private readonly server: http.Server;
private readonly wss: WebSocket.Server;
constructor(private readonly port: number,
private readonly host: string,
publicFolder: string) {
this.server = http.createServer(
(req: IncomingMessage, res: ServerResponse) =>
serve(req, res, {
public: publicFolder,
directoryListing: false,
rewrites: [
{source: '/', destination: '/index.html'},
{source: '/blog', destination: '/blog/index.html'},
],
}));
this.wss = new WebSocket.Server({server: this.server});
}
run() {
this.server.listen(this.port, this.host, () =>
console.log('Dev server started and listening to '
+ `http://${this.host}:${this.port}/`));
}
async broadcastRefresh(): Promise<void> {
this.wss.clients.forEach(ws => ws.send('refresh'));
}
}
import {Debugger, default as debug, enable} from 'debug';
export abstract class Task {
protected LOG_NAME: string;
abstract async run(): Promise<void>;
protected get log(): Debugger {
enable(this.LOG_NAME);
return debug(this.LOG_NAME);
}
}
export interface Metadata {
styleSRI?: string,
}
import {Task} from "../tasks";
import {ArticleManager, groupByCategory, groupByYear} from "../article-manager";
import {TemplateManager} from "../template-manager";
import {FeedManager} from "../feed-manager";
import {FileManager} from "../file";
export class Articles extends Task {
LOG_NAME = 'articles';
constructor(private readonly tm: TemplateManager,
private readonly am: ArticleManager,
private readonly xm: FeedManager,
private readonly fm: FileManager
) {
super();
}
async run(): Promise<void> {
this.log(`Building everything for ${this.am.allArticles.length} articles...`);
const awaitable: Promise<void>[] = [];
// Render every article
awaitable.push(...this.am.allArticles
.map(article => this.tm.renderArticle(article)));
// Render home page
awaitable.push(this.tm.renderHomePage(this.am.publishedArticles));
// Render categories
// Global category
awaitable.push(this.tm.renderArticleList(
groupByYear(this.am.publishedArticles)));
awaitable.push(this.xm.generate(this.am.publishedArticles));
// categories
groupByCategory(this.am.publishedArticles)
.map(category => {
return {
...category,
byYear: groupByYear(category.articles),
};
})
.forEach(category => {
awaitable.push(this.tm.renderArticleList(category.byYear,
category.category));
awaitable.push(this.xm.generate(category.articles, category.category));
});
awaitable.push(...this.am.allArticles.map(article =>
this.fm.copy(
`articles/${article.file.name}`,
`public/blog/${article.file.name.slice(9, -3)}.txt`
)));
await Promise.all(awaitable);
}
}
import { FileManager } from "../file";
import { Task } from "../tasks";
export class Reset extends Task {
LOG_NAME = 'reset';
constructor(private readonly fm: FileManager) {
super();
}
async run(): Promise<void> {
this.log('Removing potentially existing public folder');
await this.fm.remove('public');
this.log('Creating folder structure');
await Promise.all([
'public', // Data-less and index pages
'public/lists', // Categories
'public/blog', // Blog
'public/blog/feed', // RSS
'public/e', // ?
].map(this.fm.create.bind(this.fm)));
}
}
export class Assets extends Task {
LOG_NAME = 'assets';
constructor(private readonly fm: FileManager) {
super();
}
async run(): Promise<void> {
this.log('Copying assets');
return this.fm.copy('site/assets', 'public');
}
}
import {FileManager} from "../file";
import {createHash} from "crypto";
import {processFiles as uglify} from 'uglifycss';
import {Task} from "../tasks";
import {TemplateManager} from "../template-manager";
// helpers
const sri = async (fm: FileManager, path: string): Promise<string> => {
const hash = createHash('sha384');
const data = await fm.read(path);
hash.update(data);
return hash.digest('base64');
};
const stripComments = (input: string) => input.replace(/\/\*.+?\*\//g, '');
// task
export class CSS extends Task {
LOG_NAME = 'css';
private sources: string[] = [
'site/css/normalize.css',
'site/css/style.css',
'node_modules/highlight.js/styles/atom-one-light.css',
];
private destination = 'public/style.css';
constructor(private readonly fm: FileManager,
private readonly tm: TemplateManager
) {
super();
}
async run(): Promise<void> {
this.log('Minifying CSS...');
let minified = uglify(this.sources.map((source: string) => this.fm.path(source)));
minified = stripComments(minified);
await this.fm.write(this.destination, minified);
this.log('Computing resulting SRI...');
this.tm.sri = await sri(this.fm, this.destination);
}
}
import {FileManager} from '../file';
import {Task} from '../tasks';
import {transformFileAsync} from '@babel/core';
export class JS extends Task {
LOG_NAME = 'js';
constructor(private readonly fm: FileManager) {
super();
}
async run(): Promise<void> {
this.log('Minifying JS...');
const awaitable: Promise<void>[] = [];
const files = await this.fm.files('site/js/');
for (const file of files) {
const result = await transformFileAsync(file.path, {
presets: ['minify'],
comments: false,
});
if (!result || !result.code) {
this.log(`Couldn't minify JS file ${file.name}`);
continue;
}
awaitable.push(this.fm.write(`public/a/${file.name}`, result.code));
}
await Promise.all(awaitable);
}
}
import {Task} from "../tasks";
import {FileManager} from "../file";
import {ArticleManager} from "../article-manager";
import lunr from 'lunr';
// A reduced interface that only contains what's needed for the front-end lunr database
// Keys should be short, but still understandable
interface ReducedArticle {
title: string,
cat: string | null,
date: string,
}