@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.
- package/README.md +8 -5
- package/astro.config.mjs +14 -2
- package/package.json +10 -8
- package/schemas/config.schema.json +80 -65
- package/schemas/content-frontmatter.schema.json +15 -8
- package/schemas/manifest.json +1 -1
- package/schemas/page-theme.schema.json +237 -237
- package/schemas/root-theme.schema.json +237 -237
- package/schemas/sitewide-content.schema.json +18 -18
- package/schemas/theme.schema.json +237 -237
- package/scripts/add-site-node.mjs +29 -100
- package/scripts/dev-local.mjs +34 -7
- package/scripts/generate-images.mjs +83 -65
- package/scripts/generate-search-index.mjs +3 -2
- package/scripts/lib/attachment-render.mjs +23 -0
- package/scripts/lib/dev-server-identity.mjs +30 -0
- package/scripts/lib/documentation-routes.json +63 -62
- package/scripts/lib/docusaurus-migration-audit.mjs +9 -9
- package/scripts/lib/editor-attachments.mjs +69 -0
- package/scripts/lib/editor-file-policy.mjs +2 -0
- package/scripts/lib/editor-language-service.mjs +21 -18
- package/scripts/lib/editor-page-addresses.mjs +5 -3
- package/scripts/lib/editor-page-placement.mjs +28 -98
- package/scripts/lib/editor-resource-actions.mjs +216 -0
- package/scripts/lib/editor-site-editing-tree.mjs +44 -9
- package/scripts/lib/editor-site-files.mjs +3 -2
- package/scripts/lib/editor-site-links.mjs +6 -4
- package/scripts/lib/editor-site-preview.mjs +149 -0
- package/scripts/lib/editor-site-tree.mjs +10 -3
- package/scripts/lib/editor-source-files.mjs +1 -1
- package/scripts/lib/logo-assets.mjs +2 -1
- package/scripts/lib/markdown-images.mjs +59 -0
- package/scripts/lib/markdown-links.mjs +29 -9
- package/scripts/lib/norna-markdown-blocks.mjs +0 -57
- package/scripts/lib/norna-markdown-render-plugin.mjs +29 -0
- package/scripts/lib/page-aliases.mjs +5 -0
- package/scripts/lib/page-attachments.mjs +59 -0
- package/scripts/lib/page-markdown.mjs +11 -7
- package/scripts/lib/page-model.mjs +8 -22
- package/scripts/lib/page-move-apply.mjs +6 -4
- package/scripts/lib/page-move-plan.mjs +37 -19
- package/scripts/lib/page-order-source.mjs +22 -0
- package/scripts/lib/page-order.mjs +45 -0
- package/scripts/lib/page-placement-cli.mjs +50 -0
- package/scripts/lib/project-config.mjs +3 -0
- package/scripts/lib/publication-paths.mjs +72 -0
- package/scripts/lib/schema-definitions.mjs +4 -0
- package/scripts/lib/schema-editor-metadata.mjs +4 -1
- package/scripts/lib/site-content.mjs +1 -1
- package/scripts/lib/site-link-graph.mjs +88 -18
- package/scripts/lib/site-node-create.mjs +47 -34
- package/scripts/lib/site-paths.mjs +2 -2
- package/scripts/lib/site-source-format.mjs +12 -0
- package/scripts/lib/site-structure.mjs +14 -13
- package/scripts/lib/site-upgrade.mjs +2 -0
- package/scripts/lib/social-image-assets.mjs +2 -1
- package/scripts/lib/source-files.mjs +1 -1
- package/scripts/lib/structured-blocks.mjs +3 -1
- package/scripts/move-site-page.mjs +46 -133
- package/scripts/sync-content-sections.mjs +8 -10
- package/scripts/sync-site-public.mjs +44 -66
- package/src/components/ContentBlocks.astro +2 -1
- package/src/components/ImageStack.astro +7 -5
- package/src/components/SearchPage.astro +2 -1
- package/src/components/SitePage.astro +1 -0
- package/src/components/SiteSection.astro +1 -0
- package/src/content.config.ts +1 -4
- package/src/lib/imagePresentation.ts +3 -0
- package/src/lib/markdownImages.ts +101 -0
- package/src/lib/sectionContent.ts +64 -19
- package/src/lib/sitePages.ts +4 -30
- package/src/lib/sitePublicAssets.ts +2 -1
- package/src/pages/[...slug].astro +2 -1
- package/src/styles/area-navigation.css +14 -5
- package/src/styles/content.css +6 -0
- package/src/styles/media.css +24 -0
- 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:
|
|
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
|
|
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('&', '&').replaceAll('"', '"').replaceAll('<', '<').replaceAll('>', '>');
|
|
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 =
|
|
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:
|
|
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,
|