@janga/norna 0.7.24 → 0.7.26

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 (142) hide show
  1. package/README.md +5 -4
  2. package/astro.config.mjs +27 -11
  3. package/bin/norna-cli.mjs +12 -0
  4. package/package.json +26 -5
  5. package/schemas/card-list.schema.json +121 -0
  6. package/schemas/category.schema.json +2 -2
  7. package/schemas/config.schema.json +342 -33
  8. package/schemas/content-frontmatter.schema.json +7 -7
  9. package/schemas/image-carousel.schema.json +43 -0
  10. package/schemas/image-stack.schema.json +43 -0
  11. package/schemas/manifest.json +6 -1
  12. package/schemas/page-theme.schema.json +26 -26
  13. package/schemas/sitewide-content.schema.json +18 -18
  14. package/schemas/theme.schema.json +237 -262
  15. package/scripts/check-config.mjs +5 -1
  16. package/scripts/dev-local.mjs +130 -13
  17. package/scripts/generate-schemas.mjs +15 -3
  18. package/scripts/init-site.mjs +2 -0
  19. package/scripts/lib/category-destinations.mjs +48 -0
  20. package/scripts/lib/code-fence-metadata.mjs +25 -43
  21. package/scripts/lib/content-tabs.mjs +155 -0
  22. package/scripts/lib/details-headings.mjs +58 -0
  23. package/scripts/lib/docusaurus-migration-audit.mjs +603 -0
  24. package/scripts/lib/edit-source-link.mjs +53 -0
  25. package/scripts/lib/editor-language-service.mjs +238 -116
  26. package/scripts/lib/locale-registry.mjs +597 -0
  27. package/scripts/lib/locales/az-Latn.mjs +78 -0
  28. package/scripts/lib/locales/be.mjs +78 -0
  29. package/scripts/lib/locales/bg.mjs +78 -0
  30. package/scripts/lib/locales/bs.mjs +78 -0
  31. package/scripts/lib/locales/ca.mjs +78 -0
  32. package/scripts/lib/locales/cnr.mjs +78 -0
  33. package/scripts/lib/locales/cs.mjs +78 -0
  34. package/scripts/lib/locales/da.mjs +78 -0
  35. package/scripts/lib/locales/de.mjs +78 -0
  36. package/scripts/lib/locales/el.mjs +78 -0
  37. package/scripts/lib/locales/en.mjs +78 -0
  38. package/scripts/lib/locales/es.mjs +78 -0
  39. package/scripts/lib/locales/et.mjs +78 -0
  40. package/scripts/lib/locales/fi.mjs +78 -0
  41. package/scripts/lib/locales/fil.mjs +78 -0
  42. package/scripts/lib/locales/fr.mjs +78 -0
  43. package/scripts/lib/locales/ga.mjs +78 -0
  44. package/scripts/lib/locales/ha.mjs +78 -0
  45. package/scripts/lib/locales/hr.mjs +78 -0
  46. package/scripts/lib/locales/hu.mjs +78 -0
  47. package/scripts/lib/locales/id.mjs +78 -0
  48. package/scripts/lib/locales/is.mjs +78 -0
  49. package/scripts/lib/locales/it.mjs +78 -0
  50. package/scripts/lib/locales/jv.mjs +78 -0
  51. package/scripts/lib/locales/lb.mjs +78 -0
  52. package/scripts/lib/locales/lt.mjs +78 -0
  53. package/scripts/lib/locales/lv.mjs +78 -0
  54. package/scripts/lib/locales/mk.mjs +78 -0
  55. package/scripts/lib/locales/mt.mjs +78 -0
  56. package/scripts/lib/locales/nb.mjs +78 -0
  57. package/scripts/lib/locales/nl.mjs +78 -0
  58. package/scripts/lib/locales/nn.mjs +78 -0
  59. package/scripts/lib/locales/pcm.mjs +78 -0
  60. package/scripts/lib/locales/pl.mjs +78 -0
  61. package/scripts/lib/locales/pt.mjs +78 -0
  62. package/scripts/lib/locales/ro.mjs +78 -0
  63. package/scripts/lib/locales/ru.mjs +78 -0
  64. package/scripts/lib/locales/sk.mjs +78 -0
  65. package/scripts/lib/locales/sl.mjs +78 -0
  66. package/scripts/lib/locales/sq.mjs +78 -0
  67. package/scripts/lib/locales/sr-Cyrl.mjs +78 -0
  68. package/scripts/lib/locales/sr-Latn.mjs +78 -0
  69. package/scripts/lib/locales/sv.mjs +78 -0
  70. package/scripts/lib/locales/sw.mjs +78 -0
  71. package/scripts/lib/locales/tr.mjs +78 -0
  72. package/scripts/lib/locales/uk.mjs +78 -0
  73. package/scripts/lib/locales/vi.mjs +78 -0
  74. package/scripts/lib/markdown-links.mjs +4 -25
  75. package/scripts/lib/markdown-notes-render-plugin.mjs +75 -0
  76. package/scripts/lib/markdown-notes.mjs +165 -0
  77. package/scripts/lib/navigation-model.mjs +1 -1
  78. package/scripts/lib/norna-markdown-blocks.mjs +123 -447
  79. package/scripts/lib/norna-markdown-render-plugin.mjs +4 -0
  80. package/scripts/lib/page-aliases.mjs +12 -1
  81. package/scripts/lib/page-markdown.mjs +63 -16
  82. package/scripts/lib/page-move-plan.mjs +22 -5
  83. package/scripts/lib/pagefind-translations.mjs +620 -0
  84. package/scripts/lib/presentation-contract.mjs +6 -1
  85. package/scripts/lib/presentation.mjs +29 -30
  86. package/scripts/lib/project-config.mjs +22 -163
  87. package/scripts/lib/schema-definitions.mjs +13 -12
  88. package/scripts/lib/schema-editor-metadata.mjs +42 -46
  89. package/scripts/lib/schema-value-definitions.mjs +7 -8
  90. package/scripts/lib/semantic-callouts.mjs +4 -2
  91. package/scripts/lib/site-content.mjs +4 -2
  92. package/scripts/lib/site-link-graph.mjs +16 -6
  93. package/scripts/lib/site-navigation-tree.mjs +0 -11
  94. package/scripts/lib/site-paths.mjs +14 -2
  95. package/scripts/lib/sitemap.mjs +5 -3
  96. package/scripts/lib/structured-blocks.mjs +108 -0
  97. package/scripts/lib/table-render-plugin.mjs +76 -0
  98. package/scripts/lib/table-row-headers.mjs +218 -0
  99. package/scripts/lib/theme-presets.mjs +3 -10
  100. package/scripts/lib/yaml-config.mjs +2 -6
  101. package/scripts/migrate-check.mjs +19 -0
  102. package/scripts/sync-content-sections.mjs +39 -10
  103. package/scripts/sync-site-public.mjs +7 -1
  104. package/src/components/CardList.astro +1 -0
  105. package/src/components/CodeBlockCopyScript.astro +49 -6
  106. package/src/components/ContentBlocks.astro +39 -0
  107. package/src/components/ContentTabsScript.astro +60 -0
  108. package/src/components/DisplaySettings.astro +17 -19
  109. package/src/components/ImageCarousel.astro +4 -1
  110. package/src/components/ImageStack.astro +34 -9
  111. package/src/components/ImageStackEnhancement.astro +331 -0
  112. package/src/components/NavigationPageTree.astro +1 -1
  113. package/src/components/NoteNavigationScript.astro +4 -0
  114. package/src/components/PageAliasRedirect.astro +5 -4
  115. package/src/components/PageContentsNavigation.astro +2 -3
  116. package/src/components/SearchNavigationScript.astro +4 -0
  117. package/src/components/SearchPage.astro +43 -2
  118. package/src/components/SectionNavigationScript.astro +86 -16
  119. package/src/components/SiteBreadcrumbs.astro +1 -1
  120. package/src/components/SiteNavigation.astro +98 -65
  121. package/src/components/SitePage.astro +73 -33
  122. package/src/components/SiteSection.astro +5 -81
  123. package/src/components/SiteTreeNavigation.astro +5 -2
  124. package/src/components/TableOverflowScript.astro +579 -0
  125. package/src/components/TopPageMenu.astro +47 -0
  126. package/src/components/TreeNavigationScript.astro +86 -23
  127. package/src/layouts/BaseLayout.astro +34 -12
  128. package/src/lib/generatedImages.ts +18 -0
  129. package/src/lib/navigationFollowing.ts +142 -0
  130. package/src/lib/noteNavigation.ts +50 -0
  131. package/src/lib/searchNavigation.ts +107 -0
  132. package/src/lib/sectionContent.ts +23 -116
  133. package/src/lib/siteNavigation.ts +0 -5
  134. package/src/lib/sitePages.ts +18 -4
  135. package/src/lib/table-sort-tooltip.ts +109 -0
  136. package/src/pages/[...slug].astro +16 -6
  137. package/src/styles/content.css +563 -26
  138. package/src/styles/foundations.css +9 -0
  139. package/src/styles/media.css +192 -3
  140. package/src/styles/navigation.css +214 -60
  141. package/src/styles/page-layout.css +55 -16
  142. package/src/styles/responsive.css +62 -20
