@janga/norna 0.7.28 → 0.7.30
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 -6
- package/astro.config.mjs +14 -2
- package/package.json +13 -9
- 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 +97 -127
- 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 +7 -1
- package/scripts/lib/image-dimensions.mjs +3 -72
- 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/.github/workflows/deploy.yml +3 -6
- package/starters/basic/README.md +2 -0
- package/starters/basic/site/site-config/settings.yaml +3 -0
|
@@ -1,103 +1,32 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { invocationRoot, siteDir, siteProjectRoot } from './lib/site-paths.mjs';
|
|
3
3
|
import { createSiteNode, planSiteNodeCreation } from './lib/site-node-create.mjs';
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
Usage:
|
|
7
|
-
norna page:add <title> [--parent <path>] [--
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
help: false,
|
|
34
|
-
order: null,
|
|
35
|
-
parent: null,
|
|
36
|
-
slug: null,
|
|
37
|
-
titleParts: [],
|
|
38
|
-
};
|
|
39
|
-
const seen = new Set();
|
|
40
|
-
|
|
41
|
-
for (let index = 0; index < rawArgs.length;) {
|
|
42
|
-
const arg = rawArgs[index];
|
|
43
|
-
if (arg === '-h' || arg === '--help') {
|
|
44
|
-
options.help = true;
|
|
45
|
-
index += 1;
|
|
46
|
-
continue;
|
|
47
|
-
}
|
|
48
|
-
if (arg === '--dry-run') {
|
|
49
|
-
if (seen.has('dryRun')) throw new Error(`--dry-run may be specified only once.\n${usage}`);
|
|
50
|
-
seen.add('dryRun');
|
|
51
|
-
options.dryRun = true;
|
|
52
|
-
index += 1;
|
|
53
|
-
continue;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
let matched = false;
|
|
57
|
-
for (const [name, key] of [['--order', 'order'], ['--parent', 'parent'], ['--slug', 'slug']]) {
|
|
58
|
-
const parsed = parseValueOption(rawArgs, index, name);
|
|
59
|
-
if (!parsed) continue;
|
|
60
|
-
if (seen.has(key)) throw new Error(`${name} may be specified only once.\n${usage}`);
|
|
61
|
-
seen.add(key);
|
|
62
|
-
options[key] = parsed.value;
|
|
63
|
-
index += parsed.consumed;
|
|
64
|
-
matched = true;
|
|
65
|
-
break;
|
|
66
|
-
}
|
|
67
|
-
if (matched) continue;
|
|
68
|
-
|
|
69
|
-
if (arg.startsWith('-')) throw new Error(`Unknown option: ${arg}\n${usage}`);
|
|
70
|
-
options.titleParts.push(arg);
|
|
71
|
-
index += 1;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
return options;
|
|
75
|
-
};
|
|
76
|
-
|
|
77
|
-
const toDisplayPath = (filePath) => {
|
|
78
|
-
const relativePath = path.relative(siteProjectRoot, filePath);
|
|
79
|
-
return (relativePath && !relativePath.startsWith('..') && !path.isAbsolute(relativePath)
|
|
80
|
-
? relativePath
|
|
81
|
-
: filePath).split(path.sep).join('/');
|
|
82
|
-
};
|
|
83
|
-
|
|
84
|
-
const [kind, ...rawArgs] = process.argv.slice(2);
|
|
85
|
-
if (kind !== 'page') throw new Error(usage);
|
|
86
|
-
const options = parseArgs(rawArgs);
|
|
87
|
-
if (options.help) {
|
|
88
|
-
console.log(usage);
|
|
89
|
-
process.exit(0);
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
const title = options.titleParts.join(' ').trim();
|
|
93
|
-
if (!title || /[\r\n]/.test(title)) throw new Error(`A one-line page title is required.\n${usage}`);
|
|
94
|
-
const plan = await planSiteNodeCreation({
|
|
95
|
-
siteRoot: siteDir, kind, title, slug: options.slug, order: options.order,
|
|
96
|
-
parentPath: options.parent, invocationDirectory: invocationRoot,
|
|
97
|
-
});
|
|
98
|
-
const { destination, pagePath } = plan;
|
|
99
|
-
const action = options.dryRun ? 'Would create' : 'Created';
|
|
100
|
-
if (!options.dryRun) await createSiteNode(plan);
|
|
101
|
-
|
|
102
|
-
console.log(`${action} page "${title}" at ${toDisplayPath(destination)}.`);
|
|
103
|
-
console.log(`URL: /${pagePath}/`);
|
|
4
|
+
import { parsePlacementArguments, confirmCliRenumbering, describeRenumbering } from './lib/page-placement-cli.mjs';
|
|
5
|
+
|
|
6
|
+
const usage = `Usage:
|
|
7
|
+
norna page:add <title> [--parent <path>] [--first | --last] [--slug <slug>]
|
|
8
|
+
norna page:add <title> --before <page-url> | --after <page-url>
|
|
9
|
+
|
|
10
|
+
Options: --order <integer>, --dry-run, --allow-renumber
|
|
11
|
+
Without a placement, append under --parent or the current page/site directory.
|
|
12
|
+
Before/after infer the parent. --order cannot accompany a relative placement.`;
|
|
13
|
+
const display = filename => path.relative(siteProjectRoot, filename) || filename;
|
|
14
|
+
try {
|
|
15
|
+
const [kind, ...args] = process.argv.slice(2);
|
|
16
|
+
if (kind !== 'page') throw new Error(usage);
|
|
17
|
+
const options = parsePlacementArguments(args);
|
|
18
|
+
if (options.help) { console.log(usage); process.exit(0); }
|
|
19
|
+
if (options.write || options.noAliases) throw new Error('page:add uses --dry-run; --write and --no-aliases are page:move options.');
|
|
20
|
+
const title = options.positionals.join(' ').trim();
|
|
21
|
+
if (!title) throw new Error(`A one-line page title is required.\n${usage}`);
|
|
22
|
+
const plan = await planSiteNodeCreation({ siteRoot: siteDir, kind, title, slug: options.slug, order: options.order,
|
|
23
|
+
parentPath: options.parent, invocationDirectory: invocationRoot,
|
|
24
|
+
placement: options.order !== null ? null : options.placement, targetPath: options.target });
|
|
25
|
+
console.log(`Page: ${title}\nSource: ${display(plan.destination)}\nURL: ${plan.url}\nOrder: ${plan.order}`);
|
|
26
|
+
const renumbering = describeRenumbering(plan.orderChanges, display);
|
|
27
|
+
if (renumbering) console.log(renumbering);
|
|
28
|
+
if (options.dryRun) { console.log(`Would create page "${title}". Dry run only. No files changed.`); process.exit(0); }
|
|
29
|
+
if (!await confirmCliRenumbering(plan.orderChanges, options)) { console.log('Cancelled. No files changed.'); process.exit(0); }
|
|
30
|
+
const result = await createSiteNode(plan, { allowRenumber: true });
|
|
31
|
+
console.log(`Created page "${title}" at ${display(plan.destination)}. ${result.renumberedPages} existing sibling page(s) renumbered.`);
|
|
32
|
+
} catch (error) { console.error(error.message); process.exitCode = 1; }
|
package/scripts/dev-local.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { readDevServerIdentity, verifyDevServer } from './lib/dev-server-identity.mjs';
|
|
1
3
|
import { execFile } from 'node:child_process';
|
|
2
4
|
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
3
5
|
import net from 'node:net';
|
|
@@ -10,11 +12,14 @@ import {
|
|
|
10
12
|
engineRoot,
|
|
11
13
|
siteProjectRoot,
|
|
12
14
|
siteStateDir,
|
|
15
|
+
siteDir,
|
|
13
16
|
} from './lib/site-paths.mjs';
|
|
14
17
|
|
|
15
18
|
const execFileAsync = promisify(execFile);
|
|
16
19
|
|
|
17
|
-
const
|
|
20
|
+
const portValue = process.env.NORNA_DEV_PORT ?? '4321';
|
|
21
|
+
const port = /^\d+$/.test(portValue) ? Number(portValue) : NaN;
|
|
22
|
+
const serverToken = process.env.NORNA_DEV_START_TOKEN || randomUUID();
|
|
18
23
|
if (!Number.isInteger(port) || port < 1 || port > 65_535) {
|
|
19
24
|
throw new Error('NORNA_DEV_PORT must be an integer from 1 through 65535.');
|
|
20
25
|
}
|
|
@@ -107,6 +112,8 @@ const writeState = async (host) => {
|
|
|
107
112
|
await writeFile(statePath, `${JSON.stringify({
|
|
108
113
|
port,
|
|
109
114
|
pid,
|
|
115
|
+
token: serverToken,
|
|
116
|
+
siteRoot: siteDir,
|
|
110
117
|
host,
|
|
111
118
|
mode: host === '0.0.0.0' ? 'lan' : 'local',
|
|
112
119
|
url: localUrl,
|
|
@@ -367,6 +374,12 @@ const openBrowser = async () => {
|
|
|
367
374
|
|
|
368
375
|
const stopServer = async ({ quiet = false } = {}) => {
|
|
369
376
|
const state = await readState();
|
|
377
|
+
if (process.env.NORNA_DEV_EXPECT_TOKEN) {
|
|
378
|
+
if (state?.token !== process.env.NORNA_DEV_EXPECT_TOKEN || state.port !== port || !(await verifyDevServer(state, siteDir))) throw new Error('Cannot verify this site’s server. No process was stopped.');
|
|
379
|
+
if (!(await terminateTrackedProcess(state, state.host ?? localHost))) throw new Error('The verified preview server did not stop. Try Stop Preview Server again.');
|
|
380
|
+
await removeState();
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
370
383
|
let astroError;
|
|
371
384
|
let astroOutput = '';
|
|
372
385
|
try {
|
|
@@ -397,7 +410,7 @@ const stopServer = async ({ quiet = false } = {}) => {
|
|
|
397
410
|
};
|
|
398
411
|
|
|
399
412
|
const startServer = async ({ host = localHost, open = true, killBlockingPort = false } = {}) => {
|
|
400
|
-
await stopServer({ quiet: true });
|
|
413
|
+
if (process.env.NORNA_DEV_NO_RESTART !== '1') await stopServer({ quiet: true });
|
|
401
414
|
|
|
402
415
|
if (!(await isPortFree(host))) {
|
|
403
416
|
if (!killBlockingPort) {
|
|
@@ -432,8 +445,24 @@ const startServer = async ({ host = localHost, open = true, killBlockingPort = f
|
|
|
432
445
|
}
|
|
433
446
|
const previousLog = await readDevLog();
|
|
434
447
|
try {
|
|
435
|
-
await runAstroInherit(['dev', '--background', '--host', host, '--port', String(port)]);
|
|
448
|
+
await runAstroInherit(['dev', '--background', '--host', host, '--port', String(port)], { env: { ...process.env, NORNA_DEV_TOKEN: serverToken } });
|
|
449
|
+
// Record the process before probing rendered pages, so failed readiness
|
|
450
|
+
// checks can clean up only the server started by this request.
|
|
451
|
+
await writeState(host);
|
|
452
|
+
await waitForServer();
|
|
436
453
|
} catch (error) {
|
|
454
|
+
let cleanupHint = '';
|
|
455
|
+
const identity = await readDevServerIdentity({ port, token: serverToken }, siteDir);
|
|
456
|
+
if (identity) {
|
|
457
|
+
const candidate = { port, ...identity };
|
|
458
|
+
try {
|
|
459
|
+
if (!(await terminateTrackedProcess(candidate, host))) throw new Error('The process did not stop.');
|
|
460
|
+
const recorded = await readState();
|
|
461
|
+
if (recorded?.token === serverToken) await removeState();
|
|
462
|
+
} catch (cleanup) {
|
|
463
|
+
cleanupHint = `Cleanup failed: ${cleanup.message} Use the site's dev:stop command after inspecting the log.`;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
437
466
|
const currentLog = await readDevLog();
|
|
438
467
|
const newLog = currentLog.startsWith(previousLog)
|
|
439
468
|
? currentLog.slice(previousLog.length)
|
|
@@ -443,11 +472,9 @@ const startServer = async ({ host = localHost, open = true, killBlockingPort = f
|
|
|
443
472
|
`Could not start the dev server at ${localUrl}.`,
|
|
444
473
|
excerpt ? `Astro reported:\n${excerpt}` : 'Astro did not write a detailed startup error.',
|
|
445
474
|
`Full log: ${logPath}`,
|
|
446
|
-
|
|
475
|
+
cleanupHint,
|
|
476
|
+
].filter(Boolean).join('\n\n'), { cause: error });
|
|
447
477
|
}
|
|
448
|
-
await waitForServer();
|
|
449
|
-
|
|
450
|
-
await writeState(host);
|
|
451
478
|
if (open) {
|
|
452
479
|
await openBrowser();
|
|
453
480
|
} else {
|
|
@@ -1,13 +1,10 @@
|
|
|
1
1
|
import { getPageImageSourceKey } from './lib/site-conventions.mjs';
|
|
2
|
-
import { execFile } from 'node:child_process';
|
|
3
2
|
import { createHash } from 'node:crypto';
|
|
4
|
-
import { copyFile, mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { copyFile, mkdir, readdir, readFile, rm, stat, lstat, writeFile } from 'node:fs/promises';
|
|
5
4
|
import path from 'node:path';
|
|
6
|
-
import { promisify } from 'node:util';
|
|
7
5
|
import {
|
|
8
6
|
getContentFiles,
|
|
9
7
|
readSiteFile,
|
|
10
|
-
rasterImageExtensions,
|
|
11
8
|
staticImageExtensions,
|
|
12
9
|
supportedImageExtensions,
|
|
13
10
|
toPosixPath,
|
|
@@ -22,61 +19,16 @@ import {
|
|
|
22
19
|
siteDir,
|
|
23
20
|
} from './lib/site-paths.mjs';
|
|
24
21
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const
|
|
22
|
+
// Bump only when the raster recipe changes, not on ordinary engine releases.
|
|
23
|
+
const rasterOutputVersion = 4;
|
|
24
|
+
const staticOutputVersion = 3;
|
|
28
25
|
const sourceHashSlugLength = 8;
|
|
29
26
|
const widths = [480, 768, 1080, 1440, 1920];
|
|
30
27
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
const canRun = async (command, args = ['-version']) => {
|
|
37
|
-
try {
|
|
38
|
-
await run(command, args);
|
|
39
|
-
return true;
|
|
40
|
-
} catch {
|
|
41
|
-
return false;
|
|
42
|
-
}
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
const getImageMagick = async () => {
|
|
46
|
-
if (await canRun('magick')) {
|
|
47
|
-
return {
|
|
48
|
-
identify: (sourcePath) => run('magick', ['identify', '-format', '%w %h', sourcePath]),
|
|
49
|
-
convert: (sourcePath, outputPath, width) => run('magick', [
|
|
50
|
-
sourcePath,
|
|
51
|
-
'-auto-orient',
|
|
52
|
-
'-resize',
|
|
53
|
-
`${width}x`,
|
|
54
|
-
'-strip',
|
|
55
|
-
'-quality',
|
|
56
|
-
'82',
|
|
57
|
-
outputPath,
|
|
58
|
-
]),
|
|
59
|
-
};
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
if (await canRun('identify') && await canRun('convert')) {
|
|
63
|
-
return {
|
|
64
|
-
identify: (sourcePath) => run('identify', ['-format', '%w %h', sourcePath]),
|
|
65
|
-
convert: (sourcePath, outputPath, width) => run('convert', [
|
|
66
|
-
sourcePath,
|
|
67
|
-
'-auto-orient',
|
|
68
|
-
'-resize',
|
|
69
|
-
`${width}x`,
|
|
70
|
-
'-strip',
|
|
71
|
-
'-quality',
|
|
72
|
-
'82',
|
|
73
|
-
outputPath,
|
|
74
|
-
]),
|
|
75
|
-
};
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
throw new Error('ImageMagick is missing. Install either the "magick" command or both "identify" and "convert".');
|
|
79
|
-
};
|
|
28
|
+
let sharpPromise;
|
|
29
|
+
const getSharp = () => sharpPromise ??= import('sharp').then(({ default: sharp }) => sharp).catch(() => {
|
|
30
|
+
throw new Error('Norna could not load Sharp for image processing. Run npm install --include=optional in the project to install the native image dependencies for this computer.');
|
|
31
|
+
});
|
|
80
32
|
|
|
81
33
|
const toPublicPath = (filePath) => filePath.split(path.sep).join('/');
|
|
82
34
|
const getPublicPath = (filePath) => `/${toPublicPath(path.relative(astroPublicDir, filePath))}`;
|
|
@@ -86,7 +38,7 @@ const getSourceHashSlug = (sourceHash) => sourceHash.slice(0, sourceHashSlugLeng
|
|
|
86
38
|
|
|
87
39
|
const getGeneratedPath = (sourceKey, sourceHash, width) => {
|
|
88
40
|
const parsed = path.parse(sourceKey.replace(/^root\//, ''));
|
|
89
|
-
return path.join(generatedImagesDir, parsed.dir, `${parsed.
|
|
41
|
+
return path.join(generatedImagesDir, parsed.dir, `${parsed.base}-${getSourceHashSlug(sourceHash)}-v${rasterOutputVersion}-${width}.webp`);
|
|
90
42
|
};
|
|
91
43
|
|
|
92
44
|
const getOriginalPath = (sourceKey, sourceHash) => {
|
|
@@ -103,6 +55,8 @@ const getImageSourceKey = (contentFile, image) => getPageImageSourceKey(contentF
|
|
|
103
55
|
const getReferencedImages = async (contentFile) => {
|
|
104
56
|
const { body } = await readSiteFile(contentFile.contentPath, contentFile.contentLabel);
|
|
105
57
|
const page = await parsePageMarkdown(body, { label: contentFile.contentLabel });
|
|
58
|
+
const imageError = page.diagnostics.find((issue) => issue.code === 'invalid-markdown-image');
|
|
59
|
+
if (imageError) throw new Error(imageError.message + ' ' + imageError.fix);
|
|
106
60
|
const references = [];
|
|
107
61
|
|
|
108
62
|
for (const region of page.regions) {
|
|
@@ -138,6 +92,7 @@ const getContentSourcePath = ({ image, sourceKey }) => {
|
|
|
138
92
|
const getReferencedSources = async () => {
|
|
139
93
|
const contentFiles = await getContentFiles();
|
|
140
94
|
const seen = new Set();
|
|
95
|
+
const normalizedSources = new Map();
|
|
141
96
|
const sources = [];
|
|
142
97
|
|
|
143
98
|
for (const contentFile of contentFiles) {
|
|
@@ -152,7 +107,9 @@ const getReferencedSources = async () => {
|
|
|
152
107
|
continue;
|
|
153
108
|
}
|
|
154
109
|
|
|
155
|
-
const fileStat = await
|
|
110
|
+
const fileStat = await lstat(sourcePath).catch(() => null);
|
|
111
|
+
const directoryStat = await lstat(contentFile.imagesDir).catch(() => null);
|
|
112
|
+
if (fileStat?.isSymbolicLink() || directoryStat?.isSymbolicLink()) fail(`Image ${sourcePath} must belong to a regular images/ directory; symbolic links are not supported.`);
|
|
156
113
|
if (!fileStat?.isFile()) {
|
|
157
114
|
fail(`Image file does not exist: ${contentFile.imagesLabel}/${siteImagePath}`);
|
|
158
115
|
}
|
|
@@ -161,6 +118,10 @@ const getReferencedSources = async () => {
|
|
|
161
118
|
fail(`Image "${imageName}" is used in ${reference.sectionLabel} but is not stored directly in ${contentFile.imagesLabel}/. Run norna content:sync, or npm run norna:sync in starter-style repositories, to move it.`);
|
|
162
119
|
}
|
|
163
120
|
|
|
121
|
+
const normalizedKey = reference.sourceKey.normalize('NFC').toLowerCase();
|
|
122
|
+
const previous = normalizedSources.get(normalizedKey);
|
|
123
|
+
if (previous) fail(`Image sources ${previous} and ${sourcePath} conflict after case/Unicode normalization. Rename one image.`);
|
|
124
|
+
normalizedSources.set(normalizedKey, sourcePath);
|
|
164
125
|
seen.add(reference.sourceKey);
|
|
165
126
|
sources.push({ sourcePath, sourceKey: reference.sourceKey, reference });
|
|
166
127
|
}
|
|
@@ -203,15 +164,18 @@ const validateCarouselOrientations = async (manifest) => {
|
|
|
203
164
|
}
|
|
204
165
|
};
|
|
205
166
|
|
|
206
|
-
const identify = async (sourcePath) => {
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
167
|
+
const identify = async (sourcePath, sharp) => {
|
|
168
|
+
const { autoOrient: dimensions } = await sharp(sourcePath).metadata();
|
|
169
|
+
if (!dimensions || !Number.isSafeInteger(dimensions.width) || !Number.isSafeInteger(dimensions.height)
|
|
170
|
+
|| dimensions.width <= 0 || dimensions.height <= 0) throw new Error('The image has no usable dimensions.');
|
|
171
|
+
return dimensions;
|
|
210
172
|
};
|
|
211
173
|
|
|
212
|
-
const convert = async (sourcePath, outputPath, width) => {
|
|
174
|
+
const convert = async (sourcePath, outputPath, width, sharp) => {
|
|
213
175
|
await mkdir(path.dirname(outputPath), { recursive: true });
|
|
214
|
-
|
|
176
|
+
// Sharp converts to sRGB and strips metadata by default.
|
|
177
|
+
await sharp(sourcePath).autoOrient().resize({ width, withoutEnlargement: true })
|
|
178
|
+
.webp({ quality: 82 }).toFile(outputPath);
|
|
215
179
|
};
|
|
216
180
|
|
|
217
181
|
const getHash = (value) => createHash('sha256').update(value).digest('hex');
|
|
@@ -266,7 +230,7 @@ const hasGeneratedVariants = async (variants) => {
|
|
|
266
230
|
const getReusableEntry = async (sourceKey, previousEntry, sourceHash) => {
|
|
267
231
|
if (
|
|
268
232
|
previousEntry?.sourceHash !== sourceHash
|
|
269
|
-
|| previousEntry?.outputVersion !==
|
|
233
|
+
|| previousEntry?.outputVersion !== rasterOutputVersion
|
|
270
234
|
|| !Number.isFinite(previousEntry?.width)
|
|
271
235
|
|| !Number.isFinite(previousEntry?.height)
|
|
272
236
|
) {
|
|
@@ -280,7 +244,7 @@ const getReusableEntry = async (sourceKey, previousEntry, sourceHash) => {
|
|
|
280
244
|
}
|
|
281
245
|
|
|
282
246
|
return {
|
|
283
|
-
outputVersion:
|
|
247
|
+
outputVersion: rasterOutputVersion,
|
|
284
248
|
sourceHash,
|
|
285
249
|
width: previousEntry.width,
|
|
286
250
|
height: previousEntry.height,
|
|
@@ -288,12 +252,12 @@ const getReusableEntry = async (sourceKey, previousEntry, sourceHash) => {
|
|
|
288
252
|
};
|
|
289
253
|
};
|
|
290
254
|
|
|
291
|
-
const getReusableStaticEntry = async (previousEntry, sourceHash) => {
|
|
255
|
+
const getReusableStaticEntry = async (previousEntry, sourceHash, sourceKey) => {
|
|
292
256
|
if (
|
|
293
257
|
previousEntry?.kind !== 'static'
|
|
294
258
|
|| previousEntry?.sourceHash !== sourceHash
|
|
295
|
-
|| previousEntry?.outputVersion !==
|
|
296
|
-
||
|
|
259
|
+
|| previousEntry?.outputVersion !== staticOutputVersion
|
|
260
|
+
|| previousEntry?.src !== getPublicPath(getOriginalPath(sourceKey, sourceHash))
|
|
297
261
|
|| !(await fileExists(getFilePathFromPublicPath(previousEntry.src)))
|
|
298
262
|
) {
|
|
299
263
|
return null;
|
|
@@ -349,80 +313,86 @@ const removeUnreferencedGeneratedFiles = async (manifest) => {
|
|
|
349
313
|
}
|
|
350
314
|
};
|
|
351
315
|
|
|
352
|
-
|
|
316
|
+
const main = async () => {
|
|
317
|
+
await mkdir(generatedImagesDir, { recursive: true });
|
|
353
318
|
|
|
354
|
-
const previousManifest = await readManifest();
|
|
355
|
-
const sources = await getReferencedSources();
|
|
356
|
-
const manifest = {};
|
|
357
|
-
const isStaticSource = (sourcePath) => staticImageExtensions.has(path.extname(sourcePath).toLowerCase());
|
|
358
|
-
const isRasterSource = (sourcePath) => rasterImageExtensions.has(path.extname(sourcePath).toLowerCase());
|
|
319
|
+
const previousManifest = await readManifest();
|
|
320
|
+
const sources = await getReferencedSources();
|
|
321
|
+
const manifest = {};
|
|
322
|
+
const isStaticSource = (sourcePath) => staticImageExtensions.has(path.extname(sourcePath).toLowerCase());
|
|
359
323
|
|
|
360
|
-
if (sources.length === 0) {
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
}
|
|
324
|
+
if (sources.length === 0) {
|
|
325
|
+
await removeUnreferencedGeneratedFiles(manifest);
|
|
326
|
+
await mkdir(path.dirname(generatedImagesManifestPath), { recursive: true });
|
|
327
|
+
await writeFile(generatedImagesManifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
366
330
|
|
|
367
|
-
const
|
|
368
|
-
|
|
369
|
-
: null;
|
|
331
|
+
for (const { sourcePath, sourceKey } of sources) {
|
|
332
|
+
const sourceHash = await getSourceHash(sourcePath);
|
|
370
333
|
|
|
371
|
-
|
|
372
|
-
|
|
334
|
+
if (isStaticSource(sourcePath)) {
|
|
335
|
+
const reusableEntry = await getReusableStaticEntry(previousManifest[sourceKey], sourceHash, sourceKey);
|
|
373
336
|
|
|
374
|
-
|
|
375
|
-
|
|
337
|
+
if (reusableEntry) {
|
|
338
|
+
manifest[sourceKey] = reusableEntry;
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const outputPath = getOriginalPath(sourceKey, sourceHash);
|
|
343
|
+
await mkdir(path.dirname(outputPath), { recursive: true });
|
|
344
|
+
await copyFile(sourcePath, outputPath);
|
|
345
|
+
|
|
346
|
+
const dimensions = await readImageDimensions(sourcePath).catch(() => null);
|
|
347
|
+
manifest[sourceKey] = {
|
|
348
|
+
kind: 'static',
|
|
349
|
+
outputVersion: staticOutputVersion,
|
|
350
|
+
sourceHash,
|
|
351
|
+
src: getPublicPath(outputPath),
|
|
352
|
+
...(dimensions ? { width: dimensions.width, height: dimensions.height } : {}),
|
|
353
|
+
};
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const reusableEntry = await getReusableEntry(sourceKey, previousManifest[sourceKey], sourceHash);
|
|
376
358
|
|
|
377
359
|
if (reusableEntry) {
|
|
378
360
|
manifest[sourceKey] = reusableEntry;
|
|
379
361
|
continue;
|
|
380
362
|
}
|
|
381
363
|
|
|
382
|
-
const
|
|
383
|
-
|
|
384
|
-
|
|
364
|
+
const sharp = await getSharp();
|
|
365
|
+
let dimensions;
|
|
366
|
+
let variants;
|
|
367
|
+
try {
|
|
368
|
+
dimensions = await identify(sourcePath, sharp);
|
|
369
|
+
const variantWidths = getVariantWidths(dimensions);
|
|
370
|
+
variants = getVariants(sourceKey, sourceHash, variantWidths);
|
|
371
|
+
for (const width of variantWidths) {
|
|
372
|
+
await convert(sourcePath, getGeneratedPath(sourceKey, sourceHash, width), width, sharp);
|
|
373
|
+
}
|
|
374
|
+
} catch (error) {
|
|
375
|
+
throw new Error(`Cannot process image ${sourcePath}: ${error.message}`);
|
|
376
|
+
}
|
|
385
377
|
|
|
386
|
-
const dimensions = await readImageDimensions(sourcePath).catch(() => null);
|
|
387
378
|
manifest[sourceKey] = {
|
|
388
|
-
|
|
389
|
-
outputVersion: imageOutputVersion,
|
|
379
|
+
outputVersion: rasterOutputVersion,
|
|
390
380
|
sourceHash,
|
|
391
|
-
|
|
392
|
-
|
|
381
|
+
width: dimensions.width,
|
|
382
|
+
height: dimensions.height,
|
|
383
|
+
variants,
|
|
393
384
|
};
|
|
394
|
-
continue;
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
const reusableEntry = await getReusableEntry(sourceKey, previousManifest[sourceKey], sourceHash);
|
|
398
|
-
|
|
399
|
-
if (reusableEntry) {
|
|
400
|
-
manifest[sourceKey] = reusableEntry;
|
|
401
|
-
continue;
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
if (!imageMagick) {
|
|
405
|
-
throw new Error(`ImageMagick is missing for raster image: ${sourcePath}`);
|
|
406
385
|
}
|
|
407
386
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
await convert(sourcePath, outputPath, width);
|
|
414
|
-
}
|
|
387
|
+
await validateCarouselOrientations(manifest);
|
|
388
|
+
await removeUnreferencedGeneratedFiles(manifest);
|
|
389
|
+
await mkdir(path.dirname(generatedImagesManifestPath), { recursive: true });
|
|
390
|
+
await writeFile(generatedImagesManifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
391
|
+
};
|
|
415
392
|
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
variants,
|
|
422
|
-
};
|
|
393
|
+
try {
|
|
394
|
+
await main();
|
|
395
|
+
} catch (error) {
|
|
396
|
+
console.error(error.message);
|
|
397
|
+
process.exitCode = 1;
|
|
423
398
|
}
|
|
424
|
-
|
|
425
|
-
await validateCarouselOrientations(manifest);
|
|
426
|
-
await removeUnreferencedGeneratedFiles(manifest);
|
|
427
|
-
await mkdir(path.dirname(generatedImagesManifestPath), { recursive: true });
|
|
428
|
-
await writeFile(generatedImagesManifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { searchOutputDirectory } from './lib/publication-paths.mjs';
|
|
1
2
|
import { cp, mkdir, readdir, rm } from 'node:fs/promises';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import * as pagefind from 'pagefind';
|
|
@@ -7,7 +8,7 @@ import {
|
|
|
7
8
|
astroPublicDir,
|
|
8
9
|
} from './lib/site-paths.mjs';
|
|
9
10
|
|
|
10
|
-
const searchDirectoryName =
|
|
11
|
+
const searchDirectoryName = searchOutputDirectory;
|
|
11
12
|
const distSearchDirectory = path.join(astroDistDir, searchDirectoryName);
|
|
12
13
|
const localSearchDirectory = path.join(astroPublicDir, searchDirectoryName);
|
|
13
14
|
|
|
@@ -44,7 +45,7 @@ if (!projectConfig.search.enabled) {
|
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
await rm(localSearchDirectory, { force: true, recursive: true });
|
|
47
|
-
await mkdir(
|
|
48
|
+
await mkdir(path.dirname(localSearchDirectory), { recursive: true });
|
|
48
49
|
await cp(distSearchDirectory, localSearchDirectory, { recursive: true });
|
|
49
50
|
|
|
50
51
|
console.log(`Generated static search index for ${indexedPageCount} page${indexedPageCount === 1 ? '' : 's'}.`);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { getSourcePageLocation } from './source-files.mjs';
|
|
4
|
+
import { parsePageMarkdownSource } from './page-markdown.mjs';
|
|
5
|
+
import { readPageAttachments, resolveAttachment, attachmentHref } from './page-attachments.mjs';
|
|
6
|
+
|
|
7
|
+
export const rewriteAttachmentLinks = async (source, fileURL, siteRoot) => {
|
|
8
|
+
if (!fileURL) return source;
|
|
9
|
+
const contentPath = fileURLToPath(fileURL);
|
|
10
|
+
const location = getSourcePageLocation(siteRoot, path.dirname(contentPath));
|
|
11
|
+
if (!location || path.basename(contentPath) !== 'content.md') return source;
|
|
12
|
+
const { files } = await readPageAttachments({ ...location, contentPath });
|
|
13
|
+
if (!files.length) return source;
|
|
14
|
+
const document = await parsePageMarkdownSource(source);
|
|
15
|
+
const edits = new Map();
|
|
16
|
+
for (const link of document.links) {
|
|
17
|
+
const file = resolveAttachment(link.target, location.isHome ? '/' : `/${location.pagePath}/`, files);
|
|
18
|
+
if (!file || !link.targetRange) continue;
|
|
19
|
+
const href = attachmentHref(file, link.target);
|
|
20
|
+
edits.set(link.targetRange.start, { ...link.targetRange, text: link.yamlScalar ? JSON.stringify(href) : href });
|
|
21
|
+
}
|
|
22
|
+
return [...edits.values()].sort((a,b) => b.start-a.start).reduce((text, edit) => text.slice(0, edit.start) + edit.text + text.slice(edit.end), source);
|
|
23
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { realpath } from 'node:fs/promises';
|
|
2
|
+
|
|
3
|
+
export const identityPath = '/.well-known/norna-dev';
|
|
4
|
+
export const readDevServerIdentity = async (state, siteRoot) => {
|
|
5
|
+
if (!state?.token || !Number.isInteger(state.port) || state.port < 1 || state.port > 65535) return null;
|
|
6
|
+
try {
|
|
7
|
+
const response = await fetch(`http://127.0.0.1:${state.port}${identityPath}`, { signal: AbortSignal.timeout(1500), redirect: 'error' });
|
|
8
|
+
if (!response.ok) return null;
|
|
9
|
+
const identity = await response.json();
|
|
10
|
+
return identity.token === state.token && Number.isInteger(identity.pid) && identity.pid > 0 && identity.siteRoot === await realpath(siteRoot) ? identity : null;
|
|
11
|
+
} catch { return null; }
|
|
12
|
+
};
|
|
13
|
+
export const verifyDevServer = async (state, siteRoot) => Number.isInteger(state?.pid) && state.pid > 0
|
|
14
|
+
&& (await readDevServerIdentity(state, siteRoot))?.pid === state.pid;
|
|
15
|
+
|
|
16
|
+
export const devServerIdentityPlugin = siteRoot => ({
|
|
17
|
+
name: 'norna-dev-server-identity',
|
|
18
|
+
async configureServer(server) {
|
|
19
|
+
const canonicalRoot = await realpath(siteRoot);
|
|
20
|
+
return () => {
|
|
21
|
+
server.middlewares.stack.unshift({ route: '', handle(request, response, next) {
|
|
22
|
+
if (request.url !== identityPath || request.method !== 'GET' || !process.env.NORNA_DEV_TOKEN) return next();
|
|
23
|
+
if (!['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(request.socket.remoteAddress)) return next();
|
|
24
|
+
response.setHeader('Content-Type', 'application/json');
|
|
25
|
+
response.setHeader('Cache-Control', 'no-store');
|
|
26
|
+
response.end(JSON.stringify({ siteRoot: canonicalRoot, token: process.env.NORNA_DEV_TOKEN, pid: process.pid }));
|
|
27
|
+
} });
|
|
28
|
+
};
|
|
29
|
+
},
|
|
30
|
+
});
|