@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.
Files changed (48) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +144 -16
  3. package/bin/ursa.js +14 -1
  4. package/meta/templates/default-template/default.css +144 -0
  5. package/meta/templates/default-template/menu.js +18 -1
  6. package/meta/templates/default-template/search.js +11 -0
  7. package/meta/templates/default-template/sectionify.js +17 -9
  8. package/meta/templates/default-template/widgets.js +4 -0
  9. package/package.json +1 -2
  10. package/src/dev.js +13 -23
  11. package/src/helper/__test__/contentHash.test.js +16 -6
  12. package/src/helper/__test__/inlineMenu.test.js +142 -0
  13. package/src/helper/assetBundler.js +93 -19
  14. package/src/helper/automenu.js +39 -13
  15. package/src/helper/build/__test__/autoIndex.test.js +2 -132
  16. package/src/helper/build/__test__/graph.test.js +259 -3
  17. package/src/helper/build/__test__/pass.test.js +664 -0
  18. package/src/helper/build/autoIndex.js +6 -371
  19. package/src/helper/build/excludeFilter.js +1 -2
  20. package/src/helper/build/footer.js +27 -14
  21. package/src/helper/build/graph.js +575 -152
  22. package/src/helper/build/index.js +0 -2
  23. package/src/helper/build/metadata.js +19 -5
  24. package/src/helper/build/pass.js +497 -0
  25. package/src/helper/build/precedence.js +174 -0
  26. package/src/helper/build/site.js +1392 -0
  27. package/src/helper/build/templates.js +1 -2
  28. package/src/helper/build/tracedFs.js +247 -0
  29. package/src/helper/contentHash.js +0 -78
  30. package/src/helper/customMenu.js +27 -4
  31. package/src/helper/fileRenderer.js +119 -111
  32. package/src/helper/findScriptJs.js +1 -1
  33. package/src/helper/findStyleCss.js +1 -1
  34. package/src/helper/folderConfig.js +7 -18
  35. package/src/helper/fullTextIndex.js +41 -29
  36. package/src/helper/imageProcessor.js +45 -0
  37. package/src/helper/inlineMenu.js +275 -0
  38. package/src/helper/linkValidator.js +118 -127
  39. package/src/helper/mdxRenderer.js +27 -5
  40. package/src/helper/menuLabels.js +30 -5
  41. package/src/helper/whitelistFilter.js +1 -2
  42. package/src/jobs/generate.js +67 -1829
  43. package/src/serve.js +317 -697
  44. package/src/helper/__test__/dependencyTracker.test.js +0 -157
  45. package/src/helper/build/cacheBust.js +0 -141
  46. package/src/helper/build/navCache.js +0 -145
  47. package/src/helper/build/watchCache.js +0 -33
  48. package/src/helper/dependencyTracker.js +0 -384
@@ -1,11 +1,8 @@
1
- import { existsSync, readFileSync } from 'fs';
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
- * Clear the config cache (useful between generation runs)
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
- const config = JSON.parse(content);
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
- for (const doc of documents) {
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: doc.path, // path (shortened key for smaller JSON)
145
- s: count, // score (shortened key)
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, "&amp;")
266
+ .replace(/</g, "&lt;")
267
+ .replace(/>/g, "&gt;")
268
+ .replace(/"/g, "&quot;");
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
+ }