@janga/norna 0.7.28 → 0.7.29

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 (77) hide show
  1. package/README.md +8 -5
  2. package/astro.config.mjs +14 -2
  3. package/package.json +10 -8
  4. package/schemas/config.schema.json +80 -65
  5. package/schemas/content-frontmatter.schema.json +15 -8
  6. package/schemas/manifest.json +1 -1
  7. package/schemas/page-theme.schema.json +237 -237
  8. package/schemas/root-theme.schema.json +237 -237
  9. package/schemas/sitewide-content.schema.json +18 -18
  10. package/schemas/theme.schema.json +237 -237
  11. package/scripts/add-site-node.mjs +29 -100
  12. package/scripts/dev-local.mjs +34 -7
  13. package/scripts/generate-images.mjs +83 -65
  14. package/scripts/generate-search-index.mjs +3 -2
  15. package/scripts/lib/attachment-render.mjs +23 -0
  16. package/scripts/lib/dev-server-identity.mjs +30 -0
  17. package/scripts/lib/documentation-routes.json +63 -62
  18. package/scripts/lib/docusaurus-migration-audit.mjs +9 -9
  19. package/scripts/lib/editor-attachments.mjs +69 -0
  20. package/scripts/lib/editor-file-policy.mjs +2 -0
  21. package/scripts/lib/editor-language-service.mjs +21 -18
  22. package/scripts/lib/editor-page-addresses.mjs +5 -3
  23. package/scripts/lib/editor-page-placement.mjs +28 -98
  24. package/scripts/lib/editor-resource-actions.mjs +216 -0
  25. package/scripts/lib/editor-site-editing-tree.mjs +44 -9
  26. package/scripts/lib/editor-site-files.mjs +3 -2
  27. package/scripts/lib/editor-site-links.mjs +6 -4
  28. package/scripts/lib/editor-site-preview.mjs +149 -0
  29. package/scripts/lib/editor-site-tree.mjs +10 -3
  30. package/scripts/lib/editor-source-files.mjs +1 -1
  31. package/scripts/lib/logo-assets.mjs +2 -1
  32. package/scripts/lib/markdown-images.mjs +59 -0
  33. package/scripts/lib/markdown-links.mjs +29 -9
  34. package/scripts/lib/norna-markdown-blocks.mjs +0 -57
  35. package/scripts/lib/norna-markdown-render-plugin.mjs +29 -0
  36. package/scripts/lib/page-aliases.mjs +5 -0
  37. package/scripts/lib/page-attachments.mjs +59 -0
  38. package/scripts/lib/page-markdown.mjs +11 -7
  39. package/scripts/lib/page-model.mjs +8 -22
  40. package/scripts/lib/page-move-apply.mjs +6 -4
  41. package/scripts/lib/page-move-plan.mjs +37 -19
  42. package/scripts/lib/page-order-source.mjs +22 -0
  43. package/scripts/lib/page-order.mjs +45 -0
  44. package/scripts/lib/page-placement-cli.mjs +50 -0
  45. package/scripts/lib/project-config.mjs +3 -0
  46. package/scripts/lib/publication-paths.mjs +72 -0
  47. package/scripts/lib/schema-definitions.mjs +4 -0
  48. package/scripts/lib/schema-editor-metadata.mjs +4 -1
  49. package/scripts/lib/site-content.mjs +1 -1
  50. package/scripts/lib/site-link-graph.mjs +88 -18
  51. package/scripts/lib/site-node-create.mjs +47 -34
  52. package/scripts/lib/site-paths.mjs +2 -2
  53. package/scripts/lib/site-source-format.mjs +12 -0
  54. package/scripts/lib/site-structure.mjs +14 -13
  55. package/scripts/lib/site-upgrade.mjs +2 -0
  56. package/scripts/lib/social-image-assets.mjs +2 -1
  57. package/scripts/lib/source-files.mjs +1 -1
  58. package/scripts/lib/structured-blocks.mjs +3 -1
  59. package/scripts/move-site-page.mjs +46 -133
  60. package/scripts/sync-content-sections.mjs +8 -10
  61. package/scripts/sync-site-public.mjs +44 -66
  62. package/src/components/ContentBlocks.astro +2 -1
  63. package/src/components/ImageStack.astro +7 -5
  64. package/src/components/SearchPage.astro +2 -1
  65. package/src/components/SitePage.astro +1 -0
  66. package/src/components/SiteSection.astro +1 -0
  67. package/src/content.config.ts +1 -4
  68. package/src/lib/imagePresentation.ts +3 -0
  69. package/src/lib/markdownImages.ts +101 -0
  70. package/src/lib/sectionContent.ts +64 -19
  71. package/src/lib/sitePages.ts +4 -30
  72. package/src/lib/sitePublicAssets.ts +2 -1
  73. package/src/pages/[...slug].astro +2 -1
  74. package/src/styles/area-navigation.css +14 -5
  75. package/src/styles/content.css +6 -0
  76. package/src/styles/media.css +24 -0
  77. package/starters/basic/site/site-config/settings.yaml +1 -0
