@janga/norna 0.7.23 → 0.7.25

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 (82) hide show
  1. package/README.md +14 -18
  2. package/astro.config.mjs +14 -0
  3. package/bin/norna-cli.mjs +6 -0
  4. package/package.json +25 -2
  5. package/schemas/category.schema.json +2 -2
  6. package/schemas/config.schema.json +80 -15
  7. package/schemas/content-frontmatter.schema.json +7 -7
  8. package/schemas/page-theme.schema.json +26 -26
  9. package/schemas/sitewide-content.schema.json +18 -18
  10. package/schemas/theme.schema.json +246 -246
  11. package/scripts/build-site.mjs +1 -0
  12. package/scripts/check-config.mjs +16 -1
  13. package/scripts/dev-local.mjs +106 -11
  14. package/scripts/generate-search-index.mjs +57 -0
  15. package/scripts/init-site.mjs +3 -0
  16. package/scripts/lib/code-fence-metadata.mjs +228 -0
  17. package/scripts/lib/edit-source-link.mjs +102 -0
  18. package/scripts/lib/editor-language-service.mjs +32 -12
  19. package/scripts/lib/image-presentation.mjs +4 -0
  20. package/scripts/lib/navigation-model.mjs +34 -13
  21. package/scripts/lib/navigation-review.mjs +396 -0
  22. package/scripts/lib/norna-markdown-blocks.mjs +164 -26
  23. package/scripts/lib/norna-markdown-render-plugin.mjs +64 -1
  24. package/scripts/lib/page-aliases.mjs +21 -1
  25. package/scripts/lib/page-markdown.mjs +32 -1
  26. package/scripts/lib/page-move-plan.mjs +659 -0
  27. package/scripts/lib/presentation-palette-metadata.mjs +1 -1
  28. package/scripts/lib/presentation.mjs +1 -10
  29. package/scripts/lib/project-config.mjs +141 -5
  30. package/scripts/lib/public-asset-conventions.mjs +36 -2
  31. package/scripts/lib/schema-definitions.mjs +30 -5
  32. package/scripts/lib/schema-editor-metadata.mjs +36 -4
  33. package/scripts/lib/schema-value-definitions.mjs +4 -1
  34. package/scripts/lib/semantic-callouts.mjs +128 -0
  35. package/scripts/lib/site-content.mjs +2 -1
  36. package/scripts/lib/site-link-graph.mjs +33 -2
  37. package/scripts/lib/site-navigation-tree.mjs +85 -0
  38. package/scripts/lib/site-paths.mjs +14 -2
  39. package/scripts/lib/social-image-assets.mjs +30 -0
  40. package/scripts/lib/table-render-plugin.mjs +33 -0
  41. package/scripts/lib/theme-presets.mjs +2 -2
  42. package/scripts/lib/theme-profiles.mjs +0 -4
  43. package/scripts/move-site-page.mjs +256 -0
  44. package/scripts/review-navigation.mjs +32 -0
  45. package/scripts/sync-content-sections.mjs +67 -4
  46. package/scripts/sync-site-public.mjs +24 -5
  47. package/src/components/CardList.astro +1 -0
  48. package/src/components/CodeBlockCopyScript.astro +11 -5
  49. package/src/components/EditSourceLink.astro +17 -0
  50. package/src/components/ImageCarousel.astro +9 -7
  51. package/src/components/ImageStack.astro +34 -9
  52. package/src/components/ImageStackEnhancement.astro +331 -0
  53. package/src/components/NavigationPageTree.astro +66 -55
  54. package/src/components/NavigationTreeControls.astro +77 -0
  55. package/src/components/PageAliasRedirect.astro +1 -1
  56. package/src/components/PageContentsNavigation.astro +2 -3
  57. package/src/components/PageList.astro +33 -0
  58. package/src/components/PageSequenceNavigation.astro +37 -0
  59. package/src/components/SearchPage.astro +151 -0
  60. package/src/components/SectionNavigationScript.astro +45 -7
  61. package/src/components/SiteNavigation.astro +102 -51
  62. package/src/components/SitePage.astro +107 -39
  63. package/src/components/SiteSection.astro +11 -3
  64. package/src/components/SiteTreeNavigation.astro +11 -1
  65. package/src/components/TableOverflowScript.astro +251 -0
  66. package/src/components/TreeNavigationScript.astro +271 -34
  67. package/src/layouts/BaseLayout.astro +83 -2
  68. package/src/lib/generatedImages.ts +18 -0
  69. package/src/lib/sectionContent.ts +36 -30
  70. package/src/lib/siteNavigation.ts +31 -52
  71. package/src/lib/sitePages.ts +6 -3
  72. package/src/lib/sitePublicAssets.ts +1 -0
  73. package/src/pages/404.astro +110 -0
  74. package/src/pages/[...slug].astro +15 -5
  75. package/src/styles/content.css +538 -39
  76. package/src/styles/media.css +187 -8
  77. package/src/styles/navigation.css +199 -3
  78. package/src/styles/page-layout.css +481 -100
  79. package/src/styles/responsive.css +144 -28
  80. package/starters/basic/README.md +6 -19
  81. package/starters/basic/package.json +1 -0
  82. package/starters/basic/site/pages/000-home/content.md +13 -50
