@janga/norna 0.7.24 → 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.
- package/README.md +2 -2
- package/astro.config.mjs +2 -0
- package/package.json +14 -2
- package/schemas/category.schema.json +2 -2
- package/schemas/config.schema.json +56 -21
- package/schemas/content-frontmatter.schema.json +7 -7
- package/schemas/page-theme.schema.json +26 -26
- package/schemas/sitewide-content.schema.json +18 -18
- package/schemas/theme.schema.json +241 -241
- package/scripts/check-config.mjs +5 -1
- package/scripts/dev-local.mjs +106 -11
- package/scripts/init-site.mjs +2 -0
- package/scripts/lib/code-fence-metadata.mjs +11 -6
- package/scripts/lib/edit-source-link.mjs +53 -0
- package/scripts/lib/navigation-model.mjs +1 -1
- package/scripts/lib/norna-markdown-blocks.mjs +91 -0
- package/scripts/lib/project-config.mjs +42 -12
- package/scripts/lib/schema-definitions.mjs +10 -2
- package/scripts/lib/schema-editor-metadata.mjs +20 -3
- package/scripts/lib/schema-value-definitions.mjs +3 -0
- package/scripts/lib/site-paths.mjs +14 -2
- package/scripts/lib/table-render-plugin.mjs +33 -0
- package/src/components/CardList.astro +1 -0
- package/src/components/CodeBlockCopyScript.astro +2 -1
- package/src/components/ImageCarousel.astro +4 -1
- package/src/components/ImageStack.astro +34 -9
- package/src/components/ImageStackEnhancement.astro +331 -0
- package/src/components/PageContentsNavigation.astro +2 -3
- package/src/components/SectionNavigationScript.astro +45 -7
- package/src/components/SiteNavigation.astro +73 -49
- package/src/components/SitePage.astro +38 -23
- package/src/components/SiteTreeNavigation.astro +3 -0
- package/src/components/TableOverflowScript.astro +251 -0
- package/src/components/TreeNavigationScript.astro +78 -22
- package/src/layouts/BaseLayout.astro +31 -5
- package/src/lib/generatedImages.ts +18 -0
- package/src/lib/sectionContent.ts +4 -27
- package/src/lib/sitePages.ts +6 -3
- package/src/styles/content.css +298 -23
- package/src/styles/media.css +187 -3
- package/src/styles/navigation.css +168 -3
- package/src/styles/page-layout.css +48 -16
- package/src/styles/responsive.css +67 -20
package/scripts/check-config.mjs
CHANGED
|
@@ -76,7 +76,11 @@ try {
|
|
|
76
76
|
console.log('Config check passed.');
|
|
77
77
|
console.log(`Site URL: ${projectConfig.site.url}`);
|
|
78
78
|
console.log(`Base path: ${projectConfig.site.basePath}`);
|
|
79
|
-
|
|
79
|
+
const editLinkDestinations = [
|
|
80
|
+
projectConfig.editLink?.localEditor ? `local ${projectConfig.editLink.localEditor}` : null,
|
|
81
|
+
projectConfig.editLink?.baseUrl ? `remote ${projectConfig.editLink.baseUrl}` : null,
|
|
82
|
+
].filter(Boolean);
|
|
83
|
+
console.log(`Edit links: ${editLinkDestinations.join(', ') || '(disabled)'}`);
|
|
80
84
|
console.log(`Theme preset: ${themeConfig.preset ?? '(none)'}`);
|
|
81
85
|
console.log(`Page width: ${projectConfig.layout.pageWidth}`);
|
|
82
86
|
console.log(`Gutter: desktop ${projectConfig.layout.gutter.desktop}, mobile ${projectConfig.layout.gutter.mobile}`);
|
package/scripts/dev-local.mjs
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
62
|
+
const readJsonFile = async (filePath) => {
|
|
55
63
|
try {
|
|
56
|
-
return JSON.parse(await readFile(
|
|
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
|
-
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
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
|
|
package/scripts/init-site.mjs
CHANGED
|
@@ -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 = `
|
|
@@ -210,12 +210,17 @@ export const nornaCodeFenceTransformer = {
|
|
|
210
210
|
tagName: 'figure',
|
|
211
211
|
properties: { className: ['norna-code-example'] },
|
|
212
212
|
children: [
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
+
},
|
|
219
224
|
node,
|
|
220
225
|
],
|
|
221
226
|
};
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { pathToFileURL } from 'node:url';
|
|
3
|
+
|
|
4
|
+
export const localEditorNames = Object.freeze(['vscode']);
|
|
5
|
+
|
|
1
6
|
const assertHttpUrl = (value, label) => {
|
|
2
7
|
if (typeof value !== 'string' || value.trim() === '') {
|
|
3
8
|
throw new Error(`${label} must be a non-empty absolute URL.`);
|
|
@@ -47,3 +52,51 @@ export const getEditSourceUrl = ({ baseUrl, sourcePath }) => {
|
|
|
47
52
|
const encodedPath = segments.map((segment) => encodeURIComponent(segment)).join('/');
|
|
48
53
|
return new URL(encodedPath, normalizedBaseUrl).href;
|
|
49
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
|
+
};
|
|
@@ -75,7 +75,7 @@ export const resolvePageContentsPlacement = ({
|
|
|
75
75
|
Math.max(maximum, getNodeDepth(node))
|
|
76
76
|
), 0);
|
|
77
77
|
const hasPageContents = headingCount >= 2;
|
|
78
|
-
const placement = navigationMode !== 'tree' || !hasPageContents
|
|
78
|
+
const placement = currentPage?.isHome || navigationMode !== 'tree' || !hasPageContents
|
|
79
79
|
? 'none'
|
|
80
80
|
: activeBranchDepth <= 2
|
|
81
81
|
? 'page-tree'
|
|
@@ -607,6 +607,97 @@ export const extractNornaMarkdownBlockDiagnostics = (markdown, options = {}) =>
|
|
|
607
607
|
return { blocks, errors };
|
|
608
608
|
};
|
|
609
609
|
|
|
610
|
+
const stripRenderedTags = (value) => value.replace(/<[^>]*>/g, '');
|
|
611
|
+
const decodeRenderedEntities = (value) => value
|
|
612
|
+
.replace(/&/g, '&')
|
|
613
|
+
.replace(/</g, '<')
|
|
614
|
+
.replace(/>/g, '>')
|
|
615
|
+
.replace(/"/g, '"')
|
|
616
|
+
.replace(/'/g, "'");
|
|
617
|
+
const normalizeRenderedBlockSource = (value) => value.replace(/\r\n?/g, '\n').trim();
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Splits rendered HTML at Norna markers. During an Astro content hot reload,
|
|
621
|
+
* custom code nodes can occasionally arrive as highlighted code instead of
|
|
622
|
+
* their marker; match that output against the already validated source block.
|
|
623
|
+
*
|
|
624
|
+
* @template {{ type: string, source?: string }} Block
|
|
625
|
+
* @param {string} html
|
|
626
|
+
* @param {Block[]} blocks
|
|
627
|
+
* @returns {Array<{ type: 'html', html: string } | Block>}
|
|
628
|
+
*/
|
|
629
|
+
export const splitNornaRenderedBlocks = (html, blocks) => {
|
|
630
|
+
const markerRegex = /<norna-block\s+data-index="(\d+)"\s*><\/norna-block>/g;
|
|
631
|
+
const markerMatches = [...html.matchAll(markerRegex)];
|
|
632
|
+
let replacements;
|
|
633
|
+
|
|
634
|
+
if (markerMatches.length > 0) {
|
|
635
|
+
const seen = new Set();
|
|
636
|
+
replacements = markerMatches.map((match) => {
|
|
637
|
+
const blockIndex = Number.parseInt(match[1] ?? '', 10);
|
|
638
|
+
if (!blocks[blockIndex]) {
|
|
639
|
+
throw new Error(`Rendered Norna block ${blockIndex + 1} has no matching parsed block.`);
|
|
640
|
+
}
|
|
641
|
+
if (seen.has(blockIndex)) {
|
|
642
|
+
throw new Error(`Rendered Norna block ${blockIndex + 1} appears more than once.`);
|
|
643
|
+
}
|
|
644
|
+
seen.add(blockIndex);
|
|
645
|
+
return { match, blockIndex };
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
if (seen.size !== blocks.length) {
|
|
649
|
+
throw new Error(`Rendered Markdown contains ${seen.size} Norna block markers, but ${blocks.length} blocks were parsed.`);
|
|
650
|
+
}
|
|
651
|
+
} else {
|
|
652
|
+
const codeRegex = /<pre\b[^>]*>\s*<code\b[^>]*>([\s\S]*?)<\/code>\s*<\/pre>/g;
|
|
653
|
+
const candidates = [...html.matchAll(codeRegex)].map((match) => ({
|
|
654
|
+
match,
|
|
655
|
+
source: normalizeRenderedBlockSource(decodeRenderedEntities(stripRenderedTags(match[1] ?? ''))),
|
|
656
|
+
}));
|
|
657
|
+
const solutions = [];
|
|
658
|
+
const findSolutions = (blockIndex, candidateIndex, selected) => {
|
|
659
|
+
if (solutions.length > 1) return;
|
|
660
|
+
if (blockIndex === blocks.length) {
|
|
661
|
+
solutions.push([...selected]);
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
const source = normalizeRenderedBlockSource(blocks[blockIndex]?.source ?? '');
|
|
666
|
+
for (let index = candidateIndex; index < candidates.length; index += 1) {
|
|
667
|
+
if (candidates[index].source !== source) continue;
|
|
668
|
+
selected.push(index);
|
|
669
|
+
findSolutions(blockIndex + 1, index + 1, selected);
|
|
670
|
+
selected.pop();
|
|
671
|
+
}
|
|
672
|
+
};
|
|
673
|
+
findSolutions(0, 0, []);
|
|
674
|
+
|
|
675
|
+
if (solutions.length === 0) {
|
|
676
|
+
throw new Error(`Rendered Markdown contains 0 Norna block markers, but ${blocks.length} blocks were parsed.`);
|
|
677
|
+
}
|
|
678
|
+
if (solutions.length > 1) {
|
|
679
|
+
throw new Error('Rendered Markdown contains ambiguous plain-code matches for Norna blocks. Restart the local preview to rebuild the Markdown content cache.');
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
replacements = solutions[0].map((candidateIndex, blockIndex) => ({
|
|
683
|
+
match: candidates[candidateIndex].match,
|
|
684
|
+
blockIndex,
|
|
685
|
+
}));
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
const result = [];
|
|
689
|
+
let cursor = 0;
|
|
690
|
+
for (const { match, blockIndex } of replacements) {
|
|
691
|
+
const start = match.index ?? 0;
|
|
692
|
+
if (start > cursor) result.push({ type: 'html', html: html.slice(cursor, start) });
|
|
693
|
+
result.push(blocks[blockIndex]);
|
|
694
|
+
cursor = start + match[0].length;
|
|
695
|
+
}
|
|
696
|
+
if (cursor < html.length) result.push({ type: 'html', html: html.slice(cursor) });
|
|
697
|
+
|
|
698
|
+
return result.filter((block) => block.type !== 'html' || block.html.trim());
|
|
699
|
+
};
|
|
700
|
+
|
|
610
701
|
const maskFencedCodeBlocks = (markdown) => {
|
|
611
702
|
const lines = normalizeLines(markdown);
|
|
612
703
|
const maskedLines = [];
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
siteConfigPath,
|
|
14
14
|
siteThemeLabel,
|
|
15
15
|
} from './site-paths.mjs';
|
|
16
|
-
import { normalizeEditLinkBaseUrl } from './edit-source-link.mjs';
|
|
16
|
+
import { localEditorNames, normalizeEditLinkBaseUrl } from './edit-source-link.mjs';
|
|
17
17
|
import { readThemeConfig } from './theme-config.mjs';
|
|
18
18
|
import { resolveThemeConfig } from './theme-presets.mjs';
|
|
19
19
|
import { parseYamlConfig } from './yaml-config.mjs';
|
|
@@ -213,6 +213,7 @@ const localeLabels = Object.freeze({
|
|
|
213
213
|
calloutNote: 'Note',
|
|
214
214
|
calloutTip: 'Tip',
|
|
215
215
|
calloutWarning: 'Warning',
|
|
216
|
+
closeNavigation: 'Close navigation',
|
|
216
217
|
editSource: 'Edit this page',
|
|
217
218
|
displaySettings: 'Display',
|
|
218
219
|
focusReading: 'Focus reading',
|
|
@@ -227,6 +228,11 @@ const localeLabels = Object.freeze({
|
|
|
227
228
|
built: 'Built',
|
|
228
229
|
images: 'Images',
|
|
229
230
|
imageCarousel: 'image carousel',
|
|
231
|
+
imageInspection: 'Image inspection',
|
|
232
|
+
imageInspectionActualSize: 'Show actual size',
|
|
233
|
+
imageInspectionClose: 'Close image inspection',
|
|
234
|
+
imageInspectionFit: 'Fit image to window',
|
|
235
|
+
inspectImage: 'Inspect image: {description}',
|
|
230
236
|
navigationCollapsedAll: 'All navigation items collapsed.',
|
|
231
237
|
navigationCollapseAll: 'Collapse all',
|
|
232
238
|
navigationControls: 'Navigation tree controls',
|
|
@@ -242,6 +248,7 @@ const localeLabels = Object.freeze({
|
|
|
242
248
|
nextImage: 'Next image',
|
|
243
249
|
nextPage: 'Next page',
|
|
244
250
|
note: 'Note',
|
|
251
|
+
openInVsCode: 'Open in VS Code',
|
|
245
252
|
notFound: 'Page not found',
|
|
246
253
|
notFoundText: 'The requested page does not exist or may have moved.',
|
|
247
254
|
pageMoved: 'Page moved',
|
|
@@ -260,6 +267,10 @@ const localeLabels = Object.freeze({
|
|
|
260
267
|
siteBanners: 'Site notices',
|
|
261
268
|
siteNavigation: 'Pages',
|
|
262
269
|
skipToContent: 'Skip to content',
|
|
270
|
+
tableColumns: 'Table columns',
|
|
271
|
+
tableNextColumns: 'Show next columns',
|
|
272
|
+
tableOverflowDescription: 'More table columns are available horizontally.',
|
|
273
|
+
tablePreviousColumns: 'Show previous columns',
|
|
263
274
|
}),
|
|
264
275
|
sv: Object.freeze({
|
|
265
276
|
breadcrumb: 'Brödsmulor',
|
|
@@ -276,6 +287,7 @@ const localeLabels = Object.freeze({
|
|
|
276
287
|
calloutNote: 'Notera',
|
|
277
288
|
calloutTip: 'Tips',
|
|
278
289
|
calloutWarning: 'Varning',
|
|
290
|
+
closeNavigation: 'Stäng navigationen',
|
|
279
291
|
editSource: 'Redigera den här sidan',
|
|
280
292
|
displaySettings: 'Visning',
|
|
281
293
|
focusReading: 'Fokuserad läsning',
|
|
@@ -290,6 +302,11 @@ const localeLabels = Object.freeze({
|
|
|
290
302
|
built: 'Byggd',
|
|
291
303
|
images: 'Bilder',
|
|
292
304
|
imageCarousel: 'bildkarusell',
|
|
305
|
+
imageInspection: 'Bildgranskning',
|
|
306
|
+
imageInspectionActualSize: 'Visa faktisk storlek',
|
|
307
|
+
imageInspectionClose: 'Stäng bildgranskning',
|
|
308
|
+
imageInspectionFit: 'Anpassa bilden till fönstret',
|
|
309
|
+
inspectImage: 'Granska bild: {description}',
|
|
293
310
|
navigationCollapsedAll: 'Alla navigationsposter har fällts ihop.',
|
|
294
311
|
navigationCollapseAll: 'Fäll ihop alla',
|
|
295
312
|
navigationControls: 'Kontroller för navigationsträdet',
|
|
@@ -305,6 +322,7 @@ const localeLabels = Object.freeze({
|
|
|
305
322
|
nextImage: 'Nästa bild',
|
|
306
323
|
nextPage: 'Nästa sida',
|
|
307
324
|
note: 'Not',
|
|
325
|
+
openInVsCode: 'Öppna i VS Code',
|
|
308
326
|
notFound: 'Sidan hittades inte',
|
|
309
327
|
notFoundText: 'Den begärda sidan finns inte eller kan ha flyttats.',
|
|
310
328
|
pageMoved: 'Sidan har flyttats',
|
|
@@ -323,6 +341,10 @@ const localeLabels = Object.freeze({
|
|
|
323
341
|
siteBanners: 'Meddelanden',
|
|
324
342
|
siteNavigation: 'Sidor',
|
|
325
343
|
skipToContent: 'Hoppa till innehållet',
|
|
344
|
+
tableColumns: 'Tabellkolumner',
|
|
345
|
+
tableNextColumns: 'Visa nästa kolumner',
|
|
346
|
+
tableOverflowDescription: 'Fler tabellkolumner är tillgängliga i sidled.',
|
|
347
|
+
tablePreviousColumns: 'Visa föregående kolumner',
|
|
326
348
|
}),
|
|
327
349
|
});
|
|
328
350
|
|
|
@@ -541,22 +563,30 @@ export const resolveEditLinkConfig = (config, sourceLabel = siteConfigLabel) =>
|
|
|
541
563
|
if (config.editLink === undefined) return null;
|
|
542
564
|
|
|
543
565
|
const rawEditLink = assertObject(config.editLink, 'editLink', sourceLabel);
|
|
544
|
-
|
|
545
|
-
throw new Error(`editLink.baseUrl is required when editLink is configured in ${sourceLabel}.`);
|
|
546
|
-
}
|
|
547
|
-
const unknownKeys = Object.keys(rawEditLink).filter((key) => key !== 'baseUrl');
|
|
566
|
+
const unknownKeys = Object.keys(rawEditLink).filter((key) => !['baseUrl', 'localEditor'].includes(key));
|
|
548
567
|
if (unknownKeys.length > 0) {
|
|
549
568
|
throw new Error(`editLink.${unknownKeys[0]} is not a valid setting in ${sourceLabel}.`);
|
|
550
569
|
}
|
|
570
|
+
if (!Object.hasOwn(rawEditLink, 'baseUrl') && !Object.hasOwn(rawEditLink, 'localEditor')) {
|
|
571
|
+
throw new Error(`editLink must specify baseUrl, localEditor, or both in ${sourceLabel}.`);
|
|
572
|
+
}
|
|
551
573
|
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
574
|
+
let baseUrl = null;
|
|
575
|
+
if (Object.hasOwn(rawEditLink, 'baseUrl')) {
|
|
576
|
+
try {
|
|
577
|
+
baseUrl = normalizeEditLinkBaseUrl(rawEditLink.baseUrl, 'editLink.baseUrl');
|
|
578
|
+
} catch (error) {
|
|
579
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
580
|
+
throw new Error(`${message.replace(/\.$/, '')} in ${sourceLabel}.`);
|
|
581
|
+
}
|
|
559
582
|
}
|
|
583
|
+
|
|
584
|
+
return Object.freeze({
|
|
585
|
+
baseUrl,
|
|
586
|
+
localEditor: Object.hasOwn(rawEditLink, 'localEditor')
|
|
587
|
+
? readEnum(rawEditLink, 'localEditor', 'editLink', localEditorNames, undefined, sourceLabel)
|
|
588
|
+
: null,
|
|
589
|
+
});
|
|
560
590
|
};
|
|
561
591
|
|
|
562
592
|
export const projectConfig = Object.freeze({
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'astro/zod';
|
|
2
|
+
import { localEditorNames } from './edit-source-link.mjs';
|
|
2
3
|
import { imagePresentationNames } from './image-presentation.mjs';
|
|
3
4
|
import { navigationModeNames } from './navigation-model.mjs';
|
|
4
5
|
import { presentationPaletteNames } from './presentation-palette-metadata.mjs';
|
|
@@ -51,8 +52,15 @@ const editLink = z.object({
|
|
|
51
52
|
return false;
|
|
52
53
|
}
|
|
53
54
|
}, 'Use an absolute http or https URL without credentials, a query string, or a fragment.')
|
|
54
|
-
.describe('Absolute edit URL prefix containing the repository, branch, and any repository subdirectory.')
|
|
55
|
-
|
|
55
|
+
.describe('Absolute edit URL prefix containing the repository, branch, and any repository subdirectory.')
|
|
56
|
+
.optional(),
|
|
57
|
+
localEditor: z.enum(localEditorNames)
|
|
58
|
+
.optional()
|
|
59
|
+
.describe('Editor used by source links in a development preview opened through a loopback address.'),
|
|
60
|
+
}).strict().refine(
|
|
61
|
+
(value) => value.baseUrl !== undefined || value.localEditor !== undefined,
|
|
62
|
+
'Specify baseUrl, localEditor, or both.',
|
|
63
|
+
).describe('Optional local and remote links from rendered pages to their Markdown source files.');
|
|
56
64
|
const createLineHeight = (minimum, role) => z.number()
|
|
57
65
|
.min(minimum, `Use a unitless ${role} line height of at least ${minimum}.`)
|
|
58
66
|
.max(3, `Use a unitless ${role} line height of at most 3.`)
|
|
@@ -160,15 +160,32 @@ const addConfigHelp = (jsonSchema) => {
|
|
|
160
160
|
documentationLink('Language reference', 'configuration.md', 'language'),
|
|
161
161
|
], ['en', 'sv', 'en-GB', 'sv-SE']);
|
|
162
162
|
addHelp(jsonSchema, 'editLink', [
|
|
163
|
-
yamlExample('editLink:\n baseUrl: https://github.com/owner/repository/edit/main/'),
|
|
164
|
-
'
|
|
163
|
+
yamlExample('editLink:\n localEditor: vscode\n baseUrl: https://github.com/owner/repository/edit/main/'),
|
|
164
|
+
'Links each rendered page to its `content.md` source. A loopback development preview uses the explicitly selected local editor; published output and LAN previews use the optional remote base URL. Specify either destination or both.',
|
|
165
165
|
documentationLink('Edit-link reference', 'configuration.md', 'edit-link'),
|
|
166
166
|
]);
|
|
167
|
+
const editLink = schemaProperty(jsonSchema, 'editLink');
|
|
168
|
+
editLink.anyOf = [{ required: ['localEditor'] }, { required: ['baseUrl'] }];
|
|
169
|
+
addSnippets(jsonSchema, 'editLink', [schemaSnippet({
|
|
170
|
+
label: 'Local and remote source links',
|
|
171
|
+
body: {
|
|
172
|
+
localEditor: 'vscode',
|
|
173
|
+
baseUrl: 'https://github.com/${1:owner}/${2:repository}/edit/${3:main}/',
|
|
174
|
+
},
|
|
175
|
+
description: 'Open page source in VS Code during same-computer development and on the remote source host elsewhere.',
|
|
176
|
+
file: 'configuration.md',
|
|
177
|
+
anchor: 'edit-link',
|
|
178
|
+
})]);
|
|
167
179
|
addHelp(jsonSchema, 'editLink.baseUrl', [
|
|
168
180
|
yamlExample('editLink:\n baseUrl: https://github.com/owner/repository/edit/main/'),
|
|
169
|
-
'
|
|
181
|
+
'Optional absolute remote edit URL prefix. Include any repository subdirectory and branch. Norna adds the project-relative path to each page\'s `content.md` file. This destination is used by published output and LAN previews.',
|
|
170
182
|
documentationLink('Edit-link reference', 'configuration.md', 'edit-link'),
|
|
171
183
|
], ['https://github.com/owner/repository/edit/main/']);
|
|
184
|
+
addHelp(jsonSchema, 'editLink.localEditor', [
|
|
185
|
+
yamlExample('editLink:\n localEditor: vscode'),
|
|
186
|
+
'Optional editor for a development preview opened through localhost or another loopback address. `vscode` opens the absolute page source path through VS Code\'s registered URL handler. The setting is never emitted as a local path in published output.',
|
|
187
|
+
documentationLink('Edit-link reference', 'configuration.md', 'edit-link'),
|
|
188
|
+
], ['vscode']);
|
|
172
189
|
addHelp(jsonSchema, 'navigation', [
|
|
173
190
|
yamlExample('navigation:\n mode: automatic'),
|
|
174
191
|
'Sets the site-wide navigation policy. `automatic` uses sections for one page, top navigation for a flat multi-page site, and a stable left rail throughout a hierarchical site.',
|
|
@@ -32,6 +32,9 @@ export const schemaValueDefinitions = Object.freeze([
|
|
|
32
32
|
en: option('English', 'Use Norna\'s built-in English interface text.'),
|
|
33
33
|
sv: option('Swedish', 'Use Norna\'s built-in Swedish interface text.'),
|
|
34
34
|
}),
|
|
35
|
+
definition(['vscode'], {
|
|
36
|
+
vscode: option('Visual Studio Code', 'Open the current page source in the locally installed Visual Studio Code application.'),
|
|
37
|
+
}),
|
|
35
38
|
definition(['left', 'center', 'right'], {
|
|
36
39
|
left: option('Left', 'Align text with the left edge of its text area.'),
|
|
37
40
|
center: option('Center', 'Center text within its text area.'),
|
|
@@ -5,6 +5,7 @@ import { homePageDirectory } from './site-conventions.mjs';
|
|
|
5
5
|
|
|
6
6
|
const siteDirectoryEnvName = 'NORNA_SITE_DIR';
|
|
7
7
|
const invocationRootEnvName = 'NORNA_INVOCATION_ROOT';
|
|
8
|
+
const stateDirectoryEnvName = 'NORNA_INTERNAL_STATE_DIR';
|
|
8
9
|
const defaultSiteDirectory = 'site';
|
|
9
10
|
export { homePageDirectory };
|
|
10
11
|
|
|
@@ -16,12 +17,19 @@ export const siteDirectoryEnv = siteDirectoryEnvName;
|
|
|
16
17
|
|
|
17
18
|
const normalizeSiteDirectory = (value) => String(value ?? '').trim();
|
|
18
19
|
const normalizeInvocationRoot = (value) => String(value ?? '').trim();
|
|
20
|
+
const normalizeStateDirectory = (value) => String(value ?? '').trim();
|
|
19
21
|
|
|
20
22
|
const configuredInvocationRoot = normalizeInvocationRoot(process.env[invocationRootEnvName]);
|
|
21
23
|
export const invocationRoot = configuredInvocationRoot
|
|
22
24
|
? path.resolve(configuredInvocationRoot)
|
|
23
25
|
: process.cwd();
|
|
24
26
|
|
|
27
|
+
const hasStateDirectoryEnv = Object.hasOwn(process.env, stateDirectoryEnvName);
|
|
28
|
+
const configuredStateDirectory = normalizeStateDirectory(process.env[stateDirectoryEnvName]);
|
|
29
|
+
if (hasStateDirectoryEnv && !configuredStateDirectory) {
|
|
30
|
+
throw new Error(`${stateDirectoryEnvName} must not be empty.`);
|
|
31
|
+
}
|
|
32
|
+
|
|
25
33
|
const hasSiteFilesInDirectory = (siteDir) => {
|
|
26
34
|
return (
|
|
27
35
|
existsSync(path.join(siteDir, 'config.yaml'))
|
|
@@ -126,9 +134,13 @@ export const siteHomePageDir = path.join(sitePagesDir, homePageDirectory);
|
|
|
126
134
|
export const siteContentPath = path.join(siteHomePageDir, 'content.md');
|
|
127
135
|
export const siteImagesDir = path.join(siteHomePageDir, 'images');
|
|
128
136
|
export const sitePublicDir = path.join(siteDir, 'public');
|
|
129
|
-
export const siteStateDir =
|
|
137
|
+
export const siteStateDir = configuredStateDirectory
|
|
138
|
+
? path.resolve(configuredStateDirectory)
|
|
139
|
+
: path.join(siteDir, '.norna');
|
|
130
140
|
export const astroPublicDir = path.join(siteStateDir, 'public');
|
|
131
|
-
export const astroDistDir =
|
|
141
|
+
export const astroDistDir = configuredStateDirectory
|
|
142
|
+
? path.join(siteStateDir, 'dist')
|
|
143
|
+
: path.join(siteProjectRoot, 'dist');
|
|
132
144
|
export const astroRootDir = siteStateDir;
|
|
133
145
|
export const astroCacheDir = path.join(astroRootDir, '.astro');
|
|
134
146
|
export const generatedImagesDir = path.join(astroPublicDir, 'images', 'generated');
|