@kenjura/ursa 0.81.3 → 0.82.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 CHANGED
@@ -1,3 +1,13 @@
1
+ # 0.82.0
2
+ 2026-05-06
3
+
4
+ - **Frontmatter table is now opt-in**: The HTML frontmatter table that was previously injected into every Markdown/MDX document after the first H1 is now only rendered when the document's frontmatter sets `render-frontmatter: true` (boolean `true` or string `"true"`). Documents without the flag (or with it set to `false`) no longer have the table injected. The `render-frontmatter` key itself is excluded from the rendered table.
5
+
6
+ # 0.81.4
7
+ 2026-05-04
8
+
9
+ - bug fix: directory index html files were never being written...impossibly, but there it is
10
+
1
11
  # 0.81.3
2
12
  2026-04-14
3
13
 
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@kenjura/ursa",
3
3
  "author": "Andrew London <andrew@kenjura.com>",
4
4
  "type": "module",
5
- "version": "0.81.3",
5
+ "version": "0.82.0",
6
6
  "description": "static site generator from MD/wikitext/YML",
7
7
  "main": "lib/index.js",
8
8
  "bin": {
@@ -0,0 +1,88 @@
1
+ import {
2
+ metadataToTable,
3
+ injectFrontmatterTable,
4
+ isRenderFrontmatterEnabled,
5
+ } from "../frontmatterTable.js";
6
+
7
+ describe("isRenderFrontmatterEnabled", () => {
8
+ it("returns false for missing metadata", () => {
9
+ expect(isRenderFrontmatterEnabled(null)).toBe(false);
10
+ expect(isRenderFrontmatterEnabled(undefined)).toBe(false);
11
+ expect(isRenderFrontmatterEnabled({})).toBe(false);
12
+ });
13
+ it("returns false when flag is missing", () => {
14
+ expect(isRenderFrontmatterEnabled({ title: "Foo" })).toBe(false);
15
+ });
16
+ it("returns false when flag is false", () => {
17
+ expect(isRenderFrontmatterEnabled({ "render-frontmatter": false })).toBe(false);
18
+ });
19
+ it("returns true when flag is boolean true", () => {
20
+ expect(isRenderFrontmatterEnabled({ "render-frontmatter": true })).toBe(true);
21
+ });
22
+ it("returns true when flag is the string 'true'", () => {
23
+ expect(isRenderFrontmatterEnabled({ "render-frontmatter": "true" })).toBe(true);
24
+ });
25
+ });
26
+
27
+ describe("injectFrontmatterTable", () => {
28
+ const body = "<h1>Title</h1>\n<p>Body</p>";
29
+
30
+ it("does not inject when render-frontmatter flag is missing", () => {
31
+ const result = injectFrontmatterTable(body, { type: "power", cost: 3 });
32
+ expect(result).toBe(body);
33
+ expect(result).not.toContain("frontmatter-table");
34
+ });
35
+
36
+ it("does not inject when render-frontmatter is false", () => {
37
+ const result = injectFrontmatterTable(body, {
38
+ "render-frontmatter": false,
39
+ type: "power",
40
+ });
41
+ expect(result).toBe(body);
42
+ });
43
+
44
+ it("injects table after first H1 when render-frontmatter is true", () => {
45
+ const result = injectFrontmatterTable(body, {
46
+ "render-frontmatter": true,
47
+ type: "power",
48
+ cost: 3,
49
+ });
50
+ expect(result).toContain('<table class="frontmatter-table">');
51
+ expect(result).toContain("<th>Type</th>");
52
+ expect(result).toContain("<td>power</td>");
53
+ // Table appears after the </h1>
54
+ expect(result.indexOf("</h1>")).toBeLessThan(result.indexOf("frontmatter-table"));
55
+ });
56
+
57
+ it("accepts string 'true' for the flag", () => {
58
+ const result = injectFrontmatterTable(body, {
59
+ "render-frontmatter": "true",
60
+ type: "power",
61
+ });
62
+ expect(result).toContain('<table class="frontmatter-table">');
63
+ });
64
+
65
+ it("does not include render-frontmatter itself as a row", () => {
66
+ const result = injectFrontmatterTable(body, {
67
+ "render-frontmatter": true,
68
+ type: "power",
69
+ });
70
+ expect(result).not.toContain(">Render Frontmatter<");
71
+ });
72
+
73
+ it("returns body unchanged when no displayable entries exist", () => {
74
+ const result = injectFrontmatterTable(body, {
75
+ "render-frontmatter": true,
76
+ title: "ignored",
77
+ });
78
+ expect(result).toBe(body);
79
+ });
80
+ });
81
+
82
+ describe("metadataToTable", () => {
83
+ it("still produces a table when called directly (used by other consumers)", () => {
84
+ const html = metadataToTable({ type: "power", cost: 3 });
85
+ expect(html).toContain('<table class="frontmatter-table">');
86
+ expect(html).toContain("<th>Type</th>");
87
+ });
88
+ });
@@ -3,6 +3,18 @@
3
3
  * and inject it into the document body after the first H1
4
4
  */
5
5
 
6
+ /**
7
+ * Determine whether the document's frontmatter opts in to rendering
8
+ * the frontmatter table. Accepts boolean true or the string "true".
9
+ * @param {Object} metadata
10
+ * @returns {boolean}
11
+ */
12
+ export function isRenderFrontmatterEnabled(metadata) {
13
+ if (!metadata || typeof metadata !== 'object') return false;
14
+ const value = metadata['render-frontmatter'];
15
+ return value === true || value === 'true';
16
+ }
17
+
6
18
  /**
7
19
  * Convert a metadata value to a displayable string
8
20
  * @param {any} value - The value to convert
@@ -75,7 +87,8 @@ export function metadataToTable(metadata) {
75
87
  'generate-auto-index', // Auto-indexing control
76
88
  'auto-index-depth', // Auto-indexing depth
77
89
  'auto-index-position', // Auto-indexing position
78
- 'hydrate' // MDX hydration control
90
+ 'hydrate', // MDX hydration control
91
+ 'render-frontmatter' // Opt-in flag controlling whether this table is rendered
79
92
  ];
80
93
  const entries = Object.entries(metadata).filter(
81
94
  ([key]) => !excludeKeys.includes(key.toLowerCase())
@@ -109,8 +122,14 @@ ${rows}
109
122
  * @returns {string} The body HTML with the frontmatter table injected
110
123
  */
111
124
  export function injectFrontmatterTable(bodyHtml, metadata) {
125
+ // Opt-in: only render the frontmatter table when the document explicitly
126
+ // sets `render-frontmatter: true` in its frontmatter.
127
+ if (!metadata || !isRenderFrontmatterEnabled(metadata)) {
128
+ return bodyHtml;
129
+ }
130
+
112
131
  const table = metadataToTable(metadata);
113
-
132
+
114
133
  if (!table) {
115
134
  return bodyHtml;
116
135
  }
@@ -1025,7 +1025,7 @@ export async function generate({
1025
1025
 
1026
1026
  // html
1027
1027
  const htmlOutputFilename = dirPath.replace(source, output) + ".html";
1028
- const indexAlreadyExists = fileExists(htmlOutputFilename);
1028
+ const indexAlreadyExists = await fileExists(htmlOutputFilename);
1029
1029
  if (!indexAlreadyExists) {
1030
1030
  const template = templates["default-template"];
1031
1031
  const indexHtml = `<ul>${pathsInThisDirectory
@@ -1583,7 +1583,24 @@ export async function regenerateSingleFile(changedFile, {
1583
1583
  const xmlOutputFilename = outputFilename.replace(".html", ".xml");
1584
1584
  const xml = `<article>${o2x(jsonObject)}</article>`;
1585
1585
  await outputFile(xmlOutputFilename, xml);
1586
-
1586
+
1587
+ // Folder-named index promotion (mirrors autoIndex behavior on full builds):
1588
+ // If the file is named like its parent folder (e.g. aletheia/aletheia.md) and
1589
+ // no explicit index.{md,mdx,txt,yml,html} exists alongside it, also write the
1590
+ // same outputs to <dir>/index.html|json|xml so the canonical URL stays fresh.
1591
+ const sourceDirOfFile = dirname(changedFile);
1592
+ const parentFolderName = basename(sourceDirOfFile);
1593
+ if (base && parentFolderName && base === parentFolderName) {
1594
+ const hasExplicitIndex = ['index.md', 'index.mdx', 'index.txt', 'index.yml', 'index.html']
1595
+ .some(name => existsSync(join(sourceDirOfFile, name)));
1596
+ if (!hasExplicitIndex) {
1597
+ const outDirOfFile = dirname(outputFilename);
1598
+ await outputFile(join(outDirOfFile, 'index.html'), finalHtml);
1599
+ await outputFile(join(outDirOfFile, 'index.json'), json);
1600
+ await outputFile(join(outDirOfFile, 'index.xml'), xml);
1601
+ }
1602
+ }
1603
+
1587
1604
  // Update hash cache
1588
1605
  updateHash(changedFile, rawBody, hashCache);
1589
1606
 
package/src/serve.js CHANGED
@@ -69,6 +69,30 @@ function docPathToUrl(docPath, sourceDir) {
69
69
  return normalizeUrl(rawUrl);
70
70
  }
71
71
 
72
+ /**
73
+ * Return all URL paths a source file maps to. Most files map to a single URL,
74
+ * but folder-named files (e.g. aletheia/aletheia.md) are promoted to
75
+ * <folder>/index.html during the build, so they also serve the folder URL.
76
+ * @param {string} docPath - Absolute source path
77
+ * @param {string} sourceDir - Absolute source directory (with trailing slash)
78
+ * @returns {string[]} Normalized URL paths
79
+ */
80
+ function docPathToUrls(docPath, sourceDir) {
81
+ const urls = [docPathToUrl(docPath, sourceDir)];
82
+ const ext = docPath.match(/\.(md|mdx|txt|yml|yaml)$/);
83
+ if (ext) {
84
+ const base = basename(docPath, ext[0]);
85
+ const parent = basename(dirname(docPath));
86
+ if (base && parent && base === parent) {
87
+ // Folder-named file → also serves the folder's index URL
88
+ const folderUrl = normalizeUrl('/' + dirname(docPath).replace(
89
+ sourceDir.endsWith('/') ? sourceDir : sourceDir + '/', '') + '/');
90
+ if (!urls.includes(folderUrl)) urls.push(folderUrl);
91
+ }
92
+ }
93
+ return urls;
94
+ }
95
+
72
96
  /**
73
97
  * Broadcast a message to all connected WebSocket clients.
74
98
  * @param {object} messageObj - Object to JSON.stringify and send
@@ -573,10 +597,12 @@ export async function serve({
573
597
  const affectedUrlSet = new Set();
574
598
 
575
599
  for (const docPath of docPathsArray) {
576
- const url = docPathToUrl(docPath, sourceDir + '/');
577
- affectedUrlSet.add(url);
578
- if (viewedUrls.includes(url)) {
579
- priorityPaths.push(docPath);
600
+ const urls = docPathToUrls(docPath, sourceDir + '/');
601
+ for (const url of urls) {
602
+ affectedUrlSet.add(url);
603
+ if (viewedUrls.includes(url) && !priorityPaths.includes(docPath)) {
604
+ priorityPaths.push(docPath);
605
+ }
580
606
  }
581
607
  }
582
608
 
@@ -599,7 +625,7 @@ export async function serve({
599
625
  onPriorityComplete: ({ regenerated, failed, priorityDocs }) => {
600
626
  if (regenerated > 0) {
601
627
  // Immediately reload clients whose pages are now ready
602
- const readyUrls = new Set(priorityDocs.map(p => docPathToUrl(p, sourceDir + '/')));
628
+ const readyUrls = new Set(priorityDocs.flatMap(p => docPathToUrls(p, sourceDir + '/')));
603
629
  console.log(`⚡ Priority complete: ${regenerated} OK, ${failed} failed → reloading clients`);
604
630
  reloadAffectedClients(readyUrls, uniqueNames[0]);
605
631
  } else if (failed > 0) {
@@ -610,7 +636,7 @@ export async function serve({
610
636
 
611
637
  // After all remaining docs are done, reload any remaining affected clients
612
638
  // (non-priority clients that weren't reloaded during onPriorityComplete)
613
- const priorityUrlSet = new Set(priorityPaths.map(p => docPathToUrl(p, sourceDir + '/')));
639
+ const priorityUrlSet = new Set(priorityPaths.flatMap(p => docPathToUrls(p, sourceDir + '/')));
614
640
  const remainingUrls = new Set([...affectedUrlSet].filter(u => !priorityUrlSet.has(u)));
615
641
  if (remainingUrls.size > 0) {
616
642
  reloadAffectedClients(remainingUrls, uniqueNames[0]);