@janga/norna 0.7.7 → 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.
package/README.md CHANGED
@@ -25,12 +25,13 @@ A `norna` site is file-driven:
25
25
  base path, in `site/config.mjs`.
26
26
  3. The site keeps site-wide visual theme defaults, including layout, image
27
27
  sizing, font, typography, colors, and frame colors, in `site/theme.md`.
28
- 4. The site keeps homepage content, Markdown section order, image blocks, alt
29
- text, captions, and optional section metadata in `site/content.md`.
28
+ 4. The site keeps homepage content, Markdown section order, managed media
29
+ blocks, alt text, captions, and optional section metadata in
30
+ `site/content.md`.
30
31
  5. Optional route pages live under
31
- `site/routes/<route-folder>/route-content.md`.
32
+ `site/routes/<NNN-route-id>/route-content.md`.
32
33
  6. Source images live under `site/images/<section-id>/` for the homepage, or
33
- under `site/routes/<route-folder>/images/<section-id>/` for a route.
34
+ under `site/routes/<NNN-route-id>/images/<section-id>/` for a route.
34
35
  7. Static public files live under `site/public/`.
35
36
  8. `norna` validates the files, prepares managed images, builds static Astro
36
37
  pages, and can help publish the committed branch through GitHub Pages.
@@ -75,7 +76,7 @@ Actions use the same engine version.
75
76
 
76
77
  - Create a site: [Getting Started](docs/getting-started.md)
77
78
  - Understand required site files: [Site Structure](docs/site-structure.md)
78
- - Edit sections and image blocks: [Content](docs/content.md)
79
+ - Edit sections and managed media blocks: [Content](docs/content.md)
79
80
  - Configure a site: [Configuration](docs/configuration.md)
80
81
  - Publish under a GitHub Pages project path:
81
82
  [`site.basePath`](docs/configuration.md#sitebasepath)
package/docs/README.md CHANGED
@@ -39,7 +39,7 @@ For a quick reference:
39
39
  fields such as URL, base path, locale labels, footer, GitHub, and deploy
40
40
  settings.
41
41
  - [Content](content.md) describes page frontmatter, Markdown-authored
42
- sections, optional section metadata, image stacks, carousels, temporary
42
+ sections, optional section metadata, managed media blocks, temporary
43
43
  sections, and content validation.
44
44
  - [Theme](theme.md) describes site-wide layout, layout density, image sizing,
45
45
  font, typography rhythm, presentation colors, frame colors, and inline styles.
package/docs/commands.md CHANGED
@@ -101,10 +101,11 @@ should normally mean the repository's complete publishable artifact, while
101
101
  cache path.
102
102
  - `config:check`: validates `site/config.mjs` against the runtime config
103
103
  reader.
104
- - `content:check`: validates section structure, image references, inline
105
- styles, and common content mistakes.
104
+ - `content:check`: validates section structure, managed media references,
105
+ inline styles, and common content mistakes.
106
106
  - `content:sync` / `norna:sync`: moves misplaced referenced image files after
107
- confirmation.
107
+ confirmation when the intended move is unambiguous. Moving files between the
108
+ homepage and routes, or between routes, requires a clean Git working tree.
108
109
  - `typography presets`: prints the exact built-in values for typography
109
110
  presets and rhythms.
110
111
  - `typography show`: prints the selected site's resolved typography for the
@@ -141,7 +141,7 @@ Fields:
141
141
  `Sections`.
142
142
  - `locale.labels.menu`: mobile menu summary text, default `Menu`.
143
143
  - `locale.labels.closeMenu`: reserved close-menu label, default `Close menu`.
144
- - `locale.labels.gallery`: Norna image block ARIA label prefix, default
144
+ - `locale.labels.gallery`: Norna managed media ARIA label prefix, default
145
145
  `Images`.
146
146
 
147
147
  Example:
package/docs/content.md CHANGED
@@ -91,10 +91,12 @@ page file:
91
91
  Do not list sections in frontmatter just to define order. Section order comes
92
92
  from the Markdown heading order.
93
93
 
94
- ## Image Blocks
94
+ ## Managed Media Blocks
95
95
 
96
- Norna-managed local images are written in Markdown fenced blocks at the point
97
- where they should appear in the section.
96
+ Norna-managed local images and cards are written in Markdown fenced blocks at
97
+ the point where they should appear in the section. Markdown determines
98
+ placement: move the fenced block in the page file to move the rendered image,
99
+ carousel, or card list.
98
100
 
99
101
  Use `norna-image-stack` for one or more stacked images:
100
102
 
@@ -119,6 +121,38 @@ Use `norna-image-carousel` for a carousel:
119
121
  ```
120
122
  ````
121
123
 
124
+ Use `norna-card-list` for a list of compact cards. Cards can include text,
125
+ managed images, links, and optional badge text:
126
+
127
+ ````md
128
+ ```norna-card-list
129
+ layout: image-top
130
+ flow: grid
131
+ size: m
132
+ width: normal
133
+
134
+ - title: Adopt
135
+ text: Give a dog a new home.
136
+ image: adopt.svg
137
+ link: /adopt/
138
+ badge-text: Recommended
139
+ - title: Foster
140
+ text: Help for a shorter period.
141
+ image: foster.svg
142
+ ```
143
+ ````
144
+
145
+ Card-list options:
146
+
147
+ - `layout`: `image-top`, `image-left`, or `image-right`.
148
+ - `flow`: `grid` or `stack`.
149
+ - `size`: `s`, `m`, `l`, or `xl`.
150
+ - `width`: `text`, `narrow`, `normal`, or `wide`.
151
+
152
+ Each card starts with `- title: Card title`. Card fields use two spaces of
153
+ indentation. Supported fields are `text`, `image`, `link`, and `badge-text`.
154
+ Each card must include at least one of `text`, `image`, or `link`.
155
+
122
156
  Each image entry supports:
123
157
 
124
158
  - `image`: required filename matching
@@ -127,8 +161,8 @@ Each image entry supports:
127
161
  - `alt`: optional alt text. If omitted, Norna renders an empty alt attribute.
128
162
  - `caption`: optional caption.
129
163
 
130
- Start every image entry with `- image: filename.jpg`. Optional fields use two
131
- spaces of indentation:
164
+ Start every image entry in image stacks and carousels with
165
+ `- image: filename.jpg`. Optional fields use two spaces of indentation:
132
166
 
133
167
  ````md
134
168
  ```norna-image-stack
@@ -174,7 +208,7 @@ Route images live under:
174
208
  site/routes/<NNN-route-id>/images/<section-id>/
175
209
  ```
176
210
 
177
- Image references in Norna image blocks use only the filename:
211
+ Image references in Norna managed media blocks use only the filename:
178
212
 
179
213
  ````md
180
214
  ```norna-image-stack
@@ -187,7 +221,8 @@ location is `site/images/team/portrait.jpg`.
187
221
 
188
222
  Filenames do not have to be globally unique for the site to be valid. Automatic
189
223
  sync only moves files when the filename identifies exactly one source candidate
190
- within the current page or route.
224
+ across the site's page and route image roots. If more than one candidate
225
+ exists, Norna reports the ambiguity instead of guessing.
191
226
 
192
227
  ## Markdown Images
193
228
 
@@ -199,8 +234,9 @@ Markdown image syntax is allowed for external images and public static assets:
199
234
  ```
200
235
 
201
236
  Relative local Markdown images such as `![Portrait](portrait.jpg)` are not
202
- managed by Norna. Use `norna-image-stack` or `norna-image-carousel` for local
203
- site images that should be validated, processed and synced.
237
+ managed by Norna. Use `norna-image-stack`, `norna-image-carousel`, or
238
+ `norna-card-list` for local site images that should be validated, processed and
239
+ synced.
204
240
 
205
241
  ## Temporary Sections
206
242
 
@@ -253,9 +289,9 @@ npm run norna:content:check
253
289
 
254
290
  This checks section heading ids, section metadata, duplicate image names,
255
291
  missing image files, misplaced referenced images, duplicate image references,
256
- invalid image blocks, unreferenced images, undefined inline styles, Markdown
257
- image references to unmanaged local files, and common frontmatter indentation
258
- and structure mistakes.
292
+ invalid managed media blocks, unreferenced images, undefined inline styles,
293
+ Markdown image references to unmanaged local files, and common frontmatter
294
+ indentation and structure mistakes.
259
295
 
260
296
  Frontmatter uses YAML indentation. Use ordinary spaces, not tabs or
261
297
  non-breaking spaces. `content:check` reports a focused error when indentation is
@@ -282,5 +318,19 @@ npm run norna:sync
282
318
  ```
283
319
 
284
320
  This moves referenced image files into the section directory shown by the
285
- Markdown placement. It prompts before writing unless `--yes` is passed. Sync is
286
- currently page- or route-local: it does not move images across routes.
321
+ Markdown placement. It prompts before writing unless `--yes` is passed.
322
+
323
+ `content:sync` is intentionally conservative. It only moves a file when the
324
+ filename identifies exactly one matching source candidate across the site's
325
+ page and route image roots, and when the move will not break another reference.
326
+ If the intended move is ambiguous, rename or move the file manually and run
327
+ `content:check` again.
328
+
329
+ Duplicate filenames are allowed when files already live where their Markdown
330
+ references expect them. Automatic relocation only requires site-wide filename
331
+ uniqueness for the file being moved.
332
+
333
+ When `content:sync` needs to move an image between the homepage and a route, or
334
+ between two routes, the Git working tree must be clean before the write. This
335
+ keeps cross-route sync easy to roll back. `content:check` only reports issues
336
+ and does not require a clean working tree.
@@ -16,10 +16,13 @@ Install Playwright Chromium only when you plan to run navigation diagnostics:
16
16
  npx playwright install chromium
17
17
  ```
18
18
 
19
- ## Create A Site Repository
19
+ ## Create A Site
20
20
 
21
- Create the site from the starter first, then install dependencies inside the
22
- new site directory:
21
+ ### Standalone Site
22
+
23
+ Use a standalone setup when the website is its own project. Create the site
24
+ from the starter first, then install dependencies inside the new site
25
+ directory:
23
26
 
24
27
  ```sh
25
28
  cd path/to/your/projects
@@ -55,11 +58,33 @@ The starter contains:
55
58
  - `site/config.mjs`
56
59
  - `site/theme.md`
57
60
  - `site/content.md`
58
- - `site/images/work/.gitkeep`
59
61
  - `site/public/robots.txt`
60
62
 
61
63
  Commit the generated `package-lock.json` after the first install.
62
64
 
65
+ ### Embedded Site
66
+
67
+ Use an embedded setup when a Norna site should live inside an existing Node or
68
+ GitHub project:
69
+
70
+ ```sh
71
+ cd existing-project
72
+ npx @janga/norna@latest init . --type embedded --site-dir presentation
73
+ npm install
74
+ npm run norna:dev
75
+ ```
76
+
77
+ The command looks different because the target is different. `.` means "add
78
+ Norna to the current project" instead of creating a new directory.
79
+ `--site-dir presentation` tells Norna to manage `presentation/` as the site
80
+ directory instead of the default `site/`.
81
+
82
+ Embedded setup keeps the surrounding project structure. It adds namespaced
83
+ `norna:*` scripts so Norna does not take over the project's normal `build`,
84
+ `test`, or deploy scripts.
85
+
86
+ Relevant documentation: [Commands](commands.md), [Site Structure](site-structure.md).
87
+
63
88
  ## First Edits
64
89
 
65
90
  1. Edit `site/config.mjs` for the site's URL, language labels, GitHub
@@ -68,9 +93,10 @@ Commit the generated `package-lock.json` after the first install.
68
93
  rhythm, image sizing, font, typography preset, colors, inline styles, and
69
94
  frame colors, or omit it to use engine defaults.
70
95
  3. Edit `site/content.md` for homepage title, description, Markdown sections,
71
- text, Norna image blocks, page/section presentation overrides, alt text, and
72
- captions.
73
- 4. Put source images under `site/images/<section-id>/`.
96
+ text, Norna managed media blocks, page/section presentation overrides, alt
97
+ text, and captions.
98
+ 4. Put source images under `site/images/<section-id>/`, where `<section-id>`
99
+ matches a `## Section {#section-id}` heading in `site/content.md`.
74
100
  5. Add optional route pages under
75
101
  `site/routes/<NNN-route-id>/route-content.md`, for example
76
102
  `site/routes/010-about/route-content.md`.
@@ -79,10 +105,23 @@ Commit the generated `package-lock.json` after the first install.
79
105
  7. Run:
80
106
 
81
107
  ```sh
108
+ npm run norna:config:check
109
+ npm run norna:content:check
110
+ npm run norna:sync
82
111
  npm run norna:check
83
112
  npm run build
84
113
  ```
85
114
 
115
+ `norna:sync` moves misplaced referenced image files when the intended move is
116
+ unambiguous. It can move images between sections and routes, but cross-route
117
+ writes require a clean Git working tree so the operation is easy to roll back.
118
+ Publishing is normally done by committing the site files and pushing them with
119
+ Git; the starter GitHub Pages workflow runs the required checks before
120
+ publishing.
121
+
122
+ Relevant documentation: [Content](content.md), [Theme](theme.md),
123
+ [Typography](typography.md), [Routes](routes.md), [Images And Metadata](images-and-metadata.md).
124
+
86
125
  ## Configure The Public URL
87
126
 
88
127
  For a custom domain or any site published at the web root, use:
@@ -112,6 +151,8 @@ The starter includes a GitHub Pages workflow in `.github/workflows/deploy.yml`.
112
151
  In the GitHub repository settings, configure Pages to build from GitHub
113
152
  Actions.
114
153
 
154
+ Relevant documentation: [Configuration](configuration.md), [Publishing](publishing.md).
155
+
115
156
  Read [Site Structure](site-structure.md), [Content](content.md),
116
157
  [Theme](theme.md), [Typography](typography.md), [Routes](routes.md), and
117
158
  [Configuration](configuration.md) before publishing a real site.
@@ -6,8 +6,8 @@ source images and any copyright or licensing policy for those images.
6
6
  ## Managed Source Images
7
7
 
8
8
  Norna-managed local images are referenced from Markdown with
9
- `norna-image-stack` or `norna-image-carousel` blocks. See
10
- [Content](content.md#image-blocks) for the block syntax.
9
+ `norna-image-stack`, `norna-image-carousel`, or `norna-card-list` blocks. See
10
+ [Content](content.md#managed-media-blocks) for the block syntax.
11
11
 
12
12
  Homepage source images live under:
13
13
 
@@ -28,7 +28,7 @@ Supported source extensions:
28
28
  - `.png`
29
29
  - `.svg`
30
30
 
31
- Image block references use only the filename:
31
+ Managed media block references use only the filename:
32
32
 
33
33
  ````md
34
34
  ```norna-image-stack
@@ -53,8 +53,10 @@ prompt: Short prompt or editing note.
53
53
  ```
54
54
 
55
55
  Filenames do not have to be globally unique for the site to be valid.
56
- Automatic sync only moves files when the filename identifies exactly one source
57
- candidate within the current page or route.
56
+ Automatic sync only moves misplaced files when the filename identifies exactly
57
+ one source candidate across the site's page and route image roots, and when the
58
+ move will not break another reference. If the move crosses page or route image
59
+ roots, `content:sync` requires a clean Git working tree before writing.
58
60
 
59
61
  ## Markdown Images
60
62
 
@@ -98,7 +100,7 @@ example-work-1a2b3c4d-1440.webp
98
100
  When a source image changes, the generated URL changes too. This avoids stale
99
101
  browser, CDN, and GitHub Actions cache entries at the old URL.
100
102
 
101
- SVG files are managed by the same Markdown image blocks, validation and sync
103
+ SVG files are managed by the same Markdown media blocks, validation and sync
102
104
  model, but they are not rasterized and do not get WebP variants. Norna copies
103
105
  the SVG source to:
104
106
 
@@ -164,6 +166,9 @@ static website, restrained colors, readable composition, no text in the image.
164
166
  ```
165
167
  ````
166
168
 
169
+ The same provenance comment style can be used next to a `norna-image-carousel`
170
+ or `norna-card-list` block when generated images are referenced there.
171
+
167
172
  This is a maintenance rule for real editorial images. It is not necessary for
168
173
  throwaway test fixtures or examples where the prompt has no practical value.
169
174
 
package/docs/routes.md CHANGED
@@ -4,9 +4,14 @@
4
4
  under `site/routes/<NNN-route-id>/route-content.md` and build to first-level
5
5
  URLs.
6
6
 
7
- Routes use the same page frontmatter, optional section metadata, Norna image
8
- blocks, and Markdown section model as the homepage. See [Content](content.md)
9
- for the page and section model.
7
+ Routes use the same page frontmatter, optional section metadata, Norna managed
8
+ media blocks, and Markdown section model as the homepage. See
9
+ [Content](content.md) for the page and section model.
10
+
11
+ Route directories can contain route content, route-local images, and
12
+ route-local presentation overrides in `route-content.md`. They cannot contain
13
+ technical site configuration; technical configuration stays at the selected
14
+ site directory's top-level `config.mjs`.
10
15
 
11
16
  ## Route Directory Format
12
17
 
@@ -107,3 +112,9 @@ Image references in route content still use only the filename:
107
112
  ````
108
113
 
109
114
  The image directory segment after `images/` should match the section id.
115
+ If the section id or route folder changes, run `norna content:check` to find
116
+ misplaced images and `norna content:sync` to move unambiguous files into the
117
+ expected section folder. `content:sync` can move images between route image
118
+ roots when the filename is unambiguous across the site and the move will not
119
+ break another reference; these cross-route writes require a clean Git working
120
+ tree.
@@ -42,7 +42,8 @@ Version these files in a site repository:
42
42
  colors.
43
43
  See [Theme](theme.md).
44
44
  - `site/content.md`: homepage page file with editable content, Markdown
45
- sections, optional section metadata, image blocks, alt text, and captions.
45
+ sections, optional section metadata, managed media blocks, alt text, and
46
+ captions.
46
47
  See [Content](content.md).
47
48
  - `site/images/<section-id>/`: original source images.
48
49
  - `site/routes/<NNN-route-id>/route-content.md`: optional route page files.
@@ -82,8 +83,10 @@ In this repository:
82
83
  - `bin/norna-cli.mjs` dispatches public CLI commands.
83
84
  - `scripts/` contains validation, image, local preview, deploy, and test tools.
84
85
  - `src/` contains the Astro renderer, components, styles, and content schema.
85
- - `starters/basic/` is copied by `norna init <target-dir>` to create a
86
- site repository.
86
+ - `starters/basic/` is the default compact starter copied by
87
+ `norna init <target-dir>`.
88
+ - `starters/project/` is a project-site starter used as a maintained example
89
+ for small project and developer-tool sites.
87
90
  - `fixtures/basic/site/` is used by engine regression tests.
88
91
  - `examples/dog-gallery/site/` is the local visual dog example used by demo
89
92
  builds and navigation diagnostics.
package/docs/theme.md CHANGED
@@ -6,7 +6,8 @@ missing, built-in engine defaults are used.
6
6
 
7
7
  Page-level presentation in `site/content.md` and route page files is always an
8
8
  override on top of `site/theme.md`. Section-level presentation is an override
9
- on top of the resolved page presentation.
9
+ on top of the resolved page presentation. Use those overrides for focused
10
+ exceptions; the normal place for shared visual choices is `site/theme.md`.
10
11
 
11
12
  ## Minimal Theme
12
13
 
@@ -74,7 +75,7 @@ navigation:
74
75
  - `density`: default structural spacing profile. Allowed values are `compact`,
75
76
  `normal`, and `airy`.
76
77
  - `spacing`: optional structural spacing overrides for sections and Norna
77
- image blocks. Every spacing value accepts either one CSS length for all
78
+ managed media blocks. Every spacing value accepts either one CSS length for all
78
79
  viewports or `desktop` / `mobile` values.
79
80
 
80
81
  Example:
@@ -104,7 +105,7 @@ Spacing keys:
104
105
  - `firstSectionTop`: space above the first section heading.
105
106
  - `sectionGap`: space above each following section.
106
107
  - `finalSectionBottom`: space below the final section.
107
- - `bodyToImages`: space from section body text to Norna image blocks.
108
+ - `bodyToImages`: space from section body text to Norna managed media blocks.
108
109
  - `imageGap`: space between stacked images or carousel blocks.
109
110
 
110
111
  Text-near spacing, such as spacing after headings, spacing before Markdown
@@ -207,7 +208,9 @@ presentation:
207
208
  ```
208
209
 
209
210
  Section-specific presentation belongs under
210
- `sections.<section-id>.presentation`:
211
+ `sections.<section-id>.presentation`. Use it when one section genuinely needs
212
+ different presentation; do not use `sections` to define section order or normal
213
+ Markdown structure.
211
214
 
212
215
  ```yaml
213
216
  sections:
@@ -5,8 +5,8 @@ presets, text rhythm, and optional overrides.
5
5
 
6
6
  The normal place to choose a site-wide typography preset and rhythm is the
7
7
  top-level `typography` block in `site/theme.md`.
8
- Page and section files may override the theme when a page or section needs a
9
- different presentation.
8
+ Page and section files may override the theme when a focused exception is
9
+ needed, but the normal workflow is to keep typography in `theme.md`.
10
10
 
11
11
  ## Presets
12
12
 
@@ -18,9 +18,8 @@ Available presets:
18
18
  short information blocks.
19
19
  - `text-forward`: more generous body text for pages where longer text carries
20
20
  more of the experience.
21
- - `statement`: tighter, more declarative line-height for introductions, first
22
- sections, and short programmatic statements. Use it sparingly, usually as a
23
- section override.
21
+ - `statement`: tighter, more declarative line-height for short programmatic
22
+ statements. Use it sparingly, usually as a page or section exception.
24
23
 
25
24
  If theme typography is omitted, `quiet-gallery` is used.
26
25
 
@@ -4,44 +4,10 @@ description: A small norna example with free dog images.
4
4
  navigation:
5
5
  label: Dog site!
6
6
  sections:
7
- black-dogs:
8
- presentation:
9
- typography:
10
- preset: statement
11
- overrides:
12
- headings:
13
- h2:
14
- size: xlarge
15
7
  summer-dogs:
16
8
  visible:
17
9
  from: "2026-08-01"
18
10
  until: "2026-09-01"
19
- presentation:
20
- backgroundColor: "#181818"
21
- typography:
22
- preset: compact-gallery
23
- brown-dogs:
24
- presentation:
25
- typography:
26
- preset: text-forward
27
- golden-dogs:
28
- presentation:
29
- typography:
30
- overrides:
31
- headings:
32
- h2:
33
- size: large
34
- h3:
35
- size: medium
36
- body:
37
- lineHeight: 1.58
38
- paragraphSpacing: 1em
39
- white-dogs:
40
- presentation:
41
- typography:
42
- overrides:
43
- caption:
44
- spacingBefore: 0.35em
45
11
  ---
46
12
 
47
13
  ## Black Dogs {#black-dogs}
@@ -112,8 +78,7 @@ Cras justo odio, dapibus ac facilisis in, egestas eget quam.
112
78
 
113
79
  ### Training note
114
80
 
115
- This section overrides `headings.h2` and `headings.h3`, while keeping the
116
- site-wide `headings.h4` style.
81
+ This `###` heading follows the site-wide `headings.h3` style.
117
82
 
118
83
  Nulla vitae elit libero, a pharetra augue. Integer posuere erat a ante venenatis
119
84
  dapibus posuere velit aliquet. Aenean eu leo quam. Pellentesque ornare sem
@@ -3,14 +3,6 @@ title: Dog care
3
3
  description: A small route for testing multi-page norna navigation.
4
4
  navigation:
5
5
  label: Dog care
6
- sections:
7
- daily-rhythm:
8
- presentation:
9
- typography:
10
- overrides:
11
- headings:
12
- h2:
13
- size: large
14
6
  ---
15
7
 
16
8
  ## Daily rhythm {#daily-rhythm}
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@janga/norna",
3
3
  "description": "A command-line publishing workflow for simple static sites.",
4
4
  "type": "module",
5
- "version": "0.7.7",
5
+ "version": "0.7.8",
6
6
  "license": "GPL-3.0-only",
7
7
  "repository": {
8
8
  "type": "git",
@@ -1,7 +1,9 @@
1
+ import { execFile } from 'node:child_process';
1
2
  import { mkdir, rename, stat } from 'node:fs/promises';
2
3
  import { createInterface } from 'node:readline/promises';
3
4
  import { stdin as input, stdout as output } from 'node:process';
4
5
  import path from 'node:path';
6
+ import { promisify } from 'node:util';
5
7
  import {
6
8
  extractMarkdownImageReferences,
7
9
  extractNornaMarkdownBlockDiagnostics,
@@ -25,8 +27,11 @@ import { readImageDimensions } from './lib/image-dimensions.mjs';
25
27
  import {
26
28
  siteThemeLabel,
27
29
  siteThemePath,
30
+ siteProjectRoot,
28
31
  } from './lib/site-paths.mjs';
29
32
 
33
+ const execFileAsync = promisify(execFile);
34
+
30
35
  const args = new Set(process.argv.slice(2));
31
36
  const shouldWrite = args.has('--write');
32
37
  const shouldCheck = args.has('--check') || !shouldWrite;
@@ -60,7 +65,7 @@ const promptForWrite = async () => {
60
65
  if (!process.stdin.isTTY) return false;
61
66
 
62
67
  const rl = createInterface({ input, output });
63
- const answer = await rl.question('This will move image files inside their current page or route image root if needed. Continue? [y/N] ');
68
+ const answer = await rl.question('This will move image files into the page or route image roots where Norna references them. Continue? [y/N] ');
64
69
  rl.close();
65
70
 
66
71
  return answer.trim().toLowerCase() === 'y';
@@ -172,16 +177,110 @@ const getExpectedImagePath = (contentFile, sectionId, imageName) =>
172
177
  const getExpectedImageLabel = (contentFile, sectionId, imageName) =>
173
178
  `${contentFile.imagesLabel}/${sectionId}/${imageName}`;
174
179
 
175
- const getImageCandidates = (imageCandidatesByName, imageName, expectedPath) =>
176
- (imageCandidatesByName.get(imageName) ?? []).filter((candidate) => candidate !== expectedPath);
180
+ const getGlobalImageCandidates = (globalImageCandidatesByName, imageName, expectedPath) =>
181
+ (globalImageCandidatesByName.get(imageName) ?? []).filter(({ imagePath }) => imagePath !== expectedPath);
177
182
 
178
183
  const getReferenceLabel = (contentFile, section) => `${contentFile.contentLabel} [${section.id}]`;
179
184
 
185
+ const getRootLabel = (contentFile) => contentFile.isHome
186
+ ? contentFile.imagesLabel
187
+ : contentFile.imagesLabel.replace(/\/images$/, '');
188
+
189
+ const getCandidateLabel = ({ contentFile, imagePath }) =>
190
+ `${contentFile.imagesLabel}/${toPosixPath(path.relative(contentFile.imagesDir, imagePath))}`;
191
+
192
+ const addExpectedReference = (expectedReferencesByPath, expectedPath, expectedReference) => {
193
+ if (!expectedReferencesByPath.has(expectedPath)) {
194
+ expectedReferencesByPath.set(expectedPath, []);
195
+ }
196
+
197
+ expectedReferencesByPath.get(expectedPath).push(expectedReference);
198
+ };
199
+
200
+ const addGlobalImageCandidate = (globalImageCandidatesByName, imageName, candidate) => {
201
+ if (!globalImageCandidatesByName.has(imageName)) {
202
+ globalImageCandidatesByName.set(imageName, []);
203
+ }
204
+
205
+ globalImageCandidatesByName.get(imageName).push(candidate);
206
+ };
207
+
208
+ const assertCleanGitForCrossRootSync = async (moves) => {
209
+ if (!moves.some((move) => move.crossRoot)) return;
210
+
211
+ let stdout = '';
212
+ try {
213
+ ({ stdout } = await execFileAsync('git', ['status', '--short'], {
214
+ cwd: siteProjectRoot,
215
+ maxBuffer: 1024 * 1024,
216
+ }));
217
+ } catch (error) {
218
+ addIssue({
219
+ severity: 'error',
220
+ message: 'Cross-route content sync requires a clean git working tree, but git status could not be checked.',
221
+ fix: 'Run content:sync inside a Git worktree, or move cross-route image files manually.',
222
+ });
223
+ return;
224
+ }
225
+
226
+ if (!stdout.trim()) return;
227
+
228
+ addIssue({
229
+ severity: 'error',
230
+ message: 'Cross-route content sync requires a clean git working tree before moving files between page or route image roots.',
231
+ fix: `Commit or stash your current changes, then run content:sync again. Current changes:\n${stdout.trim()}`,
232
+ });
233
+ };
234
+
235
+ const addConflictingMoveIssues = () => {
236
+ const movesBySource = new Map();
237
+ const movesByDestination = new Map();
238
+
239
+ for (const move of imageMoves) {
240
+ const sourceKey = move.from;
241
+ const destinationKey = move.to;
242
+
243
+ if (!movesBySource.has(sourceKey)) {
244
+ movesBySource.set(sourceKey, []);
245
+ }
246
+ movesBySource.get(sourceKey).push(move);
247
+
248
+ if (!movesByDestination.has(destinationKey)) {
249
+ movesByDestination.set(destinationKey, []);
250
+ }
251
+ movesByDestination.get(destinationKey).push(move);
252
+ }
253
+
254
+ for (const moves of movesBySource.values()) {
255
+ const destinations = new Set(moves.map((move) => move.to));
256
+ if (destinations.size <= 1) continue;
257
+
258
+ const firstMove = moves[0];
259
+ addSectionIssue(firstMove.contentFile, { id: firstMove.sectionId, heading: firstMove.sectionId }, {
260
+ severity: 'error',
261
+ message: `Cannot relocate "${firstMove.imageName}" because the same source file is referenced from multiple destinations: ${moves.map((move) => getExpectedImageLabel(move.contentFile, move.sectionId, move.imageName)).join(', ')}.`,
262
+ fix: 'Duplicate the image manually or rename one of the image files so each move has a single destination.',
263
+ });
264
+ }
265
+
266
+ for (const moves of movesByDestination.values()) {
267
+ const sources = new Set(moves.map((move) => move.from));
268
+ if (sources.size <= 1) continue;
269
+
270
+ const firstMove = moves[0];
271
+ addSectionIssue(firstMove.contentFile, { id: firstMove.sectionId, heading: firstMove.sectionId }, {
272
+ severity: 'error',
273
+ message: `Cannot relocate "${firstMove.imageName}" because multiple source files would move to ${getExpectedImageLabel(firstMove.contentFile, firstMove.sectionId, firstMove.imageName)}: ${moves.map((move) => getCandidateLabel(move.sourceContentFile ? { contentFile: move.sourceContentFile, imagePath: move.from } : move)).join(', ')}.`,
274
+ fix: 'Move the intended file manually or rename files so the destination is unambiguous.',
275
+ });
276
+ }
277
+ };
278
+
180
279
  const validateImageReference = async (
181
280
  contentFile,
182
281
  section,
183
282
  reference,
184
- imageCandidatesByName,
283
+ globalImageCandidatesByName,
185
284
  expectedReferencesByPath,
186
285
  ) => {
187
286
  const imageName = reference.image;
@@ -194,11 +293,11 @@ const validateImageReference = async (
194
293
  return expectedPath;
195
294
  }
196
295
 
197
- const candidates = getImageCandidates(imageCandidatesByName, imageName, expectedPath);
296
+ const candidates = getGlobalImageCandidates(globalImageCandidatesByName, imageName, expectedPath);
198
297
  if (candidates.length === 0) {
199
298
  addSectionIssue(contentFile, section, {
200
299
  severity: 'error',
201
- message: `Image "${imageName}" does not exist at ${expectedLabel} or anywhere under ${contentFile.imagesLabel}/.`,
300
+ message: `Image "${imageName}" does not exist at ${expectedLabel} or anywhere under any page or route image root.`,
202
301
  fix: `Add the source image to ${contentFile.imagesLabel}/${section.id}/ or remove the Norna-managed image reference.`,
203
302
  });
204
303
  return null;
@@ -207,20 +306,21 @@ const validateImageReference = async (
207
306
  if (candidates.length > 1) {
208
307
  addSectionIssue(contentFile, section, {
209
308
  severity: 'error',
210
- message: `Cannot relocate "${imageName}". Multiple files with this filename were found: ${candidates.map((candidate) => `${contentFile.imagesLabel}/${toPosixPath(path.relative(contentFile.imagesDir, candidate))}`).join(', ')}.`,
211
- fix: 'Move the intended file manually or rename files so the local move is unambiguous.',
309
+ message: `Cannot relocate "${imageName}". Multiple files with this filename were found: ${candidates.map(getCandidateLabel).join(', ')}.`,
310
+ fix: 'Move the intended file manually or rename files so the move is unambiguous.',
212
311
  });
213
312
  return null;
214
313
  }
215
314
 
216
- const sourcePath = candidates[0];
315
+ const sourceCandidate = candidates[0];
316
+ const sourcePath = sourceCandidate.imagePath;
217
317
  const referencesAtCurrentLocation = (expectedReferencesByPath.get(sourcePath) ?? [])
218
- .filter((expectedReference) => expectedReference.section.id !== section.id);
318
+ .filter((expectedReference) => expectedReference.contentFile !== contentFile || expectedReference.section.id !== section.id);
219
319
 
220
320
  if (referencesAtCurrentLocation.length > 0) {
221
321
  addSectionIssue(contentFile, section, {
222
322
  severity: 'error',
223
- message: `Cannot relocate "${imageName}" from ${contentFile.imagesLabel}/${toPosixPath(path.relative(contentFile.imagesDir, sourcePath))} because it is still referenced from ${referencesAtCurrentLocation.map((expectedReference) => getReferenceLabel(contentFile, expectedReference.section)).join(', ')}.`,
323
+ message: `Cannot relocate "${imageName}" from ${getCandidateLabel(sourceCandidate)} because it is still referenced from ${referencesAtCurrentLocation.map((expectedReference) => getReferenceLabel(expectedReference.contentFile, expectedReference.section)).join(', ')}.`,
224
324
  fix: 'Remove the extra reference, duplicate the image file manually, or rename one of the image files so the intended move is unambiguous.',
225
325
  });
226
326
  return null;
@@ -231,15 +331,19 @@ const validateImageReference = async (
231
331
  from: sourcePath,
232
332
  to: expectedPath,
233
333
  contentFile,
334
+ sourceContentFile: sourceCandidate.contentFile,
234
335
  sectionId: section.id,
336
+ crossRoot: sourceCandidate.contentFile !== contentFile,
235
337
  });
236
338
  referencedImagePaths.add(sourcePath);
237
339
 
238
340
  if (!shouldWrite) {
239
341
  addSectionIssue(contentFile, section, {
240
342
  severity: 'error',
241
- message: `Image "${imageName}" is used here but is located in ${contentFile.imagesLabel}/${toPosixPath(path.relative(contentFile.imagesDir, sourcePath))}.`,
242
- fix: 'Run norna content:sync to move it inside the current page or route image root.',
343
+ message: `Image "${imageName}" is used here but is located in ${getCandidateLabel(sourceCandidate)}.`,
344
+ fix: sourceCandidate.contentFile === contentFile
345
+ ? 'Run norna content:sync to move it inside the current page or route image root.'
346
+ : `Run norna content:sync to move it from ${getRootLabel(sourceCandidate.contentFile)} to ${getRootLabel(contentFile)}. Cross-route sync requires a clean git working tree when files are moved.`,
243
347
  });
244
348
  }
245
349
 
@@ -302,6 +406,9 @@ const themeInlineStyleNames = themeFrontmatter
302
406
 
303
407
  const contentFiles = await getContentFiles();
304
408
  const allImageFiles = [];
409
+ const contentFileContexts = [];
410
+ const globalImageCandidatesByName = new Map();
411
+ const globalExpectedReferencesByPath = new Map();
305
412
 
306
413
  for (const contentFile of contentFiles) {
307
414
  const { frontmatter, body } = await readSiteFile(contentFile.contentPath, contentFile.contentLabel);
@@ -314,11 +421,14 @@ for (const contentFile of contentFiles) {
314
421
  const { sections } = getBodySections(body);
315
422
  const sectionsById = new Map();
316
423
  const blockResultsBySectionId = new Map();
317
- const expectedReferencesByPath = new Map();
318
424
  const imageCandidatesByName = await getImageCandidatesByName(contentFile.imagesDir);
319
425
 
320
- for (const candidates of imageCandidatesByName.values()) {
321
- allImageFiles.push(...candidates.map((imagePath) => ({ contentFile, imagePath })));
426
+ for (const [imageName, candidates] of imageCandidatesByName) {
427
+ for (const imagePath of candidates) {
428
+ const candidate = { contentFile, imagePath };
429
+ allImageFiles.push(candidate);
430
+ addGlobalImageCandidate(globalImageCandidatesByName, imageName, candidate);
431
+ }
322
432
  }
323
433
 
324
434
  for (const section of sections) {
@@ -354,13 +464,30 @@ for (const contentFile of contentFiles) {
354
464
 
355
465
  for (const reference of getNornaBlockImageReferences(blockResults.blocks)) {
356
466
  const expectedPath = getExpectedImagePath(contentFile, section.id, reference.image);
357
- if (!expectedReferencesByPath.has(expectedPath)) {
358
- expectedReferencesByPath.set(expectedPath, []);
359
- }
360
- expectedReferencesByPath.get(expectedPath).push({ section, reference });
467
+ addExpectedReference(globalExpectedReferencesByPath, expectedPath, { contentFile, section, reference });
361
468
  }
362
469
  }
363
470
 
471
+ contentFileContexts.push({
472
+ contentFile,
473
+ frontmatterIds,
474
+ sections,
475
+ sectionsById,
476
+ blockResultsBySectionId,
477
+ body,
478
+ });
479
+ }
480
+
481
+ for (const context of contentFileContexts) {
482
+ const {
483
+ contentFile,
484
+ frontmatterIds,
485
+ sections,
486
+ sectionsById,
487
+ blockResultsBySectionId,
488
+ body,
489
+ } = context;
490
+
364
491
  for (const id of frontmatterIds) {
365
492
  if (!sectionsById.has(id)) {
366
493
  addContentIssue(contentFile, {
@@ -417,8 +544,8 @@ for (const contentFile of contentFiles) {
417
544
  contentFile,
418
545
  section,
419
546
  reference,
420
- imageCandidatesByName,
421
- expectedReferencesByPath,
547
+ globalImageCandidatesByName,
548
+ globalExpectedReferencesByPath,
422
549
  );
423
550
  if (sourcePath) {
424
551
  sourcePathsByImage.set(reference.image, sourcePath);
@@ -436,6 +563,12 @@ const unreferencedImages = allImageFiles
436
563
  .map(({ contentFile, imagePath }) => `${contentFile.imagesLabel}/${toPosixPath(path.relative(contentFile.imagesDir, imagePath))}`)
437
564
  .sort((left, right) => left.localeCompare(right, 'sv'));
438
565
 
566
+ addConflictingMoveIssues();
567
+
568
+ if (shouldWrite) {
569
+ await assertCleanGitForCrossRootSync(imageMoves);
570
+ }
571
+
439
572
  if (hasErrors()) {
440
573
  printReport(shouldWrite ? 'Content sync failed.' : 'Content check failed.', unreferencedImages);
441
574
  process.exit(1);
@@ -129,7 +129,7 @@ test('content:check groups section issues, global issues, and unreferenced image
129
129
  assert.match(output, /^Content check failed\./m);
130
130
  assert.match(output, /Content Issues\n\n\[site\/content\.md \[min-konst\]\]\n Errors:/);
131
131
  assert.match(output, /Image "vav\.jpeg" is used here but is located in site\/images\/mitt-hem\/vav\.jpeg\./);
132
- assert.match(output, /Image "missing\.jpeg" does not exist at site\/images\/min-konst\/missing\.jpeg or anywhere under site\/images\/\./);
132
+ assert.match(output, /Image "missing\.jpeg" does not exist at site\/images\/min-konst\/missing\.jpeg or anywhere under any page or route image root\./);
133
133
  assert.match(output, /Unreferenced Images\nThese files are kept under page or route image roots but are not referenced by Norna-managed image references:/);
134
134
  assert.match(output, /site\/images\/karin-walde\/unreferenced\.jpg/);
135
135
  });
@@ -73,6 +73,26 @@ typography:
73
73
 
74
74
  const fileExists = async (filePath) => access(filePath).then(() => true, () => false);
75
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
+
76
96
  const createTempFixtureCopy = async () => {
77
97
  const tempParent = path.join(repoRoot, 'node_modules', '.cache');
78
98
  await mkdir(tempParent, { recursive: true });
@@ -310,7 +330,7 @@ description: Fixture
310
330
  await assert.rejects(
311
331
  () => runContentScript(siteDir, ['--check']),
312
332
  (error) => {
313
- assert.match(error.output, /Image "missing\.svg" does not exist at .*site\/images\/help\/missing\.svg or anywhere under .*site\/images\//);
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\./);
314
334
  return true;
315
335
  },
316
336
  );
@@ -493,6 +513,218 @@ description: Fixture
493
513
  }
494
514
  });
495
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
+
496
728
  test('CLI content:sync refreshes generated images after moving a carousel image', async () => {
497
729
  const { root, siteDir } = await createTempSite();
498
730
  try {
@@ -773,8 +1005,8 @@ image broken.jpg
773
1005
  () => runContentScript(siteDir, ['--check']),
774
1006
  (error) => {
775
1007
  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\//);
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\./);
778
1010
  return true;
779
1011
  },
780
1012
  );
@@ -826,7 +1058,7 @@ description: Fixture
826
1058
  () => runContentScript(siteDir, ['--check']),
827
1059
  (error) => {
828
1060
  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\//);
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\./);
830
1062
  return true;
831
1063
  },
832
1064
  );
@@ -854,7 +1086,7 @@ description: Fixture
854
1086
  await assert.rejects(
855
1087
  () => runContentScript(siteDir, ['--check']),
856
1088
  (error) => {
857
- assert.match(error.output, /Image "missing\.jpg" does not exist at .*site\/images\/intro\/missing\.jpg or anywhere under .*site\/images\//);
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\./);
858
1090
  return true;
859
1091
  },
860
1092
  );
@@ -919,7 +1151,7 @@ Text.
919
1151
  }
920
1152
  });
921
1153
 
922
- test('content:sync refuses ambiguous page-local image relocation', async () => {
1154
+ test('content:sync refuses ambiguous image relocation', async () => {
923
1155
  const { root, siteDir } = await createTempSite();
924
1156
  try {
925
1157
  await writeFile(path.join(siteDir, 'content.md'), `---
@@ -17,8 +17,10 @@ npm run dev -- --kill
17
17
 
18
18
  Edit site-wide visual defaults in `site/theme.md`, homepage content and section
19
19
  overrides in `site/content.md`, technical settings such as URL and locale
20
- labels in `site/config.mjs`, source images under
21
- `site/images/<section-id>/`, and static public files under `site/public/`.
20
+ labels in `site/config.mjs`, source images under `site/images/<section-id>/`,
21
+ and static public files under `site/public/`. The image folder name should
22
+ match the section id in Markdown, for example `## Work {#work}` uses
23
+ `site/images/work/`.
22
24
 
23
25
  Use `site.basePath: '/'` for a custom domain or root-hosted site. For a GitHub
24
26
  Pages project site without a custom domain, set both values:
@@ -1,12 +1,6 @@
1
1
  ---
2
2
  title: Example Site
3
3
  description: Minimal norna starter site.
4
- sections:
5
- intro:
6
- presentation:
7
- typography:
8
- preset: statement
9
-
10
4
  ---
11
5
 
12
6
  ## Intro {#intro}
@@ -15,8 +9,8 @@ routes defined, the navigation menu will be a sticky bar with anchor links for
15
9
  ease of use on phones etc. [Highlighted inline text]{.highlight} when a short
16
10
  phrase needs emphasis
17
11
  ## Work {#work}
18
- Add Norna image blocks in Markdown and matching source images under
19
- `images/work/`.
12
+ Add Norna managed media blocks in Markdown where the media should appear. Put
13
+ matching source images under `images/work/`.
20
14
 
21
15
  ````
22
16
  ```norna-image-stack
@@ -23,7 +23,8 @@ npm run dev -- --kill
23
23
  - `site/theme.md`: site-wide visual settings such as brand text, layout
24
24
  density, typography rhythm, colors, frame colors, and inline text styles.
25
25
  - `site/content.md`: homepage title, sections, placeholders, project summary,
26
- links, install command, example usage, benefits, next steps, and license.
26
+ links, install command, example usage, benefits, next steps, managed media
27
+ blocks, and license.
27
28
  - `site/routes/010-guide/route-content.md`: short secondary page with realistic
28
29
  project guide content.
29
30
  - `site/public/robots.txt`: static public file copied into the built site.
@@ -40,15 +41,18 @@ GitHub Pages workflow uses `npm ci`.
40
41
  delete the route if the homepage is enough.
41
42
  3. Edit `site/theme.md` for brand name, layout density, typography rhythm,
42
43
  colors, frame colors, and inline text styles.
43
- 4. Edit `site/config.mjs` for public URL, GitHub repository, footer text, and
44
+ 4. Put any local source images in the image folder for the Markdown section
45
+ that references them, for example `site/images/intro/`.
46
+ 5. Edit `site/config.mjs` for public URL, GitHub repository, footer text, and
44
47
  deploy workflow name.
45
- 5. Update `package.json` with the site's package name and keep
48
+ 6. Update `package.json` with the site's package name and keep
46
49
  `package-lock.json` committed.
47
50
 
48
51
  ## Common Commands
49
52
 
50
53
  ```sh
51
54
  npm run norna:check
55
+ npm run norna:sync
52
56
  npm run norna:typography:show
53
57
  npm run build
54
58
  ```