@@ -76,7 +76,11 @@ try {
76
76
  console.log('Config check passed.');
77
77
  console.log(`Site URL: ${projectConfig.site.url}`);
78
78
  console.log(`Base path: ${projectConfig.site.basePath}`);
79
- console.log(`Edit links: ${projectConfig.editLink?.baseUrl ?? '(disabled)'}`);
79
+ const editLinkDestinations = [
80
+ projectConfig.editLink?.localEditor ? `local ${projectConfig.editLink.localEditor}` : null,
81
+ projectConfig.editLink?.baseUrl ? `remote ${projectConfig.editLink.baseUrl}` : null,
82
+ ].filter(Boolean);
83
+ console.log(`Edit links: ${editLinkDestinations.join(', ') || '(disabled)'}`);
80
84
  console.log(`Theme preset: ${themeConfig.preset ?? '(none)'}`);
81
85
  console.log(`Page width: ${projectConfig.layout.pageWidth}`);
82
86
  console.log(`Gutter: desktop ${projectConfig.layout.gutter.desktop}, mobile ${projectConfig.layout.gutter.mobile}`);
@@ -5,7 +5,12 @@ import { networkInterfaces } from 'node:os';
5
5
  import path from 'node:path';
6
6
  import { promisify } from 'node:util';
7
7
  import { getAstroArgs, runAstroInherit } from './lib/astro-command.mjs';
8
- import { astroCacheDir, engineRoot, siteProjectRoot } from './lib/site-paths.mjs';
8
+ import {
9
+ astroCacheDir,
10
+ engineRoot,
11
+ siteProjectRoot,
12
+ siteStateDir,
13
+ } from './lib/site-paths.mjs';
9
14
 
10
15
  const execFileAsync = promisify(execFile);
11
16
 
@@ -27,8 +32,12 @@ const probeUrls = [
27
32
  `http://localhost:${port}${projectConfig.site.basePath}`,
28
33
  ];
29
34
  const skipOpen = process.env.NORNA_NO_OPEN === '1';
30
- const statePath = path.join(astroCacheDir, 'dev-local.json');
35
+ const stateDirectory = path.join(siteStateDir, 'dev');
36
+ const statePath = path.join(stateDirectory, 'state.json');
37
+ const legacyStatePath = path.join(astroCacheDir, 'dev-local.json');
38
+ const astroStatePath = path.join(astroCacheDir, 'dev.json');
31
39
  const logPath = path.join(astroCacheDir, 'dev.log');
40
+ const preparationLogPath = path.join(stateDirectory, 'preparation.log');
32
41
  const args = process.argv.slice(2);
33
42
  const knownCommands = new Set(['start', 'lan', 'status', 'logs', 'restart', 'stop']);
34
43
  const command = args.find((arg) => knownCommands.has(arg)) ?? args.find((arg) => !arg.startsWith('-')) ?? 'start';
@@ -51,9 +60,9 @@ const generateImages = async () => execFileAsync(process.execPath, [path.join(en
51
60
  maxBuffer: 1024 * 1024 * 10,
52
61
  });
53
62
 
54
- const readState = async () => {
63
+ const readJsonFile = async (filePath) => {
55
64
  try {
56
- return JSON.parse(await readFile(statePath, 'utf8'));
65
+ return JSON.parse(await readFile(filePath, 'utf8'));
57
66
  } catch (error) {
58
67
  if (error.code === 'ENOENT') {
59
68
  return null;
@@ -63,15 +72,34 @@ const readState = async () => {
63
72
  }
64
73
  };
65
74
 
75
+ const readState = async () => (
76
+ await readJsonFile(statePath)
77
+ ?? await readJsonFile(legacyStatePath)
78
+ );
79
+
66
80
  const getLanUrls = () => Object.values(networkInterfaces())
67
81
  .flat()
68
82
  .filter((network) => network?.family === 'IPv4' && !network.internal)
69
83
  .map((network) => `http://${network.address}:${port}${projectConfig.site.basePath}`);
70
84
 
71
85
  const writeState = async (host) => {
72
- await mkdir(path.dirname(statePath), { recursive: true });
86
+ const astroState = await readJsonFile(astroStatePath);
87
+ let pid = astroState?.port === port && isProcessAlive(astroState.pid)
88
+ ? astroState.pid
89
+ : null;
90
+ if (!Number.isInteger(pid)) {
91
+ const listeningPids = await getPortPids({ required: false });
92
+ pid = listeningPids?.length === 1 ? listeningPids[0] : null;
93
+ }
94
+
95
+ if (!Number.isInteger(pid)) {
96
+ throw new Error(`The dev server started at ${localUrl}, but Norna could not record its process. Stop the process on port ${port} before starting it again.`);
97
+ }
98
+
99
+ await mkdir(stateDirectory, { recursive: true });
73
100
  await writeFile(statePath, `${JSON.stringify({
74
101
  port,
102
+ pid,
75
103
  host,
76
104
  mode: host === '0.0.0.0' ? 'lan' : 'local',
77
105
  url: localUrl,
@@ -81,6 +109,8 @@ const writeState = async (host) => {
81
109
 
82
110
  const removeState = async () => {
83
111
  await rm(statePath, { force: true });
112
+ await rm(legacyStatePath, { force: true });
113
+ await rm(stateDirectory, { recursive: true, force: true });
84
114
  };
85
115
 
86
116
  const readDevLog = async () => {
@@ -161,6 +191,16 @@ const sleep = (milliseconds) => new Promise((resolve) => {
161
191
  setTimeout(resolve, milliseconds);
162
192
  });
163
193
 
194
+ const isProcessAlive = (pid) => {
195
+ if (!Number.isInteger(pid)) return false;
196
+ try {
197
+ process.kill(pid, 0);
198
+ return true;
199
+ } catch (error) {
200
+ return error.code === 'EPERM';
201
+ }
202
+ };
203
+
164
204
  const isServerReachable = async () => {
165
205
  for (const probeUrl of probeUrls) {
166
206
  try {
@@ -213,7 +253,7 @@ const parseWindowsPortPids = (stdout) => [...new Set(
213
253
  .filter(Number.isInteger),
214
254
  )];
215
255
 
216
- const getPortPids = async () => {
256
+ const getPortPids = async ({ required = true } = {}) => {
217
257
  try {
218
258
  if (process.platform === 'win32') {
219
259
  const { stdout } = await execFileAsync('powershell.exe', [
@@ -234,6 +274,7 @@ const getPortPids = async () => {
234
274
  } catch (error) {
235
275
  if (error.code === 1) return [];
236
276
  if (error.code === 'ENOENT') {
277
+ if (!required) return null;
237
278
  const commandName = process.platform === 'win32' ? 'PowerShell' : 'lsof';
238
279
  throw new Error(`Cannot use --kill because ${commandName} is not available. Stop the process on port ${port} manually, then rerun the command.`);
239
280
  }
@@ -273,6 +314,31 @@ const terminatePortProcesses = async (host) => {
273
314
  }
274
315
  };
275
316
 
317
+ const terminateTrackedProcess = async (state, host) => {
318
+ if (state?.port !== port || !Number.isInteger(state.pid)) return false;
319
+ if (!isProcessAlive(state.pid)) return false;
320
+
321
+ const listeningPids = await getPortPids({ required: false });
322
+ if (listeningPids && !listeningPids.includes(state.pid)) return false;
323
+ if (!listeningPids && !(await isServerReachable())) return false;
324
+
325
+ try {
326
+ process.kill(state.pid, 'SIGTERM');
327
+ } catch (error) {
328
+ if (error.code !== 'ESRCH') throw error;
329
+ }
330
+
331
+ if (await waitForPortToBeFree(host)) return true;
332
+
333
+ try {
334
+ process.kill(state.pid, 'SIGKILL');
335
+ } catch (error) {
336
+ if (error.code !== 'ESRCH') throw error;
337
+ }
338
+
339
+ return waitForPortToBeFree(host);
340
+ };
341
+
276
342
  const openBrowser = async () => {
277
343
  if (skipOpen) {
278
344
  console.log(`Browser open skipped. Open ${localUrl}`);
@@ -294,10 +360,27 @@ const openBrowser = async () => {
294
360
 
295
361
  const stopServer = async ({ quiet = false } = {}) => {
296
362
  const state = await readState();
297
- const { stdout = '', stderr = '' } = await runAstro(['dev', 'stop']);
298
- const stopped = `${stdout}\n${stderr}`.includes('Stopped dev server');
299
- if (stopped) await waitForPortToBeFree(state?.host ?? localHost);
300
- await removeState();
363
+ let astroError;
364
+ let astroOutput = '';
365
+ try {
366
+ const { stdout = '', stderr = '' } = await runAstro(['dev', 'stop']);
367
+ astroOutput = `${stdout}\n${stderr}`;
368
+ } catch (error) {
369
+ astroError = error;
370
+ }
371
+
372
+ let stopped = astroOutput.includes('Stopped dev server');
373
+ if (stopped) {
374
+ await waitForPortToBeFree(state?.host ?? localHost);
375
+ } else {
376
+ stopped = await terminateTrackedProcess(state, state?.host ?? localHost);
377
+ }
378
+
379
+ if (stopped || !(await isServerReachable())) {
380
+ await removeState();
381
+ }
382
+
383
+ if (astroError && !stopped) throw astroError;
301
384
 
302
385
  if (!quiet) {
303
386
  console.log(stopped
@@ -317,8 +400,29 @@ const startServer = async ({ host = localHost, open = true, killBlockingPort = f
317
400
  await terminatePortProcesses(host);
318
401
  }
319
402
 
320
- await syncSitePublic();
321
- await generateImages();
403
+ try {
404
+ await syncSitePublic();
405
+ await generateImages();
406
+ } catch (error) {
407
+ const output = [error.stdout, error.stderr].filter(Boolean).join('\n') || String(error);
408
+ const excerpt = getLogExcerpt(output)
409
+ .replace(/\n\s+at [\s\S]*$/u, '')
410
+ .replace(/\nNode\.js v[^\n]*$/u, '')
411
+ .trim();
412
+ let logHint;
413
+ try {
414
+ await mkdir(stateDirectory, { recursive: true });
415
+ await writeFile(preparationLogPath, output);
416
+ logHint = `Full log: ${preparationLogPath}`;
417
+ } catch (logError) {
418
+ logHint = `Could not save the preparation log: ${logError.message}`;
419
+ }
420
+ throw new Error([
421
+ `Could not prepare the site for the dev server at ${localUrl}.`,
422
+ excerpt,
423
+ logHint,
424
+ ].join('\n\n'), { cause: error });
425
+ }
322
426
  const previousLog = await readDevLog();
323
427
  try {
324
428
  await runAstroInherit(['dev', '--background', '--host', host, '--port', String(port)]);
@@ -370,7 +474,20 @@ const showStatus = async () => {
370
474
  }
371
475
 
372
476
  if (reachable) {
373
- console.log(`A server is responding at ${localUrl}, but Astro does not track it for this site.`);
477
+ const listeningPids = await getPortPids({ required: false });
478
+ const stateOwnsPort = state?.port === port
479
+ && Number.isInteger(state.pid)
480
+ && (listeningPids ? listeningPids.includes(state.pid) : isProcessAlive(state.pid));
481
+ if (stateOwnsPort) {
482
+ console.log(`dev:${state.mode ?? 'local'} is running at ${localUrl}. Norna retained its process record after Astro cleared its cache.`);
483
+ if (state.host === '0.0.0.0') {
484
+ const lanUrls = getLanUrls();
485
+ if (lanUrls.length > 0) console.log(`On this local network: ${lanUrls.join(', ')}`);
486
+ }
487
+ return;
488
+ }
489
+
490
+ console.log(`A server is responding at ${localUrl}, but neither Astro nor Norna tracks it for this site.`);
374
491
  return;
375
492
  }
376
493
 
@@ -11,6 +11,7 @@ import {
11
11
  themeVisualSchema,
12
12
  } from './lib/schema-definitions.mjs';
13
13
  import { applySchemaEditorMetadata } from './lib/schema-editor-metadata.mjs';
14
+ import { getNornaBlockSchema } from './lib/norna-markdown-blocks.mjs';
14
15
 
15
16
  const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
16
17
  const schemaDirectory = path.join(root, 'schemas');
@@ -32,9 +33,20 @@ for (const [filename, title, schema] of definitions) {
32
33
  schemaFiles.set(filename, `${JSON.stringify(jsonSchema, null, 2)}\n`);
33
34
  }
34
35
 
36
+ const blockTypes = ['image-stack', 'image-carousel', 'card-list'];
37
+ for (const type of blockTypes) {
38
+ const filename = `${type}.schema.json`;
39
+ schemaFiles.set(filename, `${JSON.stringify({
40
+ ...getNornaBlockSchema(type),
41
+ $id: `https://janga.github.io/norna/schemas/${filename}`,
42
+ title: `Norna ${type}`,
43
+ }, null, 2)}\n`);
44
+ }
45
+
35
46
  schemaFiles.set('manifest.json', `${JSON.stringify({
36
- editorApiVersion: 1,
47
+ editorApiVersion: 2,
37
48
  schemaVersion: 3,
49
+ blockSchemas: Object.fromEntries(blockTypes.map((type) => [type, `${type}.schema.json`])),
38
50
  files: {
39
51
  category: 'category.schema.json',
40
52
  config: 'config.schema.json',
@@ -64,12 +76,12 @@ if (checkOnly) {
64
76
  throw new Error(`Generated Norna schemas are stale: ${stale.join(', ')}. Run "npm run schemas:generate".`);
65
77
  }
66
78
 
67
- console.log(`Norna schemas are up to date (${definitions.length} schemas).`);
79
+ console.log(`Norna schemas are up to date (${definitions.length + blockTypes.length} schemas).`);
68
80
  } else {
69
81
  await mkdir(schemaDirectory, { recursive: true });
70
82
  for (const [filename, source] of schemaFiles) {
71
83
  await writeFile(path.join(schemaDirectory, filename), source);
72
84
  }
73
85
 
74
- console.log(`Generated ${definitions.length} Norna schemas in ${path.relative(root, schemaDirectory)}.`);
86
+ console.log(`Generated ${definitions.length + blockTypes.length} Norna schemas in ${path.relative(root, schemaDirectory)}.`);
75
87
  }
@@ -18,6 +18,7 @@ dist/
18
18
  # generated site state
19
19
  **/.norna/public/
20
20
  **/.norna/.astro/
21
+ **/.norna/dev/
21
22
 
22
23
  # legacy Astro cache location
23
24
  .astro/
@@ -40,6 +41,7 @@ pnpm-debug.log*
40
41
  `;
41
42
  const siteGitignore = `.norna/public/
42
43
  .norna/.astro/
44
+ .norna/dev/
43
45
  `;
44
46
 
45
47
  const usage = `
@@ -0,0 +1,48 @@
1
+ import { getSiteNavigationTree } from './site-navigation-tree.mjs';
2
+ import { getSiteNodePathname } from './site-page-urls.mjs';
3
+
4
+ // Accept the same normalized nodes as navigation; excluded ancestors exclude
5
+ // their whole subtree, including category destinations.
6
+ export const createCategoryDestinationModel = (nodes) => {
7
+ const destinations = [];
8
+ const diagnostics = [];
9
+ const visit = (branch) => {
10
+ const { node } = branch;
11
+ if (node.navigation?.listed === false) return false;
12
+ const children = branch.children.filter(({ node: child }) => child.navigation?.listed !== false);
13
+ const reachable = children.map(visit).some(Boolean);
14
+ if (node.kind !== 'category') return true;
15
+ if (!reachable) {
16
+ const source = node.categorySourceLabel ?? node.nodeLabel ?? node.pageDirectory;
17
+ diagnostics.push({
18
+ code: 'category-without-listed-content',
19
+ severity: 'error',
20
+ message: `${source} has no listed reachable content page.`,
21
+ fix: 'Add a listed page under this category, or remove the category.',
22
+ });
23
+ return false;
24
+ }
25
+ const first = children[0].node;
26
+ destinations.push({
27
+ category: node,
28
+ pathname: getSiteNodePathname(node),
29
+ kind: first.kind === 'page' ? 'redirect' : 'listing',
30
+ target: first.kind === 'page' ? first : null,
31
+ children: children.map(({ node: child }) => child),
32
+ });
33
+ return true;
34
+ };
35
+ getSiteNavigationTree(nodes.map((node) => ({ node }))).forEach(visit);
36
+ return {
37
+ destinations,
38
+ byPathname: new Map(destinations.map((destination) => [destination.pathname, destination])),
39
+ diagnostics,
40
+ };
41
+ };
42
+
43
+ export const assertCategoryDestinationModel = (model) => {
44
+ if (model.diagnostics.length) {
45
+ throw new Error(model.diagnostics.map(({ message, fix }) => `${message}\n${fix}`).join('\n'));
46
+ }
47
+ return model;
48
+ };
@@ -9,40 +9,17 @@ const invalidMetadata = (message, fix) => ({
9
9
  },
10
10
  });
11
11
 
12
- const parseTitle = (source) => {
13
- let title = '';
14
- let index = titlePrefix.length;
15
-
16
- while (index < source.length) {
17
- const character = source[index];
18
- if (character === '"') {
19
- return {
20
- rest: source.slice(index + 1),
21
- title,
22
- };
23
- }
24
-
25
- if (character === '\\') {
26
- const escaped = source[index + 1];
27
- if (escaped !== '"' && escaped !== '\\') {
28
- return invalidMetadata(
29
- `Code title contains unsupported escape "\\${escaped ?? ''}".`,
30
- 'Only escape a double quote (\\") or backslash (\\\\) inside the title.',
31
- );
32
- }
33
- title += escaped;
34
- index += 2;
35
- continue;
36
- }
37
-
38
- title += character;
39
- index += 1;
12
+ export const parseQuotedContentString = (source) => {
13
+ const quoted = source.match(/^"(?:[^"\\]|\\[\s\S])*"/)?.[0];
14
+ if (!quoted) return { error: 'Use a JSON double-quoted string with a closing double quote.' };
15
+ let value;
16
+ try { value = JSON.parse(quoted); } catch {
17
+ return { error: 'Use valid JSON escapes inside the double-quoted string.' };
40
18
  }
41
-
42
- return invalidMetadata(
43
- 'Code title is missing its closing double quote.',
44
- 'Close the title, for example title="src/config.js".',
45
- );
19
+ if (/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/u.test(value)) {
20
+ return { error: 'Quoted labels and titles cannot contain control characters, including line breaks and tabs.' };
21
+ }
22
+ return { value, rest: source.slice(quoted.length) };
46
23
  };
47
24
 
48
25
  const parseHighlightedLines = (selector, lineCount) => {
@@ -94,15 +71,15 @@ export const parseCodeFenceMetadata = (rawMetadata, options = {}) => {
94
71
  let rest = source;
95
72
  let title = null;
96
73
  if (rest.startsWith(titlePrefix)) {
97
- const titleResult = parseTitle(rest);
98
- if (titleResult.error) return titleResult;
99
- if (!titleResult.title.trim()) {
74
+ const titleResult = parseQuotedContentString(rest.slice('title='.length));
75
+ if (titleResult.error) return invalidMetadata(`Invalid code title. ${titleResult.error}`, 'Write a single-line title, for example title="src/config.js".');
76
+ if (!titleResult.value.trim()) {
100
77
  return invalidMetadata(
101
78
  'Code title cannot be empty.',
102
79
  'Remove title="" or provide a short filename or label.',
103
80
  );
104
81
  }
105
- title = titleResult.title;
82
+ title = titleResult.value;
106
83
  rest = titleResult.rest;
107
84
  if (rest && !rest.startsWith(' ')) {
108
85
  return invalidMetadata(
@@ -210,12 +187,17 @@ export const nornaCodeFenceTransformer = {
210
187
  tagName: 'figure',
211
188
  properties: { className: ['norna-code-example'] },
212
189
  children: [
213
- {
214
- type: 'element',
215
- tagName: 'figcaption',
216
- properties: { className: ['norna-code-title'] },
217
- children: [{ type: 'text', value: metadata.title }],
218
- },
190
+ {
191
+ type: 'element',
192
+ tagName: 'figcaption',
193
+ properties: { className: ['norna-code-title'] },
194
+ children: [{
195
+ type: 'element',
196
+ tagName: 'span',
197
+ properties: { className: ['norna-code-title-text'] },
198
+ children: [{ type: 'text', value: metadata.title }],
199
+ }],
200
+ },
219
201
  node,
220
202
  ],
221
203
  };
@@ -0,0 +1,155 @@
1
+ import { markdownToMdast } from 'satteri';
2
+ import { parseQuotedContentString } from './code-fence-metadata.mjs';
3
+
4
+ // Only delimiters belong to this scanner. Markdown parsing and source positions
5
+ // stay with Satteri, including code examples that happen to contain delimiters.
6
+ export const parseContentTabs = (source, { label = 'Markdown', lineOffset = 0 } = {}) => {
7
+ if (!/^\s*(?:(?:>\s*|[-+*]\s+))?:{3,}\s*tabs?\b/m.test(source)) return { groups: [], diagnostics: [], replacements: [], maskedSource: source };
8
+ const tree = markdownToMdast(source, { features: { gfm: true, frontmatter: true } });
9
+ const nodes = [];
10
+ const visit = (node, parent) => {
11
+ nodes.push({ node, parent });
12
+ for (const child of node.children ?? []) visit(child, node);
13
+ };
14
+ visit(tree, null);
15
+ const ignored = nodes.filter(({ node }) => ['code', 'inlineCode', 'html', 'yaml', 'toml'].includes(node.type));
16
+ const groups = [];
17
+ const diagnostics = [];
18
+ const replacements = [];
19
+ let group = null;
20
+ let panel = null;
21
+ const outerContainers = [];
22
+ let offset = 0;
23
+ let section = 'page introduction';
24
+ const error = (message, at = offset) => diagnostics.push({
25
+ code: 'invalid-content-tabs', severity: 'error', offset: at,
26
+ line: lineOffset + source.slice(0, at).split('\n').length,
27
+ message: `${label} line ${lineOffset + source.slice(0, at).split('\n').length}, section "${groups.find((item) => at >= item.start && at < (item.end ?? source.length))?.section ?? section}": ${message}`,
28
+ });
29
+ for (const [index, line] of source.split('\n').entries()) {
30
+ const start = offset;
31
+ offset += line.length + 1;
32
+ const contentStart = start + line.search(/\S|$/);
33
+ if (ignored.some(({ node }) => contentStart >= node.position.start.offset && contentStart < node.position.end.offset)) continue;
34
+ if (!group && /^#{1,3}\s/.test(line)) section = line.replace(/^#+\s*/, '').replace(/\s*\{#.*\}$/, '');
35
+ const match = line.match(/^(\s*)(:{3,})\s*(.*?)\s*$/);
36
+ if (!match) {
37
+ if (group && !panel && line.trim()) error('Put content inside a labelled tab, not directly inside tabs.', start);
38
+ if (/^\s*(?:>\s*|[-+*]\s+)(?::+)\s*tabs?\b/.test(line)) error('Place tabs directly in the section, outside lists and blockquotes.', start);
39
+ continue;
40
+ }
41
+ const [, indent, fence, declaration] = match;
42
+ const name = declaration.split(/\s/)[0];
43
+ if (!group && name !== 'tabs' && name !== 'tab') {
44
+ if (declaration) outerContainers.push(fence.length);
45
+ else if (outerContainers.length) outerContainers.pop();
46
+ else if (groups.length) error('This closing marker has no open tab group.', start);
47
+ continue;
48
+ }
49
+ const replace = (kind, data = {}) => replacements.push({ start, end: start + line.length, kind, ...data });
50
+ if (indent || (!group && nodes.some(({ node, parent }) => node.type === 'paragraph' && parent?.type !== 'root'
51
+ && start >= node.position.start.offset && start < node.position.end.offset))) {
52
+ error('Place tabs directly in the section, outside other blocks.', start);
53
+ }
54
+ if (name === 'tabs') {
55
+ if (outerContainers.length) error('Place tabs directly in the section, outside other containers.', start);
56
+ if (group) { error('Tab groups cannot be nested.', start); continue; }
57
+ if (fence !== '::::' || declaration !== 'tabs') error('Open a tab group with ":::: tabs" on its own line.', start);
58
+ group = { index: groups.length, start, section, line: lineOffset + index + 1, panels: [] };
59
+ groups.push(group);
60
+ replace('group-start', { index: group.index });
61
+ } else if (name === 'tab') {
62
+ if (!group) { error('A tab must be inside a ":::: tabs" block.', start); continue; }
63
+ if (panel) error('Close the preceding tab with ":::" before starting another tab.', start);
64
+ const decoded = parseQuotedContentString(declaration.slice(3).trim());
65
+ let title = decoded.value ?? '';
66
+ if (fence !== ':::' || decoded.error || decoded.rest || !title.trim()) {
67
+ error(`Write a nonempty quoted label, for example ::: tab "Windows".${decoded.error ? ` ${decoded.error}` : ''}`, start);
68
+ title = '';
69
+ }
70
+ if (group.panels.some((item) => item.label.normalize('NFC') === title.trim().normalize('NFC'))) error(`Duplicate tab label "${title}". Use unique labels within this group.`, start);
71
+ panel = { label: title.trim(), start: offset, declarationStart: start, index: group.panels.length };
72
+ group.panels.push(panel);
73
+ replace('panel-start', { index: panel.index });
74
+ } else if (!declaration) {
75
+ if (fence === '::::') {
76
+ if (panel) error('Close the tab with ":::" before closing the group.', start);
77
+ if (group.panels.length < 2) error('A tab group needs at least two alternatives.', group.start);
78
+ group.end = offset;
79
+ replace('group-end');
80
+ group = panel = null;
81
+ } else if (fence !== ':::') {
82
+ error('Close tabs with ":::" and the group with "::::".', start);
83
+ } else if (panel) {
84
+ panel.end = start;
85
+ if (!source.slice(panel.start, start).replace(/<!--[\s\S]*?-->/g, '').trim()) error('A tab cannot be empty. Explain when the step does not apply.', panel.declarationStart);
86
+ replace('panel-end');
87
+ panel = null;
88
+ } else error('This closing marker has no open tab.', start);
89
+ } else error(`Unknown or misplaced tab marker "${declaration}". Write callouts as GitHub-style alerts, for example > [!TIP] followed by quoted body text.`, start);
90
+ }
91
+ if (group) error('The tab group is unclosed. Close each tab with ":::" and the group with "::::".', group.start);
92
+ let maskedSource = source;
93
+ for (const replacement of [...replacements].reverse()) maskedSource = maskedSource.slice(0, replacement.start)
94
+ + ' '.repeat(replacement.end - replacement.start) + maskedSource.slice(replacement.end);
95
+ nodes.length = 0;
96
+ visit(markdownToMdast(maskedSource, { features: { gfm: true, frontmatter: true } }), null);
97
+ for (const item of groups) {
98
+ for (const tab of item.panels) {
99
+ const end = tab.end ?? item.end ?? source.length;
100
+ for (const { node } of nodes) {
101
+ if (!node.position || node.position.start.offset < tab.start || node.position.start.offset >= end) continue;
102
+ if (node.type === 'heading' || ['yaml', 'toml'].includes(node.type)
103
+ || (node.type === 'html' && /<(?:h[1-6]|nav)\b/i.test(node.value))) error('Tabs cannot contain headings, frontmatter, or navigation definitions. Keep the shared headings outside the group.', node.position.start.offset);
104
+ }
105
+ if (/^---\s*\n[\s\S]*?\n---\s*(?:\n|$)/.test(source.slice(tab.start, end).trimStart())) error('Frontmatter belongs at the start of content.md, not inside a tab.', tab.declarationStart);
106
+ }
107
+ }
108
+ return { groups, diagnostics, replacements, maskedSource };
109
+ };
110
+
111
+ export const prepareContentTabs = (source, options = {}) => {
112
+ const parsed = parseContentTabs(source, options);
113
+ if (parsed.diagnostics.length) throw new Error(parsed.diagnostics[0].message);
114
+ let result = source;
115
+ for (const marker of [...parsed.replacements].reverse()) {
116
+ const html = `<div data-norna-tabs-marker="${marker.kind}"${marker.index === undefined ? '' : ` data-index="${marker.index}"`}></div>`;
117
+ result = result.slice(0, marker.start) + '\n' + html + '\n' + result.slice(marker.end);
118
+ }
119
+ return result;
120
+ };
121
+
122
+ export const groupRenderedTabs = (blocks, groups) => {
123
+ const result = [];
124
+ let group = null;
125
+ let panel = null;
126
+ const append = (block) => (panel ? panel.contentBlocks : result).push(block);
127
+ for (const block of blocks) {
128
+ if (block.type !== 'html') { append(block); continue; }
129
+ const pattern = /<div data-norna-tabs-marker="([a-z-]+)"(?: data-index="(\d+)")?><\/div>/g;
130
+ let cursor = 0;
131
+ for (const match of block.html.matchAll(pattern)) {
132
+ const before = block.html.slice(cursor, match.index);
133
+ if (before.trim()) append({ type: 'html', html: before });
134
+ const kind = match[1];
135
+ if (kind === 'group-start') {
136
+ const source = groups.find((item) => item.index === Number(match[2]));
137
+ if (!source) throw new Error('Rendered tab group has no matching source.');
138
+ group = { type: 'tabs', index: source.index, panels: [], source };
139
+ } else if (kind === 'panel-start') {
140
+ panel = { label: group.source.panels[Number(match[2])].label, contentBlocks: [] };
141
+ group.panels.push(panel);
142
+ } else if (kind === 'panel-end') panel = null;
143
+ else if (kind === 'group-end') {
144
+ const { source, ...resolved } = group;
145
+ result.push(resolved);
146
+ group = null;
147
+ }
148
+ cursor = match.index + match[0].length;
149
+ }
150
+ const after = block.html.slice(cursor);
151
+ if (after.trim()) append({ type: 'html', html: after });
152
+ }
153
+ if (group || panel) throw new Error('Rendered tab group is incomplete.');
154
+ return result;
155
+ };
@@ -0,0 +1,58 @@
1
+ import { parseFragment } from 'parse5';
2
+
3
+ export const getDetailsHeadingDiagnostics = (tree, { source, label, lineOffset = 0 }) => {
4
+ if (!/<details[\s/>]/i.test(source)) return [];
5
+ const lines = source.split('\n');
6
+ const lineStarts = [];
7
+ let offset = 0;
8
+ for (const line of lines) {
9
+ lineStarts.push(offset);
10
+ offset += line.length + 1;
11
+ }
12
+ const html = source.replace(/[^\n]/g, ' ').split('');
13
+ const headings = [];
14
+ const visitMarkdown = (node) => {
15
+ if (node.type === 'heading') headings.push(node.position.start.offset);
16
+ if (node.type === 'html') {
17
+ // Keep only parsed HTML, preserving source offsets and stripping list/quote
18
+ // prefixes. Literal HTML in Markdown code or escapes never reaches parse5.
19
+ for (const [index, value] of node.value.split('\n').entries()) {
20
+ const lineIndex = node.position.start.line - 1 + index;
21
+ const column = index === 0 ? node.position.start.column - 1 : lines[lineIndex].indexOf(value);
22
+ const start = lineStarts[lineIndex] + column;
23
+ for (let i = 0; i < value.length; i += 1) html[start + i] = value[i];
24
+ }
25
+ }
26
+ for (const child of node.children ?? []) visitMarkdown(child);
27
+ };
28
+ visitMarkdown(tree);
29
+ const fragment = parseFragment(html.join(''), { sourceCodeLocationInfo: true });
30
+ const disclosures = [];
31
+ const visitHtml = (node) => {
32
+ const location = node.sourceCodeLocation;
33
+ if (location && node.tagName === 'details') {
34
+ disclosures.push({
35
+ start: location.startTag.endOffset,
36
+ end: location.endTag?.startOffset ?? location.endOffset,
37
+ });
38
+ }
39
+ if (location && /^h[1-6]$/.test(node.tagName)) headings.push(location.startOffset);
40
+ for (const child of node.childNodes ?? []) visitHtml(child);
41
+ if (node.content) visitHtml(node.content);
42
+ };
43
+ visitHtml(fragment);
44
+ const fix = 'Move the heading outside <details>, or use bold text inside it.';
45
+ return [...new Set(headings)].sort((a, b) => a - b)
46
+ .filter((start) => disclosures.some((details) => start >= details.start && start < details.end))
47
+ .map((start) => {
48
+ const line = lineOffset + source.slice(0, start).split('\n').length;
49
+ return {
50
+ code: 'heading-inside-details',
51
+ severity: 'error',
52
+ offset: start,
53
+ line,
54
+ message: `${label} line ${line}: Headings H1-H6 are not allowed inside <details>, including <summary>.`,
55
+ fix,
56
+ };
57
+ });
58
+ };