@@ -14,3 +14,4 @@ await runScript('scripts/sync-content-sections.mjs', ['--check']);
14
14
  await runScript('scripts/sync-site-public.mjs');
15
15
  await runScript('scripts/generate-images.mjs');
16
16
  await runAstroInherit(['build']);
17
+ await runScript('scripts/generate-search-index.mjs');
@@ -1,7 +1,8 @@
1
1
  import path from 'node:path';
2
2
  import { siteConfigLabel, sitePagesDir, sitePublicLabel, siteThemeLabel } from './lib/site-paths.mjs';
3
3
  import { getLogoAssets, getPublicAssetInspection } from './lib/logo-assets.mjs';
4
- import { logoAssetFilenames } from './lib/public-asset-conventions.mjs';
4
+ import { logoAssetFilenames, socialImageAssetFilenames } from './lib/public-asset-conventions.mjs';
5
+ import { getSocialImageAssets } from './lib/social-image-assets.mjs';
5
6
  import { readSitewideContent } from './lib/sitewide-content.mjs';
6
7
  import { assertSectionBackgroundPatternCompatibility } from './lib/presentation.mjs';
7
8
  import { readThemeConfig, validatePageThemeFiles } from './lib/theme-config.mjs';
@@ -43,6 +44,7 @@ try {
43
44
  }
44
45
  }
45
46
  const logoAssets = getLogoAssets();
47
+ const socialImageAssets = getSocialImageAssets();
46
48
  const publicAssetInspection = getPublicAssetInspection();
47
49
  const logoAssetPaths = logoAssetFilenames.map((filename) => `${sitePublicLabel}/${filename}`);
48
50
  for (const issue of publicAssetInspection.suspicious) {
@@ -56,6 +58,13 @@ try {
56
58
  ].join('\n'));
57
59
  }
58
60
 
