@hraness/wordcell 0.21.3 → 0.22.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/README.md +170 -64
- package/dist/authoring.js +5 -3
- package/dist/{authority-4sp9d552.js → authority-06eqhqam.js} +57 -12
- package/dist/benchmark.js +4 -4
- package/dist/capture.js +6 -5
- package/dist/cli.js +39 -33
- package/dist/clip/acquire.js +3 -2
- package/dist/clip/cli.js +6 -5
- package/dist/clip/doctor.js +4 -3
- package/dist/clip/jobs.js +3 -3
- package/dist/clip/refresh.js +1 -1
- package/dist/evaluation-builder.js +20 -19
- package/dist/evaluation-kb.js +16 -15
- package/dist/graph-authority.js +5 -5
- package/dist/graph-percolation.js +7 -7
- package/dist/graph.js +1 -1
- package/dist/{index-pbr6rv47.js → index-1k595z6v.js} +6 -6
- package/dist/{index-0k2x4nn9.js → index-233z9wmn.js} +17 -5
- package/dist/index-3agn8scn.js +306 -0
- package/dist/index-4knsp9qj.js +34 -0
- package/dist/index-66pshdtx.js +341 -0
- package/dist/{index-59ssmqqm.js → index-76dbn6pe.js} +1 -1
- package/dist/{index-0rfcqab6.js → index-810nfzdj.js} +617 -66
- package/dist/index-9rf81m0p.js +368 -0
- package/dist/{index-51bap0mq.js → index-bqtqeak3.js} +10 -10
- package/dist/{index-9zcpdbjg.js → index-de2w8crk.js} +13 -13
- package/dist/{index-bgfzwt4h.js → index-djrcc1yf.js} +6 -6
- package/dist/{index-6sw24nvv.js → index-ecqpgr5y.js} +53 -2
- package/dist/{index-van48n3g.js → index-fc4dr114.js} +4 -2
- package/dist/{index-jzcws0zn.js → index-g5vsqmdy.js} +27 -57
- package/dist/index-hmw17zaa.js +1985 -0
- package/dist/index-j70m75wd.js +217 -0
- package/dist/{index-nd6nynv2.js → index-jk1sgx36.js} +1 -1
- package/dist/{index-3ehtt59c.js → index-n386evxj.js} +1 -1
- package/dist/{index-vdw5yjfa.js → index-q7tbg07z.js} +110 -20
- package/dist/{index-ekpwvbra.js → index-qbssx940.js} +1 -2
- package/dist/{index-115b07ap.js → index-rr9kzq4n.js} +5 -5
- package/dist/{index-11621h23.js → index-tf4wzjew.js} +1 -1
- package/dist/{index-why54q5p.js → index-vnybgywh.js} +4 -4
- package/dist/{index-bcfn9xah.js → index-xv80vzfp.js} +7 -7
- package/dist/index.js +289 -95
- package/dist/percolate.js +2 -2
- package/dist/portfolio.js +16 -15
- package/dist/publish-model.js +36 -0
- package/dist/publish-reader/reader.css +1042 -0
- package/dist/publish-reader/reader.js +1534 -0
- package/dist/publish-reader/theme.js +92 -0
- package/dist/publish-search.js +52 -0
- package/dist/publish.js +36 -0
- package/dist/rerank-typesafe.js +13 -0
- package/dist/rerank.js +14 -0
- package/dist/sdk.js +16 -13
- package/dist/search.js +3 -3
- package/dist/semantic.js +5 -5
- package/dist/{support-vm4mkw0k.js → support-1rh2d7mt.js} +38 -32
- package/dist/workflows/decision-context.js +15 -14
- package/dist/workflows/index.js +15 -14
- package/package.json +65 -6
- package/skills/wordcell/AGENTS.md +1 -0
- package/skills/wordcell/SKILL.md +2 -1
- package/skills/wordcell/agents/openai.yaml +1 -1
- package/skills/wordcell/references/publish.md +127 -0
- package/skills/wordcell/references/query.md +22 -0
- package/skills/wordcell/references/refresh.md +14 -0
- package/src/authoring-model.ts +29 -0
- package/src/authoring-program.ts +38 -3
- package/src/authoring.ts +17 -3
- package/src/cli-program.ts +441 -12
- package/src/graph.ts +1 -1
- package/src/index.ts +4 -0
- package/src/oh/authority.ts +14 -5
- package/src/oh/canonical-rust.ts +127 -0
- package/src/oh/projection-rust.ts +63 -0
- package/src/oh-adoption.ts +7 -1
- package/src/publish-graph.ts +0 -0
- package/src/publish-index.ts +195 -0
- package/src/publish-markdown.ts +617 -0
- package/src/publish-model.ts +599 -0
- package/src/publish-nav.ts +204 -0
- package/src/publish-pages.ts +382 -0
- package/src/publish-reader/reader.css +1042 -0
- package/src/publish-reader/reader.ts +1017 -0
- package/src/publish-reader/theme.ts +97 -0
- package/src/publish-search.ts +418 -0
- package/src/publish-select.ts +419 -0
- package/src/publish-theme.ts +106 -0
- package/src/publish.ts +904 -0
- package/src/rerank-credentials.ts +56 -0
- package/src/rerank-typesafe.ts +472 -0
- package/src/rerank.ts +389 -0
- package/src/sdk.ts +157 -6
- package/src/serve.ts +180 -0
- package/src/vault.ts +23 -9
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { stripMarkup } from "./publish-markdown.js";
|
|
2
|
+
import type { WordcellSiteCatalogEntryV1 } from "./publish-model.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Navigation derivation for published sites: the sidebar tree, breadcrumbs,
|
|
6
|
+
* and the per-note table of contents. Everything here is pure data — page
|
|
7
|
+
* chrome renders it to static HTML that works without JavaScript, and the
|
|
8
|
+
* bundled reader never needs it.
|
|
9
|
+
*
|
|
10
|
+
* The tree is built over catalog slugs (the site-facing path hierarchy), not
|
|
11
|
+
* note ids, so what the sidebar shows is exactly what the URL space serves.
|
|
12
|
+
* Deterministic order: directory groups sort before leaves at each level, then
|
|
13
|
+
* code-unit order by label and path — no locale-dependent collation.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export type SiteNavNode = {
|
|
17
|
+
/** Slug path at this level; equals `slug` when a note owns the path. */
|
|
18
|
+
readonly path: string;
|
|
19
|
+
/** Note slug when a published note sits at this path. */
|
|
20
|
+
readonly slug?: string;
|
|
21
|
+
/** Note title for owned paths, otherwise the raw directory segment. */
|
|
22
|
+
readonly label: string;
|
|
23
|
+
readonly children: readonly SiteNavNode[];
|
|
24
|
+
/** Direct children dropped by pruning, rendered as a "+N more" link. */
|
|
25
|
+
readonly hiddenChildren?: number;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type SiteNavTree = {
|
|
29
|
+
readonly nodes: readonly SiteNavNode[];
|
|
30
|
+
/** Top-level nodes dropped by pruning. */
|
|
31
|
+
readonly hiddenRoots: number;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const SITE_NAV_GROUP_LIMIT = 96;
|
|
35
|
+
export const SITE_NAV_NODE_BUDGET = 768;
|
|
36
|
+
export const SITE_TOC_LIMIT = 128;
|
|
37
|
+
|
|
38
|
+
type MutableNavNode = {
|
|
39
|
+
path: string;
|
|
40
|
+
slug?: string;
|
|
41
|
+
label: string;
|
|
42
|
+
children: Map<string, MutableNavNode>;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function compareNavNodes(left: SiteNavNode, right: SiteNavNode): number {
|
|
46
|
+
return Number(right.children.length > 0) - Number(left.children.length > 0)
|
|
47
|
+
|| (left.label < right.label ? -1 : left.label > right.label ? 1 : 0)
|
|
48
|
+
|| (left.path < right.path ? -1 : left.path > right.path ? 1 : 0);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Build the publication navigation tree from catalog entries. */
|
|
52
|
+
export function siteNavFromCatalog(
|
|
53
|
+
entries: readonly WordcellSiteCatalogEntryV1[],
|
|
54
|
+
): SiteNavTree {
|
|
55
|
+
const root = new Map<string, MutableNavNode>();
|
|
56
|
+
for (const entry of entries) {
|
|
57
|
+
// The vault index note owns the site root and never appears in the tree.
|
|
58
|
+
if (entry.s === "") continue;
|
|
59
|
+
const segments = entry.s.split("/");
|
|
60
|
+
let level = root;
|
|
61
|
+
let path = "";
|
|
62
|
+
for (const [index, segment] of segments.entries()) {
|
|
63
|
+
path = path === "" ? segment : `${path}/${segment}`;
|
|
64
|
+
let node = level.get(segment);
|
|
65
|
+
if (node === undefined) {
|
|
66
|
+
node = { path, label: segment, children: new Map() };
|
|
67
|
+
level.set(segment, node);
|
|
68
|
+
}
|
|
69
|
+
if (index === segments.length - 1) {
|
|
70
|
+
node.slug = entry.s;
|
|
71
|
+
node.label = entry.t;
|
|
72
|
+
}
|
|
73
|
+
level = node.children;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const freeze = (level: Map<string, MutableNavNode>): SiteNavNode[] =>
|
|
77
|
+
[...level.values()]
|
|
78
|
+
.map((node): SiteNavNode => ({
|
|
79
|
+
path: node.path,
|
|
80
|
+
...(node.slug === undefined ? {} : { slug: node.slug }),
|
|
81
|
+
label: node.label,
|
|
82
|
+
children: freeze(node.children),
|
|
83
|
+
}))
|
|
84
|
+
.toSorted(compareNavNodes);
|
|
85
|
+
return { nodes: freeze(root), hiddenRoots: 0 };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Bound the tree emitted into each page. Kept nodes are shallow (depth ≤ 2),
|
|
90
|
+
* on the current path, descendants of current, or siblings of a current
|
|
91
|
+
* ancestor — everything else folds into per-group "+N more" links back to the
|
|
92
|
+
* catalog. A global node budget additionally bounds pathological breadth while
|
|
93
|
+
* always preserving the current path's ancestor chain.
|
|
94
|
+
*/
|
|
95
|
+
export function pruneSiteNav(
|
|
96
|
+
tree: SiteNavTree,
|
|
97
|
+
current: string | undefined,
|
|
98
|
+
groupLimit = SITE_NAV_GROUP_LIMIT,
|
|
99
|
+
nodeBudget = SITE_NAV_NODE_BUDGET,
|
|
100
|
+
): SiteNavTree {
|
|
101
|
+
const onPath = (path: string): boolean =>
|
|
102
|
+
current !== undefined && current !== ""
|
|
103
|
+
&& (path === current || current.startsWith(`${path}/`));
|
|
104
|
+
const nearCurrent = (path: string): boolean =>
|
|
105
|
+
current !== undefined && current !== ""
|
|
106
|
+
&& (path === current
|
|
107
|
+
|| current.startsWith(`${path}/`)
|
|
108
|
+
|| path.startsWith(`${current}/`));
|
|
109
|
+
const keepNode = (node: SiteNavNode): boolean => {
|
|
110
|
+
if (node.path.split("/").length <= 2) return true;
|
|
111
|
+
if (nearCurrent(node.path)) return true;
|
|
112
|
+
const parent = node.path.slice(0, node.path.lastIndexOf("/"));
|
|
113
|
+
return parent !== "" && onPath(parent);
|
|
114
|
+
};
|
|
115
|
+
const budget = { left: nodeBudget };
|
|
116
|
+
const pruneLevel = (nodes: readonly SiteNavNode[]): SiteNavTree => {
|
|
117
|
+
const kept = nodes.filter(keepNode);
|
|
118
|
+
const required = kept.filter((node) => onPath(node.path) || node.path === current);
|
|
119
|
+
const requiredSet = new Set(required);
|
|
120
|
+
const fillable = Math.max(0, groupLimit - required.length);
|
|
121
|
+
const optional = kept.filter((node) => !requiredSet.has(node)).slice(0, fillable);
|
|
122
|
+
const shown = new Set([...required, ...optional]);
|
|
123
|
+
let hidden = nodes.length - shown.size;
|
|
124
|
+
const out: SiteNavNode[] = [];
|
|
125
|
+
for (const node of kept) {
|
|
126
|
+
if (!shown.has(node)) continue;
|
|
127
|
+
if (budget.left <= 0 && !onPath(node.path)) {
|
|
128
|
+
hidden += 1;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
budget.left -= 1;
|
|
132
|
+
const children = node.children.length === 0
|
|
133
|
+
? { nodes: [] as SiteNavNode[], hiddenRoots: 0 }
|
|
134
|
+
: pruneLevel(node.children);
|
|
135
|
+
out.push({
|
|
136
|
+
path: node.path,
|
|
137
|
+
...(node.slug === undefined ? {} : { slug: node.slug }),
|
|
138
|
+
label: node.label,
|
|
139
|
+
children: children.nodes,
|
|
140
|
+
...(children.hiddenRoots === 0 ? {} : { hiddenChildren: children.hiddenRoots }),
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
return { nodes: out, hiddenRoots: hidden };
|
|
144
|
+
};
|
|
145
|
+
return pruneLevel(tree.nodes);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export type SiteBreadcrumb = {
|
|
149
|
+
readonly label: string;
|
|
150
|
+
/** Published slug for linked ancestors; absent for plain segments. */
|
|
151
|
+
readonly slug?: string;
|
|
152
|
+
readonly current: boolean;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Breadcrumb segments for a note slug. Ancestor segments link when a published
|
|
157
|
+
* note owns that path; the last segment is the current note (never linked).
|
|
158
|
+
*/
|
|
159
|
+
export function siteBreadcrumbs(
|
|
160
|
+
slug: string,
|
|
161
|
+
titleBySlug: ReadonlyMap<string, string>,
|
|
162
|
+
): readonly SiteBreadcrumb[] {
|
|
163
|
+
if (slug === "") return [];
|
|
164
|
+
const segments = slug.split("/");
|
|
165
|
+
return segments.map((segment, index) => {
|
|
166
|
+
const path = segments.slice(0, index + 1).join("/");
|
|
167
|
+
const current = index === segments.length - 1;
|
|
168
|
+
return {
|
|
169
|
+
label: titleBySlug.get(path) ?? segment,
|
|
170
|
+
...(current || !titleBySlug.has(path) ? {} : { slug: path }),
|
|
171
|
+
current,
|
|
172
|
+
};
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export type SiteTocEntry = {
|
|
177
|
+
readonly level: number;
|
|
178
|
+
readonly id: string;
|
|
179
|
+
readonly text: string;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const HEADING_PATTERN = /<h([1-6]) id="([^"]{0,256})">([\s\S]*?)<\/h\1>/gu;
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Table of contents from rendered note HTML. The renderer emits one
|
|
186
|
+
* `<hN id="…">` per authored heading on a single line with no nested headings,
|
|
187
|
+
* so a bounded scan is exact — ids and order come straight from the emitted
|
|
188
|
+
* document rather than a second anchoring implementation.
|
|
189
|
+
*/
|
|
190
|
+
export function siteTocFromHtml(
|
|
191
|
+
html: string,
|
|
192
|
+
limit = SITE_TOC_LIMIT,
|
|
193
|
+
): readonly SiteTocEntry[] {
|
|
194
|
+
const entries: SiteTocEntry[] = [];
|
|
195
|
+
for (const match of html.matchAll(HEADING_PATTERN)) {
|
|
196
|
+
if (entries.length >= limit) break;
|
|
197
|
+
entries.push({
|
|
198
|
+
level: Number(match[1]),
|
|
199
|
+
id: match[2] ?? "",
|
|
200
|
+
text: stripMarkup(match[3] ?? "").replace(/\s+/gu, " ").trim(),
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
return entries;
|
|
204
|
+
}
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import type { Note } from "./graph.js";
|
|
2
|
+
import { escapeAttribute, escapeHtml } from "./publish-markdown.js";
|
|
3
|
+
import type {
|
|
4
|
+
WordcellSiteCatalogEntryV1,
|
|
5
|
+
WordcellSiteManifestV1,
|
|
6
|
+
WordcellSiteNoteLinkV1,
|
|
7
|
+
WordcellSiteNoteRelationV1,
|
|
8
|
+
} from "./publish-model.js";
|
|
9
|
+
import {
|
|
10
|
+
pruneSiteNav,
|
|
11
|
+
siteBreadcrumbs,
|
|
12
|
+
siteTocFromHtml,
|
|
13
|
+
type SiteNavNode,
|
|
14
|
+
type SiteNavTree,
|
|
15
|
+
} from "./publish-nav.js";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Static page chrome for a published site. Pages are complete HTML documents
|
|
19
|
+
* that read without JavaScript: the sidebar tree, breadcrumbs, table of
|
|
20
|
+
* contents, and graph note index are all prerendered markup, and the bundled
|
|
21
|
+
* reader progressively adds search and the interactive graph. No inline
|
|
22
|
+
* scripts or styles are emitted so a strict `default-src 'self'`
|
|
23
|
+
* Content-Security-Policy applies to every page.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export const SITE_CONTENT_SECURITY_POLICY =
|
|
27
|
+
"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; connect-src 'self'; font-src 'self'; object-src 'none'; base-uri 'none'; form-action 'none'" as const;
|
|
28
|
+
|
|
29
|
+
export type PageContext = {
|
|
30
|
+
readonly site: WordcellSiteManifestV1["site"];
|
|
31
|
+
/** Relative path prefix from the current page to the site root ("" or "../"×n). */
|
|
32
|
+
readonly rel: string;
|
|
33
|
+
/** Noindex emitted on every page when the publisher asked for it. */
|
|
34
|
+
readonly noindex: boolean;
|
|
35
|
+
readonly generator: string;
|
|
36
|
+
/** Full catalog nav tree; chrome prunes it around `current` per page. */
|
|
37
|
+
readonly nav: SiteNavTree;
|
|
38
|
+
/** Slug of the page being rendered; "" when nothing in the tree is current. */
|
|
39
|
+
readonly current: string;
|
|
40
|
+
/** Slug → note title for breadcrumb labels. */
|
|
41
|
+
readonly titles: ReadonlyMap<string, string>;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export function relativePrefix(slug: string): string {
|
|
45
|
+
if (slug === "") return "";
|
|
46
|
+
const depth = slug.split("/").length;
|
|
47
|
+
return "../".repeat(depth + 1);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function noteHref(rel: string, slug: string): string {
|
|
51
|
+
return slug === "" ? rel || "./" : `${rel}n/${encodeURI(slug)}/`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function head(
|
|
55
|
+
title: string,
|
|
56
|
+
ctx: PageContext,
|
|
57
|
+
extra: readonly string[] = [],
|
|
58
|
+
): string {
|
|
59
|
+
const lines = [
|
|
60
|
+
`<meta charset="utf-8">`,
|
|
61
|
+
`<meta name="viewport" content="width=device-width, initial-scale=1">`,
|
|
62
|
+
`<title>${escapeHtml(title === "" ? ctx.site.title : `${title} — ${ctx.site.title}`)}</title>`,
|
|
63
|
+
`<meta name="generator" content="${escapeAttribute(ctx.generator)}">`,
|
|
64
|
+
`<meta name="wordcell:base" content="${escapeAttribute(ctx.rel || "./")}">`,
|
|
65
|
+
`<meta http-equiv="Content-Security-Policy" content="${escapeAttribute(SITE_CONTENT_SECURITY_POLICY)}">`,
|
|
66
|
+
`<script src="${escapeAttribute(ctx.rel)}reader/theme.js"></script>`,
|
|
67
|
+
`<link rel="stylesheet" href="${escapeAttribute(ctx.rel)}reader/reader.css">`,
|
|
68
|
+
`<script type="module" src="${escapeAttribute(ctx.rel)}reader/reader.js"></script>`,
|
|
69
|
+
];
|
|
70
|
+
if (ctx.site.description !== undefined) {
|
|
71
|
+
lines.push(`<meta name="description" content="${escapeAttribute(ctx.site.description)}">`);
|
|
72
|
+
}
|
|
73
|
+
if (ctx.noindex) lines.push(`<meta name="robots" content="noindex">`);
|
|
74
|
+
lines.push(...extra);
|
|
75
|
+
return lines.join("\n ");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function navLink(rel: string, node: SiteNavNode, current: string, extraClass = ""): string {
|
|
79
|
+
const isCurrent = node.slug !== undefined && node.slug === current;
|
|
80
|
+
const classes = `site-tree-link${isCurrent ? " site-tree-current" : ""}${extraClass}`;
|
|
81
|
+
return `<a class="${classes}" href="${escapeAttribute(noteHref(rel, node.slug ?? ""))}"${
|
|
82
|
+
isCurrent ? ` aria-current="page"` : ""
|
|
83
|
+
}>${escapeHtml(node.label)}</a>`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function navItems(
|
|
87
|
+
nodes: readonly SiteNavNode[],
|
|
88
|
+
current: string,
|
|
89
|
+
rel: string,
|
|
90
|
+
depth: number,
|
|
91
|
+
): string {
|
|
92
|
+
const items = nodes.map((node) => {
|
|
93
|
+
const grouped = node.children.length > 0 || (node.hiddenChildren ?? 0) > 0;
|
|
94
|
+
if (!grouped) {
|
|
95
|
+
const body = node.slug === undefined
|
|
96
|
+
? `<span class="site-tree-empty">${escapeHtml(node.label)}</span>`
|
|
97
|
+
: navLink(rel, node, current);
|
|
98
|
+
return ` <li class="site-tree-leaf">${body}</li>`;
|
|
99
|
+
}
|
|
100
|
+
const onPath = current !== ""
|
|
101
|
+
&& (current === node.path || current.startsWith(`${node.path}/`));
|
|
102
|
+
const open = depth === 0 || onPath;
|
|
103
|
+
const self = node.slug === undefined
|
|
104
|
+
? ""
|
|
105
|
+
: ` <li class="site-tree-self">${navLink(rel, node, current)}</li>\n`;
|
|
106
|
+
const more = (node.hiddenChildren ?? 0) === 0
|
|
107
|
+
? ""
|
|
108
|
+
: ` <li class="site-tree-more"><a href="${escapeAttribute(rel)}#catalog">+${String(node.hiddenChildren)} more</a></li>\n`;
|
|
109
|
+
return ` <li class="site-tree-group">
|
|
110
|
+
<details${open ? " open" : ""}>
|
|
111
|
+
<summary>${escapeHtml(node.label)}</summary>
|
|
112
|
+
<ul>
|
|
113
|
+
${self}${navItems(node.children, current, rel, depth + 1)}${more} </ul>
|
|
114
|
+
</details>
|
|
115
|
+
</li>`;
|
|
116
|
+
});
|
|
117
|
+
return items.join("\n") + (items.length === 0 ? "" : "\n");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function renderSiteNav(tree: SiteNavTree, current: string, rel: string): string {
|
|
121
|
+
if (tree.nodes.length === 0) return "";
|
|
122
|
+
const more = tree.hiddenRoots === 0
|
|
123
|
+
? ""
|
|
124
|
+
: ` <li class="site-tree-more"><a href="${escapeAttribute(rel)}#catalog">+${String(tree.hiddenRoots)} more</a></li>\n`;
|
|
125
|
+
return ` <nav class="site-nav" aria-label="Published notes">
|
|
126
|
+
<ul class="site-tree">
|
|
127
|
+
${navItems(tree.nodes, current, rel, 0)}${more} </ul>
|
|
128
|
+
</nav>`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function breadcrumbsHtml(ctx: PageContext): string {
|
|
132
|
+
const crumbs = siteBreadcrumbs(ctx.current, ctx.titles);
|
|
133
|
+
if (crumbs.length === 0) return "";
|
|
134
|
+
const items = [
|
|
135
|
+
` <li><a href="${escapeAttribute(ctx.rel || "./")}">Home</a></li>`,
|
|
136
|
+
...crumbs.map((crumb) => {
|
|
137
|
+
if (crumb.current) {
|
|
138
|
+
return ` <li><span aria-current="page">${escapeHtml(crumb.label)}</span></li>`;
|
|
139
|
+
}
|
|
140
|
+
if (crumb.slug === undefined) {
|
|
141
|
+
return ` <li><span>${escapeHtml(crumb.label)}</span></li>`;
|
|
142
|
+
}
|
|
143
|
+
return ` <li><a href="${escapeAttribute(noteHref(ctx.rel, crumb.slug))}">${escapeHtml(crumb.label)}</a></li>`;
|
|
144
|
+
}),
|
|
145
|
+
];
|
|
146
|
+
return ` <nav class="breadcrumbs" aria-label="Breadcrumb">
|
|
147
|
+
<ol>
|
|
148
|
+
${items.join("\n")}
|
|
149
|
+
</ol>
|
|
150
|
+
</nav>\n`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function tocHtml(bodyHtml: string): string {
|
|
154
|
+
const toc = siteTocFromHtml(bodyHtml);
|
|
155
|
+
if (toc.length < 2) return "";
|
|
156
|
+
const items = toc.map((entry) =>
|
|
157
|
+
` <li class="toc-level-${String(Math.min(entry.level, 4))}"><a href="#${escapeAttribute(entry.id)}">${escapeHtml(entry.text)}</a></li>`,
|
|
158
|
+
);
|
|
159
|
+
return ` <nav class="note-toc" aria-label="On this page">
|
|
160
|
+
<h2>On this page</h2>
|
|
161
|
+
<ol>
|
|
162
|
+
${items.join("\n")}
|
|
163
|
+
</ol>
|
|
164
|
+
</nav>`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function chrome(
|
|
168
|
+
title: string,
|
|
169
|
+
main: string,
|
|
170
|
+
ctx: PageContext,
|
|
171
|
+
extra: readonly string[] = [],
|
|
172
|
+
options: { readonly graph?: boolean } = {},
|
|
173
|
+
): string {
|
|
174
|
+
const nav = renderSiteNav(pruneSiteNav(ctx.nav, ctx.current), ctx.current, ctx.rel);
|
|
175
|
+
const graphCurrent = options.graph === true ? ` aria-current="page"` : "";
|
|
176
|
+
const layoutClass = nav === "" ? "site-layout site-layout-flat" : "site-layout";
|
|
177
|
+
return `<!doctype html>
|
|
178
|
+
<html lang="en">
|
|
179
|
+
<head>
|
|
180
|
+
${head(title, ctx, extra)}
|
|
181
|
+
</head>
|
|
182
|
+
<body>
|
|
183
|
+
<a class="skip-link" href="#wordcell-main">Skip to content</a>
|
|
184
|
+
<header class="site-header">
|
|
185
|
+
<a class="site-title" href="${escapeAttribute(ctx.rel || "./")}">${escapeHtml(ctx.site.title)}</a>
|
|
186
|
+
<nav class="site-actions" aria-label="Site">
|
|
187
|
+
<a class="graph-link" href="${escapeAttribute(ctx.rel)}graph/"${graphCurrent}>Graph</a>
|
|
188
|
+
<button type="button" class="search-button" data-wordcell-search aria-keyshortcuts="/">
|
|
189
|
+
<span>Search</span><kbd>/</kbd>
|
|
190
|
+
</button>
|
|
191
|
+
</nav>
|
|
192
|
+
</header>
|
|
193
|
+
<div class="${layoutClass}">
|
|
194
|
+
${nav}
|
|
195
|
+
<main id="wordcell-main">
|
|
196
|
+
${main}
|
|
197
|
+
</main>
|
|
198
|
+
</div>
|
|
199
|
+
<footer class="site-footer">
|
|
200
|
+
<span>Published with <a href="https://wordcell.io" rel="noopener noreferrer">Wordcell</a>.</span>
|
|
201
|
+
</footer>
|
|
202
|
+
</body>
|
|
203
|
+
</html>
|
|
204
|
+
`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function propertiesBlock(note: Note): string {
|
|
208
|
+
const rows: string[] = [];
|
|
209
|
+
const type = note.metadata["type"];
|
|
210
|
+
if (typeof type === "string" && type !== "") {
|
|
211
|
+
rows.push(`<span class="note-type">${escapeHtml(type)}</span>`);
|
|
212
|
+
}
|
|
213
|
+
for (const tag of note.tags) {
|
|
214
|
+
rows.push(`<span class="note-tag">${escapeHtml(tag)}</span>`);
|
|
215
|
+
}
|
|
216
|
+
if (rows.length === 0) return "";
|
|
217
|
+
return `<div class="note-meta">${rows.join("\n ")}</div>`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function linkList(title: string, links: readonly WordcellSiteNoteLinkV1[], rel: string): string {
|
|
221
|
+
if (links.length === 0) return "";
|
|
222
|
+
const items = links.map((link) =>
|
|
223
|
+
` <li><a href="${escapeAttribute(noteHref(rel, link.s))}">${escapeHtml(link.t)}</a></li>`,
|
|
224
|
+
).join("\n");
|
|
225
|
+
return ` <section class="note-links">
|
|
226
|
+
<h2>${escapeHtml(title)}</h2>
|
|
227
|
+
<ul>
|
|
228
|
+
${items}
|
|
229
|
+
</ul>
|
|
230
|
+
</section>`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function relationList(
|
|
234
|
+
title: string,
|
|
235
|
+
relations: readonly WordcellSiteNoteRelationV1[],
|
|
236
|
+
rel: string,
|
|
237
|
+
): string {
|
|
238
|
+
if (relations.length === 0) return "";
|
|
239
|
+
const items = relations.map((relation) =>
|
|
240
|
+
` <li><span class="predicate">${escapeHtml(relation.p)}</span> <a href="${escapeAttribute(noteHref(rel, relation.s))}">${escapeHtml(relation.t)}</a></li>`,
|
|
241
|
+
).join("\n");
|
|
242
|
+
return ` <section class="note-relations">
|
|
243
|
+
<h2>${escapeHtml(title)}</h2>
|
|
244
|
+
<ul>
|
|
245
|
+
${items}
|
|
246
|
+
</ul>
|
|
247
|
+
</section>`;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Reuse only a plain leading title; preserve its authored anchor and all other HTML. */
|
|
251
|
+
function notePageTitle(title: string, bodyHtml: string): { heading: string; body: string } {
|
|
252
|
+
const leading = /^\s*(<h1 id="[^"]*">([^<]*)<\/h1>)/u.exec(bodyHtml);
|
|
253
|
+
if (leading !== null && leading[2] === escapeHtml(title)) {
|
|
254
|
+
return { heading: leading[1] ?? "", body: bodyHtml.slice(leading[0].length) };
|
|
255
|
+
}
|
|
256
|
+
return { heading: `<h1>${escapeHtml(title)}</h1>`, body: bodyHtml };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function renderNotePage(
|
|
260
|
+
note: Note,
|
|
261
|
+
bodyHtml: string,
|
|
262
|
+
sides: {
|
|
263
|
+
readonly links: readonly WordcellSiteNoteLinkV1[];
|
|
264
|
+
readonly backlinks: readonly WordcellSiteNoteLinkV1[];
|
|
265
|
+
readonly relations: readonly WordcellSiteNoteRelationV1[];
|
|
266
|
+
readonly relationBacklinks: readonly WordcellSiteNoteRelationV1[];
|
|
267
|
+
},
|
|
268
|
+
ctx: PageContext,
|
|
269
|
+
): string {
|
|
270
|
+
const meta = propertiesBlock(note);
|
|
271
|
+
const aliasRow = note.aliases.length === 0
|
|
272
|
+
? ""
|
|
273
|
+
: `<p class="note-aliases">Also known as ${escapeHtml(note.aliases.join(", "))}</p>`;
|
|
274
|
+
const aside = [
|
|
275
|
+
linkList("Linked from", sides.backlinks, ctx.rel),
|
|
276
|
+
relationList("Relations", sides.relations, ctx.rel),
|
|
277
|
+
relationList("Referenced by", sides.relationBacklinks, ctx.rel),
|
|
278
|
+
].filter((section) => section !== "").join("\n");
|
|
279
|
+
const toc = tocHtml(bodyHtml);
|
|
280
|
+
const title = notePageTitle(note.title, bodyHtml);
|
|
281
|
+
const article = ` <article class="note">
|
|
282
|
+
${title.heading}
|
|
283
|
+
${meta}
|
|
284
|
+
${aliasRow}
|
|
285
|
+
<div class="note-body">
|
|
286
|
+
${title.body}
|
|
287
|
+
</div>
|
|
288
|
+
</article>${aside === "" ? "" : `\n <aside class="note-aside">\n${aside}\n </aside>`}`;
|
|
289
|
+
const main = `${breadcrumbsHtml(ctx)} <div class="note-columns">
|
|
290
|
+
${article}
|
|
291
|
+
${toc}
|
|
292
|
+
</div>`;
|
|
293
|
+
return chrome(note.title, main, ctx);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function renderLandingPage(
|
|
297
|
+
indexBodyHtml: string | undefined,
|
|
298
|
+
entries: readonly WordcellSiteCatalogEntryV1[],
|
|
299
|
+
ctx: PageContext,
|
|
300
|
+
): string {
|
|
301
|
+
const groups = new Map<string, WordcellSiteCatalogEntryV1[]>();
|
|
302
|
+
for (const entry of entries) {
|
|
303
|
+
const top = entry.s.split("/")[0] ?? "";
|
|
304
|
+
const group = groups.get(top) ?? [];
|
|
305
|
+
group.push(entry);
|
|
306
|
+
groups.set(top, group);
|
|
307
|
+
}
|
|
308
|
+
const sections = [...groups.entries()]
|
|
309
|
+
.toSorted(([left], [right]) => left.localeCompare(right))
|
|
310
|
+
.map(([group, members]) => {
|
|
311
|
+
const items = members.map((entry) =>
|
|
312
|
+
` <li><a href="${escapeAttribute(noteHref(ctx.rel, entry.s))}">${escapeHtml(entry.t)}</a></li>`,
|
|
313
|
+
).join("\n");
|
|
314
|
+
return ` <section class="catalog-group">
|
|
315
|
+
<h2>${escapeHtml(group === "" ? "Notes" : group)}</h2>
|
|
316
|
+
<ul>
|
|
317
|
+
${items}
|
|
318
|
+
</ul>
|
|
319
|
+
</section>`;
|
|
320
|
+
}).join("\n");
|
|
321
|
+
const body = indexBodyHtml === undefined || indexBodyHtml === ""
|
|
322
|
+
? ""
|
|
323
|
+
: ` <div class="note-body index-body">\n${indexBodyHtml}\n </div>\n`;
|
|
324
|
+
const main = `${body} <nav class="catalog" id="catalog" aria-label="All published notes">
|
|
325
|
+
${sections}
|
|
326
|
+
</nav>`;
|
|
327
|
+
return chrome("", main, ctx, [
|
|
328
|
+
`<meta name="wordcell:landing" content="true">`,
|
|
329
|
+
]);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export function renderGraphPage(
|
|
333
|
+
entries: readonly WordcellSiteCatalogEntryV1[],
|
|
334
|
+
edgeCount: number,
|
|
335
|
+
ctx: PageContext,
|
|
336
|
+
): string {
|
|
337
|
+
const items = entries.map((entry) =>
|
|
338
|
+
` <li><a href="${escapeAttribute(noteHref(ctx.rel, entry.s))}">${escapeHtml(entry.t)}</a></li>`,
|
|
339
|
+
).join("\n");
|
|
340
|
+
const main = ` <article class="note graph-page">
|
|
341
|
+
<h1>Graph</h1>
|
|
342
|
+
<p class="graph-lede">${String(entries.length)} notes and ${String(edgeCount)} links. Drag to pan, scroll or pinch to zoom, click a node to open the note.</p>
|
|
343
|
+
<div class="graph-shell" data-wordcell-graph>
|
|
344
|
+
<canvas class="graph-canvas" data-wordcell-graph-canvas width="1280" height="720" tabindex="0" role="img" aria-label="Interactive map of ${String(entries.length)} published notes"></canvas>
|
|
345
|
+
<div class="graph-status" data-wordcell-graph-status>The interactive map needs JavaScript. Every note is listed below.</div>
|
|
346
|
+
<div class="graph-toolbar">
|
|
347
|
+
<button type="button" class="graph-reset" data-wordcell-graph-reset>Reset view</button>
|
|
348
|
+
<span class="graph-legend"><span class="graph-edge graph-edge-link"></span> link <span class="graph-edge graph-edge-relation"></span> typed relation</span>
|
|
349
|
+
</div>
|
|
350
|
+
</div>
|
|
351
|
+
<h2>All notes</h2>
|
|
352
|
+
<ul class="graph-index">
|
|
353
|
+
${items}
|
|
354
|
+
</ul>
|
|
355
|
+
</article>`;
|
|
356
|
+
return chrome("Graph", main, ctx, [], { graph: true });
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export function renderNotFoundPage(ctx: PageContext): string {
|
|
360
|
+
return chrome("Not found", ` <article class="note">
|
|
361
|
+
<h1>Not found</h1>
|
|
362
|
+
<p>This page is not part of the published site.</p>
|
|
363
|
+
</article>`, { ...ctx, noindex: true });
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export function renderRobotsTxt(noindex: boolean): string {
|
|
367
|
+
return noindex
|
|
368
|
+
? "User-agent: *\nDisallow: /\n"
|
|
369
|
+
: "User-agent: *\nAllow: /\n";
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export function renderSitemapXml(slugs: readonly string[], baseUrl: string): string {
|
|
373
|
+
const base = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
|
|
374
|
+
const urls = slugs.map((slug) =>
|
|
375
|
+
` <url><loc>${escapeHtml(base + (slug === "" ? "" : `n/${encodeURI(slug)}/`))}</loc></url>`,
|
|
376
|
+
).join("\n");
|
|
377
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
378
|
+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
379
|
+
${urls}
|
|
380
|
+
</urlset>
|
|
381
|
+
`;
|
|
382
|
+
}
|