@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 +6 -5
- package/docs/README.md +1 -1
- package/docs/commands.md +4 -3
- package/docs/configuration.md +1 -1
- package/docs/content.md +64 -14
- package/docs/getting-started.md +48 -7
- package/docs/images-and-metadata.md +11 -6
- package/docs/routes.md +14 -3
- package/docs/site-structure.md +6 -3
- package/docs/theme.md +7 -4
- package/docs/typography.md +4 -5
- package/examples/dog-gallery/site/content.md +1 -36
- package/examples/dog-gallery/site/routes/010-dog-care/route-content.md +0 -8
- package/package.json +1 -1
- package/scripts/sync-content-sections.mjs +155 -22
- package/scripts/test-content-check.mjs +1 -1
- package/scripts/test-content-model-v2.mjs +238 -6
- package/starters/basic/README.md +4 -2
- package/starters/basic/site/content.md +2 -8
- package/starters/project/README.md +7 -3
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,
|
|
29
|
-
text, captions, and optional section metadata in
|
|
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-
|
|
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-
|
|
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
|
|
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,
|
|
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,
|
|
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
|
package/docs/configuration.md
CHANGED
|
@@ -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
|
|
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
|
-
##
|
|
94
|
+
## Managed Media Blocks
|
|
95
95
|
|
|
96
|
-
Norna-managed local images are written in Markdown fenced blocks at
|
|
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
|
|
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
|
|
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
|
-
|
|
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 `` are not
|
|
202
|
-
managed by Norna. Use `norna-image-stack
|
|
203
|
-
site images that should be validated, processed and
|
|
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
|
|
257
|
-
image references to unmanaged local files, and common frontmatter
|
|
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.
|
|
286
|
-
|
|
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.
|
package/docs/getting-started.md
CHANGED
|
@@ -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
|
|
19
|
+
## Create A Site
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
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-
|
|
10
|
-
[Content](content.md#
|
|
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
|
-
|
|
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
|
|
57
|
-
candidate
|
|
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
|
|
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
|
|
8
|
-
blocks, and Markdown section model as the homepage. See
|
|
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.
|
package/docs/site-structure.md
CHANGED
|
@@ -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,
|
|
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
|
|
86
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
package/docs/typography.md
CHANGED
|
@@ -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
|
|
9
|
-
|
|
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
|
|
22
|
-
|
|
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
|
|
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
|
@@ -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
|
|
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
|
|
176
|
-
(
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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(
|
|
211
|
-
fix: 'Move the intended file manually or rename files so the
|
|
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
|
|
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 ${
|
|
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 ${
|
|
242
|
-
fix:
|
|
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
|
|
321
|
-
|
|
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
|
-
|
|
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
|
-
|
|
421
|
-
|
|
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
|
|
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
|
|
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
|
|
777
|
-
assert.match(error.output, /Image "missing-more\.jpg" does not exist at .*site\/images\/more\/missing-more\.jpg or anywhere under
|
|
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
|
|
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
|
|
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
|
|
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'), `---
|
package/starters/basic/README.md
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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,
|
|
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.
|
|
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
|
-
|
|
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
|
```
|