@wxxb789/anc 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +295 -0
- package/astro.config.mjs +255 -0
- package/bin/anc.mjs +798 -0
- package/package.json +93 -0
- package/public/_headers +45 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.svg +9 -0
- package/public/fonts/Temml.woff2 +0 -0
- package/scripts/build-snapshot.js +69 -0
- package/scripts/build-wasm.js +67 -0
- package/scripts/build-workspace.js +50 -0
- package/scripts/copy-snapshot.js +48 -0
- package/scripts/copy-wasm.js +57 -0
- package/scripts/emit-redirects.js +64 -0
- package/scripts/generate-corpus.js +421 -0
- package/scripts/init-repository.js +249 -0
- package/scripts/load-config.js +975 -0
- package/scripts/markdown-to-artifact.js +1286 -0
- package/scripts/preview-site.js +364 -0
- package/scripts/publish-set-review.js +157 -0
- package/scripts/resolve-links.js +640 -0
- package/scripts/run-pagefind.js +125 -0
- package/scripts/scan-residue.js +1016 -0
- package/scripts/scan-secrets.js +268 -0
- package/scripts/seed-gitignore.js +410 -0
- package/scripts/snapshot-rows.js +205 -0
- package/scripts/validate-content.js +95 -0
- package/scripts/vendor-provenance.js +307 -0
- package/scripts/verify-output-inventory.js +307 -0
- package/scripts/write-report.js +351 -0
- package/scripts/write-snapshot.js +136 -0
- package/src/components/CollectionExplorer.astro +149 -0
- package/src/components/FacetIndex.astro +45 -0
- package/src/components/LinkedNotes.astro +94 -0
- package/src/components/NoteGraph.astro +434 -0
- package/src/components/NoteList.astro +54 -0
- package/src/components/SiteMetadata.astro +143 -0
- package/src/components/TableOfContents.astro +69 -0
- package/src/components/TagBrowser.astro +70 -0
- package/src/layouts/Layout.astro +305 -0
- package/src/lib/artifact-source.js +72 -0
- package/src/lib/build-bindings.js +11 -0
- package/src/lib/collection-navigation.js +145 -0
- package/src/lib/content.js +92 -0
- package/src/lib/diagram-mode.js +18 -0
- package/src/lib/graph-layout.js +219 -0
- package/src/lib/graph-selection.js +84 -0
- package/src/lib/graph.js +42 -0
- package/src/lib/link-resolution.js +389 -0
- package/src/lib/markdown.js +1469 -0
- package/src/lib/math-mode.js +31 -0
- package/src/lib/math-output.js +131 -0
- package/src/lib/math.js +165 -0
- package/src/lib/mermaid-environment.js +475 -0
- package/src/lib/mermaid-render.js +769 -0
- package/src/lib/preview-model.js +96 -0
- package/src/lib/relations.js +242 -0
- package/src/lib/rendered-marker.js +55 -0
- package/src/lib/route-path.js +192 -0
- package/src/lib/routes.js +342 -0
- package/src/lib/schema.js +578 -0
- package/src/lib/site.js +578 -0
- package/src/lib/snapshot-contract.js +216 -0
- package/src/lib/snapshot-operations.js +147 -0
- package/src/lib/snapshot-queries.js +216 -0
- package/src/lib/snapshot-reader.js +210 -0
- package/src/lib/snapshot.js +233 -0
- package/src/lib/sqlite-warning.js +22 -0
- package/src/lib/sqlite.js +14 -0
- package/src/lib/tag-browser-model.js +18 -0
- package/src/lib/translations.js +533 -0
- package/src/lib/wasm-asset.js +47 -0
- package/src/lib/worker-protocol.js +93 -0
- package/src/pages/404.astro +35 -0
- package/src/pages/about.astro +100 -0
- package/src/pages/collections/[slug].astro +36 -0
- package/src/pages/collections/index.astro +33 -0
- package/src/pages/graph.astro +64 -0
- package/src/pages/index.astro +33 -0
- package/src/pages/notes/[slug].astro +509 -0
- package/src/pages/privacy.astro +121 -0
- package/src/pages/private.astro +59 -0
- package/src/pages/recent.astro +33 -0
- package/src/pages/robots.txt.js +15 -0
- package/src/pages/rss.xml.js +21 -0
- package/src/pages/sitemap.xml.js +20 -0
- package/src/pages/tags/[tag].astro +56 -0
- package/src/pages/tags/index.astro +37 -0
- package/src/scripts/code-copy.js +45 -0
- package/src/scripts/diagram-disabled.js +16 -0
- package/src/scripts/diagram.js +322 -0
- package/src/scripts/graph-client.js +222 -0
- package/src/scripts/graph-draw.js +166 -0
- package/src/scripts/link-preview.js +334 -0
- package/src/scripts/math-disabled.js +18 -0
- package/src/scripts/math.js +133 -0
- package/src/scripts/preferences.js +123 -0
- package/src/scripts/search-dialog.js +651 -0
- package/src/scripts/snapshot-client.js +157 -0
- package/src/scripts/snapshot-worker.js +341 -0
- package/src/scripts/tag-browser.js +153 -0
- package/src/scripts/theme-init.js +35 -0
- package/src/scripts/toc-active.js +38 -0
- package/src/styles/code.css +152 -0
- package/src/styles/diagram.css +149 -0
- package/src/styles/global.css +1739 -0
- package/src/styles/graph.css +317 -0
- package/src/styles/math.css +376 -0
- package/src/styles/tokens.css +171 -0
- package/tsconfig.json +25 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Where every link to a note this build did not publish lands.
|
|
4
|
+
*
|
|
5
|
+
* **One page for every withheld target, and it names none of them.** A link to
|
|
6
|
+
* an excluded note used to render as plain text; owner decision 2026-08-17 made
|
|
7
|
+
* it live, so it needs a destination that exists and tells the truth. This is
|
|
8
|
+
* that destination — the same static document whichever link led here, which is
|
|
9
|
+
* what makes it safe: it has no subject, so it discloses nothing beyond what
|
|
10
|
+
* the link the reader already read told them.
|
|
11
|
+
*
|
|
12
|
+
* The disclosure that *does* now happen is in the linking page, not in this one:
|
|
13
|
+
* a wikilink's own text is its target path, and that text is what ships. See
|
|
14
|
+
* `scripts/resolve-links.ts`'s `unpublished` branch, where the decision and its
|
|
15
|
+
* cost are recorded.
|
|
16
|
+
*
|
|
17
|
+
* **It says the note will not appear later**, and that sentence is the reason
|
|
18
|
+
* this is a page rather than a redirect to `/404/`. A 404 tells a reader the
|
|
19
|
+
* address is wrong, which is false — the author wrote that link deliberately and
|
|
20
|
+
* the build resolved it to a real file. Saying "not published, and not pending"
|
|
21
|
+
* is the honest version, and it is the one a reader can act on by not coming
|
|
22
|
+
* back.
|
|
23
|
+
*
|
|
24
|
+
* `noindex`, via `isIndexable`: a crawler that indexed this would list one page
|
|
25
|
+
* under the text of every withheld link on the site, which is the disclosure
|
|
26
|
+
* concentrated rather than dispersed. Nothing links here from the chrome — the
|
|
27
|
+
* only inbound links are the ones a note body wrote — so it is reachable exactly
|
|
28
|
+
* as often as an author chose.
|
|
29
|
+
*
|
|
30
|
+
* The route segment is reserved in `src/lib/schema.ts`; without that a user's
|
|
31
|
+
* own `private.md` would capture every withheld link on their site.
|
|
32
|
+
*/
|
|
33
|
+
import Layout from '../layouts/Layout.astro';
|
|
34
|
+
import { SITE_MAP } from '../lib/routes.js';
|
|
35
|
+
import { TITLE_SUFFIX } from '../lib/site.js';
|
|
36
|
+
import { NAV_LANGUAGE, translate } from '../lib/translations.js';
|
|
37
|
+
|
|
38
|
+
// The navigation language, for the same reason `404.astro` uses it: this page
|
|
39
|
+
// renders no document, so there is no document language to read. A withheld
|
|
40
|
+
// note has a language and this page may not consult it — reading one would make
|
|
41
|
+
// the chrome here a fact about the file that was not published.
|
|
42
|
+
const t = translate(NAV_LANGUAGE);
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
<Layout title={`${t.withheldTitle}${TITLE_SUFFIX}`} description={t.withheldDescription}>
|
|
46
|
+
<article class="prose">
|
|
47
|
+
<h1>{t.withheldTitle}</h1>
|
|
48
|
+
|
|
49
|
+
<p>{t.withheldBody}</p>
|
|
50
|
+
|
|
51
|
+
<p>{t.withheldPermanence}</p>
|
|
52
|
+
|
|
53
|
+
<h2>{t.notFoundWhereTo}</h2>
|
|
54
|
+
|
|
55
|
+
<ul>
|
|
56
|
+
{SITE_MAP.map((item) => <li><a href={item.href}>{t[item.label]}</a></li>)}
|
|
57
|
+
</ul>
|
|
58
|
+
</article>
|
|
59
|
+
</Layout>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* `/recent/` — every published note, most recently updated first.
|
|
4
|
+
*
|
|
5
|
+
* A corpus route rather than a document route, so its chrome is the navigation
|
|
6
|
+
* language. The note titles it lists are their authors' own words and carry
|
|
7
|
+
* their own `lang` where it differs; `NoteList` handles that.
|
|
8
|
+
*/
|
|
9
|
+
import Layout from '../layouts/Layout.astro';
|
|
10
|
+
import NoteList from '../components/NoteList.astro';
|
|
11
|
+
import { entries } from '../lib/content.js';
|
|
12
|
+
import { noteTimestamp, recentFirst } from '../lib/routes.js';
|
|
13
|
+
import { TITLE_SUFFIX } from '../lib/site.js';
|
|
14
|
+
import { NAV_LANGUAGE, translate } from '../lib/translations.js';
|
|
15
|
+
|
|
16
|
+
const t = translate(NAV_LANGUAGE);
|
|
17
|
+
const ordered = recentFirst(entries);
|
|
18
|
+
// The artifact's date fields are optional. Saying which ordering a reader is
|
|
19
|
+
// actually looking at is the honest alternative to implying a recency the
|
|
20
|
+
// current artifact cannot support.
|
|
21
|
+
const dated = entries.some((entry) => noteTimestamp(entry) !== undefined);
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
<Layout title={`${t.recentTitle}${TITLE_SUFFIX}`} description={t.recentDescription}>
|
|
25
|
+
<section aria-labelledby="recent-title">
|
|
26
|
+
<div class="section-heading">
|
|
27
|
+
<h1 id="recent-title">{t.recentTitle}</h1>
|
|
28
|
+
<span>{t.publishedCount(ordered.length)}</span>
|
|
29
|
+
</div>
|
|
30
|
+
<p class="facet-note">{dated ? t.recentOrderedByDate : t.recentOrderedBySlug}</p>
|
|
31
|
+
<NoteList entries={ordered} level={2} lang={NAV_LANGUAGE} />
|
|
32
|
+
</section>
|
|
33
|
+
</Layout>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/robots.txt` — the indexing policy, and where the sitemap is.
|
|
3
|
+
*
|
|
4
|
+
* Generated rather than the committed `public/robots.txt` it replaces. The
|
|
5
|
+
* `Sitemap:` directive takes an absolute URL, so a static file would be a second
|
|
6
|
+
* place this site's origin is written down — and the origin is a placeholder
|
|
7
|
+
* until a domain is assigned, which makes "written once" the difference between
|
|
8
|
+
* a one-line change and a search-and-replace across the tree.
|
|
9
|
+
*
|
|
10
|
+
* The policy itself is in `renderRobots`.
|
|
11
|
+
*/
|
|
12
|
+
import { renderRobots } from "../lib/site.js";
|
|
13
|
+
export const GET = ({ site }) => new Response(renderRobots(site), {
|
|
14
|
+
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
|
15
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/rss.xml` — the Atom feed of every published note.
|
|
3
|
+
*
|
|
4
|
+
* An Astro endpoint rather than a build script, so the feed is part of the route
|
|
5
|
+
* model and `Astro.site` reaches it the same way it reaches every page. The
|
|
6
|
+
* `.xml.ts` name is what makes the emitted file `rss.xml`.
|
|
7
|
+
*
|
|
8
|
+
* `rss.xml` rather than `atom.xml` although the document is Atom 1.0: `rss` is
|
|
9
|
+
* the reserved route segment TK-01 already set aside, `/rss.xml` is the path
|
|
10
|
+
* readers and feed autodiscovery tools try first, and the `type` attribute on
|
|
11
|
+
* the discovery link states the actual format. The filename is an address, not
|
|
12
|
+
* a format declaration.
|
|
13
|
+
*
|
|
14
|
+
* The whole document, and the reasoning behind Atom over RSS 2.0, is in
|
|
15
|
+
* `src/lib/site.ts`.
|
|
16
|
+
*/
|
|
17
|
+
import { entries } from "../lib/content.js";
|
|
18
|
+
import { renderFeed } from "../lib/site.js";
|
|
19
|
+
export const GET = ({ site }) => new Response(renderFeed(site, entries), {
|
|
20
|
+
headers: { 'Content-Type': 'application/atom+xml; charset=utf-8' },
|
|
21
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/sitemap.xml` — every public route, for crawlers.
|
|
3
|
+
*
|
|
4
|
+
* Hand-emitted rather than `@astrojs/sitemap`, and that is a correctness
|
|
5
|
+
* decision rather than a dependency-count one. The integration enumerates what
|
|
6
|
+
* Astro emitted, which would include `/404.html` and this file's siblings and
|
|
7
|
+
* would need a filter to exclude them; and it has no access to the artifact, so
|
|
8
|
+
* `<lastmod>` would either be absent or be the build clock — the second of which
|
|
9
|
+
* breaks the determinism this ticket is measured on. Reading the artifact
|
|
10
|
+
* directly gives the right URL set and a real modification date, in the same
|
|
11
|
+
* thirty lines.
|
|
12
|
+
*
|
|
13
|
+
* `tests/metadata.test.ts` proves the route set here is exactly the set the
|
|
14
|
+
* build wrote to `dist/`, so this cannot drift from what is actually served.
|
|
15
|
+
*/
|
|
16
|
+
import { entries, tagFacets } from "../lib/content.js";
|
|
17
|
+
import { publicRoutes, renderSitemap } from "../lib/site.js";
|
|
18
|
+
export const GET = ({ site }) => new Response(renderSitemap(site, publicRoutes(entries, tagFacets())), {
|
|
19
|
+
headers: { 'Content-Type': 'application/xml; charset=utf-8' },
|
|
20
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* `/tags/<key>/` — the notes carrying one tag.
|
|
4
|
+
*
|
|
5
|
+
* A facet aggregates many documents, so its chrome is the navigation language
|
|
6
|
+
* rather than any member's. Resolving it from, say, the first note would make
|
|
7
|
+
* this page's language depend on which entry sorts first — a public page
|
|
8
|
+
* changing language because an unrelated note was published.
|
|
9
|
+
*
|
|
10
|
+
* The tag itself is interpolated into the heading and the title as artifact
|
|
11
|
+
* text, unchanged.
|
|
12
|
+
*
|
|
13
|
+
* The complete static `NoteList` is always rendered. The enhanced region below it
|
|
14
|
+
* is hidden until `tag-browser.ts` runs, so a reader with no scripting (or whose
|
|
15
|
+
* chunk failed) sees the whole tag through the static route. The region itself,
|
|
16
|
+
* with the browser's shared ids and status sentences, is `TagBrowser.astro`.
|
|
17
|
+
*/
|
|
18
|
+
import Layout from '../../layouts/Layout.astro';
|
|
19
|
+
import NoteList from '../../components/NoteList.astro';
|
|
20
|
+
import TagBrowser from '../../components/TagBrowser.astro';
|
|
21
|
+
import { tagFacets } from '../../lib/content.js';
|
|
22
|
+
import { TITLE_SUFFIX } from '../../lib/site.js';
|
|
23
|
+
import { NAV_LANGUAGE, translate } from '../../lib/translations.js';
|
|
24
|
+
|
|
25
|
+
export function getStaticPaths() {
|
|
26
|
+
// No facets means no paths, which is the correct static answer: `/tags/` still
|
|
27
|
+
// renders its empty state, and any `/tags/<anything>/` request is a 404
|
|
28
|
+
// because that tag genuinely does not exist.
|
|
29
|
+
return tagFacets().map((facet) => ({ params: { tag: facet.key }, props: { facet } }));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const { facet } = Astro.props;
|
|
33
|
+
const t = translate(NAV_LANGUAGE);
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
<Layout
|
|
37
|
+
title={`${t.tagPageTitle(facet.label)}${TITLE_SUFFIX}`}
|
|
38
|
+
description={t.tagPageDescription(facet.label)}
|
|
39
|
+
>
|
|
40
|
+
<section aria-labelledby="tag-title">
|
|
41
|
+
<div class="section-heading">
|
|
42
|
+
<h1 id="tag-title">{t.tagPageTitle(facet.label)}</h1>
|
|
43
|
+
<span>{t.noteCount(facet.entries.length)}</span>
|
|
44
|
+
</div>
|
|
45
|
+
<p class="facet-note"><a href="/tags/">{t.allTags}</a></p>
|
|
46
|
+
|
|
47
|
+
<div id="tag-static-list">
|
|
48
|
+
<NoteList entries={facet.entries} level={2} lang={NAV_LANGUAGE} />
|
|
49
|
+
</div>
|
|
50
|
+
|
|
51
|
+
{/* The complete static list above stays the no-JS route; the region below is
|
|
52
|
+
the enhanced browser, hidden until the script runs — which unhides the
|
|
53
|
+
static list on any failure. */}
|
|
54
|
+
<TagBrowser mode="fixed" tagKey={facet.key} />
|
|
55
|
+
</section>
|
|
56
|
+
</Layout>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* `/tags/` — every public tag in the projection.
|
|
4
|
+
*
|
|
5
|
+
* A corpus route, so the chrome is the navigation language. The tag labels
|
|
6
|
+
* themselves are artifact text and are never translated: `security` stays
|
|
7
|
+
* `security`, `笔记` stays `笔记`.
|
|
8
|
+
*
|
|
9
|
+
* `FacetIndex`'s anchors remain the complete no-JS route to each tag's page. The
|
|
10
|
+
* chooser below is an additive enhancement that enumerates a selected tag in
|
|
11
|
+
* place through the snapshot; switching tags resets the continuation. The chooser
|
|
12
|
+
* itself, with the browser's shared ids and status sentences, is
|
|
13
|
+
* `TagBrowser.astro`.
|
|
14
|
+
*/
|
|
15
|
+
import Layout from '../../layouts/Layout.astro';
|
|
16
|
+
import FacetIndex from '../../components/FacetIndex.astro';
|
|
17
|
+
import TagBrowser from '../../components/TagBrowser.astro';
|
|
18
|
+
import { tagFacets } from '../../lib/content.js';
|
|
19
|
+
import { tagRoute } from '../../lib/routes.js';
|
|
20
|
+
import { TITLE_SUFFIX } from '../../lib/site.js';
|
|
21
|
+
import { NAV_LANGUAGE, translate } from '../../lib/translations.js';
|
|
22
|
+
|
|
23
|
+
const t = translate(NAV_LANGUAGE);
|
|
24
|
+
const facets = tagFacets();
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
<Layout title={`${t.tagsTitle}${TITLE_SUFFIX}`} description={t.tagsDescription}>
|
|
28
|
+
<section aria-labelledby="tags-title">
|
|
29
|
+
<div class="section-heading">
|
|
30
|
+
<h1 id="tags-title">{t.tagsTitle}</h1>
|
|
31
|
+
<span>{t.tagCount(facets.length)}</span>
|
|
32
|
+
</div>
|
|
33
|
+
<FacetIndex facets={facets} route={tagRoute} empty={t.tagsEmpty} lang={NAV_LANGUAGE} />
|
|
34
|
+
|
|
35
|
+
{facets.length > 0 && <TagBrowser mode="switch" facets={facets} />}
|
|
36
|
+
</section>
|
|
37
|
+
</Layout>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** Progressive code-fence copy controls, inserted only when JavaScript runs. */
|
|
2
|
+
export {};
|
|
3
|
+
const article = document.querySelector('.prose[data-code-copy]');
|
|
4
|
+
const idle = article?.dataset['codeCopy'];
|
|
5
|
+
const copied = article?.dataset['codeCopied'];
|
|
6
|
+
const failed = article?.dataset['codeCopyFailed'];
|
|
7
|
+
if (article && idle && copied && failed && typeof navigator.clipboard?.writeText === 'function') {
|
|
8
|
+
for (const code of article.querySelectorAll('.code-block > pre > code')) {
|
|
9
|
+
const figure = code.closest('.code-block');
|
|
10
|
+
if (!figure || figure.querySelector('.code-copy') !== null)
|
|
11
|
+
continue;
|
|
12
|
+
const button = document.createElement('button');
|
|
13
|
+
button.type = 'button';
|
|
14
|
+
button.className = 'code-copy';
|
|
15
|
+
button.textContent = idle;
|
|
16
|
+
const status = document.createElement('span');
|
|
17
|
+
status.className = 'sr-only';
|
|
18
|
+
status.setAttribute('role', 'status');
|
|
19
|
+
let resetTimer;
|
|
20
|
+
button.addEventListener('click', async () => {
|
|
21
|
+
if (resetTimer !== undefined)
|
|
22
|
+
window.clearTimeout(resetTimer);
|
|
23
|
+
button.disabled = true;
|
|
24
|
+
try {
|
|
25
|
+
await navigator.clipboard.writeText(code.textContent ?? '');
|
|
26
|
+
button.textContent = copied;
|
|
27
|
+
status.textContent = copied;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
button.textContent = failed;
|
|
31
|
+
status.textContent = failed;
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
34
|
+
button.disabled = false;
|
|
35
|
+
resetTimer = window.setTimeout(() => {
|
|
36
|
+
button.textContent = idle;
|
|
37
|
+
status.textContent = '';
|
|
38
|
+
resetTimer = undefined;
|
|
39
|
+
}, 1600);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
figure.prepend(status);
|
|
43
|
+
figure.prepend(button);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The diagram runtime, in the mode that does not have one.
|
|
3
|
+
*
|
|
4
|
+
* `astro.config.mjs` aliases `src/scripts/diagram.ts` to this file whenever
|
|
5
|
+
* `DIAGRAM_MODE` is `'build-time'`. The mode that ships is `'client'`
|
|
6
|
+
* (`src/lib/diagram-mode.ts`), so this stub is bundled only when that constant
|
|
7
|
+
* is flipped. The page's
|
|
8
|
+
* `<script>` is bundled eagerly by Astro regardless of any condition around it,
|
|
9
|
+
* so the only way to keep Mermaid's 119 chunks out of `dist/` is to make the
|
|
10
|
+
* module they hang from import nothing.
|
|
11
|
+
*
|
|
12
|
+
* An empty module rather than a deleted `<script>`: the tag still exists, so
|
|
13
|
+
* the two modes emit the same page shape, and the difference between them is
|
|
14
|
+
* exactly one aliased import instead of a divergence in the template.
|
|
15
|
+
*/
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Client-mode diagram rendering.
|
|
4
|
+
*
|
|
5
|
+
* Loaded only when `DIAGRAM_MODE` is `'client'` and only on pages that contain
|
|
6
|
+
* a diagram. In the shipped mode (`'build-time'`) this file is never imported,
|
|
7
|
+
* so none of it — and none of Mermaid — reaches any page. See
|
|
8
|
+
* `src/lib/diagram-mode.ts` for what the two modes cost.
|
|
9
|
+
*
|
|
10
|
+
* Three properties this has to preserve, all of which the build-time path gets
|
|
11
|
+
* for free and this one has to work for:
|
|
12
|
+
*
|
|
13
|
+
* 1. **Nothing loads until it is needed.** Mermaid's floor is 201 KB gzip for
|
|
14
|
+
* the cheapest single diagram — 4× the §18 article budget — so the import is
|
|
15
|
+
* deferred behind an `IntersectionObserver`. A reader who never scrolls to
|
|
16
|
+
* the diagram never fetches the parser. That also places the cost outside
|
|
17
|
+
* "initial JS on the article route", which is the only reading under which
|
|
18
|
+
* client mode fits the stated budget at all.
|
|
19
|
+
* 2. **The source stays until the diagram replaces it.** The `<pre>` the
|
|
20
|
+
* renderer emitted is removed only after a successful render, so a failure
|
|
21
|
+
* at any point leaves the reader with the diagram's source rather than an
|
|
22
|
+
* empty figure.
|
|
23
|
+
* 3. **The accessible name survives.** The figure's caption already names the
|
|
24
|
+
* diagram, and the injected SVG is given the same name, so replacing the
|
|
25
|
+
* source does not silently drop it.
|
|
26
|
+
*/
|
|
27
|
+
const DIAGRAM_SELECTOR = 'figure.diagram';
|
|
28
|
+
/**
|
|
29
|
+
* Attributes that may carry a URL. Mirrors `URL_ATTRIBUTES` in
|
|
30
|
+
* `src/lib/mermaid-render.ts`, which enforces the same rule at build time;
|
|
31
|
+
* the two must agree, or one mode admits a link the other rejects.
|
|
32
|
+
*/
|
|
33
|
+
const URL_ATTRIBUTES = ['href', 'xlink:href', 'src'];
|
|
34
|
+
/** Mermaid is imported at most once per page, on first diagram in view. */
|
|
35
|
+
let runtime;
|
|
36
|
+
/**
|
|
37
|
+
* Mermaid's own configuration, applied on import and again on every theme
|
|
38
|
+
* change.
|
|
39
|
+
*
|
|
40
|
+
* `initialize` is what installs the palette, and it is *not* idempotent with
|
|
41
|
+
* respect to the theme: calling it again with new `themeVariables` is the only
|
|
42
|
+
* way to make a later `render()` draw different colours. Without the second
|
|
43
|
+
* call a re-render redraws the diagram in exactly the palette it already had —
|
|
44
|
+
* measured, 0 of 60 sampled fills changed.
|
|
45
|
+
*/
|
|
46
|
+
function configure(mermaid) {
|
|
47
|
+
mermaid.initialize({
|
|
48
|
+
startOnLoad: false,
|
|
49
|
+
// **Deliberately not the build-time renderer's `'loose'`.** That level is
|
|
50
|
+
// forced there by happy-dom, under which DOMPurify returns an empty string
|
|
51
|
+
// (`src/lib/mermaid-render.ts`). A real browser has no such defect, so this
|
|
52
|
+
// path takes Mermaid's own label sanitizing, drops `click ... call`
|
|
53
|
+
// callbacks and `_blank` targets, and keeps the post-render pass below as
|
|
54
|
+
// a second boundary rather than the only one. Measured in Chromium: all
|
|
55
|
+
// four diagram types and the theme re-render still draw, and
|
|
56
|
+
// `tests/diagram-client.test.ts` gates a hostile label and `click`
|
|
57
|
+
// directive set against the rendered SVG.
|
|
58
|
+
securityLevel: 'strict',
|
|
59
|
+
deterministicIds: true,
|
|
60
|
+
handDrawnSeed: 42,
|
|
61
|
+
theme: 'base',
|
|
62
|
+
fontFamily: 'inherit',
|
|
63
|
+
themeVariables: currentThemeVariables(),
|
|
64
|
+
maxTextSize: 50000,
|
|
65
|
+
maxEdges: 500,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
function loadMermaid() {
|
|
69
|
+
runtime ??= import('mermaid').then((module) => {
|
|
70
|
+
const mermaid = module.default;
|
|
71
|
+
configure(mermaid);
|
|
72
|
+
return mermaid;
|
|
73
|
+
});
|
|
74
|
+
return runtime;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Palette values for the theme in effect right now.
|
|
78
|
+
*
|
|
79
|
+
* Read from the live page rather than duplicated as literals, so the client
|
|
80
|
+
* path cannot drift from `src/styles/tokens.css` the way a second copy would.
|
|
81
|
+
*
|
|
82
|
+
* **Resolved through a probe element, not read off the custom property.**
|
|
83
|
+
* `getComputedStyle(root).getPropertyValue('--color-surface')` returns the
|
|
84
|
+
* *declared* value — `light-dark(#fff, #161b21)` — because a custom property is
|
|
85
|
+
* substituted, not computed. Mermaid runs every theme value through `khroma`,
|
|
86
|
+
* which parses colours and throws `Unsupported color format` on that string,
|
|
87
|
+
* and the whole render fails. Measured: it is what made every diagram in client
|
|
88
|
+
* mode stay as source.
|
|
89
|
+
*
|
|
90
|
+
* Assigning the property to a real `color` and reading *that* back makes the
|
|
91
|
+
* browser resolve `light-dark()` against the active `color-scheme`, which is
|
|
92
|
+
* exactly the value the diagram should use and follows the theme toggle.
|
|
93
|
+
*/
|
|
94
|
+
function currentThemeVariables() {
|
|
95
|
+
const probe = document.createElement('span');
|
|
96
|
+
// Out of flow and invisible, but still styled: `display: none` would leave
|
|
97
|
+
// the computed colour unresolved in some engines.
|
|
98
|
+
probe.style.position = 'absolute';
|
|
99
|
+
probe.style.visibility = 'hidden';
|
|
100
|
+
document.body.append(probe);
|
|
101
|
+
const resolve = (name, fallback) => {
|
|
102
|
+
probe.style.color = '';
|
|
103
|
+
probe.style.color = `var(${name})`;
|
|
104
|
+
const value = getComputedStyle(probe).color;
|
|
105
|
+
return value === '' ? fallback : value;
|
|
106
|
+
};
|
|
107
|
+
const palette = {
|
|
108
|
+
background: resolve('--color-bg', '#fbfaf7'),
|
|
109
|
+
surface: resolve('--color-surface', '#ffffff'),
|
|
110
|
+
surfaceAlt: resolve('--color-surface-alt', '#f2f0ea'),
|
|
111
|
+
text: resolve('--color-text', '#1b1f24'),
|
|
112
|
+
line: resolve('--color-line-strong', '#767d87'),
|
|
113
|
+
};
|
|
114
|
+
probe.remove();
|
|
115
|
+
return {
|
|
116
|
+
background: palette.background,
|
|
117
|
+
primaryColor: palette.surfaceAlt,
|
|
118
|
+
primaryTextColor: palette.text,
|
|
119
|
+
primaryBorderColor: palette.line,
|
|
120
|
+
lineColor: palette.line,
|
|
121
|
+
secondaryColor: palette.surface,
|
|
122
|
+
tertiaryColor: palette.surface,
|
|
123
|
+
textColor: palette.text,
|
|
124
|
+
mainBkg: palette.surfaceAlt,
|
|
125
|
+
nodeBorder: palette.line,
|
|
126
|
+
nodeTextColor: palette.text,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
async function renderInto(figure, index) {
|
|
130
|
+
const source = figure.querySelector('.diagram-source');
|
|
131
|
+
const text = source?.textContent;
|
|
132
|
+
if (source === null || text === undefined || text === null || text.trim() === '')
|
|
133
|
+
return;
|
|
134
|
+
try {
|
|
135
|
+
const mermaid = await loadMermaid();
|
|
136
|
+
const { svg } = await mermaid.render(`diagram-client-${index}`, text);
|
|
137
|
+
if (svg === '')
|
|
138
|
+
throw new Error('the renderer produced empty output');
|
|
139
|
+
// Parsed rather than assigned as `innerHTML`: requirements §19.2 asks for
|
|
140
|
+
// DOM APIs or trusted structured rendering rather than untrusted
|
|
141
|
+
// `innerHTML`, and the string here is renderer output over content that,
|
|
142
|
+
// although allowlisted, has not passed the build-time sanitizer on this
|
|
143
|
+
// path. `image/svg+xml` also parses `<style>` inside `<svg>` correctly,
|
|
144
|
+
// which the HTML parser does not.
|
|
145
|
+
//
|
|
146
|
+
// **`xmlns:xlink` is declared here because Mermaid does not declare it and
|
|
147
|
+
// still uses it.** XML has no implicit prefixes, so an `xlink:href` on an
|
|
148
|
+
// undeclared prefix is a well-formedness *error* rather than an unknown
|
|
149
|
+
// attribute — the parse yields a `parsererror` document and the reader is
|
|
150
|
+
// left looking at the fence source. Measured on Mermaid 11.16.1 in
|
|
151
|
+
// Chromium: a C4 diagram renders, and parsing its output gives
|
|
152
|
+
// `Namespace prefix xlink for href on image is not defined` at column
|
|
153
|
+
// 17664; seven other diagram types are unaffected because only C4 emits
|
|
154
|
+
// `<image xlink:href>` for its icons. Build-time mode never meets this —
|
|
155
|
+
// it parses as HTML, where an undeclared prefix is just a name.
|
|
156
|
+
//
|
|
157
|
+
// Declaring the standard binding is a smaller and safer repair than
|
|
158
|
+
// rewriting the attribute: the value is what Mermaid meant, and the URL
|
|
159
|
+
// allowlist below still governs what it may point at.
|
|
160
|
+
//
|
|
161
|
+
// Matched on the **root element's own tag**, not on the whole string. A
|
|
162
|
+
// `svg.includes('xmlns:xlink')` guard was the first version and is wrong in
|
|
163
|
+
// the direction that matters: measured through the real renderer, a diagram
|
|
164
|
+
// whose *label* contains the text `xmlns:xlink` puts it in a `<tspan>` or a
|
|
165
|
+
// `foreignObject`, so such a diagram would skip the declaration and fall
|
|
166
|
+
// back to source — the defect this exists to fix, reintroduced by its own
|
|
167
|
+
// guard.
|
|
168
|
+
const declared = /<svg\b[^>]*\bxmlns:xlink=/.test(svg)
|
|
169
|
+
? svg
|
|
170
|
+
: svg.replace(/<svg\b/, '<svg xmlns:xlink="http://www.w3.org/1999/xlink"');
|
|
171
|
+
const parsed = new DOMParser().parseFromString(declared, 'image/svg+xml');
|
|
172
|
+
const root = parsed.documentElement;
|
|
173
|
+
if (root.querySelector('parsererror') !== null)
|
|
174
|
+
throw new Error('renderer output is not well-formed');
|
|
175
|
+
if (root.querySelector('script') !== null)
|
|
176
|
+
throw new Error('renderer emitted a script');
|
|
177
|
+
for (const element of [root, ...root.querySelectorAll('*')]) {
|
|
178
|
+
// `attributes` is live and `removeAttribute` mutates it, so the names are
|
|
179
|
+
// snapshotted first. Iterating it directly skips the entry after each
|
|
180
|
+
// removal — which would leave a handler on the page.
|
|
181
|
+
const names = Array.from(element.attributes, (attribute) => attribute.name);
|
|
182
|
+
for (const name of names) {
|
|
183
|
+
if (/^on/i.test(name))
|
|
184
|
+
element.removeAttribute(name);
|
|
185
|
+
}
|
|
186
|
+
// The same URL rule the build-time renderer enforces, for the same
|
|
187
|
+
// reason: Mermaid's `click` directive turns a node into an anchor
|
|
188
|
+
// pointing wherever the diagram source said, including a `javascript:`
|
|
189
|
+
// URL, and this SVG never passed through the build-time href allowlist.
|
|
190
|
+
// Build-time mode fails the build on one of these; here the diagram is
|
|
191
|
+
// already in front of a reader, so the link is dropped and the rest of
|
|
192
|
+
// the diagram still renders.
|
|
193
|
+
for (const attribute of URL_ATTRIBUTES) {
|
|
194
|
+
const value = element.getAttribute(attribute);
|
|
195
|
+
if (value === null)
|
|
196
|
+
continue;
|
|
197
|
+
const isLocal = value.startsWith('#');
|
|
198
|
+
const isInlineRaster = /^data:image\/(?:png|jpe?g|gif|webp|avif);base64,[A-Za-z0-9+/=]*$/i.test(value);
|
|
199
|
+
if (!isLocal && !isInlineRaster)
|
|
200
|
+
element.removeAttribute(attribute);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const caption = figure.querySelector('figcaption')?.textContent?.trim();
|
|
204
|
+
if (caption !== undefined && caption !== '')
|
|
205
|
+
root.setAttribute('aria-label', caption);
|
|
206
|
+
root.setAttribute('focusable', 'false');
|
|
207
|
+
const canvas = document.createElement('div');
|
|
208
|
+
canvas.className = 'diagram-canvas';
|
|
209
|
+
// The container scrolls horizontally when the diagram is wider than the
|
|
210
|
+
// measure, and a scrollable region must be keyboard reachable — axe
|
|
211
|
+
// `scrollable-region-focusable`, WCAG 2.1.1. Build-time mode sets the same
|
|
212
|
+
// attribute in the markup; the two modes must not differ on this.
|
|
213
|
+
canvas.tabIndex = 0;
|
|
214
|
+
canvas.append(document.importNode(root, true));
|
|
215
|
+
// Replace only now that a diagram exists: every failure above leaves the
|
|
216
|
+
// source in place, which is a readable page rather than a blank figure.
|
|
217
|
+
source.replaceWith(canvas);
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
// The source is still on the page and is the honest fallback. Nothing is
|
|
221
|
+
// logged: a console error on a published page is a gate failure in
|
|
222
|
+
// `tests/rendered-page.test.ts`, and a reader can act on neither.
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
const figures = [...document.querySelectorAll(DIAGRAM_SELECTOR)];
|
|
226
|
+
if (figures.length > 0) {
|
|
227
|
+
/**
|
|
228
|
+
* Each figure's source, captured before anything replaces it.
|
|
229
|
+
*
|
|
230
|
+
* A re-render needs the text back, and by then the `<pre>` that held it is
|
|
231
|
+
* gone — `renderInto` replaces it on success, which is the property that
|
|
232
|
+
* leaves a readable page when a render fails.
|
|
233
|
+
*/
|
|
234
|
+
const sources = new WeakMap();
|
|
235
|
+
for (const figure of figures) {
|
|
236
|
+
const text = figure.querySelector('.diagram-source')?.textContent;
|
|
237
|
+
if (text !== null && text !== undefined && text.trim() !== '')
|
|
238
|
+
sources.set(figure, text);
|
|
239
|
+
}
|
|
240
|
+
const observer = new IntersectionObserver((entries) => {
|
|
241
|
+
for (const entry of entries) {
|
|
242
|
+
if (!entry.isIntersecting)
|
|
243
|
+
continue;
|
|
244
|
+
observer.unobserve(entry.target);
|
|
245
|
+
void renderInto(entry.target, figures.indexOf(entry.target));
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
// Start fetching a screen ahead, so a diagram is usually ready by the time
|
|
249
|
+
// it is scrolled to rather than appearing after it.
|
|
250
|
+
{ rootMargin: '200px' });
|
|
251
|
+
for (const figure of figures)
|
|
252
|
+
observer.observe(figure);
|
|
253
|
+
/**
|
|
254
|
+
* Re-render every drawn diagram when the theme changes.
|
|
255
|
+
*
|
|
256
|
+
* Mermaid bakes the palette into the SVG at render time, so a diagram drawn
|
|
257
|
+
* in light mode stays light for ever. Build-time mode has no such problem —
|
|
258
|
+
* it renders both palettes and merges them into `light-dark()` values the
|
|
259
|
+
* browser re-resolves — which is why this regression is invisible until
|
|
260
|
+
* somebody runs the client path. Measured before this existed: toggling to
|
|
261
|
+
* dark changed 0 of 60 sampled fills and strokes.
|
|
262
|
+
*
|
|
263
|
+
* **A `MutationObserver` on `data-theme` rather than a custom event**, because
|
|
264
|
+
* `preferences.ts` already mutates that attribute and nothing else needs to
|
|
265
|
+
* know this file exists. `matchMedia` alone would miss the explicit toggle,
|
|
266
|
+
* which is the case a reader actually operates.
|
|
267
|
+
*
|
|
268
|
+
* **Passes are serialized rather than raced, and that is a correctness fix
|
|
269
|
+
* rather than tidiness.** An earlier version started a pass per change and
|
|
270
|
+
* guarded with a generation counter checked *after* `renderInto` — which is
|
|
271
|
+
* after the paint, so a stale pass had already committed its DOM before
|
|
272
|
+
* discovering it was stale. Worse, the newer pass skipped any figure without
|
|
273
|
+
* a `.diagram-canvas`, which is exactly the state an in-flight figure is in,
|
|
274
|
+
* so it stepped over the one figure the stale pass was about to paint in the
|
|
275
|
+
* old palette. The result was a deterministic mixed-palette page: every
|
|
276
|
+
* figure new except the one that had been rendering.
|
|
277
|
+
*
|
|
278
|
+
* Chaining removes the interleaving instead of detecting it. `pending` is the
|
|
279
|
+
* tail of the chain; a change appends to it, so a pass never observes a DOM
|
|
280
|
+
* another pass is midway through mutating. `wanted` is read at the start of
|
|
281
|
+
* each pass rather than captured per change, so a burst of toggles collapses
|
|
282
|
+
* into one redraw at the theme that is actually in effect when its turn
|
|
283
|
+
* comes — measured, a fast triple toggle settles byte-identical to a single
|
|
284
|
+
* clean toggle to the same theme.
|
|
285
|
+
*
|
|
286
|
+
* **Every figure with a captured source is redrawn**, including one still
|
|
287
|
+
* showing that source. A figure mid-initial-render is the case the skip used
|
|
288
|
+
* to miss: its `IntersectionObserver` pass may have called `render()` before
|
|
289
|
+
* the new palette was installed, and nothing would revisit it.
|
|
290
|
+
*/
|
|
291
|
+
let pending = Promise.resolve();
|
|
292
|
+
const redraw = () => {
|
|
293
|
+
pending = pending.then(async () => {
|
|
294
|
+
// Re-configure before rendering: `initialize` is what installs the
|
|
295
|
+
// palette, so without this every re-render redraws the old colours.
|
|
296
|
+
configure(await loadMermaid());
|
|
297
|
+
for (const [index, figure] of figures.entries()) {
|
|
298
|
+
const text = sources.get(figure);
|
|
299
|
+
if (text === undefined)
|
|
300
|
+
continue;
|
|
301
|
+
const canvas = figure.querySelector('.diagram-canvas');
|
|
302
|
+
if (canvas !== null) {
|
|
303
|
+
const source = document.createElement('pre');
|
|
304
|
+
source.className = 'diagram-source';
|
|
305
|
+
source.textContent = text;
|
|
306
|
+
// Put the source back so `renderInto` has its input, then let it do
|
|
307
|
+
// the replacing exactly as on first draw — one code path draws a
|
|
308
|
+
// diagram, and its failure path leaves the source in place.
|
|
309
|
+
canvas.replaceWith(source);
|
|
310
|
+
}
|
|
311
|
+
await renderInto(figure, index);
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
};
|
|
315
|
+
new MutationObserver(redraw).observe(document.documentElement, {
|
|
316
|
+
attributes: true,
|
|
317
|
+
attributeFilter: ['data-theme'],
|
|
318
|
+
});
|
|
319
|
+
// The system-preference case, for a reader on "system" who changes it at the
|
|
320
|
+
// OS level: no attribute mutates, so the observer above never fires.
|
|
321
|
+
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', redraw);
|
|
322
|
+
}
|