@janga/norna 0.7.13 → 0.7.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/README.md +15 -9
  2. package/bin/norna-cli.mjs +11 -4
  3. package/docs/README.md +71 -43
  4. package/docs/commands.md +34 -20
  5. package/docs/configuration.md +43 -62
  6. package/docs/content.md +34 -7
  7. package/docs/design/command-organization.md +15 -8
  8. package/docs/design/norna-diagram-design.md +6 -6
  9. package/docs/design/site-examples-structure.md +1 -1
  10. package/docs/engine-development.md +22 -9
  11. package/docs/getting-started.md +29 -16
  12. package/docs/how-to/embedded-site.md +1 -1
  13. package/docs/images-and-metadata.md +3 -3
  14. package/docs/local-development.md +11 -3
  15. package/docs/public-files.md +105 -0
  16. package/docs/publishing.md +4 -8
  17. package/docs/routes.md +6 -7
  18. package/docs/site-files.md +144 -0
  19. package/docs/sitewide-content.md +47 -33
  20. package/docs/theme.md +37 -49
  21. package/docs/typography.md +25 -31
  22. package/examples/complete-sites/dog-shelter-multi-page/README.md +8 -0
  23. package/examples/complete-sites/{dog-shelter-single-page/site/config.md → dog-shelter-multi-page/site/config.yaml} +0 -2
  24. package/examples/complete-sites/dog-shelter-multi-page/site/sitewide-content.yaml +6 -0
  25. package/examples/complete-sites/dog-shelter-multi-page/site/{theme.md → theme.yaml} +0 -2
  26. package/examples/complete-sites/dog-shelter-single-page/README.md +8 -0
  27. package/examples/complete-sites/{dog-shelter-multi-page/site/config.md → dog-shelter-single-page/site/config.yaml} +0 -2
  28. package/examples/complete-sites/dog-shelter-single-page/site/sitewide-content.yaml +6 -0
  29. package/examples/complete-sites/dog-shelter-single-page/site/{theme.md → theme.yaml} +0 -2
  30. package/examples/feature-demos/media-and-surfaces/.astro/data-store.json +1 -1
  31. package/examples/feature-demos/media-and-surfaces/README.md +1 -1
  32. package/examples/feature-demos/media-and-surfaces/site/{config.md → config.yaml} +0 -2
  33. package/examples/feature-demos/media-and-surfaces/site/content.md +2 -2
  34. package/examples/feature-demos/media-and-surfaces/site/routes/020-surfaces/{route-content.md → content.md} +3 -7
  35. package/examples/feature-demos/media-and-surfaces/site/routes/020-surfaces/theme.yaml +3 -0
  36. package/examples/feature-demos/media-and-surfaces/site/{sitewide-content.md → sitewide-content.yaml} +1 -3
  37. package/examples/feature-demos/media-and-surfaces/site/{theme.md → theme.yaml} +0 -2
  38. package/examples/feature-demos/sitewide-content/README.md +8 -0
  39. package/examples/feature-demos/sitewide-content/site/{config.md → config.yaml} +0 -2
  40. package/examples/feature-demos/sitewide-content/site/routes/010-identity/{route-content.md → content.md} +3 -3
  41. package/examples/feature-demos/sitewide-content/site/routes/020-notices/{route-content.md → content.md} +1 -1
  42. package/examples/feature-demos/sitewide-content/site/routes/030-footer/{route-content.md → content.md} +1 -1
  43. package/examples/feature-demos/sitewide-content/site/{sitewide-content.md → sitewide-content.yaml} +1 -11
  44. package/examples/feature-demos/sitewide-content/site/{theme.md → theme.yaml} +0 -2
  45. package/examples/feature-demos/theme-presets/site/config.yaml +1 -0
  46. package/examples/feature-demos/theme-presets/site/routes/010-portfolio/{theme.md → theme.yaml} +0 -2
  47. package/examples/feature-demos/theme-presets/site/routes/020-documentation/{theme.md → theme.yaml} +0 -2
  48. package/examples/feature-demos/theme-presets/site/routes/030-project/theme.yaml +1 -0
  49. package/examples/feature-demos/theme-presets/site/routes/040-statement/{route-content.md → content.md} +1 -1
  50. package/examples/feature-demos/theme-presets/site/routes/040-statement/{theme.md → theme.yaml} +0 -2
  51. package/examples/feature-demos/theme-presets/site/{sitewide-content.md → sitewide-content.yaml} +1 -3
  52. package/examples/feature-demos/theme-presets/site/theme.yaml +1 -0
  53. package/fixtures/basic/site/{config.md → config.yaml} +0 -2
  54. package/fixtures/basic/site/content.md +3 -3
  55. package/fixtures/basic/site/{sitewide-content.md → sitewide-content.yaml} +0 -2
  56. package/fixtures/basic/site/{theme.md → theme.yaml} +3 -6
  57. package/fixtures/content-model-v2/site/{config.md → config.yaml} +0 -2
  58. package/fixtures/content-model-v2/site/theme.yaml +4 -0
  59. package/package.json +10 -3
  60. package/schemas/config.schema.json +77 -0
  61. package/schemas/content-frontmatter.schema.json +81 -0
  62. package/schemas/manifest.json +9 -0
  63. package/schemas/sitewide-content.schema.json +215 -0
  64. package/schemas/theme.schema.json +1208 -0
  65. package/scripts/check-config.mjs +18 -11
  66. package/scripts/export-theme-preset.mjs +1 -1
  67. package/scripts/generate-schemas.mjs +283 -0
  68. package/scripts/init-site.mjs +22 -27
  69. package/scripts/lib/documentation-links.mjs +8 -0
  70. package/scripts/lib/editor-language-service.mjs +609 -0
  71. package/scripts/lib/example-sites.mjs +2 -2
  72. package/scripts/lib/git-status.mjs +7 -0
  73. package/scripts/lib/logo-assets.mjs +15 -10
  74. package/scripts/lib/norna-markdown-blocks.mjs +90 -11
  75. package/scripts/lib/presentation.mjs +23 -12
  76. package/scripts/lib/project-config.mjs +17 -41
  77. package/scripts/lib/public-asset-conventions.mjs +112 -0
  78. package/scripts/lib/schema-definitions.mjs +213 -0
  79. package/scripts/lib/schema-value-definitions.mjs +86 -0
  80. package/scripts/lib/site-content.mjs +24 -18
  81. package/scripts/lib/site-paths.mjs +4 -8
  82. package/scripts/lib/sitewide-content.mjs +24 -0
  83. package/scripts/lib/theme-config.mjs +15 -35
  84. package/scripts/lib/theme-presets.mjs +76 -66
  85. package/scripts/lib/typography.mjs +15 -15
  86. package/scripts/lib/yaml-config.mjs +50 -0
  87. package/scripts/list-theme-presets.mjs +15 -0
  88. package/scripts/release.mjs +84 -8
  89. package/scripts/show-typography.mjs +22 -46
  90. package/scripts/sync-content-sections.mjs +2 -2
  91. package/scripts/test-client-javascript.mjs +28 -28
  92. package/scripts/test-content-check.mjs +21 -26
  93. package/scripts/test-content-model-v2.mjs +72 -40
  94. package/scripts/test-documentation.mjs +70 -4
  95. package/scripts/test-editor-language-service.mjs +158 -0
  96. package/scripts/test-engine-commands.mjs +65 -34
  97. package/scripts/test-package-check.mjs +37 -30
  98. package/scripts/test-project-config.mjs +15 -20
  99. package/scripts/test-schemas.mjs +102 -0
  100. package/scripts/test-temporary-visibility.mjs +5 -13
  101. package/scripts/test-theme-presets.mjs +64 -37
  102. package/src/components/CardList.astro +2 -5
  103. package/src/components/ImageCarousel.astro +21 -21
  104. package/src/components/{GalleryGrid.astro → ImageStack.astro} +12 -12
  105. package/src/components/SectionNavigationScript.astro +28 -0
  106. package/src/components/SiteNavigation.astro +6 -5
  107. package/src/components/SitePage.astro +8 -8
  108. package/src/components/SiteSection.astro +18 -21
  109. package/src/content.config.ts +17 -209
  110. package/src/layouts/BaseLayout.astro +9 -9
  111. package/src/lib/{galleryImages.ts → imagePresentation.ts} +7 -7
  112. package/src/lib/routeThemes.ts +9 -29
  113. package/src/lib/sectionContent.ts +3 -3
  114. package/src/lib/sitePublicAssets.ts +5 -21
  115. package/src/styles/global.css +73 -73
  116. package/starters/basic/.github/workflows/deploy.yml +1 -1
  117. package/starters/basic/README.md +16 -13
  118. package/starters/basic/package.json +3 -2
  119. package/starters/basic/site/{config.md → config.yaml} +0 -2
  120. package/starters/basic/site/content.md +1 -1
  121. package/starters/basic/site/{sitewide-content.md → sitewide-content.yaml} +1 -3
  122. package/starters/basic/site/{theme.md → theme.yaml} +8 -15
  123. package/starters/project/.github/workflows/deploy.yml +1 -1
  124. package/starters/project/README.md +22 -15
  125. package/starters/project/package.json +3 -2
  126. package/starters/project/site/{config.md → config.yaml} +0 -2
  127. package/starters/project/site/{sitewide-content.md → sitewide-content.yaml} +1 -3
  128. package/starters/project/site/{theme.md → theme.yaml} +4 -8
  129. package/docs/site-structure.md +0 -98
  130. package/examples/complete-sites/dog-shelter-multi-page/site/sitewide-content.md +0 -16
  131. package/examples/complete-sites/dog-shelter-single-page/site/sitewide-content.md +0 -16
  132. package/examples/feature-demos/media-and-surfaces/site/routes/020-surfaces/theme.md +0 -8
  133. package/examples/feature-demos/theme-presets/site/config.md +0 -3
  134. package/examples/feature-demos/theme-presets/site/routes/030-project/theme.md +0 -3
  135. package/examples/feature-demos/theme-presets/site/theme.md +0 -3
  136. package/fixtures/content-model-v2/site/theme.md +0 -7
  137. package/scripts/lib/sitewide-config.mjs +0 -34
  138. package/src/components/MobileSectionNavigationScript.astro +0 -14
  139. /package/examples/complete-sites/dog-shelter-multi-page/site/routes/010-dogs/{route-content.md → content.md} +0 -0
  140. /package/examples/complete-sites/dog-shelter-multi-page/site/routes/020-adopt/{route-content.md → content.md} +0 -0
  141. /package/examples/feature-demos/media-and-surfaces/site/routes/010-media/{route-content.md → content.md} +0 -0
  142. /package/examples/feature-demos/theme-presets/site/routes/010-portfolio/{route-content.md → content.md} +0 -0
  143. /package/examples/feature-demos/theme-presets/site/routes/020-documentation/{route-content.md → content.md} +0 -0
  144. /package/examples/feature-demos/theme-presets/site/routes/030-project/{route-content.md → content.md} +0 -0
  145. /package/fixtures/content-model-v2/site/routes/010-guide/{route-content.md → content.md} +0 -0
  146. /package/starters/project/site/routes/010-guide/{route-content.md → content.md} +0 -0