@@ -0,0 +1,149 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { readFile, realpath } from 'node:fs/promises';
4
+ import net from 'node:net';
5
+ import path from 'node:path';
6
+ import { fileURLToPath, pathToFileURL } from 'node:url';
7
+ import { verifyDevServer } from './dev-server-identity.mjs';
8
+ import { getEditorPageAddresses } from './editor-page-addresses.mjs';
9
+
10
+ export const sitePreviewApiVersion = 1;
11
+ const engineRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
12
+ const queues = new Map();
13
+ const serialize = (key, action) => {
14
+ const task = (queues.get(key) ?? Promise.resolve()).catch(() => {}).then(action);
15
+ queues.set(key, task);
16
+ void task.finally(() => { if (queues.get(key) === task) queues.delete(key); }).catch(() => {});
17
+ return task;
18
+ };
19
+ const readState = async root => {
20
+ try { return JSON.parse(await readFile(path.join(root, '.norna/dev/state.json'), 'utf8')); }
21
+ catch (error) { if (error.code === 'ENOENT') return null; throw new Error('The preview server record is unreadable. Inspect Show Preview Log before restarting.', { cause: error }); }
22
+ };
23
+ const registryEntry = async siteRoot => {
24
+ let registry;
25
+ try { registry = await import(pathToFileURL(path.join(engineRoot, 'scripts/review-environment-registry.mjs')).href); }
26
+ catch (error) { if (error.code === 'ERR_MODULE_NOT_FOUND') return null; throw error; }
27
+ for (const [target, definition] of Object.entries(registry.reviewEnvironments)) {
28
+ const root = await realpath(path.join(engineRoot, definition.siteDirectory)).catch(() => null);
29
+ if (root === siteRoot) return { target, ...definition };
30
+ }
31
+ return null;
32
+ };
33
+ export const getEditorPreviewConfiguration = async ({ siteRoot, environment = process.env }) => {
34
+ const root = await realpath(siteRoot);
35
+ const registered = await registryEntry(root);
36
+ const value = registered ? String(registered.port) : environment.NORNA_DEV_PORT ?? '4321';
37
+ const port = /^\d+$/.test(value) ? Number(value) : NaN;
38
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('NORNA_DEV_PORT must be an integer from 1 through 65535. Correct the VS Code launch environment and restart VS Code.');
39
+ return { siteRoot: root, port, registered, source: registered ? `review registry (${registered.target})` : environment.NORNA_DEV_PORT === undefined ? 'default' : 'NORNA_DEV_PORT' };
40
+ };
41
+ export const getEditorPreviewStatus = async ({ siteRoot }) => {
42
+ const root = await realpath(siteRoot);
43
+ const state = await readState(root);
44
+ return { state, verified: await verifyDevServer(state, root) };
45
+ };
46
+ export const getEditorPreviewLog = async ({ siteRoot }) => {
47
+ const root = await realpath(siteRoot);
48
+ const files = ['dev/preparation.log', '.astro/dev.log', 'dev/state.json'];
49
+ const logs = await Promise.all(files.map(async name => {
50
+ try { return `${name}\n${(await readFile(path.join(root, '.norna', name), 'utf8')).slice(-64000)}`; }
51
+ catch (error) { if (error.code === 'ENOENT') return ''; throw error; }
52
+ }));
53
+ return logs.filter(Boolean).join('\n\n') || 'No preview log exists for this site yet.';
54
+ };
55
+ const runManager = (config, operation, { token, environment, onOutput = () => {} } = {}) => new Promise((resolve, reject) => {
56
+ const args = config.registered
57
+ ? [path.join(engineRoot, 'scripts/review-environments.mjs'), operation === 'stop' ? 'stop' : 'start', config.registered.target]
58
+ : [path.join(engineRoot, 'bin/norna.mjs'), '--site-dir', config.siteRoot, operation === 'stop' ? 'dev:stop' : 'dev:local'];
59
+ const env = { ...process.env, ...environment, NORNA_DEV_PORT: String(config.port), NORNA_SITE_DIR: config.siteRoot,
60
+ NORNA_NO_OPEN: '1', NORNA_DEV_NO_RESTART: '1', ELECTRON_RUN_AS_NODE: '1' };
61
+ // The extension may itself have been launched while another site was selected.
62
+ delete env.NORNA_INTERNAL_STATE_DIR;
63
+ delete env.NORNA_INVOCATION_ROOT;
64
+ delete env.NORNA_DEV_EXPECT_TOKEN;
65
+ delete env.NORNA_DEV_START_TOKEN;
66
+ if (operation === 'stop') env.NORNA_DEV_EXPECT_TOKEN = token;
67
+ else env.NORNA_DEV_START_TOKEN = token;
68
+ const child = spawn(process.execPath, args, { cwd: engineRoot, env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
69
+ let output = '';
70
+ const capture = chunk => { output = (output + chunk).slice(-64000); onOutput(String(chunk)); };
71
+ child.stdout.on('data', capture); child.stderr.on('data', capture);
72
+ child.once('error', reject);
73
+ child.once('exit', code => code === 0 ? resolve() : reject(new Error(`Preview ${operation} failed. ${output.replace(/\n\s+at [\s\S]*$/, '').trim().slice(-2500) || 'Show Preview Log for details.'}`)));
74
+ });
75
+ const portOccupied = (port, host) => new Promise((resolve, reject) => {
76
+ const socket = net.connect({ port, host });
77
+ socket.once('connect', () => { socket.destroy(); resolve(true); });
78
+ socket.once('error', error => { socket.destroy(); ['ECONNREFUSED', 'EADDRNOTAVAIL', 'ENETUNREACH', 'EHOSTUNREACH'].includes(error.code) ? resolve(false) : reject(error); });
79
+ socket.setTimeout(1000, () => { socket.destroy(); resolve(true); });
80
+ });
81
+ const cancelled = signal => { if (signal?.aborted) throw new Error('Preview cancelled.'); };
82
+ const stopVerified = async (config, state, options) => {
83
+ if (!(await verifyDevServer(state, config.siteRoot))) throw new Error('Cannot verify this site’s preview server. No process was stopped. Use Show Preview Log to inspect its record.');
84
+ if (config.registered && config.registered.port !== state.port) throw new Error('The registered port differs from the tracked server. Restore its registry port before stopping through the registered review command.');
85
+ await runManager({ ...config, port: state.port }, 'stop', { ...options, token: state.token });
86
+ };
87
+ export const stopEditorSitePreview = async (options) => {
88
+ const root = await realpath(options.siteRoot);
89
+ return serialize(root, async () => {
90
+ const state = await readState(root);
91
+ if (!state) return { stopped: false };
92
+ await stopVerified({ siteRoot: root, registered: await registryEntry(root) }, state, options);
93
+ return { stopped: true };
94
+ });
95
+ };
96
+ export const startEditorSitePreview = async (options) => {
97
+ const config = await getEditorPreviewConfiguration(options);
98
+ return serialize(config.siteRoot, async () => {
99
+ cancelled(options.signal);
100
+ // Read saved sources now, after any explicit Save Site action.
101
+ const addresses = await getEditorPageAddresses({ siteRoot: config.siteRoot, sourcePath: options.sourcePath ?? path.join(config.siteRoot, 'root/content.md') });
102
+ if (!addresses.webAddress || addresses.incomplete.length) throw new Error(`Repair the site before previewing it. ${addresses.incomplete.join('\n')}`);
103
+ let state = await readState(config.siteRoot);
104
+ let created = false;
105
+ if (await verifyDevServer(state, config.siteRoot)) {
106
+ if (state.port !== config.port) throw new Error(`This site is already running on port ${state.port}, but ${config.source} selects ${config.port}. Use Stop Preview Server before starting on the new port.`);
107
+ if (state.mode !== 'local') throw new Error('This site is running in LAN mode. Stop that server explicitly before starting a local preview.');
108
+ } else {
109
+ if (state) throw new Error('The tracked preview server cannot be verified. It may be stopped or belong to an older engine. Inspect Show Preview Log and stop/restart it through the site’s dev commands; preview did not stop any process.');
110
+ if (await portOccupied(config.port, '127.0.0.1') || await portOccupied(config.port, '::1')) throw new Error(`Port ${config.port} is used by another or unverified server. Stop it yourself or configure NORNA_DEV_PORT in the VS Code launch environment. No port fallback was attempted.`);
111
+ cancelled(options.signal);
112
+ const token = randomUUID();
113
+ try {
114
+ await runManager(config, 'start', { ...options, token });
115
+ state = await readState(config.siteRoot);
116
+ if (state?.token !== token || !(await verifyDevServer(state, config.siteRoot))) throw new Error('The new preview server did not provide a matching site identity. Inspect Show Preview Log.');
117
+ created = true;
118
+ } catch (error) {
119
+ const failed = await readState(config.siteRoot);
120
+ if (failed?.token === token && await verifyDevServer(failed, config.siteRoot)) {
121
+ try { await stopVerified(config, failed, options); } catch (cleanup) { throw new Error(`${error.message}\nCleanup failed: ${cleanup.message}`); }
122
+ }
123
+ throw error;
124
+ }
125
+ }
126
+ const url = new URL(addresses.webAddress);
127
+ url.protocol = 'http:'; url.hostname = '127.0.0.1'; url.port = String(state.port);
128
+ try {
129
+ cancelled(options.signal);
130
+ const timeout = AbortSignal.timeout(30_000);
131
+ const signal = options.signal ? AbortSignal.any([timeout, options.signal]) : timeout;
132
+ const response = await fetch(url, { signal, redirect: 'manual' });
133
+ await response.arrayBuffer();
134
+ if (!response.ok) throw new Error(`The preview page returned HTTP ${response.status}: ${url.href}. Repair the page and use Show Preview Log for details.`);
135
+ cancelled(options.signal);
136
+ if (!(await verifyDevServer(state, config.siteRoot))) throw new Error('The preview server identity changed while loading the page. No browser was opened.');
137
+ } catch (failure) {
138
+ const message = options.signal?.aborted ? 'Preview cancelled.'
139
+ : failure.name === 'TimeoutError' ? `Timed out loading the preview page ${url.href}. Use Show Preview Log.`
140
+ : failure.message;
141
+ if (created) {
142
+ try { await stopVerified(config, state, options); }
143
+ catch (cleanup) { throw new Error(`${message}\nCleanup failed: ${cleanup.message} Use Stop Preview Server after inspecting the log.`); }
144
+ }
145
+ throw new Error(message, { cause: failure });
146
+ }
147
+ return { url: url.href, reused: !created, siteRoot: config.siteRoot, port: state.port };
148
+ });
149
+ };
@@ -17,12 +17,13 @@ import { readSiteEditingTree } from './editor-site-editing-tree.mjs';
17
17
  export const siteTreeApiVersion = 1;
18
18
  export const siteFileTreeApiVersion = 1;
19
19
  export const sitePageFormApiVersion = 1;
20
+ export const sitePageOrderApiVersion = 1;
20
21
  export { siteTreeEditingApiVersion, getEditorSourceFileChoices, planEditorSourceFileCreation, createEditorSourceFile } from './editor-source-files.mjs';
21
22
  export { createSiteNode, planSiteNodeCreation, slugifyAsciiIdentifier };
22
23
  export { siteFileOperationsApiVersion, siteRemovalApiVersion, planEditorImageCopy, planEditorRemoval, getEditorImageUsage, createEditorImageAppend, createEditorImageBatchAppend } from './editor-site-files.mjs';
23
24
  export { getEditorIncomingLinks } from './editor-site-links.mjs';
24
- export { siteAddressApiVersion, getEditorPageAddresses, planEditorPageAddress, applyEditorPageAddress } from './editor-page-addresses.mjs';
25
- export { sitePagePlacementApiVersion, planEditorPagePlacement, applyEditorPagePlacement } from './editor-page-placement.mjs';
25
+ export { siteAddressApiVersion, sitePageAddressOptionsApiVersion, getEditorPageAddresses, planEditorPageAddress, applyEditorPageAddress } from './editor-page-addresses.mjs';
26
+ export { sitePagePlacementApiVersion, sitePagePlacementOptionsApiVersion, planEditorPagePlacement, applyEditorPagePlacement } from './editor-page-placement.mjs';
26
27
 
27
28
  const sourceParts = (source, kind) => {
28
29
  const split = splitPageMarkdownSource(source);
@@ -87,7 +88,7 @@ export const readSiteTree = async ({ siteRoot, sources = new Map(), cache = new
87
88
  problem: information.problem ?? node.problems?.[0] ?? null });
88
89
  }
89
90
  for (const filename of cache.keys()) if (!used.has(filename)) cache.delete(filename);
90
- return { nodes, problems: structure.problems };
91
+ return { nodes, problems: [...structure.problems, ...structure.warnings.map(warning => ({ ...warning, severity: 'warning' }))] };
91
92
  };
92
93
 
93
94
  // One physical projection preserves resources and repairable entries even when
@@ -149,3 +150,9 @@ export const editSiteNodeInformation = async ({ siteRoot, sourcePath, source, fi
149
150
  while (end > start && nextEnd > start && source[end - 1] === next[nextEnd - 1]) { end--; nextEnd--; }
150
151
  return [{ start, end, text: next.slice(start, nextEnd) }];
151
152
  };
153
+
154
+ export { siteResourceActionsApiVersion, getEditorResourceReferences, planEditorResourceRename, planEditorPublicCreation, planEditorFolderRemoval, getEditorResourceAddress } from './editor-resource-actions.mjs';
155
+
156
+ export { siteAttachmentsApiVersion, planEditorAttachmentCopy, createEditorAttachmentInsertion } from './editor-attachments.mjs';
157
+
158
+ export { sitePreviewApiVersion, getEditorPreviewConfiguration, getEditorPreviewStatus, getEditorPreviewLog, startEditorSitePreview, stopEditorSitePreview } from './editor-site-preview.mjs';
@@ -54,7 +54,7 @@ export const planEditorSourceFileCreation = async ({ siteRoot, directory, filena
54
54
  if (definition.input) {
55
55
  if (typeof value !== 'string' || !value.trim() || /[\r\n]/.test(value)) throw new Error('Enter a non-empty, single line of text.');
56
56
  text = definition.input === 'title' ? `# ${escapeMarkdownHeading(value.trim())}\n`
57
- : `${definition.input}: ${JSON.stringify(value.trim())}\n`;
57
+ : `${definition.schemaKind === 'config' ? 'sourceFormat: 2\n' : ''}${definition.input}: ${JSON.stringify(value.trim())}\n`;
58
58
  }
59
59
  if (definition.schema) parseYamlConfig(text, filename, { schema: definition.schema });
60
60
  const identity = async (location) => { const entry = await stat(location); return entry ? [entry.dev, entry.ino] : null; };
@@ -1,3 +1,4 @@
1
+ import { publicFilePublication } from './publication-paths.mjs';
1
2
  import { readdirSync } from 'node:fs';
2
3
  import { sitePublicDir, sitePublicLabel } from './site-paths.mjs';
3
4
  import {
@@ -23,7 +24,7 @@ export const getPublicAssetInspection = () => inspectPublicAssetFilenames(getPub
23
24
  export const getLogoAssets = () => getPublicAssetInspection().logos
24
25
  .map((entry) => ({
25
26
  filename: entry,
26
- href: `/${entry}`,
27
+ href: publicFilePublication(entry).pathname,
27
28
  }));
28
29
 
29
30
  export const getLogoAsset = () => {
@@ -0,0 +1,59 @@
1
+ import { getNodeRange, getInlineLinkTargetRange, getDefinitionTargetRange, visitNodes } from './markdown-links.mjs';
2
+ import { managedImageFilenamePattern } from './structured-blocks.mjs';
3
+
4
+ const filenamePattern = new RegExp(managedImageFilenamePattern);
5
+
6
+ // Public URLs and remote resources keep their existing rendering rules.
7
+ export const classifyMarkdownImage = (target) => {
8
+ if (/^(?:[a-z][a-z0-9+.-]*:|\/|#)/i.test(target)) return { kind: 'external' };
9
+ let decoded;
10
+ try { decoded = decodeURIComponent(target); } catch { return { kind: 'invalid' }; }
11
+ const image = decoded.replace(/^(?:\.\/)?images\//, '');
12
+ return filenamePattern.test(image) ? { kind: 'managed', image } : { kind: 'invalid' };
13
+ };
14
+
15
+ export const getImageDefinitions = (tree) => {
16
+ const definitions = new Map();
17
+ visitNodes(tree, (node) => {
18
+ if (node.type === 'definition' && !definitions.has(node.identifier)) definitions.set(node.identifier, node);
19
+ });
20
+ return definitions;
21
+ };
22
+
23
+ export const extractMarkdownImageReferences = ({ source, tree, lineOffset = 0 }) => {
24
+ const definitions = getImageDefinitions(tree);
25
+ const references = [];
26
+ visitNodes(tree, (node) => {
27
+ if (!['image', 'imageReference'].includes(node.type)) return;
28
+ const destination = node.type === 'image' ? node : definitions.get(node.identifier);
29
+ if (!destination) return;
30
+ const target = destination.url ?? '';
31
+ const classification = classifyMarkdownImage(target);
32
+ if (classification.kind === 'external') return;
33
+ const targetRange = node.type === 'image'
34
+ ? getInlineLinkTargetRange(source, node)
35
+ : getDefinitionTargetRange(source, destination);
36
+ references.push({
37
+ ...classification,
38
+ target,
39
+ alt: node.alt ?? '',
40
+ title: destination.title,
41
+ line: lineOffset + (node.position?.start.line ?? 1),
42
+ blockType: 'markdown-image',
43
+ blockDisplayType: 'Markdown image',
44
+ range: getNodeRange(node),
45
+ targetRange,
46
+ targetSource: targetRange ? source.slice(targetRange.start, targetRange.end) : target,
47
+ definitionSource: node.type === 'imageReference' ? source.slice(destination.position.start.offset, destination.position.end.offset) : undefined,
48
+ });
49
+ });
50
+ return references;
51
+ };
52
+
53
+ export const markdownImageDiagnostic = (reference, label) => ({
54
+ code: 'invalid-markdown-image',
55
+ severity: 'error',
56
+ line: reference.line,
57
+ message: `${label} line ${reference.line}: Local Markdown image "${reference.target}" must identify a supported file in this page's images/ folder.`,
58
+ fix: 'Use images/name.jpg (or .jpeg, .png, .svg), with a filename containing letters, numbers, dots or hyphens. Norna also accepts ./images/name.jpg and the bare filename.',
59
+ });
@@ -1,4 +1,4 @@
1
- const getNodeRange = (node) => {
1
+ export const getNodeRange = (node) => {
2
2
  const start = node.position?.start.offset;
3
3
  const end = node.position?.end.offset;
4
4
  return Number.isInteger(start) && Number.isInteger(end)
@@ -6,7 +6,7 @@ const getNodeRange = (node) => {
6
6
  : null;
7
7
  };
8
8
 
9
- const visitNodes = (node, visit) => {
9
+ export const visitNodes = (node, visit) => {
10
10
  if (!node || typeof node !== 'object') return;
11
11
  visit(node);
12
12
  if (!Array.isArray(node.children)) return;
@@ -55,9 +55,28 @@ const scanDestination = (source, start, end) => {
55
55
  return { start: targetStart, end: cursor };
56
56
  };
57
57
 
58
- const getInlineLinkTargetRange = (source, node) => {
58
+ export const getInlineLinkTargetRange = (source, node) => {
59
59
  const nodeRange = getNodeRange(node);
60
60
  if (!nodeRange) return null;
61
+ if (node.type === 'image') {
62
+ let depth = 1;
63
+ for (let cursor = nodeRange.start + 2; cursor < nodeRange.end; cursor += 1) {
64
+ if (source[cursor] === '\\') { cursor += 1; continue; }
65
+ if (source[cursor] === '`') {
66
+ const run = source.slice(cursor).match(/^`+/)[0];
67
+ const followingRuns = source.slice(cursor + run.length, nodeRange.end).matchAll(/`+/g);
68
+ const closing = [...followingRuns].find((match) => match[0].length === run.length);
69
+ if (closing) { cursor += run.length + closing.index + run.length - 1; continue; }
70
+ cursor += run.length - 1;
71
+ continue;
72
+ }
73
+ if (source[cursor] === '[') depth += 1;
74
+ if (source[cursor] === ']' && --depth === 0) {
75
+ return source[cursor + 1] === '(' ? scanDestination(source, cursor + 2, nodeRange.end) : null;
76
+ }
77
+ }
78
+ return null;
79
+ }
61
80
  const childEnd = Array.isArray(node.children)
62
81
  ? Math.max(nodeRange.start, ...node.children.map((child) => child.position?.end.offset ?? nodeRange.start))
63
82
  : nodeRange.start;
@@ -66,7 +85,7 @@ const getInlineLinkTargetRange = (source, node) => {
66
85
  return scanDestination(source, delimiter + 2, nodeRange.end);
67
86
  };
68
87
 
69
- const getDefinitionTargetRange = (source, node) => {
88
+ export const getDefinitionTargetRange = (source, node) => {
70
89
  const nodeRange = getNodeRange(node);
71
90
  if (!nodeRange) return null;
72
91
  const delimiter = source.indexOf(']:', nodeRange.start);
@@ -82,16 +101,16 @@ export const extractMarkdownLinks = ({ source, tree, lineOffset = 0 }) => {
82
101
  const definitions = new Map();
83
102
  visitNodes(tree, (node) => {
84
103
  if (node.type !== 'definition') return;
85
- definitions.set(node.identifier, node);
104
+ if (!definitions.has(node.identifier)) definitions.set(node.identifier, node);
86
105
  });
87
106
 
88
107
  const links = [];
89
108
  visitNodes(tree, (node) => {
90
- if (node.type === 'link') {
109
+ if (node.type === 'link' || node.type === 'image' && /^(?:\/|https?:)/i.test(node.url ?? '')) {
91
110
  const range = getNodeRange(node);
92
111
  const targetRange = getInlineLinkTargetRange(source, node);
93
112
  links.push({
94
- kind: 'markdown-link',
113
+ kind: node.type === 'image' ? 'markdown-image-link' : 'markdown-link',
95
114
  column: node.position?.start.column ?? 1,
96
115
  line: lineOffset + (node.position?.start.line ?? 1),
97
116
  range,
@@ -102,14 +121,15 @@ export const extractMarkdownLinks = ({ source, tree, lineOffset = 0 }) => {
102
121
  return;
103
122
  }
104
123
 
105
- if (node.type !== 'linkReference') return;
124
+ if (!['linkReference', 'imageReference'].includes(node.type)) return;
106
125
  const definition = definitions.get(node.identifier);
107
- if (!definition) return;
126
+ if (!definition || node.type === 'imageReference' && !/^(?:\/|https?:)/i.test(definition.url ?? '')) return;
108
127
  const targetRange = getDefinitionTargetRange(source, definition);
109
128
  links.push({
110
129
  kind: 'markdown-reference-link',
111
130
  column: node.position?.start.column ?? 1,
112
131
  definitionLine: lineOffset + (definition.position?.start.line ?? 1),
132
+ definitionSource: source.slice(definition.position.start.offset, definition.position.end.offset),
113
133
  line: lineOffset + (node.position?.start.line ?? 1),
114
134
  range: getNodeRange(node),
115
135
  target: definition.url ?? '',
@@ -534,63 +534,6 @@ export const splitNornaRenderedBlocks = (html, blocks) => {
534
534
  return result.filter((block) => block.type !== 'html' || block.html.trim());
535
535
  };
536
536
 
537
- const maskFencedCodeBlocks = (markdown) => {
538
- const lines = normalizeLines(markdown);
539
- const maskedLines = [];
540
- let index = 0;
541
-
542
- while (index < lines.length) {
543
- const fence = getFenceInfo(lines[index]);
544
- if (!fence || fence.length < 3) {
545
- maskedLines.push(lines[index]);
546
- index += 1;
547
- continue;
548
- }
549
-
550
- let closingIndex = -1;
551
- for (let candidateIndex = index + 1; candidateIndex < lines.length; candidateIndex += 1) {
552
- if (getFenceCloseInfo(lines[candidateIndex], fence)) {
553
- closingIndex = candidateIndex;
554
- break;
555
- }
556
- }
557
-
558
- if (closingIndex === -1) {
559
- maskedLines.push(lines[index]);
560
- index += 1;
561
- continue;
562
- }
563
-
564
- for (let maskedIndex = index; maskedIndex <= closingIndex; maskedIndex += 1) {
565
- maskedLines.push(lines[maskedIndex].replace(/[^\r\n]/g, ' '));
566
- }
567
- index = closingIndex + 1;
568
- }
569
-
570
- return maskedLines.join('\n');
571
- };
572
-
573
- const isExternalImageTarget = (target) => /^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith('//');
574
- const isRootRelativePublicTarget = (target) => target.startsWith('/');
575
-
576
- export const extractMarkdownImageReferences = (markdown) => {
577
- const masked = maskFencedCodeBlocks(markdown);
578
- const references = [];
579
- const imageRegex = /!\[[^\]\n]*\]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g;
580
-
581
- for (const match of masked.matchAll(imageRegex)) {
582
- const target = match[1] ?? '';
583
- if (!target || target.startsWith('#') || isExternalImageTarget(target) || isRootRelativePublicTarget(target)) continue;
584
-
585
- references.push({
586
- target,
587
- line: getLineNumber(markdown, match.index ?? 0),
588
- });
589
- }
590
-
591
- return references;
592
- };
593
-
594
537
  export const getNornaBlockImageReferences = (blocks) =>
595
538
  blocks.flatMap((block) => {
596
539
  if (block.type === 'card-list') {
@@ -3,6 +3,26 @@ import { nornaBlockTypes } from './norna-markdown-blocks.mjs';
3
3
  import projectConfig from './project-config.mjs';
4
4
  import { getSemanticCalloutMarker } from './semantic-callouts.mjs';
5
5
  import { applyTableRowHeaderDeclaration } from './table-row-headers.mjs';
6
+ import { classifyMarkdownImage, getImageDefinitions } from './markdown-images.mjs';
7
+
8
+ const escapeAttribute = (value) => String(value).replaceAll('&', '&amp;').replaceAll('"', '&quot;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
9
+ const markManagedImage = (node, context) => {
10
+ let root = node;
11
+ while (context.parent(root)) root = context.parent(root);
12
+ const destination = node.type === 'image' ? node : getImageDefinitions(root).get(node.identifier);
13
+ if (!destination) return;
14
+ const result = classifyMarkdownImage(destination.url ?? '');
15
+ if (result.kind === 'external') return;
16
+ if (result.kind === 'invalid') throw new Error(`Local Markdown image "${destination.url}" must reference a .jpg, .jpeg, .png or .svg file directly in this page's images/ folder.`);
17
+ const title = destination.title == null ? '' : ` title="${escapeAttribute(destination.title)}"`;
18
+ // Raw HTML keeps Astro's independent image importer from processing these
19
+ // sources. The page renderer resolves this marker through Norna's manifest.
20
+ context.replaceNode(node, { type: 'html', value: `<img data-norna-managed-image="${escapeAttribute(result.image)}" alt="${escapeAttribute(node.alt ?? '')}"${title}>` });
21
+ };
22
+
23
+ export const nornaMarkdownImagePlugin = defineMdastPlugin({
24
+ name: 'norna-markdown-images', image: markManagedImage, imageReference: markManagedImage,
25
+ });
6
26
 
7
27
  const stateKey = 'nornaMarkdownRender';
8
28
 
@@ -33,6 +53,8 @@ const hasBlockquoteAncestor = (node, context) => {
33
53
 
34
54
  export const nornaMarkdownRenderPlugin = defineMdastPlugin({
35
55
  name: 'norna-markdown-render',
56
+ image: markManagedImage,
57
+ imageReference: markManagedImage,
36
58
  blockquote(node, context) {
37
59
  const marker = getSemanticCalloutMarker(node);
38
60
  if (
@@ -88,6 +110,13 @@ export const nornaMarkdownRenderPlugin = defineMdastPlugin({
88
110
  });
89
111
  },
90
112
  heading(node, context) {
113
+ if (node.depth === 3) {
114
+ if (context.parent(node)?.type !== 'root') return;
115
+ context.setProperty(node, 'data', {
116
+ hProperties: { 'data-norna-page-heading': 'true' },
117
+ });
118
+ return;
119
+ }
91
120
  if (node.depth !== 1 && node.depth !== 2) return;
92
121
  const state = getState(context);
93
122
  const regionIndex = state.regionIndex;
@@ -2,6 +2,11 @@ import { getSiteNodePathname } from './site-page-urls.mjs';
2
2
  import { sitemapFilename } from './sitemap.mjs';
3
3
 
4
4
  export const getGeneratedSiteRoutes = ({ searchEnabled = false } = {}) => Object.freeze([
5
+ Object.freeze({
6
+ kind: 'generated-route',
7
+ label: 'Norna generated 404 page',
8
+ pathname: '/404.html',
9
+ }),
5
10
  Object.freeze({
6
11
  kind: 'generated-route',
7
12
  label: `Norna generated ${sitemapFilename}`,
@@ -0,0 +1,59 @@
1
+ import { lstat, readdir } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { getSiteNodePathname } from './site-page-urls.mjs';
4
+ import { attachmentPublicationPath, publicationIssues } from './publication-paths.mjs';
5
+
6
+ export const attachmentPathname = attachmentPublicationPath;
7
+ export const encodeAttachmentPath = value => value.split('/').map(segment => encodeURIComponent(segment).replace(/[()']/g, char => '%' + char.charCodeAt(0).toString(16))).join('/');
8
+ export const validateAttachmentName = name => {
9
+ if (typeof name !== 'string' || !name || name !== name.trim() || /[\\/\x00-\x1f\x7f<>:"|?*#%]/.test(name) || /^\.{1,2}$/.test(name) || /[. ]$/.test(name)) throw new Error('Use one filename without slashes, URL punctuation or trailing dots/spaces.');
10
+ return name;
11
+ };
12
+
13
+ export const readPageAttachments = async contentFile => {
14
+ const directory = path.join(path.dirname(contentFile.contentPath), 'downloads');
15
+ const info = await lstat(directory).catch(error => { if (error.code === 'ENOENT') return null; throw error; });
16
+ if (!info) return { files: [], diagnostics: [] };
17
+ const diagnostics = [], files = [];
18
+ const problem = message => diagnostics.push({ severity: 'error', code: 'invalid-attachment', contentFile, message, fix: 'Keep regular files directly in the page’s downloads/ folder; remove unsupported entries.' });
19
+ if (!info.isDirectory() || info.isSymbolicLink()) { problem(`${directory} must be a real directory.`); return { files, diagnostics }; }
20
+ const names = new Set();
21
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
22
+ if (entry.name === '.DS_Store' || entry.name.startsWith('._')) continue;
23
+ const filePath = path.join(directory, entry.name);
24
+ try { validateAttachmentName(entry.name); } catch (error) { problem(`${filePath}: ${error.message}`); continue; }
25
+ if (!entry.isFile()) { problem(`${filePath}: attachment subfolders and symbolic links are not supported.`); continue; }
26
+ const key = entry.name.normalize('NFC').toLowerCase();
27
+ if (names.has(key)) problem(`${directory}: attachment filenames must be unique regardless of case or Unicode normalization.`);
28
+ names.add(key);
29
+ files.push({ filePath, filename: entry.name, contentPath: contentFile.contentPath, ownerPathname: getSiteNodePathname(contentFile), pathname: attachmentPathname(getSiteNodePathname(contentFile), entry.name) });
30
+ }
31
+ return { files, diagnostics };
32
+ };
33
+ export const getSiteAttachments = async siteStructure => {
34
+ const results = await Promise.all(siteStructure.contentFiles.map(readPageAttachments));
35
+ return { files: results.flatMap(result => result.files), diagnostics: results.flatMap(result => result.diagnostics) };
36
+ };
37
+
38
+ // Explicit downloads paths identify page-owned files. Bare names retain the
39
+ // existing shorthand rules, including ambiguity with page/public links.
40
+ export const parseLocalAttachmentTarget = target => {
41
+ const raw = target.split(/[?#]/, 1)[0];
42
+ if (!raw || /^[a-z][a-z\d+.-]*:/i.test(raw)) return null;
43
+ const prefix = raw.match(/^(?:\.\/)?downloads\//)?.[0] ?? '';
44
+ try {
45
+ const filename = decodeURIComponent(raw.slice(prefix.length));
46
+ validateAttachmentName(filename);
47
+ return { filename, prefix };
48
+ } catch { return null; }
49
+ };
50
+ export const localAttachmentName = target => parseLocalAttachmentTarget(target)?.filename ?? null;
51
+ export const resolveAttachment = (target, sourcePathname, files, resolvedPathname) => {
52
+ const name = localAttachmentName(target);
53
+ return name ? files.find(file => file.ownerPathname === sourcePathname && file.filename === name)
54
+ : files.find(file => file.pathname === resolvedPathname);
55
+ };
56
+ export const attachmentHref = (file, target) => encodeAttachmentPath(file.pathname) + (target.match(/[?#].*$/)?.[0] ?? '');
57
+
58
+ export const attachmentOutputIssues = ({ files, pages, publicFiles, generatedRoutes }) =>
59
+ publicationIssues({ attachments: files, pages, publicFiles, generatedRoutes });
@@ -1,3 +1,4 @@
1
+ import { extractMarkdownImageReferences, markdownImageDiagnostic } from './markdown-images.mjs';
1
2
  import { getCodeFenceMetadataDiagnostics } from './code-fence-metadata.mjs';
2
3
  import { parseContentTabs } from './content-tabs.mjs';
3
4
  import { getDetailsHeadingDiagnostics } from './details-headings.mjs';
@@ -11,7 +12,6 @@ import {
11
12
  } from './markdown-links.mjs';
12
13
  import {
13
14
  extractInlineNoteDiagnostics,
14
- extractMarkdownImageReferences,
15
15
  extractNornaMarkdownBlockDiagnostics,
16
16
  getNornaBlockImageReferences,
17
17
  nornaBlockTypes,
@@ -191,6 +191,7 @@ const createRegion = ({
191
191
  tableErrors,
192
192
  tabResult,
193
193
  pageNotes,
194
+ imageReferences,
194
195
  }) => {
195
196
  const endOffset = nextHeading?.index ?? source.length;
196
197
  const markdown = source.slice(heading.index, endOffset).trimEnd();
@@ -201,10 +202,7 @@ const createRegion = ({
201
202
  });
202
203
  const inRegion = (offset) => offset >= heading.index && offset < endOffset;
203
204
  const fieldNoteErrors = getStructuredSidenoteDiagnostics(blockResult.blocks, { label, regionOffset: heading.index });
204
- const markdownImages = extractMarkdownImageReferences(markdown).map((reference) => ({
205
- ...reference,
206
- line: regionLineOffset + reference.line,
207
- }));
205
+ const markdownImages = imageReferences.filter((reference) => inRegion(reference.range.start));
208
206
  const regionHeadings = headings.filter((candidate) => (
209
207
  candidate.index >= heading.index
210
208
  && candidate.index < endOffset
@@ -226,7 +224,7 @@ const createRegion = ({
226
224
  kind: heading.depth === 1 ? 'page-intro' : 'section',
227
225
  bodyLine: heading.line,
228
226
  line: lineOffset + heading.line,
229
- managedImages: getNornaBlockImageReferences(blockResult.blocks),
227
+ managedImages: [...getNornaBlockImageReferences(blockResult.blocks), ...markdownImages.filter((reference) => reference.kind === 'managed')],
230
228
  markdown,
231
229
  markdownImages,
232
230
  notes: pageNotes.notes.filter((note) => inRegion(note.referenceOffset)),
@@ -246,6 +244,7 @@ export const parsePageMarkdown = async (markdown, options = {}) => {
246
244
  const tabResult = parseContentTabs(source, { label, lineOffset });
247
245
  const pageNotes = extractInlineNoteDiagnostics(source, { label, lineOffset, tabResult });
248
246
  const { headings, tree } = await getMarkdownHeadings(tabResult.maskedSource);
247
+ const imageReferences = extractMarkdownImageReferences({ source, tree, lineOffset });
249
248
  const detailsDiagnostics = getDetailsHeadingDiagnostics(tree, { source: tabResult.maskedSource, label, lineOffset });
250
249
  const calloutErrors = getSemanticCalloutDiagnostics(tree, { label, lineOffset });
251
250
  const codeFenceErrors = getCodeFenceMetadataDiagnostics(tree, {
@@ -271,6 +270,7 @@ export const parsePageMarkdown = async (markdown, options = {}) => {
271
270
  pageNotes,
272
271
  tableErrors,
273
272
  tabResult,
273
+ imageReferences,
274
274
  }));
275
275
  const headingIssues = getHeadingIdentifierIssues(headings);
276
276
  const headingDiagnostics = getHeadingDiagnostics(headings, lineOffset);
@@ -346,6 +346,7 @@ export const parsePageMarkdown = async (markdown, options = {}) => {
346
346
  return {
347
347
  blocks,
348
348
  diagnostics: [
349
+ ...imageReferences.filter((reference) => reference.kind === 'invalid').map((reference) => markdownImageDiagnostic(reference, label)),
349
350
  ...detailsDiagnostics,
350
351
  ...structureDiagnostics,
351
352
  ...headingDiagnostics,
@@ -363,7 +364,7 @@ export const parsePageMarkdown = async (markdown, options = {}) => {
363
364
  lineOffset,
364
365
  links,
365
366
  managedImages: regions.flatMap((region) => region.managedImages),
366
- markdownImages: regions.flatMap((region) => region.markdownImages),
367
+ markdownImages: imageReferences,
367
368
  navigationHeadings,
368
369
  notes: regions.flatMap((region) => region.notes),
369
370
  noteDiagnostics,
@@ -395,8 +396,11 @@ export const parsePageMarkdownSource = async (source, options = {}) => {
395
396
  };
396
397
  };
397
398
 
399
+ const mapImage = (reference) => reference.blockType === 'markdown-image' ? { ...reference, range: toOriginalRange(reference.range), targetRange: toOriginalRange(reference.targetRange) } : reference;
398
400
  return {
399
401
  ...model,
402
+ markdownImages: model.markdownImages.map(mapImage),
403
+ managedImages: model.managedImages.map(mapImage),
400
404
  bodyOffset: normalization.originalOffsets[split.bodyOffset] ?? split.bodyOffset,
401
405
  frontmatter: split.frontmatter,
402
406
  frontmatterUnclosed: split.frontmatterUnclosed,