@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,617 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Restricted Markdown→HTML renderer for published sites. Output is safe by
|
|
3
|
+
* construction: every text span is entity-escaped, raw HTML is never passed
|
|
4
|
+
* through, and only the authored block/inline grammar below produces markup.
|
|
5
|
+
* The renderer is pure and deterministic — link and asset resolution arrive
|
|
6
|
+
* as closures so the same vault always renders the same bytes.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type PublishRenderContext = {
|
|
10
|
+
/** Source note path of the document being rendered (for asset resolution). */
|
|
11
|
+
readonly source: string;
|
|
12
|
+
/** Resolve a wikilink target to a published note, or undefined when the
|
|
13
|
+
* target is excluded, ambiguous, or missing. */
|
|
14
|
+
readonly resolveNote: (rawTarget: string) =>
|
|
15
|
+
| { readonly slug: string; readonly title: string }
|
|
16
|
+
| undefined;
|
|
17
|
+
/** Resolve a local attachment target to its published asset URL. */
|
|
18
|
+
readonly resolveAsset: (rawTarget: string, source: string) => string | undefined;
|
|
19
|
+
/** Build a site-relative href for a published note slug. */
|
|
20
|
+
readonly noteHref: (slug: string) => string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const VOID_SLUG = /[^\p{L}\p{N}._~-]+/gu;
|
|
24
|
+
|
|
25
|
+
export function escapeHtml(value: string): string {
|
|
26
|
+
return value
|
|
27
|
+
.replaceAll("&", "&")
|
|
28
|
+
.replaceAll("<", "<")
|
|
29
|
+
.replaceAll(">", ">")
|
|
30
|
+
.replaceAll('"', """)
|
|
31
|
+
.replaceAll("'", "'");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function escapeAttribute(value: string): string {
|
|
35
|
+
return escapeHtml(value);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function anchorId(text: string, used: Map<string, number>): string {
|
|
39
|
+
const base = text
|
|
40
|
+
.normalize("NFC")
|
|
41
|
+
.toLocaleLowerCase("en-US")
|
|
42
|
+
.replace(VOID_SLUG, "-")
|
|
43
|
+
.replace(/^[-._~]+|[-._~]+$/gu, "") || "section";
|
|
44
|
+
const seen = used.get(base) ?? 0;
|
|
45
|
+
used.set(base, seen + 1);
|
|
46
|
+
return seen === 0 ? base : `${base}-${seen + 1}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isExternalUrl(value: string): boolean {
|
|
50
|
+
return /^(?:https?|mailto):/iu.test(value);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isSafeUrl(value: string): boolean {
|
|
54
|
+
const trimmed = value.trim();
|
|
55
|
+
return !/^[a-z][a-z\d+.-]*:/iu.test(trimmed) || isExternalUrl(trimmed);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function splitWikiTarget(raw: string): { target: string; fragment: string; alias?: string } {
|
|
59
|
+
const pipe = raw.indexOf("|");
|
|
60
|
+
const alias = pipe === -1 ? undefined : raw.slice(pipe + 1).trim();
|
|
61
|
+
const head = pipe === -1 ? raw : raw.slice(0, pipe);
|
|
62
|
+
const hash = head.indexOf("#");
|
|
63
|
+
const caret = head.indexOf("^");
|
|
64
|
+
let cut = head.length;
|
|
65
|
+
if (hash !== -1) cut = Math.min(cut, hash);
|
|
66
|
+
if (caret !== -1) cut = Math.min(cut, caret);
|
|
67
|
+
const target = head.slice(0, cut).trim();
|
|
68
|
+
const fragment = hash === -1
|
|
69
|
+
? ""
|
|
70
|
+
: head.slice(hash + 1).split("^")[0]?.trim() ?? "";
|
|
71
|
+
return { target, fragment, ...(alias === undefined ? {} : { alias }) };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function wikiDisplay(raw: string): { target: string; fragment: string; label: string } {
|
|
75
|
+
const { target, fragment, alias } = splitWikiTarget(raw);
|
|
76
|
+
return {
|
|
77
|
+
target,
|
|
78
|
+
fragment,
|
|
79
|
+
label: alias ?? (fragment === "" ? target : `${target} > ${fragment}`),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function renderInline(text: string, ctx: PublishRenderContext): string {
|
|
84
|
+
let output = "";
|
|
85
|
+
let cursor = 0;
|
|
86
|
+
const length = text.length;
|
|
87
|
+
|
|
88
|
+
const pushText = (value: string): void => {
|
|
89
|
+
output += escapeHtml(value);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
while (cursor < length) {
|
|
93
|
+
const rest = text.slice(cursor);
|
|
94
|
+
|
|
95
|
+
// Inline code: `code` or ``code`` spans.
|
|
96
|
+
if (rest[0] === "`") {
|
|
97
|
+
const ticks = /^`+/u.exec(rest)?.[0] ?? "`";
|
|
98
|
+
const closing = rest.indexOf(ticks, ticks.length);
|
|
99
|
+
if (closing !== -1) {
|
|
100
|
+
const code = rest.slice(ticks.length, closing).replace(/\s+/gu, " ");
|
|
101
|
+
output += `<code>${escapeHtml(code)}</code>`;
|
|
102
|
+
cursor += ticks.length + closing + ticks.length;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
pushText("`");
|
|
106
|
+
cursor += 1;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Obsidian embeds: ![[target]]
|
|
111
|
+
if (rest.startsWith("![[")) {
|
|
112
|
+
const closing = rest.indexOf("]]");
|
|
113
|
+
if (closing !== -1) {
|
|
114
|
+
const raw = rest.slice(3, closing);
|
|
115
|
+
const { target, fragment, label } = wikiDisplay(raw);
|
|
116
|
+
if (target === "" && fragment !== "") {
|
|
117
|
+
output += `<a href="#${escapeAttribute(fragment.normalize("NFC").toLocaleLowerCase("en-US").replace(VOID_SLUG, "-"))}">${escapeHtml(fragment)}</a>`;
|
|
118
|
+
cursor += closing + 2;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const asset = ctx.resolveAsset(target, ctx.source);
|
|
122
|
+
if (asset !== undefined) {
|
|
123
|
+
if (/\.(?:apng|avif|bmp|gif|heic|heif|jpe?g|png|svg|webp)$/iu.test(asset)) {
|
|
124
|
+
output += `<img src="${escapeAttribute(asset)}" alt="${escapeAttribute(label)}" loading="lazy">`;
|
|
125
|
+
} else {
|
|
126
|
+
output += `<a class="asset" href="${escapeAttribute(asset)}">${escapeHtml(label)}</a>`;
|
|
127
|
+
}
|
|
128
|
+
} else {
|
|
129
|
+
const note = ctx.resolveNote(target);
|
|
130
|
+
if (note === undefined) {
|
|
131
|
+
output += `<span class="unresolved">${escapeHtml(label)}</span>`;
|
|
132
|
+
} else {
|
|
133
|
+
const hash = fragment === "" ? "" : `#${anchorId(fragment, new Map())}`;
|
|
134
|
+
output += `<a href="${escapeAttribute(ctx.noteHref(note.slug) + hash)}">${escapeHtml(label === "" ? note.title : label)}</a>`;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
cursor += closing + 2;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
pushText("!");
|
|
141
|
+
cursor += 1;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Wikilinks: [[target|alias]]
|
|
146
|
+
if (rest.startsWith("[[")) {
|
|
147
|
+
const closing = rest.indexOf("]]");
|
|
148
|
+
if (closing !== -1) {
|
|
149
|
+
const raw = rest.slice(2, closing);
|
|
150
|
+
const { target, fragment, label } = wikiDisplay(raw);
|
|
151
|
+
if (target === "" && fragment !== "") {
|
|
152
|
+
output += `<a href="#${escapeAttribute(fragment.normalize("NFC").toLocaleLowerCase("en-US").replace(VOID_SLUG, "-"))}">${escapeHtml(fragment)}</a>`;
|
|
153
|
+
cursor += closing + 2;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
const note = ctx.resolveNote(target);
|
|
157
|
+
if (note === undefined) {
|
|
158
|
+
output += `<span class="unresolved">${escapeHtml(label)}</span>`;
|
|
159
|
+
} else {
|
|
160
|
+
const hash = fragment === "" ? "" : `#${anchorId(fragment, new Map())}`;
|
|
161
|
+
output += `<a href="${escapeAttribute(ctx.noteHref(note.slug) + hash)}">${escapeHtml(label === "" ? note.title : label)}</a>`;
|
|
162
|
+
}
|
|
163
|
+
cursor += closing + 2;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
pushText("[");
|
|
167
|
+
cursor += 1;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Images: 
|
|
172
|
+
if (rest.startsWith("![")) {
|
|
173
|
+
const closing = findClosingBracket(rest, 1);
|
|
174
|
+
if (closing !== -1 && rest[closing + 1] === "(") {
|
|
175
|
+
const destination = readDestination(rest, closing + 2);
|
|
176
|
+
if (destination !== null) {
|
|
177
|
+
const alt = rest.slice(2, closing);
|
|
178
|
+
const asset = ctx.resolveAsset(destination.target, ctx.source);
|
|
179
|
+
if (asset !== undefined) {
|
|
180
|
+
output += `<img src="${escapeAttribute(asset)}" alt="${escapeAttribute(alt)}" loading="lazy">`;
|
|
181
|
+
} else if (isExternalUrl(destination.target)) {
|
|
182
|
+
output += `<a class="asset" href="${escapeAttribute(destination.target)}" rel="noopener noreferrer">${escapeHtml(alt === "" ? destination.target : alt)}</a>`;
|
|
183
|
+
} else {
|
|
184
|
+
output += `<span class="unresolved">${escapeHtml(alt)}</span>`;
|
|
185
|
+
}
|
|
186
|
+
cursor += destination.end;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
pushText("!");
|
|
191
|
+
cursor += 1;
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Markdown links: [text](href)
|
|
196
|
+
if (rest[0] === "[") {
|
|
197
|
+
const closing = findClosingBracket(rest, 0);
|
|
198
|
+
if (closing !== -1 && rest[closing + 1] === "(") {
|
|
199
|
+
const destination = readDestination(rest, closing + 2);
|
|
200
|
+
if (destination !== null) {
|
|
201
|
+
const label = renderInline(rest.slice(1, closing), ctx);
|
|
202
|
+
const target = destination.target;
|
|
203
|
+
const asset = ctx.resolveAsset(target, ctx.source);
|
|
204
|
+
if (asset !== undefined) {
|
|
205
|
+
output += `<a class="asset" href="${escapeAttribute(asset)}">${label}</a>`;
|
|
206
|
+
} else if (isSafeUrl(target)) {
|
|
207
|
+
const external = isExternalUrl(target);
|
|
208
|
+
const note = external ? undefined : ctx.resolveNote(target.replace(/\.md$/iu, ""));
|
|
209
|
+
if (note !== undefined) {
|
|
210
|
+
const hash = destination.fragment === "" ? "" : `#${anchorId(destination.fragment, new Map())}`;
|
|
211
|
+
output += `<a href="${escapeAttribute(ctx.noteHref(note.slug) + hash)}">${label}</a>`;
|
|
212
|
+
} else {
|
|
213
|
+
output += `<a href="${escapeAttribute(target)}"${external ? ' rel="noopener noreferrer"' : ""}>${label}</a>`;
|
|
214
|
+
}
|
|
215
|
+
} else {
|
|
216
|
+
output += label;
|
|
217
|
+
}
|
|
218
|
+
cursor += destination.end;
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
pushText("[");
|
|
223
|
+
cursor += 1;
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Autolinks: <https://…>
|
|
228
|
+
if (rest[0] === "<") {
|
|
229
|
+
const match = /^<([a-z][a-z\d+.-]*:[^>\s]+)>/iu.exec(rest);
|
|
230
|
+
if (match !== null && isExternalUrl(match[1] ?? "")) {
|
|
231
|
+
output += `<a href="${escapeAttribute(match[1] ?? "")}" rel="noopener noreferrer">${escapeHtml(match[1] ?? "")}</a>`;
|
|
232
|
+
cursor += (match[0] ?? "").length;
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
// Raw HTML tags render as escaped text.
|
|
236
|
+
const tagMatch = /^<\/?[a-z][^>]*>/iu.exec(rest);
|
|
237
|
+
if (tagMatch !== null) {
|
|
238
|
+
pushText(tagMatch[0]);
|
|
239
|
+
cursor += tagMatch[0].length;
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
pushText("<");
|
|
243
|
+
cursor += 1;
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Strong/emphasis/strikethrough/highlight — simple balanced delimiters.
|
|
248
|
+
const emphasis = /^(?:\*\*|__|\*|~~|==|_)/u.exec(rest)?.[0];
|
|
249
|
+
if (emphasis !== undefined && emphasis !== "_") {
|
|
250
|
+
const closing = rest.indexOf(emphasis, emphasis.length);
|
|
251
|
+
if (closing > emphasis.length) {
|
|
252
|
+
const inner = renderInline(rest.slice(emphasis.length, closing), ctx);
|
|
253
|
+
const tag = emphasis === "**" || emphasis === "__"
|
|
254
|
+
? "strong"
|
|
255
|
+
: emphasis === "~~"
|
|
256
|
+
? "del"
|
|
257
|
+
: emphasis === "=="
|
|
258
|
+
? "mark"
|
|
259
|
+
: "em";
|
|
260
|
+
output += `<${tag}>${inner}</${tag}>`;
|
|
261
|
+
cursor += closing + emphasis.length;
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (emphasis === "_") {
|
|
266
|
+
const closing = rest.indexOf("_", 1);
|
|
267
|
+
if (closing > 1) {
|
|
268
|
+
output += `<em>${renderInline(rest.slice(1, closing), ctx)}</em>`;
|
|
269
|
+
cursor += closing + 1;
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Escaped punctuation.
|
|
275
|
+
if (rest[0] === "\\" && length > cursor + 1 && /[\\`*[\]#|!><~=_-]/u.test(rest[1] ?? "")) {
|
|
276
|
+
pushText(rest[1] ?? "");
|
|
277
|
+
cursor += 2;
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
pushText(rest[0] ?? "");
|
|
282
|
+
cursor += 1;
|
|
283
|
+
}
|
|
284
|
+
return output;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function findClosingBracket(text: string, opening: number): number {
|
|
288
|
+
for (let index = opening + 1; index < text.length; index += 1) {
|
|
289
|
+
if (text[index] === "\\") {
|
|
290
|
+
index += 1;
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
if (text[index] === "]") return index;
|
|
294
|
+
if (text[index] === "[" || text[index] === "\n") return -1;
|
|
295
|
+
}
|
|
296
|
+
return -1;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function readDestination(
|
|
300
|
+
text: string,
|
|
301
|
+
start: number,
|
|
302
|
+
): { target: string; fragment: string; end: number } | null {
|
|
303
|
+
let cursor = start;
|
|
304
|
+
while (text[cursor] === " " || text[cursor] === "\t") cursor += 1;
|
|
305
|
+
let target = "";
|
|
306
|
+
if (text[cursor] === "<") {
|
|
307
|
+
const end = text.indexOf(">", cursor + 1);
|
|
308
|
+
if (end === -1) return null;
|
|
309
|
+
target = text.slice(cursor + 1, end);
|
|
310
|
+
cursor = end + 1;
|
|
311
|
+
} else {
|
|
312
|
+
let depth = 0;
|
|
313
|
+
const begin = cursor;
|
|
314
|
+
for (; cursor < text.length; cursor += 1) {
|
|
315
|
+
const character = text[cursor];
|
|
316
|
+
if (character === "\\") {
|
|
317
|
+
cursor += 1;
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
if (character === "(") depth += 1;
|
|
321
|
+
else if (character === ")") {
|
|
322
|
+
if (depth === 0) break;
|
|
323
|
+
depth -= 1;
|
|
324
|
+
} else if (character === " " || character === "\t" || character === "\n") {
|
|
325
|
+
break;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
target = text.slice(begin, cursor);
|
|
329
|
+
}
|
|
330
|
+
// Optional "title" after whitespace.
|
|
331
|
+
while (text[cursor] === " " || text[cursor] === "\t") cursor += 1;
|
|
332
|
+
const quoteChar = text[cursor];
|
|
333
|
+
if (quoteChar === '"' || quoteChar === "'") {
|
|
334
|
+
const end = text.indexOf(quoteChar, cursor + 1);
|
|
335
|
+
if (end !== -1) cursor = end + 1;
|
|
336
|
+
while (text[cursor] === " " || text[cursor] === "\t") cursor += 1;
|
|
337
|
+
}
|
|
338
|
+
if (text[cursor] !== ")") return null;
|
|
339
|
+
const hash = target.indexOf("#");
|
|
340
|
+
const fragment = hash === -1 ? "" : target.slice(hash + 1);
|
|
341
|
+
return {
|
|
342
|
+
target: hash === -1 ? target : target.slice(0, hash),
|
|
343
|
+
fragment: decodeURIComponentSafe(fragment),
|
|
344
|
+
end: cursor + 1,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function decodeURIComponentSafe(value: string): string {
|
|
349
|
+
try {
|
|
350
|
+
return decodeURIComponent(value);
|
|
351
|
+
} catch {
|
|
352
|
+
return value;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
type ListMarker = {
|
|
357
|
+
readonly indent: number;
|
|
358
|
+
readonly ordered: boolean;
|
|
359
|
+
readonly checked?: boolean;
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
function listMarker(line: string): ListMarker | undefined {
|
|
363
|
+
const match = /^(\s*)(?:([-+*])|(\d{1,9})[.)])(\s+)/u.exec(line);
|
|
364
|
+
if (match === null) return undefined;
|
|
365
|
+
const indent = (match[1] ?? "").replaceAll("\t", " ").length;
|
|
366
|
+
const body = line.slice((match[0] ?? "").length);
|
|
367
|
+
const task = /^\[([ xX])\]\s+/u.exec(body);
|
|
368
|
+
return {
|
|
369
|
+
indent,
|
|
370
|
+
ordered: match[3] !== undefined,
|
|
371
|
+
...(task === null ? {} : { checked: (task[1] ?? " ").toLowerCase() === "x" }),
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function isTableDivider(line: string): boolean {
|
|
376
|
+
return /^\s*\|?(?:\s*:?-{1,}:?\s*\|)+\s*:?-{0,}:?\s*$/u.test(line);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function tableCells(line: string): string[] {
|
|
380
|
+
const trimmed = line.trim().replace(/^\|/u, "").replace(/\|$/u, "");
|
|
381
|
+
const cells: string[] = [];
|
|
382
|
+
let current = "";
|
|
383
|
+
for (let index = 0; index < trimmed.length; index += 1) {
|
|
384
|
+
const character = trimmed[index];
|
|
385
|
+
if (character === "\\" && trimmed[index + 1] === "|") {
|
|
386
|
+
current += "|";
|
|
387
|
+
index += 1;
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
if (character === "|") {
|
|
391
|
+
cells.push(current);
|
|
392
|
+
current = "";
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
current += character;
|
|
396
|
+
}
|
|
397
|
+
cells.push(current);
|
|
398
|
+
return cells.map((cell) => cell.trim());
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** Render authored Markdown to a sanitized HTML fragment. */
|
|
402
|
+
export function renderMarkdownToHtml(content: string, ctx: PublishRenderContext): string {
|
|
403
|
+
const lines = content.split("\n");
|
|
404
|
+
// Strip frontmatter.
|
|
405
|
+
let start = 0;
|
|
406
|
+
if (lines[0]?.trim() === "---") {
|
|
407
|
+
for (let index = 1; index < lines.length; index += 1) {
|
|
408
|
+
if (lines[index]?.trim() === "---" || lines[index]?.trim() === "...") {
|
|
409
|
+
start = index + 1;
|
|
410
|
+
break;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const anchors = new Map<string, number>();
|
|
416
|
+
const html: string[] = [];
|
|
417
|
+
let index = start;
|
|
418
|
+
let inComment = false;
|
|
419
|
+
|
|
420
|
+
const paragraph: string[] = [];
|
|
421
|
+
const flushParagraph = (): void => {
|
|
422
|
+
if (paragraph.length === 0) return;
|
|
423
|
+
html.push(`<p>${renderInline(paragraph.join("\n").replace(/\n/gu, " "), ctx)}</p>`);
|
|
424
|
+
paragraph.length = 0;
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
while (index < lines.length) {
|
|
428
|
+
const line = lines[index] ?? "";
|
|
429
|
+
|
|
430
|
+
// HTML comments and Obsidian %% comments are stripped entirely.
|
|
431
|
+
if (inComment) {
|
|
432
|
+
const close = line.indexOf("-->");
|
|
433
|
+
index += 1;
|
|
434
|
+
if (close !== -1) inComment = false;
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
if (line.trim().startsWith("%%") && line.trim().endsWith("%%") && line.trim().length > 3) {
|
|
438
|
+
index += 1;
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Fenced code.
|
|
443
|
+
const fence = /^\s{0,3}(`{3,}|~{3,})([^`]*)$/u.exec(line);
|
|
444
|
+
if (fence !== null) {
|
|
445
|
+
flushParagraph();
|
|
446
|
+
const marker = fence[1] ?? "```";
|
|
447
|
+
const language = (fence[2] ?? "").trim().split(/\s/u)[0] ?? "";
|
|
448
|
+
const body: string[] = [];
|
|
449
|
+
index += 1;
|
|
450
|
+
while (index < lines.length) {
|
|
451
|
+
const candidate = lines[index] ?? "";
|
|
452
|
+
const close = /^\s{0,3}(`{3,}|~{3,})\s*$/u.exec(candidate);
|
|
453
|
+
if (close !== null && (close[1] ?? "")[0] === marker[0] && (close[1] ?? "").length >= marker.length) {
|
|
454
|
+
break;
|
|
455
|
+
}
|
|
456
|
+
body.push(candidate);
|
|
457
|
+
index += 1;
|
|
458
|
+
}
|
|
459
|
+
index += 1;
|
|
460
|
+
const className = language === "" ? "" : ` class="language-${escapeAttribute(language)}"`;
|
|
461
|
+
html.push(`<pre><code${className}>${escapeHtml(body.join("\n"))}</code></pre>`);
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// Blank line.
|
|
466
|
+
if (line.trim() === "") {
|
|
467
|
+
flushParagraph();
|
|
468
|
+
index += 1;
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// ATX heading.
|
|
473
|
+
const heading = /^(#{1,6})\s+(.*)$/u.exec(line);
|
|
474
|
+
if (heading !== null) {
|
|
475
|
+
flushParagraph();
|
|
476
|
+
const level = (heading[1] ?? "#").length;
|
|
477
|
+
const body = renderInline((heading[2] ?? "").replace(/\s+#+$/u, ""), ctx);
|
|
478
|
+
const id = anchorId(stripMarkup(body), anchors);
|
|
479
|
+
html.push(`<h${level} id="${escapeAttribute(id)}">${body}</h${level}>`);
|
|
480
|
+
index += 1;
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// Thematic break.
|
|
485
|
+
if (/^\s{0,3}(?:\*\s*){3,}$/u.test(line) || /^\s{0,3}(?:-\s*){3,}$/u.test(line) || /^\s{0,3}(?:_\s*){3,}$/u.test(line)) {
|
|
486
|
+
flushParagraph();
|
|
487
|
+
html.push("<hr>");
|
|
488
|
+
index += 1;
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Blockquote (one level per pass; nesting via > > accumulation).
|
|
493
|
+
if (/^\s{0,3}>/u.test(line)) {
|
|
494
|
+
flushParagraph();
|
|
495
|
+
const quote: string[] = [];
|
|
496
|
+
while (index < lines.length && /^\s{0,3}>/u.test(lines[index] ?? "")) {
|
|
497
|
+
quote.push((lines[index] ?? "").replace(/^\s{0,3}>\s?/u, ""));
|
|
498
|
+
index += 1;
|
|
499
|
+
}
|
|
500
|
+
html.push(`<blockquote>${renderMarkdownToHtml(quote.join("\n"), ctx)}</blockquote>`);
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Tables: header row, divider, body rows.
|
|
505
|
+
if (line.includes("|") && index + 1 < lines.length && isTableDivider(lines[index + 1] ?? "")) {
|
|
506
|
+
flushParagraph();
|
|
507
|
+
const header = tableCells(line);
|
|
508
|
+
index += 2;
|
|
509
|
+
const rows: string[] = [];
|
|
510
|
+
while (index < lines.length && (lines[index] ?? "").includes("|") && (lines[index] ?? "").trim() !== "") {
|
|
511
|
+
rows.push(lines[index] ?? "");
|
|
512
|
+
index += 1;
|
|
513
|
+
}
|
|
514
|
+
const head = header.map((cell) => `<th>${renderInline(cell, ctx)}</th>`).join("");
|
|
515
|
+
const bodyRows = rows.map((row) =>
|
|
516
|
+
`<tr>${tableCells(row).map((cell) => `<td>${renderInline(cell, ctx)}</td>`).join("")}</tr>`).join("");
|
|
517
|
+
html.push(`<table><thead><tr>${head}</tr></thead><tbody>${bodyRows}</tbody></table>`);
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Lists: flat scan with indent-based nesting.
|
|
522
|
+
const marker = listMarker(line);
|
|
523
|
+
if (marker !== undefined) {
|
|
524
|
+
flushParagraph();
|
|
525
|
+
const items: string[] = [];
|
|
526
|
+
let ordered = marker.ordered;
|
|
527
|
+
const baseIndent = marker.indent;
|
|
528
|
+
const stack: { readonly indent: number; readonly ordered: boolean }[] = [];
|
|
529
|
+
while (index < lines.length) {
|
|
530
|
+
const current = listMarker(lines[index] ?? "");
|
|
531
|
+
const raw = lines[index] ?? "";
|
|
532
|
+
if (current === undefined) {
|
|
533
|
+
if (raw.trim() === "") {
|
|
534
|
+
index += 1;
|
|
535
|
+
break;
|
|
536
|
+
}
|
|
537
|
+
const continuationIndent = raw.length - raw.trimStart().length;
|
|
538
|
+
if (items.length > 0 && continuationIndent > baseIndent) {
|
|
539
|
+
items.push(`\n${renderInline(raw.trim(), ctx)}`);
|
|
540
|
+
index += 1;
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
break;
|
|
544
|
+
}
|
|
545
|
+
const body = raw.slice(raw.search(/\S/u));
|
|
546
|
+
const itemBody = body.replace(/^(?:[-+*]|\d{1,9}[.)])\s+/u, "");
|
|
547
|
+
const task = /^\[([ xX])\]\s+/u.exec(itemBody);
|
|
548
|
+
const content = task === null ? itemBody : itemBody.slice((task[0] ?? "").length);
|
|
549
|
+
const checkbox = task === null
|
|
550
|
+
? ""
|
|
551
|
+
: `<input type="checkbox" disabled${(task[1] ?? " ").toLowerCase() === "x" ? " checked" : ""}> `;
|
|
552
|
+
const top = stack[stack.length - 1];
|
|
553
|
+
if (current.indent > (top?.indent ?? baseIndent) && stack.length < 8) {
|
|
554
|
+
stack.push({ indent: current.indent, ordered: current.ordered });
|
|
555
|
+
items.push(`<${current.ordered ? "ol" : "ul"}>`);
|
|
556
|
+
}
|
|
557
|
+
while (stack.length > 0 && current.indent < (stack[stack.length - 1]?.indent ?? baseIndent)) {
|
|
558
|
+
const popped = stack.pop();
|
|
559
|
+
items.push(`</${popped?.ordered === true ? "ol" : "ul"}>`);
|
|
560
|
+
}
|
|
561
|
+
items.push(`<li>${checkbox}${renderInline(content, ctx)}</li>`);
|
|
562
|
+
index += 1;
|
|
563
|
+
}
|
|
564
|
+
while (stack.length > 0) {
|
|
565
|
+
const popped = stack.pop();
|
|
566
|
+
items.push(`</${popped?.ordered === true ? "ol" : "ul"}>`);
|
|
567
|
+
}
|
|
568
|
+
html.push(`<${ordered ? "ol" : "ul"}>${items.join("")}</${ordered ? "ol" : "ul"}>`);
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// HTML comment open.
|
|
573
|
+
const commentStart = line.indexOf("<!--");
|
|
574
|
+
if (commentStart !== -1) {
|
|
575
|
+
const close = line.indexOf("-->", commentStart + 4);
|
|
576
|
+
if (close === -1) inComment = true;
|
|
577
|
+
index += 1;
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// Indented code.
|
|
582
|
+
if (/^(?: {4}|\t)/u.test(line)) {
|
|
583
|
+
flushParagraph();
|
|
584
|
+
const body: string[] = [];
|
|
585
|
+
while (index < lines.length && /^(?: {4}|\t| *$)/u.test(lines[index] ?? "")) {
|
|
586
|
+
body.push((lines[index] ?? "").replace(/^(?: {4}|\t)/u, ""));
|
|
587
|
+
index += 1;
|
|
588
|
+
}
|
|
589
|
+
html.push(`<pre><code>${escapeHtml(body.join("\n").replace(/\n+$/u, ""))}</code></pre>`);
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
paragraph.push(line);
|
|
594
|
+
index += 1;
|
|
595
|
+
}
|
|
596
|
+
flushParagraph();
|
|
597
|
+
return html.join("\n");
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
export function stripMarkup(html: string): string {
|
|
601
|
+
// Scan literal tags first — escaped angle brackets (<) decode to text,
|
|
602
|
+
// never tag boundaries. The character scanner (not a regex) keeps CodeQL's
|
|
603
|
+
// incomplete-multi-character-sanitization rule quiet.
|
|
604
|
+
let stripped = "";
|
|
605
|
+
let inTag = false;
|
|
606
|
+
for (const character of html) {
|
|
607
|
+
if (character === "<") inTag = true;
|
|
608
|
+
else if (character === ">") inTag = false;
|
|
609
|
+
else if (!inTag) stripped += character;
|
|
610
|
+
}
|
|
611
|
+
return stripped
|
|
612
|
+
.replaceAll("<", "<")
|
|
613
|
+
.replaceAll(">", ">")
|
|
614
|
+
.replaceAll(""", '"')
|
|
615
|
+
.replaceAll("'", "'")
|
|
616
|
+
.replaceAll("&", "&");
|
|
617
|
+
}
|