@@ -42,12 +42,9 @@ const makePngHeader = ({ width, height }) => {
42
42
  return buffer;
43
43
  };
44
44
 
45
- const defaultTheme = `---
46
- typography:
47
- preset: quiet-gallery
48
- presentation:
49
- palette: dark
50
- ---
45
+ const defaultTheme = `typography:
46
+ profile: restrained
47
+ palette: dark
51
48
  `;
52
49
 
53
50
  const withTempProject = async ({ site, theme = defaultTheme, files, siteDirectory = 'site' }, run) => {
@@ -55,7 +52,7 @@ const withTempProject = async ({ site, theme = defaultTheme, files, siteDirector
55
52
 
56
53
  try {
57
54
  await writeFixtureFile(root, `${siteDirectory}/content.md`, site);
58
- await writeFixtureFile(root, `${siteDirectory}/theme.md`, theme);
55
+ await writeFixtureFile(root, `${siteDirectory}/theme.yaml`, theme);
59
56
 
60
57
  for (const file of files) {
61
58
  if (typeof file === 'string') {
@@ -370,12 +367,11 @@ This paragraph has {note-ref} two references {note-ref}.
370
367
  });
371
368
 
372
369
  const badWhitespaceSite = `---
373
- presentation:
374
- typography:
375
- preset: quiet-gallery
376
- \u00a0\u00a0\u00a0\u00a0overrides:
377
- body:
378
- paragraphSpacing: 0.3em
370
+ typography:
371
+ profile: restrained
372
+ \u00a0\u00a0overrides:
373
+ body:
374
+ paragraphSpacing: 0.3em
379
375
  sections:
