@kenjura/ursa 0.96.0 → 0.98.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 +44 -0
- package/README.md +144 -16
- package/bin/ursa.js +14 -1
- package/meta/templates/default-template/default.css +144 -0
- package/meta/templates/default-template/menu.js +18 -1
- package/meta/templates/default-template/search.js +11 -0
- package/meta/templates/default-template/sectionify.js +17 -9
- package/meta/templates/default-template/widgets.js +4 -0
- package/package.json +1 -2
- package/src/dev.js +13 -23
- package/src/helper/__test__/contentHash.test.js +16 -6
- package/src/helper/__test__/inlineMenu.test.js +142 -0
- package/src/helper/assetBundler.js +93 -19
- package/src/helper/automenu.js +39 -13
- package/src/helper/build/__test__/autoIndex.test.js +2 -132
- package/src/helper/build/__test__/graph.test.js +259 -3
- package/src/helper/build/__test__/pass.test.js +664 -0
- package/src/helper/build/autoIndex.js +6 -371
- package/src/helper/build/excludeFilter.js +1 -2
- package/src/helper/build/footer.js +27 -14
- package/src/helper/build/graph.js +575 -152
- package/src/helper/build/index.js +0 -2
- package/src/helper/build/metadata.js +19 -5
- package/src/helper/build/pass.js +497 -0
- package/src/helper/build/precedence.js +174 -0
- package/src/helper/build/site.js +1392 -0
- package/src/helper/build/templates.js +1 -2
- package/src/helper/build/tracedFs.js +247 -0
- package/src/helper/contentHash.js +0 -78
- package/src/helper/customMenu.js +27 -4
- package/src/helper/fileRenderer.js +119 -111
- package/src/helper/findScriptJs.js +1 -1
- package/src/helper/findStyleCss.js +1 -1
- package/src/helper/folderConfig.js +7 -18
- package/src/helper/fullTextIndex.js +41 -29
- package/src/helper/imageProcessor.js +45 -0
- package/src/helper/inlineMenu.js +275 -0
- package/src/helper/linkValidator.js +118 -127
- package/src/helper/mdxRenderer.js +27 -5
- package/src/helper/menuLabels.js +30 -5
- package/src/helper/whitelistFilter.js +1 -2
- package/src/jobs/generate.js +67 -1829
- package/src/serve.js +317 -697
- package/src/helper/__test__/dependencyTracker.test.js +0 -157
- package/src/helper/build/cacheBust.js +0 -141
- package/src/helper/build/navCache.js +0 -145
- package/src/helper/build/watchCache.js +0 -33
- package/src/helper/dependencyTracker.js +0 -384
|
@@ -1,11 +1,8 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from '
|
|
1
|
+
import { existsSync, readFileSync } from './build/tracedFs.js';
|
|
2
2
|
import { join, dirname } from 'path';
|
|
3
3
|
|
|
4
4
|
const CONFIG_FILENAME = 'config.json';
|
|
5
5
|
|
|
6
|
-
// Cache for folder configs to avoid repeated file reads
|
|
7
|
-
const configCache = new Map();
|
|
8
|
-
|
|
9
6
|
/**
|
|
10
7
|
* Folder configuration schema:
|
|
11
8
|
* {
|
|
@@ -24,11 +21,12 @@ const configCache = new Map();
|
|
|
24
21
|
*/
|
|
25
22
|
|
|
26
23
|
/**
|
|
27
|
-
*
|
|
24
|
+
* Kept for callers that used to reset the per-run cache. There is no cache
|
|
25
|
+
* any more: every read goes to the (traced) filesystem so that the build
|
|
26
|
+
* graph records config.json as an input of whatever consulted it. A cached
|
|
27
|
+
* hit would record nothing, and a `hidden: true` flip would go unnoticed.
|
|
28
28
|
*/
|
|
29
|
-
export function clearConfigCache() {
|
|
30
|
-
configCache.clear();
|
|
31
|
-
}
|
|
29
|
+
export function clearConfigCache() {}
|
|
32
30
|
|
|
33
31
|
/**
|
|
34
32
|
* Read and parse a folder's config.json if it exists (synchronous)
|
|
@@ -36,24 +34,15 @@ export function clearConfigCache() {
|
|
|
36
34
|
* @returns {object|null} Parsed config object or null if not found
|
|
37
35
|
*/
|
|
38
36
|
export function getFolderConfig(folderPath) {
|
|
39
|
-
// Check cache first
|
|
40
|
-
if (configCache.has(folderPath)) {
|
|
41
|
-
return configCache.get(folderPath);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
37
|
const configPath = join(folderPath, CONFIG_FILENAME);
|
|
45
38
|
try {
|
|
46
39
|
if (existsSync(configPath)) {
|
|
47
40
|
const content = readFileSync(configPath, 'utf8');
|
|
48
|
-
|
|
49
|
-
configCache.set(folderPath, config);
|
|
50
|
-
return config;
|
|
41
|
+
return JSON.parse(content);
|
|
51
42
|
}
|
|
52
43
|
} catch (e) {
|
|
53
44
|
console.warn(`Could not read folder config at ${configPath}:`, e.message);
|
|
54
45
|
}
|
|
55
|
-
|
|
56
|
-
configCache.set(folderPath, null);
|
|
57
46
|
return null;
|
|
58
47
|
}
|
|
59
48
|
|
|
@@ -113,49 +113,61 @@ function extractWords(text) {
|
|
|
113
113
|
* @returns {Object} - Inverted index: { word: [{ path, score }] }
|
|
114
114
|
*/
|
|
115
115
|
export function buildFullTextIndex(documents) {
|
|
116
|
+
return mergeWordCounts(
|
|
117
|
+
documents
|
|
118
|
+
.filter((doc) => doc.content || doc.title)
|
|
119
|
+
.map((doc) => ({ path: doc.path, counts: documentWordCounts(doc) }))
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Word → weighted count for one document: title words weigh 10, content
|
|
125
|
+
* words 1. This is the per-document half of the index, so the build graph can
|
|
126
|
+
* cache it per document and re-tokenize only what was edited.
|
|
127
|
+
* @param {{title: string, content: string}} doc
|
|
128
|
+
* @returns {Record<string, number>}
|
|
129
|
+
*/
|
|
130
|
+
export function documentWordCounts(doc) {
|
|
131
|
+
const wordCounts = {};
|
|
132
|
+
for (const word of extractWords(doc.title)) {
|
|
133
|
+
wordCounts[word] = (wordCounts[word] || 0) + 10;
|
|
134
|
+
}
|
|
135
|
+
for (const word of extractWords(doc.content)) {
|
|
136
|
+
wordCounts[word] = (wordCounts[word] || 0) + 1;
|
|
137
|
+
}
|
|
138
|
+
return wordCounts;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Merge per-document word counts into the inverted index.
|
|
143
|
+
* Deterministic: ties in score are broken by path, so the index is a pure
|
|
144
|
+
* function of its inputs regardless of the order documents arrive in.
|
|
145
|
+
* @param {Array<{path: string, counts: Record<string, number>}>} docs
|
|
146
|
+
* @returns {Object} - Inverted index: { word: [{ p: path, s: score }] }
|
|
147
|
+
*/
|
|
148
|
+
export function mergeWordCounts(docs) {
|
|
116
149
|
const index = {};
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
if (!doc.content && !doc.title) continue;
|
|
120
|
-
|
|
121
|
-
// Extract words from title (higher weight) and content
|
|
122
|
-
const titleWords = extractWords(doc.title);
|
|
123
|
-
const contentWords = extractWords(doc.content);
|
|
124
|
-
|
|
125
|
-
// Count word frequencies in this document
|
|
126
|
-
const wordCounts = {};
|
|
127
|
-
|
|
128
|
-
// Title words get weight of 10
|
|
129
|
-
for (const word of titleWords) {
|
|
130
|
-
wordCounts[word] = (wordCounts[word] || 0) + 10;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
// Content words get weight of 1
|
|
134
|
-
for (const word of contentWords) {
|
|
135
|
-
wordCounts[word] = (wordCounts[word] || 0) + 1;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// Add to inverted index
|
|
139
|
-
for (const [word, count] of Object.entries(wordCounts)) {
|
|
150
|
+
for (const { path, counts } of docs) {
|
|
151
|
+
for (const [word, count] of Object.entries(counts)) {
|
|
140
152
|
if (!index[word]) {
|
|
141
153
|
index[word] = [];
|
|
142
154
|
}
|
|
143
155
|
index[word].push({
|
|
144
|
-
p:
|
|
145
|
-
s: count,
|
|
156
|
+
p: path, // path (shortened key for smaller JSON)
|
|
157
|
+
s: count, // score (shortened key)
|
|
146
158
|
});
|
|
147
159
|
}
|
|
148
160
|
}
|
|
149
|
-
|
|
150
|
-
// Sort each word's document list by score (descending)
|
|
161
|
+
|
|
162
|
+
// Sort each word's document list by score (descending), then path
|
|
151
163
|
for (const word of Object.keys(index)) {
|
|
152
|
-
index[word].sort((a, b) => b.s - a.s);
|
|
164
|
+
index[word].sort((a, b) => b.s - a.s || (a.p < b.p ? -1 : a.p > b.p ? 1 : 0));
|
|
153
165
|
// Limit to top 100 documents per word to keep index size reasonable
|
|
154
166
|
if (index[word].length > 100) {
|
|
155
167
|
index[word] = index[word].slice(0, 100);
|
|
156
168
|
}
|
|
157
169
|
}
|
|
158
|
-
|
|
170
|
+
|
|
159
171
|
return index;
|
|
160
172
|
}
|
|
161
173
|
|
|
@@ -122,6 +122,51 @@ async function isImageSmallEnough(sourcePath) {
|
|
|
122
122
|
}
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Whether ursa handles this extension as an image at all.
|
|
127
|
+
*/
|
|
128
|
+
export function isImageExtension(ext) {
|
|
129
|
+
const e = ext.toLowerCase();
|
|
130
|
+
return PROCESSABLE_EXTENSIONS.includes(e) || COPY_ONLY_EXTENSIONS.includes(e);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Decide whether an image gets a WebP preview, without rendering one.
|
|
135
|
+
* SVG/ICO and unknown formats never do; neither does an image already within
|
|
136
|
+
* the preview bounds, nor anything when sharp is unavailable. Cheap: reads the
|
|
137
|
+
* header only. This is what a page needs to know to write its markup; the
|
|
138
|
+
* expensive encode (`renderPreview`) can then run after the page is served.
|
|
139
|
+
* @param {string} sourcePath - Absolute path to the image
|
|
140
|
+
* @returns {Promise<boolean>}
|
|
141
|
+
*/
|
|
142
|
+
export async function willHavePreview(sourcePath) {
|
|
143
|
+
const ext = extname(sourcePath).toLowerCase();
|
|
144
|
+
if (!PROCESSABLE_EXTENSIONS.includes(ext)) return false;
|
|
145
|
+
if (!(await ensureSharp())) return false;
|
|
146
|
+
return !(await isImageSmallEnough(sourcePath));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Encode the WebP preview of an image and return it.
|
|
151
|
+
* @param {string} sourcePath - Absolute path to the image
|
|
152
|
+
* @returns {Promise<Buffer|null>} The WebP bytes, or null when no preview can be made
|
|
153
|
+
*/
|
|
154
|
+
export async function renderPreview(sourcePath) {
|
|
155
|
+
if (!(await ensureSharp())) return null;
|
|
156
|
+
try {
|
|
157
|
+
return await sharp(sourcePath)
|
|
158
|
+
.resize(PREVIEW_MAX_WIDTH, PREVIEW_MAX_HEIGHT, {
|
|
159
|
+
fit: 'inside',
|
|
160
|
+
withoutEnlargement: true, // Don't upscale small images
|
|
161
|
+
})
|
|
162
|
+
.webp({ quality: PREVIEW_QUALITY })
|
|
163
|
+
.toBuffer();
|
|
164
|
+
} catch (e) {
|
|
165
|
+
console.warn(`⚠️ Failed to generate preview for ${basename(sourcePath)}: ${e.message}`);
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
125
170
|
/**
|
|
126
171
|
* Generate preview filename from original filename
|
|
127
172
|
* e.g., "photo.jpg" -> "photo.preview.webp"
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Named menus: `menu-<name>.md` files rendered inline where a document asks
|
|
3
|
+
* for 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
|
+
* Failure is quiet by design: an anchor whose menu is not found, or whose menu
|
|
18
|
+
* file cannot be parsed, is replaced by an HTML comment and reported as a
|
|
19
|
+
* build warning. The page still renders, with nothing visible where the menu
|
|
20
|
+
* would have been, and the surrounding Markdown is untouched.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { readdirSync, readFileSync } from "./build/tracedFs.js";
|
|
24
|
+
import { join, dirname, resolve, basename } from "path";
|
|
25
|
+
import { extractMenuFrontmatter, isMenuFile } from "./customMenu.js";
|
|
26
|
+
|
|
27
|
+
/** An anchor: `{menu:<id>}`. Ids are letters, digits, `_`, `-` and `.`. */
|
|
28
|
+
const ANCHOR_ID = "[A-Za-z0-9_][A-Za-z0-9_.-]*";
|
|
29
|
+
const ANCHOR_RE = new RegExp(`\\{menu:(${ANCHOR_ID})\\}`, "g");
|
|
30
|
+
/** The anchor alone on a line (leading/trailing blanks allowed). */
|
|
31
|
+
const ANCHOR_LINE_RE = new RegExp(`^[ \\t]*\\{menu:(${ANCHOR_ID})\\}[ \\t]*$`, "gm");
|
|
32
|
+
/** The element form of the anchor, which is what the MDX pipeline sees. */
|
|
33
|
+
const ANCHOR_ELEMENT_RE = /<div\s+data-ursa-menu="([^"]+)"\s*(?:\/>|>\s*<\/div>)/g;
|
|
34
|
+
|
|
35
|
+
export const APPEARANCES = ["horizontal", "vertical"];
|
|
36
|
+
const DEFAULT_APPEARANCE = "horizontal";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Normalise a menu file's frontmatter into the fields named menus use.
|
|
40
|
+
* `id` is required for a named menu; without it the file is a folder menu
|
|
41
|
+
* (menu.md) or invalid (menu-x.md), which the caller decides.
|
|
42
|
+
* @param {object} frontmatter
|
|
43
|
+
* @returns {{id: string|null, appearance: string, appearanceInvalid: string|null}}
|
|
44
|
+
*/
|
|
45
|
+
export function namedMenuOptions(frontmatter) {
|
|
46
|
+
const rawId = frontmatter?.id;
|
|
47
|
+
const id = rawId === undefined || rawId === null || rawId === "" ? null : String(rawId).trim();
|
|
48
|
+
const rawAppearance = frontmatter?.appearance;
|
|
49
|
+
let appearance = DEFAULT_APPEARANCE;
|
|
50
|
+
let appearanceInvalid = null;
|
|
51
|
+
if (rawAppearance !== undefined && rawAppearance !== "") {
|
|
52
|
+
const a = String(rawAppearance).trim().toLowerCase();
|
|
53
|
+
if (APPEARANCES.includes(a)) appearance = a;
|
|
54
|
+
else appearanceInvalid = String(rawAppearance);
|
|
55
|
+
}
|
|
56
|
+
return { id, appearance, appearanceInvalid };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Menu files in one directory, sorted. Reads the listing through tracedFs so
|
|
61
|
+
* a file appearing later is an observed change.
|
|
62
|
+
* @param {string} dirPath - Absolute directory
|
|
63
|
+
* @returns {string[]} - Absolute paths
|
|
64
|
+
*/
|
|
65
|
+
export function menuFilesIn(dirPath) {
|
|
66
|
+
let entries;
|
|
67
|
+
try {
|
|
68
|
+
entries = readdirSync(dirPath, { withFileTypes: true });
|
|
69
|
+
} catch {
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
return entries
|
|
73
|
+
.filter((e) => e.isFile() && isMenuFile(e.name))
|
|
74
|
+
.map((e) => join(dirPath, e.name))
|
|
75
|
+
.sort();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Find the nearest menu file with the given id, walking up from `dirPath` to
|
|
80
|
+
* the source root. A deeper file with the same id shadows a shallower one.
|
|
81
|
+
* @param {string} dirPath - Absolute directory to start from
|
|
82
|
+
* @param {string} sourceRoot - Absolute docroot; the walk stops here
|
|
83
|
+
* @param {string} id - The menu id an anchor named
|
|
84
|
+
* @returns {{path: string, menuDir: string, content: string, frontmatter: object, body: string} | null}
|
|
85
|
+
*/
|
|
86
|
+
export function findNamedMenu(dirPath, sourceRoot, id) {
|
|
87
|
+
const root = resolve(sourceRoot);
|
|
88
|
+
let current = resolve(dirPath);
|
|
89
|
+
while (current.startsWith(root)) {
|
|
90
|
+
for (const menuPath of menuFilesIn(current)) {
|
|
91
|
+
let content;
|
|
92
|
+
try {
|
|
93
|
+
content = readFileSync(menuPath, "utf8");
|
|
94
|
+
} catch {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const { frontmatter, body } = extractMenuFrontmatter(content);
|
|
98
|
+
if (namedMenuOptions(frontmatter).id === id) {
|
|
99
|
+
return { path: menuPath, menuDir: current, content, frontmatter, body };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const parent = dirname(current);
|
|
103
|
+
if (parent === current) break;
|
|
104
|
+
current = parent;
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Every menu id a rendered body anchors, in both forms, in order of first
|
|
111
|
+
* appearance. Used to demand the menus before substituting them.
|
|
112
|
+
* @param {string} html
|
|
113
|
+
* @returns {string[]}
|
|
114
|
+
*/
|
|
115
|
+
export function collectMenuAnchorIds(html) {
|
|
116
|
+
const ids = [];
|
|
117
|
+
const seen = new Set();
|
|
118
|
+
const add = (id) => {
|
|
119
|
+
if (!seen.has(id)) {
|
|
120
|
+
seen.add(id);
|
|
121
|
+
ids.push(id);
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
for (const m of html.matchAll(ANCHOR_ELEMENT_RE)) add(m[1]);
|
|
125
|
+
for (const m of html.matchAll(ANCHOR_RE)) add(m[1]);
|
|
126
|
+
return ids;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* MDX reads `{menu:x}` as a JavaScript expression and fails to compile it.
|
|
131
|
+
* Before compiling, an anchor alone on a line becomes the element form, which
|
|
132
|
+
* is JSX the compiler passes through and `resolveMenuAnchors` recognises.
|
|
133
|
+
* @param {string} source - Raw .mdx source
|
|
134
|
+
* @returns {string}
|
|
135
|
+
*/
|
|
136
|
+
export function prepareMdxMenuAnchors(source) {
|
|
137
|
+
if (!source.includes("{menu:")) return source;
|
|
138
|
+
return source.replace(ANCHOR_LINE_RE, (_, id) => `\n<div data-ursa-menu="${id}"></div>\n`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Replace every anchor in rendered HTML with what `render(id)` returns.
|
|
143
|
+
*
|
|
144
|
+
* Handles the element form anywhere, and the `{menu:x}` form inside a
|
|
145
|
+
* paragraph: a paragraph that is only the anchor is replaced whole, and a
|
|
146
|
+
* paragraph with text around the anchor is split so the `<nav>` never sits
|
|
147
|
+
* inside a `<p>`. Anchors inside `<code>` are left alone — they are being
|
|
148
|
+
* talked about, not used.
|
|
149
|
+
*
|
|
150
|
+
* @param {string} html - Rendered body
|
|
151
|
+
* @param {(id: string) => string} render - Markup for one id (never throws; see `menuNotFoundComment`)
|
|
152
|
+
* @returns {string}
|
|
153
|
+
*/
|
|
154
|
+
export function resolveMenuAnchors(html, render) {
|
|
155
|
+
if (!html || (!html.includes("{menu:") && !html.includes("data-ursa-menu"))) return html;
|
|
156
|
+
|
|
157
|
+
html = html.replace(ANCHOR_ELEMENT_RE, (_, id) => render(id));
|
|
158
|
+
|
|
159
|
+
if (!html.includes("{menu:")) return html;
|
|
160
|
+
return html.replace(/<p>([\s\S]*?)<\/p>/g, (paragraph, inner) => {
|
|
161
|
+
if (!inner.includes("{menu:")) return paragraph;
|
|
162
|
+
// Anchors quoted in code spans stay as written
|
|
163
|
+
const codeSpans = [];
|
|
164
|
+
const masked = inner.replace(/<code[\s>][\s\S]*?<\/code>/g, (span) => {
|
|
165
|
+
codeSpans.push(span);
|
|
166
|
+
return `\u0000${codeSpans.length - 1}\u0000`;
|
|
167
|
+
});
|
|
168
|
+
const unmask = (s) => s.replace(/\u0000(\d+)\u0000/g, (_, i) => codeSpans[Number(i)]);
|
|
169
|
+
const parts = masked.split(new RegExp(`\\{menu:(${ANCHOR_ID})\\}`));
|
|
170
|
+
if (parts.length === 1) return paragraph;
|
|
171
|
+
let out = "";
|
|
172
|
+
for (let i = 0; i < parts.length; i++) {
|
|
173
|
+
if (i % 2 === 1) {
|
|
174
|
+
out += render(parts[i]);
|
|
175
|
+
} else {
|
|
176
|
+
const text = unmask(parts[i]).trim();
|
|
177
|
+
if (text) out += `<p>${text}</p>\n`;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return out;
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** What an anchor becomes when its menu cannot be rendered. */
|
|
185
|
+
export function menuNotFoundComment(id, reason = "not found") {
|
|
186
|
+
return `<!-- ursa: menu "${escapeHtml(id)}" ${escapeHtml(reason)} -->`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* True when a rendered body begins with inline menus (after optional
|
|
191
|
+
* whitespace), so the default-title injection can place its `<h1>` after them
|
|
192
|
+
* rather than pushing a top-of-page menu below the title.
|
|
193
|
+
* @param {string} html
|
|
194
|
+
* @returns {number} - Index just past the leading menus (0 when there are none)
|
|
195
|
+
*/
|
|
196
|
+
export function leadingMenusEnd(html) {
|
|
197
|
+
let i = 0;
|
|
198
|
+
const re = /^\s*(<nav class="ursa-menu[^"]*"[^>]*>[\s\S]*?<\/nav>|<!-- ursa: menu [^>]*-->)/;
|
|
199
|
+
for (;;) {
|
|
200
|
+
const m = re.exec(html.slice(i));
|
|
201
|
+
if (!m) return i;
|
|
202
|
+
i += m[0].length;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Static markup for a named menu.
|
|
208
|
+
*
|
|
209
|
+
* @param {Array} menuData - Items as `parseCustomMenu` produces them ({label, href, children})
|
|
210
|
+
* @param {object} opts
|
|
211
|
+
* @param {string} opts.id
|
|
212
|
+
* @param {string} [opts.appearance="horizontal"]
|
|
213
|
+
* @param {string|null} [opts.currentUrl] - The page's root-absolute `.html` URL, to mark the current item
|
|
214
|
+
* @returns {string}
|
|
215
|
+
*/
|
|
216
|
+
export function renderInlineMenuHtml(menuData, { id, appearance = DEFAULT_APPEARANCE, currentUrl = null }) {
|
|
217
|
+
const current = currentUrl ? normalizeUrl(currentUrl) : null;
|
|
218
|
+
const list = renderLevel(menuData || [], current, 0);
|
|
219
|
+
return `<nav class="ursa-menu ursa-menu-${appearance}" data-menu-id="${escapeHtml(id)}" aria-label="${escapeHtml(id)}">${list}</nav>`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function renderLevel(items, current, depth) {
|
|
223
|
+
if (!items || items.length === 0) return "";
|
|
224
|
+
const lis = items.map((item) => {
|
|
225
|
+
const children = item.children || [];
|
|
226
|
+
const isCurrent = current !== null && item.href && normalizeUrl(item.href) === current;
|
|
227
|
+
const hasCurrentBelow = !isCurrent && containsCurrent(children, current);
|
|
228
|
+
const classes = ["ursa-menu-item"];
|
|
229
|
+
if (children.length > 0) classes.push("ursa-menu-has-children");
|
|
230
|
+
if (isCurrent) classes.push("ursa-menu-current");
|
|
231
|
+
if (hasCurrentBelow) classes.push("ursa-menu-active");
|
|
232
|
+
const label = escapeHtml(item.label ?? "");
|
|
233
|
+
const link = item.href
|
|
234
|
+
? `<a href="${escapeHtml(item.href)}"${isCurrent ? ' aria-current="page"' : ""}>${label}</a>`
|
|
235
|
+
: `<span>${label}</span>`;
|
|
236
|
+
return `<li class="${classes.join(" ")}">${link}${renderLevel(children, current, depth + 1)}</li>`;
|
|
237
|
+
});
|
|
238
|
+
return `<ul class="ursa-menu-level" data-depth="${depth}">${lis.join("")}</ul>`;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function containsCurrent(items, current) {
|
|
242
|
+
if (current === null) return false;
|
|
243
|
+
for (const item of items || []) {
|
|
244
|
+
if (item.href && normalizeUrl(item.href) === current) return true;
|
|
245
|
+
if (containsCurrent(item.children, current)) return true;
|
|
246
|
+
}
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** `/a/b/index.html`, `/a/b/`, `/a/b` and `/a/b.html` compare by the same key. */
|
|
251
|
+
function normalizeUrl(url) {
|
|
252
|
+
let u = String(url).split("#")[0].split("?")[0];
|
|
253
|
+
try {
|
|
254
|
+
u = decodeURIComponent(u);
|
|
255
|
+
} catch {
|
|
256
|
+
// leave as written
|
|
257
|
+
}
|
|
258
|
+
u = u.replace(/\/index\.html$/i, "/").replace(/\.html$/i, "");
|
|
259
|
+
if (u.length > 1) u = u.replace(/\/$/, "");
|
|
260
|
+
return u.toLowerCase();
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function escapeHtml(s) {
|
|
264
|
+
return String(s)
|
|
265
|
+
.replace(/&/g, "&")
|
|
266
|
+
.replace(/</g, "<")
|
|
267
|
+
.replace(/>/g, ">")
|
|
268
|
+
.replace(/"/g, """);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** The menu id a `menu-<id>.md` filename suggests; for messages only. */
|
|
272
|
+
export function menuFileSuffix(path) {
|
|
273
|
+
const m = basename(path).match(/^_?menu-(.+)\.(md|txt)$/i);
|
|
274
|
+
return m ? m[1] : null;
|
|
275
|
+
}
|