@janga/norna 0.7.6 → 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.
Files changed (137) hide show
  1. package/README.md +5 -5
  2. package/astro.config.mjs +76 -0
  3. package/bin/norna-cli.mjs +2 -2
  4. package/docs/README.md +3 -3
  5. package/docs/commands.md +6 -6
  6. package/docs/configuration.md +3 -2
  7. package/docs/content.md +152 -91
  8. package/docs/design/site-examples-structure.md +1 -1
  9. package/docs/engine-development.md +3 -3
  10. package/docs/getting-started.md +4 -3
  11. package/docs/images-and-metadata.md +112 -20
  12. package/docs/routes.md +9 -12
  13. package/docs/site-structure.md +4 -3
  14. package/docs/theme.md +8 -7
  15. package/docs/typography.md +5 -5
  16. package/examples/dog-gallery/site/content.md +41 -45
  17. package/examples/dog-gallery/site/routes/010-dog-care/route-content.md +1 -3
  18. package/fixtures/basic/site/content.md +2 -4
  19. package/fixtures/content-model-v2/.astro/collections/site.schema.json +776 -0
  20. package/fixtures/content-model-v2/.astro/collections/theme.schema.json +668 -0
  21. package/fixtures/content-model-v2/.astro/content-assets.mjs +4 -0
  22. package/fixtures/content-model-v2/.astro/content-modules.mjs +1 -0
  23. package/fixtures/content-model-v2/.astro/content.d.ts +175 -0
  24. package/fixtures/content-model-v2/.astro/data-store.json +1 -0
  25. package/fixtures/content-model-v2/.astro/dev-local.json +8 -0
  26. package/fixtures/content-model-v2/.astro/dev.json +13 -0
  27. package/fixtures/content-model-v2/.astro/dev.log +231 -0
  28. package/fixtures/content-model-v2/.astro/settings.json +5 -0
  29. package/fixtures/content-model-v2/.astro/types.d.ts +2 -0
  30. package/fixtures/content-model-v2/dist/_astro/GalleryGrid.astro_astro_type_script_index_0_lang.B0kxDyGz.js +1 -0
  31. package/fixtures/content-model-v2/dist/_astro/SitePage.BbfZub4g.css +1 -0
  32. package/fixtures/content-model-v2/dist/guide/index.html +3 -0
  33. package/fixtures/content-model-v2/dist/images/generated/intro/detail-27930b14-480.webp +0 -0
  34. package/fixtures/content-model-v2/dist/images/generated/intro/detail-27930b14-768.webp +0 -0
  35. package/fixtures/content-model-v2/dist/images/generated/intro/detail-27930b14-971.webp +0 -0
  36. package/fixtures/content-model-v2/dist/images/generated/intro/duplicate-49c98f6c-480.webp +0 -0
  37. package/fixtures/content-model-v2/dist/images/generated/intro/duplicate-49c98f6c-768.webp +0 -0
  38. package/fixtures/content-model-v2/dist/images/generated/intro/duplicate-49c98f6c-971.webp +0 -0
  39. package/fixtures/content-model-v2/dist/images/generated/intro/hero-6d588a83-480.webp +0 -0
  40. package/fixtures/content-model-v2/dist/images/generated/intro/hero-6d588a83-768.webp +0 -0
  41. package/fixtures/content-model-v2/dist/images/generated/intro/hero-6d588a83-971.webp +0 -0
  42. package/fixtures/content-model-v2/dist/images/generated/plain/image-3770d763-1080.webp +0 -0
  43. package/fixtures/content-model-v2/dist/images/generated/plain/image-3770d763-1440.webp +0 -0
  44. package/fixtures/content-model-v2/dist/images/generated/plain/image-3770d763-1600.webp +0 -0
  45. package/fixtures/content-model-v2/dist/images/generated/plain/image-3770d763-480.webp +0 -0
  46. package/fixtures/content-model-v2/dist/images/generated/plain/image-3770d763-768.webp +0 -0
  47. package/fixtures/content-model-v2/dist/images/generated/routes/010-guide/images/intro/duplicate-5cf614d2-480.webp +0 -0
  48. package/fixtures/content-model-v2/dist/images/generated/routes/010-guide/images/intro/duplicate-5cf614d2-768.webp +0 -0
  49. package/fixtures/content-model-v2/dist/images/generated/routes/010-guide/images/intro/duplicate-5cf614d2-960.webp +0 -0
  50. package/fixtures/content-model-v2/dist/images/generated/timed/slide-one-3770d763-1080.webp +0 -0
  51. package/fixtures/content-model-v2/dist/images/generated/timed/slide-one-3770d763-1440.webp +0 -0
  52. package/fixtures/content-model-v2/dist/images/generated/timed/slide-one-3770d763-1600.webp +0 -0
  53. package/fixtures/content-model-v2/dist/images/generated/timed/slide-one-3770d763-480.webp +0 -0
  54. package/fixtures/content-model-v2/dist/images/generated/timed/slide-one-3770d763-768.webp +0 -0
  55. package/fixtures/content-model-v2/dist/images/generated/timed/slide-three-5bdec65e-1080.webp +0 -0
  56. package/fixtures/content-model-v2/dist/images/generated/timed/slide-three-5bdec65e-1440.webp +0 -0
  57. package/fixtures/content-model-v2/dist/images/generated/timed/slide-three-5bdec65e-1600.webp +0 -0
  58. package/fixtures/content-model-v2/dist/images/generated/timed/slide-three-5bdec65e-480.webp +0 -0
  59. package/fixtures/content-model-v2/dist/images/generated/timed/slide-three-5bdec65e-768.webp +0 -0
  60. package/fixtures/content-model-v2/dist/images/generated/timed/slide-two-26c08aee-1080.webp +0 -0
  61. package/fixtures/content-model-v2/dist/images/generated/timed/slide-two-26c08aee-1440.webp +0 -0
  62. package/fixtures/content-model-v2/dist/images/generated/timed/slide-two-26c08aee-1600.webp +0 -0
  63. package/fixtures/content-model-v2/dist/images/generated/timed/slide-two-26c08aee-480.webp +0 -0
  64. package/fixtures/content-model-v2/dist/images/generated/timed/slide-two-26c08aee-768.webp +0 -0
  65. package/fixtures/content-model-v2/dist/index.html +8 -0
  66. package/fixtures/content-model-v2/node_modules/.vite/deps/_metadata.json +80 -0
  67. package/fixtures/content-model-v2/node_modules/.vite/deps/astro-D_GIpJIE.js +434 -0
  68. package/fixtures/content-model-v2/node_modules/.vite/deps/astro-D_GIpJIE.js.map +1 -0
  69. package/fixtures/content-model-v2/node_modules/.vite/deps/astro_n_aria-query.js +6390 -0
  70. package/fixtures/content-model-v2/node_modules/.vite/deps/astro_n_aria-query.js.map +1 -0
  71. package/fixtures/content-model-v2/node_modules/.vite/deps/astro_n_axobject-query.js +2702 -0
  72. package/fixtures/content-model-v2/node_modules/.vite/deps/astro_n_axobject-query.js.map +1 -0
  73. package/fixtures/content-model-v2/node_modules/.vite/deps/astro_n_html-escaper.js +65 -0
  74. package/fixtures/content-model-v2/node_modules/.vite/deps/astro_n_html-escaper.js.map +1 -0
  75. package/fixtures/content-model-v2/node_modules/.vite/deps/astro_runtime_client_dev-toolbar_entrypoint__js.js +455 -0
  76. package/fixtures/content-model-v2/node_modules/.vite/deps/astro_runtime_client_dev-toolbar_entrypoint__js.js.map +1 -0
  77. package/fixtures/content-model-v2/node_modules/.vite/deps/audit-DM9Hkl5B.js +1534 -0
  78. package/fixtures/content-model-v2/node_modules/.vite/deps/audit-DM9Hkl5B.js.map +1 -0
  79. package/fixtures/content-model-v2/node_modules/.vite/deps/embla-carousel.js +1389 -0
  80. package/fixtures/content-model-v2/node_modules/.vite/deps/embla-carousel.js.map +1 -0
  81. package/fixtures/content-model-v2/node_modules/.vite/deps/highlight-DnZ1ULOL.js +48 -0
  82. package/fixtures/content-model-v2/node_modules/.vite/deps/highlight-DnZ1ULOL.js.map +1 -0
  83. package/fixtures/content-model-v2/node_modules/.vite/deps/icons-RyebKi45.js +42 -0
  84. package/fixtures/content-model-v2/node_modules/.vite/deps/icons-RyebKi45.js.map +1 -0
  85. package/fixtures/content-model-v2/node_modules/.vite/deps/package.json +3 -0
  86. package/fixtures/content-model-v2/node_modules/.vite/deps/rolldown-runtime-BvCyGRYZ.js +4 -0
  87. package/fixtures/content-model-v2/node_modules/.vite/deps/settings-DaU-gNnK.js +190 -0
  88. package/fixtures/content-model-v2/node_modules/.vite/deps/settings-DaU-gNnK.js.map +1 -0
  89. package/fixtures/content-model-v2/node_modules/.vite/deps/toolbar-63PA619H.js +458 -0
  90. package/fixtures/content-model-v2/node_modules/.vite/deps/toolbar-63PA619H.js.map +1 -0
  91. package/fixtures/content-model-v2/node_modules/.vite/deps/ui-library--bkcNSYz.js +1016 -0
  92. package/fixtures/content-model-v2/node_modules/.vite/deps/ui-library--bkcNSYz.js.map +1 -0
  93. package/fixtures/content-model-v2/node_modules/.vite/deps/window-jPkpKVcy.js +32 -0
  94. package/fixtures/content-model-v2/node_modules/.vite/deps/window-jPkpKVcy.js.map +1 -0
  95. package/fixtures/content-model-v2/node_modules/.vite/deps/xray-BXCHpEJP.js +111 -0
  96. package/fixtures/content-model-v2/node_modules/.vite/deps/xray-BXCHpEJP.js.map +1 -0
  97. package/fixtures/content-model-v2/site/.norna/generated-images.json +210 -0
  98. package/fixtures/content-model-v2/site/config.mjs +11 -0
  99. package/fixtures/content-model-v2/site/content.md +84 -0
  100. package/fixtures/content-model-v2/site/images/card-section/adopt.svg +9 -0
  101. package/fixtures/content-model-v2/site/images/card-section/foster.svg +12 -0
  102. package/fixtures/content-model-v2/site/images/intro/detail.jpg +0 -0
  103. package/fixtures/content-model-v2/site/images/intro/duplicate.jpg +0 -0
  104. package/fixtures/content-model-v2/site/images/intro/hero.jpg +0 -0
  105. package/fixtures/content-model-v2/site/images/plain/image.jpg +0 -0
  106. package/fixtures/content-model-v2/site/images/timed/slide-one.jpg +0 -0
  107. package/fixtures/content-model-v2/site/images/timed/slide-three.jpg +0 -0
  108. package/fixtures/content-model-v2/site/images/timed/slide-two.jpg +0 -0
  109. package/fixtures/content-model-v2/site/routes/010-guide/images/intro/duplicate.jpg +0 -0
  110. package/fixtures/content-model-v2/site/routes/010-guide/route-content.md +17 -0
  111. package/fixtures/content-model-v2/site/theme.md +9 -0
  112. package/package.json +3 -2
  113. package/scripts/deploy-site.mjs +11 -4
  114. package/scripts/dev-local.mjs +6 -0
  115. package/scripts/generate-images.mjs +132 -56
  116. package/scripts/lib/image-dimensions.mjs +26 -0
  117. package/scripts/lib/norna-markdown-blocks.mjs +643 -0
  118. package/scripts/lib/site-content.mjs +31 -29
  119. package/scripts/show-typography.mjs +17 -9
  120. package/scripts/sync-content-sections.mjs +280 -411
  121. package/scripts/test-content-check.mjs +59 -45
  122. package/scripts/test-content-model-v2.mjs +998 -0
  123. package/scripts/test-engine-commands.mjs +1 -1
  124. package/scripts/test-package-check.mjs +36 -8
  125. package/scripts/test-temporary-visibility.mjs +3 -6
  126. package/src/components/CardList.astro +77 -0
  127. package/src/components/GalleryGrid.astro +22 -12
  128. package/src/components/SiteNavigation.astro +5 -4
  129. package/src/components/SitePage.astro +9 -2
  130. package/src/components/SiteSection.astro +35 -13
  131. package/src/content.config.ts +4 -17
  132. package/src/lib/generatedImages.ts +50 -10
  133. package/src/lib/sectionContent.ts +130 -35
  134. package/src/styles/global.css +187 -0
  135. package/starters/basic/site/content.md +10 -6
  136. package/starters/project/site/content.md +0 -9
  137. package/starters/project/site/routes/010-guide/route-content.md +0 -5
