@janga/norna 0.7.23 → 0.7.24
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 +14 -18
- package/astro.config.mjs +12 -0
- package/bin/norna-cli.mjs +6 -0
- package/package.json +13 -2
- package/schemas/category.schema.json +2 -2
- package/schemas/config.schema.json +45 -15
- package/schemas/content-frontmatter.schema.json +7 -7
- package/schemas/page-theme.schema.json +26 -26
- package/schemas/sitewide-content.schema.json +18 -18
- package/schemas/theme.schema.json +246 -246
- package/scripts/build-site.mjs +1 -0
- package/scripts/check-config.mjs +12 -1
- package/scripts/generate-search-index.mjs +57 -0
- package/scripts/init-site.mjs +1 -0
- package/scripts/lib/code-fence-metadata.mjs +223 -0
- package/scripts/lib/edit-source-link.mjs +49 -0
- package/scripts/lib/editor-language-service.mjs +32 -12
- package/scripts/lib/image-presentation.mjs +4 -0
- package/scripts/lib/navigation-model.mjs +34 -13
- package/scripts/lib/navigation-review.mjs +396 -0
- package/scripts/lib/norna-markdown-blocks.mjs +73 -26
- package/scripts/lib/norna-markdown-render-plugin.mjs +64 -1
- package/scripts/lib/page-aliases.mjs +21 -1
- package/scripts/lib/page-markdown.mjs +32 -1
- package/scripts/lib/page-move-plan.mjs +659 -0
- package/scripts/lib/presentation-palette-metadata.mjs +1 -1
- package/scripts/lib/presentation.mjs +1 -10
- package/scripts/lib/project-config.mjs +111 -5
- package/scripts/lib/public-asset-conventions.mjs +36 -2
- package/scripts/lib/schema-definitions.mjs +22 -5
- package/scripts/lib/schema-editor-metadata.mjs +19 -4
- package/scripts/lib/schema-value-definitions.mjs +1 -1
- package/scripts/lib/semantic-callouts.mjs +128 -0
- package/scripts/lib/site-content.mjs +2 -1
- package/scripts/lib/site-link-graph.mjs +33 -2
- package/scripts/lib/site-navigation-tree.mjs +85 -0
- package/scripts/lib/social-image-assets.mjs +30 -0
- package/scripts/lib/theme-presets.mjs +2 -2
- package/scripts/lib/theme-profiles.mjs +0 -4
- package/scripts/move-site-page.mjs +256 -0
- package/scripts/review-navigation.mjs +32 -0
- package/scripts/sync-content-sections.mjs +67 -4
- package/scripts/sync-site-public.mjs +24 -5
- package/src/components/CodeBlockCopyScript.astro +9 -4
- package/src/components/EditSourceLink.astro +17 -0
- package/src/components/ImageCarousel.astro +5 -6
- package/src/components/NavigationPageTree.astro +66 -55
- package/src/components/NavigationTreeControls.astro +77 -0
- package/src/components/PageAliasRedirect.astro +1 -1
- package/src/components/PageList.astro +33 -0
- package/src/components/PageSequenceNavigation.astro +37 -0
- package/src/components/SearchPage.astro +151 -0
- package/src/components/SiteNavigation.astro +35 -8
- package/src/components/SitePage.astro +79 -26
- package/src/components/SiteSection.astro +11 -3
- package/src/components/SiteTreeNavigation.astro +8 -1
- package/src/components/TreeNavigationScript.astro +207 -26
- package/src/layouts/BaseLayout.astro +57 -2
- package/src/lib/sectionContent.ts +32 -3
- package/src/lib/siteNavigation.ts +31 -52
- package/src/lib/sitePublicAssets.ts +1 -0
- package/src/pages/404.astro +110 -0
- package/src/pages/[...slug].astro +15 -5
- package/src/styles/content.css +242 -18
- package/src/styles/media.css +0 -5
- package/src/styles/navigation.css +31 -0
- package/src/styles/page-layout.css +438 -89
- package/src/styles/responsive.css +77 -8
- package/starters/basic/README.md +6 -19
- package/starters/basic/package.json +1 -0
- package/starters/basic/site/pages/000-home/content.md +13 -50
|
@@ -44,7 +44,6 @@ const createPaletteMode = ({ appearance, page, surfaces, frame, css }) => {
|
|
|
44
44
|
|
|
45
45
|
const presentationPalettes = Object.freeze({
|
|
46
46
|
'near-monochrome': Object.freeze({
|
|
47
|
-
defaultAppearance: 'dark',
|
|
48
47
|
modes: Object.freeze({
|
|
49
48
|
light: createPaletteMode({
|
|
50
49
|
appearance: 'light',
|
|
@@ -85,7 +84,6 @@ const presentationPalettes = Object.freeze({
|
|
|
85
84
|
}),
|
|
86
85
|
}),
|
|
87
86
|
'arctic-blue': Object.freeze({
|
|
88
|
-
defaultAppearance: 'light',
|
|
89
87
|
modes: Object.freeze({
|
|
90
88
|
light: createPaletteMode({
|
|
91
89
|
appearance: 'light',
|
|
@@ -126,7 +124,6 @@ const presentationPalettes = Object.freeze({
|
|
|
126
124
|
}),
|
|
127
125
|
}),
|
|
128
126
|
'mineral-teal': Object.freeze({
|
|
129
|
-
defaultAppearance: 'light',
|
|
130
127
|
modes: Object.freeze({
|
|
131
128
|
light: createPaletteMode({
|
|
132
129
|
appearance: 'light',
|
|
@@ -167,7 +164,6 @@ const presentationPalettes = Object.freeze({
|
|
|
167
164
|
}),
|
|
168
165
|
}),
|
|
169
166
|
'soft-lavender': Object.freeze({
|
|
170
|
-
defaultAppearance: 'light',
|
|
171
167
|
modes: Object.freeze({
|
|
172
168
|
light: createPaletteMode({
|
|
173
169
|
appearance: 'light',
|
|
@@ -208,7 +204,6 @@ const presentationPalettes = Object.freeze({
|
|
|
208
204
|
}),
|
|
209
205
|
}),
|
|
210
206
|
'warm-paper': Object.freeze({
|
|
211
|
-
defaultAppearance: 'light',
|
|
212
207
|
modes: Object.freeze({
|
|
213
208
|
light: createPaletteMode({
|
|
214
209
|
appearance: 'light',
|
|
@@ -249,7 +244,6 @@ const presentationPalettes = Object.freeze({
|
|
|
249
244
|
}),
|
|
250
245
|
}),
|
|
251
246
|
'retro-earth': Object.freeze({
|
|
252
|
-
defaultAppearance: 'light',
|
|
253
247
|
modes: Object.freeze({
|
|
254
248
|
light: createPaletteMode({
|
|
255
249
|
appearance: 'light',
|
|
@@ -290,7 +284,6 @@ const presentationPalettes = Object.freeze({
|
|
|
290
284
|
}),
|
|
291
285
|
}),
|
|
292
286
|
'clay-rose': Object.freeze({
|
|
293
|
-
defaultAppearance: 'light',
|
|
294
287
|
modes: Object.freeze({
|
|
295
288
|
light: createPaletteMode({
|
|
296
289
|
appearance: 'light',
|
|
@@ -331,7 +324,6 @@ const presentationPalettes = Object.freeze({
|
|
|
331
324
|
}),
|
|
332
325
|
}),
|
|
333
326
|
'forest-moss': Object.freeze({
|
|
334
|
-
defaultAppearance: 'light',
|
|
335
327
|
modes: Object.freeze({
|
|
336
328
|
light: createPaletteMode({
|
|
337
329
|
appearance: 'light',
|
|
@@ -372,7 +364,6 @@ const presentationPalettes = Object.freeze({
|
|
|
372
364
|
}),
|
|
373
365
|
}),
|
|
374
366
|
'vivid-night': Object.freeze({
|
|
375
|
-
defaultAppearance: 'dark',
|
|
376
367
|
modes: Object.freeze({
|
|
377
368
|
light: createPaletteMode({
|
|
378
369
|
appearance: 'light',
|
|
@@ -477,7 +468,7 @@ export const resolveThemePresentation = (theme, sourceLabel = 'theme.yaml') => {
|
|
|
477
468
|
const palette = getPresentationPalette(paletteName);
|
|
478
469
|
const appearance = normalizedTheme.appearance ?? {};
|
|
479
470
|
const readerControls = normalizedTheme.readerControls ?? {};
|
|
480
|
-
const defaultAppearance = appearance.default ??
|
|
471
|
+
const defaultAppearance = appearance.default ?? 'system';
|
|
481
472
|
if (!appearanceNames.includes(defaultAppearance)) {
|
|
482
473
|
throw new Error(`appearance.default must be one of ${appearanceNames.join(', ')} in ${sourceLabel}.`);
|
|
483
474
|
}
|
|
@@ -2,13 +2,18 @@ import { readFile } from 'node:fs/promises';
|
|
|
2
2
|
import {
|
|
3
3
|
validateConfigYamlStructure,
|
|
4
4
|
} from './site-content.mjs';
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
defaultImageMaxAvailableHeightPercent,
|
|
7
|
+
defaultImagePresentation,
|
|
8
|
+
imagePresentationNames,
|
|
9
|
+
} from './image-presentation.mjs';
|
|
6
10
|
import { navigationModeNames } from './navigation-model.mjs';
|
|
7
11
|
import {
|
|
8
12
|
siteConfigLabel,
|
|
9
13
|
siteConfigPath,
|
|
10
14
|
siteThemeLabel,
|
|
11
15
|
} from './site-paths.mjs';
|
|
16
|
+
import { normalizeEditLinkBaseUrl } from './edit-source-link.mjs';
|
|
12
17
|
import { readThemeConfig } from './theme-config.mjs';
|
|
13
18
|
import { resolveThemeConfig } from './theme-presets.mjs';
|
|
14
19
|
import { parseYamlConfig } from './yaml-config.mjs';
|
|
@@ -52,6 +57,16 @@ const readEnum = (object, key, path, allowedValues, fallback, sourceLabel = site
|
|
|
52
57
|
return value;
|
|
53
58
|
};
|
|
54
59
|
|
|
60
|
+
const readBoolean = (object, key, path, fallback, sourceLabel = siteConfigLabel) => {
|
|
61
|
+
const value = object[key] ?? fallback;
|
|
62
|
+
|
|
63
|
+
if (typeof value !== 'boolean') {
|
|
64
|
+
throw new Error(`${path}.${key} must be true or false in ${sourceLabel}.`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return value;
|
|
68
|
+
};
|
|
69
|
+
|
|
55
70
|
const readFontFamily = (object, key, path, fallback, sourceLabel = siteConfigLabel) => {
|
|
56
71
|
const value = object[key] ?? fallback;
|
|
57
72
|
|
|
@@ -192,8 +207,17 @@ const localeLabels = Object.freeze({
|
|
|
192
207
|
codeCopied: 'Copied',
|
|
193
208
|
codeCopyFailed: 'Could not copy code',
|
|
194
209
|
copyCode: 'Copy code',
|
|
210
|
+
calloutCaution: 'Caution',
|
|
211
|
+
calloutDanger: 'Danger',
|
|
212
|
+
calloutImportant: 'Important',
|
|
213
|
+
calloutNote: 'Note',
|
|
214
|
+
calloutTip: 'Tip',
|
|
215
|
+
calloutWarning: 'Warning',
|
|
216
|
+
editSource: 'Edit this page',
|
|
195
217
|
displaySettings: 'Display',
|
|
196
218
|
focusReading: 'Focus reading',
|
|
219
|
+
footnoteBackReference: 'Back to reference {reference}',
|
|
220
|
+
footnotes: 'Footnotes',
|
|
197
221
|
readingWidth: 'Reading width',
|
|
198
222
|
readingWidthNarrow: 'Narrow',
|
|
199
223
|
readingWidthStandard: 'Standard',
|
|
@@ -203,14 +227,36 @@ const localeLabels = Object.freeze({
|
|
|
203
227
|
built: 'Built',
|
|
204
228
|
images: 'Images',
|
|
205
229
|
imageCarousel: 'image carousel',
|
|
230
|
+
navigationCollapsedAll: 'All navigation items collapsed.',
|
|
231
|
+
navigationCollapseAll: 'Collapse all',
|
|
232
|
+
navigationControls: 'Navigation tree controls',
|
|
206
233
|
navigationChildren: 'Child pages',
|
|
234
|
+
navigationExpandedAll: 'All navigation items expanded.',
|
|
235
|
+
navigationExpandAll: 'Expand all',
|
|
236
|
+
navigationFilter: 'Filter pages and groups',
|
|
237
|
+
navigationFilterEmpty: 'No matching pages or groups. The current page remains available.',
|
|
238
|
+
navigationFilterMatches: 'Matching navigation items: {count}',
|
|
239
|
+
navigationLocatedCurrent: 'Current page located.',
|
|
240
|
+
navigationLocateCurrent: 'Locate current page',
|
|
207
241
|
navigationMenu: 'Menu',
|
|
208
242
|
nextImage: 'Next image',
|
|
243
|
+
nextPage: 'Next page',
|
|
209
244
|
note: 'Note',
|
|
245
|
+
notFound: 'Page not found',
|
|
246
|
+
notFoundText: 'The requested page does not exist or may have moved.',
|
|
210
247
|
pageMoved: 'Page moved',
|
|
211
248
|
pageMovedText: 'This address now identifies',
|
|
212
249
|
pageNavigation: 'Page contents',
|
|
250
|
+
pageSequence: 'Page sequence',
|
|
251
|
+
pageSections: 'Sections',
|
|
213
252
|
previousImage: 'Previous image',
|
|
253
|
+
previousPage: 'Previous page',
|
|
254
|
+
returnHome: 'Go to the homepage',
|
|
255
|
+
search: 'Search',
|
|
256
|
+
searchDescription: 'Search the published content on this site.',
|
|
257
|
+
searchLoading: 'Loading search…',
|
|
258
|
+
searchNoScript: 'Search requires JavaScript. Use the page navigation when JavaScript is unavailable.',
|
|
259
|
+
searchUnavailable: 'Search is unavailable. During local work, run norna build:local to create or refresh the search index.',
|
|
214
260
|
siteBanners: 'Site notices',
|
|
215
261
|
siteNavigation: 'Pages',
|
|
216
262
|
skipToContent: 'Skip to content',
|
|
@@ -224,8 +270,17 @@ const localeLabels = Object.freeze({
|
|
|
224
270
|
codeCopied: 'Kopierat',
|
|
225
271
|
codeCopyFailed: 'Kunde inte kopiera koden',
|
|
226
272
|
copyCode: 'Kopiera kod',
|
|
273
|
+
calloutCaution: 'Var försiktig',
|
|
274
|
+
calloutDanger: 'Fara',
|
|
275
|
+
calloutImportant: 'Viktigt',
|
|
276
|
+
calloutNote: 'Notera',
|
|
277
|
+
calloutTip: 'Tips',
|
|
278
|
+
calloutWarning: 'Varning',
|
|
279
|
+
editSource: 'Redigera den här sidan',
|
|
227
280
|
displaySettings: 'Visning',
|
|
228
281
|
focusReading: 'Fokuserad läsning',
|
|
282
|
+
footnoteBackReference: 'Tillbaka till referens {reference}',
|
|
283
|
+
footnotes: 'Fotnoter',
|
|
229
284
|
readingWidth: 'Textbredd',
|
|
230
285
|
readingWidthNarrow: 'Smal',
|
|
231
286
|
readingWidthStandard: 'Standard',
|
|
@@ -235,14 +290,36 @@ const localeLabels = Object.freeze({
|
|
|
235
290
|
built: 'Byggd',
|
|
236
291
|
images: 'Bilder',
|
|
237
292
|
imageCarousel: 'bildkarusell',
|
|
293
|
+
navigationCollapsedAll: 'Alla navigationsposter har fällts ihop.',
|
|
294
|
+
navigationCollapseAll: 'Fäll ihop alla',
|
|
295
|
+
navigationControls: 'Kontroller för navigationsträdet',
|
|
238
296
|
navigationChildren: 'Undersidor',
|
|
297
|
+
navigationExpandedAll: 'Alla navigationsposter har fällts ut.',
|
|
298
|
+
navigationExpandAll: 'Fäll ut alla',
|
|
299
|
+
navigationFilter: 'Filtrera sidor och grupper',
|
|
300
|
+
navigationFilterEmpty: 'Inga sidor eller grupper matchar. Den aktuella sidan är fortfarande tillgänglig.',
|
|
301
|
+
navigationFilterMatches: 'Matchande navigationsposter: {count}',
|
|
302
|
+
navigationLocatedCurrent: 'Den aktuella sidan har hittats.',
|
|
303
|
+
navigationLocateCurrent: 'Hitta aktuell sida',
|
|
239
304
|
navigationMenu: 'Meny',
|
|
240
305
|
nextImage: 'Nästa bild',
|
|
306
|
+
nextPage: 'Nästa sida',
|
|
241
307
|
note: 'Not',
|
|
308
|
+
notFound: 'Sidan hittades inte',
|
|
309
|
+
notFoundText: 'Den begärda sidan finns inte eller kan ha flyttats.',
|
|
242
310
|
pageMoved: 'Sidan har flyttats',
|
|
243
311
|
pageMovedText: 'Den här adressen identifierar nu',
|
|
244
312
|
pageNavigation: 'Sidinnehåll',
|
|
313
|
+
pageSequence: 'Sidföljd',
|
|
314
|
+
pageSections: 'Avsnitt',
|
|
245
315
|
previousImage: 'Föregående bild',
|
|
316
|
+
previousPage: 'Föregående sida',
|
|
317
|
+
returnHome: 'Gå till startsidan',
|
|
318
|
+
search: 'Sök',
|
|
319
|
+
searchDescription: 'Sök i det publicerade innehållet på webbplatsen.',
|
|
320
|
+
searchLoading: 'Laddar sökning…',
|
|
321
|
+
searchNoScript: 'Sökning kräver JavaScript. Använd sidnavigeringen när JavaScript inte är tillgängligt.',
|
|
322
|
+
searchUnavailable: 'Sökningen är inte tillgänglig. Kör norna build:local under lokalt arbete för att skapa eller uppdatera sökindexet.',
|
|
246
323
|
siteBanners: 'Meddelanden',
|
|
247
324
|
siteNavigation: 'Sidor',
|
|
248
325
|
skipToContent: 'Hoppa till innehållet',
|
|
@@ -411,10 +488,13 @@ export const resolveThemeVisualConfig = (theme, sourceLabel = siteThemeLabel) =>
|
|
|
411
488
|
presentation: imagePresentation,
|
|
412
489
|
...(imagePresentation === 'centered-fit'
|
|
413
490
|
? {
|
|
414
|
-
maxAvailableHeightPercent: readResponsivePercent(
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
491
|
+
maxAvailableHeightPercent: readResponsivePercent(
|
|
492
|
+
rawImagesConfig,
|
|
493
|
+
'maxAvailableHeightPercent',
|
|
494
|
+
'images',
|
|
495
|
+
defaultImageMaxAvailableHeightPercent,
|
|
496
|
+
sourceLabel,
|
|
497
|
+
),
|
|
418
498
|
}
|
|
419
499
|
: {}),
|
|
420
500
|
maxAvailableWidthPercent: readResponsivePercent(rawImagesConfig, 'maxAvailableWidthPercent', 'images', Object.freeze({
|
|
@@ -457,12 +537,38 @@ export const resolveNavigationConfig = (config, sourceLabel = siteConfigLabel) =
|
|
|
457
537
|
});
|
|
458
538
|
};
|
|
459
539
|
|
|
540
|
+
export const resolveEditLinkConfig = (config, sourceLabel = siteConfigLabel) => {
|
|
541
|
+
if (config.editLink === undefined) return null;
|
|
542
|
+
|
|
543
|
+
const rawEditLink = assertObject(config.editLink, 'editLink', sourceLabel);
|
|
544
|
+
if (!Object.hasOwn(rawEditLink, 'baseUrl')) {
|
|
545
|
+
throw new Error(`editLink.baseUrl is required when editLink is configured in ${sourceLabel}.`);
|
|
546
|
+
}
|
|
547
|
+
const unknownKeys = Object.keys(rawEditLink).filter((key) => key !== 'baseUrl');
|
|
548
|
+
if (unknownKeys.length > 0) {
|
|
549
|
+
throw new Error(`editLink.${unknownKeys[0]} is not a valid setting in ${sourceLabel}.`);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
try {
|
|
553
|
+
return Object.freeze({
|
|
554
|
+
baseUrl: normalizeEditLinkBaseUrl(rawEditLink.baseUrl, 'editLink.baseUrl'),
|
|
555
|
+
});
|
|
556
|
+
} catch (error) {
|
|
557
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
558
|
+
throw new Error(`${message.replace(/\.$/, '')} in ${sourceLabel}.`);
|
|
559
|
+
}
|
|
560
|
+
};
|
|
561
|
+
|
|
460
562
|
export const projectConfig = Object.freeze({
|
|
461
563
|
site: Object.freeze({
|
|
462
564
|
basePath: siteUrl.pathname,
|
|
463
565
|
url: siteUrl.href,
|
|
464
566
|
}),
|
|
465
567
|
...resolveThemeVisualConfig(rawTheme, siteThemeLabel),
|
|
568
|
+
editLink: resolveEditLinkConfig(rawConfig, siteConfigLabel),
|
|
569
|
+
search: Object.freeze({
|
|
570
|
+
enabled: readBoolean(rawConfig, 'search', 'config YAML', false),
|
|
571
|
+
}),
|
|
466
572
|
navigation: Object.freeze({
|
|
467
573
|
...resolveNavigationConfig(rawConfig, siteConfigLabel),
|
|
468
574
|
scrollBehavior: readEnum(
|
|
@@ -18,10 +18,22 @@ export const browserIconAssetFilenames = Object.freeze(
|
|
|
18
18
|
browserIconAssetDefinitions.map(({ filename }) => filename),
|
|
19
19
|
);
|
|
20
20
|
|
|
21
|
+
export const socialImageAssetDefinitions = Object.freeze([
|
|
22
|
+
Object.freeze({ filename: 'social-image.png', type: 'image/png' }),
|
|
23
|
+
Object.freeze({ filename: 'social-image.jpg', type: 'image/jpeg' }),
|
|
24
|
+
Object.freeze({ filename: 'social-image.jpeg', type: 'image/jpeg' }),
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
export const socialImageAssetFilenames = Object.freeze(
|
|
28
|
+
socialImageAssetDefinitions.map(({ filename }) => filename),
|
|
29
|
+
);
|
|
30
|
+
|
|
21
31
|
const logoExtensions = new Set(['.svg', '.png', '.jpg', '.jpeg']);
|
|
22
32
|
const faviconExtensions = new Set(['.svg', '.png', '.ico']);
|
|
33
|
+
const socialImageExtensions = new Set(['.png', '.jpg', '.jpeg']);
|
|
23
34
|
const exactLogoNames = new Set(logoAssetFilenames);
|
|
24
35
|
const exactBrowserIconNames = new Set(browserIconAssetFilenames);
|
|
36
|
+
const exactSocialImageNames = new Set(socialImageAssetFilenames);
|
|
25
37
|
|
|
26
38
|
export const isLogoAssetFilename = (filename) => exactLogoNames.has(filename);
|
|
27
39
|
|
|
@@ -60,6 +72,13 @@ const getSuspiciousAsset = (filename) => {
|
|
|
60
72
|
};
|
|
61
73
|
}
|
|
62
74
|
|
|
75
|
+
if (exactSocialImageNames.has(lowerFilename) && filename !== lowerFilename) {
|
|
76
|
+
return {
|
|
77
|
+
code: 'public-asset-case',
|
|
78
|
+
message: `Norna does not recognize "${filename}" as the social sharing image on case-sensitive file systems. Rename it to "${lowerFilename}".`,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
63
82
|
if (parsed.name === 'logo' && !logoExtensions.has(parsed.ext)) {
|
|
64
83
|
return {
|
|
65
84
|
code: 'unsupported-logo-file',
|
|
@@ -74,6 +93,13 @@ const getSuspiciousAsset = (filename) => {
|
|
|
74
93
|
};
|
|
75
94
|
}
|
|
76
95
|
|
|
96
|
+
if (parsed.name === 'social-image' && !socialImageExtensions.has(parsed.ext)) {
|
|
97
|
+
return {
|
|
98
|
+
code: 'unsupported-social-image-file',
|
|
99
|
+
message: `Norna does not recognize "${filename}" as the social sharing image. Use exactly one of ${socialImageAssetFilenames.join(', ')}.`,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
77
103
|
if (/^favicon[-_.]/.test(lowerFilename) && !exactBrowserIconNames.has(lowerFilename)) {
|
|
78
104
|
return {
|
|
79
105
|
code: 'unrecognized-favicon-file',
|
|
@@ -95,6 +121,13 @@ const getSuspiciousAsset = (filename) => {
|
|
|
95
121
|
};
|
|
96
122
|
}
|
|
97
123
|
|
|
124
|
+
if (socialImageExtensions.has(parsed.ext) && editDistance(parsed.name, 'social-image') <= 2) {
|
|
125
|
+
return {
|
|
126
|
+
code: 'possible-social-image-typo',
|
|
127
|
+
message: `"${filename}" looks like a misspelled social sharing image filename. Norna recognizes ${socialImageAssetFilenames.join(', ')}.`,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
98
131
|
return null;
|
|
99
132
|
};
|
|
100
133
|
|
|
@@ -102,11 +135,12 @@ export const inspectPublicAssetFilenames = (filenames) => {
|
|
|
102
135
|
const files = [...filenames].sort((left, right) => left.localeCompare(right, 'en'));
|
|
103
136
|
const logos = files.filter(isLogoAssetFilename);
|
|
104
137
|
const browserIcons = files.filter((filename) => exactBrowserIconNames.has(filename));
|
|
138
|
+
const socialImages = files.filter((filename) => exactSocialImageNames.has(filename));
|
|
105
139
|
const suspicious = files.flatMap((filename) => {
|
|
106
|
-
if (isLogoAssetFilename(filename) || exactBrowserIconNames.has(filename)) return [];
|
|
140
|
+
if (isLogoAssetFilename(filename) || exactBrowserIconNames.has(filename) || exactSocialImageNames.has(filename)) return [];
|
|
107
141
|
const issue = getSuspiciousAsset(filename);
|
|
108
142
|
return issue ? [{ filename, ...issue }] : [];
|
|
109
143
|
});
|
|
110
144
|
|
|
111
|
-
return { browserIcons, files, logos, suspicious };
|
|
145
|
+
return { browserIcons, files, logos, socialImages, suspicious };
|
|
112
146
|
};
|
|
@@ -17,13 +17,13 @@ const textWidth = z.enum(['narrow', 'normal', 'wide']).describe('Maximum width o
|
|
|
17
17
|
const headingWeight = z.union([z.literal(400), z.literal(500), z.literal(600), z.literal(700)]).describe('CSS font weight.');
|
|
18
18
|
const typographyProfile = z.enum(['restrained', 'dense', 'reading', 'statement']).describe('Coordinated typography defaults. Omit this to use the selected preset.');
|
|
19
19
|
const themePreset = z.enum(themePresetNames).describe('Complete Norna visual preset. Start here and add overrides only when needed.');
|
|
20
|
-
const presentationPalette = z.enum(presentationPaletteNames).describe('Coordinated site color palette. Every palette provides light and dark variants. Omit this to use the selected preset.');
|
|
20
|
+
const presentationPalette = z.enum(presentationPaletteNames).describe('Coordinated site color palette. Every palette provides light and dark variants without changing the initial appearance. Omit this to use the selected preset.');
|
|
21
21
|
const themeAppearance = z.object({
|
|
22
|
-
default: z.enum(['system', 'light', 'dark']).optional().describe('Initial appearance.
|
|
22
|
+
default: z.enum(['system', 'light', 'dark']).optional().describe('Initial appearance. Omit this setting to follow the visitor\'s operating-system preference.'),
|
|
23
23
|
}).strict().refine(
|
|
24
24
|
(value) => value.default !== undefined,
|
|
25
25
|
'Specify default.',
|
|
26
|
-
).describe('Site-wide initial appearance.');
|
|
26
|
+
).describe('Site-wide initial appearance. The default is system.');
|
|
27
27
|
const readerControls = z.object({
|
|
28
28
|
appearance: z.boolean().optional().describe('Show an Appearance control that lets readers choose System, Light, or Dark.'),
|
|
29
29
|
focusReading: z.boolean().optional().describe('Show a Focus reading control that lets readers hide navigation, breadcrumbs, and the footer. Sites with tree navigation always show this control.'),
|
|
@@ -35,9 +35,24 @@ const spacingDensity = z.enum(['compact', 'normal', 'airy']).describe('Coordinat
|
|
|
35
35
|
const contentSpacing = z.enum(['compact', 'normal', 'spacious']).describe('Vertical spacing between page sections and structured content blocks.');
|
|
36
36
|
const backgroundPattern = z.enum(['uniform', 'alternating', 'accented']).describe('How coordinated backgrounds are assigned to H2 sections. Non-uniform patterns are unavailable with tree navigation.');
|
|
37
37
|
const cornerTreatment = z.enum(['square', 'rounded']).describe('Site-wide corner treatment for navigation, cards and framed content.');
|
|
38
|
-
const cardListWidth = z.enum(['text', 'narrow', 'normal', 'wide']).describe('Default maximum width for card lists. A width written in a
|
|
38
|
+
const cardListWidth = z.enum(['text', 'narrow', 'normal', 'wide']).describe('Default maximum width for card lists. A width written in a card-list block overrides this value.');
|
|
39
39
|
const imagePresentation = z.enum(imagePresentationNames).describe('How managed image stacks and carousels are placed on the page. Prose-aligned starts them at the body-text edge; centered-fit centers them and constrains them by available width and viewport height.');
|
|
40
|
-
const navigationMode = z.enum(navigationModeNames).describe('Site-wide navigation policy. Automatic
|
|
40
|
+
const navigationMode = z.enum(navigationModeNames).describe('Site-wide navigation policy. Automatic uses sections for one page, top navigation for a flat multi-page site, and a stable left rail throughout a hierarchical site.');
|
|
41
|
+
const editLink = z.object({
|
|
42
|
+
baseUrl: z.string().url().refine((value) => {
|
|
43
|
+
try {
|
|
44
|
+
const url = new URL(value);
|
|
45
|
+
return ['http:', 'https:'].includes(url.protocol)
|
|
46
|
+
&& !url.username
|
|
47
|
+
&& !url.password
|
|
48
|
+
&& !url.search
|
|
49
|
+
&& !url.hash;
|
|
50
|
+
} catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}, 'Use an absolute http or https URL without credentials, a query string, or a fragment.')
|
|
54
|
+
.describe('Absolute edit URL prefix containing the repository, branch, and any repository subdirectory.'),
|
|
55
|
+
}).strict().describe('Optional base URL for links from rendered pages to their Markdown source files.');
|
|
41
56
|
const createLineHeight = (minimum, role) => z.number()
|
|
42
57
|
.min(minimum, `Use a unitless ${role} line height of at least ${minimum}.`)
|
|
43
58
|
.max(3, `Use a unitless ${role} line height of at most 3.`)
|
|
@@ -222,7 +237,9 @@ const sitewideFooter = z.object({
|
|
|
222
237
|
const configShape = {
|
|
223
238
|
url: z.string().url().describe('Absolute public URL for the built site.'),
|
|
224
239
|
language: z.string().regex(/^(?:en|sv)(?:-[a-zA-Z0-9]+)*$/).optional().describe('Site language tag using Norna\'s English or Swedish interface text; the default is en.'),
|
|
240
|
+
editLink: editLink.optional(),
|
|
225
241
|
navigation: configNavigation.optional(),
|
|
242
|
+
search: z.boolean().optional().default(false).describe('Generate a static site search page and Pagefind index. The default is false.'),
|
|
226
243
|
scrollBehavior: z.enum(['instant', 'smooth']).optional().default('instant').describe('Use instant anchors by default or the browser\'s native smooth scrolling.'),
|
|
227
244
|
};
|
|
228
245
|
|
|
@@ -159,11 +159,26 @@ const addConfigHelp = (jsonSchema) => {
|
|
|
159
159
|
'Sets the page language and selects Norna\'s built-in English or Swedish interface text. The default is `en`.',
|
|
160
160
|
documentationLink('Language reference', 'configuration.md', 'language'),
|
|
161
161
|
], ['en', 'sv', 'en-GB', 'sv-SE']);
|
|
162
|
+
addHelp(jsonSchema, 'editLink', [
|
|
163
|
+
yamlExample('editLink:\n baseUrl: https://github.com/owner/repository/edit/main/'),
|
|
164
|
+
'Adds a localized **Edit this page** link from each rendered page to its `content.md` source file. The base URL identifies the repository, branch, and any repository subdirectory; Norna appends the page source path.',
|
|
165
|
+
documentationLink('Edit-link reference', 'configuration.md', 'edit-link'),
|
|
166
|
+
]);
|
|
167
|
+
addHelp(jsonSchema, 'editLink.baseUrl', [
|
|
168
|
+
yamlExample('editLink:\n baseUrl: https://github.com/owner/repository/edit/main/'),
|
|
169
|
+
'Absolute edit URL prefix. Include any repository subdirectory and branch in this URL. Norna adds the project-relative path to each page\'s `content.md` file.',
|
|
170
|
+
documentationLink('Edit-link reference', 'configuration.md', 'edit-link'),
|
|
171
|
+
], ['https://github.com/owner/repository/edit/main/']);
|
|
162
172
|
addHelp(jsonSchema, 'navigation', [
|
|
163
173
|
yamlExample('navigation:\n mode: automatic'),
|
|
164
|
-
'Sets the site-wide navigation policy. `automatic`
|
|
174
|
+
'Sets the site-wide navigation policy. `automatic` uses sections for one page, top navigation for a flat multi-page site, and a stable left rail throughout a hierarchical site.',
|
|
165
175
|
documentationLink('Navigation reference', 'pages.md', 'navigation'),
|
|
166
176
|
]);
|
|
177
|
+
addHelp(jsonSchema, 'search', [
|
|
178
|
+
yamlExample('search: true'),
|
|
179
|
+
'Generates a localized `/search/` page and a static Pagefind index during each build. Search is off by default, and only the search page loads the search JavaScript.',
|
|
180
|
+
documentationLink('Search reference', 'configuration.md', 'search'),
|
|
181
|
+
], [true, false]);
|
|
167
182
|
addFieldHelp(
|
|
168
183
|
jsonSchema,
|
|
169
184
|
'navigation.mode',
|
|
@@ -212,7 +227,7 @@ const addThemeHelp = (jsonSchema) => {
|
|
|
212
227
|
]);
|
|
213
228
|
addHelp(jsonSchema, 'blocks.cardList', [
|
|
214
229
|
yamlExample('blocks:\n cardList:\n width: text'),
|
|
215
|
-
'Sets site-wide defaults for card lists. A `width` option in an individual `
|
|
230
|
+
'Sets site-wide defaults for card lists. A `width` option in an individual `card-list` block overrides this setting.',
|
|
216
231
|
documentationLink('Content block defaults', 'theme.md', 'content-block-defaults'),
|
|
217
232
|
]);
|
|
218
233
|
addFieldHelp(
|
|
@@ -241,12 +256,12 @@ const addThemeHelp = (jsonSchema) => {
|
|
|
241
256
|
]);
|
|
242
257
|
addHelp(jsonSchema, 'palette', [
|
|
243
258
|
yamlExample('palette: warm-paper'),
|
|
244
|
-
'Chooses the site color character. Every palette provides coordinated light and dark variants.',
|
|
259
|
+
'Chooses the site color character. Every palette provides coordinated light and dark variants without changing the initial Appearance.',
|
|
245
260
|
documentationLink('Palette and appearance', 'theme.md', 'palette-and-appearance'),
|
|
246
261
|
], presentationPaletteNames);
|
|
247
262
|
addHelp(jsonSchema, 'appearance', [
|
|
248
263
|
yamlExample('appearance:\n default: system'),
|
|
249
|
-
'Selects the initial
|
|
264
|
+
'Selects the initial Appearance. Omit this setting to follow the visitor\'s system preference. Enable the reader-facing choice under readerControls.',
|
|
250
265
|
documentationLink('Appearance', 'theme.md', 'appearance'),
|
|
251
266
|
]);
|
|
252
267
|
addFieldHelp(jsonSchema, 'appearance.default', 'appearance:\n default: system', 'theme.md', 'appearance', ['system', 'light', 'dark']);
|
|
@@ -23,7 +23,7 @@ export const schemaValueDefinitions = Object.freeze([
|
|
|
23
23
|
smooth: option('Browser smooth', 'Use the browser\'s native smooth scrolling for anchor navigation.'),
|
|
24
24
|
}),
|
|
25
25
|
definition(['automatic', 'sections', 'top', 'tree'], {
|
|
26
|
-
automatic: option('Automatic', 'Choose sections for one page, top navigation for
|
|
26
|
+
automatic: option('Automatic', 'Choose sections for one page, top navigation for a flat multi-page site, and a stable left rail throughout a site with listed child pages or categories.'),
|
|
27
27
|
sections: option('Sections', 'Navigate the sections of the current page.'),
|
|
28
28
|
top: option('Top', 'Use pages in the top navigation and sections below them.'),
|
|
29
29
|
tree: option('Tree', 'Use a left page rail and a separate right H2/H3 contents rail where the page has enough headings.'),
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
export const semanticCalloutTypes = Object.freeze([
|
|
2
|
+
'NOTE',
|
|
3
|
+
'TIP',
|
|
4
|
+
'IMPORTANT',
|
|
5
|
+
'WARNING',
|
|
6
|
+
'CAUTION',
|
|
7
|
+
'DANGER',
|
|
8
|
+
]);
|
|
9
|
+
|
|
10
|
+
const semanticCalloutTypeSet = new Set(semanticCalloutTypes);
|
|
11
|
+
const markerPattern = /^\[!([A-Za-z][A-Za-z0-9-]*)\]([^\n]*)(?:\n|$)/;
|
|
12
|
+
|
|
13
|
+
const hasMeaningfulContent = (node) => {
|
|
14
|
+
if (!node || typeof node !== 'object') return false;
|
|
15
|
+
if ('value' in node && typeof node.value === 'string') return node.value.trim().length > 0;
|
|
16
|
+
if (node.type === 'image') return Boolean(node.alt);
|
|
17
|
+
return Array.isArray(node.children) && node.children.some(hasMeaningfulContent);
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const getSemanticCalloutMarker = (node) => {
|
|
21
|
+
if (node?.type !== 'blockquote') return null;
|
|
22
|
+
const firstParagraph = node.children?.[0];
|
|
23
|
+
const firstText = firstParagraph?.type === 'paragraph' ? firstParagraph.children?.[0] : null;
|
|
24
|
+
if (firstText?.type !== 'text') return null;
|
|
25
|
+
|
|
26
|
+
const match = firstText.value.match(markerPattern);
|
|
27
|
+
if (!match) {
|
|
28
|
+
return firstText.value.startsWith('[!')
|
|
29
|
+
? { malformed: true, firstParagraph, firstText }
|
|
30
|
+
: null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const rawType = match[1] ?? '';
|
|
34
|
+
const type = rawType.toUpperCase();
|
|
35
|
+
const markerSource = match[0];
|
|
36
|
+
const title = (match[2] ?? '').trim();
|
|
37
|
+
const firstTextRemainder = firstText.value.slice(markerSource.length);
|
|
38
|
+
const firstParagraphRemainder = [
|
|
39
|
+
{ ...firstText, value: firstTextRemainder },
|
|
40
|
+
...(firstParagraph.children?.slice(1) ?? []),
|
|
41
|
+
];
|
|
42
|
+
const hasBody = firstParagraphRemainder.some(hasMeaningfulContent)
|
|
43
|
+
|| (node.children?.slice(1) ?? []).some(hasMeaningfulContent);
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
firstParagraph,
|
|
47
|
+
firstText,
|
|
48
|
+
firstTextRemainder,
|
|
49
|
+
hasBody,
|
|
50
|
+
malformed: false,
|
|
51
|
+
markerSource,
|
|
52
|
+
rawType,
|
|
53
|
+
supported: semanticCalloutTypeSet.has(type),
|
|
54
|
+
title,
|
|
55
|
+
type,
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const formatDiagnostic = ({ code, fix, label, line, message, offset }) => ({
|
|
60
|
+
code,
|
|
61
|
+
fix,
|
|
62
|
+
line,
|
|
63
|
+
message: `${label} line ${line}: ${message}`,
|
|
64
|
+
offset,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
export const getSemanticCalloutDiagnostics = (tree, options = {}) => {
|
|
68
|
+
const diagnostics = [];
|
|
69
|
+
const label = options.label ?? 'Markdown';
|
|
70
|
+
const lineOffset = options.lineOffset ?? 0;
|
|
71
|
+
|
|
72
|
+
const visit = (node, blockquoteDepth = 0) => {
|
|
73
|
+
if (!node || typeof node !== 'object') return;
|
|
74
|
+
const nextBlockquoteDepth = blockquoteDepth + (node.type === 'blockquote' ? 1 : 0);
|
|
75
|
+
const marker = getSemanticCalloutMarker(node);
|
|
76
|
+
|
|
77
|
+
if (marker) {
|
|
78
|
+
const line = lineOffset + (node.position?.start.line ?? 1);
|
|
79
|
+
const offset = node.position?.start.offset ?? 0;
|
|
80
|
+
let issue = null;
|
|
81
|
+
|
|
82
|
+
if (blockquoteDepth > 0) {
|
|
83
|
+
issue = {
|
|
84
|
+
code: 'nested-semantic-callout',
|
|
85
|
+
message: 'Semantic callouts cannot be nested inside another blockquote or callout.',
|
|
86
|
+
fix: 'Move the inner callout after the surrounding blockquote.',
|
|
87
|
+
};
|
|
88
|
+
} else if (marker.malformed) {
|
|
89
|
+
issue = {
|
|
90
|
+
code: 'invalid-semantic-callout-marker',
|
|
91
|
+
message: 'This blockquote starts like a semantic callout but its marker is incomplete.',
|
|
92
|
+
fix: 'Write the marker on its own first line, for example "> [!WARNING]".',
|
|
93
|
+
};
|
|
94
|
+
} else if (marker.rawType !== marker.type) {
|
|
95
|
+
issue = {
|
|
96
|
+
code: 'invalid-semantic-callout-type-case',
|
|
97
|
+
message: `Semantic callout type "${marker.rawType}" must use uppercase ASCII letters.`,
|
|
98
|
+
fix: `Write "> [!${marker.type}]".`,
|
|
99
|
+
};
|
|
100
|
+
} else if (!marker.supported) {
|
|
101
|
+
issue = {
|
|
102
|
+
code: 'unknown-semantic-callout-type',
|
|
103
|
+
message: `Unknown semantic callout type "${marker.rawType}".`,
|
|
104
|
+
fix: `Use one of: ${semanticCalloutTypes.join(', ')}.`,
|
|
105
|
+
};
|
|
106
|
+
} else if (marker.title) {
|
|
107
|
+
issue = {
|
|
108
|
+
code: 'unsupported-semantic-callout-title',
|
|
109
|
+
message: `Semantic callout markers cannot contain a custom title ("${marker.title}").`,
|
|
110
|
+
fix: `Keep "> [!${marker.type}]" on its own line. Begin the callout body with bold text when local emphasis is useful.`,
|
|
111
|
+
};
|
|
112
|
+
} else if (!marker.hasBody) {
|
|
113
|
+
issue = {
|
|
114
|
+
code: 'empty-semantic-callout',
|
|
115
|
+
message: `${marker.type} callout has no content.`,
|
|
116
|
+
fix: 'Add at least one quoted content line after the marker.',
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (issue) diagnostics.push(formatDiagnostic({ ...issue, label, line, offset }));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
for (const child of node.children ?? []) visit(child, nextBlockquoteDepth);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
visit(tree);
|
|
127
|
+
return diagnostics;
|
|
128
|
+
};
|
|
@@ -30,6 +30,7 @@ const knownNestedFrontmatterKeys = new Set([
|
|
|
30
30
|
'align',
|
|
31
31
|
'aliases',
|
|
32
32
|
'alt',
|
|
33
|
+
'baseUrl',
|
|
33
34
|
'body',
|
|
34
35
|
'blockGap',
|
|
35
36
|
'caption',
|
|
@@ -152,7 +153,7 @@ export const validateFrontmatterStructure = (frontmatter, addIssue, {
|
|
|
152
153
|
} else if (fileKind === 'content' && key === 'description') {
|
|
153
154
|
fix = 'Indent "description:" under "page:". The optional meta description belongs in the page object.';
|
|
154
155
|
} else if (fileKind === 'content' && key === 'images') {
|
|
155
|
-
fix = 'Put local image references in
|
|
156
|
+
fix = 'Put local image references in image-stack or image-carousel blocks in the Markdown body.';
|
|
156
157
|
} else if (fileKind === 'content' && knownThemeTopLevelFrontmatterKeys.has(key)) {
|
|
157
158
|
fix = `Move "${key}:" to theme.yaml. Visual settings do not belong in content frontmatter.`;
|
|
158
159
|
} else if ((fileKind === 'theme' || fileKind === 'page theme') && key === 'navigation') {
|