@jxsuite/parser 1.8.2 → 2.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 +1 -1
- package/README.md +33 -0
- package/dist/content-loader.js +34611 -62
- package/dist/content.js +119 -3
- package/dist/markdown.js +1990 -481
- package/dist/serialize.js +1889 -1225
- package/package.json +8 -8
- package/schemas/project.fragment.schema.json +138 -6
- package/src/Content.class.json +6 -3
- package/src/Markdown.class.json +14 -3
- package/src/alerts.ts +257 -0
- package/src/content-links.ts +343 -0
- package/src/content-loader.ts +863 -75
- package/src/content-routes.ts +369 -0
- package/src/content-rules.ts +474 -0
- package/src/content.ts +49 -13
- package/src/grammars/caddyfile.ts +86 -0
- package/src/highlight.ts +49 -9
- package/src/inert.ts +101 -0
- package/src/markdown.ts +43 -17
- package/src/md.ts +29 -6
- package/src/transpile.ts +60 -4
- package/src/types.ts +17 -0
- package/src/walk.ts +97 -0
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content links — relative links between the entries of one collection, rewritten to routes.
|
|
3
|
+
*
|
|
4
|
+
* An author writes `[Swap](../Linux/Swap%20Configuration.md)` because that link works in Obsidian,
|
|
5
|
+
* in VS Code and on GitHub. On the published site the same link has to go to the page that document
|
|
6
|
+
* became, and that URL is not the file path: it is whatever the content type's `route` template
|
|
7
|
+
* says (`content-routes.ts`). This module is the join between the two, run once over each entry's
|
|
8
|
+
* rendered tree after the whole collection has loaded, because a link can only be resolved once
|
|
9
|
+
* every other entry is known.
|
|
10
|
+
*
|
|
11
|
+
* **What it touches, and what it never does.** Only a relative reference that resolves to a file of
|
|
12
|
+
* the collection is rewritten: an entry to its route, and any other file (a PDF, an image, a
|
|
13
|
+
* download) to the URL the collection's asset mount publishes it at, the same remap an image's
|
|
14
|
+
* `src` gets. A scheme (`https:`, `mailto:`, `tel:`), an absolute path and a same-page `#anchor`
|
|
15
|
+
* that already is a heading id are left exactly as authored. The resolution is a walk over path
|
|
16
|
+
* SEGMENTS against the entry's own directory and it stops at the source root: a `..` that would
|
|
17
|
+
* climb out of it never produces a path, so a link cannot be made to name a file outside the
|
|
18
|
+
* collection.
|
|
19
|
+
*
|
|
20
|
+
* **A link to nothing becomes text.** An entry that is excluded, filtered out, unrouted or simply
|
|
21
|
+
* absent has no page, and neither has a folder with no README or index, or an excluded attachment;
|
|
22
|
+
* a published `<a>` to any of them is a 404 waiting for a reader. The anchor is replaced by its own
|
|
23
|
+
* content and the problem is reported with the source file and the target, at the severity the
|
|
24
|
+
* content type's `links` option asks for.
|
|
25
|
+
*
|
|
26
|
+
* Pure: no `node:` imports. The loader supplies the file facts through {@link LinkIndex}.
|
|
27
|
+
*
|
|
28
|
+
* @module @jxsuite/parser/content-links
|
|
29
|
+
* @license MIT
|
|
30
|
+
* @docs framework/site/content-collections
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import type { JxElement } from "@jxsuite/schema/types";
|
|
34
|
+
import { routeHref } from "./content-routes.ts";
|
|
35
|
+
import type { ContentNodes } from "./inert.ts";
|
|
36
|
+
import { slugifyHeading } from "./transpile.ts";
|
|
37
|
+
|
|
38
|
+
/** What the loader knows about one source file of the collection. */
|
|
39
|
+
export type FileOutcome =
|
|
40
|
+
/** Published at this route. */
|
|
41
|
+
| { route: string }
|
|
42
|
+
/** Loaded but not published, and why. */
|
|
43
|
+
| { unpublished: string };
|
|
44
|
+
|
|
45
|
+
/** How the resolver asks the loader about files. */
|
|
46
|
+
export interface LinkIndex {
|
|
47
|
+
/** Every file the collection loaded, keyed by path relative to the source root. */
|
|
48
|
+
files: ReadonlyMap<string, FileOutcome>;
|
|
49
|
+
/** Directory (relative path, `""` for the root) → the file that is its index. */
|
|
50
|
+
indexes: ReadonlyMap<string, string>;
|
|
51
|
+
/** Extensions that make a path an ENTRY link rather than an asset link (`.md`). */
|
|
52
|
+
entryExtensions: ReadonlySet<string>;
|
|
53
|
+
/** The `exclude` pattern that hides this path, if one does. */
|
|
54
|
+
excludedBy: (path: string) => string | undefined;
|
|
55
|
+
/** Whether a path exists on disk, so "excluded" and "missing" can be told apart. */
|
|
56
|
+
exists: (path: string) => boolean;
|
|
57
|
+
/** Whether a path is a directory on disk. */
|
|
58
|
+
isDirectory?: (path: string) => boolean;
|
|
59
|
+
/** Whether an `exclude` pattern hides every file below a directory. */
|
|
60
|
+
excludesDir?: (path: string) => boolean;
|
|
61
|
+
/**
|
|
62
|
+
* What the collection's asset mount does with an existing file that is not an entry: the URL it
|
|
63
|
+
* is published at, or why it is not. `undefined` when there is nothing to say (no such file, a
|
|
64
|
+
* directory, or no mount), which leaves the link as authored.
|
|
65
|
+
*/
|
|
66
|
+
asset?: (path: string) => { url: string } | { unpublished: string } | undefined;
|
|
67
|
+
/** `build.trailingSlash`. */
|
|
68
|
+
trailingSlash: string;
|
|
69
|
+
/** A locale URL prefix (`/fr`), or `""`. */
|
|
70
|
+
localePrefix: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** One link that could not be resolved to a page. */
|
|
74
|
+
export interface LinkProblem {
|
|
75
|
+
/** The href as authored. */
|
|
76
|
+
href: string;
|
|
77
|
+
/** Why it has no page: "is not published (…)", "does not exist", … */
|
|
78
|
+
reason: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The outcome for one href. */
|
|
82
|
+
type Resolution = { keep: true } | { href: string } | { problem: string };
|
|
83
|
+
|
|
84
|
+
/** A scheme, so `https:`, `mailto:`, `tel:`, `data:` and friends are never ours. */
|
|
85
|
+
const SCHEME = /^[a-z][a-z\d+.-]*:/i;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* A `#fragment` in the form the rendered heading ids use.
|
|
89
|
+
*
|
|
90
|
+
* Obsidian writes `Note.md#Heading%20Text` and GitHub writes `#heading-text`; the page carries
|
|
91
|
+
* `id="heading-text"`. Both end up at the same slug, through the same function that built the ids
|
|
92
|
+
* (`slugifyHeading`), so the link lands wherever the heading did however it was typed. The function
|
|
93
|
+
* is idempotent on text that is already a slug, so a GitHub-spelled fragment (`#foo---bar` for `##
|
|
94
|
+
* Foo - Bar`, whose id collapses the run to `foo-bar`) is not special-cased and cannot drift.
|
|
95
|
+
*/
|
|
96
|
+
function fragmentFor(raw: string): string {
|
|
97
|
+
try {
|
|
98
|
+
// A fragment with no letter or digit has no heading to name; it stays as written.
|
|
99
|
+
return slugifyHeading(decodeURIComponent(raw)) || raw;
|
|
100
|
+
} catch {
|
|
101
|
+
return raw;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Split `path?query#fragment` into its three parts (query and fragment keep their delimiter). */
|
|
106
|
+
function splitHref(href: string): { path: string; query: string; hash: string } {
|
|
107
|
+
const hashAt = href.indexOf("#");
|
|
108
|
+
const beforeHash = hashAt === -1 ? href : href.slice(0, hashAt);
|
|
109
|
+
const hash = hashAt === -1 ? "" : href.slice(hashAt);
|
|
110
|
+
const queryAt = beforeHash.indexOf("?");
|
|
111
|
+
return {
|
|
112
|
+
hash,
|
|
113
|
+
path: queryAt === -1 ? beforeHash : beforeHash.slice(0, queryAt),
|
|
114
|
+
query: queryAt === -1 ? "" : beforeHash.slice(queryAt),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The source-root-relative path a relative href names, or `null` when it climbs out of the root.
|
|
120
|
+
*
|
|
121
|
+
* Decodes each segment on its own, so `%2F` stays a character of one name instead of becoming a
|
|
122
|
+
* separator, then walks `.` and `..` against the entry's directory. `undefined` means the href is
|
|
123
|
+
* not a path this module understands (a malformed escape, a decoded separator): leave it alone.
|
|
124
|
+
*/
|
|
125
|
+
function resolvePath(from: string, path: string): string | null | undefined {
|
|
126
|
+
const segments = path.split("/");
|
|
127
|
+
const stack = from.split("/").slice(0, -1);
|
|
128
|
+
for (const raw of segments) {
|
|
129
|
+
let segment: string;
|
|
130
|
+
try {
|
|
131
|
+
segment = decodeURIComponent(raw);
|
|
132
|
+
} catch {
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
if (segment.includes("/") || segment.includes("\\")) {
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
if (segment === "" || segment === ".") {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (segment === "..") {
|
|
142
|
+
if (stack.length === 0) {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
stack.pop();
|
|
146
|
+
} else {
|
|
147
|
+
stack.push(segment);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return stack.join("/");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function extensionOf(path: string): string {
|
|
154
|
+
const name = path.slice(path.lastIndexOf("/") + 1);
|
|
155
|
+
const dot = name.lastIndexOf(".");
|
|
156
|
+
return dot > 0 ? name.slice(dot).toLowerCase() : "";
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** A path in the form two spellings of one name agree on: composed Unicode, lower case. */
|
|
160
|
+
const fold = (path: string): string => path.normalize("NFC").toLowerCase();
|
|
161
|
+
|
|
162
|
+
/** Folded path → the real paths it could mean, built once per index. */
|
|
163
|
+
const FOLDED = new WeakMap<LinkIndex, Map<string, string[]>>();
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* A file of the collection by exact path, then by a match that ignores case and Unicode
|
|
167
|
+
* normalisation form and is unambiguous. The second half is what makes a link typed on one system
|
|
168
|
+
* (a decomposed `é`, written by a macOS sync) find the file stored on another.
|
|
169
|
+
*/
|
|
170
|
+
function findFile(index: LinkIndex, path: string): string | undefined {
|
|
171
|
+
if (index.files.has(path)) {
|
|
172
|
+
return path;
|
|
173
|
+
}
|
|
174
|
+
let folded = FOLDED.get(index);
|
|
175
|
+
if (!folded) {
|
|
176
|
+
folded = new Map();
|
|
177
|
+
for (const key of index.files.keys()) {
|
|
178
|
+
const name = fold(key);
|
|
179
|
+
folded.set(name, [...(folded.get(name) ?? []), key]);
|
|
180
|
+
}
|
|
181
|
+
FOLDED.set(index, folded);
|
|
182
|
+
}
|
|
183
|
+
const hits = folded.get(fold(path));
|
|
184
|
+
return hits?.length === 1 ? hits[0] : undefined;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The index file of a directory, matching the directory name by Unicode form as well. */
|
|
188
|
+
function findIndex(index: LinkIndex, dir: string): string | undefined {
|
|
189
|
+
const exact = index.indexes.get(dir);
|
|
190
|
+
if (exact !== undefined) {
|
|
191
|
+
return exact;
|
|
192
|
+
}
|
|
193
|
+
const wanted = dir.normalize("NFC");
|
|
194
|
+
for (const [name, file] of index.indexes) {
|
|
195
|
+
if (name.normalize("NFC") === wanted) {
|
|
196
|
+
return file;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return undefined;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Why a path with an entry extension has no page: excluded, or simply not there. */
|
|
203
|
+
function whyMissing(index: LinkIndex, path: string): string {
|
|
204
|
+
const pattern = index.excludedBy(path);
|
|
205
|
+
if (pattern !== undefined && index.exists(path)) {
|
|
206
|
+
return `is not published (excluded by "${pattern}")`;
|
|
207
|
+
}
|
|
208
|
+
return "does not exist";
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* A relative link to something that is not an entry: an attachment, or a folder.
|
|
213
|
+
*
|
|
214
|
+
* An attachment that exists is published by the collection's asset mount at its own URL, so the
|
|
215
|
+
* link follows it there (a relative `files/report.pdf` on a page at `/kb/a/note/` would otherwise
|
|
216
|
+
* be `/kb/a/note/files/report.pdf`, which nothing serves). One the type does not publish, or a
|
|
217
|
+
* folder with no README or index, has no page, which makes it a broken link like any other.
|
|
218
|
+
* Anything else, a path that exists nowhere, is not this module's to judge and stays as authored.
|
|
219
|
+
*/
|
|
220
|
+
function resolveNonEntry(
|
|
221
|
+
target: string,
|
|
222
|
+
query: string,
|
|
223
|
+
hash: string,
|
|
224
|
+
index: LinkIndex,
|
|
225
|
+
): Resolution {
|
|
226
|
+
const asset = index.asset?.(target);
|
|
227
|
+
if (asset !== undefined) {
|
|
228
|
+
return "url" in asset
|
|
229
|
+
? { href: `${asset.url}${query}${hash}` }
|
|
230
|
+
: { problem: `is not published (${asset.unpublished})` };
|
|
231
|
+
}
|
|
232
|
+
if (index.isDirectory?.(target) === true) {
|
|
233
|
+
return index.excludesDir?.(target) === true
|
|
234
|
+
? { problem: "is not published (its folder is excluded)" }
|
|
235
|
+
: { problem: "is a folder with no README.md or index.md" };
|
|
236
|
+
}
|
|
237
|
+
return { keep: true };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Resolve one href found in the entry at `from`. */
|
|
241
|
+
function resolveHref(href: string, from: string, index: LinkIndex): Resolution {
|
|
242
|
+
if (href === "" || href.startsWith("/") || SCHEME.test(href) || href.includes("${")) {
|
|
243
|
+
return { keep: true };
|
|
244
|
+
}
|
|
245
|
+
const { path, query, hash } = splitHref(href);
|
|
246
|
+
if (path === "") {
|
|
247
|
+
// A same-page anchor: only the fragment can need work.
|
|
248
|
+
const fixed = hash.length > 1 ? `#${fragmentFor(hash.slice(1))}` : hash;
|
|
249
|
+
return fixed === hash ? { keep: true } : { href: fixed };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const target = resolvePath(from, path);
|
|
253
|
+
if (target === undefined) {
|
|
254
|
+
return { keep: true };
|
|
255
|
+
}
|
|
256
|
+
const entryLink = index.entryExtensions.has(extensionOf(path));
|
|
257
|
+
if (target === null) {
|
|
258
|
+
return entryLink
|
|
259
|
+
? { problem: "points outside the collection's source directory" }
|
|
260
|
+
: { keep: true };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
let file = findFile(index, target);
|
|
264
|
+
if (file === undefined && (path.endsWith("/") || extensionOf(path) === "")) {
|
|
265
|
+
/* A directory link (`../Frappe/`, `../Frappe`) names that directory's index entry, and an
|
|
266
|
+
extension-less one may name a file whose extension the author left off. */
|
|
267
|
+
file = findIndex(index, target);
|
|
268
|
+
for (const ext of path.endsWith("/") ? [] : index.entryExtensions) {
|
|
269
|
+
file ??= findFile(index, `${target}${ext}`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
if (file === undefined) {
|
|
273
|
+
return entryLink
|
|
274
|
+
? { problem: whyMissing(index, target) }
|
|
275
|
+
: resolveNonEntry(target, query, hash, index);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const outcome = index.files.get(file)!;
|
|
279
|
+
if ("unpublished" in outcome) {
|
|
280
|
+
return { problem: `is not published (${outcome.unpublished})` };
|
|
281
|
+
}
|
|
282
|
+
const fragment = hash.length > 1 ? `#${fragmentFor(hash.slice(1))}` : hash;
|
|
283
|
+
return {
|
|
284
|
+
href: `${routeHref(outcome.route, index.trailingSlash, index.localePrefix)}${query}${fragment}`,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** What a removed anchor leaves behind: its own content, as inline nodes. */
|
|
289
|
+
function contentOf(anchor: JxElement): ContentNodes {
|
|
290
|
+
if (Array.isArray(anchor.children)) {
|
|
291
|
+
return anchor.children as ContentNodes;
|
|
292
|
+
}
|
|
293
|
+
if (typeof anchor.innerHTML === "string") {
|
|
294
|
+
// Text that holds `${` was made inert as escaped `innerHTML` (inert.ts); it keeps that form.
|
|
295
|
+
return [{ innerHTML: anchor.innerHTML, tagName: "span" }];
|
|
296
|
+
}
|
|
297
|
+
return typeof anchor.textContent === "string" ? [anchor.textContent] : [];
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Rewrite the relative links in an entry's rendered tree, in place.
|
|
302
|
+
*
|
|
303
|
+
* @param {ContentNodes} nodes - The entry's `$children`
|
|
304
|
+
* @param {string} from - The entry's source file, relative to the source root, `/`-separated
|
|
305
|
+
* @param {LinkIndex} index - What the collection knows about its files
|
|
306
|
+
* @param {(problem: LinkProblem) => void} report - Called once per link that has no page
|
|
307
|
+
*/
|
|
308
|
+
export function rewriteLinks(
|
|
309
|
+
nodes: ContentNodes,
|
|
310
|
+
from: string,
|
|
311
|
+
index: LinkIndex,
|
|
312
|
+
report: (problem: LinkProblem) => void,
|
|
313
|
+
): void {
|
|
314
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
315
|
+
const node = nodes[i];
|
|
316
|
+
if (Array.isArray(node)) {
|
|
317
|
+
// A raw HTML block arrives as a nested list of nodes.
|
|
318
|
+
rewriteLinks(node, from, index, report);
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
if (typeof node !== "object" || node === null) {
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
const href = node.tagName === "a" ? node.attributes?.href : undefined;
|
|
325
|
+
if (typeof href === "string") {
|
|
326
|
+
const resolution = resolveHref(href, from, index);
|
|
327
|
+
if ("problem" in resolution) {
|
|
328
|
+
report({ href, reason: resolution.problem });
|
|
329
|
+
const replacement = contentOf(node);
|
|
330
|
+
rewriteLinks(replacement, from, index, report);
|
|
331
|
+
nodes.splice(i, 1, ...replacement);
|
|
332
|
+
i += replacement.length - 1;
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
if ("href" in resolution) {
|
|
336
|
+
node.attributes = { ...node.attributes, href: resolution.href };
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
if (Array.isArray(node.children)) {
|
|
340
|
+
rewriteLinks(node.children as ContentNodes, from, index, report);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|