61
+ if (socialImageAssets.length > 1) {
62
+ throw new Error([
63
+ `Found multiple social sharing images in ${sitePublicLabel}. Keep exactly one of ${socialImageAssetFilenames.join(', ')}.`,
64
+ ...socialImageAssets.map(({ filename }) => `- ${sitePublicLabel}/${filename}`),
65
+ ].join('\n'));
66
+ }
67
+
59
68
  if (logoAssets.length === 0) {
60
69
  if (sitewideContent.logo) {
61
70
  throw new Error(`Site-wide logo is configured, but no logo file was found. Add exactly one of ${logoAssetPaths.join(', ')}, or remove logo.`);
@@ -67,6 +76,11 @@ try {
67
76
  console.log('Config check passed.');
68
77
  console.log(`Site URL: ${projectConfig.site.url}`);
69
78
  console.log(`Base path: ${projectConfig.site.basePath}`);
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)'}`);
70
84
  console.log(`Theme preset: ${themeConfig.preset ?? '(none)'}`);
71
85
  console.log(`Page width: ${projectConfig.layout.pageWidth}`);
72
86
  console.log(`Gutter: desktop ${projectConfig.layout.gutter.desktop}, mobile ${projectConfig.layout.gutter.mobile}`);
@@ -81,6 +95,7 @@ try {
81
95
  console.log(`Font family: ${projectConfig.typography.fontFamily}`);
82
96
  console.log(`Language: ${projectConfig.locale.lang}`);
83
97
  console.log(`Navigation mode: ${projectConfig.navigation.mode}`);
98
+ console.log(`Static search: ${projectConfig.search.enabled ? 'enabled' : 'disabled'}`);
84
99
  console.log(`Scroll behavior: ${projectConfig.navigation.scrollBehavior}`);
85
100
  } catch (error) {
86
101
  console.error('Config check failed.');
@@ -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,7 +32,10 @@ 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');
32
40
  const args = process.argv.slice(2);
33
41
  const knownCommands = new Set(['start', 'lan', 'status', 'logs', 'restart', 'stop']);
@@ -51,9 +59,9 @@ const generateImages = async () => execFileAsync(process.execPath, [path.join(en
51
59
  maxBuffer: 1024 * 1024 * 10,
52
60
  });
53
61
 
54
- const readState = async () => {
62
+ const readJsonFile = async (filePath) => {
55
63
  try {
56
- return JSON.parse(await readFile(statePath, 'utf8'));
64
+ return JSON.parse(await readFile(filePath, 'utf8'));
57
65
  } catch (error) {
58
66
  if (error.code === 'ENOENT') {
59
67
  return null;
@@ -63,15 +71,34 @@ const readState = async () => {
63
71
  }
64
72
  };
65
73
 
74
+ const readState = async () => (
75
+ await readJsonFile(statePath)
76
+ ?? await readJsonFile(legacyStatePath)
77
+ );
78
+
66
79
  const getLanUrls = () => Object.values(networkInterfaces())
67
80
  .flat()
68
81
  .filter((network) => network?.family === 'IPv4' && !network.internal)
69
82
  .map((network) => `http://${network.address}:${port}${projectConfig.site.basePath}`);
70
83
 
71
84
  const writeState = async (host) => {
72
- await mkdir(path.dirname(statePath), { recursive: true });
85
+ const astroState = await readJsonFile(astroStatePath);
86
+ let pid = astroState?.port === port && isProcessAlive(astroState.pid)
87
+ ? astroState.pid
88
+ : null;
89
+ if (!Number.isInteger(pid)) {
90
+ const listeningPids = await getPortPids({ required: false });
91
+ pid = listeningPids?.length === 1 ? listeningPids[0] : null;
92
+ }
93
+
94
+ if (!Number.isInteger(pid)) {
95
+ 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.`);
96
+ }
97
+
98
+ await mkdir(stateDirectory, { recursive: true });
73
99
  await writeFile(statePath, `${JSON.stringify({
74
100
  port,
101
+ pid,
75
102
  host,
76
103
  mode: host === '0.0.0.0' ? 'lan' : 'local',
77
104
  url: localUrl,
@@ -81,6 +108,8 @@ const writeState = async (host) => {
81
108
 
82
109
  const removeState = async () => {
83
110
  await rm(statePath, { force: true });
111
+ await rm(legacyStatePath, { force: true });
112
+ await rm(stateDirectory, { recursive: true, force: true });
84
113
  };
85
114
 
86
115
  const readDevLog = async () => {
@@ -161,6 +190,16 @@ const sleep = (milliseconds) => new Promise((resolve) => {
161
190
  setTimeout(resolve, milliseconds);
162
191
  });
163
192
 
193
+ const isProcessAlive = (pid) => {
194
+ if (!Number.isInteger(pid)) return false;
195
+ try {
196
+ process.kill(pid, 0);
197
+ return true;
198
+ } catch (error) {
199
+ return error.code === 'EPERM';
200
+ }
201
+ };
202
+
164
203
  const isServerReachable = async () => {
165
204
  for (const probeUrl of probeUrls) {
166
205
  try {
@@ -213,7 +252,7 @@ const parseWindowsPortPids = (stdout) => [...new Set(
213
252
  .filter(Number.isInteger),
214
253
  )];
215
254
 
216
- const getPortPids = async () => {
255
+ const getPortPids = async ({ required = true } = {}) => {
217
256
  try {
218
257
  if (process.platform === 'win32') {
219
258
  const { stdout } = await execFileAsync('powershell.exe', [
@@ -234,6 +273,7 @@ const getPortPids = async () => {
234
273
  } catch (error) {
235
274
  if (error.code === 1) return [];
236
275
  if (error.code === 'ENOENT') {
276
+ if (!required) return null;
237
277
  const commandName = process.platform === 'win32' ? 'PowerShell' : 'lsof';
238
278
  throw new Error(`Cannot use --kill because ${commandName} is not available. Stop the process on port ${port} manually, then rerun the command.`);
239
279
  }
@@ -273,6 +313,31 @@ const terminatePortProcesses = async (host) => {
273
313
  }
274
314
  };
275
315
 
316
+ const terminateTrackedProcess = async (state, host) => {
317
+ if (state?.port !== port || !Number.isInteger(state.pid)) return false;
318
+ if (!isProcessAlive(state.pid)) return false;
319
+
320
+ const listeningPids = await getPortPids({ required: false });
321
+ if (listeningPids && !listeningPids.includes(state.pid)) return false;
322
+ if (!listeningPids && !(await isServerReachable())) return false;
323
+
324
+ try {
325
+ process.kill(state.pid, 'SIGTERM');
326
+ } catch (error) {
327
+ if (error.code !== 'ESRCH') throw error;
328
+ }
329
+
330
+ if (await waitForPortToBeFree(host)) return true;
331
+
332
+ try {
333
+ process.kill(state.pid, 'SIGKILL');
334
+ } catch (error) {
335
+ if (error.code !== 'ESRCH') throw error;
336
+ }
337
+
338
+ return waitForPortToBeFree(host);
339
+ };
340
+
276
341
  const openBrowser = async () => {
277
342
  if (skipOpen) {
278
343
  console.log(`Browser open skipped. Open ${localUrl}`);
@@ -294,10 +359,27 @@ const openBrowser = async () => {
294
359
 
295
360
  const stopServer = async ({ quiet = false } = {}) => {
296
361
  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();
362
+ let astroError;
363
+ let astroOutput = '';
364
+ try {
365
+ const { stdout = '', stderr = '' } = await runAstro(['dev', 'stop']);
366
+ astroOutput = `${stdout}\n${stderr}`;
367
+ } catch (error) {
368
+ astroError = error;
369
+ }
370
+
371
+ let stopped = astroOutput.includes('Stopped dev server');
372
+ if (stopped) {
373
+ await waitForPortToBeFree(state?.host ?? localHost);
374
+ } else {
375
+ stopped = await terminateTrackedProcess(state, state?.host ?? localHost);
376
+ }
377
+
378
+ if (stopped || !(await isServerReachable())) {
379
+ await removeState();
380
+ }
381
+
382
+ if (astroError && !stopped) throw astroError;
301
383
 
302
384
  if (!quiet) {
303
385
  console.log(stopped
@@ -370,7 +452,20 @@ const showStatus = async () => {
370
452
  }
371
453
 
372
454
  if (reachable) {
373
- console.log(`A server is responding at ${localUrl}, but Astro does not track it for this site.`);
455
+ const listeningPids = await getPortPids({ required: false });
456
+ const stateOwnsPort = state?.port === port
457
+ && Number.isInteger(state.pid)
458
+ && (listeningPids ? listeningPids.includes(state.pid) : isProcessAlive(state.pid));
459
+ if (stateOwnsPort) {
460
+ console.log(`dev:${state.mode ?? 'local'} is running at ${localUrl}. Norna retained its process record after Astro cleared its cache.`);
461
+ if (state.host === '0.0.0.0') {
462
+ const lanUrls = getLanUrls();
463
+ if (lanUrls.length > 0) console.log(`On this local network: ${lanUrls.join(', ')}`);
464
+ }
465
+ return;
466
+ }
467
+
468
+ console.log(`A server is responding at ${localUrl}, but neither Astro nor Norna tracks it for this site.`);
374
469
  return;
375
470
  }
376
471
 
@@ -0,0 +1,57 @@
1
+ import { cp, mkdir, readdir, rm } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import * as pagefind from 'pagefind';
4
+ import projectConfig from './lib/project-config.mjs';
5
+ import {
6
+ astroDistDir,
7
+ astroPublicDir,
8
+ } from './lib/site-paths.mjs';
9
+
10
+ const searchDirectoryName = 'pagefind';
11
+ const distSearchDirectory = path.join(astroDistDir, searchDirectoryName);
12
+ const localSearchDirectory = path.join(astroPublicDir, searchDirectoryName);
13
+
14
+ if (!projectConfig.search.enabled) {
15
+ await rm(localSearchDirectory, { force: true, recursive: true });
16
+ console.log('Static search is disabled.');
17
+ } else {
18
+ await rm(distSearchDirectory, { force: true, recursive: true });
19
+
20
+ try {
21
+ const { errors: createErrors, index } = await pagefind.createIndex();
22
+ if (!index || createErrors.length > 0) {
23
+ throw new Error(createErrors.join('\n') || 'Pagefind did not create an index.');
24
+ }
25
+
26
+ const { errors: indexingErrors, page_count: scannedPageCount } = await index.addDirectory({
27
+ path: astroDistDir,
28
+ });
29
+ if (indexingErrors.length > 0) throw new Error(indexingErrors.join('\n'));
30
+ if (scannedPageCount === 0) {
31
+ throw new Error('No rendered pages contained searchable editorial content.');
32
+ }
33
+
34
+ const { errors: writeErrors } = await index.writeFiles({
35
+ outputPath: distSearchDirectory,
36
+ });
37
+ if (writeErrors.length > 0) throw new Error(writeErrors.join('\n'));
38
+ const fragmentDirectory = path.join(distSearchDirectory, 'fragment');
39
+ const indexedPageCount = (await readdir(fragmentDirectory))
40
+ .filter((filename) => filename.endsWith('.pf_fragment'))
41
+ .length;
42
+ if (indexedPageCount === 0) {
43
+ throw new Error('No rendered pages contained searchable editorial content.');
44
+ }
45
+
46
+ await rm(localSearchDirectory, { force: true, recursive: true });
47
+ await mkdir(astroPublicDir, { recursive: true });
48
+ await cp(distSearchDirectory, localSearchDirectory, { recursive: true });
49
+
50
+ console.log(`Generated static search index for ${indexedPageCount} page${indexedPageCount === 1 ? '' : 's'}.`);
51
+ } catch (error) {
52
+ const message = error instanceof Error ? error.message : String(error);
53
+ throw new Error(`Could not generate the static search index. ${message}`);
54
+ } finally {
55
+ await pagefind.close();
56
+ }
57
+ }
@@ -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 = `
@@ -165,6 +167,7 @@ const nornaScripts = {
165
167
  'norna:config:check': cliCommand('config:check'),
166
168
  'norna:content:check': cliCommand('content:check'),
167
169
  'norna:sync': cliCommand('content:sync'),
170
+ 'norna:navigation:review': cliCommand('navigation:review'),
168
171
  'norna:theme:presets': cliCommand('theme:presets'),
169
172
  'norna:theme:export': cliCommand('theme:export'),
170
173
  'norna:typography:profiles': cliCommand('typography profiles'),
@@ -0,0 +1,228 @@
1
+ const titlePrefix = 'title="';
2
+ const lineSelectorPattern = /^\{(\d+(?:-\d+)?(?:,\d+(?:-\d+)?)*)\}$/;
3
+
4
+ const invalidMetadata = (message, fix) => ({
5
+ error: {
6
+ code: 'invalid-code-fence-metadata',
7
+ fix,
8
+ message,
9
+ },
10
+ });
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;
40
+ }
41
+
42
+ return invalidMetadata(
43
+ 'Code title is missing its closing double quote.',
44
+ 'Close the title, for example title="src/config.js".',
45
+ );
46
+ };
47
+
48
+ const parseHighlightedLines = (selector, lineCount) => {
49
+ const match = selector.match(lineSelectorPattern);
50
+ if (!match) {
51
+ return invalidMetadata(
52
+ `Invalid code line selector "${selector}".`,
53
+ 'Use positive line numbers and inclusive ranges without spaces, for example {2,4-6}.',
54
+ );
55
+ }
56
+
57
+ const highlightedLines = new Set();
58
+ for (const part of match[1].split(',')) {
59
+ const [startSource, endSource = startSource] = part.split('-');
60
+ const start = Number.parseInt(startSource, 10);
61
+ const end = Number.parseInt(endSource, 10);
62
+
63
+ if (start < 1 || end < start) {
64
+ return invalidMetadata(
65
+ `Invalid code line range "${part}".`,
66
+ 'Use positive line numbers with the lower number first, for example {2,4-6}.',
67
+ );
68
+ }
69
+ if (Number.isInteger(lineCount) && end > lineCount) {
70
+ return invalidMetadata(
71
+ `Code line selector "${part}" refers to line ${end}, but the block has ${lineCount} ${lineCount === 1 ? 'line' : 'lines'}.`,
72
+ `Select only lines 1-${lineCount}.`,
73
+ );
74
+ }
75
+
76
+ for (let line = start; line <= end; line += 1) {
77
+ if (highlightedLines.has(line)) {
78
+ return invalidMetadata(
79
+ `Code line ${line} is selected more than once.`,
80
+ 'Remove overlapping or repeated line numbers from the selector.',
81
+ );
82
+ }
83
+ highlightedLines.add(line);
84
+ }
85
+ }
86
+
87
+ return { highlightedLines: [...highlightedLines].sort((left, right) => left - right) };
88
+ };
89
+
90
+ export const parseCodeFenceMetadata = (rawMetadata, options = {}) => {
91
+ const source = String(rawMetadata ?? '').trim();
92
+ if (!source) return { highlightedLines: [], title: null };
93
+
94
+ let rest = source;
95
+ let title = null;
96
+ if (rest.startsWith(titlePrefix)) {
97
+ const titleResult = parseTitle(rest);
98
+ if (titleResult.error) return titleResult;
99
+ if (!titleResult.title.trim()) {
100
+ return invalidMetadata(
101
+ 'Code title cannot be empty.',
102
+ 'Remove title="" or provide a short filename or label.',
103
+ );
104
+ }
105
+ title = titleResult.title;
106
+ rest = titleResult.rest;
107
+ if (rest && !rest.startsWith(' ')) {
108
+ return invalidMetadata(
109
+ 'Code title must be separated from the line selector by one space.',
110
+ 'Write metadata as title="src/config.js" {2,4-6}.',
111
+ );
112
+ }
113
+ rest = rest.trim();
114
+ }
115
+
116
+ if (!rest) return { highlightedLines: [], title };
117
+ if (!rest.startsWith('{')) {
118
+ return invalidMetadata(
119
+ `Unknown code fence metadata "${rest}".`,
120
+ 'Use an optional title followed by an optional line selector: title="src/config.js" {2,4-6}.',
121
+ );
122
+ }
123
+ if (/^\{[^}]*\}\s+/.test(rest)) {
124
+ return invalidMetadata(
125
+ 'The code line selector must come after the optional title.',
126
+ 'Write metadata as title="src/config.js" {2,4-6}.',
127
+ );
128
+ }
129
+
130
+ const linesResult = parseHighlightedLines(rest, options.lineCount);
131
+ if (linesResult.error) return linesResult;
132
+ return {
133
+ highlightedLines: linesResult.highlightedLines,
134
+ title,
135
+ };
136
+ };
137
+
138
+ const formatDiagnostic = ({ error, label, line, offset }) => ({
139
+ ...error,
140
+ line,
141
+ message: `${label} line ${line}: ${error.message}`,
142
+ offset,
143
+ });
144
+
145
+ export const getCodeFenceMetadataDiagnostics = (tree, options = {}) => {
146
+ const diagnostics = [];
147
+ const label = options.label ?? 'Markdown';
148
+ const lineOffset = options.lineOffset ?? 0;
149
+ const excludedLanguages = options.excludedLanguages ?? new Set();
150
+
151
+ const visit = (node) => {
152
+ if (!node || typeof node !== 'object') return;
153
+ if (node.type === 'code' && !excludedLanguages.has(node.lang)) {
154
+ const metadataWithoutLanguage = typeof node.lang === 'string'
155
+ && (node.lang.startsWith('title=') || node.lang.startsWith('{'));
156
+ const result = metadataWithoutLanguage
157
+ ? invalidMetadata(
158
+ 'Code fence metadata requires a language before it.',
159
+ 'Add a language first, for example ```js title="src/config.js" {2}.',
160
+ )
161
+ : parseCodeFenceMetadata(node.meta, {
162
+ lineCount: String(node.value ?? '').split('\n').length,
163
+ });
164
+ if (result.error) {
165
+ const line = lineOffset + (node.position?.start.line ?? 1);
166
+ diagnostics.push(formatDiagnostic({
167
+ error: result.error,
168
+ label,
169
+ line,
170
+ offset: node.position?.start.offset ?? 0,
171
+ }));
172
+ }
173
+ }
174
+
175
+ for (const child of node.children ?? []) visit(child);
176
+ };
177
+
178
+ visit(tree);
179
+ return diagnostics;
180
+ };
181
+
182
+ const getTransformerMetadata = (context) => {
183
+ if (!context.meta.nornaCodeFence) {
184
+ const result = parseCodeFenceMetadata(context.options.meta?.__raw, {
185
+ lineCount: context.tokens?.length,
186
+ });
187
+ if (result.error) throw new Error(`${result.error.message} ${result.error.fix}`);
188
+ context.meta.nornaCodeFence = result;
189
+ }
190
+ return context.meta.nornaCodeFence;
191
+ };
192
+
193
+ export const nornaCodeFenceTransformer = {
194
+ name: 'norna-code-fence-metadata',
195
+ line(node, line) {
196
+ const metadata = getTransformerMetadata(this);
197
+ if (!metadata.highlightedLines.includes(line)) return;
198
+ this.addClassToHast(node, 'norna-code-line-highlighted');
199
+ node.properties.dataLine = String(line);
200
+ },
201
+ pre(node) {
202
+ const metadata = getTransformerMetadata(this);
203
+ if (metadata.highlightedLines.length > 0) {
204
+ this.addClassToHast(node, 'norna-code-has-highlighted-lines');
205
+ }
206
+ if (!metadata.title) return;
207
+
208
+ return {
209
+ type: 'element',
210
+ tagName: 'figure',
211
+ properties: { className: ['norna-code-example'] },
212
+ children: [
213
+ {
214
+ type: 'element',
215
+ tagName: 'figcaption',
216
+ properties: { className: ['norna-code-title'] },
217
+ children: [{
218
+ type: 'element',
219
+ tagName: 'span',
220
+ properties: { className: ['norna-code-title-text'] },
221
+ children: [{ type: 'text', value: metadata.title }],
222
+ }],
223
+ },
224
+ node,
225
+ ],
226
+ };
227
+ },
228
+ };
@@ -0,0 +1,102 @@
1
+ import path from 'node:path';
2
+ import { pathToFileURL } from 'node:url';
3
+
4
+ export const localEditorNames = Object.freeze(['vscode']);
5
+
6
+ const assertHttpUrl = (value, label) => {
7
+ if (typeof value !== 'string' || value.trim() === '') {
8
+ throw new Error(`${label} must be a non-empty absolute URL.`);
9
+ }
10
+
11
+ let url;
12
+ try {
13
+ url = new URL(value.trim());
14
+ } catch {
15
+ throw new Error(`${label} must be an absolute URL such as "https://github.com/owner/repository/edit/main/".`);
16
+ }
17
+
18
+ if (!['http:', 'https:'].includes(url.protocol)) {
19
+ throw new Error(`${label} must use http or https.`);
20
+ }
21
+ if (url.username || url.password) {
22
+ throw new Error(`${label} must not contain credentials.`);
23
+ }
24
+ if (url.search || url.hash) {
25
+ throw new Error(`${label} must not contain a query string or fragment.`);
26
+ }
27
+
28
+ return url;
29
+ };
30
+
31
+ export const normalizeEditLinkBaseUrl = (value, label = 'editLink.baseUrl') => {
32
+ const url = assertHttpUrl(value, label);
33
+ if (!url.pathname.endsWith('/')) url.pathname = `${url.pathname}/`;
34
+ return url.href;
35
+ };
36
+
37
+ export const getEditSourceUrl = ({ baseUrl, sourcePath }) => {
38
+ const normalizedBaseUrl = normalizeEditLinkBaseUrl(baseUrl);
39
+ if (typeof sourcePath !== 'string' || sourcePath.trim() === '') {
40
+ throw new Error('Edit-source path must be a non-empty project-relative path.');
41
+ }
42
+
43
+ const normalizedSourcePath = sourcePath.trim().replaceAll('\\', '/');
44
+ const segments = normalizedSourcePath.split('/');
45
+ if (
46
+ normalizedSourcePath.startsWith('/')
47
+ || segments.some((segment) => segment === '' || segment === '.' || segment === '..')
48
+ ) {
49
+ throw new Error(`Edit-source path "${sourcePath}" must be a project-relative path without traversal segments.`);
50
+ }
51
+
52
+ const encodedPath = segments.map((segment) => encodeURIComponent(segment)).join('/');
53
+ return new URL(encodedPath, normalizedBaseUrl).href;
54
+ };
55
+
56
+ export const isLoopbackHostname = (hostname) => {
57
+ const normalizedHostname = String(hostname ?? '')
58
+ .trim()
59
+ .toLowerCase()
60
+ .replace(/^\[|\]$/g, '')
61
+ .replace(/\.$/, '');
62
+
63
+ return normalizedHostname === 'localhost'
64
+ || normalizedHostname.endsWith('.localhost')
65
+ || normalizedHostname === '::1'
66
+ || normalizedHostname.startsWith('::ffff:127.')
67
+ || /^127(?:\.\d{1,3}){3}$/.test(normalizedHostname);
68
+ };
69
+
70
+ export const getLocalEditorSourceUrl = ({ editor, sourcePath }) => {
71
+ if (!localEditorNames.includes(editor)) {
72
+ throw new Error(`Unknown local editor "${editor}". Use one of: ${localEditorNames.join(', ')}.`);
73
+ }
74
+ if (typeof sourcePath !== 'string' || !path.isAbsolute(sourcePath)) {
75
+ throw new Error('Local editor source path must be absolute.');
76
+ }
77
+
78
+ const fileUrl = pathToFileURL(path.normalize(sourcePath));
79
+ return `vscode://file${fileUrl.pathname}`;
80
+ };
81
+
82
+ export const resolveEditSourceTarget = ({
83
+ baseUrl,
84
+ development = false,
85
+ hostname,
86
+ localEditor,
87
+ sourceLabel,
88
+ sourcePath,
89
+ }) => {
90
+ if (development && localEditor && isLoopbackHostname(hostname)) {
91
+ return Object.freeze({
92
+ href: getLocalEditorSourceUrl({ editor: localEditor, sourcePath }),
93
+ kind: 'local',
94
+ });
95
+ }
96
+ if (!baseUrl) return null;
97
+
98
+ return Object.freeze({
99
+ href: getEditSourceUrl({ baseUrl, sourcePath: sourceLabel }),
100
+ kind: 'remote',
101
+ });
102
+ };