-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathbuild.mjs
More file actions
267 lines (233 loc) · 10 KB
/
Copy pathbuild.mjs
File metadata and controls
267 lines (233 loc) · 10 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
// Static site generator: renders the flashtrace tool repo's docs/ plus the
// hand-written landing page into dist/. Pure Node + marked, no framework.
import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import { Marked } from 'marked';
import { docShell, esc, EXT_ATTRS, GITHUB_URL, highlightTokens, SITE_URL } from './src/layout.mjs';
import { renderLanding } from './src/landing.mjs';
import { renderImpressum } from './src/impressum.mjs';
import { renderLicense } from './src/license.mjs';
import { collectSchemas, locateSchemas } from './src/schemas.mjs';
const root = path.dirname(fileURLToPath(import.meta.url));
const dist = path.join(root, 'dist');
const schemaProblemsPath = path.join(root, 'schema-problems.json');
// --- locate the tool repo's docs (env → CI checkout → local sibling) -------
function locateDocs() {
const candidates = [
process.env.FLASHTRACE_DOCS,
path.join(root, 'flashtrace', 'docs'),
path.join(root, '..', 'flashtrace', 'docs'),
].filter(Boolean);
for (const c of candidates) if (existsSync(path.join(c, 'index.md'))) return c;
console.error(
'error: flashtrace docs not found. Set FLASHTRACE_DOCS, or clone the tool repo:\n' +
' git clone https://github.com/flashtrace/flashtrace ../flashtrace',
);
process.exit(1);
}
const docsDir = locateDocs();
// --- license: raw text from the tool repo root, next to docs/ ---------------
const licensePath = path.join(docsDir, '..', 'LICENSE');
if (!existsSync(licensePath)) {
console.error(`error: LICENSE not found at ${licensePath}`);
process.exit(1);
}
const licenseText = readFileSync(licensePath, 'utf8');
// --- schemas: published schemas, sitting next to docs/ in the tool repo ------
// A missing folder only warns, and a file the site cannot serve is skipped
// rather than fatal, for the same reason: one broken schema - or a release
// predating schemas/ entirely - must not cost the deploy of the docs, which
// have nothing to do with it. The skips are written to schemaProblemsPath
// below so the trade stays visible; CI turns that into a tracking issue.
const schemasDir = locateSchemas(docsDir);
let schemas = [];
let schemaProblems = [];
if (schemasDir) {
try {
({ schemas, problems: schemaProblems } = collectSchemas(schemasDir));
} catch (err) {
console.error(`error: could not read ${schemasDir}: ${err.message}`);
process.exit(1);
}
for (const p of schemaProblems) console.warn(`warn: schemas/${p.path} ${p.reason}`);
// An empty folder is not a problem: schemas/ can land upstream a release
// before the first schema inside it does.
if (schemas.length === 0) {
console.warn(`warn: ${schemasDir} holds no v<N>.json files - nothing to serve under /schemas/.`);
}
} else {
console.warn('warn: no schemas/ in the flashtrace checkout - skipping /schemas/.');
}
// --- version: release tag from env, else the tool repo's package.json ------
function readVersion() {
const ref = process.env.FLASHTRACE_REF;
if (ref) return ref.startsWith('v') ? ref : `v${ref}`;
try {
const pkg = JSON.parse(readFileSync(path.join(docsDir, '..', 'package.json'), 'utf8'));
if (pkg.version) return `v${pkg.version}`;
} catch {
/* fall through */
}
return '';
}
const resolvedVersion = readVersion();
const gitRef = resolvedVersion || 'main'; // for links into the tool repo on github.com
const version = resolvedVersion || 'dev'; // display label (header badge, footer note)
// --- nav order: derived from docs/index.md, the single source of truth -----
const indexMd = readFileSync(path.join(docsDir, 'index.md'), 'utf8');
const specPages = [...indexMd.matchAll(/\[([^\]]+)\]\(docs\/([A-Za-z0-9_-]+)\.md\)/g)].map(
(m) => ({ title: m[1], slug: m[2], file: `${m[2]}.md` }),
);
if (specPages.length === 0) {
console.error('error: no doc links found in docs/index.md - nav derivation failed.');
process.exit(1);
}
const pages = [
{ title: 'Usage Guide', slug: 'usage', file: 'USAGE.md' },
{ title: 'Overview', slug: '', file: 'index.md' },
...specPages,
];
const slugByName = new Map(pages.map((p) => [p.file.replace(/\.md$/, ''), p.slug]));
// --- markdown rendering ------------------------------------------------------
function slugify(html) {
return html
.replace(/<[^>]*>/g, '')
.replace(/&[a-z]+;|&#\d+;/gi, '')
.toLowerCase()
.trim()
.replace(/[^\w\- ]/g, '')
.replace(/ /g, '-');
}
// Rewrite the tool repo's relative links to the site's clean URLs. index.md
// links as docs/<name>.md; USAGE.md and the spec pages link bare <name>.md -
// both forms are handled. Other repo-relative paths go to github.com.
function rewriteHref(href) {
if (/^(https?:|mailto:|#)/.test(href)) return href;
const clean = href.replace(/^\.\//, '');
const m = clean.match(/^(?:docs\/)?([A-Za-z0-9_-]+)\.(?:md|markdown)(#.*)?$/);
if (m) {
const [, name, anchor = ''] = m;
if (name === 'index') return `/docs/${anchor}`;
const slug = slugByName.get(name);
if (slug !== undefined) return slug === '' ? `/docs/${anchor}` : `/docs/${slug}/${anchor}`;
}
return `${GITHUB_URL}/blob/${gitRef}/${clean}`;
}
// Per-page render state (marked renderer hooks close over this).
const state = { toc: [], slugCounts: new Map() };
// Docs are trusted first-party input, so we don't sanitize marked's output
// (raw HTML passes through). Revisit before rendering any untrusted markdown here.
const marked = new Marked({
gfm: true,
renderer: {
heading({ tokens, depth }) {
const text = this.parser.parseInline(tokens);
let id = slugify(text);
const n = state.slugCounts.get(id) ?? 0;
state.slugCounts.set(id, n + 1);
if (n > 0) id = `${id}-${n}`;
if (depth === 2 || depth === 3) state.toc.push({ id, text, level: depth });
return `<h${depth} id="${id}">${text}<a class="heading-anchor" href="#${id}" aria-label="Link to this section">#</a></h${depth}>\n`;
},
link({ href, title, tokens }) {
const text = this.parser.parseInline(tokens);
const t = title ? ` title="${esc(title)}"` : '';
const url = rewriteHref(href);
const ext = /^https?:/.test(url) ? EXT_ATTRS : '';
return `<a href="${url}"${t}${ext}>${text}</a>`;
},
code({ text, lang }) {
const cls = lang ? ` class="language-${esc(lang)}"` : '';
return `<pre><code${cls}>${highlightTokens(esc(text))}</code></pre>\n`;
},
codespan({ text }) {
return `<code>${highlightTokens(esc(text))}</code>`;
},
},
});
function renderDoc(page) {
state.toc = [];
state.slugCounts = new Map();
const md = readFileSync(path.join(docsDir, page.file), 'utf8');
const content = marked.parse(md);
const navGroups = [
{
label: 'Getting started',
items: [{ title: 'Usage Guide', href: '/docs/usage/', current: page.slug === 'usage' }],
},
{
label: 'Specification',
items: [
{ title: 'Overview', href: '/docs/', current: page.slug === '' },
...specPages.map((p) => ({
title: p.title,
href: `/docs/${p.slug}/`,
current: p.slug === page.slug,
})),
],
},
];
return docShell({
title: `${page.title} · flashtrace`,
description: `flashtrace documentation - ${page.title}.`,
path: page.slug ? `/docs/${page.slug}/` : '/docs/',
version,
navGroups,
toc: state.toc,
content,
});
}
// --- emit --------------------------------------------------------------------
rmSync(dist, { recursive: true, force: true });
mkdirSync(dist, { recursive: true });
writeFileSync(path.join(dist, 'index.html'), renderLanding({ version, gitRef }));
mkdirSync(path.join(dist, 'impressum'), { recursive: true });
writeFileSync(path.join(dist, 'impressum', 'index.html'), renderImpressum({ version }));
mkdirSync(path.join(dist, 'license'), { recursive: true });
writeFileSync(path.join(dist, 'license', 'index.html'), renderLicense({ version, text: licenseText }));
for (const page of pages) {
const dir = page.slug ? path.join(dist, 'docs', page.slug) : path.join(dist, 'docs');
mkdirSync(dir, { recursive: true });
writeFileSync(path.join(dir, 'index.html'), renderDoc(page));
}
// Never through the markdown pipeline, whose link rewriting would corrupt the
// identifiers ($id, $ref, $schema) inside them.
for (const schema of schemas) {
const dir = path.join(dist, 'schemas', schema.name);
mkdirSync(dir, { recursive: true });
for (const version of schema.versions) writeFileSync(path.join(dir, version.file), version.bytes);
// A real file, not a redirect - GitHub Pages has no server-side redirects
// and a meta-refresh means nothing to a JSON fetch. Copied verbatim, $id
// included: a copy fetched from latest.json must still say which version it
// actually is.
writeFileSync(path.join(dir, `latest.${schema.format}`), schema.latest.bytes);
}
// Outside dist/ - a build artifact for CI to read, not something to publish.
// Written on every build, including a clean one: "no problems" has to be a
// statement the workflow can act on, or it could never close a stale issue.
writeFileSync(
schemaProblemsPath,
`${JSON.stringify({ version, schemas: schemas.length, problems: schemaProblems }, null, 2)}\n`,
);
const sitePaths = [
'/',
...pages.map((p) => (p.slug ? `/docs/${p.slug}/` : '/docs/')),
'/license/',
'/impressum/',
];
writeFileSync(
path.join(dist, 'sitemap.xml'),
`<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${sitePaths.map((p) => ` <url><loc>${SITE_URL}${p}</loc></url>`).join('\n')}
</urlset>
`,
);
// Last, so a file in public/ silently wins against a generated file at the
// same path - check here first if a generated file is not the one being served.
cpSync(path.join(root, 'public'), dist, { recursive: true });
cpSync(path.join(root, 'src', 'styles', 'site.css'), path.join(dist, 'site.css'));
cpSync(path.join(root, 'src', 'scripts', 'site.js'), path.join(dist, 'site.js'));
console.log(`built ${pages.length + 3} pages into dist/ (flashtrace ${version || 'unknown version'})`);