import { ColorTheme } from '@doc-utils/color-themes'; import { build_markdown_from_json_schema } from '@doc-utils/jsonschema2markdown'; import { render_markdown_to_html, render_markdown_to_html_inline_sync } from '@doc-utils/markdown2html'; import { glob } from 'glob'; import { DateTime } from 'luxon'; import { stringify as to_yaml } from 'yaml'; import { promises as fs } from 'fs'; import { dirname, join as path_join } from 'path'; import { icons } from './icons'; import { Config } from './conf'; import { build_env_scope } from './env'; import { load_themes, render_theme_css_properties } from './themes'; import { mkdirp, read_json, read_text, read_yaml, write_text } from './fs'; import { load_layout, Context, render_template, load_partials, load_extras, FrontMatter } from './template'; interface BuildState { conf: Config; seen_files: Set; env: Record; partials?: Record; layouts: Record; themes: Record; theme_groups: ThemeGroups; extras: Record; made_directories: Set; build_time: { iso: string; rfc2822: string; }; } export interface ThemeGroups { all: ColorTheme[]; light: ColorTheme[]; dark: ColorTheme[]; high_contrast: ColorTheme[]; low_contrast: ColorTheme[]; monochrome: ColorTheme[]; greyscale: ColorTheme[]; protanopia_safe: ColorTheme[]; deuteranopia_safe: ColorTheme[]; tritanopia_safe: ColorTheme[]; } export async function build_docs_project(conf: Config) { const now = DateTime.now(); const themes = await load_themes(conf); const state: BuildState = { conf, seen_files: new Set(), env: build_env_scope(conf), layouts: Object.create(null), themes: themes, theme_groups: { // fixme: this is horribly inefficient all: Object.values(themes), light: Object.values(themes).filter((theme) => theme.labels.includes('light')), dark: Object.values(themes).filter((theme) => theme.labels.includes('dark')), high_contrast: Object.values(themes).filter((theme) => theme.labels.includes('high_contrast')), low_contrast: Object.values(themes).filter((theme) => theme.labels.includes('low_contrast')), monochrome: Object.values(themes).filter((theme) => theme.labels.includes('monochrome')), greyscale: Object.values(themes).filter((theme) => theme.labels.includes('greyscale')), protanopia_safe: Object.values(themes).filter((theme) => theme.labels.includes('protanopia_safe')), deuteranopia_safe: Object.values(themes).filter((theme) => theme.labels.includes('deuteranopia_safe')), tritanopia_safe: Object.values(themes).filter((theme) => theme.labels.includes('tritanopia_safe')), }, extras: await load_extras(), made_directories: new Set(), build_time: { iso: now.toISO(), rfc2822: now.toRFC2822(), }, } if (conf.input.raw) { await copy_raw_files(state); } if (conf.input.text) { await render_text_file_templates(state); } if (conf.input.markdown) { await render_markdown_files(state); } if (conf.input['schema+json'] || conf.input['schema+yaml']) { await render_json_schema_files(state); } // todo... } // ===== Raw File Copy ===== async function copy_raw_files(state: BuildState) { const promises: Promise[] = [ ]; const files = await glob(state.conf.input.raw, { absolute: true, cwd: state.conf.input.root, }); for (const in_file of files) { if (state.seen_files.has(in_file)) { continue; } state.seen_files.add(in_file); const out_file = map_input_file_to_output_file(state, in_file); promises.push( fs.copyFile(in_file, await out_file) ); } await Promise.all(promises); } // ===== File Renderers ===== async function render_text_file_templates(state: BuildState) { const promises: Promise[] = [ ]; const files = await glob(state.conf.input.text, { absolute: true, cwd: state.conf.input.root, }); if (! state.partials) { await build_partials(state); } for (const in_file of files) { if (state.seen_files.has(in_file)) { continue; } state.seen_files.add(in_file); promises.push( render_text_file_template(state, in_file) ); } await Promise.all(promises); } async function render_text_file_template(state: BuildState, in_file: string) { const out_file = map_input_file_to_output_file(state, in_file); const { frontmatter, text } = await read_text(in_file); 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, frontmatter); const rendered = render_template(text, context, layout, structuredClone(state.partials), tags); await write_text(await out_file, rendered); } async function render_markdown_files(state: BuildState) { const promises: Promise[] = [ ]; const files = await glob(state.conf.input.markdown, { absolute: true, cwd: state.conf.input.root, }); if (! state.partials) { await build_partials(state); } for (const in_file of files) { if (state.seen_files.has(in_file)) { continue; } state.seen_files.add(in_file); promises.push( render_markdown_file(state, in_file) ); } await Promise.all(promises); } async function render_markdown_file(state: BuildState, in_file: string) { const out_file = map_input_file_to_output_file(state, in_file, [ '.md', '.markdown' ], '.html'); const { frontmatter, text } = await read_text(in_file); const html = render_markdown_to_html(text, state.conf.markdown); 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, frontmatter); const rendered = render_template(await html, context, layout, structuredClone(state.partials), tags); await write_text(await out_file, rendered); } async function render_json_schema_files(state: BuildState) { const promises: Promise[] = [ ]; const json_files = await glob(state.conf.input['schema+json'], { absolute: true, cwd: state.conf.input.root, }); const yaml_files = await glob(state.conf.input['schema+yaml'], { absolute: true, cwd: state.conf.input.root, }); if (! state.partials) { await build_partials(state); } for (const in_file of json_files) { if (state.seen_files.has(in_file)) { continue; } state.seen_files.add(in_file); promises.push( handle_json_schema_json_file(state, in_file) ); } for (const in_file of yaml_files) { if (state.seen_files.has(in_file)) { continue; } state.seen_files.add(in_file); promises.push( handle_json_schema_yaml_file(state, in_file) ); } await Promise.all(promises); } async function handle_json_schema_json_file(state: BuildState, in_file: string) { const promises: Promise[] = [ ]; const out_file = map_input_file_to_output_file(state, in_file, [ '.json' ], '.html'); const { frontmatter, parsed, json } = await read_json(in_file, true); promises.push( render_json_schema(state, parsed, await out_file, frontmatter) ); if (state.conf.output.include_inputs?.includes('schema+json')) { const json_file = await map_input_file_to_output_file(state, in_file); promises.push( write_text(json_file, json) ); if (state.conf.output.include_yaml_and_json) { const yaml_file = await map_input_file_to_output_file(state, in_file, [ '.json' ], '.yaml'); promises.push( write_text(yaml_file, to_yaml(parsed)) ); } } await Promise.all(promises); } async function handle_json_schema_yaml_file(state: BuildState, in_file: string) { const promises: Promise[] = [ ]; const out_file = map_input_file_to_output_file(state, in_file, [ '.yaml', '.yml' ], '.html'); const { frontmatter, parsed, yaml } = await read_yaml(in_file, true); promises.push( render_json_schema(state, parsed, await out_file, frontmatter) ); if (state.conf.output.include_inputs?.includes('schema+yaml')) { const yaml_file = await map_input_file_to_output_file(state, in_file); promises.push( write_text(yaml_file, yaml) ); if (state.conf.output.include_yaml_and_json) { const json_file = await map_input_file_to_output_file(state, in_file, [ '.yaml', '.yml' ], '.json'); promises.push( write_text(json_file, JSON.stringify(parsed, null, ' ')) ); } } await Promise.all(promises); } async function render_json_schema(state: BuildState, schema: unknown, out_file: string, frontmatter?: any) { const promises: Promise[] = [ ]; const markdown = build_markdown_from_json_schema(schema); if (state.conf.output.include_intermediate_markdown) { promises.push( map_input_file_to_output_file(state, out_file, [ '.html' ], '.md') .then((md_file) => write_text(md_file, markdown)) ); } const html = render_markdown_to_html(markdown, state.conf.markdown); 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, frontmatter); const rendered = render_template(await html, context, layout, structuredClone(state.partials), tags); promises.push( write_text(await out_file, rendered) ); await Promise.all(promises); } // ===== Helpers ===== 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; } 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); } function mustache_context(state: BuildState, frontmatter?: FrontMatter) : Context { return { env: state.env, page: frontmatter, build_time: state.build_time, icons: icons, 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, state.conf.markdown); return html; }; }, } }; }