@janga/norna 0.7.6 → 0.7.8

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 (139) hide show
  1. package/README.md +8 -7
  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 +7 -6
  6. package/docs/configuration.md +3 -2
  7. package/docs/content.md +202 -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 +50 -8
  11. package/docs/images-and-metadata.md +117 -20
  12. package/docs/routes.md +20 -12
  13. package/docs/site-structure.md +9 -5
  14. package/docs/theme.md +12 -8
  15. package/docs/typography.md +9 -10
  16. package/examples/dog-gallery/site/content.md +38 -77
  17. package/examples/dog-gallery/site/routes/010-dog-care/route-content.md +0 -10
  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 +414 -412
  121. package/scripts/test-content-check.mjs +59 -45
  122. package/scripts/test-content-model-v2.mjs +1230 -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/README.md +4 -2
  136. package/starters/basic/site/content.md +9 -11
  137. package/starters/project/README.md +7 -3
  138. package/starters/project/site/content.md +0 -9
  139. package/starters/project/site/routes/010-guide/route-content.md +0 -5
@@ -0,0 +1,1230 @@
1
+ import assert from 'node:assert/strict';
2
+ import { execFile } from 'node:child_process';
3
+ import { access, cp, mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { promisify } from 'node:util';
7
+ import { test } from 'node:test';
8
+
9
+ const execFileAsync = promisify(execFile);
10
+ const repoRoot = path.resolve(import.meta.dirname, '..');
11
+ const nodeBin = process.execPath;
12
+ const nornaBin = path.join(repoRoot, 'bin', 'norna.mjs');
13
+ const contentScript = path.join(repoRoot, 'scripts', 'sync-content-sections.mjs');
14
+ const fixtureSiteDir = path.join(repoRoot, 'fixtures', 'content-model-v2', 'site');
15
+ const fixtureRoot = path.dirname(fixtureSiteDir);
16
+
17
+ const runNorna = async (args, options = {}) => {
18
+ try {
19
+ return await execFileAsync(nodeBin, [nornaBin, ...args], {
20
+ cwd: repoRoot,
21
+ maxBuffer: 1024 * 1024 * 10,
22
+ ...options,
23
+ });
24
+ } catch (error) {
25
+ error.output = `${error.stdout ?? ''}${error.stderr ?? ''}`;
26
+ throw error;
27
+ }
28
+ };
29
+
30
+ const runContentScript = async (siteDir, args) => {
31
+ try {
32
+ return await execFileAsync(nodeBin, [contentScript, ...args], {
33
+ cwd: repoRoot,
34
+ env: {
35
+ ...process.env,
36
+ NORNA_SITE_DIR: siteDir,
37
+ },
38
+ maxBuffer: 1024 * 1024 * 10,
39
+ });
40
+ } catch (error) {
41
+ error.output = `${error.stdout ?? ''}${error.stderr ?? ''}`;
42
+ throw error;
43
+ }
44
+ };
45
+
46
+ const createTempSite = async ({ underRepoCache = false } = {}) => {
47
+ const tempParent = underRepoCache
48
+ ? path.join(repoRoot, 'node_modules', '.cache')
49
+ : os.tmpdir();
50
+ await mkdir(tempParent, { recursive: true });
51
+ const root = await mkdtemp(path.join(tempParent, 'norna-content-model-v2-'));
52
+ const siteDir = path.join(root, 'site');
53
+ await mkdir(siteDir, { recursive: true });
54
+ await writeFile(path.join(siteDir, 'config.mjs'), `export default {
55
+ site: {
56
+ url: 'https://example.com',
57
+ basePath: '/',
58
+ },
59
+ github: {
60
+ branch: 'main',
61
+ repo: 'owner/repo',
62
+ pagesWorkflow: 'Deploy to GitHub Pages',
63
+ },
64
+ };
65
+ `);
66
+ await writeFile(path.join(siteDir, 'theme.md'), `---
67
+ typography:
68
+ preset: text-forward
69
+ ---
70
+ `);
71
+ return { root, siteDir };
72
+ };
73
+
74
+ const fileExists = async (filePath) => access(filePath).then(() => true, () => false);
75
+
76
+ const runGit = async (cwd, args) => execFileAsync('git', args, {
77
+ cwd,
78
+ env: {
79
+ ...process.env,
80
+ GIT_AUTHOR_NAME: 'Norna Test',
81
+ GIT_AUTHOR_EMAIL: 'norna@example.test',
82
+ GIT_COMMITTER_NAME: 'Norna Test',
83
+ GIT_COMMITTER_EMAIL: 'norna@example.test',
84
+ },
85
+ maxBuffer: 1024 * 1024,
86
+ });
87
+
88
+ const initCleanGitWorktree = async (root) => {
89
+ await runGit(root, ['init']);
90
+ await runGit(root, ['config', 'user.email', 'norna@example.test']);
91
+ await runGit(root, ['config', 'user.name', 'Norna Test']);
92
+ await runGit(root, ['add', '.']);
93
+ await runGit(root, ['commit', '-m', 'initial']);
94
+ };
95
+
96
+ const createTempFixtureCopy = async () => {
97
+ const tempParent = path.join(repoRoot, 'node_modules', '.cache');
98
+ await mkdir(tempParent, { recursive: true });
99
+ const root = await mkdtemp(path.join(tempParent, 'norna-content-model-v2-fixture-'));
100
+ const fixtureCopyRoot = path.join(root, 'content-model-v2');
101
+
102
+ await cp(fixtureRoot, fixtureCopyRoot, {
103
+ recursive: true,
104
+ filter: (source) => ![
105
+ path.join(fixtureRoot, '.astro'),
106
+ path.join(fixtureRoot, 'dist'),
107
+ path.join(fixtureRoot, 'node_modules'),
108
+ path.join(fixtureSiteDir, '.norna'),
109
+ ].some((ignoredPath) => source === ignoredPath || source.startsWith(`${ignoredPath}${path.sep}`)),
110
+ });
111
+
112
+ return {
113
+ root,
114
+ siteDir: path.join(fixtureCopyRoot, 'site'),
115
+ };
116
+ };
117
+
118
+ test('content model v2 fixture checks and builds', async () => {
119
+ const { root, siteDir } = await createTempFixtureCopy();
120
+ try {
121
+ await runNorna(['--site-dir', siteDir, 'content:check']);
122
+ await runNorna(['--site-dir', siteDir, 'build']);
123
+
124
+ const manifest = JSON.parse(await readFile(path.join(siteDir, '.norna', 'generated-images.json'), 'utf8'));
125
+ assert.ok(manifest['images/intro/duplicate.jpg']);
126
+ assert.ok(manifest['routes/010-guide/images/intro/duplicate.jpg']);
127
+ } finally {
128
+ await rm(root, { recursive: true, force: true });
129
+ }
130
+ });
131
+
132
+ test('content:check warns for local Markdown images but not external Markdown images', async () => {
133
+ const { root, siteDir } = await createTempSite();
134
+ try {
135
+ await writeFile(path.join(siteDir, 'content.md'), `---
136
+ title: Markdown Image Warning
137
+ description: Fixture
138
+ ---
139
+
140
+ ## Intro {#intro}
141
+
142
+ ![Local](portrait.jpg)
143
+
144
+ ![External](https://example.com/portrait.jpg)
145
+
146
+ ![Public](/favicon.svg)
147
+ `);
148
+
149
+ const { stdout } = await runContentScript(siteDir, ['--check']);
150
+ assert.match(stdout, /Markdown image "portrait\.jpg" references a local image that is not managed by Norna\./);
151
+ assert.doesNotMatch(stdout, /https:\/\/example\.com\/portrait\.jpg/);
152
+ assert.doesNotMatch(stdout, /\/favicon\.svg/);
153
+ } finally {
154
+ await rm(root, { recursive: true, force: true });
155
+ }
156
+ });
157
+
158
+ test('managed SVG images are copied as static image output', async () => {
159
+ const { root, siteDir } = await createTempSite();
160
+ try {
161
+ await writeFile(path.join(siteDir, 'content.md'), `---
162
+ title: SVG Fixture
163
+ description: Fixture
164
+ ---
165
+
166
+ ## Intro {#intro}
167
+
168
+ \`\`\`norna-image-stack
169
+ - image: diagram.svg
170
+ alt: Diagram
171
+ \`\`\`
172
+ `);
173
+ await mkdir(path.join(siteDir, 'images', 'intro'), { recursive: true });
174
+ await writeFile(path.join(siteDir, 'images', 'intro', 'diagram.svg'), '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 620"><rect width="900" height="620"/></svg>\n');
175
+
176
+ await runContentScript(siteDir, ['--check']);
177
+ await runNorna(['--site-dir', siteDir, 'images']);
178
+
179
+ const manifest = JSON.parse(await readFile(path.join(siteDir, '.norna', 'generated-images.json'), 'utf8'));
180
+ const entry = manifest['images/intro/diagram.svg'];
181
+ assert.equal(entry.kind, 'static');
182
+ assert.equal(entry.width, 900);
183
+ assert.equal(entry.height, 620);
184
+ assert.match(entry.src, /^\/images\/original\/images\/intro\/diagram-[a-f0-9]{8}\.svg$/);
185
+ assert.equal(entry.variants, undefined);
186
+ assert.equal(await fileExists(path.join(siteDir, '.norna', 'public', entry.src.replace(/^\//, ''))), true);
187
+ } finally {
188
+ await rm(root, { recursive: true, force: true });
189
+ }
190
+ });
191
+
192
+ test('updated managed SVG images get updated static output', async () => {
193
+ const { root, siteDir } = await createTempSite();
194
+ try {
195
+ await writeFile(path.join(siteDir, 'content.md'), `---
196
+ title: SVG Update Fixture
197
+ description: Fixture
198
+ ---
199
+
200
+ ## Intro {#intro}
201
+
202
+ \`\`\`norna-image-stack
203
+ - image: diagram.svg
204
+ alt: Diagram
205
+ \`\`\`
206
+ `);
207
+ await mkdir(path.join(siteDir, 'images', 'intro'), { recursive: true });
208
+ const sourcePath = path.join(siteDir, 'images', 'intro', 'diagram.svg');
209
+ await writeFile(sourcePath, '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 620"><rect width="900" height="620" fill="red"/></svg>\n');
210
+
211
+ await runContentScript(siteDir, ['--check']);
212
+ await runNorna(['--site-dir', siteDir, 'images']);
213
+
214
+ const firstManifest = JSON.parse(await readFile(path.join(siteDir, '.norna', 'generated-images.json'), 'utf8'));
215
+ const firstEntry = firstManifest['images/intro/diagram.svg'];
216
+ const firstOutputPath = path.join(siteDir, '.norna', 'public', firstEntry.src.replace(/^\//, ''));
217
+
218
+ await writeFile(sourcePath, '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 620"><rect width="900" height="620" fill="blue"/></svg>\n');
219
+ await runNorna(['--site-dir', siteDir, 'images']);
220
+
221
+ const secondManifest = JSON.parse(await readFile(path.join(siteDir, '.norna', 'generated-images.json'), 'utf8'));
222
+ const secondEntry = secondManifest['images/intro/diagram.svg'];
223
+ const secondOutputPath = path.join(siteDir, '.norna', 'public', secondEntry.src.replace(/^\//, ''));
224
+
225
+ assert.notEqual(secondEntry.sourceHash, firstEntry.sourceHash);
226
+ assert.notEqual(secondEntry.src, firstEntry.src);
227
+ assert.equal(await fileExists(firstOutputPath), false);
228
+ assert.match(await readFile(secondOutputPath, 'utf8'), /fill="blue"/);
229
+ } finally {
230
+ await rm(root, { recursive: true, force: true });
231
+ }
232
+ });
233
+
234
+ test('content:check warns when carousel SVG images have no intrinsic aspect ratio', async () => {
235
+ const { root, siteDir } = await createTempSite();
236
+ try {
237
+ await writeFile(path.join(siteDir, 'content.md'), `---
238
+ title: SVG Carousel Fixture
239
+ description: Fixture
240
+ ---
241
+
242
+ ## Intro {#intro}
243
+
244
+ \`\`\`norna-image-carousel
245
+ - image: first.svg
246
+ alt: First
247
+ - image: second.svg
248
+ alt: Second
249
+ \`\`\`
250
+ `);
251
+ await mkdir(path.join(siteDir, 'images', 'intro'), { recursive: true });
252
+ await writeFile(path.join(siteDir, 'images', 'intro', 'first.svg'), '<svg xmlns="http://www.w3.org/2000/svg"><rect width="100" height="100"/></svg>\n');
253
+ await writeFile(path.join(siteDir, 'images', 'intro', 'second.svg'), '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100"/></svg>\n');
254
+
255
+ const { stdout } = await runContentScript(siteDir, ['--check']);
256
+ assert.match(stdout, /Content check completed with warnings\./);
257
+ assert.match(stdout, /Carousel on line \d+ uses images without an intrinsic aspect ratio: first\.svg\./);
258
+ } finally {
259
+ await rm(root, { recursive: true, force: true });
260
+ }
261
+ });
262
+
263
+ test('norna-card-list images are managed image references', async () => {
264
+ const { root, siteDir } = await createTempSite({ underRepoCache: true });
265
+ try {
266
+ await writeFile(path.join(siteDir, 'content.md'), `---
267
+ title: Card List Fixture
268
+ description: Fixture
269
+ ---
270
+
271
+ ## Help {#help}
272
+
273
+ \`\`\`norna-card-list
274
+ layout: image-left
275
+ flow: stack
276
+ size: l
277
+ width: narrow
278
+
279
+ - title: Adopt
280
+ text: Give a dog a new home.
281
+ image: adopt.svg
282
+ link: /adopt/
283
+ badge-text: Recommended
284
+ - title: Donate
285
+ text: Support the shelter.
286
+ \`\`\`
287
+ `);
288
+ await mkdir(path.join(siteDir, 'images', 'help'), { recursive: true });
289
+ await writeFile(path.join(siteDir, 'images', 'help', 'adopt.svg'), '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 100"><rect width="160" height="100"/></svg>\n');
290
+
291
+ const { stdout } = await runContentScript(siteDir, ['--check']);
292
+ assert.match(stdout, /Content check passed\./);
293
+
294
+ await runNorna(['--site-dir', siteDir, 'images']);
295
+ const manifest = JSON.parse(await readFile(path.join(siteDir, '.norna', 'generated-images.json'), 'utf8'));
296
+ assert.equal(manifest['images/help/adopt.svg'].kind, 'static');
297
+
298
+ await runNorna(['--site-dir', siteDir, 'build']);
299
+ const html = await readFile(path.join(root, 'dist', 'index.html'), 'utf8');
300
+ assert.match(html, /card-list-layout-image-left/);
301
+ assert.match(html, /card-list-flow-stack/);
302
+ assert.match(html, /card-list-size-l/);
303
+ assert.match(html, /card-list-width-narrow/);
304
+ assert.match(html, /card-list-item-has-image/);
305
+ assert.match(html, /class="card-list-badge">Recommended<\/p>/);
306
+ assert.match(html, /Give a dog a new home\./);
307
+ assert.match(html, /href="\/adopt\/"/);
308
+ assert.match(html, /src="\/images\/original\/images\/help\/adopt-[a-f0-9]{8}\.svg"/);
309
+ } finally {
310
+ await rm(root, { recursive: true, force: true });
311
+ }
312
+ });
313
+
314
+ test('content:check fails when a norna-card-list image file is missing', async () => {
315
+ const { root, siteDir } = await createTempSite();
316
+ try {
317
+ await writeFile(path.join(siteDir, 'content.md'), `---
318
+ title: Missing Card Image
319
+ description: Fixture
320
+ ---
321
+
322
+ ## Help {#help}
323
+
324
+ \`\`\`norna-card-list
325
+ - title: Adopt
326
+ image: missing.svg
327
+ \`\`\`
328
+ `);
329
+
330
+ await assert.rejects(
331
+ () => runContentScript(siteDir, ['--check']),
332
+ (error) => {
333
+ assert.match(error.output, /Image "missing\.svg" does not exist at .*site\/images\/help\/missing\.svg or anywhere under any page or route image root\./);
334
+ return true;
335
+ },
336
+ );
337
+ } finally {
338
+ await rm(root, { recursive: true, force: true });
339
+ }
340
+ });
341
+
342
+ test('content:check fails for malformed norna-card-list blocks', async () => {
343
+ const { root, siteDir } = await createTempSite();
344
+ try {
345
+ await writeFile(path.join(siteDir, 'content.md'), `---
346
+ title: Malformed Card List
347
+ description: Fixture
348
+ ---
349
+
350
+ ## Help {#help}
351
+
352
+ \`\`\`norna-card-list
353
+ - text: Missing title
354
+ \`\`\`
355
+ `);
356
+
357
+ await assert.rejects(
358
+ () => runContentScript(siteDir, ['--check']),
359
+ (error) => {
360
+ assert.match(error.output, /Invalid norna-card-list entry "- text: Missing title"\. Start each card with "- title: Card title"\./);
361
+ return true;
362
+ },
363
+ );
364
+ } finally {
365
+ await rm(root, { recursive: true, force: true });
366
+ }
367
+ });
368
+
369
+ test('content:check fails for invalid norna-card-list options', async () => {
370
+ const { root, siteDir } = await createTempSite();
371
+ try {
372
+ await writeFile(path.join(siteDir, 'content.md'), `---
373
+ title: Invalid Card List Options
374
+ description: Fixture
375
+ ---
376
+
377
+ ## Help {#help}
378
+
379
+ \`\`\`norna-card-list
380
+ layout: floating
381
+ flow: list
382
+ size: huge
383
+ width: full
384
+
385
+ - title: Adopt
386
+ text: Give a dog a new home.
387
+ \`\`\`
388
+ `);
389
+
390
+ await assert.rejects(
391
+ () => runContentScript(siteDir, ['--check']),
392
+ (error) => {
393
+ assert.match(error.output, /Invalid norna-card-list layout "floating"\. Use one of: image-top, image-left, image-right\./);
394
+ return true;
395
+ },
396
+ );
397
+ } finally {
398
+ await rm(root, { recursive: true, force: true });
399
+ }
400
+ });
401
+
402
+ test('content:check fails for invalid norna-card-list width', async () => {
403
+ const { root, siteDir } = await createTempSite();
404
+ try {
405
+ await writeFile(path.join(siteDir, 'content.md'), `---
406
+ title: Invalid Card List Width
407
+ description: Fixture
408
+ ---
409
+
410
+ ## Help {#help}
411
+
412
+ \`\`\`norna-card-list
413
+ width: full
414
+
415
+ - title: Adopt
416
+ text: Give a dog a new home.
417
+ \`\`\`
418
+ `);
419
+
420
+ await assert.rejects(
421
+ () => runContentScript(siteDir, ['--check']),
422
+ (error) => {
423
+ assert.match(error.output, /Invalid norna-card-list width "full"\. Use one of: text, narrow, normal, wide\./);
424
+ return true;
425
+ },
426
+ );
427
+ } finally {
428
+ await rm(root, { recursive: true, force: true });
429
+ }
430
+ });
431
+
432
+ test('content:check fails for invalid norna-card-list size', async () => {
433
+ const { root, siteDir } = await createTempSite();
434
+ try {
435
+ await writeFile(path.join(siteDir, 'content.md'), `---
436
+ title: Invalid Card List Size
437
+ description: Fixture
438
+ ---
439
+
440
+ ## Help {#help}
441
+
442
+ \`\`\`norna-card-list
443
+ size: huge
444
+
445
+ - title: Adopt
446
+ text: Give a dog a new home.
447
+ \`\`\`
448
+ `);
449
+
450
+ await assert.rejects(
451
+ () => runContentScript(siteDir, ['--check']),
452
+ (error) => {
453
+ assert.match(error.output, /Invalid norna-card-list size "huge"\. Use one of: s, m, l, xl\./);
454
+ return true;
455
+ },
456
+ );
457
+ } finally {
458
+ await rm(root, { recursive: true, force: true });
459
+ }
460
+ });
461
+
462
+ test('content:sync moves Norna-managed images inside the same page image root', async () => {
463
+ const { root, siteDir } = await createTempSite();
464
+ try {
465
+ await writeFile(path.join(siteDir, 'content.md'), `---
466
+ title: Sync Fixture
467
+ description: Fixture
468
+ ---
469
+
470
+ ## Work {#work}
471
+
472
+ \`\`\`norna-image-stack
473
+ - image: moved.jpg
474
+ alt: Moved image
475
+ \`\`\`
476
+ `);
477
+ await mkdir(path.join(siteDir, 'images', 'old'), { recursive: true });
478
+ await writeFile(path.join(siteDir, 'images', 'old', 'moved.jpg'), 'fixture image');
479
+
480
+ await runContentScript(siteDir, ['--write', '--yes']);
481
+
482
+ const moved = await readFile(path.join(siteDir, 'images', 'work', 'moved.jpg'), 'utf8');
483
+ assert.equal(moved, 'fixture image');
484
+ } finally {
485
+ await rm(root, { recursive: true, force: true });
486
+ }
487
+ });
488
+
489
+ test('content:sync moves managed SVG images inside the same page image root', async () => {
490
+ const { root, siteDir } = await createTempSite();
491
+ try {
492
+ await writeFile(path.join(siteDir, 'content.md'), `---
493
+ title: SVG Sync Fixture
494
+ description: Fixture
495
+ ---
496
+
497
+ ## Work {#work}
498
+
499
+ \`\`\`norna-image-stack
500
+ - image: moved.svg
501
+ alt: Moved diagram
502
+ \`\`\`
503
+ `);
504
+ await mkdir(path.join(siteDir, 'images', 'old'), { recursive: true });
505
+ await writeFile(path.join(siteDir, 'images', 'old', 'moved.svg'), '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"></svg>\n');
506
+
507
+ await runContentScript(siteDir, ['--write', '--yes']);
508
+
509
+ const moved = await readFile(path.join(siteDir, 'images', 'work', 'moved.svg'), 'utf8');
510
+ assert.match(moved, /viewBox="0 0 10 10"/);
511
+ } finally {
512
+ await rm(root, { recursive: true, force: true });
513
+ }
514
+ });
515
+
516
+ test('content:sync moves Norna-managed images across route image roots when git is clean', async () => {
517
+ const { root, siteDir } = await createTempSite();
518
+ try {
519
+ const sourceRouteDir = path.join(siteDir, 'routes', '010-source');
520
+ const targetRouteDir = path.join(siteDir, 'routes', '020-target');
521
+ await mkdir(path.join(sourceRouteDir, 'images', 'old'), { recursive: true });
522
+ await mkdir(targetRouteDir, { recursive: true });
523
+ await writeFile(path.join(siteDir, 'content.md'), `---
524
+ title: Cross Route Sync
525
+ description: Fixture
526
+ ---
527
+
528
+ ## Home {#home}
529
+
530
+ Text.
531
+ `);
532
+ await writeFile(path.join(sourceRouteDir, 'route-content.md'), `---
533
+ title: Source
534
+ description: Fixture
535
+ ---
536
+
537
+ ## Old {#old}
538
+
539
+ Text.
540
+ `);
541
+ await writeFile(path.join(targetRouteDir, 'route-content.md'), `---
542
+ title: Target
543
+ description: Fixture
544
+ ---
545
+
546
+ ## Work {#work}
547
+
548
+ \`\`\`norna-image-stack
549
+ - image: moved.jpg
550
+ \`\`\`
551
+ `);
552
+ await writeFile(path.join(sourceRouteDir, 'images', 'old', 'moved.jpg'), 'route image');
553
+ await initCleanGitWorktree(root);
554
+
555
+ await runContentScript(siteDir, ['--write', '--yes']);
556
+
557
+ assert.equal(await fileExists(path.join(targetRouteDir, 'images', 'work', 'moved.jpg')), true);
558
+ assert.equal(await fileExists(path.join(sourceRouteDir, 'images', 'old', 'moved.jpg')), false);
559
+ } finally {
560
+ await rm(root, { recursive: true, force: true });
561
+ }
562
+ });
563
+
564
+ test('content:sync refuses cross-route image moves when git status is dirty', async () => {
565
+ const { root, siteDir } = await createTempSite();
566
+ try {
567
+ const sourceRouteDir = path.join(siteDir, 'routes', '010-source');
568
+ const targetRouteDir = path.join(siteDir, 'routes', '020-target');
569
+ await mkdir(path.join(sourceRouteDir, 'images', 'old'), { recursive: true });
570
+ await mkdir(targetRouteDir, { recursive: true });
571
+ await writeFile(path.join(siteDir, 'content.md'), `---
572
+ title: Dirty Cross Route Sync
573
+ description: Fixture
574
+ ---
575
+
576
+ ## Home {#home}
577
+
578
+ Text.
579
+ `);
580
+ await writeFile(path.join(sourceRouteDir, 'route-content.md'), `---
581
+ title: Source
582
+ description: Fixture
583
+ ---
584
+
585
+ ## Old {#old}
586
+
587
+ Text.
588
+ `);
589
+ await writeFile(path.join(targetRouteDir, 'route-content.md'), `---
590
+ title: Target
591
+ description: Fixture
592
+ ---
593
+
594
+ ## Work {#work}
595
+
596
+ \`\`\`norna-image-stack
597
+ - image: moved.jpg
598
+ \`\`\`
599
+ `);
600
+ await writeFile(path.join(sourceRouteDir, 'images', 'old', 'moved.jpg'), 'route image');
601
+ await initCleanGitWorktree(root);
602
+ await writeFile(path.join(root, 'dirty.txt'), 'dirty');
603
+
604
+ await assert.rejects(
605
+ () => runContentScript(siteDir, ['--write', '--yes']),
606
+ (error) => {
607
+ assert.match(error.output, /Cross-route content sync requires a clean git working tree before moving files between page or route image roots\./);
608
+ assert.match(error.output, /dirty\.txt/);
609
+ return true;
610
+ },
611
+ );
612
+
613
+ assert.equal(await fileExists(path.join(targetRouteDir, 'images', 'work', 'moved.jpg')), false);
614
+ assert.equal(await fileExists(path.join(sourceRouteDir, 'images', 'old', 'moved.jpg')), true);
615
+ } finally {
616
+ await rm(root, { recursive: true, force: true });
617
+ }
618
+ });
619
+
620
+ test('content:check reports cross-route image moves without requiring clean git status', async () => {
621
+ const { root, siteDir } = await createTempSite();
622
+ try {
623
+ const sourceRouteDir = path.join(siteDir, 'routes', '010-source');
624
+ const targetRouteDir = path.join(siteDir, 'routes', '020-target');
625
+ await mkdir(path.join(sourceRouteDir, 'images', 'old'), { recursive: true });
626
+ await mkdir(targetRouteDir, { recursive: true });
627
+ await writeFile(path.join(siteDir, 'content.md'), `---
628
+ title: Cross Route Check
629
+ description: Fixture
630
+ ---
631
+
632
+ ## Home {#home}
633
+
634
+ Text.
635
+ `);
636
+ await writeFile(path.join(sourceRouteDir, 'route-content.md'), `---
637
+ title: Source
638
+ description: Fixture
639
+ ---
640
+
641
+ ## Old {#old}
642
+
643
+ Text.
644
+ `);
645
+ await writeFile(path.join(targetRouteDir, 'route-content.md'), `---
646
+ title: Target
647
+ description: Fixture
648
+ ---
649
+
650
+ ## Work {#work}
651
+
652
+ \`\`\`norna-image-stack
653
+ - image: moved.jpg
654
+ \`\`\`
655
+ `);
656
+ await writeFile(path.join(sourceRouteDir, 'images', 'old', 'moved.jpg'), 'route image');
657
+ await writeFile(path.join(root, 'dirty.txt'), 'dirty');
658
+
659
+ await assert.rejects(
660
+ () => runContentScript(siteDir, ['--check']),
661
+ (error) => {
662
+ assert.match(error.output, /Image "moved\.jpg" is used here but is located in .*site\/routes\/010-source\/images\/old\/moved\.jpg\./);
663
+ assert.match(error.output, /Run norna content:sync to move it from .*site\/routes\/010-source to .*site\/routes\/020-target\./);
664
+ assert.doesNotMatch(error.output, /requires a clean git working tree before moving files/);
665
+ return true;
666
+ },
667
+ );
668
+ } finally {
669
+ await rm(root, { recursive: true, force: true });
670
+ }
671
+ });
672
+
673
+ test('content:sync refuses to move a cross-route image still referenced by its current section', async () => {
674
+ const { root, siteDir } = await createTempSite();
675
+ try {
676
+ const sourceRouteDir = path.join(siteDir, 'routes', '010-source');
677
+ const targetRouteDir = path.join(siteDir, 'routes', '020-target');
678
+ await mkdir(path.join(sourceRouteDir, 'images', 'old'), { recursive: true });
679
+ await mkdir(targetRouteDir, { recursive: true });
680
+ await writeFile(path.join(siteDir, 'content.md'), `---
681
+ title: Cross Route Copy
682
+ description: Fixture
683
+ ---
684
+
685
+ ## Home {#home}
686
+
687
+ Text.
688
+ `);
689
+ await writeFile(path.join(sourceRouteDir, 'route-content.md'), `---
690
+ title: Source
691
+ description: Fixture
692
+ ---
693
+
694
+ ## Old {#old}
695
+
696
+ \`\`\`norna-image-stack
697
+ - image: shared.jpg
698
+ \`\`\`
699
+ `);
700
+ await writeFile(path.join(targetRouteDir, 'route-content.md'), `---
701
+ title: Target
702
+ description: Fixture
703
+ ---
704
+
705
+ ## Work {#work}
706
+
707
+ \`\`\`norna-image-stack
708
+ - image: shared.jpg
709
+ \`\`\`
710
+ `);
711
+ await writeFile(path.join(sourceRouteDir, 'images', 'old', 'shared.jpg'), 'route image');
712
+
713
+ await assert.rejects(
714
+ () => runContentScript(siteDir, ['--write', '--yes']),
715
+ (error) => {
716
+ assert.match(error.output, /Cannot relocate "shared\.jpg" from .*site\/routes\/010-source\/images\/old\/shared\.jpg because it is still referenced from .*site\/routes\/010-source\/route-content\.md \[old\]\./);
717
+ return true;
718
+ },
719
+ );
720
+
721
+ assert.equal(await fileExists(path.join(targetRouteDir, 'images', 'work', 'shared.jpg')), false);
722
+ assert.equal(await fileExists(path.join(sourceRouteDir, 'images', 'old', 'shared.jpg')), true);
723
+ } finally {
724
+ await rm(root, { recursive: true, force: true });
725
+ }
726
+ });
727
+
728
+ test('CLI content:sync refreshes generated images after moving a carousel image', async () => {
729
+ const { root, siteDir } = await createTempSite();
730
+ try {
731
+ await writeFile(path.join(siteDir, 'content.md'), `---
732
+ title: Sync Fixture
733
+ description: Fixture
734
+ ---
735
+
736
+ ## Work {#work}
737
+
738
+ \`\`\`norna-image-carousel
739
+ - image: slide-one.jpg
740
+ alt: Slide one
741
+ - image: slide-two.jpg
742
+ alt: Slide two
743
+ \`\`\`
744
+ `);
745
+ await mkdir(path.join(siteDir, 'images', 'old'), { recursive: true });
746
+ await mkdir(path.join(siteDir, 'images', 'work'), { recursive: true });
747
+ await cp(path.join(fixtureSiteDir, 'images', 'intro', 'hero.jpg'), path.join(siteDir, 'images', 'old', 'slide-one.jpg'));
748
+ await cp(path.join(fixtureSiteDir, 'images', 'intro', 'detail.jpg'), path.join(siteDir, 'images', 'work', 'slide-two.jpg'), { force: true });
749
+
750
+ await runNorna(['--site-dir', siteDir, 'content:sync', '--yes']);
751
+
752
+ const manifest = JSON.parse(await readFile(path.join(siteDir, '.norna', 'generated-images.json'), 'utf8'));
753
+ assert.equal(await fileExists(path.join(siteDir, 'images', 'work', 'slide-one.jpg')), true);
754
+ assert.ok(manifest['images/work/slide-one.jpg']);
755
+ assert.ok(manifest['images/work/slide-two.jpg']);
756
+ } finally {
757
+ await rm(root, { recursive: true, force: true });
758
+ }
759
+ });
760
+
761
+ test('content:check fails when a level 2 section is missing an explicit id', async () => {
762
+ const { root, siteDir } = await createTempSite();
763
+ try {
764
+ await writeFile(path.join(siteDir, 'content.md'), `---
765
+ title: Missing Id
766
+ description: Fixture
767
+ ---
768
+
769
+ ## Intro
770
+
771
+ This section has no explicit id.
772
+ `);
773
+
774
+ await assert.rejects(
775
+ () => runContentScript(siteDir, ['--check']),
776
+ (error) => {
777
+ assert.match(error.output, /Section heading "Intro" is missing an explicit id\./);
778
+ return true;
779
+ },
780
+ );
781
+ } finally {
782
+ await rm(root, { recursive: true, force: true });
783
+ }
784
+ });
785
+
786
+ test('content:check fails for malformed Norna image blocks', async () => {
787
+ const { root, siteDir } = await createTempSite();
788
+ try {
789
+ await writeFile(path.join(siteDir, 'content.md'), `---
790
+ title: Malformed Block
791
+ description: Fixture
792
+ ---
793
+
794
+ ## Intro {#intro}
795
+
796
+ \`\`\`norna-image-stack
797
+ image hero.jpg
798
+ alt: Hero
799
+ \`\`\`
800
+ `);
801
+
802
+ await assert.rejects(
803
+ () => runContentScript(siteDir, ['--check']),
804
+ (error) => {
805
+ assert.match(error.output, /Invalid norna-image-stack entry "image hero\.jpg"\. Start each image with "- image: filename\.jpg"\./);
806
+ return true;
807
+ },
808
+ );
809
+ } finally {
810
+ await rm(root, { recursive: true, force: true });
811
+ }
812
+ });
813
+
814
+ test('content:check fails for unknown Norna block names', async () => {
815
+ const { root, siteDir } = await createTempSite();
816
+ try {
817
+ await mkdir(path.join(siteDir, 'images', 'plain'), { recursive: true });
818
+ await writeFile(path.join(siteDir, 'images', 'plain', 'image.jpg'), 'fixture image');
819
+ await writeFile(path.join(siteDir, 'content.md'), `---
820
+ title: Unknown Block
821
+ description: Fixture
822
+ ---
823
+
824
+ ## Plain {#plain}
825
+
826
+ \`\`\`norna-gallery-stack
827
+ - image: image.jpg
828
+ \`\`\`
829
+ `);
830
+
831
+ await assert.rejects(
832
+ () => runContentScript(siteDir, ['--check']),
833
+ (error) => {
834
+ assert.match(error.output, /Unknown Norna block "norna-gallery-stack"\. Use one of: norna-image-stack, norna-image-carousel, norna-card-list\./);
835
+ assert.match(error.output, /Use norna-image-stack for one or more stacked images\./);
836
+ assert.match(error.output, /Example: ```norna-image-stack\n- image: filename\.jpg\n```/);
837
+ return true;
838
+ },
839
+ );
840
+ } finally {
841
+ await rm(root, { recursive: true, force: true });
842
+ }
843
+ });
844
+
845
+ test('content:check supports tilde fenced Norna blocks', async () => {
846
+ const { root, siteDir } = await createTempSite();
847
+ try {
848
+ await mkdir(path.join(siteDir, 'images', 'intro'), { recursive: true });
849
+ await writeFile(path.join(siteDir, 'images', 'intro', 'hero.jpg'), 'fixture image');
850
+ await writeFile(path.join(siteDir, 'content.md'), `---
851
+ title: Tilde Fence
852
+ description: Fixture
853
+ ---
854
+
855
+ ## Intro {#intro}
856
+
857
+ ~~~norna-image-stack
858
+ - image: hero.jpg
859
+ ~~~
860
+ `);
861
+
862
+ const result = await runContentScript(siteDir, ['--check']);
863
+ assert.match(result.stdout, /Content check passed\./);
864
+ } finally {
865
+ await rm(root, { recursive: true, force: true });
866
+ }
867
+ });
868
+
869
+ test('content:check ignores Norna blocks shown inside longer code fences', async () => {
870
+ const { root, siteDir } = await createTempSite();
871
+ try {
872
+ await writeFile(path.join(siteDir, 'content.md'), `---
873
+ title: Code Example
874
+ description: Fixture
875
+ ---
876
+
877
+ ## Intro {#intro}
878
+
879
+ \`\`\`\`md
880
+ \`\`\`norna-image-stack
881
+ - image: missing-example.jpg
882
+ \`\`\`
883
+ \`\`\`\`
884
+ `);
885
+
886
+ const result = await runContentScript(siteDir, ['--check']);
887
+ assert.match(result.stdout, /Content check passed\./);
888
+ } finally {
889
+ await rm(root, { recursive: true, force: true });
890
+ }
891
+ });
892
+
893
+ test('content:check fails for likely Norna block names outside fences', async () => {
894
+ const { root, siteDir } = await createTempSite();
895
+ try {
896
+ await writeFile(path.join(siteDir, 'content.md'), `---
897
+ title: Missing Fence
898
+ description: Fixture
899
+ ---
900
+
901
+ ## Intro {#intro}
902
+
903
+ norna-image-stack
904
+ - image: hero.jpg
905
+ `);
906
+
907
+ await assert.rejects(
908
+ () => runContentScript(siteDir, ['--check']),
909
+ (error) => {
910
+ assert.match(error.output, /Found "norna-image-stack" outside a code block\./);
911
+ assert.match(error.output, /Start the block like this:\n```norna-image-stack\n- image: filename\.jpg\n```/);
912
+ return true;
913
+ },
914
+ );
915
+ } finally {
916
+ await rm(root, { recursive: true, force: true });
917
+ }
918
+ });
919
+
920
+ test('content:check fails for short Norna block fences', async () => {
921
+ const { root, siteDir } = await createTempSite();
922
+ try {
923
+ await writeFile(path.join(siteDir, 'content.md'), `---
924
+ title: Short Fence
925
+ description: Fixture
926
+ ---
927
+
928
+ ## Intro {#intro}
929
+
930
+ \`\`norna-image-stack
931
+ - image: hero.jpg
932
+ \`\`
933
+ `);
934
+
935
+ await assert.rejects(
936
+ () => runContentScript(siteDir, ['--check']),
937
+ (error) => {
938
+ assert.match(error.output, /Invalid Norna block start for "norna-image-stack"\./);
939
+ assert.match(error.output, /Use three backticks or three tildes/);
940
+ return true;
941
+ },
942
+ );
943
+ } finally {
944
+ await rm(root, { recursive: true, force: true });
945
+ }
946
+ });
947
+
948
+ test('content:check fails for unclosed Norna blocks', async () => {
949
+ const { root, siteDir } = await createTempSite();
950
+ try {
951
+ await writeFile(path.join(siteDir, 'content.md'), `---
952
+ title: Unclosed Fence
953
+ description: Fixture
954
+ ---
955
+
956
+ ## Intro {#intro}
957
+
958
+ \`\`\`norna-image-stack
959
+ - image: hero.jpg
960
+ ~~~
961
+ `);
962
+
963
+ await assert.rejects(
964
+ () => runContentScript(siteDir, ['--check']),
965
+ (error) => {
966
+ assert.match(error.output, /This Norna block was started on line \d+ but not closed\./);
967
+ assert.match(error.output, /Add a closing ``` line after the last entry\./);
968
+ return true;
969
+ },
970
+ );
971
+ } finally {
972
+ await rm(root, { recursive: true, force: true });
973
+ }
974
+ });
975
+
976
+ test('content:check continues after malformed Norna blocks', async () => {
977
+ const { root, siteDir } = await createTempSite();
978
+ try {
979
+ await writeFile(path.join(siteDir, 'content.md'), `---
980
+ title: Multiple Issues
981
+ description: Fixture
982
+ ---
983
+
984
+ ## Intro {#intro}
985
+
986
+ \`\`\`norna-image-stack
987
+ image broken.jpg
988
+ alt: Broken
989
+ \`\`\`
990
+
991
+ \`\`\`norna-image-stack
992
+ - image: missing-intro.jpg
993
+ alt: Missing intro
994
+ \`\`\`
995
+
996
+ ## More {#more}
997
+
998
+ \`\`\`norna-image-stack
999
+ - image: missing-more.jpg
1000
+ alt: Missing more
1001
+ \`\`\`
1002
+ `);
1003
+
1004
+ await assert.rejects(
1005
+ () => runContentScript(siteDir, ['--check']),
1006
+ (error) => {
1007
+ assert.match(error.output, /Invalid norna-image-stack entry "image broken\.jpg"\. Start each image with "- image: filename\.jpg"\./);
1008
+ assert.match(error.output, /Image "missing-intro\.jpg" does not exist at .*site\/images\/intro\/missing-intro\.jpg or anywhere under any page or route image root\./);
1009
+ assert.match(error.output, /Image "missing-more\.jpg" does not exist at .*site\/images\/more\/missing-more\.jpg or anywhere under any page or route image root\./);
1010
+ return true;
1011
+ },
1012
+ );
1013
+ } finally {
1014
+ await rm(root, { recursive: true, force: true });
1015
+ }
1016
+ });
1017
+
1018
+ test('content:check allows Norna images without alt text', async () => {
1019
+ const { root, siteDir } = await createTempSite();
1020
+ try {
1021
+ await mkdir(path.join(siteDir, 'images', 'intro'), { recursive: true });
1022
+ await writeFile(path.join(siteDir, 'images', 'intro', 'hero.jpg'), 'fixture image');
1023
+ await writeFile(path.join(siteDir, 'content.md'), `---
1024
+ title: Missing Alt
1025
+ description: Fixture
1026
+ ---
1027
+
1028
+ ## Intro {#intro}
1029
+
1030
+ \`\`\`norna-image-stack
1031
+ - image: hero.jpg
1032
+ \`\`\`
1033
+ `);
1034
+
1035
+ const result = await runContentScript(siteDir, ['--check']);
1036
+ assert.match(result.stdout, /Content check passed\./);
1037
+ } finally {
1038
+ await rm(root, { recursive: true, force: true });
1039
+ }
1040
+ });
1041
+
1042
+ test('content:check reports single-image carousel and missing file together', async () => {
1043
+ const { root, siteDir } = await createTempSite();
1044
+ try {
1045
+ await writeFile(path.join(siteDir, 'content.md'), `---
1046
+ title: Beginner Errors
1047
+ description: Fixture
1048
+ ---
1049
+
1050
+ ## Plain {#plain}
1051
+
1052
+ \`\`\`norna-image-carousel
1053
+ - image: foo.jpg
1054
+ \`\`\`
1055
+ `);
1056
+
1057
+ await assert.rejects(
1058
+ () => runContentScript(siteDir, ['--check']),
1059
+ (error) => {
1060
+ assert.match(error.output, /norna-image-carousel on line \d+ contains 1 image\. A carousel needs at least two images\./);
1061
+ assert.match(error.output, /Image "foo\.jpg" does not exist at .*site\/images\/plain\/foo\.jpg or anywhere under any page or route image root\./);
1062
+ return true;
1063
+ },
1064
+ );
1065
+ } finally {
1066
+ await rm(root, { recursive: true, force: true });
1067
+ }
1068
+ });
1069
+
1070
+ test('content:check fails when a Norna-managed image file is missing', async () => {
1071
+ const { root, siteDir } = await createTempSite();
1072
+ try {
1073
+ await writeFile(path.join(siteDir, 'content.md'), `---
1074
+ title: Missing Image
1075
+ description: Fixture
1076
+ ---
1077
+
1078
+ ## Intro {#intro}
1079
+
1080
+ \`\`\`norna-image-stack
1081
+ - image: missing.jpg
1082
+ alt: Missing
1083
+ \`\`\`
1084
+ `);
1085
+
1086
+ await assert.rejects(
1087
+ () => runContentScript(siteDir, ['--check']),
1088
+ (error) => {
1089
+ assert.match(error.output, /Image "missing\.jpg" does not exist at .*site\/images\/intro\/missing\.jpg or anywhere under any page or route image root\./);
1090
+ return true;
1091
+ },
1092
+ );
1093
+ } finally {
1094
+ await rm(root, { recursive: true, force: true });
1095
+ }
1096
+ });
1097
+
1098
+ test('content:check fails when a page repeats a section id', async () => {
1099
+ const { root, siteDir } = await createTempSite();
1100
+ try {
1101
+ await writeFile(path.join(siteDir, 'content.md'), `---
1102
+ title: Duplicate Section
1103
+ description: Fixture
1104
+ ---
1105
+
1106
+ ## Intro {#intro}
1107
+
1108
+ First.
1109
+
1110
+ ## Again {#intro}
1111
+
1112
+ Second.
1113
+ `);
1114
+
1115
+ await assert.rejects(
1116
+ () => runContentScript(siteDir, ['--check']),
1117
+ (error) => {
1118
+ assert.match(error.output, /Duplicate Markdown section heading id "intro"\./);
1119
+ return true;
1120
+ },
1121
+ );
1122
+ } finally {
1123
+ await rm(root, { recursive: true, force: true });
1124
+ }
1125
+ });
1126
+
1127
+ test('content:check fails when section metadata has no Markdown section', async () => {
1128
+ const { root, siteDir } = await createTempSite();
1129
+ try {
1130
+ await writeFile(path.join(siteDir, 'content.md'), `---
1131
+ title: Orphaned Metadata
1132
+ description: Fixture
1133
+ sections:
1134
+ missing: {}
1135
+ ---
1136
+
1137
+ ## Intro {#intro}
1138
+
1139
+ Text.
1140
+ `);
1141
+
1142
+ await assert.rejects(
1143
+ () => runContentScript(siteDir, ['--check']),
1144
+ (error) => {
1145
+ assert.match(error.output, /Section metadata "missing" does not match any Markdown section\./);
1146
+ return true;
1147
+ },
1148
+ );
1149
+ } finally {
1150
+ await rm(root, { recursive: true, force: true });
1151
+ }
1152
+ });
1153
+
1154
+ test('content:sync refuses ambiguous image relocation', async () => {
1155
+ const { root, siteDir } = await createTempSite();
1156
+ try {
1157
+ await writeFile(path.join(siteDir, 'content.md'), `---
1158
+ title: Ambiguous Move
1159
+ description: Fixture
1160
+ ---
1161
+
1162
+ ## Work {#work}
1163
+
1164
+ \`\`\`norna-image-stack
1165
+ - image: shared.jpg
1166
+ alt: Shared
1167
+ \`\`\`
1168
+ `);
1169
+ await mkdir(path.join(siteDir, 'images', 'old-a'), { recursive: true });
1170
+ await mkdir(path.join(siteDir, 'images', 'old-b'), { recursive: true });
1171
+ await writeFile(path.join(siteDir, 'images', 'old-a', 'shared.jpg'), 'a');
1172
+ await writeFile(path.join(siteDir, 'images', 'old-b', 'shared.jpg'), 'b');
1173
+
1174
+ await assert.rejects(
1175
+ () => runContentScript(siteDir, ['--write', '--yes']),
1176
+ (error) => {
1177
+ assert.match(error.output, /Cannot relocate "shared\.jpg"\. Multiple files with this filename were found:/);
1178
+ assert.match(error.output, /site\/images\/old-a\/shared\.jpg/);
1179
+ assert.match(error.output, /site\/images\/old-b\/shared\.jpg/);
1180
+ return true;
1181
+ },
1182
+ );
1183
+
1184
+ assert.equal(await fileExists(path.join(siteDir, 'images', 'work', 'shared.jpg')), false);
1185
+ assert.equal(await fileExists(path.join(siteDir, 'images', 'old-a', 'shared.jpg')), true);
1186
+ assert.equal(await fileExists(path.join(siteDir, 'images', 'old-b', 'shared.jpg')), true);
1187
+ } finally {
1188
+ await rm(root, { recursive: true, force: true });
1189
+ }
1190
+ });
1191
+
1192
+ test('content:sync refuses to move an image still referenced by its current section', async () => {
1193
+ const { root, siteDir } = await createTempSite();
1194
+ try {
1195
+ await writeFile(path.join(siteDir, 'content.md'), `---
1196
+ title: Copied Image Reference
1197
+ description: Fixture
1198
+ ---
1199
+
1200
+ ## Intro {#intro}
1201
+
1202
+ \`\`\`norna-image-stack
1203
+ - image: shared.jpg
1204
+ alt: Shared original
1205
+ \`\`\`
1206
+
1207
+ ## Plain {#plain}
1208
+
1209
+ \`\`\`norna-image-stack
1210
+ - image: shared.jpg
1211
+ alt: Shared copy
1212
+ \`\`\`
1213
+ `);
1214
+ await mkdir(path.join(siteDir, 'images', 'intro'), { recursive: true });
1215
+ await writeFile(path.join(siteDir, 'images', 'intro', 'shared.jpg'), 'shared image');
1216
+
1217
+ await assert.rejects(
1218
+ () => runContentScript(siteDir, ['--write', '--yes']),
1219
+ (error) => {
1220
+ assert.match(error.output, /Cannot relocate "shared\.jpg" from .*site\/images\/intro\/shared\.jpg because it is still referenced from .*site\/content\.md \[intro\]\./);
1221
+ return true;
1222
+ },
1223
+ );
1224
+
1225
+ assert.equal(await fileExists(path.join(siteDir, 'images', 'intro', 'shared.jpg')), true);
1226
+ assert.equal(await fileExists(path.join(siteDir, 'images', 'plain', 'shared.jpg')), false);
1227
+ } finally {
1228
+ await rm(root, { recursive: true, force: true });
1229
+ }
1230
+ });