@kenjura/ursa 0.97.0 → 0.99.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/CHANGELOG.md +32 -0
- package/README.md +104 -0
- package/meta/templates/default-template/default.css +165 -0
- package/meta/templates/default-template/sectionify.js +17 -9
- package/package.json +1 -1
- package/src/helper/__test__/inlineMenu.test.js +225 -0
- package/src/helper/automenu.js +3 -2
- package/src/helper/build/__test__/pass.test.js +205 -0
- package/src/helper/build/autoIndex.js +4 -0
- package/src/helper/build/site.js +197 -5
- package/src/helper/customMenu.js +26 -3
- package/src/helper/inlineMenu.js +413 -0
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Named menus: `menu-<name>.md` files rendered inline where a document asks
|
|
3
|
+
* for them, or where a folder's config.json injects them.
|
|
4
|
+
*
|
|
5
|
+
* `menu.md` defines a folder's navigation menu and replaces the site's nav for
|
|
6
|
+
* the folder and everything below it. A menu file whose frontmatter carries an
|
|
7
|
+
* `id` is different: it renders nowhere on its own. Instead any document in
|
|
8
|
+
* the folder (or below it) places it with an anchor on a line of its own:
|
|
9
|
+
*
|
|
10
|
+
* {menu:classes}
|
|
11
|
+
*
|
|
12
|
+
* The anchor becomes a static `<nav class="ursa-menu">` at that point in the
|
|
13
|
+
* body — part of the document, not a fixed element — with the menu's items
|
|
14
|
+
* as a horizontal strip (the default) or a vertical list (`appearance:
|
|
15
|
+
* vertical`). The item whose href is the current page is marked.
|
|
16
|
+
*
|
|
17
|
+
* A folder can also ask for a menu on every document beneath it without an
|
|
18
|
+
* anchor in each one: `"inject-menu": {"id": "classes", "position": "top"}`
|
|
19
|
+
* in its config.json (see parseInjectMenu / mergeInjectMenus below). The
|
|
20
|
+
* menu resolves by id from the document's folder exactly as an anchor does.
|
|
21
|
+
*
|
|
22
|
+
* Failure is quiet by design: an anchor whose menu is not found, or whose menu
|
|
23
|
+
* file cannot be parsed, is replaced by an HTML comment and reported as a
|
|
24
|
+
* build warning. The page still renders, with nothing visible where the menu
|
|
25
|
+
* would have been, and the surrounding Markdown is untouched.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { readdirSync, readFileSync } from "./build/tracedFs.js";
|
|
29
|
+
import { join, dirname, resolve, basename, posix } from "path";
|
|
30
|
+
import { extractMenuFrontmatter, isMenuFile } from "./customMenu.js";
|
|
31
|
+
|
|
32
|
+
/** An anchor: `{menu:<id>}`. Ids are letters, digits, `_`, `-` and `.`. */
|
|
33
|
+
const ANCHOR_ID = "[A-Za-z0-9_][A-Za-z0-9_.-]*";
|
|
34
|
+
const ANCHOR_RE = new RegExp(`\\{menu:(${ANCHOR_ID})\\}`, "g");
|
|
35
|
+
/** The anchor alone on a line (leading/trailing blanks allowed). */
|
|
36
|
+
const ANCHOR_LINE_RE = new RegExp(`^[ \\t]*\\{menu:(${ANCHOR_ID})\\}[ \\t]*$`, "gm");
|
|
37
|
+
/** The element form of the anchor, which is what the MDX pipeline sees. */
|
|
38
|
+
const ANCHOR_ELEMENT_RE = /<div\s+data-ursa-menu="([^"]+)"\s*(?:\/>|>\s*<\/div>)/g;
|
|
39
|
+
|
|
40
|
+
export const APPEARANCES = ["horizontal", "vertical"];
|
|
41
|
+
const DEFAULT_APPEARANCE = "horizontal";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Normalise a menu file's frontmatter into the fields named menus use.
|
|
45
|
+
* `id` is required for a named menu; without it the file is a folder menu
|
|
46
|
+
* (menu.md) or invalid (menu-x.md), which the caller decides.
|
|
47
|
+
* @param {object} frontmatter
|
|
48
|
+
* @returns {{id: string|null, appearance: string, appearanceInvalid: string|null}}
|
|
49
|
+
*/
|
|
50
|
+
export function namedMenuOptions(frontmatter) {
|
|
51
|
+
const rawId = frontmatter?.id;
|
|
52
|
+
const id = rawId === undefined || rawId === null || rawId === "" ? null : String(rawId).trim();
|
|
53
|
+
const rawAppearance = frontmatter?.appearance;
|
|
54
|
+
let appearance = DEFAULT_APPEARANCE;
|
|
55
|
+
let appearanceInvalid = null;
|
|
56
|
+
if (rawAppearance !== undefined && rawAppearance !== "") {
|
|
57
|
+
const a = String(rawAppearance).trim().toLowerCase();
|
|
58
|
+
if (APPEARANCES.includes(a)) appearance = a;
|
|
59
|
+
else appearanceInvalid = String(rawAppearance);
|
|
60
|
+
}
|
|
61
|
+
return { id, appearance, appearanceInvalid };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Menu files in one directory, sorted. Reads the listing through tracedFs so
|
|
66
|
+
* a file appearing later is an observed change.
|
|
67
|
+
* @param {string} dirPath - Absolute directory
|
|
68
|
+
* @returns {string[]} - Absolute paths
|
|
69
|
+
*/
|
|
70
|
+
export function menuFilesIn(dirPath) {
|
|
71
|
+
let entries;
|
|
72
|
+
try {
|
|
73
|
+
entries = readdirSync(dirPath, { withFileTypes: true });
|
|
74
|
+
} catch {
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
return entries
|
|
78
|
+
.filter((e) => e.isFile() && isMenuFile(e.name))
|
|
79
|
+
.map((e) => join(dirPath, e.name))
|
|
80
|
+
.sort();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Find the nearest menu file with the given id, walking up from `dirPath` to
|
|
85
|
+
* the source root. A deeper file with the same id shadows a shallower one.
|
|
86
|
+
* @param {string} dirPath - Absolute directory to start from
|
|
87
|
+
* @param {string} sourceRoot - Absolute docroot; the walk stops here
|
|
88
|
+
* @param {string} id - The menu id an anchor named
|
|
89
|
+
* @returns {{path: string, menuDir: string, content: string, frontmatter: object, body: string} | null}
|
|
90
|
+
*/
|
|
91
|
+
export function findNamedMenu(dirPath, sourceRoot, id) {
|
|
92
|
+
const root = resolve(sourceRoot);
|
|
93
|
+
let current = resolve(dirPath);
|
|
94
|
+
while (current.startsWith(root)) {
|
|
95
|
+
for (const menuPath of menuFilesIn(current)) {
|
|
96
|
+
let content;
|
|
97
|
+
try {
|
|
98
|
+
content = readFileSync(menuPath, "utf8");
|
|
99
|
+
} catch {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const { frontmatter, body } = extractMenuFrontmatter(content);
|
|
103
|
+
if (namedMenuOptions(frontmatter).id === id) {
|
|
104
|
+
return { path: menuPath, menuDir: current, content, frontmatter, body };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const parent = dirname(current);
|
|
108
|
+
if (parent === current) break;
|
|
109
|
+
current = parent;
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Every menu id a rendered body anchors, in both forms, in order of first
|
|
116
|
+
* appearance. Used to demand the menus before substituting them.
|
|
117
|
+
* @param {string} html
|
|
118
|
+
* @returns {string[]}
|
|
119
|
+
*/
|
|
120
|
+
export function collectMenuAnchorIds(html) {
|
|
121
|
+
const ids = [];
|
|
122
|
+
const seen = new Set();
|
|
123
|
+
const add = (id) => {
|
|
124
|
+
if (!seen.has(id)) {
|
|
125
|
+
seen.add(id);
|
|
126
|
+
ids.push(id);
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
for (const m of html.matchAll(ANCHOR_ELEMENT_RE)) add(m[1]);
|
|
130
|
+
for (const m of html.matchAll(ANCHOR_RE)) add(m[1]);
|
|
131
|
+
return ids;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* MDX reads `{menu:x}` as a JavaScript expression and fails to compile it.
|
|
136
|
+
* Before compiling, an anchor alone on a line becomes the element form, which
|
|
137
|
+
* is JSX the compiler passes through and `resolveMenuAnchors` recognises.
|
|
138
|
+
* @param {string} source - Raw .mdx source
|
|
139
|
+
* @returns {string}
|
|
140
|
+
*/
|
|
141
|
+
export function prepareMdxMenuAnchors(source) {
|
|
142
|
+
if (!source.includes("{menu:")) return source;
|
|
143
|
+
return source.replace(ANCHOR_LINE_RE, (_, id) => `\n<div data-ursa-menu="${id}"></div>\n`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Replace every anchor in rendered HTML with what `render(id)` returns.
|
|
148
|
+
*
|
|
149
|
+
* Handles the element form anywhere, and the `{menu:x}` form inside a
|
|
150
|
+
* paragraph: a paragraph that is only the anchor is replaced whole, and a
|
|
151
|
+
* paragraph with text around the anchor is split so the `<nav>` never sits
|
|
152
|
+
* inside a `<p>`. Anchors inside `<code>` are left alone — they are being
|
|
153
|
+
* talked about, not used.
|
|
154
|
+
*
|
|
155
|
+
* @param {string} html - Rendered body
|
|
156
|
+
* @param {(id: string) => string} render - Markup for one id (never throws; see `menuNotFoundComment`)
|
|
157
|
+
* @returns {string}
|
|
158
|
+
*/
|
|
159
|
+
export function resolveMenuAnchors(html, render) {
|
|
160
|
+
if (!html || (!html.includes("{menu:") && !html.includes("data-ursa-menu"))) return html;
|
|
161
|
+
|
|
162
|
+
html = html.replace(ANCHOR_ELEMENT_RE, (_, id) => render(id));
|
|
163
|
+
|
|
164
|
+
if (!html.includes("{menu:")) return html;
|
|
165
|
+
return html.replace(/<p>([\s\S]*?)<\/p>/g, (paragraph, inner) => {
|
|
166
|
+
if (!inner.includes("{menu:")) return paragraph;
|
|
167
|
+
// Anchors quoted in code spans stay as written
|
|
168
|
+
const codeSpans = [];
|
|
169
|
+
const masked = inner.replace(/<code[\s>][\s\S]*?<\/code>/g, (span) => {
|
|
170
|
+
codeSpans.push(span);
|
|
171
|
+
return `\u0000${codeSpans.length - 1}\u0000`;
|
|
172
|
+
});
|
|
173
|
+
const unmask = (s) => s.replace(/\u0000(\d+)\u0000/g, (_, i) => codeSpans[Number(i)]);
|
|
174
|
+
const parts = masked.split(new RegExp(`\\{menu:(${ANCHOR_ID})\\}`));
|
|
175
|
+
if (parts.length === 1) return paragraph;
|
|
176
|
+
let out = "";
|
|
177
|
+
for (let i = 0; i < parts.length; i++) {
|
|
178
|
+
if (i % 2 === 1) {
|
|
179
|
+
out += render(parts[i]);
|
|
180
|
+
} else {
|
|
181
|
+
const text = unmask(parts[i]).trim();
|
|
182
|
+
if (text) out += `<p>${text}</p>\n`;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return out;
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// Injected menus: config.json `inject-menu`
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
export const INJECT_POSITIONS = ["top", "bottom"];
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Normalise a folder's `inject-menu` value: one object or an array of them,
|
|
197
|
+
* each `{id, position}` or the `{inherit: true}` marker.
|
|
198
|
+
*
|
|
199
|
+
* @param {unknown} value - The raw `inject-menu` value from config.json
|
|
200
|
+
* @returns {{entries: {id: string, position: string}[], inherit: boolean, problems: string[]}}
|
|
201
|
+
*/
|
|
202
|
+
export function parseInjectMenu(value) {
|
|
203
|
+
const out = { entries: [], inherit: false, problems: [] };
|
|
204
|
+
if (value === undefined || value === null) return out;
|
|
205
|
+
const list = Array.isArray(value) ? value : [value];
|
|
206
|
+
for (const item of list) {
|
|
207
|
+
if (!item || typeof item !== "object") {
|
|
208
|
+
out.problems.push(`entry ${JSON.stringify(item)} is not an object`);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (item.inherit === true) {
|
|
212
|
+
out.inherit = true;
|
|
213
|
+
if (item.id === undefined) continue;
|
|
214
|
+
}
|
|
215
|
+
const id = item.id === undefined || item.id === null ? "" : String(item.id).trim();
|
|
216
|
+
if (!id) {
|
|
217
|
+
out.problems.push(`entry ${JSON.stringify(item)} has no id`);
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
let position = item.position === undefined ? "top" : String(item.position).trim().toLowerCase();
|
|
221
|
+
if (!INJECT_POSITIONS.includes(position)) {
|
|
222
|
+
out.problems.push(`"${id}": position "${item.position}" is not top or bottom; using top`);
|
|
223
|
+
position = "top";
|
|
224
|
+
}
|
|
225
|
+
out.entries.push({ id, position });
|
|
226
|
+
}
|
|
227
|
+
return out;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The menus a folder injects, from the chain of parsed `inject-menu` values
|
|
232
|
+
* on the way down from the docroot (`levels[0]` is the root, the last is the
|
|
233
|
+
* folder itself; a level with no `inject-menu` is null).
|
|
234
|
+
*
|
|
235
|
+
* A level that sets `inject-menu` replaces what was inherited unless one of
|
|
236
|
+
* its entries is `{inherit: true}`, in which case the ancestors' menus come
|
|
237
|
+
* first and the level's own are added after them. A level without the key
|
|
238
|
+
* changes nothing. The same id at the same position is injected once.
|
|
239
|
+
*
|
|
240
|
+
* @param {(ReturnType<typeof parseInjectMenu>|null)[]} levels
|
|
241
|
+
* @returns {{id: string, position: string}[]}
|
|
242
|
+
*/
|
|
243
|
+
export function mergeInjectMenus(levels) {
|
|
244
|
+
let effective = [];
|
|
245
|
+
for (const level of levels) {
|
|
246
|
+
if (!level) continue;
|
|
247
|
+
const base = level.inherit ? effective : [];
|
|
248
|
+
const merged = [...base];
|
|
249
|
+
for (const entry of level.entries) {
|
|
250
|
+
if (!merged.some((e) => e.id === entry.id && e.position === entry.position)) merged.push(entry);
|
|
251
|
+
}
|
|
252
|
+
effective = merged;
|
|
253
|
+
}
|
|
254
|
+
return effective;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
// Menu file bodies: item lists and the prose between them
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
/** A line the menu parser treats as an item: `- [..](..)`, `* [..](..)`, `* [[..]]`. */
|
|
262
|
+
const ITEM_LINE_RE = /^\s*(?:-\s*\[[^\]]*\]\(|\*+\s*\[)/;
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Split a menu file's body into item lists and the text around them, in
|
|
266
|
+
* order. `menu.md` only ever needed the items — a fixed nav shows nothing
|
|
267
|
+
* else — but a menu rendered into the page can carry a label before its list
|
|
268
|
+
* or a note after it. Text segments are Markdown; item segments are what
|
|
269
|
+
* `parseCustomMenu` reads.
|
|
270
|
+
*
|
|
271
|
+
* @param {string} body
|
|
272
|
+
* @returns {{kind: 'items'|'text', text: string}[]}
|
|
273
|
+
*/
|
|
274
|
+
export function splitMenuBody(body) {
|
|
275
|
+
const segments = [];
|
|
276
|
+
let current = null;
|
|
277
|
+
for (const line of body.split("\n")) {
|
|
278
|
+
if (!line.trim()) {
|
|
279
|
+
if (current) current.lines.push(line);
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
const kind = ITEM_LINE_RE.test(line) ? "items" : "text";
|
|
283
|
+
if (!current || current.kind !== kind) {
|
|
284
|
+
current = { kind, lines: [] };
|
|
285
|
+
segments.push(current);
|
|
286
|
+
}
|
|
287
|
+
current.lines.push(line);
|
|
288
|
+
}
|
|
289
|
+
return segments
|
|
290
|
+
.map(({ kind, lines }) => ({ kind, text: lines.join("\n").trim() }))
|
|
291
|
+
.filter((seg) => seg.text);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* A menu's prose is rendered once and inlined into pages in other folders,
|
|
296
|
+
* so its relative links and images must be made root-absolute against the
|
|
297
|
+
* menu file's own folder before that happens.
|
|
298
|
+
*
|
|
299
|
+
* @param {string} html - Rendered text segment
|
|
300
|
+
* @param {string} menuUrlDir - The menu file's folder as a URL path, e.g. "/character/feats"
|
|
301
|
+
*/
|
|
302
|
+
export function rebaseMenuHtml(html, menuUrlDir) {
|
|
303
|
+
const base = menuUrlDir.endsWith("/") ? menuUrlDir : menuUrlDir + "/";
|
|
304
|
+
return html.replace(/(<(?:a|img|source|video|audio)\b[^>]*?\s(?:href|src)=["'])([^"']+)(["'])/gi, (m, before, url, quote) => {
|
|
305
|
+
if (/^(?:[a-z][a-z0-9+.-]*:|\/\/|\/|#|\?)/i.test(url)) return m;
|
|
306
|
+
const [pathPart, rest = ""] = url.split(/(?=[?#])/, 2);
|
|
307
|
+
let resolved = posix.normalize(base + pathPart);
|
|
308
|
+
resolved = resolved.replace(/\.(md|mdx|txt)$/i, ".html");
|
|
309
|
+
return `${before}${resolved}${rest}${quote}`;
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** What an anchor becomes when its menu cannot be rendered. */
|
|
314
|
+
export function menuNotFoundComment(id, reason = "not found") {
|
|
315
|
+
return `<!-- ursa: menu "${escapeHtml(id)}" ${escapeHtml(reason)} -->`;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* True when a rendered body begins with inline menus (after optional
|
|
320
|
+
* whitespace), so the default-title injection can place its `<h1>` after them
|
|
321
|
+
* rather than pushing a top-of-page menu below the title.
|
|
322
|
+
* @param {string} html
|
|
323
|
+
* @returns {number} - Index just past the leading menus (0 when there are none)
|
|
324
|
+
*/
|
|
325
|
+
export function leadingMenusEnd(html) {
|
|
326
|
+
let i = 0;
|
|
327
|
+
const re = /^\s*(<nav class="ursa-menu[^"]*"[^>]*>[\s\S]*?<\/nav>|<!-- ursa: menu [^>]*-->)/;
|
|
328
|
+
for (;;) {
|
|
329
|
+
const m = re.exec(html.slice(i));
|
|
330
|
+
if (!m) return i;
|
|
331
|
+
i += m[0].length;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Static markup for a named menu.
|
|
337
|
+
*
|
|
338
|
+
* @param {Array} content - Either items as `parseCustomMenu` produces them
|
|
339
|
+
* ({label, href, children}), or segments: `{kind: 'items', items}` and
|
|
340
|
+
* `{kind: 'text', html}` in document order
|
|
341
|
+
* @param {object} opts
|
|
342
|
+
* @param {string} opts.id
|
|
343
|
+
* @param {string} [opts.appearance="horizontal"]
|
|
344
|
+
* @param {string|null} [opts.currentUrl] - The page's root-absolute `.html` URL, to mark the current item
|
|
345
|
+
* @returns {string}
|
|
346
|
+
*/
|
|
347
|
+
export function renderInlineMenuHtml(content, { id, appearance = DEFAULT_APPEARANCE, currentUrl = null }) {
|
|
348
|
+
const current = currentUrl ? normalizeUrl(currentUrl) : null;
|
|
349
|
+
const segments = Array.isArray(content) && content.length > 0 && content[0]?.kind
|
|
350
|
+
? content
|
|
351
|
+
: [{ kind: "items", items: content || [] }];
|
|
352
|
+
const inner = segments.map((seg) =>
|
|
353
|
+
seg.kind === "text"
|
|
354
|
+
? `<div class="ursa-menu-text">${seg.html}</div>`
|
|
355
|
+
: renderLevel(seg.items || [], current, 0)
|
|
356
|
+
).join("");
|
|
357
|
+
return `<nav class="ursa-menu ursa-menu-${appearance}" data-menu-id="${escapeHtml(id)}" aria-label="${escapeHtml(id)}">${inner}</nav>`;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function renderLevel(items, current, depth) {
|
|
361
|
+
if (!items || items.length === 0) return "";
|
|
362
|
+
const lis = items.map((item) => {
|
|
363
|
+
const children = item.children || [];
|
|
364
|
+
const isCurrent = current !== null && item.href && normalizeUrl(item.href) === current;
|
|
365
|
+
const hasCurrentBelow = !isCurrent && containsCurrent(children, current);
|
|
366
|
+
const classes = ["ursa-menu-item"];
|
|
367
|
+
if (children.length > 0) classes.push("ursa-menu-has-children");
|
|
368
|
+
if (isCurrent) classes.push("ursa-menu-current");
|
|
369
|
+
if (hasCurrentBelow) classes.push("ursa-menu-active");
|
|
370
|
+
const label = escapeHtml(item.label ?? "");
|
|
371
|
+
const link = item.href
|
|
372
|
+
? `<a href="${escapeHtml(item.href)}"${isCurrent ? ' aria-current="page"' : ""}>${label}</a>`
|
|
373
|
+
: `<span>${label}</span>`;
|
|
374
|
+
return `<li class="${classes.join(" ")}">${link}${renderLevel(children, current, depth + 1)}</li>`;
|
|
375
|
+
});
|
|
376
|
+
return `<ul class="ursa-menu-level" data-depth="${depth}">${lis.join("")}</ul>`;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function containsCurrent(items, current) {
|
|
380
|
+
if (current === null) return false;
|
|
381
|
+
for (const item of items || []) {
|
|
382
|
+
if (item.href && normalizeUrl(item.href) === current) return true;
|
|
383
|
+
if (containsCurrent(item.children, current)) return true;
|
|
384
|
+
}
|
|
385
|
+
return false;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** `/a/b/index.html`, `/a/b/`, `/a/b` and `/a/b.html` compare by the same key. */
|
|
389
|
+
function normalizeUrl(url) {
|
|
390
|
+
let u = String(url).split("#")[0].split("?")[0];
|
|
391
|
+
try {
|
|
392
|
+
u = decodeURIComponent(u);
|
|
393
|
+
} catch {
|
|
394
|
+
// leave as written
|
|
395
|
+
}
|
|
396
|
+
u = u.replace(/\/index\.html$/i, "/").replace(/\.html$/i, "");
|
|
397
|
+
if (u.length > 1) u = u.replace(/\/$/, "");
|
|
398
|
+
return u.toLowerCase();
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
export function escapeHtml(s) {
|
|
402
|
+
return String(s)
|
|
403
|
+
.replace(/&/g, "&")
|
|
404
|
+
.replace(/</g, "<")
|
|
405
|
+
.replace(/>/g, ">")
|
|
406
|
+
.replace(/"/g, """);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** The menu id a `menu-<id>.md` filename suggests; for messages only. */
|
|
410
|
+
export function menuFileSuffix(path) {
|
|
411
|
+
const m = basename(path).match(/^_?menu-(.+)\.(md|txt)$/i);
|
|
412
|
+
return m ? m[1] : null;
|
|
413
|
+
}
|