426 lines
11 KiB
TypeScript
426 lines
11 KiB
TypeScript
|
|
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<string>;
|
|
env: Record<string, string>;
|
|
partials?: Record<string, string>;
|
|
layouts: Record<string, string>;
|
|
themes: Record<string, ColorTheme>;
|
|
theme_groups: ThemeGroups;
|
|
extras: Record<string, string>;
|
|
made_directories: Set<string>;
|
|
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<string>(),
|
|
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<string>(),
|
|
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<any>[] = [ ];
|
|
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<any>[] = [ ];
|
|
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<any>[] = [ ];
|
|
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<any>[] = [ ];
|
|
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<any>[] = [ ];
|
|
|
|
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<any>[] = [ ];
|
|
|
|
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<any>[] = [ ];
|
|
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;
|
|
};
|
|
},
|
|
}
|
|
};
|
|
}
|