@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,998 @@
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 createTempFixtureCopy = async () => {
77
+ const tempParent = path.join(repoRoot, 'node_modules', '.cache');
78
+ await mkdir(tempParent, { recursive: true });
79
+ const root = await mkdtemp(path.join(tempParent, 'norna-content-model-v2-fixture-'));
80
+ const fixtureCopyRoot = path.join(root, 'content-model-v2');
81
+
82
+ await cp(fixtureRoot, fixtureCopyRoot, {
83
+ recursive: true,
84
+ filter: (source) => ![
85
+ path.join(fixtureRoot, '.astro'),
86
+ path.join(fixtureRoot, 'dist'),
87
+ path.join(fixtureRoot, 'node_modules'),
88
+ path.join(fixtureSiteDir, '.norna'),
89
+ ].some((ignoredPath) => source === ignoredPath || source.startsWith(`${ignoredPath}${path.sep}`)),
90
+ });
91
+
92
+ return {
93
+ root,
94
+ siteDir: path.join(fixtureCopyRoot, 'site'),
95
+ };
96
+ };
97
+
98
+ test('content model v2 fixture checks and builds', async () => {
99
+ const { root, siteDir } = await createTempFixtureCopy();
100
+ try {
101
+ await runNorna(['--site-dir', siteDir, 'content:check']);
102
+ await runNorna(['--site-dir', siteDir, 'build']);
103
+
104
+ const manifest = JSON.parse(await readFile(path.join(siteDir, '.norna', 'generated-images.json'), 'utf8'));
105
+ assert.ok(manifest['images/intro/duplicate.jpg']);
106
+ assert.ok(manifest['routes/010-guide/images/intro/duplicate.jpg']);
107
+ } finally {
108
+ await rm(root, { recursive: true, force: true });
109
+ }
110
+ });
111
+
112
+ test('content:check warns for local Markdown images but not external Markdown images', async () => {
113
+ const { root, siteDir } = await createTempSite();
114
+ try {
115
+ await writeFile(path.join(siteDir, 'content.md'), `---
116
+ title: Markdown Image Warning
117
+ description: Fixture
118
+ ---
119
+
120
+ ## Intro {#intro}
121
+
122
+ ![Local](portrait.jpg)
123
+
124
+ ![External](https://example.com/portrait.jpg)
125
+
126
+ ![Public](/favicon.svg)
127
+ `);
128
+
129
+ const { stdout } = await runContentScript(siteDir, ['--check']);
130
+ assert.match(stdout, /Markdown image "portrait\.jpg" references a local image that is not managed by Norna\./);
131
+ assert.doesNotMatch(stdout, /https:\/\/example\.com\/portrait\.jpg/);
132
+ assert.doesNotMatch(stdout, /\/favicon\.svg/);
133
+ } finally {
134
+ await rm(root, { recursive: true, force: true });
135
+ }
136
+ });
137
+
138
+ test('managed SVG images are copied as static image output', async () => {
139
+ const { root, siteDir } = await createTempSite();
140
+ try {
141
+ await writeFile(path.join(siteDir, 'content.md'), `---
142
+ title: SVG Fixture
143
+ description: Fixture
144
+ ---
145
+
146
+ ## Intro {#intro}
147
+
148
+ \`\`\`norna-image-stack
149
+ - image: diagram.svg
150
+ alt: Diagram
151
+ \`\`\`
152
+ `);
153
+ await mkdir(path.join(siteDir, 'images', 'intro'), { recursive: true });
154
+ 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');
155
+
156
+ await runContentScript(siteDir, ['--check']);
157
+ await runNorna(['--site-dir', siteDir, 'images']);
158
+
159
+ const manifest = JSON.parse(await readFile(path.join(siteDir, '.norna', 'generated-images.json'), 'utf8'));
160
+ const entry = manifest['images/intro/diagram.svg'];
161
+ assert.equal(entry.kind, 'static');
162
+ assert.equal(entry.width, 900);
163
+ assert.equal(entry.height, 620);
164
+ assert.match(entry.src, /^\/images\/original\/images\/intro\/diagram-[a-f0-9]{8}\.svg$/);
165
+ assert.equal(entry.variants, undefined);
166
+ assert.equal(await fileExists(path.join(siteDir, '.norna', 'public', entry.src.replace(/^\//, ''))), true);
167
+ } finally {
168
+ await rm(root, { recursive: true, force: true });
169
+ }
170
+ });
171
+
172
+ test('updated managed SVG images get updated static output', async () => {
173
+ const { root, siteDir } = await createTempSite();
174
+ try {
175
+ await writeFile(path.join(siteDir, 'content.md'), `---
176
+ title: SVG Update Fixture
177
+ description: Fixture
178
+ ---
179
+
180
+ ## Intro {#intro}
181
+
182
+ \`\`\`norna-image-stack
183
+ - image: diagram.svg
184
+ alt: Diagram
185
+ \`\`\`
186
+ `);
187
+ await mkdir(path.join(siteDir, 'images', 'intro'), { recursive: true });
188
+ const sourcePath = path.join(siteDir, 'images', 'intro', 'diagram.svg');
189
+ 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');
190
+
191
+ await runContentScript(siteDir, ['--check']);
192
+ await runNorna(['--site-dir', siteDir, 'images']);
193
+
194
+ const firstManifest = JSON.parse(await readFile(path.join(siteDir, '.norna', 'generated-images.json'), 'utf8'));
195
+ const firstEntry = firstManifest['images/intro/diagram.svg'];
196
+ const firstOutputPath = path.join(siteDir, '.norna', 'public', firstEntry.src.replace(/^\//, ''));
197
+
198
+ 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');
199
+ await runNorna(['--site-dir', siteDir, 'images']);
200
+
201
+ const secondManifest = JSON.parse(await readFile(path.join(siteDir, '.norna', 'generated-images.json'), 'utf8'));
202
+ const secondEntry = secondManifest['images/intro/diagram.svg'];
203
+ const secondOutputPath = path.join(siteDir, '.norna', 'public', secondEntry.src.replace(/^\//, ''));
204
+
205
+ assert.notEqual(secondEntry.sourceHash, firstEntry.sourceHash);
206
+ assert.notEqual(secondEntry.src, firstEntry.src);
207
+ assert.equal(await fileExists(firstOutputPath), false);
208
+ assert.match(await readFile(secondOutputPath, 'utf8'), /fill="blue"/);
209
+ } finally {
210
+ await rm(root, { recursive: true, force: true });
211
+ }
212
+ });
213
+
214
+ test('content:check warns when carousel SVG images have no intrinsic aspect ratio', async () => {
215
+ const { root, siteDir } = await createTempSite();
216
+ try {
217
+ await writeFile(path.join(siteDir, 'content.md'), `---
218
+ title: SVG Carousel Fixture
219
+ description: Fixture
220
+ ---
221
+
222
+ ## Intro {#intro}
223
+
224
+ \`\`\`norna-image-carousel
225
+ - image: first.svg
226
+ alt: First
227
+ - image: second.svg
228
+ alt: Second
229
+ \`\`\`
230
+ `);
231
+ await mkdir(path.join(siteDir, 'images', 'intro'), { recursive: true });
232
+ await writeFile(path.join(siteDir, 'images', 'intro', 'first.svg'), '<svg xmlns="http://www.w3.org/2000/svg"><rect width="100" height="100"/></svg>\n');
233
+ 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');
234
+
235
+ const { stdout } = await runContentScript(siteDir, ['--check']);
236
+ assert.match(stdout, /Content check completed with warnings\./);
237
+ assert.match(stdout, /Carousel on line \d+ uses images without an intrinsic aspect ratio: first\.svg\./);
238
+ } finally {
239
+ await rm(root, { recursive: true, force: true });
240
+ }
241
+ });
242
+
243
+ test('norna-card-list images are managed image references', async () => {
244
+ const { root, siteDir } = await createTempSite({ underRepoCache: true });
245
+ try {
246
+ await writeFile(path.join(siteDir, 'content.md'), `---
247
+ title: Card List Fixture
248
+ description: Fixture
249
+ ---
250
+
251
+ ## Help {#help}
252
+
253
+ \`\`\`norna-card-list
254
+ layout: image-left
255
+ flow: stack
256
+ size: l
257
+ width: narrow
258
+
259
+ - title: Adopt
260
+ text: Give a dog a new home.
261
+ image: adopt.svg
262
+ link: /adopt/
263
+ badge-text: Recommended
264
+ - title: Donate
265
+ text: Support the shelter.
266
+ \`\`\`
267
+ `);
268
+ await mkdir(path.join(siteDir, 'images', 'help'), { recursive: true });
269
+ 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');
270
+
271
+ const { stdout } = await runContentScript(siteDir, ['--check']);
272
+ assert.match(stdout, /Content check passed\./);
273
+
274
+ await runNorna(['--site-dir', siteDir, 'images']);
275
+ const manifest = JSON.parse(await readFile(path.join(siteDir, '.norna', 'generated-images.json'), 'utf8'));
276
+ assert.equal(manifest['images/help/adopt.svg'].kind, 'static');
277
+
278
+ await runNorna(['--site-dir', siteDir, 'build']);
279
+ const html = await readFile(path.join(root, 'dist', 'index.html'), 'utf8');
280
+ assert.match(html, /card-list-layout-image-left/);
281
+ assert.match(html, /card-list-flow-stack/);
282
+ assert.match(html, /card-list-size-l/);
283
+ assert.match(html, /card-list-width-narrow/);
284
+ assert.match(html, /card-list-item-has-image/);
285
+ assert.match(html, /class="card-list-badge">Recommended<\/p>/);
286
+ assert.match(html, /Give a dog a new home\./);
287
+ assert.match(html, /href="\/adopt\/"/);
288
+ assert.match(html, /src="\/images\/original\/images\/help\/adopt-[a-f0-9]{8}\.svg"/);
289
+ } finally {
290
+ await rm(root, { recursive: true, force: true });
291
+ }
292
+ });
293
+
294
+ test('content:check fails when a norna-card-list image file is missing', async () => {
295
+ const { root, siteDir } = await createTempSite();
296
+ try {
297
+ await writeFile(path.join(siteDir, 'content.md'), `---
298
+ title: Missing Card Image
299
+ description: Fixture
300
+ ---
301
+
302
+ ## Help {#help}
303
+
304
+ \`\`\`norna-card-list
305
+ - title: Adopt
306
+ image: missing.svg
307
+ \`\`\`
308
+ `);
309
+
310
+ await assert.rejects(
311
+ () => runContentScript(siteDir, ['--check']),
312
+ (error) => {
313
+ assert.match(error.output, /Image "missing\.svg" does not exist at .*site\/images\/help\/missing\.svg or anywhere under .*site\/images\//);
314
+ return true;
315
+ },
316
+ );
317
+ } finally {
318
+ await rm(root, { recursive: true, force: true });
319
+ }
320
+ });
321
+
322
+ test('content:check fails for malformed norna-card-list blocks', async () => {
323
+ const { root, siteDir } = await createTempSite();
324
+ try {
325
+ await writeFile(path.join(siteDir, 'content.md'), `---
326
+ title: Malformed Card List
327
+ description: Fixture
328
+ ---
329
+
330
+ ## Help {#help}
331
+
332
+ \`\`\`norna-card-list
333
+ - text: Missing title
334
+ \`\`\`
335
+ `);
336
+
337
+ await assert.rejects(
338
+ () => runContentScript(siteDir, ['--check']),
339
+ (error) => {
340
+ assert.match(error.output, /Invalid norna-card-list entry "- text: Missing title"\. Start each card with "- title: Card title"\./);
341
+ return true;
342
+ },
343
+ );
344
+ } finally {
345
+ await rm(root, { recursive: true, force: true });
346
+ }
347
+ });
348
+
349
+ test('content:check fails for invalid norna-card-list options', async () => {
350
+ const { root, siteDir } = await createTempSite();
351
+ try {
352
+ await writeFile(path.join(siteDir, 'content.md'), `---
353
+ title: Invalid Card List Options
354
+ description: Fixture
355
+ ---
356
+
357
+ ## Help {#help}
358
+
359
+ \`\`\`norna-card-list
360
+ layout: floating
361
+ flow: list
362
+ size: huge
363
+ width: full
364
+
365
+ - title: Adopt
366
+ text: Give a dog a new home.
367
+ \`\`\`
368
+ `);
369
+
370
+ await assert.rejects(
371
+ () => runContentScript(siteDir, ['--check']),
372
+ (error) => {
373
+ assert.match(error.output, /Invalid norna-card-list layout "floating"\. Use one of: image-top, image-left, image-right\./);
374
+ return true;
375
+ },
376
+ );
377
+ } finally {
378
+ await rm(root, { recursive: true, force: true });
379
+ }
380
+ });
381
+
382
+ test('content:check fails for invalid norna-card-list width', async () => {
383
+ const { root, siteDir } = await createTempSite();
384
+ try {
385
+ await writeFile(path.join(siteDir, 'content.md'), `---
386
+ title: Invalid Card List Width
387
+ description: Fixture
388
+ ---
389
+
390
+ ## Help {#help}
391
+
392
+ \`\`\`norna-card-list
393
+ width: full
394
+
395
+ - title: Adopt
396
+ text: Give a dog a new home.
397
+ \`\`\`
398
+ `);
399
+
400
+ await assert.rejects(
401
+ () => runContentScript(siteDir, ['--check']),
402
+ (error) => {
403
+ assert.match(error.output, /Invalid norna-card-list width "full"\. Use one of: text, narrow, normal, wide\./);
404
+ return true;
405
+ },
406
+ );
407
+ } finally {
408
+ await rm(root, { recursive: true, force: true });
409
+ }
410
+ });
411
+
412
+ test('content:check fails for invalid norna-card-list size', async () => {
413
+ const { root, siteDir } = await createTempSite();
414
+ try {
415
+ await writeFile(path.join(siteDir, 'content.md'), `---
416
+ title: Invalid Card List Size
417
+ description: Fixture
418
+ ---
419
+
420
+ ## Help {#help}
421
+
422
+ \`\`\`norna-card-list
423
+ size: huge
424
+
425
+ - title: Adopt
426
+ text: Give a dog a new home.
427
+ \`\`\`
428
+ `);
429
+
430
+ await assert.rejects(
431
+ () => runContentScript(siteDir, ['--check']),
432
+ (error) => {
433
+ assert.match(error.output, /Invalid norna-card-list size "huge"\. Use one of: s, m, l, xl\./);
434
+ return true;
435
+ },
436
+ );
437
+ } finally {
438
+ await rm(root, { recursive: true, force: true });
439
+ }
440
+ });
441
+
442
+ test('content:sync moves Norna-managed images inside the same page image root', async () => {
443
+ const { root, siteDir } = await createTempSite();
444
+ try {
445
+ await writeFile(path.join(siteDir, 'content.md'), `---
446
+ title: Sync Fixture
447
+ description: Fixture
448
+ ---
449
+
450
+ ## Work {#work}
451
+
452
+ \`\`\`norna-image-stack
453
+ - image: moved.jpg
454
+ alt: Moved image
455
+ \`\`\`
456
+ `);
457
+ await mkdir(path.join(siteDir, 'images', 'old'), { recursive: true });
458
+ await writeFile(path.join(siteDir, 'images', 'old', 'moved.jpg'), 'fixture image');
459
+
460
+ await runContentScript(siteDir, ['--write', '--yes']);
461
+
462
+ const moved = await readFile(path.join(siteDir, 'images', 'work', 'moved.jpg'), 'utf8');
463
+ assert.equal(moved, 'fixture image');
464
+ } finally {
465
+ await rm(root, { recursive: true, force: true });
466
+ }
467
+ });
468
+
469
+ test('content:sync moves managed SVG images inside the same page image root', async () => {
470
+ const { root, siteDir } = await createTempSite();
471
+ try {
472
+ await writeFile(path.join(siteDir, 'content.md'), `---
473
+ title: SVG Sync Fixture
474
+ description: Fixture
475
+ ---
476
+
477
+ ## Work {#work}
478
+
479
+ \`\`\`norna-image-stack
480
+ - image: moved.svg
481
+ alt: Moved diagram
482
+ \`\`\`
483
+ `);
484
+ await mkdir(path.join(siteDir, 'images', 'old'), { recursive: true });
485
+ await writeFile(path.join(siteDir, 'images', 'old', 'moved.svg'), '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"></svg>\n');
486
+
487
+ await runContentScript(siteDir, ['--write', '--yes']);
488
+
489
+ const moved = await readFile(path.join(siteDir, 'images', 'work', 'moved.svg'), 'utf8');
490
+ assert.match(moved, /viewBox="0 0 10 10"/);
491
+ } finally {
492
+ await rm(root, { recursive: true, force: true });
493
+ }
494
+ });
495
+
496
+ test('CLI content:sync refreshes generated images after moving a carousel image', async () => {
497
+ const { root, siteDir } = await createTempSite();
498
+ try {
499
+ await writeFile(path.join(siteDir, 'content.md'), `---
500
+ title: Sync Fixture
501
+ description: Fixture
502
+ ---
503
+
504
+ ## Work {#work}
505
+
506
+ \`\`\`norna-image-carousel
507
+ - image: slide-one.jpg
508
+ alt: Slide one
509
+ - image: slide-two.jpg
510
+ alt: Slide two
511
+ \`\`\`
512
+ `);
513
+ await mkdir(path.join(siteDir, 'images', 'old'), { recursive: true });
514
+ await mkdir(path.join(siteDir, 'images', 'work'), { recursive: true });
515
+ await cp(path.join(fixtureSiteDir, 'images', 'intro', 'hero.jpg'), path.join(siteDir, 'images', 'old', 'slide-one.jpg'));
516
+ await cp(path.join(fixtureSiteDir, 'images', 'intro', 'detail.jpg'), path.join(siteDir, 'images', 'work', 'slide-two.jpg'), { force: true });
517
+
518
+ await runNorna(['--site-dir', siteDir, 'content:sync', '--yes']);
519
+
520
+ const manifest = JSON.parse(await readFile(path.join(siteDir, '.norna', 'generated-images.json'), 'utf8'));
521
+ assert.equal(await fileExists(path.join(siteDir, 'images', 'work', 'slide-one.jpg')), true);
522
+ assert.ok(manifest['images/work/slide-one.jpg']);
523
+ assert.ok(manifest['images/work/slide-two.jpg']);
524
+ } finally {
525
+ await rm(root, { recursive: true, force: true });
526
+ }
527
+ });
528
+
529
+ test('content:check fails when a level 2 section is missing an explicit id', async () => {
530
+ const { root, siteDir } = await createTempSite();
531
+ try {
532
+ await writeFile(path.join(siteDir, 'content.md'), `---
533
+ title: Missing Id
534
+ description: Fixture
535
+ ---
536
+
537
+ ## Intro
538
+
539
+ This section has no explicit id.
540
+ `);
541
+
542
+ await assert.rejects(
543
+ () => runContentScript(siteDir, ['--check']),
544
+ (error) => {
545
+ assert.match(error.output, /Section heading "Intro" is missing an explicit id\./);
546
+ return true;
547
+ },
548
+ );
549
+ } finally {
550
+ await rm(root, { recursive: true, force: true });
551
+ }
552
+ });
553
+
554
+ test('content:check fails for malformed Norna image blocks', async () => {
555
+ const { root, siteDir } = await createTempSite();
556
+ try {
557
+ await writeFile(path.join(siteDir, 'content.md'), `---
558
+ title: Malformed Block
559
+ description: Fixture
560
+ ---
561
+
562
+ ## Intro {#intro}
563
+
564
+ \`\`\`norna-image-stack
565
+ image hero.jpg
566
+ alt: Hero
567
+ \`\`\`
568
+ `);
569
+
570
+ await assert.rejects(
571
+ () => runContentScript(siteDir, ['--check']),
572
+ (error) => {
573
+ assert.match(error.output, /Invalid norna-image-stack entry "image hero\.jpg"\. Start each image with "- image: filename\.jpg"\./);
574
+ return true;
575
+ },
576
+ );
577
+ } finally {
578
+ await rm(root, { recursive: true, force: true });
579
+ }
580
+ });
581
+
582
+ test('content:check fails for unknown Norna block names', async () => {
583
+ const { root, siteDir } = await createTempSite();
584
+ try {
585
+ await mkdir(path.join(siteDir, 'images', 'plain'), { recursive: true });
586
+ await writeFile(path.join(siteDir, 'images', 'plain', 'image.jpg'), 'fixture image');
587
+ await writeFile(path.join(siteDir, 'content.md'), `---
588
+ title: Unknown Block
589
+ description: Fixture
590
+ ---
591
+
592
+ ## Plain {#plain}
593
+
594
+ \`\`\`norna-gallery-stack
595
+ - image: image.jpg
596
+ \`\`\`
597
+ `);
598
+
599
+ await assert.rejects(
600
+ () => runContentScript(siteDir, ['--check']),
601
+ (error) => {
602
+ assert.match(error.output, /Unknown Norna block "norna-gallery-stack"\. Use one of: norna-image-stack, norna-image-carousel, norna-card-list\./);
603
+ assert.match(error.output, /Use norna-image-stack for one or more stacked images\./);
604
+ assert.match(error.output, /Example: ```norna-image-stack\n- image: filename\.jpg\n```/);
605
+ return true;
606
+ },
607
+ );
608
+ } finally {
609
+ await rm(root, { recursive: true, force: true });
610
+ }
611
+ });
612
+
613
+ test('content:check supports tilde fenced Norna blocks', async () => {
614
+ const { root, siteDir } = await createTempSite();
615
+ try {
616
+ await mkdir(path.join(siteDir, 'images', 'intro'), { recursive: true });
617
+ await writeFile(path.join(siteDir, 'images', 'intro', 'hero.jpg'), 'fixture image');
618
+ await writeFile(path.join(siteDir, 'content.md'), `---
619
+ title: Tilde Fence
620
+ description: Fixture
621
+ ---
622
+
623
+ ## Intro {#intro}
624
+
625
+ ~~~norna-image-stack
626
+ - image: hero.jpg
627
+ ~~~
628
+ `);
629
+
630
+ const result = await runContentScript(siteDir, ['--check']);
631
+ assert.match(result.stdout, /Content check passed\./);
632
+ } finally {
633
+ await rm(root, { recursive: true, force: true });
634
+ }
635
+ });
636
+
637
+ test('content:check ignores Norna blocks shown inside longer code fences', async () => {
638
+ const { root, siteDir } = await createTempSite();
639
+ try {
640
+ await writeFile(path.join(siteDir, 'content.md'), `---
641
+ title: Code Example
642
+ description: Fixture
643
+ ---
644
+
645
+ ## Intro {#intro}
646
+
647
+ \`\`\`\`md
648
+ \`\`\`norna-image-stack
649
+ - image: missing-example.jpg
650
+ \`\`\`
651
+ \`\`\`\`
652
+ `);
653
+
654
+ const result = await runContentScript(siteDir, ['--check']);
655
+ assert.match(result.stdout, /Content check passed\./);
656
+ } finally {
657
+ await rm(root, { recursive: true, force: true });
658
+ }
659
+ });
660
+
661
+ test('content:check fails for likely Norna block names outside fences', async () => {
662
+ const { root, siteDir } = await createTempSite();
663
+ try {
664
+ await writeFile(path.join(siteDir, 'content.md'), `---
665
+ title: Missing Fence
666
+ description: Fixture
667
+ ---
668
+
669
+ ## Intro {#intro}
670
+
671
+ norna-image-stack
672
+ - image: hero.jpg
673
+ `);
674
+
675
+ await assert.rejects(
676
+ () => runContentScript(siteDir, ['--check']),
677
+ (error) => {
678
+ assert.match(error.output, /Found "norna-image-stack" outside a code block\./);
679
+ assert.match(error.output, /Start the block like this:\n```norna-image-stack\n- image: filename\.jpg\n```/);
680
+ return true;
681
+ },
682
+ );
683
+ } finally {
684
+ await rm(root, { recursive: true, force: true });
685
+ }
686
+ });
687
+
688
+ test('content:check fails for short Norna block fences', async () => {
689
+ const { root, siteDir } = await createTempSite();
690
+ try {
691
+ await writeFile(path.join(siteDir, 'content.md'), `---
692
+ title: Short Fence
693
+ description: Fixture
694
+ ---
695
+
696
+ ## Intro {#intro}
697
+
698
+ \`\`norna-image-stack
699
+ - image: hero.jpg
700
+ \`\`
701
+ `);
702
+
703
+ await assert.rejects(
704
+ () => runContentScript(siteDir, ['--check']),
705
+ (error) => {
706
+ assert.match(error.output, /Invalid Norna block start for "norna-image-stack"\./);
707
+ assert.match(error.output, /Use three backticks or three tildes/);
708
+ return true;
709
+ },
710
+ );
711
+ } finally {
712
+ await rm(root, { recursive: true, force: true });
713
+ }
714
+ });
715
+
716
+ test('content:check fails for unclosed Norna blocks', async () => {
717
+ const { root, siteDir } = await createTempSite();
718
+ try {
719
+ await writeFile(path.join(siteDir, 'content.md'), `---
720
+ title: Unclosed Fence
721
+ description: Fixture
722
+ ---
723
+
724
+ ## Intro {#intro}
725
+
726
+ \`\`\`norna-image-stack
727
+ - image: hero.jpg
728
+ ~~~
729
+ `);
730
+
731
+ await assert.rejects(
732
+ () => runContentScript(siteDir, ['--check']),
733
+ (error) => {
734
+ assert.match(error.output, /This Norna block was started on line \d+ but not closed\./);
735
+ assert.match(error.output, /Add a closing ``` line after the last entry\./);
736
+ return true;
737
+ },
738
+ );
739
+ } finally {
740
+ await rm(root, { recursive: true, force: true });
741
+ }
742
+ });
743
+
744
+ test('content:check continues after malformed Norna blocks', async () => {
745
+ const { root, siteDir } = await createTempSite();
746
+ try {
747
+ await writeFile(path.join(siteDir, 'content.md'), `---
748
+ title: Multiple Issues
749
+ description: Fixture
750
+ ---
751
+
752
+ ## Intro {#intro}
753
+
754
+ \`\`\`norna-image-stack
755
+ image broken.jpg
756
+ alt: Broken
757
+ \`\`\`
758
+
759
+ \`\`\`norna-image-stack
760
+ - image: missing-intro.jpg
761
+ alt: Missing intro
762
+ \`\`\`
763
+
764
+ ## More {#more}
765
+
766
+ \`\`\`norna-image-stack
767
+ - image: missing-more.jpg
768
+ alt: Missing more
769
+ \`\`\`
770
+ `);
771
+
772
+ await assert.rejects(
773
+ () => runContentScript(siteDir, ['--check']),
774
+ (error) => {
775
+ assert.match(error.output, /Invalid norna-image-stack entry "image broken\.jpg"\. Start each image with "- image: filename\.jpg"\./);
776
+ assert.match(error.output, /Image "missing-intro\.jpg" does not exist at .*site\/images\/intro\/missing-intro\.jpg or anywhere under .*site\/images\//);
777
+ assert.match(error.output, /Image "missing-more\.jpg" does not exist at .*site\/images\/more\/missing-more\.jpg or anywhere under .*site\/images\//);
778
+ return true;
779
+ },
780
+ );
781
+ } finally {
782
+ await rm(root, { recursive: true, force: true });
783
+ }
784
+ });
785
+
786
+ test('content:check allows Norna images without alt text', async () => {
787
+ const { root, siteDir } = await createTempSite();
788
+ try {
789
+ await mkdir(path.join(siteDir, 'images', 'intro'), { recursive: true });
790
+ await writeFile(path.join(siteDir, 'images', 'intro', 'hero.jpg'), 'fixture image');
791
+ await writeFile(path.join(siteDir, 'content.md'), `---
792
+ title: Missing Alt
793
+ description: Fixture
794
+ ---
795
+
796
+ ## Intro {#intro}
797
+
798
+ \`\`\`norna-image-stack
799
+ - image: hero.jpg
800
+ \`\`\`
801
+ `);
802
+
803
+ const result = await runContentScript(siteDir, ['--check']);
804
+ assert.match(result.stdout, /Content check passed\./);
805
+ } finally {
806
+ await rm(root, { recursive: true, force: true });
807
+ }
808
+ });
809
+
810
+ test('content:check reports single-image carousel and missing file together', async () => {
811
+ const { root, siteDir } = await createTempSite();
812
+ try {
813
+ await writeFile(path.join(siteDir, 'content.md'), `---
814
+ title: Beginner Errors
815
+ description: Fixture
816
+ ---
817
+
818
+ ## Plain {#plain}
819
+
820
+ \`\`\`norna-image-carousel
821
+ - image: foo.jpg
822
+ \`\`\`
823
+ `);
824
+
825
+ await assert.rejects(
826
+ () => runContentScript(siteDir, ['--check']),
827
+ (error) => {
828
+ assert.match(error.output, /norna-image-carousel on line \d+ contains 1 image\. A carousel needs at least two images\./);
829
+ assert.match(error.output, /Image "foo\.jpg" does not exist at .*site\/images\/plain\/foo\.jpg or anywhere under .*site\/images\//);
830
+ return true;
831
+ },
832
+ );
833
+ } finally {
834
+ await rm(root, { recursive: true, force: true });
835
+ }
836
+ });
837
+
838
+ test('content:check fails when a Norna-managed image file is missing', async () => {
839
+ const { root, siteDir } = await createTempSite();
840
+ try {
841
+ await writeFile(path.join(siteDir, 'content.md'), `---
842
+ title: Missing Image
843
+ description: Fixture
844
+ ---
845
+
846
+ ## Intro {#intro}
847
+
848
+ \`\`\`norna-image-stack
849
+ - image: missing.jpg
850
+ alt: Missing
851
+ \`\`\`
852
+ `);
853
+
854
+ await assert.rejects(
855
+ () => runContentScript(siteDir, ['--check']),
856
+ (error) => {
857
+ assert.match(error.output, /Image "missing\.jpg" does not exist at .*site\/images\/intro\/missing\.jpg or anywhere under .*site\/images\//);
858
+ return true;
859
+ },
860
+ );
861
+ } finally {
862
+ await rm(root, { recursive: true, force: true });
863
+ }
864
+ });
865
+
866
+ test('content:check fails when a page repeats a section id', async () => {
867
+ const { root, siteDir } = await createTempSite();
868
+ try {
869
+ await writeFile(path.join(siteDir, 'content.md'), `---
870
+ title: Duplicate Section
871
+ description: Fixture
872
+ ---
873
+
874
+ ## Intro {#intro}
875
+
876
+ First.
877
+
878
+ ## Again {#intro}
879
+
880
+ Second.
881
+ `);
882
+
883
+ await assert.rejects(
884
+ () => runContentScript(siteDir, ['--check']),
885
+ (error) => {
886
+ assert.match(error.output, /Duplicate Markdown section heading id "intro"\./);
887
+ return true;
888
+ },
889
+ );
890
+ } finally {
891
+ await rm(root, { recursive: true, force: true });
892
+ }
893
+ });
894
+
895
+ test('content:check fails when section metadata has no Markdown section', async () => {
896
+ const { root, siteDir } = await createTempSite();
897
+ try {
898
+ await writeFile(path.join(siteDir, 'content.md'), `---
899
+ title: Orphaned Metadata
900
+ description: Fixture
901
+ sections:
902
+ missing: {}
903
+ ---
904
+
905
+ ## Intro {#intro}
906
+
907
+ Text.
908
+ `);
909
+
910
+ await assert.rejects(
911
+ () => runContentScript(siteDir, ['--check']),
912
+ (error) => {
913
+ assert.match(error.output, /Section metadata "missing" does not match any Markdown section\./);
914
+ return true;
915
+ },
916
+ );
917
+ } finally {
918
+ await rm(root, { recursive: true, force: true });
919
+ }
920
+ });
921
+
922
+ test('content:sync refuses ambiguous page-local image relocation', async () => {
923
+ const { root, siteDir } = await createTempSite();
924
+ try {
925
+ await writeFile(path.join(siteDir, 'content.md'), `---
926
+ title: Ambiguous Move
927
+ description: Fixture
928
+ ---
929
+
930
+ ## Work {#work}
931
+
932
+ \`\`\`norna-image-stack
933
+ - image: shared.jpg
934
+ alt: Shared
935
+ \`\`\`
936
+ `);
937
+ await mkdir(path.join(siteDir, 'images', 'old-a'), { recursive: true });
938
+ await mkdir(path.join(siteDir, 'images', 'old-b'), { recursive: true });
939
+ await writeFile(path.join(siteDir, 'images', 'old-a', 'shared.jpg'), 'a');
940
+ await writeFile(path.join(siteDir, 'images', 'old-b', 'shared.jpg'), 'b');
941
+
942
+ await assert.rejects(
943
+ () => runContentScript(siteDir, ['--write', '--yes']),
944
+ (error) => {
945
+ assert.match(error.output, /Cannot relocate "shared\.jpg"\. Multiple files with this filename were found:/);
946
+ assert.match(error.output, /site\/images\/old-a\/shared\.jpg/);
947
+ assert.match(error.output, /site\/images\/old-b\/shared\.jpg/);
948
+ return true;
949
+ },
950
+ );
951
+
952
+ assert.equal(await fileExists(path.join(siteDir, 'images', 'work', 'shared.jpg')), false);
953
+ assert.equal(await fileExists(path.join(siteDir, 'images', 'old-a', 'shared.jpg')), true);
954
+ assert.equal(await fileExists(path.join(siteDir, 'images', 'old-b', 'shared.jpg')), true);
955
+ } finally {
956
+ await rm(root, { recursive: true, force: true });
957
+ }
958
+ });
959
+
960
+ test('content:sync refuses to move an image still referenced by its current section', async () => {
961
+ const { root, siteDir } = await createTempSite();
962
+ try {
963
+ await writeFile(path.join(siteDir, 'content.md'), `---
964
+ title: Copied Image Reference
965
+ description: Fixture
966
+ ---
967
+
968
+ ## Intro {#intro}
969
+
970
+ \`\`\`norna-image-stack
971
+ - image: shared.jpg
972
+ alt: Shared original
973
+ \`\`\`
974
+
975
+ ## Plain {#plain}
976
+
977
+ \`\`\`norna-image-stack
978
+ - image: shared.jpg
979
+ alt: Shared copy
980
+ \`\`\`
981
+ `);
982
+ await mkdir(path.join(siteDir, 'images', 'intro'), { recursive: true });
983
+ await writeFile(path.join(siteDir, 'images', 'intro', 'shared.jpg'), 'shared image');
984
+
985
+ await assert.rejects(
986
+ () => runContentScript(siteDir, ['--write', '--yes']),
987
+ (error) => {
988
+ assert.match(error.output, /Cannot relocate "shared\.jpg" from .*site\/images\/intro\/shared\.jpg because it is still referenced from .*site\/content\.md \[intro\]\./);
989
+ return true;
990
+ },
991
+ );
992
+
993
+ assert.equal(await fileExists(path.join(siteDir, 'images', 'intro', 'shared.jpg')), true);
994
+ assert.equal(await fileExists(path.join(siteDir, 'images', 'plain', 'shared.jpg')), false);
995
+ } finally {
996
+ await rm(root, { recursive: true, force: true });
997
+ }
998
+ });