@@ -0,0 +1,9 @@
1
+ ---
2
+ typography:
3
+ preset: text-forward
4
+ rhythm: normal
5
+ presentation:
6
+ inlineStyles:
7
+ highlight:
8
+ color: "#b3261e"
9
+ ---
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.6",
5
+ "version": "0.7.7",
6
6
  "license": "GPL-3.0-only",
7
7
  "repository": {
8
8
  "type": "git",
@@ -62,10 +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:route-model && 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
67
  "test:route-model": "node scripts/test-route-model.mjs",
68
68
  "test:content-check": "node scripts/test-content-check.mjs",
69
+ "test:content-model-v2": "node scripts/test-content-model-v2.mjs",
69
70
  "test:site-public": "node scripts/test-site-public.mjs",
70
71
  "test:cli-discovery": "node scripts/test-cli-discovery.mjs",
71
72
  "test:engine-commands": "node scripts/test-engine-commands.mjs",
@@ -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
- getFrontmatterSections,
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 { frontmatter } = await readSiteFile(contentFile.contentPath, contentFile.contentLabel);
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
- for (const section of getFrontmatterSections(frontmatter)) {
150
- for (const image of section.images) {
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
 
@@ -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
- getFrontmatterSections,
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 { frontmatter } = await readSiteFile(contentFile.contentPath, contentFile.contentLabel);
96
- const sections = getFrontmatterSections(frontmatter);
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
- const imageReferences = section.imageReferences ?? section.images.map((image) => ({ image }));
101
- for (const { image, line } of imageReferences) {
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, line }, imageIndex) => {
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
- const sourcePath = imageIndex.get(image);
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 Map();
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, imageIndex);
144
- const siteImagePath = toPublicPath(path.relative(contentFile.imagesDir, sourcePath));
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(imageName)) {
148
- const firstReference = seen.get(imageName);
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.set(imageName, reference);
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 = (sections, manifest) => {
176
- for (const section of sections) {
177
- for (const carousel of section.carousels) {
178
- const orientations = new Set(
179
- carousel.images
180
- .map((image) => manifest[image])
181
- .filter(Boolean)
182
- .map(getOrientation)
183
- );
184
-
185
- orientations.delete('square');
186
- if (orientations.size > 1) {
187
- throw new Error(
188
- `Carousel in section "${section.id}" on line ${carousel.line} mixes landscape and portrait images. Use landscape images with optional square images, or portrait images with optional square images.`,
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((entry) => entry.variants ?? [])
307
- .map((variant) => getFilePathFromPublicPath(variant.src)),
355
+ .flatMap(getManifestOutputPaths),
308
356
  );
309
- const generatedFiles = await listGeneratedFiles(generatedImagesDir);
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 = await getImageMagick();
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
- const reusableEntry = await getReusableEntry(sourcePath, previousManifest[imageName], sourceHash);
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[imageName] = reusableEntry;
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[imageName] = {
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(frontmatterSections, manifest);
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
  }