380
376
  intro: {}
381
377
 
@@ -394,18 +390,17 @@ test('content:check explains invalid frontmatter indentation whitespace', async
394
390
 
395
391
  assert.equal(result.status, 1, output);
396
392
  assert.match(output, /^Content check failed\./m);
397
- assert.match(output, /Frontmatter line 5 uses tabs, non-breaking spaces, or invalid whitespace for indentation\./);
393
+ assert.match(output, /Frontmatter line 4 uses tabs, non-breaking spaces, or invalid whitespace for indentation\./);
398
394
  assert.match(output, /Replace the indentation on that line with ordinary spaces\./);
399
395
  });
400
396
  });
401
397
 
402
398
  const badNestedValueSite = `---
403
- presentation:
404
- typography:
405
- preset: quiet-gallery
406
- overrides:
407
- body:
408
- paragraphSpacing: 0.3em
399
+ typography:
400
+ profile: restrained
401
+ overrides:
402
+ body:
403
+ paragraphSpacing: 0.3em
409
404
  sections:
410
405
  intro: {}
411
406
 
@@ -424,17 +419,17 @@ test('content:check explains frontmatter indentation below scalar values', async
424
419
 
425
420
  assert.equal(result.status, 1, output);
426
421
  assert.match(output, /^Content check failed\./m);
427
- assert.match(output, /Frontmatter line 5 is indented under line 4, but line 4 already has a value\./);
422
+ assert.match(output, /Frontmatter line 4 is indented under line 3, but line 3 already has a value\./);
428
423
  assert.match(output, /Move the later line to the same indentation level as its sibling/);
429
424
  });
430
425
  });
431
426
 
432
- const topLevelGallerySite = `---
427
+ const topLevelImagesSite = `---
433
428
  title: Example
434
429
  description: Example site.
435
430
  sections:
436
431
  intro: {}
437
- gallery:
432
+ images:
438
433
  - image: intro.jpg
439
434
 
440
435
  ---
@@ -444,7 +439,7 @@ Text.
444
439
 
445
440
  test('content:check explains likely misindented frontmatter keys', async () => {
446
441
  await withTempProject({
447
- site: topLevelGallerySite,
442
+ site: topLevelImagesSite,
448
443
  files: ['site/images/intro/intro.jpg'],
449
444
  }, async (root) => {
450
445
  const result = runContentScript(root, ['--check']);
@@ -452,8 +447,8 @@ test('content:check explains likely misindented frontmatter keys', async () => {
452
447
 
453
448
  assert.equal(result.status, 1, output);
454
449
  assert.match(output, /^Content check failed\./m);
455
- assert.match(output, /Frontmatter line 6 defines "gallery" at the top level, but it is not a valid top-level content field\./);
456
- assert.match(output, /Indent "gallery:" under the object it belongs to, or move image content into Norna Markdown blocks\./);
450
+ assert.match(output, /Frontmatter line 6 defines "images" at the top level, but it is not a valid top-level content field\./);
451
+ assert.match(output, /Put local image references in norna-image-stack or norna-image-carousel blocks in the Markdown body\./);
457
452
  });
458
453
  });
459
454
 
@@ -51,14 +51,9 @@ const createTempSite = async ({ underRepoCache = false } = {}) => {
51
51
  const root = await mkdtemp(path.join(tempParent, 'norna-content-model-v2-'));
52
52
  const siteDir = path.join(root, 'site');
53
53
  await mkdir(siteDir, { recursive: true });
54
- await writeFile(path.join(siteDir, 'config.md'), `---
55
- url: https://example.com/
56
- ---
57
- `);
58
- await writeFile(path.join(siteDir, 'theme.md'), `---
59
- typography:
60
- preset: text-forward
61
- ---
54
+ await writeFile(path.join(siteDir, 'config.yaml'), 'url: https://example.com/\n');
55
+ await writeFile(path.join(siteDir, 'theme.yaml'), `typography:
56
+ profile: reading
62
57
  `);
