@janga/norna 0.7.5 → 0.7.7
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 +5 -5
- package/astro.config.mjs +76 -0
- package/bin/norna-cli.mjs +2 -2
- package/docs/README.md +3 -3
- package/docs/commands.md +6 -6
- package/docs/configuration.md +3 -2
- package/docs/content.md +154 -95
- package/docs/design/site-examples-structure.md +1 -1
- package/docs/engine-development.md +4 -4
- package/docs/getting-started.md +6 -4
- package/docs/images-and-metadata.md +112 -20
- package/docs/routes.md +52 -33
- package/docs/site-structure.md +7 -6
- package/docs/theme.md +8 -7
- package/docs/typography.md +5 -5
- package/examples/dog-gallery/site/content.md +41 -46
- package/examples/dog-gallery/site/routes/{dog-care → 010-dog-care}/route-content.md +1 -4
- package/fixtures/basic/site/content.md +2 -4
- package/fixtures/content-model-v2/.astro/collections/site.schema.json +776 -0
- package/fixtures/content-model-v2/.astro/collections/theme.schema.json +668 -0
- package/fixtures/content-model-v2/.astro/content-assets.mjs +4 -0
- package/fixtures/content-model-v2/.astro/content-modules.mjs +1 -0
- package/fixtures/content-model-v2/.astro/content.d.ts +175 -0
- package/fixtures/content-model-v2/.astro/data-store.json +1 -0
- package/fixtures/content-model-v2/.astro/dev-local.json +8 -0
- package/fixtures/content-model-v2/.astro/dev.json +13 -0
- package/fixtures/content-model-v2/.astro/dev.log +231 -0
- package/fixtures/content-model-v2/.astro/settings.json +5 -0
- package/fixtures/content-model-v2/.astro/types.d.ts +2 -0
- package/fixtures/content-model-v2/dist/_astro/GalleryGrid.astro_astro_type_script_index_0_lang.B0kxDyGz.js +1 -0
- package/fixtures/content-model-v2/dist/_astro/SitePage.BbfZub4g.css +1 -0
- package/fixtures/content-model-v2/dist/guide/index.html +3 -0
- package/fixtures/content-model-v2/dist/images/generated/intro/detail-27930b14-480.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/intro/detail-27930b14-768.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/intro/detail-27930b14-971.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/intro/duplicate-49c98f6c-480.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/intro/duplicate-49c98f6c-768.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/intro/duplicate-49c98f6c-971.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/intro/hero-6d588a83-480.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/intro/hero-6d588a83-768.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/intro/hero-6d588a83-971.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/plain/image-3770d763-1080.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/plain/image-3770d763-1440.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/plain/image-3770d763-1600.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/plain/image-3770d763-480.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/plain/image-3770d763-768.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/routes/010-guide/images/intro/duplicate-5cf614d2-480.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/routes/010-guide/images/intro/duplicate-5cf614d2-768.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/routes/010-guide/images/intro/duplicate-5cf614d2-960.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/timed/slide-one-3770d763-1080.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/timed/slide-one-3770d763-1440.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/timed/slide-one-3770d763-1600.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/timed/slide-one-3770d763-480.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/timed/slide-one-3770d763-768.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/timed/slide-three-5bdec65e-1080.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/timed/slide-three-5bdec65e-1440.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/timed/slide-three-5bdec65e-1600.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/timed/slide-three-5bdec65e-480.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/timed/slide-three-5bdec65e-768.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/timed/slide-two-26c08aee-1080.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/timed/slide-two-26c08aee-1440.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/timed/slide-two-26c08aee-1600.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/timed/slide-two-26c08aee-480.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/timed/slide-two-26c08aee-768.webp +0 -0
- package/fixtures/content-model-v2/dist/index.html +8 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/_metadata.json +80 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/astro-D_GIpJIE.js +434 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/astro-D_GIpJIE.js.map +1 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/astro_n_aria-query.js +6390 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/astro_n_aria-query.js.map +1 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/astro_n_axobject-query.js +2702 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/astro_n_axobject-query.js.map +1 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/astro_n_html-escaper.js +65 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/astro_n_html-escaper.js.map +1 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/astro_runtime_client_dev-toolbar_entrypoint__js.js +455 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/astro_runtime_client_dev-toolbar_entrypoint__js.js.map +1 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/audit-DM9Hkl5B.js +1534 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/audit-DM9Hkl5B.js.map +1 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/embla-carousel.js +1389 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/embla-carousel.js.map +1 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/highlight-DnZ1ULOL.js +48 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/highlight-DnZ1ULOL.js.map +1 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/icons-RyebKi45.js +42 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/icons-RyebKi45.js.map +1 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/package.json +3 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/rolldown-runtime-BvCyGRYZ.js +4 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/settings-DaU-gNnK.js +190 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/settings-DaU-gNnK.js.map +1 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/toolbar-63PA619H.js +458 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/toolbar-63PA619H.js.map +1 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/ui-library--bkcNSYz.js +1016 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/ui-library--bkcNSYz.js.map +1 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/window-jPkpKVcy.js +32 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/window-jPkpKVcy.js.map +1 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/xray-BXCHpEJP.js +111 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/xray-BXCHpEJP.js.map +1 -0
- package/fixtures/content-model-v2/site/.norna/generated-images.json +210 -0
- package/fixtures/content-model-v2/site/config.mjs +11 -0
- package/fixtures/content-model-v2/site/content.md +84 -0
- package/fixtures/content-model-v2/site/images/card-section/adopt.svg +9 -0
- package/fixtures/content-model-v2/site/images/card-section/foster.svg +12 -0
- package/fixtures/content-model-v2/site/images/intro/detail.jpg +0 -0
- package/fixtures/content-model-v2/site/images/intro/duplicate.jpg +0 -0
- package/fixtures/content-model-v2/site/images/intro/hero.jpg +0 -0
- package/fixtures/content-model-v2/site/images/plain/image.jpg +0 -0
- package/fixtures/content-model-v2/site/images/timed/slide-one.jpg +0 -0
- package/fixtures/content-model-v2/site/images/timed/slide-three.jpg +0 -0
- package/fixtures/content-model-v2/site/images/timed/slide-two.jpg +0 -0
- package/fixtures/content-model-v2/site/routes/010-guide/images/intro/duplicate.jpg +0 -0
- package/fixtures/content-model-v2/site/routes/010-guide/route-content.md +17 -0
- package/fixtures/content-model-v2/site/theme.md +9 -0
- package/package.json +4 -2
- package/scripts/deploy-site.mjs +12 -5
- package/scripts/dev-local.mjs +6 -0
- package/scripts/generate-images.mjs +132 -56
- package/scripts/lib/image-dimensions.mjs +26 -0
- package/scripts/lib/norna-markdown-blocks.mjs +643 -0
- package/scripts/lib/route-model.mjs +17 -0
- package/scripts/lib/site-content.mjs +45 -37
- package/scripts/show-typography.mjs +18 -10
- package/scripts/sync-content-sections.mjs +280 -423
- package/scripts/test-content-check.mjs +59 -45
- package/scripts/test-content-model-v2.mjs +998 -0
- package/scripts/test-engine-commands.mjs +1 -1
- package/scripts/test-package-check.mjs +38 -11
- package/scripts/test-route-model.mjs +32 -0
- package/scripts/test-temporary-visibility.mjs +3 -6
- package/src/components/CardList.astro +77 -0
- package/src/components/GalleryGrid.astro +22 -12
- package/src/components/SiteNavigation.astro +5 -4
- package/src/components/SitePage.astro +12 -6
- package/src/components/SiteSection.astro +35 -13
- package/src/content.config.ts +8 -23
- package/src/lib/generatedImages.ts +50 -10
- package/src/lib/sectionContent.ts +130 -35
- package/src/lib/sitePages.ts +36 -15
- package/src/pages/[slug].astro +3 -3
- package/src/styles/global.css +187 -0
- package/starters/basic/site/content.md +10 -6
- package/starters/project/README.md +2 -2
- package/starters/project/site/content.md +0 -10
- package/starters/project/site/routes/{guide → 010-guide}/route-content.md +0 -6
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@janga/norna",
|
|
3
3
|
"description": "A command-line publishing workflow for simple static sites.",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.7.
|
|
5
|
+
"version": "0.7.7",
|
|
6
6
|
"license": "GPL-3.0-only",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
@@ -62,9 +62,11 @@
|
|
|
62
62
|
"deploy:watch": "node bin/norna.mjs deploy:watch",
|
|
63
63
|
"doctor": "node bin/norna.mjs doctor",
|
|
64
64
|
"demo:build": "node bin/norna.mjs --site-dir examples/dog-gallery/site build",
|
|
65
|
-
"test": "npm run test:content-check && npm run test:site-public && npm run test:cli-discovery && npm run test:engine-commands && npm run test:ci-lockfile && npm run test:temporary-visibility && npm run test:fixture:build && npm run package:check",
|
|
65
|
+
"test": "npm run test:route-model && npm run test:content-check && npm run test:content-model-v2 && npm run test:site-public && npm run test:cli-discovery && npm run test:engine-commands && npm run test:ci-lockfile && npm run test:temporary-visibility && npm run test:fixture:build && npm run package:check",
|
|
66
66
|
"test:fixture:build": "node bin/norna.mjs --site-dir fixtures/basic/site build",
|
|
67
|
+
"test:route-model": "node scripts/test-route-model.mjs",
|
|
67
68
|
"test:content-check": "node scripts/test-content-check.mjs",
|
|
69
|
+
"test:content-model-v2": "node scripts/test-content-model-v2.mjs",
|
|
68
70
|
"test:site-public": "node scripts/test-site-public.mjs",
|
|
69
71
|
"test:cli-discovery": "node scripts/test-cli-discovery.mjs",
|
|
70
72
|
"test:engine-commands": "node scripts/test-engine-commands.mjs",
|
package/scripts/deploy-site.mjs
CHANGED
|
@@ -2,12 +2,16 @@ import { execFile, spawn } from 'node:child_process';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { promisify } from 'node:util';
|
|
4
4
|
import {
|
|
5
|
-
|
|
5
|
+
getBodySections,
|
|
6
6
|
getContentFiles,
|
|
7
7
|
readSiteFile,
|
|
8
8
|
supportedImageExtensions,
|
|
9
9
|
toPosixPath,
|
|
10
10
|
} from './lib/site-content.mjs';
|
|
11
|
+
import {
|
|
12
|
+
extractNornaMarkdownBlocks,
|
|
13
|
+
getNornaBlockImageReferences,
|
|
14
|
+
} from './lib/norna-markdown-blocks.mjs';
|
|
11
15
|
import { projectConfig } from './lib/project-config.mjs';
|
|
12
16
|
import {
|
|
13
17
|
engineRoot,
|
|
@@ -144,10 +148,13 @@ const getExpectedImagePaths = async () => {
|
|
|
144
148
|
const imagePaths = new Set();
|
|
145
149
|
|
|
146
150
|
for (const contentFile of contentFiles) {
|
|
147
|
-
const {
|
|
151
|
+
const { body } = await readSiteFile(contentFile.contentPath, contentFile.contentLabel);
|
|
152
|
+
|
|
153
|
+
for (const section of getBodySections(body).sections) {
|
|
154
|
+
if (!section.id) continue;
|
|
148
155
|
|
|
149
|
-
|
|
150
|
-
for (const image of
|
|
156
|
+
const blocks = extractNornaMarkdownBlocks(section.text, { label: contentFile.contentLabel });
|
|
157
|
+
for (const { image } of getNornaBlockImageReferences(blocks)) {
|
|
151
158
|
if (image.includes('/') || image.includes('\\')) continue;
|
|
152
159
|
if (!supportedImageExtensions.has(path.extname(image).toLowerCase())) continue;
|
|
153
160
|
|
|
@@ -206,7 +213,7 @@ const assertDeployableStatus = async (entries, expectedImagePaths) => {
|
|
|
206
213
|
if (unexpectedUntracked.length > 0) {
|
|
207
214
|
fail([
|
|
208
215
|
'Refusing to deploy: unexpected untracked files are present.',
|
|
209
|
-
`Only new referenced images under ${siteImagesLabel}/<section-id>/ or ${siteRoutesLabel}/<route-
|
|
216
|
+
`Only new referenced images under ${siteImagesLabel}/<section-id>/ or ${siteRoutesLabel}/<NNN-route-id>/images/<section-id>/ are staged automatically.`,
|
|
210
217
|
...unexpectedUntracked.map((entry) => `- ${formatEntry(entry)}`),
|
|
211
218
|
].join('\n'));
|
|
212
219
|
}
|
package/scripts/dev-local.mjs
CHANGED
|
@@ -37,6 +37,11 @@ const syncSitePublic = async () => execFileAsync(process.execPath, [path.join(en
|
|
|
37
37
|
maxBuffer: 1024 * 1024 * 10,
|
|
38
38
|
});
|
|
39
39
|
|
|
40
|
+
const generateImages = async () => execFileAsync(process.execPath, [path.join(engineRoot, 'scripts', 'generate-images.mjs')], {
|
|
41
|
+
cwd: siteProjectRoot,
|
|
42
|
+
maxBuffer: 1024 * 1024 * 10,
|
|
43
|
+
});
|
|
44
|
+
|
|
40
45
|
const getPortPids = async () => {
|
|
41
46
|
try {
|
|
42
47
|
const { stdout } = await execFileAsync('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-t']);
|
|
@@ -266,6 +271,7 @@ const startServer = async ({ host = localHost, open = true, killBlockingPort = f
|
|
|
266
271
|
}
|
|
267
272
|
|
|
268
273
|
await syncSitePublic();
|
|
274
|
+
await generateImages();
|
|
269
275
|
await runAstroInherit(['dev', '--background', '--host', host, '--port', String(port)]);
|
|
270
276
|
await waitForServer();
|
|
271
277
|
|
|
@@ -1,20 +1,28 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
|
-
import { mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { copyFile, mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { promisify } from 'node:util';
|
|
6
6
|
import {
|
|
7
|
-
|
|
7
|
+
getBodySections,
|
|
8
8
|
getContentFiles,
|
|
9
|
-
getImageIndex,
|
|
10
9
|
readSiteFile,
|
|
10
|
+
rasterImageExtensions,
|
|
11
|
+
staticImageExtensions,
|
|
11
12
|
supportedImageExtensions,
|
|
13
|
+
toPosixPath,
|
|
12
14
|
} from './lib/site-content.mjs';
|
|
15
|
+
import { readImageDimensions } from './lib/image-dimensions.mjs';
|
|
16
|
+
import {
|
|
17
|
+
extractNornaMarkdownBlocks,
|
|
18
|
+
getNornaBlockImageReferences,
|
|
19
|
+
} from './lib/norna-markdown-blocks.mjs';
|
|
13
20
|
import {
|
|
14
21
|
astroPublicDir,
|
|
15
22
|
generatedImagesDir,
|
|
16
23
|
generatedImagesManifestPath,
|
|
17
24
|
originalImagesDir,
|
|
25
|
+
siteDir,
|
|
18
26
|
siteImagesDir,
|
|
19
27
|
} from './lib/site-paths.mjs';
|
|
20
28
|
|
|
@@ -87,23 +95,40 @@ const getGeneratedPath = (sourcePath, sourceHash, width) => {
|
|
|
87
95
|
return path.join(generatedImagesDir, parsed.dir, `${parsed.name}-${getSourceHashSlug(sourceHash)}-${width}.webp`);
|
|
88
96
|
};
|
|
89
97
|
|
|
98
|
+
const getOriginalPath = (sourceKey, sourceHash) => {
|
|
99
|
+
const parsed = path.parse(sourceKey);
|
|
100
|
+
return path.join(originalImagesDir, parsed.dir, `${parsed.name}-${getSourceHashSlug(sourceHash)}${parsed.ext}`);
|
|
101
|
+
};
|
|
102
|
+
|
|
90
103
|
const fail = (message) => {
|
|
91
104
|
throw new Error(message);
|
|
92
105
|
};
|
|
93
106
|
|
|
107
|
+
const getImageSourceKey = (contentFile, sectionId, image) => (
|
|
108
|
+
contentFile.isHome
|
|
109
|
+
? `images/${sectionId}/${image}`
|
|
110
|
+
: `routes/${contentFile.routeDirectory}/images/${sectionId}/${image}`
|
|
111
|
+
);
|
|
112
|
+
|
|
94
113
|
const getReferencedImages = async (contentFile) => {
|
|
95
|
-
const {
|
|
96
|
-
const sections =
|
|
114
|
+
const { body } = await readSiteFile(contentFile.contentPath, contentFile.contentLabel);
|
|
115
|
+
const { sections } = getBodySections(body);
|
|
97
116
|
const references = [];
|
|
98
117
|
|
|
99
118
|
for (const section of sections) {
|
|
100
|
-
|
|
101
|
-
|
|
119
|
+
if (!section.id) {
|
|
120
|
+
fail(`${contentFile.contentLabel}: section heading "${section.heading}" is missing an explicit id.`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const blocks = extractNornaMarkdownBlocks(section.text, { label: contentFile.contentLabel });
|
|
124
|
+
for (const { image, line, blockDisplayType } of getNornaBlockImageReferences(blocks)) {
|
|
102
125
|
references.push({
|
|
103
126
|
contentFile,
|
|
104
127
|
image,
|
|
105
128
|
line,
|
|
129
|
+
blockDisplayType,
|
|
106
130
|
sectionId: section.id,
|
|
131
|
+
sourceKey: getImageSourceKey(contentFile, section.id, image),
|
|
107
132
|
});
|
|
108
133
|
}
|
|
109
134
|
}
|
|
@@ -111,7 +136,7 @@ const getReferencedImages = async (contentFile) => {
|
|
|
111
136
|
return references;
|
|
112
137
|
};
|
|
113
138
|
|
|
114
|
-
const getContentSourcePath = ({ image,
|
|
139
|
+
const getContentSourcePath = ({ image, sourceKey }) => {
|
|
115
140
|
if (image.includes('/') || image.includes('\\') || image.startsWith('/')) {
|
|
116
141
|
fail(`Image reference must be a filename without a directory: ${image}`);
|
|
117
142
|
}
|
|
@@ -121,32 +146,24 @@ const getContentSourcePath = ({ image, line }, imageIndex) => {
|
|
|
121
146
|
fail(`Image reference uses an unsupported file type: ${image}`);
|
|
122
147
|
}
|
|
123
148
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
if (!sourcePath) {
|
|
127
|
-
fail(`Image file "${image}" does not exist anywhere under the page images directory.`);
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
return sourcePath;
|
|
149
|
+
return path.join(siteDir, sourceKey);
|
|
131
150
|
};
|
|
132
151
|
|
|
133
152
|
const getReferencedSources = async () => {
|
|
134
153
|
const contentFiles = await getContentFiles();
|
|
135
|
-
const seen = new
|
|
154
|
+
const seen = new Set();
|
|
136
155
|
const sources = [];
|
|
137
156
|
|
|
138
157
|
for (const contentFile of contentFiles) {
|
|
139
158
|
const references = await getReferencedImages(contentFile);
|
|
140
|
-
const imageIndex = await getImageIndex(contentFile.imagesDir, fail);
|
|
141
159
|
|
|
142
160
|
for (const reference of references) {
|
|
143
|
-
const sourcePath = getContentSourcePath(reference
|
|
144
|
-
const siteImagePath =
|
|
161
|
+
const sourcePath = getContentSourcePath(reference);
|
|
162
|
+
const siteImagePath = toPosixPath(path.relative(contentFile.imagesDir, sourcePath));
|
|
145
163
|
const imageName = path.basename(sourcePath);
|
|
146
164
|
|
|
147
|
-
if (seen.has(
|
|
148
|
-
|
|
149
|
-
fail(`Image reference "${imageName}" appears more than once, in ${firstReference.contentFile.contentLabel} line ${firstReference.line} and ${reference.contentFile.contentLabel} line ${reference.line}.`);
|
|
165
|
+
if (seen.has(reference.sourceKey)) {
|
|
166
|
+
continue;
|
|
150
167
|
}
|
|
151
168
|
|
|
152
169
|
const fileStat = await stat(sourcePath).catch(() => null);
|
|
@@ -159,12 +176,12 @@ const getReferencedSources = async () => {
|
|
|
159
176
|
fail(`Image "${imageName}" is used in section "${reference.sectionId}" but is located in ${contentFile.imagesLabel}/${currentDirectory}/. Run norna content:sync, or npm run norna:sync in starter-style repositories, to move it.`);
|
|
160
177
|
}
|
|
161
178
|
|
|
162
|
-
seen.
|
|
163
|
-
sources.push(sourcePath);
|
|
179
|
+
seen.add(reference.sourceKey);
|
|
180
|
+
sources.push({ sourcePath, sourceKey: reference.sourceKey, reference });
|
|
164
181
|
}
|
|
165
182
|
}
|
|
166
183
|
|
|
167
|
-
return sources.sort();
|
|
184
|
+
return sources.sort((left, right) => left.sourceKey.localeCompare(right.sourceKey, 'sv'));
|
|
168
185
|
};
|
|
169
186
|
|
|
170
187
|
const getOrientation = ({ width, height }) => {
|
|
@@ -172,21 +189,32 @@ const getOrientation = ({ width, height }) => {
|
|
|
172
189
|
return width > height ? 'landscape' : 'portrait';
|
|
173
190
|
};
|
|
174
191
|
|
|
175
|
-
const validateCarouselOrientations = (
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
);
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
192
|
+
const validateCarouselOrientations = async (manifest) => {
|
|
193
|
+
const contentFiles = await getContentFiles();
|
|
194
|
+
|
|
195
|
+
for (const contentFile of contentFiles) {
|
|
196
|
+
const { body } = await readSiteFile(contentFile.contentPath, contentFile.contentLabel);
|
|
197
|
+
const { sections } = getBodySections(body);
|
|
198
|
+
|
|
199
|
+
for (const section of sections) {
|
|
200
|
+
if (!section.id) continue;
|
|
201
|
+
|
|
202
|
+
const blocks = extractNornaMarkdownBlocks(section.text, { label: contentFile.contentLabel });
|
|
203
|
+
for (const block of blocks.filter((candidate) => candidate.type === 'image-carousel')) {
|
|
204
|
+
const orientations = new Set(
|
|
205
|
+
block.images
|
|
206
|
+
.map((image) => manifest[getImageSourceKey(contentFile, section.id, image.image)])
|
|
207
|
+
.filter(Boolean)
|
|
208
|
+
.filter((entry) => Number.isFinite(entry.width) && Number.isFinite(entry.height))
|
|
209
|
+
.map(getOrientation)
|
|
189
210
|
);
|
|
211
|
+
|
|
212
|
+
orientations.delete('square');
|
|
213
|
+
if (orientations.size > 1) {
|
|
214
|
+
throw new Error(
|
|
215
|
+
`Carousel in section "${section.id}" on line ${block.line} mixes landscape and portrait images. Use landscape images with optional square images, or portrait images with optional square images.`,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
190
218
|
}
|
|
191
219
|
}
|
|
192
220
|
}
|
|
@@ -277,6 +305,20 @@ const getReusableEntry = async (sourcePath, previousEntry, sourceHash) => {
|
|
|
277
305
|
};
|
|
278
306
|
};
|
|
279
307
|
|
|
308
|
+
const getReusableStaticEntry = async (previousEntry, sourceHash) => {
|
|
309
|
+
if (
|
|
310
|
+
previousEntry?.kind !== 'static'
|
|
311
|
+
|| previousEntry?.sourceHash !== sourceHash
|
|
312
|
+
|| previousEntry?.outputVersion !== imageOutputVersion
|
|
313
|
+
|| typeof previousEntry?.src !== 'string'
|
|
314
|
+
|| !(await fileExists(getFilePathFromPublicPath(previousEntry.src)))
|
|
315
|
+
) {
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return previousEntry;
|
|
320
|
+
};
|
|
321
|
+
|
|
280
322
|
const listGeneratedFiles = async (directory) => {
|
|
281
323
|
const entries = await readdir(directory, { withFileTypes: true }).catch((error) => {
|
|
282
324
|
if (error?.code === 'ENOENT') {
|
|
@@ -300,13 +342,22 @@ const listGeneratedFiles = async (directory) => {
|
|
|
300
342
|
return files;
|
|
301
343
|
};
|
|
302
344
|
|
|
345
|
+
const getManifestOutputPaths = (entry) => [
|
|
346
|
+
...(entry.variants ?? []).map((variant) => getFilePathFromPublicPath(variant.src)),
|
|
347
|
+
...(entry.kind === 'static' && typeof entry.src === 'string'
|
|
348
|
+
? [getFilePathFromPublicPath(entry.src)]
|
|
349
|
+
: []),
|
|
350
|
+
];
|
|
351
|
+
|
|
303
352
|
const removeUnreferencedGeneratedFiles = async (manifest) => {
|
|
304
353
|
const expectedFiles = new Set(
|
|
305
354
|
Object.values(manifest)
|
|
306
|
-
.flatMap(
|
|
307
|
-
.map((variant) => getFilePathFromPublicPath(variant.src)),
|
|
355
|
+
.flatMap(getManifestOutputPaths),
|
|
308
356
|
);
|
|
309
|
-
const generatedFiles =
|
|
357
|
+
const generatedFiles = [
|
|
358
|
+
...await listGeneratedFiles(generatedImagesDir),
|
|
359
|
+
...await listGeneratedFiles(originalImagesDir),
|
|
360
|
+
];
|
|
310
361
|
|
|
311
362
|
for (const generatedFile of generatedFiles) {
|
|
312
363
|
if (!expectedFiles.has(generatedFile)) {
|
|
@@ -315,17 +366,13 @@ const removeUnreferencedGeneratedFiles = async (manifest) => {
|
|
|
315
366
|
}
|
|
316
367
|
};
|
|
317
368
|
|
|
318
|
-
await rm(originalImagesDir, { recursive: true, force: true });
|
|
319
369
|
await mkdir(generatedImagesDir, { recursive: true });
|
|
320
370
|
|
|
321
|
-
const contentFiles = await getContentFiles();
|
|
322
|
-
const frontmatterSections = (await Promise.all(contentFiles.map(async (contentFile) => {
|
|
323
|
-
const { frontmatter } = await readSiteFile(contentFile.contentPath, contentFile.contentLabel);
|
|
324
|
-
return getFrontmatterSections(frontmatter);
|
|
325
|
-
}))).flat();
|
|
326
|
-
const sources = await getReferencedSources();
|
|
327
371
|
const previousManifest = await readManifest();
|
|
372
|
+
const sources = await getReferencedSources();
|
|
328
373
|
const manifest = {};
|
|
374
|
+
const isStaticSource = (sourcePath) => staticImageExtensions.has(path.extname(sourcePath).toLowerCase());
|
|
375
|
+
const isRasterSource = (sourcePath) => rasterImageExtensions.has(path.extname(sourcePath).toLowerCase());
|
|
329
376
|
|
|
330
377
|
if (sources.length === 0) {
|
|
331
378
|
await removeUnreferencedGeneratedFiles(manifest);
|
|
@@ -334,18 +381,47 @@ if (sources.length === 0) {
|
|
|
334
381
|
process.exit(0);
|
|
335
382
|
}
|
|
336
383
|
|
|
337
|
-
const imageMagick =
|
|
384
|
+
const imageMagick = sources.some(({ sourcePath }) => isRasterSource(sourcePath))
|
|
385
|
+
? await getImageMagick()
|
|
386
|
+
: null;
|
|
338
387
|
|
|
339
|
-
for (const sourcePath of sources) {
|
|
340
|
-
const imageName = path.basename(sourcePath);
|
|
388
|
+
for (const { sourcePath, sourceKey } of sources) {
|
|
341
389
|
const sourceHash = await getSourceHash(sourcePath);
|
|
342
|
-
|
|
390
|
+
|
|
391
|
+
if (isStaticSource(sourcePath)) {
|
|
392
|
+
const reusableEntry = await getReusableStaticEntry(previousManifest[sourceKey], sourceHash);
|
|
393
|
+
|
|
394
|
+
if (reusableEntry) {
|
|
395
|
+
manifest[sourceKey] = reusableEntry;
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const outputPath = getOriginalPath(sourceKey, sourceHash);
|
|
400
|
+
await mkdir(path.dirname(outputPath), { recursive: true });
|
|
401
|
+
await copyFile(sourcePath, outputPath);
|
|
402
|
+
|
|
403
|
+
const dimensions = await readImageDimensions(sourcePath).catch(() => null);
|
|
404
|
+
manifest[sourceKey] = {
|
|
405
|
+
kind: 'static',
|
|
406
|
+
outputVersion: imageOutputVersion,
|
|
407
|
+
sourceHash,
|
|
408
|
+
src: getPublicPath(outputPath),
|
|
409
|
+
...(dimensions ? { width: dimensions.width, height: dimensions.height } : {}),
|
|
410
|
+
};
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const reusableEntry = await getReusableEntry(sourcePath, previousManifest[sourceKey], sourceHash);
|
|
343
415
|
|
|
344
416
|
if (reusableEntry) {
|
|
345
|
-
manifest[
|
|
417
|
+
manifest[sourceKey] = reusableEntry;
|
|
346
418
|
continue;
|
|
347
419
|
}
|
|
348
420
|
|
|
421
|
+
if (!imageMagick) {
|
|
422
|
+
throw new Error(`ImageMagick is missing for raster image: ${sourcePath}`);
|
|
423
|
+
}
|
|
424
|
+
|
|
349
425
|
const dimensions = await identify(sourcePath);
|
|
350
426
|
const variantWidths = getVariantWidths(dimensions);
|
|
351
427
|
const variants = getVariants(sourcePath, sourceHash, variantWidths);
|
|
@@ -354,7 +430,7 @@ for (const sourcePath of sources) {
|
|
|
354
430
|
await convert(sourcePath, outputPath, width);
|
|
355
431
|
}
|
|
356
432
|
|
|
357
|
-
manifest[
|
|
433
|
+
manifest[sourceKey] = {
|
|
358
434
|
outputVersion: imageOutputVersion,
|
|
359
435
|
sourceHash,
|
|
360
436
|
width: dimensions.width,
|
|
@@ -363,7 +439,7 @@ for (const sourcePath of sources) {
|
|
|
363
439
|
};
|
|
364
440
|
}
|
|
365
441
|
|
|
366
|
-
validateCarouselOrientations(
|
|
442
|
+
await validateCarouselOrientations(manifest);
|
|
367
443
|
await removeUnreferencedGeneratedFiles(manifest);
|
|
368
444
|
await mkdir(path.dirname(generatedImagesManifestPath), { recursive: true });
|
|
369
445
|
await writeFile(generatedImagesManifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
2
3
|
|
|
3
4
|
const pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
4
5
|
const jpegStartOfFrameMarkers = new Set([
|
|
@@ -67,9 +68,34 @@ const readJpegDimensions = (buffer) => {
|
|
|
67
68
|
return null;
|
|
68
69
|
};
|
|
69
70
|
|
|
71
|
+
const parseSvgLength = (value) => {
|
|
72
|
+
const match = String(value ?? '').trim().match(/^(\d+(?:\.\d+)?|\.\d+)(?:px)?$/i);
|
|
73
|
+
return match ? Number(match[1]) : null;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const readSvgDimensions = (source) => {
|
|
77
|
+
const svgTag = source.match(/<svg\b[^>]*>/i)?.[0];
|
|
78
|
+
if (!svgTag) return null;
|
|
79
|
+
|
|
80
|
+
const viewBox = svgTag.match(/\bviewBox\s*=\s*["']\s*([+-]?(?:\d+(?:\.\d+)?|\.\d+))[\s,]+([+-]?(?:\d+(?:\.\d+)?|\.\d+))[\s,]+([+-]?(?:\d+(?:\.\d+)?|\.\d+))[\s,]+([+-]?(?:\d+(?:\.\d+)?|\.\d+))\s*["']/i);
|
|
81
|
+
if (viewBox) {
|
|
82
|
+
const width = Number(viewBox[3]);
|
|
83
|
+
const height = Number(viewBox[4]);
|
|
84
|
+
return width > 0 && height > 0 ? { width, height } : null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const width = parseSvgLength(svgTag.match(/\bwidth\s*=\s*["']([^"']+)["']/i)?.[1]);
|
|
88
|
+
const height = parseSvgLength(svgTag.match(/\bheight\s*=\s*["']([^"']+)["']/i)?.[1]);
|
|
89
|
+
return width && height ? { width, height } : null;
|
|
90
|
+
};
|
|
91
|
+
|
|
70
92
|
export const readImageDimensions = async (filePath) => {
|
|
71
93
|
const buffer = await readFile(filePath);
|
|
72
94
|
|
|
95
|
+
if (path.extname(filePath).toLowerCase() === '.svg') {
|
|
96
|
+
return readSvgDimensions(buffer.toString('utf8'));
|
|
97
|
+
}
|
|
98
|
+
|
|
73
99
|
if (isPng(buffer)) {
|
|
74
100
|
return readPngDimensions(buffer);
|
|
75
101
|
}
|