@janga/norna 0.7.23 → 0.7.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -18
- package/astro.config.mjs +14 -0
- package/bin/norna-cli.mjs +6 -0
- package/package.json +25 -2
- package/schemas/category.schema.json +2 -2
- package/schemas/config.schema.json +80 -15
- 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 +246 -246
- package/scripts/build-site.mjs +1 -0
- package/scripts/check-config.mjs +16 -1
- package/scripts/dev-local.mjs +106 -11
- package/scripts/generate-search-index.mjs +57 -0
- package/scripts/init-site.mjs +3 -0
- package/scripts/lib/code-fence-metadata.mjs +228 -0
- package/scripts/lib/edit-source-link.mjs +102 -0
- package/scripts/lib/editor-language-service.mjs +32 -12
- package/scripts/lib/image-presentation.mjs +4 -0
- package/scripts/lib/navigation-model.mjs +34 -13
- package/scripts/lib/navigation-review.mjs +396 -0
- package/scripts/lib/norna-markdown-blocks.mjs +164 -26
- package/scripts/lib/norna-markdown-render-plugin.mjs +64 -1
- package/scripts/lib/page-aliases.mjs +21 -1
- package/scripts/lib/page-markdown.mjs +32 -1
- package/scripts/lib/page-move-plan.mjs +659 -0
- package/scripts/lib/presentation-palette-metadata.mjs +1 -1
- package/scripts/lib/presentation.mjs +1 -10
- package/scripts/lib/project-config.mjs +141 -5
- package/scripts/lib/public-asset-conventions.mjs +36 -2
- package/scripts/lib/schema-definitions.mjs +30 -5
- package/scripts/lib/schema-editor-metadata.mjs +36 -4
- package/scripts/lib/schema-value-definitions.mjs +4 -1
- package/scripts/lib/semantic-callouts.mjs +128 -0
- package/scripts/lib/site-content.mjs +2 -1
- package/scripts/lib/site-link-graph.mjs +33 -2
- package/scripts/lib/site-navigation-tree.mjs +85 -0
- package/scripts/lib/site-paths.mjs +14 -2
- package/scripts/lib/social-image-assets.mjs +30 -0
- package/scripts/lib/table-render-plugin.mjs +33 -0
- package/scripts/lib/theme-presets.mjs +2 -2
- package/scripts/lib/theme-profiles.mjs +0 -4
- package/scripts/move-site-page.mjs +256 -0
- package/scripts/review-navigation.mjs +32 -0
- package/scripts/sync-content-sections.mjs +67 -4
- package/scripts/sync-site-public.mjs +24 -5
- package/src/components/CardList.astro +1 -0
- package/src/components/CodeBlockCopyScript.astro +11 -5
- package/src/components/EditSourceLink.astro +17 -0
- package/src/components/ImageCarousel.astro +9 -7
- package/src/components/ImageStack.astro +34 -9
- package/src/components/ImageStackEnhancement.astro +331 -0
- package/src/components/NavigationPageTree.astro +66 -55
- package/src/components/NavigationTreeControls.astro +77 -0
- package/src/components/PageAliasRedirect.astro +1 -1
- package/src/components/PageContentsNavigation.astro +2 -3
- package/src/components/PageList.astro +33 -0
- package/src/components/PageSequenceNavigation.astro +37 -0
- package/src/components/SearchPage.astro +151 -0
- package/src/components/SectionNavigationScript.astro +45 -7
- package/src/components/SiteNavigation.astro +102 -51
- package/src/components/SitePage.astro +107 -39
- package/src/components/SiteSection.astro +11 -3
- package/src/components/SiteTreeNavigation.astro +11 -1
- package/src/components/TableOverflowScript.astro +251 -0
- package/src/components/TreeNavigationScript.astro +271 -34
- package/src/layouts/BaseLayout.astro +83 -2
- package/src/lib/generatedImages.ts +18 -0
- package/src/lib/sectionContent.ts +36 -30
- package/src/lib/siteNavigation.ts +31 -52
- package/src/lib/sitePages.ts +6 -3
- package/src/lib/sitePublicAssets.ts +1 -0
- package/src/pages/404.astro +110 -0
- package/src/pages/[...slug].astro +15 -5
- package/src/styles/content.css +538 -39
- package/src/styles/media.css +187 -8
- package/src/styles/navigation.css +199 -3
- package/src/styles/page-layout.css +481 -100
- package/src/styles/responsive.css +144 -28
- package/starters/basic/README.md +6 -19
- package/starters/basic/package.json +1 -0
- package/starters/basic/site/pages/000-home/content.md +13 -50
package/scripts/build-site.mjs
CHANGED
package/scripts/check-config.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { siteConfigLabel, sitePagesDir, sitePublicLabel, siteThemeLabel } from './lib/site-paths.mjs';
|
|
3
3
|
import { getLogoAssets, getPublicAssetInspection } from './lib/logo-assets.mjs';
|
|
4
|
-
import { logoAssetFilenames } from './lib/public-asset-conventions.mjs';
|
|
4
|
+
import { logoAssetFilenames, socialImageAssetFilenames } from './lib/public-asset-conventions.mjs';
|
|
5
|
+
import { getSocialImageAssets } from './lib/social-image-assets.mjs';
|
|
5
6
|
import { readSitewideContent } from './lib/sitewide-content.mjs';
|
|
6
7
|
import { assertSectionBackgroundPatternCompatibility } from './lib/presentation.mjs';
|
|
7
8
|
import { readThemeConfig, validatePageThemeFiles } from './lib/theme-config.mjs';
|
|
@@ -43,6 +44,7 @@ try {
|
|
|
43
44
|
}
|
|
44
45
|
}
|
|
45
46
|
const logoAssets = getLogoAssets();
|
|
47
|
+
const socialImageAssets = getSocialImageAssets();
|
|
46
48
|
const publicAssetInspection = getPublicAssetInspection();
|
|
47
49
|
const logoAssetPaths = logoAssetFilenames.map((filename) => `${sitePublicLabel}/${filename}`);
|
|
48
50
|
for (const issue of publicAssetInspection.suspicious) {
|
|
@@ -56,6 +58,13 @@ try {
|
|
|
56
58
|
].join('\n'));
|
|
57
59
|
}
|
|
58
60
|
|
|
61
|
+
if (socialImageAssets.length > 1) {
|
|
62
|
+
throw new Error([
|
|
63
|
+
`Found multiple social sharing images in ${sitePublicLabel}. Keep exactly one of ${socialImageAssetFilenames.join(', ')}.`,
|
|
64
|
+
...socialImageAssets.map(({ filename }) => `- ${sitePublicLabel}/${filename}`),
|
|
65
|
+
].join('\n'));
|
|
66
|
+
}
|
|
67
|
+
|
|
59
68
|
if (logoAssets.length === 0) {
|
|
60
69
|
if (sitewideContent.logo) {
|
|
61
70
|
throw new Error(`Site-wide logo is configured, but no logo file was found. Add exactly one of ${logoAssetPaths.join(', ')}, or remove logo.`);
|
|
@@ -67,6 +76,11 @@ try {
|
|
|
67
76
|
console.log('Config check passed.');
|
|
68
77
|
console.log(`Site URL: ${projectConfig.site.url}`);
|
|
69
78
|
console.log(`Base path: ${projectConfig.site.basePath}`);
|
|
79
|
+
const editLinkDestinations = [
|
|
80
|
+
projectConfig.editLink?.localEditor ? `local ${projectConfig.editLink.localEditor}` : null,
|
|
81
|
+
projectConfig.editLink?.baseUrl ? `remote ${projectConfig.editLink.baseUrl}` : null,
|
|
82
|
+
].filter(Boolean);
|
|
83
|
+
console.log(`Edit links: ${editLinkDestinations.join(', ') || '(disabled)'}`);
|
|
70
84
|
console.log(`Theme preset: ${themeConfig.preset ?? '(none)'}`);
|
|
71
85
|
console.log(`Page width: ${projectConfig.layout.pageWidth}`);
|
|
72
86
|
console.log(`Gutter: desktop ${projectConfig.layout.gutter.desktop}, mobile ${projectConfig.layout.gutter.mobile}`);
|
|
@@ -81,6 +95,7 @@ try {
|
|
|
81
95
|
console.log(`Font family: ${projectConfig.typography.fontFamily}`);
|
|
82
96
|
console.log(`Language: ${projectConfig.locale.lang}`);
|
|
83
97
|
console.log(`Navigation mode: ${projectConfig.navigation.mode}`);
|
|
98
|
+
console.log(`Static search: ${projectConfig.search.enabled ? 'enabled' : 'disabled'}`);
|
|
84
99
|
console.log(`Scroll behavior: ${projectConfig.navigation.scrollBehavior}`);
|
|
85
100
|
} catch (error) {
|
|
86
101
|
console.error('Config check failed.');
|
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
|
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { cp, mkdir, readdir, rm } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import * as pagefind from 'pagefind';
|
|
4
|
+
import projectConfig from './lib/project-config.mjs';
|
|
5
|
+
import {
|
|
6
|
+
astroDistDir,
|
|
7
|
+
astroPublicDir,
|
|
8
|
+
} from './lib/site-paths.mjs';
|
|
9
|
+
|
|
10
|
+
const searchDirectoryName = 'pagefind';
|
|
11
|
+
const distSearchDirectory = path.join(astroDistDir, searchDirectoryName);
|
|
12
|
+
const localSearchDirectory = path.join(astroPublicDir, searchDirectoryName);
|
|
13
|
+
|
|
14
|
+
if (!projectConfig.search.enabled) {
|
|
15
|
+
await rm(localSearchDirectory, { force: true, recursive: true });
|
|
16
|
+
console.log('Static search is disabled.');
|
|
17
|
+
} else {
|
|
18
|
+
await rm(distSearchDirectory, { force: true, recursive: true });
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
const { errors: createErrors, index } = await pagefind.createIndex();
|
|
22
|
+
if (!index || createErrors.length > 0) {
|
|
23
|
+
throw new Error(createErrors.join('\n') || 'Pagefind did not create an index.');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const { errors: indexingErrors, page_count: scannedPageCount } = await index.addDirectory({
|
|
27
|
+
path: astroDistDir,
|
|
28
|
+
});
|
|
29
|
+
if (indexingErrors.length > 0) throw new Error(indexingErrors.join('\n'));
|
|
30
|
+
if (scannedPageCount === 0) {
|
|
31
|
+
throw new Error('No rendered pages contained searchable editorial content.');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const { errors: writeErrors } = await index.writeFiles({
|
|
35
|
+
outputPath: distSearchDirectory,
|
|
36
|
+
});
|
|
37
|
+
if (writeErrors.length > 0) throw new Error(writeErrors.join('\n'));
|
|
38
|
+
const fragmentDirectory = path.join(distSearchDirectory, 'fragment');
|
|
39
|
+
const indexedPageCount = (await readdir(fragmentDirectory))
|
|
40
|
+
.filter((filename) => filename.endsWith('.pf_fragment'))
|
|
41
|
+
.length;
|
|
42
|
+
if (indexedPageCount === 0) {
|
|
43
|
+
throw new Error('No rendered pages contained searchable editorial content.');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
await rm(localSearchDirectory, { force: true, recursive: true });
|
|
47
|
+
await mkdir(astroPublicDir, { recursive: true });
|
|
48
|
+
await cp(distSearchDirectory, localSearchDirectory, { recursive: true });
|
|
49
|
+
|
|
50
|
+
console.log(`Generated static search index for ${indexedPageCount} page${indexedPageCount === 1 ? '' : 's'}.`);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
53
|
+
throw new Error(`Could not generate the static search index. ${message}`);
|
|
54
|
+
} finally {
|
|
55
|
+
await pagefind.close();
|
|
56
|
+
}
|
|
57
|
+
}
|
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 = `
|
|
@@ -165,6 +167,7 @@ const nornaScripts = {
|
|
|
165
167
|
'norna:config:check': cliCommand('config:check'),
|
|
166
168
|
'norna:content:check': cliCommand('content:check'),
|
|
167
169
|
'norna:sync': cliCommand('content:sync'),
|
|
170
|
+
'norna:navigation:review': cliCommand('navigation:review'),
|
|
168
171
|
'norna:theme:presets': cliCommand('theme:presets'),
|
|
169
172
|
'norna:theme:export': cliCommand('theme:export'),
|
|
170
173
|
'norna:typography:profiles': cliCommand('typography profiles'),
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
const titlePrefix = 'title="';
|
|
2
|
+
const lineSelectorPattern = /^\{(\d+(?:-\d+)?(?:,\d+(?:-\d+)?)*)\}$/;
|
|
3
|
+
|
|
4
|
+
const invalidMetadata = (message, fix) => ({
|
|
5
|
+
error: {
|
|
6
|
+
code: 'invalid-code-fence-metadata',
|
|
7
|
+
fix,
|
|
8
|
+
message,
|
|
9
|
+
},
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const parseTitle = (source) => {
|
|
13
|
+
let title = '';
|
|
14
|
+
let index = titlePrefix.length;
|
|
15
|
+
|
|
16
|
+
while (index < source.length) {
|
|
17
|
+
const character = source[index];
|
|
18
|
+
if (character === '"') {
|
|
19
|
+
return {
|
|
20
|
+
rest: source.slice(index + 1),
|
|
21
|
+
title,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (character === '\\') {
|
|
26
|
+
const escaped = source[index + 1];
|
|
27
|
+
if (escaped !== '"' && escaped !== '\\') {
|
|
28
|
+
return invalidMetadata(
|
|
29
|
+
`Code title contains unsupported escape "\\${escaped ?? ''}".`,
|
|
30
|
+
'Only escape a double quote (\\") or backslash (\\\\) inside the title.',
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
title += escaped;
|
|
34
|
+
index += 2;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
title += character;
|
|
39
|
+
index += 1;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return invalidMetadata(
|
|
43
|
+
'Code title is missing its closing double quote.',
|
|
44
|
+
'Close the title, for example title="src/config.js".',
|
|
45
|
+
);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const parseHighlightedLines = (selector, lineCount) => {
|
|
49
|
+
const match = selector.match(lineSelectorPattern);
|
|
50
|
+
if (!match) {
|
|
51
|
+
return invalidMetadata(
|
|
52
|
+
`Invalid code line selector "${selector}".`,
|
|
53
|
+
'Use positive line numbers and inclusive ranges without spaces, for example {2,4-6}.',
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const highlightedLines = new Set();
|
|
58
|
+
for (const part of match[1].split(',')) {
|
|
59
|
+
const [startSource, endSource = startSource] = part.split('-');
|
|
60
|
+
const start = Number.parseInt(startSource, 10);
|
|
61
|
+
const end = Number.parseInt(endSource, 10);
|
|
62
|
+
|
|
63
|
+
if (start < 1 || end < start) {
|
|
64
|
+
return invalidMetadata(
|
|
65
|
+
`Invalid code line range "${part}".`,
|
|
66
|
+
'Use positive line numbers with the lower number first, for example {2,4-6}.',
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
if (Number.isInteger(lineCount) && end > lineCount) {
|
|
70
|
+
return invalidMetadata(
|
|
71
|
+
`Code line selector "${part}" refers to line ${end}, but the block has ${lineCount} ${lineCount === 1 ? 'line' : 'lines'}.`,
|
|
72
|
+
`Select only lines 1-${lineCount}.`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
for (let line = start; line <= end; line += 1) {
|
|
77
|
+
if (highlightedLines.has(line)) {
|
|
78
|
+
return invalidMetadata(
|
|
79
|
+
`Code line ${line} is selected more than once.`,
|
|
80
|
+
'Remove overlapping or repeated line numbers from the selector.',
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
highlightedLines.add(line);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return { highlightedLines: [...highlightedLines].sort((left, right) => left - right) };
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export const parseCodeFenceMetadata = (rawMetadata, options = {}) => {
|
|
91
|
+
const source = String(rawMetadata ?? '').trim();
|
|
92
|
+
if (!source) return { highlightedLines: [], title: null };
|
|
93
|
+
|
|
94
|
+
let rest = source;
|
|
95
|
+
let title = null;
|
|
96
|
+
if (rest.startsWith(titlePrefix)) {
|
|
97
|
+
const titleResult = parseTitle(rest);
|
|
98
|
+
if (titleResult.error) return titleResult;
|
|
99
|
+
if (!titleResult.title.trim()) {
|
|
100
|
+
return invalidMetadata(
|
|
101
|
+
'Code title cannot be empty.',
|
|
102
|
+
'Remove title="" or provide a short filename or label.',
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
title = titleResult.title;
|
|
106
|
+
rest = titleResult.rest;
|
|
107
|
+
if (rest && !rest.startsWith(' ')) {
|
|
108
|
+
return invalidMetadata(
|
|
109
|
+
'Code title must be separated from the line selector by one space.',
|
|
110
|
+
'Write metadata as title="src/config.js" {2,4-6}.',
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
rest = rest.trim();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (!rest) return { highlightedLines: [], title };
|
|
117
|
+
if (!rest.startsWith('{')) {
|
|
118
|
+
return invalidMetadata(
|
|
119
|
+
`Unknown code fence metadata "${rest}".`,
|
|
120
|
+
'Use an optional title followed by an optional line selector: title="src/config.js" {2,4-6}.',
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
if (/^\{[^}]*\}\s+/.test(rest)) {
|
|
124
|
+
return invalidMetadata(
|
|
125
|
+
'The code line selector must come after the optional title.',
|
|
126
|
+
'Write metadata as title="src/config.js" {2,4-6}.',
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const linesResult = parseHighlightedLines(rest, options.lineCount);
|
|
131
|
+
if (linesResult.error) return linesResult;
|
|
132
|
+
return {
|
|
133
|
+
highlightedLines: linesResult.highlightedLines,
|
|
134
|
+
title,
|
|
135
|
+
};
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const formatDiagnostic = ({ error, label, line, offset }) => ({
|
|
139
|
+
...error,
|
|
140
|
+
line,
|
|
141
|
+
message: `${label} line ${line}: ${error.message}`,
|
|
142
|
+
offset,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
export const getCodeFenceMetadataDiagnostics = (tree, options = {}) => {
|
|
146
|
+
const diagnostics = [];
|
|
147
|
+
const label = options.label ?? 'Markdown';
|
|
148
|
+
const lineOffset = options.lineOffset ?? 0;
|
|
149
|
+
const excludedLanguages = options.excludedLanguages ?? new Set();
|
|
150
|
+
|
|
151
|
+
const visit = (node) => {
|
|
152
|
+
if (!node || typeof node !== 'object') return;
|
|
153
|
+
if (node.type === 'code' && !excludedLanguages.has(node.lang)) {
|
|
154
|
+
const metadataWithoutLanguage = typeof node.lang === 'string'
|
|
155
|
+
&& (node.lang.startsWith('title=') || node.lang.startsWith('{'));
|
|
156
|
+
const result = metadataWithoutLanguage
|
|
157
|
+
? invalidMetadata(
|
|
158
|
+
'Code fence metadata requires a language before it.',
|
|
159
|
+
'Add a language first, for example ```js title="src/config.js" {2}.',
|
|
160
|
+
)
|
|
161
|
+
: parseCodeFenceMetadata(node.meta, {
|
|
162
|
+
lineCount: String(node.value ?? '').split('\n').length,
|
|
163
|
+
});
|
|
164
|
+
if (result.error) {
|
|
165
|
+
const line = lineOffset + (node.position?.start.line ?? 1);
|
|
166
|
+
diagnostics.push(formatDiagnostic({
|
|
167
|
+
error: result.error,
|
|
168
|
+
label,
|
|
169
|
+
line,
|
|
170
|
+
offset: node.position?.start.offset ?? 0,
|
|
171
|
+
}));
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
for (const child of node.children ?? []) visit(child);
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
visit(tree);
|
|
179
|
+
return diagnostics;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const getTransformerMetadata = (context) => {
|
|
183
|
+
if (!context.meta.nornaCodeFence) {
|
|
184
|
+
const result = parseCodeFenceMetadata(context.options.meta?.__raw, {
|
|
185
|
+
lineCount: context.tokens?.length,
|
|
186
|
+
});
|
|
187
|
+
if (result.error) throw new Error(`${result.error.message} ${result.error.fix}`);
|
|
188
|
+
context.meta.nornaCodeFence = result;
|
|
189
|
+
}
|
|
190
|
+
return context.meta.nornaCodeFence;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
export const nornaCodeFenceTransformer = {
|
|
194
|
+
name: 'norna-code-fence-metadata',
|
|
195
|
+
line(node, line) {
|
|
196
|
+
const metadata = getTransformerMetadata(this);
|
|
197
|
+
if (!metadata.highlightedLines.includes(line)) return;
|
|
198
|
+
this.addClassToHast(node, 'norna-code-line-highlighted');
|
|
199
|
+
node.properties.dataLine = String(line);
|
|
200
|
+
},
|
|
201
|
+
pre(node) {
|
|
202
|
+
const metadata = getTransformerMetadata(this);
|
|
203
|
+
if (metadata.highlightedLines.length > 0) {
|
|
204
|
+
this.addClassToHast(node, 'norna-code-has-highlighted-lines');
|
|
205
|
+
}
|
|
206
|
+
if (!metadata.title) return;
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
type: 'element',
|
|
210
|
+
tagName: 'figure',
|
|
211
|
+
properties: { className: ['norna-code-example'] },
|
|
212
|
+
children: [
|
|
213
|
+
{
|
|
214
|
+
type: 'element',
|
|
215
|
+
tagName: 'figcaption',
|
|
216
|
+
properties: { className: ['norna-code-title'] },
|
|
217
|
+
children: [{
|
|
218
|
+
type: 'element',
|
|
219
|
+
tagName: 'span',
|
|
220
|
+
properties: { className: ['norna-code-title-text'] },
|
|
221
|
+
children: [{ type: 'text', value: metadata.title }],
|
|
222
|
+
}],
|
|
223
|
+
},
|
|
224
|
+
node,
|
|
225
|
+
],
|
|
226
|
+
};
|
|
227
|
+
},
|
|
228
|
+
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { pathToFileURL } from 'node:url';
|
|
3
|
+
|
|
4
|
+
export const localEditorNames = Object.freeze(['vscode']);
|
|
5
|
+
|
|
6
|
+
const assertHttpUrl = (value, label) => {
|
|
7
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
8
|
+
throw new Error(`${label} must be a non-empty absolute URL.`);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
let url;
|
|
12
|
+
try {
|
|
13
|
+
url = new URL(value.trim());
|
|
14
|
+
} catch {
|
|
15
|
+
throw new Error(`${label} must be an absolute URL such as "https://github.com/owner/repository/edit/main/".`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (!['http:', 'https:'].includes(url.protocol)) {
|
|
19
|
+
throw new Error(`${label} must use http or https.`);
|
|
20
|
+
}
|
|
21
|
+
if (url.username || url.password) {
|
|
22
|
+
throw new Error(`${label} must not contain credentials.`);
|
|
23
|
+
}
|
|
24
|
+
if (url.search || url.hash) {
|
|
25
|
+
throw new Error(`${label} must not contain a query string or fragment.`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return url;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const normalizeEditLinkBaseUrl = (value, label = 'editLink.baseUrl') => {
|
|
32
|
+
const url = assertHttpUrl(value, label);
|
|
33
|
+
if (!url.pathname.endsWith('/')) url.pathname = `${url.pathname}/`;
|
|
34
|
+
return url.href;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const getEditSourceUrl = ({ baseUrl, sourcePath }) => {
|
|
38
|
+
const normalizedBaseUrl = normalizeEditLinkBaseUrl(baseUrl);
|
|
39
|
+
if (typeof sourcePath !== 'string' || sourcePath.trim() === '') {
|
|
40
|
+
throw new Error('Edit-source path must be a non-empty project-relative path.');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const normalizedSourcePath = sourcePath.trim().replaceAll('\\', '/');
|
|
44
|
+
const segments = normalizedSourcePath.split('/');
|
|
45
|
+
if (
|
|
46
|
+
normalizedSourcePath.startsWith('/')
|
|
47
|
+
|| segments.some((segment) => segment === '' || segment === '.' || segment === '..')
|
|
48
|
+
) {
|
|
49
|
+
throw new Error(`Edit-source path "${sourcePath}" must be a project-relative path without traversal segments.`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const encodedPath = segments.map((segment) => encodeURIComponent(segment)).join('/');
|
|
53
|
+
return new URL(encodedPath, normalizedBaseUrl).href;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export const isLoopbackHostname = (hostname) => {
|
|
57
|
+
const normalizedHostname = String(hostname ?? '')
|
|
58
|
+
.trim()
|
|
59
|
+
.toLowerCase()
|
|
60
|
+
.replace(/^\[|\]$/g, '')
|
|
61
|
+
.replace(/\.$/, '');
|
|
62
|
+
|
|
63
|
+
return normalizedHostname === 'localhost'
|
|
64
|
+
|| normalizedHostname.endsWith('.localhost')
|
|
65
|
+
|| normalizedHostname === '::1'
|
|
66
|
+
|| normalizedHostname.startsWith('::ffff:127.')
|
|
67
|
+
|| /^127(?:\.\d{1,3}){3}$/.test(normalizedHostname);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export const getLocalEditorSourceUrl = ({ editor, sourcePath }) => {
|
|
71
|
+
if (!localEditorNames.includes(editor)) {
|
|
72
|
+
throw new Error(`Unknown local editor "${editor}". Use one of: ${localEditorNames.join(', ')}.`);
|
|
73
|
+
}
|
|
74
|
+
if (typeof sourcePath !== 'string' || !path.isAbsolute(sourcePath)) {
|
|
75
|
+
throw new Error('Local editor source path must be absolute.');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const fileUrl = pathToFileURL(path.normalize(sourcePath));
|
|
79
|
+
return `vscode://file${fileUrl.pathname}`;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export const resolveEditSourceTarget = ({
|
|
83
|
+
baseUrl,
|
|
84
|
+
development = false,
|
|
85
|
+
hostname,
|
|
86
|
+
localEditor,
|
|
87
|
+
sourceLabel,
|
|
88
|
+
sourcePath,
|
|
89
|
+
}) => {
|
|
90
|
+
if (development && localEditor && isLoopbackHostname(hostname)) {
|
|
91
|
+
return Object.freeze({
|
|
92
|
+
href: getLocalEditorSourceUrl({ editor: localEditor, sourcePath }),
|
|
93
|
+
kind: 'local',
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
if (!baseUrl) return null;
|
|
97
|
+
|
|
98
|
+
return Object.freeze({
|
|
99
|
+
href: getEditSourceUrl({ baseUrl, sourcePath: sourceLabel }),
|
|
100
|
+
kind: 'remote',
|
|
101
|
+
});
|
|
102
|
+
};
|