63
58
  return { root, siteDir };
64
59
  };
@@ -124,15 +119,11 @@ test('content model v2 fixture checks and builds', async () => {
124
119
  test('route theme replaces visual theme while site identity stays at the root', async () => {
125
120
  const { root, siteDir } = await createTempSite({ underRepoCache: true });
126
121
  try {
127
- await writeFile(path.join(siteDir, 'theme.md'), `---
128
- typography:
129
- preset: text-forward
130
- ---
122
+ await writeFile(path.join(siteDir, 'theme.yaml'), `typography:
123
+ profile: reading
131
124
  `);
132
- await writeFile(path.join(siteDir, 'sitewide-content.md'), `---
133
- navigation:
134
- brand: Root Brand
135
- ---
125
+ await writeFile(path.join(siteDir, 'sitewide-content.yaml'), `navigation:
126
+ label: Root Brand
136
127
  `);
137
128
  await mkdir(path.join(siteDir, 'routes', '010-guide'), { recursive: true });
138
129
  await writeFile(path.join(siteDir, 'content.md'), `---
@@ -144,7 +135,7 @@ description: Root page
144
135
 
145
136
  Root content.
146
137
  `);
147
- await writeFile(path.join(siteDir, 'routes', '010-guide', 'route-content.md'), `---
138
+ await writeFile(path.join(siteDir, 'routes', '010-guide', 'content.md'), `---
148
139
  title: Guide
149
140
  description: Guide page
150
141
  navigation:
@@ -155,12 +146,9 @@ navigation:
155
146
 
156
147
  Route content.
157
148
  `);
158
- await writeFile(path.join(siteDir, 'routes', '010-guide', 'theme.md'), `---
159
- typography:
160
- preset: statement
161
- presentation:
162
- palette: light
163
- ---
149
+ await writeFile(path.join(siteDir, 'routes', '010-guide', 'theme.yaml'), `typography:
150
+ profile: statement
151
+ palette: light
164
152
  `);
165
153
 
166
154
  await runNorna(['--site-dir', siteDir, 'build']);
@@ -187,7 +175,7 @@ description: Root page
187
175
 
188
176
  Root content.
189
177
  `);
190
- await writeFile(path.join(siteDir, 'routes', '010-guide', 'route-content.md'), `---
178
+ await writeFile(path.join(siteDir, 'routes', '010-guide', 'content.md'), `---
191
179
  title: Guide
192
180
  description: Guide page
193
181
  ---
@@ -196,16 +184,60 @@ description: Guide page
196
184
 
197
185
  Route content.
198
186
  `);
199
- await writeFile(path.join(siteDir, 'routes', '010-guide', 'theme.md'), `---
200
- navigation:
201
- brand: Route Brand
202
- ---
187
+ await writeFile(path.join(siteDir, 'routes', '010-guide', 'theme.yaml'), `navigation:
188
+ label: Route Brand
189
+ `);
190
+
191
+ await assert.rejects(
192
+ runNorna(['--site-dir', siteDir, 'config:check']),
193
+ (error) => {
194
+ assert.match(error.output, /may not define navigation\. The site label and logo settings belong in site\/sitewide-content\.yaml/);
195
+ return true;
196
+ },
197
+ );
198
+ } finally {
199
+ await rm(root, { recursive: true, force: true });
200
+ }
201
+ });
202
+
203
+ test('navigation logo settings require one conventional logo file', async () => {
204
+ const { root, siteDir } = await createTempSite();
205
+ try {
206
+ await writeFile(path.join(siteDir, 'sitewide-content.yaml'), `navigation:
207
+ label: Missing logo
208
+ logo:
209
+ height: 2rem
210
+ `);
211
+
212
+ await assert.rejects(
213
+ runNorna(['--site-dir', siteDir, 'config:check']),
214
+ (error) => {
215
+ assert.match(error.output, /navigation\.logo is configured, but no logo file was found/);
216
+ assert.match(error.output, /site\/public\/logo\.svg/);
217
+ return true;
218
+ },
219
+ );
220
+ } finally {
221
+ await rm(root, { recursive: true, force: true });
222
+ }
223
+ });
224
+
225
+ test('multiple conventional navigation logo files stop config validation', async () => {
226
+ const { root, siteDir } = await createTempSite();
227
+ try {
228
+ await writeFile(path.join(siteDir, 'sitewide-content.yaml'), `navigation:
229
+ label: Conflicting logos
203
230
  `);
231
+ await mkdir(path.join(siteDir, 'public'), { recursive: true });
232
+ await writeFile(path.join(siteDir, 'public', 'logo.svg'), '<svg/>');
233
+ await writeFile(path.join(siteDir, 'public', 'logo.png'), 'png');
204
234
 
205
235
  await assert.rejects(
206
236
  runNorna(['--site-dir', siteDir, 'config:check']),
207
237
  (error) => {
208
- assert.match(error.output, /may not define navigation\. Brand and logo belong in site\/sitewide-content\.md/);
238
+ assert.match(error.output, /Found multiple logo files/);
239
+ assert.match(error.output, /site\/public\/logo\.svg/);
240
+ assert.match(error.output, /site\/public\/logo\.png/);
209
241
  return true;
210
242
  },
211
243
  );
@@ -286,7 +318,7 @@ description: Fixture
286
318
 
287
319
  The first paragraph points to an explanation.{note-ref}
288
320
 
289
- {note: This explanation names \`sitewide-content.md\` and stays beside the
321
+ {note: This explanation names \`sitewide-content.yaml\` and stays beside the
290
322
  paragraph on wide screens.}
291
323
 
292
324
  The second paragraph has its own explanation.{note-ref}
@@ -302,7 +334,7 @@ The second paragraph has its own explanation.{note-ref}
302
334
  assert.match(html, /<sup class="section-note-ref"><a id="note-ref-home-intro-1" href="#note-home-intro-1" aria-label="Note 1" aria-describedby="note-home-intro-1" data-note-id="note-home-intro-1">1<\/a><\/sup>/);
303
335
  assert.match(html, /<aside class="section-note section-note-margin" id="note-home-intro-1" aria-label="Note 1" role="note" data-note-id="note-home-intro-1" style="grid-row: 1;">/);
304
336
  assert.match(html, /<a class="section-note-number" href="#note-ref-home-intro-1" aria-label="Note 1">\s*1\s*<\/a>/);
305
- assert.match(html, /This explanation names <code>sitewide-content\.md<\/code> and stays beside the paragraph on wide screens\./);
337
+ assert.match(html, /This explanation names <code>sitewide-content\.yaml<\/code> and stays beside the paragraph on wide screens\./);
306
338
  assert.match(html, /<sup class="section-note-ref"><a id="note-ref-home-intro-2" href="#note-home-intro-2" aria-label="Note 2" aria-describedby="note-home-intro-2" data-note-id="note-home-intro-2">2<\/a><\/sup>/);
307
339
  assert.match(html, /<aside class="section-note section-note-margin" id="note-home-intro-2" aria-label="Note 2" role="note" data-note-id="note-home-intro-2" style="grid-row: 2;">/);
308
340
  assert.match(html, /This is the second explanation\./);
@@ -652,7 +684,7 @@ description: Fixture
652
684
 
653
685
  Text.
654
686
  `);
655
- await writeFile(path.join(sourceRouteDir, 'route-content.md'), `---
687
+ await writeFile(path.join(sourceRouteDir, 'content.md'), `---
656
688
  title: Source
657
689
  description: Fixture
658
690
  ---
@@ -661,7 +693,7 @@ description: Fixture
661
693
 
662
694
  Text.
663
695
  `);
664
- await writeFile(path.join(targetRouteDir, 'route-content.md'), `---
696
+ await writeFile(path.join(targetRouteDir, 'content.md'), `---
665
697
  title: Target
666
698
  description: Fixture
667
699
  ---
@@ -700,7 +732,7 @@ description: Fixture
700
732
 
701
733
  Text.
702
734
  `);
703
- await writeFile(path.join(sourceRouteDir, 'route-content.md'), `---
735
+ await writeFile(path.join(sourceRouteDir, 'content.md'), `---
704
736
  title: Source
705
737
  description: Fixture
706
738
  ---
@@ -709,7 +741,7 @@ description: Fixture
709
741
 
710
742
  Text.
711
743
  `);
712
- await writeFile(path.join(targetRouteDir, 'route-content.md'), `---
744
+ await writeFile(path.join(targetRouteDir, 'content.md'), `---
713
745
  title: Target
714
746
  description: Fixture
715
747
  ---
@@ -756,7 +788,7 @@ description: Fixture
756
788
 
757
789
  Text.
758
790
  `);
759
- await writeFile(path.join(sourceRouteDir, 'route-content.md'), `---
791
+ await writeFile(path.join(sourceRouteDir, 'content.md'), `---
760
792
  title: Source
761
793
  description: Fixture
762
794
  ---
@@ -765,7 +797,7 @@ description: Fixture
765
797
 
766
798
  Text.
767
799
  `);
768
- await writeFile(path.join(targetRouteDir, 'route-content.md'), `---
800
+ await writeFile(path.join(targetRouteDir, 'content.md'), `---
769
801
  title: Target
770
802
  description: Fixture
771
803
  ---
@@ -809,7 +841,7 @@ description: Fixture
809
841
 
810
842
  Text.
811
843
  `);
812
- await writeFile(path.join(sourceRouteDir, 'route-content.md'), `---
844
+ await writeFile(path.join(sourceRouteDir, 'content.md'), `---
813
845
  title: Source
814
846
  description: Fixture
815
847
  ---
@@ -820,7 +852,7 @@ description: Fixture
820
852
  - image: shared.jpg
821
853
  \`\`\`
822
854
  `);
823
- await writeFile(path.join(targetRouteDir, 'route-content.md'), `---
855
+ await writeFile(path.join(targetRouteDir, 'content.md'), `---
824
856
  title: Target
825
857
  description: Fixture
826
858
  ---
@@ -836,7 +868,7 @@ description: Fixture
836
868
  await assert.rejects(
837
869
  () => runContentScript(siteDir, ['--write', '--yes']),
838
870
  (error) => {
839
- 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\]\./);
871
+ 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\/content\.md \[old\]\./);
840
872
  return true;
841
873
  },
842
874
  );
@@ -8,6 +8,20 @@ const repoRoot = path.resolve(import.meta.dirname, '..');
8
8
  const cliPath = path.join(repoRoot, 'bin', 'norna.mjs');
9
9
  const tutorialPath = path.join(repoRoot, 'docs', 'getting-started.md');
10
10
  const scratchRoot = path.join(repoRoot, 'todos');
11
+ const obsoleteSourceReferences = [
12
+ 'site/config.md',
13
+ 'site/theme.md',
14
+ 'site/sitewide-content.md',
15
+ 'route-content.md',
16
+ 'docs/site-structure.md',
17
+ ];
18
+ const obsoleteSiteFilenames = new Set([
19
+ 'config.md',
20
+ 'theme.md',
21
+ 'sitewide-content.md',
22
+ 'route-content.md',
23
+ ]);
24
+ const ignoredSiteDirectories = new Set(['.astro', '.norna', 'dist', 'node_modules']);
11
25
  await mkdir(scratchRoot, { recursive: true });
12
26
  const tempRoot = await mkdtemp(path.join(scratchRoot, 'norna-documentation-'));
13
27
  const tutorialSiteRoot = path.join(tempRoot, 'my-site');
@@ -42,6 +56,20 @@ const collectMarkdownFiles = async (directory) => {
42
56
  return files;
43
57
  };
44
58
 
59
+ const collectFiles = async (directory) => {
60
+ const files = [];
61
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
62
+ const entryPath = path.join(directory, entry.name);
63
+ if (entry.isDirectory()) {
64
+ if (ignoredSiteDirectories.has(entry.name)) continue;
65
+ files.push(...await collectFiles(entryPath));
66
+ } else {
67
+ files.push(entryPath);
68
+ }
69
+ }
70
+ return files;
71
+ };
72
+
45
73
  const checkLocalMarkdownLinks = async () => {
46
74
  const markdownFiles = [
47
75
  path.join(repoRoot, 'README.md'),
@@ -71,6 +99,42 @@ const checkLocalMarkdownLinks = async () => {
71
99
  assert.deepEqual(missing, [], `Broken local Markdown links:\n${missing.join('\n')}`);
72
100
  };
73
101
 
102
+ const checkObsoleteDocumentationReferences = async () => {
103
+ const markdownFiles = [
104
+ path.join(repoRoot, 'README.md'),
105
+ ...await collectMarkdownFiles(path.join(repoRoot, 'docs')),
106
+ ...await collectMarkdownFiles(path.join(repoRoot, 'examples')),
107
+ ...await collectMarkdownFiles(path.join(repoRoot, 'starters')),
108
+ ];
109
+ const obsolete = [];
110
+
111
+ for (const markdownPath of markdownFiles) {
112
+ const source = await readFile(markdownPath, 'utf8');
113
+ for (const reference of obsoleteSourceReferences) {
114
+ if (source.includes(reference)) {
115
+ obsolete.push(`${path.relative(repoRoot, markdownPath)} -> ${reference}`);
116
+ }
117
+ }
118
+ }
119
+
120
+ assert.deepEqual(obsolete, [], `Obsolete Norna source references:\n${obsolete.join('\n')}`);
121
+ };
122
+
123
+ const checkObsoleteSiteFiles = async () => {
124
+ const siteRoots = [
125
+ path.join(repoRoot, 'site'),
126
+ path.join(repoRoot, 'starters'),
127
+ path.join(repoRoot, 'examples'),
128
+ path.join(repoRoot, 'fixtures'),
129
+ ];
130
+ const files = (await Promise.all(siteRoots.map(collectFiles))).flat();
131
+ const obsolete = files
132
+ .filter((filePath) => obsoleteSiteFilenames.has(path.basename(filePath)))
133
+ .map((filePath) => path.relative(repoRoot, filePath));
134
+
135
+ assert.deepEqual(obsolete, [], `Obsolete Norna site files:\n${obsolete.join('\n')}`);
136
+ };
137
+
74
138
  try {
75
139
  const tutorial = await readFile(tutorialPath, 'utf8');
76
140
  const initResult = runCli(['init', tutorialSiteRoot]);
@@ -84,12 +148,12 @@ try {
84
148
  extractTutorialFile(tutorial, 'Replace `site/content.md` with:', 'md'),
85
149
  ),
86
150
  writeFile(
87
- path.join(tutorialSiteRoot, 'site', 'sitewide-content.md'),
88
- extractTutorialFile(tutorial, 'Replace `site/sitewide-content.md` with:', 'yaml'),
151
+ path.join(tutorialSiteRoot, 'site', 'sitewide-content.yaml'),
152
+ extractTutorialFile(tutorial, 'Replace `site/sitewide-content.yaml` with:', 'yaml'),
89
153
  ),
90
154
  writeFile(
91
- path.join(tutorialSiteRoot, 'site', 'theme.md'),
92
- extractTutorialFile(tutorial, 'Replace `site/theme.md` with:', 'yaml'),
155
+ path.join(tutorialSiteRoot, 'site', 'theme.yaml'),
156
+ extractTutorialFile(tutorial, 'Replace `site/theme.yaml` with:', 'yaml'),
93
157
  ),
94
158
  ]);
95
159
 
@@ -104,6 +168,8 @@ try {
104
168
  assert.match(html, /Next/);
105
169
 
106
170
  await checkLocalMarkdownLinks();
171
+ await checkObsoleteDocumentationReferences();
172
+ await checkObsoleteSiteFiles();
107
173
 
108
174
  const llms = await readFile(path.join(repoRoot, 'site', 'public', 'llms.txt'), 'utf8');
109
175
  for (const match of llms.matchAll(/https:\/\/raw\.githubusercontent\.com\/janga\/norna\/main\/([^\s)]+)/g)) {
@@ -0,0 +1,158 @@
1
+ import assert from 'node:assert/strict';
2
+ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { documentationRef } from './lib/documentation-links.mjs';
6
+ import {
7
+ findNornaSiteRoot,
8
+ getImageDefinitionContext,
9
+ getImageCompletionContext,
10
+ getMarkdownDiagnostics,
11
+ getNornaBlockCompletionContext,
12
+ getNornaBlockFieldContext,
13
+ getSitePublicAssetStatus,
14
+ nornaBlockDefinitions,
15
+ } from './lib/editor-language-service.mjs';
16
+
17
+ const root = await mkdtemp(path.join(os.tmpdir(), 'norna-editor-language-'));
18
+ const siteRoot = path.join(root, 'site');
19
+ const homeContentPath = path.join(siteRoot, 'content.md');
20
+ const routeContentPath = path.join(siteRoot, 'routes', '010-about', 'content.md');
21
+
22
+ const homeSource = `---
23
+ title: Editor fixture
24
+ description: Exercises project-local image discovery.
25
+ ---
26
+
27
+ ## Intro {#intro}
28
+
29
+ Text with a missing note {note-ref}.
30
+
31
+ \`\`\`norna-image-stack
32
+ - image: local.jpg
33
+ - image: portrait.jpg
34
+ \`\`\`
35
+ `;
36
+ const routeSource = `---
37
+ title: About
38
+ description: About this fixture.
39
+ ---
40
+
41
+ ## Team {#team}
42
+
43
+ Route content.
44
+ `;
45
+
46
+ try {
47
+ await mkdir(path.join(siteRoot, 'images', 'intro'), { recursive: true });
48
+ await mkdir(path.join(siteRoot, 'routes', '010-about', 'images', 'team'), { recursive: true });
49
+ await mkdir(path.join(siteRoot, 'public'), { recursive: true });
50
+ await writeFile(path.join(siteRoot, 'config.yaml'), 'url: https://example.com/\n');
51
+ await writeFile(path.join(siteRoot, 'sitewide-content.yaml'), `navigation:
52
+ label: Editor fixture
53
+ logo:
54
+ height: 2rem
55
+ `);
56
+ await writeFile(path.join(siteRoot, 'images', 'intro', 'local.jpg'), 'local');
57
+ await writeFile(path.join(siteRoot, 'routes', '010-about', 'images', 'team', 'portrait.jpg'), 'portrait');
58
+ await writeFile(path.join(siteRoot, 'public', 'logo.svg'), '<svg/>');
59
+ await writeFile(path.join(siteRoot, 'public', 'logo.png'), 'logo');
60
+ await writeFile(path.join(siteRoot, 'public', 'favicon.svg'), '<svg/>');
61
+ await writeFile(path.join(siteRoot, 'public', 'Logo.JPG'), 'wrong case');
62
+ await writeFile(path.join(siteRoot, 'public', 'favion.ico'), 'typo');
63
+ await writeFile(path.join(siteRoot, 'public', 'favicon-32x32.png'), 'unrecognized');
64
+ await writeFile(homeContentPath, homeSource);
65
+ await writeFile(routeContentPath, routeSource);
66
+
67
+ assert.equal(await findNornaSiteRoot(homeContentPath), siteRoot);
68
+ const publicAssetStatus = await getSitePublicAssetStatus(homeContentPath);
69
+ assert.deepEqual(publicAssetStatus.logos, ['logo.png', 'logo.svg']);
70
+ assert.deepEqual(publicAssetStatus.browserIcons, ['favicon.svg']);
71
+ assert.equal(publicAssetStatus.issues.filter(({ code }) => code === 'multiple-logo-files').length, 2);
72
+ assert.ok(publicAssetStatus.issues.some(({ code }) => code === 'possible-favicon-typo'));
73
+ assert.ok(publicAssetStatus.issues.some(({ code }) => code === 'unrecognized-favicon-file'));
74
+ assert.ok(publicAssetStatus.issues.some(({ code, filename }) => code === 'public-asset-case' && filename === 'Logo.JPG'));
75
+ await rm(path.join(siteRoot, 'public', 'logo.svg'), { force: true });
76
+ await rm(path.join(siteRoot, 'public', 'logo.png'), { force: true });
77
+ const missingLogoStatus = await getSitePublicAssetStatus(homeContentPath);
78
+ assert.ok(missingLogoStatus.issues.some(({ code, line }) => code === 'missing-logo-file' && line === 3));
79
+ assert.equal(nornaBlockDefinitions['norna-image-stack'].description.includes('vertical stack'), true);
80
+ assert.match(
81
+ nornaBlockDefinitions['norna-image-stack'].documentation,
82
+ new RegExp(`/blob/${documentationRef.replaceAll('.', '\\.')}\/docs/content\\.md#norna-blocks`),
83
+ );
84
+ assert.equal(nornaBlockDefinitions['norna-card-list'].options.layout.default, 'image-top');
85
+
86
+ const stackFieldSource = homeSource.replace('- image: portrait.jpg', '- image: portrait.jpg\n ');
87
+ const stackFieldLine = stackFieldSource.split('\n').findIndex((line) => line === ' ');
88
+ const stackFieldCompletion = getNornaBlockCompletionContext({ source: stackFieldSource, line: stackFieldLine });
89
+ assert.deepEqual(stackFieldCompletion.candidates.map(({ key }) => key), ['alt', 'caption']);
90
+
91
+ const cardValueSource = `${homeSource}\n\`\`\`norna-card-list\nlayout: \n\`\`\`\n`;
92
+ const cardValueLine = cardValueSource.split('\n').findIndex((line) => line === 'layout: ');
93
+ const cardValueCompletion = getNornaBlockCompletionContext({ source: cardValueSource, line: cardValueLine });
94
+ assert.equal(cardValueCompletion.mode, 'value');
95
+ assert.deepEqual(cardValueCompletion.candidates.map(({ label }) => label), ['image-top', 'image-left', 'image-right']);
96
+ assert.equal(
97
+ getNornaBlockFieldContext({ source: cardValueSource, line: cardValueLine }).field.default,
98
+ 'image-top',
99
+ );
100
+
101
+ const completionSource = homeSource.replace('- image: portrait.jpg', '- image: ');
102
+ const completionLine = completionSource.split('\n').findIndex((line) => line === '- image: ');
103
+ const completion = await getImageCompletionContext({
104
+ documentPath: homeContentPath,
105
+ line: completionLine,
106
+ source: completionSource,
107
+ });
108
+ assert.ok(completion);
109
+ assert.deepEqual(
110
+ completion.candidates.map(({ filename, isExpected }) => [filename, isExpected]),
111
+ [['local.jpg', true], ['portrait.jpg', false]],
112
+ );
113
+ const localDefinitionLine = homeSource.split('\n').findIndex((line) => line === '- image: local.jpg');
114
+ const localDefinition = await getImageDefinitionContext({
115
+ documentPath: homeContentPath,
116
+ line: localDefinitionLine,
117
+ source: homeSource,
118
+ });
119
+ assert.deepEqual(localDefinition.files, [path.join(siteRoot, 'images', 'intro', 'local.jpg')]);
120
+
121
+ const diagnostics = await getMarkdownDiagnostics({ documentPath: homeContentPath, source: homeSource });
122
+ assert.ok(diagnostics.some(({ code, message }) => code === 'image-needs-sync' && message.includes('Run "norna content:sync"')));
123
+ assert.ok(diagnostics.some(({ message }) => message.includes('has no following "{note: ...}"')));
124
+ const missingIdDiagnostics = await getMarkdownDiagnostics({
125
+ documentPath: homeContentPath,
126
+ source: homeSource.replace('## Intro {#intro}', '## Intro'),
127
+ });
128
+ assert.ok(missingIdDiagnostics.some(({ code }) => code === 'missing-section-id'));
129
+ const unclosedBlockDiagnostics = await getMarkdownDiagnostics({
130
+ documentPath: homeContentPath,
131
+ source: homeSource.replace(/\n```\n$/, '\n'),
132
+ });
133
+ assert.ok(unclosedBlockDiagnostics.some(({ code }) => code === 'unclosed-norna-block'));
134
+ const markdownImageDiagnostics = await getMarkdownDiagnostics({
135
+ documentPath: homeContentPath,
136
+ source: homeSource.replace('Text with a missing note {note-ref}.', '![Portrait](portrait.jpg)'),
137
+ });
138
+ assert.ok(markdownImageDiagnostics.some(({ code }) => code === 'local-markdown-image'));
139
+
140
+ await writeFile(routeContentPath, routeSource.replace('Route content.', `\`\`\`norna-image-stack
141
+ - image: portrait.jpg
142
+ \`\`\``));
143
+ const sharedDiagnostics = await getMarkdownDiagnostics({ documentPath: homeContentPath, source: homeSource });
144
+ assert.ok(sharedDiagnostics.some(({ message }) => message.includes('is still referenced by')));
145
+
146
+ await mkdir(path.join(siteRoot, 'routes', '020-contact', 'images', 'people'), { recursive: true });
147
+ await writeFile(path.join(siteRoot, 'routes', '020-contact', 'images', 'people', 'portrait.jpg'), 'duplicate');
148
+ const ambiguousDiagnostics = await getMarkdownDiagnostics({ documentPath: homeContentPath, source: homeSource });
149
+ assert.ok(ambiguousDiagnostics.some(({ message }) => message.includes('is ambiguous')));
150
+
151
+ const missingSource = homeSource.replace('portrait.jpg', 'missing.jpg');
152
+ const missingDiagnostics = await getMarkdownDiagnostics({ documentPath: homeContentPath, source: missingSource });
153
+ assert.ok(missingDiagnostics.some(({ message }) => message.includes('was not found')));
154
+
155
+ console.log('Editor language service tests passed.');
156
+ } finally {
157
+ await rm(root, { recursive: true, force: true });
158
+ }