@jxsuite/parser 1.8.2 → 2.0.0
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 +34124 -62
- package/dist/content.js +119 -3
- package/dist/markdown.js +1113 -91
- package/dist/serialize.js +177 -0
- package/package.json +7 -7
- 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
package/src/alerts.ts
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Alerts — GitHub-style callouts (`> [!NOTE]`) recognised in a Markdown tree.
|
|
3
|
+
*
|
|
4
|
+
* GitHub, Obsidian and most static-site plugins agree on one notation for a callout: a blockquote
|
|
5
|
+
* whose first line is `[!TYPE]`. Without recognising it a document renders as a quotation that
|
|
6
|
+
* begins with the literal text `[!NOTE]`, which is wrong everywhere a reader sees it.
|
|
7
|
+
*
|
|
8
|
+
* Recognition is a **tree rewrite that runs before conversion**, not a case inside the blockquote
|
|
9
|
+
* handler, for two reasons. Alerts nest (in list items, in other callouts, inside directives), and
|
|
10
|
+
* a rewrite that visits every blockquote gets all of those without the converter having to know the
|
|
11
|
+
* configuration at every depth. And it keeps `transpileJxMarkdown`, the path Studio parses a file
|
|
12
|
+
* through, untouched: that path round-trips back to Markdown on save, and a callout already
|
|
13
|
+
* converted to a `div` would be written back as a directive, silently rewriting the author's file.
|
|
14
|
+
* Only the content loader asks for alerts.
|
|
15
|
+
*
|
|
16
|
+
* Pure: no `node:` imports, no knowledge of Jx elements. The converter lives beside `mdastNodeToJx`
|
|
17
|
+
* in `transpile.ts`.
|
|
18
|
+
*
|
|
19
|
+
* @module @jxsuite/parser/alerts
|
|
20
|
+
* @license MIT
|
|
21
|
+
* @docs framework/site/jx-markdown
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { MdastNode } from "./types.ts";
|
|
25
|
+
|
|
26
|
+
/** The five alert types GitHub defines, and the title each shows by default. */
|
|
27
|
+
export const DEFAULT_ALERT_TITLES: Readonly<Record<string, string>> = {
|
|
28
|
+
caution: "Caution",
|
|
29
|
+
important: "Important",
|
|
30
|
+
note: "Note",
|
|
31
|
+
tip: "Tip",
|
|
32
|
+
warning: "Warning",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** The synthetic mdast node type an alert becomes, consumed by `mdastNodeToJx`. */
|
|
36
|
+
export const ALERT_NODE = "jxAlert";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The `alerts` option of a content type: alert type (case-insensitive) → the custom element that
|
|
40
|
+
* renders it, or `true` for the built-in markup (which is also how a type beyond GitHub's five,
|
|
41
|
+
* such as Obsidian's `info` or `example`, is switched on). A `null` or `false` value leaves that
|
|
42
|
+
* type as an ordinary blockquote, and `false` for the whole option turns alert recognition off.
|
|
43
|
+
*/
|
|
44
|
+
export type AlertsConfig = Record<string, string | boolean | null> | false;
|
|
45
|
+
|
|
46
|
+
/** The option after validation, keyed by lower-case alert type. */
|
|
47
|
+
export interface AlertSettings {
|
|
48
|
+
/** Type → element tag name, or `null` to render the built-in markup. */
|
|
49
|
+
types: Map<string, string | null>;
|
|
50
|
+
/** Types the option switched off on purpose, so an unknown-type report can skip them. */
|
|
51
|
+
disabled: Set<string>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A tag name an author could mean: HTML elements and custom elements alike. */
|
|
55
|
+
const TAG_NAME = /^[A-Za-z][\w.-]*$/;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Validate the `alerts` option into settings, or `null` when recognition is off.
|
|
59
|
+
*
|
|
60
|
+
* Throws on a value that is not a tag name so a typo in `project.json` fails the build where it was
|
|
61
|
+
* written rather than rendering a callout as an unknown element.
|
|
62
|
+
*/
|
|
63
|
+
export function normalizeAlerts(config?: unknown, where = "alerts"): AlertSettings | null {
|
|
64
|
+
if (config === false) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
const types = new Map<string, string | null>(
|
|
68
|
+
Object.keys(DEFAULT_ALERT_TITLES).map((type) => [type, null]),
|
|
69
|
+
);
|
|
70
|
+
const disabled = new Set<string>();
|
|
71
|
+
if (config === undefined || config === true) {
|
|
72
|
+
return { disabled, types };
|
|
73
|
+
}
|
|
74
|
+
if (typeof config !== "object" || config === null || Array.isArray(config)) {
|
|
75
|
+
throw new TypeError(
|
|
76
|
+
`${where}: expected an object mapping alert types to element names, or false`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
for (const [type, element] of Object.entries(config)) {
|
|
80
|
+
const key = type.toLowerCase();
|
|
81
|
+
if (!/^[a-z][\w-]*$/.test(key)) {
|
|
82
|
+
throw new TypeError(`${where}: "${type}" is not a valid alert type name`);
|
|
83
|
+
}
|
|
84
|
+
if (element === false || element === null) {
|
|
85
|
+
types.delete(key);
|
|
86
|
+
disabled.add(key);
|
|
87
|
+
} else if (element === true) {
|
|
88
|
+
types.set(key, null);
|
|
89
|
+
} else if (typeof element === "string" && TAG_NAME.test(element)) {
|
|
90
|
+
types.set(key, element);
|
|
91
|
+
} else {
|
|
92
|
+
throw new TypeError(
|
|
93
|
+
`${where}.${type}: expected an element name such as "doc-note", true or false, got ${JSON.stringify(element)}`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return { disabled, types };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The shape an alert takes after the rewrite. */
|
|
101
|
+
export interface AlertNode extends MdastNode {
|
|
102
|
+
type: typeof ALERT_NODE;
|
|
103
|
+
/** Lower-case alert type: `note`, `tip`, … */
|
|
104
|
+
alert: string;
|
|
105
|
+
/** Custom element tag from the `alerts` option, or null for the built-in markup. */
|
|
106
|
+
element: string | null;
|
|
107
|
+
/** The author's title as inline nodes, or null to use the type's default title. */
|
|
108
|
+
titleNodes: MdastNode[] | null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const MARKER = /^\[!([A-Za-z][\w-]*)\][+-]?/;
|
|
112
|
+
|
|
113
|
+
/** Plain text of a run of inline nodes. */
|
|
114
|
+
export function inlineText(nodes: readonly MdastNode[]): string {
|
|
115
|
+
let out = "";
|
|
116
|
+
for (const node of nodes) {
|
|
117
|
+
if (typeof node.value === "string") {
|
|
118
|
+
out += node.value;
|
|
119
|
+
} else if (node.children) {
|
|
120
|
+
out += inlineText(node.children);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function textNode(value: string): MdastNode {
|
|
127
|
+
return { type: "text", value };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** A line end in either of its spellings: a file saved on Windows keeps `\r\n` in its text nodes. */
|
|
131
|
+
const LINE_END = /\r?\n/;
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The `[!TYPE]` marker at the start of a paragraph, or null when there is none.
|
|
135
|
+
*
|
|
136
|
+
* `rest` is the text after the marker in that first text node. A marker must be followed by
|
|
137
|
+
* whitespace or the end of the node (`[!NOTE]x` is not one), and the whitespace may be a carriage
|
|
138
|
+
* return, which is what a CRLF file puts there.
|
|
139
|
+
*/
|
|
140
|
+
function markerOf(paragraph: MdastNode): { type: string; rest: string } | null {
|
|
141
|
+
const [first] = paragraph.children ?? [];
|
|
142
|
+
if (first?.type !== "text" || typeof first.value !== "string") {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
const match = MARKER.exec(first.value);
|
|
146
|
+
if (!match) {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
const rest = first.value.slice(match[0].length);
|
|
150
|
+
if (rest !== "" && !/^[ \t\r\n]/.test(rest)) {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
return { rest, type: match[1]!.toLowerCase() };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Split the first paragraph of a blockquote into the title line and the rest, or null when the
|
|
158
|
+
* blockquote is not an alert of a configured type.
|
|
159
|
+
*
|
|
160
|
+
* The title is whatever follows `[!TYPE]` on its own line, across inline formatting (`> [!NOTE] A
|
|
161
|
+
* **bold** title`). That is Obsidian's custom-title form; GitHub itself shows such a blockquote as
|
|
162
|
+
* plain text, so a file using it still reads sensibly there.
|
|
163
|
+
*/
|
|
164
|
+
function splitMarker(
|
|
165
|
+
paragraph: MdastNode,
|
|
166
|
+
settings: AlertSettings,
|
|
167
|
+
): { type: string; title: MdastNode[]; body: MdastNode[] } | null {
|
|
168
|
+
const marker = markerOf(paragraph);
|
|
169
|
+
if (!marker || !settings.types.has(marker.type)) {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
const { type, rest } = marker;
|
|
173
|
+
const [, ...siblings] = paragraph.children ?? [];
|
|
174
|
+
|
|
175
|
+
const title: MdastNode[] = [];
|
|
176
|
+
const body: MdastNode[] = [];
|
|
177
|
+
let inTitle = true;
|
|
178
|
+
const take = (node: MdastNode) => {
|
|
179
|
+
if (inTitle && node.type === "break") {
|
|
180
|
+
inTitle = false;
|
|
181
|
+
} else if (inTitle && node.type === "text" && typeof node.value === "string") {
|
|
182
|
+
const lineEnd = LINE_END.exec(node.value);
|
|
183
|
+
if (lineEnd === null) {
|
|
184
|
+
title.push(node);
|
|
185
|
+
} else {
|
|
186
|
+
title.push(textNode(node.value.slice(0, lineEnd.index)));
|
|
187
|
+
inTitle = false;
|
|
188
|
+
body.push(textNode(node.value.slice(lineEnd.index + lineEnd[0].length)));
|
|
189
|
+
}
|
|
190
|
+
} else {
|
|
191
|
+
(inTitle ? title : body).push(node);
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
take(textNode(rest.replace(/^[ \t]+/, "")));
|
|
195
|
+
for (const node of siblings) {
|
|
196
|
+
take(node);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const trimmed = title.filter((node) => !(node.type === "text" && node.value === ""));
|
|
200
|
+
const text = inlineText(trimmed);
|
|
201
|
+
return {
|
|
202
|
+
body: body.filter((node) => !(node.type === "text" && node.value === "")),
|
|
203
|
+
title: text.trim() === "" ? [] : trimmed,
|
|
204
|
+
type,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Rewrite every alert blockquote in a tree into an {@link AlertNode}, in place.
|
|
210
|
+
*
|
|
211
|
+
* A blockquote that is not an alert, or is of a type the settings do not know, is left alone and
|
|
212
|
+
* its children are still searched, so a callout nested inside an ordinary quotation is found.
|
|
213
|
+
* `onUnknown` hears each well-formed `[!type]` marker whose type is not enabled.
|
|
214
|
+
*
|
|
215
|
+
* @param {MdastNode} tree
|
|
216
|
+
* @param {AlertSettings} settings
|
|
217
|
+
* @param {(type: string) => void} [onUnknown]
|
|
218
|
+
*/
|
|
219
|
+
export function transformAlerts(
|
|
220
|
+
tree: MdastNode,
|
|
221
|
+
settings: AlertSettings,
|
|
222
|
+
onUnknown?: (type: string) => void,
|
|
223
|
+
): void {
|
|
224
|
+
const { children } = tree;
|
|
225
|
+
if (!children) {
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
for (const [i, child] of children.entries()) {
|
|
229
|
+
if (child.type === "blockquote") {
|
|
230
|
+
const [paragraph, ...rest] = child.children ?? [];
|
|
231
|
+
const split = paragraph?.type === "paragraph" ? splitMarker(paragraph, settings) : null;
|
|
232
|
+
if (split) {
|
|
233
|
+
const bodyParagraph: MdastNode[] =
|
|
234
|
+
split.body.length > 0 && inlineText(split.body).trim() !== ""
|
|
235
|
+
? [{ children: split.body, type: "paragraph" }]
|
|
236
|
+
: [];
|
|
237
|
+
const alert: AlertNode = {
|
|
238
|
+
alert: split.type,
|
|
239
|
+
children: [...bodyParagraph, ...rest],
|
|
240
|
+
element: settings.types.get(split.type) ?? null,
|
|
241
|
+
titleNodes: split.title.length > 0 ? split.title : null,
|
|
242
|
+
type: ALERT_NODE,
|
|
243
|
+
};
|
|
244
|
+
children[i] = alert;
|
|
245
|
+
transformAlerts(alert, settings, onUnknown);
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
// A well-formed marker of a type nobody switched on: it stays a blockquote, and the author
|
|
249
|
+
// Is told, because the literal `[!info]` on the page is otherwise the only clue.
|
|
250
|
+
const unknown = paragraph?.type === "paragraph" ? markerOf(paragraph) : null;
|
|
251
|
+
if (unknown && !settings.disabled.has(unknown.type)) {
|
|
252
|
+
onUnknown?.(unknown.type);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
transformAlerts(child, settings, onUnknown);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
@@ -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
|
+
}
|