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 } 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'; 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, frontmatter?: FrontMatter) : Context { return { env: state.env, page: frontmatter, base_url: state.conf.base_url, page_url: page_url, site_title: state.conf.title, author: get_author(state, frontmatter), 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, 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 tags = state.conf.templates?.tags; const context = mustache_context(state, out_url.abs_url, frontmatter); const rendered = render_template(text, context, layout, structuredClone(state.partials), tags); await write_text(out_file, rendered); 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) { const author_or_authors = get_author(state, frontmatter); const author = Array.isArray(author_or_authors) ? author_or_authors[0] : author_or_authors; entries.push({ url: out_url.abs_url, in_file: in_file, html_content: text, title: frontmatter?.title, description: frontmatter?.description, author_name: author?.name, tags: frontmatter?.tags, }); } function handle_sitemap(state: BuildState, out_url: OutFileURL, frontmatter: FrontMatter) { 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; state.events.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_time: frontmatter.event?.start_time, end_time: frontmatter.event?.end_time, 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; 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_time: frontmatter.event?.start_time, end_time: frontmatter.event?.end_time, 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 copy_metadata(state: BuildState, in_file: string) { in_file = in_file.slice(state.conf.input.root.length); state.new_metadata.files[in_file] = structuredClone(state.old_metadata?.files?.[in_file]); } export function update_metadata(state: BuildState, in_file: string, hash: string) { in_file = in_file.slice(state.conf.input.root.length); state.new_metadata.files[in_file] = { first_seen_time: state.old_metadata?.files?.[in_file]?.first_seen_time || state.build_time.iso, last_build_hash: hash, last_updated_time: state.build_time.iso, }; } 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] }