359 lines
10 KiB
TypeScript
359 lines
10 KiB
TypeScript
|
|
import { dirname, join as path_join } from 'path';
|
|
import { icons } from '../icons';
|
|
import { BuildState } from './state';
|
|
import { mkdirp, write_text } from '../fs';
|
|
import { CalendarConfig, RSSConfig } from '../conf';
|
|
import { render_theme_css_properties } from '../themes';
|
|
import { load_partials, FrontMatter, Context, load_layout, render_template, EventFrontmatter } from '../template';
|
|
import { render_markdown_to_html, render_markdown_to_html_inline_sync } from '@doc-utils/markdown2html';
|
|
import { RSSEntry } from './rss';
|
|
import { DateTime } from 'luxon';
|
|
import { EventEntry } from './icalendar';
|
|
import { as_context_time, as_html_time, from_iso } from '../time';
|
|
import { FileMetadata } from '../metadata';
|
|
|
|
export interface OutFileURL {
|
|
base_url: string;
|
|
rel_path: string;
|
|
dir_url: string;
|
|
abs_url: string;
|
|
}
|
|
|
|
export function map_output_file_to_url(state: BuildState, out_file: string, index_file?: string) : OutFileURL {
|
|
let rel_path = out_file.slice(state.conf.input.root.length);
|
|
|
|
if (index_file && rel_path.endsWith('/' + index_file)) {
|
|
rel_path = rel_path.slice(0, -(index_file.length + 1));
|
|
}
|
|
|
|
if (! rel_path.startsWith('/')) {
|
|
rel_path = '/' + rel_path;
|
|
}
|
|
|
|
const base_url = state.conf.base_url.endsWith('/')
|
|
? state.conf.base_url.slice(0, -1)
|
|
: state.conf.base_url;
|
|
|
|
return {
|
|
base_url,
|
|
rel_path,
|
|
dir_url: base_url + dirname(rel_path),
|
|
abs_url: base_url + rel_path
|
|
};
|
|
}
|
|
|
|
export async function map_input_file_to_output_file(state: BuildState, in_file: string, remove_exts?: string[], add_ext?: string) {
|
|
if (! in_file.startsWith(state.conf.input.root)) {
|
|
throw new Error('input file expected to be inside input root');
|
|
}
|
|
|
|
let out_file = path_join(state.conf.output.root, in_file.slice(state.conf.input.root.length));
|
|
|
|
if (remove_exts) {
|
|
for (const ext of remove_exts) {
|
|
if (out_file.endsWith(ext)) {
|
|
out_file = out_file.slice(0, -ext.length);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (add_ext) {
|
|
out_file += add_ext;
|
|
}
|
|
|
|
const dir = dirname(out_file);
|
|
|
|
if (! state.made_directories.has(dir)) {
|
|
state.made_directories.add(dir);
|
|
await mkdirp(dir);
|
|
}
|
|
|
|
return out_file;
|
|
}
|
|
|
|
export async function build_partials(state: BuildState) {
|
|
state.partials = await load_partials(state.conf);
|
|
|
|
for (const [name, theme] of Object.entries(state.themes)) {
|
|
state.partials[`.themes/${name}`] = render_theme_css_properties(theme);
|
|
}
|
|
|
|
Object.assign(state.partials, state.extras);
|
|
}
|
|
|
|
export function mustache_context(state: BuildState, page_url: string, metadata: FileMetadata, frontmatter?: FrontMatter) : Context {
|
|
let event: Context['event'];
|
|
|
|
if (frontmatter?.event) {
|
|
event = Array.isArray(frontmatter.event)
|
|
? frontmatter.event.map(to_context_event)
|
|
: to_context_event(frontmatter.event);
|
|
|
|
function to_context_event(event_fm: EventFrontmatter) {
|
|
const start = from_iso(event_fm.start, event_fm.time_zone);
|
|
const end = from_iso(event_fm.end, event_fm.time_zone);
|
|
|
|
return {
|
|
start: as_context_time(start),
|
|
end: as_context_time(end),
|
|
time_zone: event_fm.time_zone,
|
|
};
|
|
}
|
|
}
|
|
|
|
const has_been_updated = metadata.first_seen_time !== metadata.last_updated_time;
|
|
|
|
return {
|
|
env: state.env,
|
|
page: frontmatter,
|
|
base_url: state.conf.base_url,
|
|
page_url: page_url,
|
|
page_published: as_context_time(from_iso(metadata.first_seen_time), 'dt-published'),
|
|
page_updated: has_been_updated ? as_context_time(from_iso(metadata.last_updated_time), 'dt-updated') : null,
|
|
site_title: state.conf.title,
|
|
author: get_author(state, frontmatter),
|
|
event: event,
|
|
event_series: Array.isArray(event) && {
|
|
start: event[0].start,
|
|
end: event[event.length - 1].end,
|
|
},
|
|
build_time: state.build_time,
|
|
icons: icons,
|
|
rss_feeds: state.conf.rss || [ ],
|
|
calendars: state.conf.calendars || [ ],
|
|
themes: Object.values(state.themes),
|
|
theme_groups: structuredClone(state.theme_groups),
|
|
markdown: {
|
|
render_inline() {
|
|
return (text, render) => {
|
|
const md = render(text)
|
|
const html = render_markdown_to_html_inline_sync(md,
|
|
Object.assign({ }, state.conf.markdown, { base_url: page_url })
|
|
);
|
|
return html;
|
|
};
|
|
},
|
|
}
|
|
};
|
|
}
|
|
|
|
export async function render_page(state: BuildState, in_file: string, out_file: string, out_url: OutFileURL, text: string, render_as_markdown: boolean, hash: string, frontmatter?: any) {
|
|
if (render_as_markdown) {
|
|
const opts = Object.assign({ }, state.conf.markdown, {
|
|
base_url: out_url.abs_url
|
|
});
|
|
|
|
text = await render_markdown_to_html(text, opts);
|
|
}
|
|
|
|
let layout: string;
|
|
const layout_file = frontmatter?.layout;
|
|
|
|
if (layout_file) {
|
|
if (! state.layouts[layout_file]) {
|
|
state.layouts[layout_file] = await load_layout(state.conf, layout_file);
|
|
}
|
|
|
|
layout = state.layouts[layout_file];
|
|
}
|
|
|
|
const hash_matches = file_hash_matches(state, in_file, hash);
|
|
const rel_in_file = in_file.slice(state.conf.input.root.length);
|
|
const old_metadata = state.old_metadata?.files?.[rel_in_file];
|
|
const new_metadata = hash_matches ? structuredClone(old_metadata) : {
|
|
first_seen_time: old_metadata?.first_seen_time || state.build_time.iso,
|
|
last_build_hash: hash,
|
|
last_updated_time: state.build_time.iso,
|
|
};
|
|
|
|
const tags = state.conf.templates?.tags;
|
|
const context = mustache_context(state, out_url.abs_url, new_metadata, frontmatter);
|
|
const rendered = render_template(text, context, layout, structuredClone(state.partials), tags);
|
|
await write_text(out_file, rendered);
|
|
|
|
state.new_metadata.files[rel_in_file] = new_metadata;
|
|
handle_page_side_effects(state, in_file, out_file, out_url, text, frontmatter);
|
|
}
|
|
|
|
function handle_page_side_effects(state: BuildState, in_file: string, out_file: string, out_url: OutFileURL, text: string, frontmatter?: any) {
|
|
// Only actual HTML webpages are registered with things like the RSS feed; This is
|
|
// to prevent things like CSS files showing up, which may be templated (and therefore
|
|
// pass through this function), but are not really "pages"
|
|
if (frontmatter && typeof frontmatter === 'object' && out_file.endsWith('.html')) {
|
|
if (state.conf.rss) {
|
|
for (const [index, rss_conf] of Object.entries(state.conf.rss)) {
|
|
if (in_file.startsWith(rss_conf.in_dir + '/')) {
|
|
const entries = (state.rss[index] = state.rss[index] || [ ]);
|
|
handle_rss(state, rss_conf, entries, in_file, out_url, text, frontmatter);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (state.conf.sitemap) {
|
|
handle_sitemap(state, out_url, frontmatter);
|
|
}
|
|
|
|
if (frontmatter?.event) {
|
|
if (state.conf.events) {
|
|
handle_event(state, in_file, out_url, frontmatter);
|
|
}
|
|
|
|
if (state.conf.calendars) {
|
|
for (const [index, cal_conf] of Object.entries(state.conf.calendars)) {
|
|
if (in_file.startsWith(cal_conf.in_dir + '/')) {
|
|
const entries = (state.rss[index] = state.rss[index] || [ ]);
|
|
handle_calendar(state, cal_conf, entries, in_file, out_url, frontmatter);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function handle_rss(state: BuildState, rss_conf: RSSConfig, entries: RSSEntry[], in_file: string, out_url: OutFileURL, text: string, frontmatter: FrontMatter) {
|
|
if (frontmatter?.rss?.skip) {
|
|
return;
|
|
}
|
|
|
|
const author_or_authors = get_author(state, frontmatter);
|
|
const authors = author_or_authors && (Array.isArray(author_or_authors) ? author_or_authors : [ author_or_authors ]);
|
|
|
|
entries.push({
|
|
url: out_url.abs_url,
|
|
in_file: in_file,
|
|
html_content: text,
|
|
title: frontmatter?.title,
|
|
description: frontmatter?.description,
|
|
authors: authors,
|
|
tags: frontmatter?.tags,
|
|
});
|
|
}
|
|
|
|
function handle_sitemap(state: BuildState, out_url: OutFileURL, frontmatter: FrontMatter) {
|
|
if (frontmatter?.sitemap?.skip) {
|
|
return;
|
|
}
|
|
|
|
state.sitemap.push({
|
|
url: out_url.abs_url,
|
|
lastmod: state.build_time.iso,
|
|
change_freq: frontmatter?.sitemap?.change_freq,
|
|
priority: frontmatter?.sitemap?.priority,
|
|
});
|
|
}
|
|
|
|
function handle_event(state: BuildState, in_file: string, out_url: OutFileURL, frontmatter: FrontMatter) {
|
|
if (! state.conf.events) {
|
|
return;
|
|
}
|
|
|
|
const author_or_authors = get_author(state, frontmatter);
|
|
const author = Array.isArray(author_or_authors) ? author_or_authors[0] : author_or_authors;
|
|
|
|
if (Array.isArray(frontmatter.event)) {
|
|
state.event_series.push({
|
|
url: out_url.abs_url,
|
|
in_file: in_file,
|
|
title: frontmatter.title,
|
|
description: frontmatter.description,
|
|
author_name: author?.name,
|
|
author_email: author?.email,
|
|
entries: frontmatter.event.slice(),
|
|
});
|
|
|
|
return;
|
|
}
|
|
|
|
state.events.push({
|
|
url: out_url.abs_url,
|
|
in_file: in_file,
|
|
title: frontmatter.event.title || frontmatter.title,
|
|
description: frontmatter.description,
|
|
author_name: author?.name,
|
|
author_email: author?.email,
|
|
start: frontmatter.event.start,
|
|
end: frontmatter.event.end,
|
|
time_zone: frontmatter.event.time_zone,
|
|
});
|
|
}
|
|
|
|
function handle_calendar(state: BuildState, cal_conf: CalendarConfig, entries: EventEntry[], in_file: string, out_url: OutFileURL, frontmatter: FrontMatter) {
|
|
const author_or_authors = get_author(state, frontmatter);
|
|
const author = Array.isArray(author_or_authors) ? author_or_authors[0] : author_or_authors;
|
|
|
|
if (Array.isArray(frontmatter.event)) {
|
|
// todo: add each event to the calendar
|
|
|
|
return;
|
|
}
|
|
|
|
entries.push({
|
|
url: out_url.abs_url,
|
|
in_file: in_file,
|
|
title: frontmatter.title,
|
|
description: frontmatter.description,
|
|
author_name: author?.name,
|
|
author_email: author?.email,
|
|
start: frontmatter.event?.start,
|
|
end: frontmatter.event?.end,
|
|
time_zone: frontmatter.event?.time_zone,
|
|
});
|
|
}
|
|
|
|
export function config_hash_matches(state: BuildState) {
|
|
const { old_metadata, new_metadata } = state;
|
|
|
|
if (! old_metadata.last_build?.config_hash) {
|
|
return false;
|
|
}
|
|
|
|
if (old_metadata.last_build.config_hash !== new_metadata.last_build.config_hash) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
export function file_hash_matches(state: BuildState, in_file: string, new_hash: string) {
|
|
in_file = in_file.slice(state.conf.input.root.length);
|
|
const old_hash = state.old_metadata.files[in_file]?.last_build_hash;
|
|
|
|
if (! old_hash) {
|
|
return false;
|
|
}
|
|
|
|
if (old_hash !== new_hash) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
export function skip_file(state: BuildState, in_file: string, out_file: string, out_url: OutFileURL, frontmatter?: any) {
|
|
in_file = in_file.slice(state.conf.input.root.length);
|
|
state.new_metadata.files[in_file] = structuredClone(state.old_metadata?.files?.[in_file]);
|
|
handle_page_side_effects(state, in_file, out_file, out_url, frontmatter);
|
|
}
|
|
|
|
export function get_author(state: BuildState, frontmatter?: FrontMatter) {
|
|
if (! frontmatter?.author) {
|
|
return null;
|
|
}
|
|
|
|
if (Array.isArray(frontmatter.author)) {
|
|
const list = frontmatter.author
|
|
.map((author) => state.conf.authors?.[author])
|
|
.filter((author) => author);
|
|
|
|
if (! list.length) {
|
|
return null;
|
|
}
|
|
|
|
return list;
|
|
}
|
|
|
|
return state.conf.authors?.[frontmatter.author]
|
|
}
|