+301
-209
@@ -1,16 +1,18 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { escapeHtml, h, raw, render } from './html.js';
|
||||
import { marked } from 'marked';
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { escapeHtml, h, raw, render } from './html.js'
|
||||
import { marked } from 'marked'
|
||||
|
||||
const siteName = 'PaulW.XYZ'
|
||||
const output = '.generated'
|
||||
const markdownDirs = ['notes', 'posts']
|
||||
|
||||
const siteName = 'PaulW.XYZ';
|
||||
const output = '.generated';
|
||||
const markdownDirs = ['notes', 'posts'];
|
||||
const standalonePages = [
|
||||
{ source: 'README.md', path: 'about', title: 'About' },
|
||||
{ source: 'THIRD_PARTY_LICENSES.md', path: 'licenses', title: 'Licenses' },
|
||||
];
|
||||
]
|
||||
|
||||
const languageLabels = {
|
||||
ini: 'INI',
|
||||
js: 'JavaScript',
|
||||
@@ -18,213 +20,269 @@ const languageLabels = {
|
||||
lua: 'Lua',
|
||||
sh: 'Shell',
|
||||
bash: 'Shell',
|
||||
};
|
||||
}
|
||||
|
||||
const slug = (value) => {
|
||||
let result = '';
|
||||
let dash = false;
|
||||
let result = ''
|
||||
let dash = false
|
||||
for (const character of String(value).toLowerCase()) {
|
||||
const valid = (character >= 'a' && character <= 'z') || (character >= '0' && character <= '9');
|
||||
if (valid) { if (dash && result) result += '-'; result += character; dash = false; }
|
||||
else if (result) dash = true;
|
||||
const valid = (character >= 'a' && character <= 'z') || (character >= '0' && character <= '9')
|
||||
if (valid) {
|
||||
if (dash && result) result += '-'
|
||||
result += character
|
||||
dash = false
|
||||
} else if (result) dash = true
|
||||
}
|
||||
return result;
|
||||
};
|
||||
const headingLabel = (tokens) => tokens.map((token) => {
|
||||
if (token.type === 'image') return token.text ?? '';
|
||||
if (token.tokens) return headingLabel(token.tokens);
|
||||
return token.text ?? token.raw ?? '';
|
||||
}).join('').trim();
|
||||
return result
|
||||
}
|
||||
|
||||
const headingLabel = (tokens) =>
|
||||
tokens
|
||||
.map((token) => {
|
||||
if (token.type === 'image') return token.text ?? ''
|
||||
if (token.tokens) return headingLabel(token.tokens)
|
||||
return token.text ?? token.raw ?? ''
|
||||
})
|
||||
.join('')
|
||||
.trim()
|
||||
|
||||
const createSlugger = () => {
|
||||
const counts = new Map();
|
||||
const counts = new Map()
|
||||
return (tokens) => {
|
||||
const base = slug(headingLabel(tokens)) || 'section';
|
||||
const count = counts.get(base) ?? 0;
|
||||
counts.set(base, count + 1);
|
||||
return count === 0 ? base : `${base}-${count + 1}`;
|
||||
};
|
||||
};
|
||||
const base = slug(headingLabel(tokens)) || 'section'
|
||||
const count = counts.get(base) ?? 0
|
||||
counts.set(base, count + 1)
|
||||
return count === 0 ? base : `${base}-${count + 1}`
|
||||
}
|
||||
}
|
||||
|
||||
const pageShell = (title, body, path = '/', navTitle = '') => `<!DOCTYPE html>${render(h('html', { lang: 'en-US' }, [
|
||||
h('head', {}, [
|
||||
h('meta', { charset: 'utf-8' }),
|
||||
h('meta', { name: 'viewport', content: 'width=device-width, initial-scale=1' }),
|
||||
h('title', {}, [title ? `${title} | ${siteName}` : siteName]),
|
||||
h('link', { rel: 'icon', href: '/favicon.ico' }),
|
||||
h('link', { rel: 'stylesheet', href: '/src/global.css' }),
|
||||
h('script', { type: 'module', src: '/src/main.js' }),
|
||||
]),
|
||||
h('body', {}, [raw(body), h('nav', { 'aria-label': 'Breadcrumb' }, [raw(nav(path, navTitle))])]),
|
||||
]))}`;
|
||||
const pageShell = (title, body, path = '/', navTitle = '') =>
|
||||
`<!DOCTYPE html>${render(
|
||||
h('html', { lang: 'en-US' }, [
|
||||
h('head', {}, [
|
||||
h('meta', { charset: 'utf-8' }),
|
||||
h('meta', {
|
||||
name: 'viewport',
|
||||
content: 'width=device-width, initial-scale=1',
|
||||
}),
|
||||
h('title', {}, [title ? `${title} | ${siteName}` : siteName]),
|
||||
h('link', { rel: 'icon', href: '/favicon.ico' }),
|
||||
h('link', { rel: 'stylesheet', href: '/src/global.css' }),
|
||||
h('script', { type: 'module', src: '/src/main.js' }),
|
||||
]),
|
||||
h('body', {}, [raw(body), h('nav', { 'aria-label': 'Breadcrumb' }, [raw(nav(path, navTitle))])]),
|
||||
]),
|
||||
)}`
|
||||
|
||||
const nav = (path, currentTitle = '') => {
|
||||
if (path === '/') {
|
||||
if (!currentTitle) return render(h('span', {}, [siteName]));
|
||||
return render([h('a', { href: '/' }, [siteName]), ' / ', currentTitle]);
|
||||
if (!currentTitle) return render(h('span', {}, [siteName]))
|
||||
return render([h('a', { href: '/' }, [siteName]), ' / ', currentTitle])
|
||||
}
|
||||
const parts = path.split('/').filter(Boolean);
|
||||
const children = [h('a', { href: '/' }, [siteName])];
|
||||
const sectionTitle = { notes: 'Notes', posts: 'Posts', about: 'About', licenses: 'Licenses' }[parts[0]] ?? parts[0];
|
||||
if (parts.length >= 1) children.push(' / ', parts.length > 1 ? h('a', { href: `/${parts[0]}/` }, [sectionTitle]) : sectionTitle);
|
||||
if (parts.length > 1) children.push(' / ', currentTitle);
|
||||
return render(children);
|
||||
};
|
||||
const parts = path.split('/').filter(Boolean)
|
||||
const children = [h('a', { href: '/' }, [siteName])]
|
||||
const sectionTitle = { notes: 'Notes', posts: 'Posts', about: 'About', licenses: 'Licenses' }[parts[0]] ?? parts[0]
|
||||
if (parts.length >= 1)
|
||||
children.push(' / ', parts.length > 1 ? h('a', { href: `/${parts[0]}/` }, [sectionTitle]) : sectionTitle)
|
||||
if (parts.length > 1) children.push(' / ', currentTitle)
|
||||
return render(children)
|
||||
}
|
||||
|
||||
const dateFor = async (file) => {
|
||||
try {
|
||||
const gitDate = execFileSync('git', ['log', '-1', '--date=format-local:%Y-%m-%dT%H:%M:%SZ', '--format=%ad', '--', file], { env: { ...process.env, TZ: 'UTC' } }).toString().trim();
|
||||
if (gitDate) return gitDate;
|
||||
const gitDate = execFileSync(
|
||||
'git',
|
||||
['log', '-1', '--date=format-local:%Y-%m-%dT%H:%M:%SZ', '--format=%ad', '--', file],
|
||||
{ env: { ...process.env, TZ: 'UTC' } },
|
||||
)
|
||||
.toString()
|
||||
.trim()
|
||||
if (gitDate) return gitDate
|
||||
} catch {}
|
||||
try { return (await stat(file)).mtime.toISOString(); }
|
||||
catch { return ''; }
|
||||
};
|
||||
try {
|
||||
return (await stat(file)).mtime.toISOString()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
const readPages = async (dir) => {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
const entries = await readdir(dir, { withFileTypes: true })
|
||||
const markdownEntries = entries
|
||||
.filter((entry) => entry.isFile() && entry.name.endsWith('.md') && !entry.name.startsWith('.'))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
const pages = await Promise.all(markdownEntries.map(async (entry) => {
|
||||
const file = join(dir, entry.name);
|
||||
const source = await readFile(file, 'utf8');
|
||||
const titleLine = source.split(/\r?\n/).find((line) => line.startsWith('# '));
|
||||
if (!titleLine) return undefined;
|
||||
return {
|
||||
name: entry.name.slice(0, -3),
|
||||
title: stripSidenotes(titleLine.slice(2).trim()),
|
||||
file,
|
||||
source,
|
||||
date: await dateFor(file),
|
||||
};
|
||||
}));
|
||||
return pages.filter(Boolean);
|
||||
};
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
const pages = await Promise.all(
|
||||
markdownEntries.map(async (entry) => {
|
||||
const file = join(dir, entry.name)
|
||||
const source = await readFile(file, 'utf8')
|
||||
const titleLine = source.split(/\r?\n/).find((line) => line.startsWith('# '))
|
||||
if (!titleLine) return undefined
|
||||
return {
|
||||
name: entry.name.slice(0, -3),
|
||||
title: stripSidenotes(titleLine.slice(2).trim()),
|
||||
file,
|
||||
source,
|
||||
date: await dateFor(file),
|
||||
}
|
||||
}),
|
||||
)
|
||||
return pages.filter(Boolean)
|
||||
}
|
||||
|
||||
let nextHeadingId = createSlugger();
|
||||
let sidenoteNumber = 0;
|
||||
let nextHeadingId = createSlugger()
|
||||
let sidenoteNumber = 0
|
||||
|
||||
marked.use({
|
||||
extensions: [{
|
||||
name: 'sidenote',
|
||||
level: 'inline',
|
||||
start: (source) => source.indexOf('^['),
|
||||
tokenizer(source) {
|
||||
if (!source.startsWith('^[')) return undefined;
|
||||
let depth = 1;
|
||||
for (let index = 2; index < source.length; index += 1) {
|
||||
if (source[index] === '\\') {
|
||||
index += 1;
|
||||
} else if (source[index] === '[') {
|
||||
depth += 1;
|
||||
} else if (source[index] === ']') {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
const text = source.slice(2, index);
|
||||
return {
|
||||
type: 'sidenote',
|
||||
raw: source.slice(0, index + 1),
|
||||
tokens: this.lexer.inlineTokens(text),
|
||||
};
|
||||
extensions: [
|
||||
{
|
||||
name: 'sidenote',
|
||||
level: 'inline',
|
||||
start: (source) => source.indexOf('^['),
|
||||
tokenizer(source) {
|
||||
if (!source.startsWith('^[')) return undefined
|
||||
let depth = 1
|
||||
for (let index = 2; index < source.length; index += 1) {
|
||||
if (source[index] === '\\') {
|
||||
index += 1
|
||||
} else if (source[index] === '[') {
|
||||
depth += 1
|
||||
} else if (source[index] === ']') {
|
||||
depth -= 1
|
||||
if (depth === 0) {
|
||||
const text = source.slice(2, index)
|
||||
return {
|
||||
type: 'sidenote',
|
||||
raw: source.slice(0, index + 1),
|
||||
tokens: this.lexer.inlineTokens(text),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
return undefined
|
||||
},
|
||||
renderer({ tokens }) {
|
||||
sidenoteNumber += 1
|
||||
const id = `sidenote-${sidenoteNumber}`
|
||||
const content = this.parser.parseInline(tokens)
|
||||
return `<input type="checkbox" id="${id}" class="sidenote-toggle visually-hidden"><label for="${id}" class="margin-toggle sidenote-number"><span class="visually-hidden">Toggle sidenote ${sidenoteNumber}</span></label><span class="sidenote">${content}</span>`
|
||||
},
|
||||
},
|
||||
renderer({ tokens }) {
|
||||
sidenoteNumber += 1;
|
||||
const id = `sidenote-${sidenoteNumber}`;
|
||||
const content = this.parser.parseInline(tokens);
|
||||
return `<input type="checkbox" id="${id}" class="sidenote-toggle visually-hidden"><label for="${id}" class="margin-toggle sidenote-number"><span class="visually-hidden">Toggle sidenote ${sidenoteNumber}</span></label><span class="sidenote">${content}</span>`;
|
||||
},
|
||||
}],
|
||||
],
|
||||
renderer: {
|
||||
code({ text, lang, escaped }) {
|
||||
const language = lang?.trim().split(/\s+/, 1)[0].toLowerCase() || 'plain';
|
||||
const label = languageLabels[language]
|
||||
?? language.charAt(0).toUpperCase() + language.slice(1);
|
||||
const source = escaped ? text : escapeHtml(text.replace(/\n$/, ''));
|
||||
return `<div class="code-block"><div class="code-block-header"><span>${escapeHtml(label)}</span><button type="button" class="copy-code" aria-label="Copy ${escapeHtml(label)} code" aria-live="polite">Copy</button></div><pre><code class="language-${escapeHtml(language)}">${source}\n</code></pre></div>\n`;
|
||||
const language = lang?.trim().split(/\s+/, 1)[0].toLowerCase() || 'plain'
|
||||
const label = languageLabels[language] ?? language.charAt(0).toUpperCase() + language.slice(1)
|
||||
const source = escaped ? text : escapeHtml(text.replace(/\n$/, ''))
|
||||
return `<div class="code-block"><div class="code-block-header"><span>${escapeHtml(label)}</span><button type="button" class="copy-code" aria-label="Copy ${escapeHtml(label)} code" aria-live="polite">Copy</button></div><pre><code class="language-${escapeHtml(language)}">${source}\n</code></pre></div>\n`
|
||||
},
|
||||
heading({ tokens, depth }) {
|
||||
const text = this.parser.parseInline(tokens);
|
||||
const id = nextHeadingId(tokens);
|
||||
return `<h${depth} id="${id}">${text}</h${depth}>\n`;
|
||||
const text = this.parser.parseInline(tokens)
|
||||
const id = nextHeadingId(tokens)
|
||||
return `<h${depth} id="${id}">${text}</h${depth}>\n`
|
||||
},
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
const withoutTitle = (source) => {
|
||||
const lines = source.split('\n');
|
||||
const index = lines.findIndex((line) => line.startsWith('# '));
|
||||
if (index >= 0) lines.splice(index, 1);
|
||||
return lines.join('\n');
|
||||
};
|
||||
const lines = source.split('\n')
|
||||
const index = lines.findIndex((line) => line.startsWith('# '))
|
||||
if (index >= 0) lines.splice(index, 1)
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
const stripSidenotes = (text) => {
|
||||
let result = '';
|
||||
let result = ''
|
||||
for (let index = 0; index < text.length;) {
|
||||
if (text[index] === '^' && text[index + 1] === '[') {
|
||||
const end = text.indexOf(']', index + 2);
|
||||
if (end >= 0) { index = end + 1; continue; }
|
||||
const end = text.indexOf(']', index + 2)
|
||||
if (end >= 0) {
|
||||
index = end + 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
result += text[index++];
|
||||
result += text[index++]
|
||||
}
|
||||
return result;
|
||||
};
|
||||
const stripHeadingSidenotes = (source) => source.split('\n').map((line) => {
|
||||
const content = line.trimStart();
|
||||
return content.startsWith('# ') || content.startsWith('## ') || content.startsWith('### ') || content.startsWith('#### ') || content.startsWith('##### ') || content.startsWith('###### ')
|
||||
? line.slice(0, line.length - content.length) + stripSidenotes(content)
|
||||
: line;
|
||||
}).join('\n');
|
||||
return result
|
||||
}
|
||||
|
||||
const stripHeadingSidenotes = (source) =>
|
||||
source
|
||||
.split('\n')
|
||||
.map((line) => {
|
||||
const content = line.trimStart()
|
||||
return content.startsWith('# ') ||
|
||||
content.startsWith('## ') ||
|
||||
content.startsWith('### ') ||
|
||||
content.startsWith('#### ') ||
|
||||
content.startsWith('##### ') ||
|
||||
content.startsWith('###### ')
|
||||
? line.slice(0, line.length - content.length) + stripSidenotes(content)
|
||||
: line
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
const renderMarkdown = (source) => {
|
||||
nextHeadingId = createSlugger();
|
||||
sidenoteNumber = 0;
|
||||
const html = marked.parse(withoutTitle(stripHeadingSidenotes(source)));
|
||||
let result = '';
|
||||
let cursor = 0;
|
||||
nextHeadingId = createSlugger()
|
||||
sidenoteNumber = 0
|
||||
const html = marked.parse(withoutTitle(stripHeadingSidenotes(source)))
|
||||
let result = ''
|
||||
let cursor = 0
|
||||
while (cursor < html.length) {
|
||||
const start = html.indexOf('<table', cursor);
|
||||
if (start < 0) { result += html.slice(cursor); break; }
|
||||
const end = html.indexOf('</table>', start);
|
||||
if (end < 0) { result += html.slice(cursor); break; }
|
||||
result += html.slice(cursor, start);
|
||||
result += `<div class="table-scroll">${html.slice(start, end + 8)}</div>`;
|
||||
cursor = end + 8;
|
||||
const start = html.indexOf('<table', cursor)
|
||||
if (start < 0) {
|
||||
result += html.slice(cursor)
|
||||
break
|
||||
}
|
||||
const end = html.indexOf('</table>', start)
|
||||
if (end < 0) {
|
||||
result += html.slice(cursor)
|
||||
break
|
||||
}
|
||||
result += html.slice(cursor, start)
|
||||
result += `<div class="table-scroll">${html.slice(start, end + 8)}</div>`
|
||||
cursor = end + 8
|
||||
}
|
||||
return result;
|
||||
};
|
||||
return result
|
||||
}
|
||||
|
||||
const toc = (source) => {
|
||||
const headings = marked.lexer(withoutTitle(stripHeadingSidenotes(source))).filter((token) => token.type === 'heading');
|
||||
if (headings.length === 0) return '';
|
||||
const root = { depth: 0, children: [] };
|
||||
const stack = [root];
|
||||
const headingId = createSlugger();
|
||||
const headings = marked.lexer(withoutTitle(stripHeadingSidenotes(source))).filter((token) => token.type === 'heading')
|
||||
if (headings.length === 0) return ''
|
||||
const root = { depth: 0, children: [] }
|
||||
const stack = [root]
|
||||
const headingId = createSlugger()
|
||||
for (const heading of headings) {
|
||||
while (stack.length > 1 && stack.at(-1).depth >= heading.depth) stack.pop();
|
||||
const node = { depth: heading.depth, heading, children: [] };
|
||||
stack.at(-1).children.push(node);
|
||||
stack.push(node);
|
||||
while (stack.length > 1 && stack.at(-1).depth >= heading.depth) stack.pop()
|
||||
const node = { depth: heading.depth, heading, children: [] }
|
||||
stack.at(-1).children.push(node)
|
||||
stack.push(node)
|
||||
}
|
||||
const renderItems = (items) => `<ol>${items.map(({ heading, children }) => {
|
||||
const id = headingId(heading.tokens);
|
||||
const nested = children.length > 0 ? renderItems(children) : '';
|
||||
return `<li><a href="#${id}">${escapeHtml(headingLabel(heading.tokens))}</a>${nested}</li>`;
|
||||
}).join('')}</ol>`;
|
||||
return renderItems(root.children);
|
||||
};
|
||||
const renderItems = (items) =>
|
||||
`<ol>${items
|
||||
.map(({ heading, children }) => {
|
||||
const id = headingId(heading.tokens)
|
||||
const nested = children.length > 0 ? renderItems(children) : ''
|
||||
return `<li><a href="#${id}">${escapeHtml(headingLabel(heading.tokens))}</a>${nested}</li>`
|
||||
})
|
||||
.join('')}</ol>`
|
||||
return renderItems(root.children)
|
||||
}
|
||||
|
||||
const writePage = async (path, html) => {
|
||||
const directory = join(output, path);
|
||||
await mkdir(directory, { recursive: true });
|
||||
await writeFile(join(directory, 'index.html'), html);
|
||||
};
|
||||
const directory = join(output, path)
|
||||
await mkdir(directory, { recursive: true })
|
||||
await writeFile(join(directory, 'index.html'), html)
|
||||
}
|
||||
|
||||
const titleHeading = (title, className = '') => `
|
||||
<header class="page-heading${className ? ` ${className}` : ''}">
|
||||
<h1 class="title">${escapeHtml(title)}</h1>
|
||||
</header>`;
|
||||
</header>`
|
||||
|
||||
const renderHome = (externalLinks, sectionLinks, recentLinks) => pageShell('', `
|
||||
const renderHome = (externalLinks, sectionLinks, recentLinks) =>
|
||||
pageShell(
|
||||
'',
|
||||
`
|
||||
${titleHeading(siteName)}
|
||||
<main class="container">
|
||||
<section class="block">${sectionLinks}${externalLinks}</section>
|
||||
@@ -232,97 +290,131 @@ const renderHome = (externalLinks, sectionLinks, recentLinks) => pageShell('', `
|
||||
<h2>Recent</h2>
|
||||
<ul>${recentLinks || '<li>Nothing to show yet.</li>'}</ul>
|
||||
</section>
|
||||
</main>`);
|
||||
</main>`,
|
||||
)
|
||||
|
||||
const renderNotesTable = (pages, directory) => {
|
||||
const rows = pages.map((page) => `
|
||||
const rows = pages
|
||||
.map(
|
||||
(page) => `
|
||||
<tr>
|
||||
<td><a href="/${directory}/${page.name}/">${escapeHtml(page.title)}</a></td>
|
||||
<td>${page.date ? `<time class="table-last-updated" datetime="${escapeHtml(page.date)}" data-value="${escapeHtml(page.date)}">${escapeHtml(page.date)}</time>` : ''}</td>
|
||||
</tr>`).join('');
|
||||
return `<table class="${directory}-table"><caption class="visually-hidden">${escapeHtml(directory)} pages</caption><thead><tr><th scope="col">Page</th><th scope="col">Last updated</th></tr></thead><tbody>${rows}</tbody></table>`;
|
||||
};
|
||||
</tr>`,
|
||||
)
|
||||
.join('')
|
||||
return `<table class="${directory}-table"><caption class="visually-hidden">${escapeHtml(directory)} pages</caption><tbody>${rows}</tbody></table>`
|
||||
}
|
||||
|
||||
const renderPostsList = (pages, directory) => {
|
||||
const items = pages.map((page) => `
|
||||
const items = pages
|
||||
.map(
|
||||
(page) => `
|
||||
<li>
|
||||
<a href="/${directory}/${page.name}/">${escapeHtml(page.title)}</a>
|
||||
${page.date ? `<time datetime="${escapeHtml(page.date)}" data-value="${escapeHtml(page.date)}">${escapeHtml(page.date)}</time>` : ''}
|
||||
</li>`).join('');
|
||||
return `<ul class="posts-list">${items}</ul>`;
|
||||
};
|
||||
</li>`,
|
||||
)
|
||||
.join('')
|
||||
return `<ul class="posts-list">${items}</ul>`
|
||||
}
|
||||
|
||||
const renderEmptySection = () => `
|
||||
<div class="text center">
|
||||
<p><strong>crickets</strong></p>
|
||||
<p>**crickets**</p>
|
||||
<p>Nothing to list...</p>
|
||||
<a class="link button back" href="/">Go Home</a>
|
||||
</div>`;
|
||||
</div>`
|
||||
|
||||
const renderNotFound = () =>
|
||||
pageShell(
|
||||
'Page not found',
|
||||
`${titleHeading('404: Page Not Found')}<main class="container"><section class="block text center"><p><strong>Uh oh! The page you are looking for does not exist...</strong></p><a class="link button back" href="/">Go Home</a><a class='button blue link extern' href='https://en.wikipedia.org/wiki/HTTP_404'>More on 404</a></section></main>`,
|
||||
'/',
|
||||
' ... ??? / 404: Page Not Found',
|
||||
)
|
||||
|
||||
const renderSection = (directory, pages) => {
|
||||
const title = directory[0].toUpperCase() + directory.slice(1);
|
||||
const listing = pages.length === 0
|
||||
? renderEmptySection()
|
||||
: directory === 'posts'
|
||||
? renderPostsList(pages, directory)
|
||||
: renderNotesTable(pages, directory);
|
||||
const title = directory[0].toUpperCase() + directory.slice(1)
|
||||
const listing =
|
||||
pages.length === 0
|
||||
? renderEmptySection()
|
||||
: directory === 'posts'
|
||||
? renderPostsList(pages, directory)
|
||||
: renderNotesTable(pages, directory)
|
||||
|
||||
return pageShell(
|
||||
title,
|
||||
`${titleHeading(title)}<main class="container"><section class="block">${listing}</section></main>`,
|
||||
`/${directory}`,
|
||||
title,
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
const renderArticle = (directory, page, navigationPath = `/${directory}/${page.name}`) => {
|
||||
const updated = page.date
|
||||
? `<time class="last-updated" datetime="${escapeHtml(page.date)}" data-value="${escapeHtml(page.date)}">Last updated: ${escapeHtml(page.date)}</time>`
|
||||
: '';
|
||||
const heading = titleHeading(page.title, `${directory}-heading`);
|
||||
const tableOfContents = toc(page.source);
|
||||
: ''
|
||||
const heading = titleHeading(page.title, `${directory}-heading`)
|
||||
const tableOfContents = toc(page.source)
|
||||
const contents = tableOfContents
|
||||
? `<section class="block toc"><details open><summary>Contents</summary>${tableOfContents}</details></section><hr>`
|
||||
: '';
|
||||
const article = `<section class="block">${renderMarkdown(page.source)}</section>`;
|
||||
const body = `${heading}<main class="article-page ${directory}-page container">${updated}${contents}${article}</main>`;
|
||||
: ''
|
||||
const article = `<section class="block">${renderMarkdown(page.source)}</section>`
|
||||
const body = `${heading}<main class="article-page ${directory}-page container">${updated}${contents}${article}</main>`
|
||||
|
||||
return pageShell(page.title, body, navigationPath, page.title);
|
||||
};
|
||||
return pageShell(page.title, body, navigationPath, page.title)
|
||||
}
|
||||
|
||||
if (process.argv.includes('--clean')) await rm(output, { recursive: true, force: true });
|
||||
await mkdir(output, { recursive: true });
|
||||
const sections = Object.fromEntries(await Promise.all(markdownDirs.map(async (dir) => [dir, await readPages(dir)])));
|
||||
if (process.argv.includes('--clean')) await rm(output, { recursive: true, force: true })
|
||||
await mkdir(output, { recursive: true })
|
||||
const sections = Object.fromEntries(await Promise.all(markdownDirs.map(async (dir) => [dir, await readPages(dir)])))
|
||||
|
||||
const external = JSON.parse(await readFile('external.json', 'utf8'))
|
||||
|
||||
const externalLinks = Object.entries(external)
|
||||
.map(([text, href]) => `<a class="button blue link extern" href="${escapeHtml(href)}">${escapeHtml(text)}</a>`)
|
||||
.join('')
|
||||
|
||||
const sectionLinks = markdownDirs
|
||||
.map((dir) => `<a class="button link" href="/${dir}/">${dir[0].toUpperCase() + dir.slice(1)}</a>`)
|
||||
.join('')
|
||||
|
||||
const external = JSON.parse(await readFile('external.json', 'utf8'));
|
||||
const externalLinks = Object.entries(external).map(([text, href]) => `<a class="button blue link extern" href="${escapeHtml(href)}">${escapeHtml(text)}</a>`).join('');
|
||||
const sectionLinks = markdownDirs.map((dir) => `<a class="button link" href="/${dir}/">${dir[0].toUpperCase() + dir.slice(1)}</a>`).join('');
|
||||
const standaloneLinks = standalonePages
|
||||
.filter((page) => page.path !== 'licenses')
|
||||
.map((page) => `<a class="button link" href="/${page.path}/">${page.title}</a>`)
|
||||
.join('');
|
||||
const recentPages = markdownDirs.flatMap((dir) => sections[dir].map((page) => ({ ...page, dir })))
|
||||
.join('')
|
||||
|
||||
const recentPages = markdownDirs
|
||||
.flatMap((dir) => sections[dir].map((page) => ({ ...page, dir })))
|
||||
.sort((a, b) => b.date.localeCompare(a.date))
|
||||
.slice(0, 5);
|
||||
const recentLinks = recentPages.map((page) => `<li>${page.date ? `<time class="recent-date" datetime="${escapeHtml(page.date)}" data-value="${escapeHtml(page.date)}" data-tooltip="${escapeHtml(page.date)}">${escapeHtml(page.date)}</time>` : ''}<span class="recent-gap" aria-hidden="true"></span><span class="recent-type">${page.dir}</span><a href="/${page.dir}/${page.name}/">${escapeHtml(page.title)}</a></li>`).join('');
|
||||
await writeFile(join(output, 'index.html'), renderHome(externalLinks, sectionLinks + standaloneLinks, recentLinks));
|
||||
.slice(0, 5)
|
||||
|
||||
const recentLinks = recentPages
|
||||
.map(
|
||||
(page) =>
|
||||
`<li>${page.date ? `<time class="recent-date" datetime="${escapeHtml(page.date)}" data-value="${escapeHtml(page.date)}" data-tooltip="${escapeHtml(page.date)}">${escapeHtml(page.date)}</time>` : ''}<span class="recent-gap" aria-hidden="true"></span><span class="recent-type">${page.dir}</span><a href="/${page.dir}/${page.name}/">${escapeHtml(page.title)}</a></li>`,
|
||||
)
|
||||
.join('')
|
||||
await writeFile(join(output, 'index.html'), renderHome(externalLinks, sectionLinks + standaloneLinks, recentLinks))
|
||||
await writeFile(join(output, '404.html'), renderNotFound())
|
||||
|
||||
for (const dir of markdownDirs) {
|
||||
const pages = sections[dir];
|
||||
await writePage(dir, renderSection(dir, pages));
|
||||
const pages = sections[dir]
|
||||
await writePage(dir, renderSection(dir, pages))
|
||||
|
||||
for (const page of pages) {
|
||||
await writePage(join(dir, page.name), renderArticle(dir, page));
|
||||
await writePage(join(dir, page.name), renderArticle(dir, page))
|
||||
}
|
||||
}
|
||||
|
||||
for (const page of standalonePages) {
|
||||
const source = await readFile(page.source, 'utf8');
|
||||
const source = await readFile(page.source, 'utf8')
|
||||
const generatedPage = {
|
||||
name: page.path,
|
||||
title: page.title,
|
||||
source,
|
||||
date: await dateFor(page.source),
|
||||
};
|
||||
await writePage(page.path, renderArticle(page.path, generatedPage, '/'));
|
||||
}
|
||||
await writePage(page.path, renderArticle(page.path, generatedPage, '/'))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user