@janga/norna 0.7.22 → 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 +15 -0
- package/bin/norna-cli.mjs +8 -2
- package/package.json +28 -3
- package/schemas/category.schema.json +2 -2
- package/schemas/config.schema.json +52 -24
- package/schemas/content-frontmatter.schema.json +25 -5
- package/schemas/page-theme.schema.json +53 -25
- package/schemas/sitewide-content.schema.json +19 -18
- package/schemas/theme.schema.json +385 -252
- package/scripts/build-site.mjs +1 -0
- package/scripts/check-config.mjs +36 -5
- package/scripts/deploy-site.mjs +0 -1
- package/scripts/dev-local.mjs +175 -112
- 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/content-sync-apply.mjs +34 -0
- package/scripts/lib/content-sync-plan.mjs +179 -0
- package/scripts/lib/edit-source-link.mjs +49 -0
- package/scripts/lib/editor-language-service.mjs +160 -12
- package/scripts/lib/heading-ids.mjs +1 -1
- package/scripts/lib/image-presentation.mjs +6 -0
- package/scripts/lib/markdown-links.mjs +182 -0
- package/scripts/lib/navigation-model.mjs +44 -12
- package/scripts/lib/navigation-review.mjs +396 -0
- package/scripts/lib/norna-markdown-blocks.mjs +74 -26
- package/scripts/lib/norna-markdown-render-plugin.mjs +64 -1
- package/scripts/lib/page-aliases.mjs +184 -0
- package/scripts/lib/page-markdown.mjs +88 -7
- package/scripts/lib/page-move-plan.mjs +659 -0
- package/scripts/lib/presentation-palette-metadata.mjs +1 -1
- package/scripts/lib/presentation.mjs +10 -37
- package/scripts/lib/project-config.mjs +146 -19
- package/scripts/lib/public-asset-conventions.mjs +36 -2
- package/scripts/lib/schema-definitions.mjs +43 -15
- package/scripts/lib/schema-editor-metadata.mjs +60 -32
- package/scripts/lib/schema-value-definitions.mjs +6 -2
- package/scripts/lib/semantic-callouts.mjs +128 -0
- package/scripts/lib/site-content.mjs +28 -5
- package/scripts/lib/site-link-graph.mjs +357 -0
- package/scripts/lib/site-navigation-tree.mjs +85 -0
- package/scripts/lib/site-page-urls.mjs +10 -0
- package/scripts/lib/sitemap.mjs +34 -0
- package/scripts/lib/social-image-assets.mjs +30 -0
- package/scripts/lib/theme-presets.mjs +53 -25
- package/scripts/lib/theme-profiles.mjs +6 -8
- package/scripts/lib/yaml-config.mjs +6 -2
- package/scripts/move-site-page.mjs +256 -0
- package/scripts/review-navigation.mjs +32 -0
- package/scripts/sync-content-sections.mjs +182 -216
- package/scripts/sync-site-public.mjs +43 -3
- package/src/components/CodeBlockCopyScript.astro +97 -0
- package/src/components/DisplaySettings.astro +4 -4
- package/src/components/EditSourceLink.astro +17 -0
- package/src/components/ImageCarousel.astro +6 -3
- package/src/components/NavigationPageTree.astro +81 -86
- package/src/components/NavigationTreeControls.astro +77 -0
- package/src/components/PageAliasRedirect.astro +49 -0
- package/src/components/PageContentsNavigation.astro +21 -0
- package/src/components/PageList.astro +33 -0
- package/src/components/PageSequenceNavigation.astro +37 -0
- package/src/components/SearchPage.astro +151 -0
- package/src/components/SectionNavigationScript.astro +62 -6
- package/src/components/SiteNavigation.astro +50 -11
- package/src/components/SitePage.astro +113 -20
- package/src/components/SiteSection.astro +16 -3
- package/src/components/SiteTreeNavigation.astro +8 -2
- package/src/components/TreeNavigationScript.astro +207 -26
- package/src/content.config.ts +5 -2
- package/src/layouts/BaseLayout.astro +71 -10
- package/src/lib/readerPreferencesScript.mjs +2 -2
- package/src/lib/sectionContent.ts +32 -3
- package/src/lib/siteNavigation.ts +31 -52
- package/src/lib/sitePages.ts +3 -2
- package/src/lib/sitePublicAssets.ts +1 -0
- package/src/pages/404.astro +110 -0
- package/src/pages/[...slug].astro +45 -8
- package/src/styles/content.css +718 -0
- package/src/styles/foundations.css +284 -0
- package/src/styles/global.css +6 -2510
- package/src/styles/media.css +518 -0
- package/src/styles/navigation.css +526 -0
- package/src/styles/page-layout.css +786 -0
- package/src/styles/responsive.css +594 -0
- 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
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'astro/zod';
|
|
2
|
+
import { imagePresentationNames } from './image-presentation.mjs';
|
|
2
3
|
import { navigationModeNames } from './navigation-model.mjs';
|
|
3
4
|
import { presentationPaletteNames } from './presentation-palette-metadata.mjs';
|
|
4
5
|
import { themePresetNames } from './theme-presets.mjs';
|
|
@@ -16,26 +17,42 @@ const textWidth = z.enum(['narrow', 'normal', 'wide']).describe('Maximum width o
|
|
|
16
17
|
const headingWeight = z.union([z.literal(400), z.literal(500), z.literal(600), z.literal(700)]).describe('CSS font weight.');
|
|
17
18
|
const typographyProfile = z.enum(['restrained', 'dense', 'reading', 'statement']).describe('Coordinated typography defaults. Omit this to use the selected preset.');
|
|
18
19
|
const themePreset = z.enum(themePresetNames).describe('Complete Norna visual preset. Start here and add overrides only when needed.');
|
|
19
|
-
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
|
|
21
|
-
default: z.enum(['system', 'light', 'dark']).optional().describe('Initial
|
|
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
|
+
const themeAppearance = z.object({
|
|
22
|
+
default: z.enum(['system', 'light', 'dark']).optional().describe('Initial appearance. Omit this setting to follow the visitor\'s operating-system preference.'),
|
|
22
23
|
}).strict().refine(
|
|
23
24
|
(value) => value.default !== undefined,
|
|
24
25
|
'Specify default.',
|
|
25
|
-
).describe('Site-wide
|
|
26
|
+
).describe('Site-wide initial appearance. The default is system.');
|
|
26
27
|
const readerControls = z.object({
|
|
27
|
-
|
|
28
|
-
focusReading: z.boolean().optional().describe('
|
|
28
|
+
appearance: z.boolean().optional().describe('Show an Appearance control that lets readers choose System, Light, or Dark.'),
|
|
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.'),
|
|
29
30
|
}).strict().refine(
|
|
30
|
-
(value) => value.
|
|
31
|
-
'Specify
|
|
32
|
-
).describe('Optional
|
|
31
|
+
(value) => value.appearance !== undefined || value.focusReading !== undefined,
|
|
32
|
+
'Specify appearance, focusReading, or both.',
|
|
33
|
+
).describe('Optional controls for the site-wide Display panel.');
|
|
33
34
|
const spacingDensity = z.enum(['compact', 'normal', 'airy']).describe('Coordinated spacing density. Omit this to use the selected preset.');
|
|
34
35
|
const contentSpacing = z.enum(['compact', 'normal', 'spacious']).describe('Vertical spacing between page sections and structured content blocks.');
|
|
35
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.');
|
|
36
37
|
const cornerTreatment = z.enum(['square', 'rounded']).describe('Site-wide corner treatment for navigation, cards and framed content.');
|
|
37
|
-
const cardListWidth = z.enum(['text', 'narrow', 'normal', 'wide']).describe('Default maximum width for card lists. A width written in a
|
|
38
|
-
const
|
|
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
|
+
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 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.');
|
|
39
56
|
const createLineHeight = (minimum, role) => z.number()
|
|
40
57
|
.min(minimum, `Use a unitless ${role} line height of at least ${minimum}.`)
|
|
41
58
|
.max(3, `Use a unitless ${role} line height of at most 3.`)
|
|
@@ -156,10 +173,11 @@ const pageThemeLayout = z.object({
|
|
|
156
173
|
'Specify contentSpacing, textWidth, or both.',
|
|
157
174
|
).describe('Page-local layout settings inherited by descendant pages.');
|
|
158
175
|
const themeImages = z.object({
|
|
176
|
+
presentation: imagePresentation.optional().describe('Managed-image presentation method. Omit this to keep the selected preset or inherited page setting.'),
|
|
159
177
|
width: visualCssLength.optional().describe('Maximum managed-image width. Omit this to keep the selected preset.'),
|
|
160
178
|
maxAvailableWidthPercent: responsivePercent.optional().describe('Maximum percentage of available horizontal space used by managed images.'),
|
|
161
|
-
maxAvailableHeightPercent: responsivePercent.optional().describe('Maximum percentage of viewport height used by
|
|
162
|
-
}).strict().describe('Optional defaults for managed
|
|
179
|
+
maxAvailableHeightPercent: responsivePercent.optional().describe('Maximum percentage of viewport height used by centered-fit images. This setting is unavailable with prose-aligned presentation.'),
|
|
180
|
+
}).strict().describe('Optional defaults for placing managed images inside the page content area. Persistent navigation remains outside this area.');
|
|
163
181
|
const themeCardList = z.object({
|
|
164
182
|
width: cardListWidth,
|
|
165
183
|
}).strict().describe('Site-wide card-list defaults.');
|
|
@@ -171,7 +189,6 @@ const themeBlocks = z.object({
|
|
|
171
189
|
).describe('Optional site-wide defaults for structured Norna content blocks.');
|
|
172
190
|
const configNavigation = z.object({
|
|
173
191
|
mode: navigationMode.optional().describe('Navigation model. Omit this to let Norna choose from the site structure.'),
|
|
174
|
-
sectionTracking: z.boolean().optional().default(false).describe('As the reader scrolls in tree navigation, mark the last H2 or H3 that has reached the reading area below the sticky header. The URL and keyboard focus do not change.'),
|
|
175
192
|
}).strict().describe('Site-wide navigation behavior.');
|
|
176
193
|
const themeSections = z.object({
|
|
177
194
|
backgroundPattern: backgroundPattern.optional().describe('Section background pattern. Alternating and accented create full-width bands with sections or top navigation; tree navigation requires uniform.'),
|
|
@@ -185,8 +202,17 @@ const pageThemeSections = z.object({
|
|
|
185
202
|
const pageNavigation = z.object({
|
|
186
203
|
listed: z.boolean().optional().default(true).describe('List this page in site navigation. The page remains public when false.'),
|
|
187
204
|
}).strict();
|
|
205
|
+
const pageAlias = z.string().regex(
|
|
206
|
+
/^\/(?:[a-z0-9]+(?:-[a-z0-9]+)*\/)+$/,
|
|
207
|
+
'Use an old site-relative Norna page URL such as "/guides/install/". Start and end with / and use lowercase letters, numbers, and single hyphens.',
|
|
208
|
+
).describe('Previous site-relative URL that permanently identifies this page.');
|
|
188
209
|
const pageMetadata = z.object({
|
|
189
210
|
description: z.string().min(1).optional().describe('Optional page-specific meta description.'),
|
|
211
|
+
aliases: z.array(pageAlias)
|
|
212
|
+
.min(1)
|
|
213
|
+
.refine((values) => new Set(values).size === values.length, 'Page aliases must be unique within the page.')
|
|
214
|
+
.optional()
|
|
215
|
+
.describe('Previous site-relative URLs that permanently identify this page. The current page is always the target.'),
|
|
190
216
|
}).strict().describe('Optional metadata for this homepage or additional page. The Markdown H1 supplies the page title.');
|
|
191
217
|
const sitewideLogo = z.object({
|
|
192
218
|
height: visualCssLength.optional().describe('Displayed logo height on wider screens. Omit it to use 2.6rem; narrow screens cap the height at 2.15rem. Width follows the intrinsic aspect ratio.'),
|
|
@@ -211,7 +237,9 @@ const sitewideFooter = z.object({
|
|
|
211
237
|
const configShape = {
|
|
212
238
|
url: z.string().url().describe('Absolute public URL for the built site.'),
|
|
213
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(),
|
|
214
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.'),
|
|
215
243
|
scrollBehavior: z.enum(['instant', 'smooth']).optional().default('instant').describe('Use instant anchors by default or the browser\'s native smooth scrolling.'),
|
|
216
244
|
};
|
|
217
245
|
|
|
@@ -222,7 +250,7 @@ const siteShape = {
|
|
|
222
250
|
|
|
223
251
|
const themeVisualShape = {
|
|
224
252
|
preset: themePreset.optional().describe('Complete visual starting point. Add only the overrides the site actually needs.'),
|
|
225
|
-
|
|
253
|
+
appearance: themeAppearance.optional(),
|
|
226
254
|
readerControls: readerControls.optional(),
|
|
227
255
|
corners: cornerTreatment.optional().describe('Site-wide corner treatment. Omit this to use the selected preset.'),
|
|
228
256
|
layout: themeLayout.optional(),
|
|
@@ -33,6 +33,13 @@ const schemaProperty = (schema, propertyPath) => schemaProperties(schema, proper
|
|
|
33
33
|
const addHelp = (schema, propertyPath, paragraphs, examples = []) => {
|
|
34
34
|
for (const property of schemaProperties(schema, propertyPath)) {
|
|
35
35
|
property.markdownDescription = paragraphs.join('\n\n');
|
|
36
|
+
const reference = paragraphs.findLast((paragraph) => /^\[[^\]]+\]\(https:\/\//.test(paragraph));
|
|
37
|
+
if (reference && Array.isArray(property.oneOf)) {
|
|
38
|
+
for (const candidate of property.oneOf) {
|
|
39
|
+
if (!Object.hasOwn(candidate, 'const') || !candidate.description) continue;
|
|
40
|
+
candidate.markdownDescription = [candidate.description, reference].join('\n\n');
|
|
41
|
+
}
|
|
42
|
+
}
|
|
36
43
|
if (examples.length > 0) property.examples = examples;
|
|
37
44
|
}
|
|
38
45
|
};
|
|
@@ -152,11 +159,26 @@ const addConfigHelp = (jsonSchema) => {
|
|
|
152
159
|
'Sets the page language and selects Norna\'s built-in English or Swedish interface text. The default is `en`.',
|
|
153
160
|
documentationLink('Language reference', 'configuration.md', 'language'),
|
|
154
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/']);
|
|
155
172
|
addHelp(jsonSchema, 'navigation', [
|
|
156
173
|
yamlExample('navigation:\n mode: automatic'),
|
|
157
|
-
'Sets
|
|
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.',
|
|
158
175
|
documentationLink('Navigation reference', 'pages.md', 'navigation'),
|
|
159
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]);
|
|
160
182
|
addFieldHelp(
|
|
161
183
|
jsonSchema,
|
|
162
184
|
'navigation.mode',
|
|
@@ -165,14 +187,6 @@ const addConfigHelp = (jsonSchema) => {
|
|
|
165
187
|
'navigation',
|
|
166
188
|
['automatic', 'sections', 'top', 'tree'],
|
|
167
189
|
);
|
|
168
|
-
addFieldHelp(
|
|
169
|
-
jsonSchema,
|
|
170
|
-
'navigation.sectionTracking',
|
|
171
|
-
'navigation:\n sectionTracking: true',
|
|
172
|
-
'configuration.md',
|
|
173
|
-
'navigationsectiontracking',
|
|
174
|
-
[true, false],
|
|
175
|
-
);
|
|
176
190
|
addHelp(jsonSchema, 'scrollBehavior', [
|
|
177
191
|
yamlExample('scrollBehavior: smooth'),
|
|
178
192
|
'Controls same-page anchor movement. `instant` is the default; `smooth` uses the browser\'s native smooth scrolling.',
|
|
@@ -202,8 +216,8 @@ const addThemeHelp = (jsonSchema) => {
|
|
|
202
216
|
documentationLink('Layout reference', 'theme.md', 'layout'),
|
|
203
217
|
]);
|
|
204
218
|
addHelp(jsonSchema, 'images', [
|
|
205
|
-
yamlExample('images:\n
|
|
206
|
-
'Overrides the
|
|
219
|
+
yamlExample('images:\n presentation: prose-aligned\n width: 900px'),
|
|
220
|
+
'Overrides how Norna-managed image stacks and carousels align inside the page content area and how much space they may use. Persistent navigation remains outside this area.',
|
|
207
221
|
documentationLink('Image sizing reference', 'theme.md', 'image-sizing'),
|
|
208
222
|
]);
|
|
209
223
|
addHelp(jsonSchema, 'blocks', [
|
|
@@ -213,7 +227,7 @@ const addThemeHelp = (jsonSchema) => {
|
|
|
213
227
|
]);
|
|
214
228
|
addHelp(jsonSchema, 'blocks.cardList', [
|
|
215
229
|
yamlExample('blocks:\n cardList:\n width: text'),
|
|
216
|
-
'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.',
|
|
217
231
|
documentationLink('Content block defaults', 'theme.md', 'content-block-defaults'),
|
|
218
232
|
]);
|
|
219
233
|
addFieldHelp(
|
|
@@ -242,29 +256,31 @@ const addThemeHelp = (jsonSchema) => {
|
|
|
242
256
|
]);
|
|
243
257
|
addHelp(jsonSchema, 'palette', [
|
|
244
258
|
yamlExample('palette: warm-paper'),
|
|
245
|
-
'Chooses the site color character. Every palette provides coordinated light and dark variants.',
|
|
246
|
-
documentationLink('Palette and
|
|
259
|
+
'Chooses the site color character. Every palette provides coordinated light and dark variants without changing the initial Appearance.',
|
|
260
|
+
documentationLink('Palette and appearance', 'theme.md', 'palette-and-appearance'),
|
|
247
261
|
], presentationPaletteNames);
|
|
248
|
-
addHelp(jsonSchema, '
|
|
249
|
-
yamlExample('
|
|
250
|
-
'
|
|
251
|
-
documentationLink('
|
|
262
|
+
addHelp(jsonSchema, 'appearance', [
|
|
263
|
+
yamlExample('appearance:\n default: system'),
|
|
264
|
+
'Selects the initial Appearance. Omit this setting to follow the visitor\'s system preference. Enable the reader-facing choice under readerControls.',
|
|
265
|
+
documentationLink('Appearance', 'theme.md', 'appearance'),
|
|
252
266
|
]);
|
|
253
|
-
addFieldHelp(jsonSchema, '
|
|
254
|
-
addSnippets(jsonSchema, '
|
|
255
|
-
label: 'Set the initial
|
|
256
|
-
body: '
|
|
267
|
+
addFieldHelp(jsonSchema, 'appearance.default', 'appearance:\n default: system', 'theme.md', 'appearance', ['system', 'light', 'dark']);
|
|
268
|
+
addSnippets(jsonSchema, 'appearance', [schemaSnippet({
|
|
269
|
+
label: 'Set the initial appearance',
|
|
270
|
+
body: 'appearance:\n default: ${1:system}',
|
|
257
271
|
description: 'Follow the system color preference or choose a fixed initial appearance.',
|
|
258
272
|
file: 'theme.md',
|
|
259
|
-
anchor: '
|
|
273
|
+
anchor: 'appearance',
|
|
260
274
|
})]);
|
|
261
275
|
addHelp(jsonSchema, 'readerControls', [
|
|
262
|
-
yamlExample('readerControls:\n
|
|
263
|
-
'
|
|
276
|
+
yamlExample('readerControls:\n appearance: true\n focusReading: true'),
|
|
277
|
+
'Choose which optional controls readers can use in the site-wide Display panel.',
|
|
278
|
+
'`appearance: true` lets readers choose System, Light, or Dark. `focusReading: true` lets them hide navigation, breadcrumbs, and the footer while reading.',
|
|
279
|
+
'Reading width is always included. Sites with tree navigation always include Focus reading, even when it is not enabled here.',
|
|
264
280
|
documentationLink('Reader Display controls', 'theme.md', 'reader-display-controls'),
|
|
265
281
|
]);
|
|
266
282
|
for (const [propertyPath, example] of [
|
|
267
|
-
['readerControls.
|
|
283
|
+
['readerControls.appearance', 'readerControls:\n appearance: true'],
|
|
268
284
|
['readerControls.focusReading', 'readerControls:\n focusReading: true'],
|
|
269
285
|
]) {
|
|
270
286
|
addFieldHelp(
|
|
@@ -277,8 +293,8 @@ const addThemeHelp = (jsonSchema) => {
|
|
|
277
293
|
}
|
|
278
294
|
addSnippets(jsonSchema, 'readerControls', [schemaSnippet({
|
|
279
295
|
label: 'Configure the Display panel',
|
|
280
|
-
body: 'readerControls:\n
|
|
281
|
-
description: '
|
|
296
|
+
body: 'readerControls:\n appearance: ${1:true}\n focusReading: ${2:true}',
|
|
297
|
+
description: 'Show optional Appearance and Focus reading controls. Reading width is already included, and tree navigation always includes Focus reading.',
|
|
282
298
|
file: 'theme.md',
|
|
283
299
|
anchor: 'reader-display-controls',
|
|
284
300
|
})]);
|
|
@@ -352,6 +368,7 @@ const addThemeHelp = (jsonSchema) => {
|
|
|
352
368
|
})]);
|
|
353
369
|
|
|
354
370
|
const imageFields = [
|
|
371
|
+
['images.presentation', 'images:\n presentation: prose-aligned'],
|
|
355
372
|
['images.width', 'images:\n width: 900px'],
|
|
356
373
|
['images.maxAvailableWidthPercent', 'images:\n maxAvailableWidthPercent:\n desktop: 100\n mobile: 100'],
|
|
357
374
|
['images.maxAvailableWidthPercent.desktop', 'images:\n maxAvailableWidthPercent:\n desktop: 100'],
|
|
@@ -464,11 +481,12 @@ const addPageThemeHelp = (jsonSchema) => {
|
|
|
464
481
|
addFieldHelp(jsonSchema, 'layout.textWidth', 'layout:\n textWidth: narrow', 'theme.md', 'page-themes', ['narrow', 'normal', 'wide']);
|
|
465
482
|
addFieldHelp(jsonSchema, 'layout.contentSpacing', 'layout:\n contentSpacing: compact', 'theme.md', 'page-themes', ['compact', 'normal', 'spacious']);
|
|
466
483
|
addHelp(jsonSchema, 'images', [
|
|
467
|
-
yamlExample('images:\n width: 900px'),
|
|
468
|
-
'Adjusts managed-image sizing for pages below this directory.',
|
|
484
|
+
yamlExample('images:\n presentation: prose-aligned\n width: 900px'),
|
|
485
|
+
'Adjusts managed-image presentation and sizing inside the content area for pages below this directory. Persistent navigation remains outside this area.',
|
|
469
486
|
documentationLink('Image sizing reference', 'theme.md', 'image-sizing'),
|
|
470
487
|
]);
|
|
471
488
|
const imageFields = [
|
|
489
|
+
['images.presentation', 'images:\n presentation: prose-aligned'],
|
|
472
490
|
['images.width', 'images:\n width: 900px'],
|
|
473
491
|
['images.maxAvailableWidthPercent', 'images:\n maxAvailableWidthPercent:\n desktop: 100\n mobile: 100'],
|
|
474
492
|
['images.maxAvailableWidthPercent.desktop', 'images:\n maxAvailableWidthPercent:\n desktop: 100'],
|
|
@@ -563,8 +581,8 @@ const addContentHelp = (jsonSchema) => {
|
|
|
563
581
|
documentationLink('Page title and frontmatter reference', 'content.md', 'page-title-and-frontmatter'),
|
|
564
582
|
].join('\n\n');
|
|
565
583
|
addHelp(jsonSchema, 'page', [
|
|
566
|
-
yamlExample('page:\n description: About this project
|
|
567
|
-
'Contains optional metadata for the current homepage or additional page. The Markdown H1 supplies its title.',
|
|
584
|
+
yamlExample('page:\n description: About this project.\n aliases:\n - /old-about/'),
|
|
585
|
+
'Contains optional metadata for the current homepage or additional page. The Markdown H1 supplies its title. An alias records an old URL for this same page.',
|
|
568
586
|
documentationLink('Page title and frontmatter reference', 'content.md', 'page-title-and-frontmatter'),
|
|
569
587
|
]);
|
|
570
588
|
addHelp(jsonSchema, 'page.description', [
|
|
@@ -572,6 +590,16 @@ const addContentHelp = (jsonSchema) => {
|
|
|
572
590
|
'An optional page-specific description rendered as `<meta name="description">`. It is not visible in page content.',
|
|
573
591
|
documentationLink('Page title and frontmatter reference', 'content.md', 'page-title-and-frontmatter'),
|
|
574
592
|
], ['About this project.']);
|
|
593
|
+
addHelp(jsonSchema, 'page.aliases', [
|
|
594
|
+
yamlExample('page:\n aliases:\n - /old-about/'),
|
|
595
|
+
'Lists previous site-relative URLs that permanently identify this page. The page containing the list is always the target. Paths omit the configured base path and are excluded from the sitemap.',
|
|
596
|
+
documentationLink('Preserve old page URLs', 'pages.md', 'preserve-old-page-urls'),
|
|
597
|
+
], [['/old-about/']]);
|
|
598
|
+
addHelp(jsonSchema, 'page.aliases.[]', [
|
|
599
|
+
yamlExample('page:\n aliases:\n - /old-about/'),
|
|
600
|
+
'Use a previous Norna page URL. Start and end with `/`; use lowercase ASCII letters, numbers, and single hyphens; do not include the configured base path, a query string, or a fragment.',
|
|
601
|
+
documentationLink('Alias path rules', 'pages.md', 'preserve-old-page-urls'),
|
|
602
|
+
], ['/old-about/']);
|
|
575
603
|
addHelp(jsonSchema, 'navigation', [
|
|
576
604
|
yamlExample('navigation:\n listed: false'),
|
|
577
605
|
'Optionally excludes an additional page from site navigation without removing its public URL.',
|
|
@@ -23,10 +23,10 @@ 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 a
|
|
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
|
-
tree: option('Tree', 'Use a
|
|
29
|
+
tree: option('Tree', 'Use a left page rail and a separate right H2/H3 contents rail where the page has enough headings.'),
|
|
30
30
|
}),
|
|
31
31
|
definition(['en', 'sv'], {
|
|
32
32
|
en: option('English', 'Use Norna\'s built-in English interface text.'),
|
|
@@ -54,6 +54,10 @@ export const schemaValueDefinitions = Object.freeze([
|
|
|
54
54
|
normal: option('Normal', 'Limit the complete card list to at most 56rem.'),
|
|
55
55
|
wide: option('Wide', 'Allow the complete card list to use the available page-layout width.'),
|
|
56
56
|
}),
|
|
57
|
+
definition(['prose-aligned', 'centered-fit'], {
|
|
58
|
+
'prose-aligned': option('Prose-aligned', 'Start managed images and captions at the body-text edge and size them from the page content area.'),
|
|
59
|
+
'centered-fit': option('Centered fit', 'Center managed images and captions in the page media area and constrain them by available width and viewport height.'),
|
|
60
|
+
}),
|
|
57
61
|
definition([400, 500, 600, 700], {
|
|
58
62
|
400: option('Regular', 'Use regular font weight.'),
|
|
59
63
|
500: option('Medium', 'Use medium font weight.'),
|
|
@@ -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
|
+
};
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { readdir, readFile } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
schemaTopLevelKeys,
|
|
5
|
+
siteSchema,
|
|
6
|
+
} from './schema-definitions.mjs';
|
|
3
7
|
import {
|
|
4
8
|
siteContentLabel,
|
|
5
9
|
sitewideContentLabel,
|
|
6
10
|
} from './site-paths.mjs';
|
|
7
|
-
import { schemaTopLevelKeys } from './schema-definitions.mjs';
|
|
8
11
|
import { getIndentInfo, validateYamlIndentation } from './yaml-indentation.mjs';
|
|
12
|
+
import { parseYamlConfig } from './yaml-config.mjs';
|
|
9
13
|
|
|
10
14
|
export { getContentFiles } from './site-structure.mjs';
|
|
11
15
|
|
|
@@ -24,11 +28,13 @@ const knownPageThemeTopLevelFrontmatterKeys = new Set(
|
|
|
24
28
|
const knownSitewideTopLevelFrontmatterKeys = new Set(schemaTopLevelKeys.sitewide);
|
|
25
29
|
const knownNestedFrontmatterKeys = new Set([
|
|
26
30
|
'align',
|
|
31
|
+
'aliases',
|
|
27
32
|
'alt',
|
|
33
|
+
'baseUrl',
|
|
28
34
|
'body',
|
|
29
35
|
'blockGap',
|
|
30
36
|
'caption',
|
|
31
|
-
'
|
|
37
|
+
'appearance',
|
|
32
38
|
'carousel',
|
|
33
39
|
'contentSpacing',
|
|
34
40
|
'desktop',
|
|
@@ -104,7 +110,10 @@ export const splitSiteFile = (source, label = siteContentLabel) => {
|
|
|
104
110
|
};
|
|
105
111
|
};
|
|
106
112
|
|
|
107
|
-
export const readSiteFile = async (sitePath, label = siteContentLabel) =>
|
|
113
|
+
export const readSiteFile = async (sitePath, label = siteContentLabel) => {
|
|
114
|
+
const source = await readFile(sitePath, 'utf8');
|
|
115
|
+
return { ...splitSiteFile(source, label), source };
|
|
116
|
+
};
|
|
108
117
|
|
|
109
118
|
export const validateFrontmatterIndentation = (frontmatter, addIssue) => {
|
|
110
119
|
validateYamlIndentation(frontmatter, (issue) => addIssue({
|
|
@@ -144,7 +153,7 @@ export const validateFrontmatterStructure = (frontmatter, addIssue, {
|
|
|
144
153
|
} else if (fileKind === 'content' && key === 'description') {
|
|
145
154
|
fix = 'Indent "description:" under "page:". The optional meta description belongs in the page object.';
|
|
146
155
|
} else if (fileKind === 'content' && key === 'images') {
|
|
147
|
-
fix = 'Put local image references in
|
|
156
|
+
fix = 'Put local image references in image-stack or image-carousel blocks in the Markdown body.';
|
|
148
157
|
} else if (fileKind === 'content' && knownThemeTopLevelFrontmatterKeys.has(key)) {
|
|
149
158
|
fix = `Move "${key}:" to theme.yaml. Visual settings do not belong in content frontmatter.`;
|
|
150
159
|
} else if ((fileKind === 'theme' || fileKind === 'page theme') && key === 'navigation') {
|
|
@@ -157,7 +166,12 @@ export const validateFrontmatterStructure = (frontmatter, addIssue, {
|
|
|
157
166
|
fix = fileKind === 'page theme'
|
|
158
167
|
? 'Remove "shape:" from this page theme. Set "corners: square" or "corners: rounded" in the root theme.yaml when an override is needed.'
|
|
159
168
|
: 'Replace "shape:" with "corners:". Use "square" or replace the old "soft" value with "rounded".';
|
|
160
|
-
} else if (fileKind === 'page theme' &&
|
|
169
|
+
} else if ((fileKind === 'theme' || fileKind === 'page theme') && key === 'colorMode') {
|
|
170
|
+
message = `Frontmatter line ${lineNumber}: Theme setting "colorMode" was replaced by "appearance".`;
|
|
171
|
+
fix = fileKind === 'page theme'
|
|
172
|
+
? 'Remove "colorMode:" from this page theme. Set "appearance:" in the root theme.yaml when an override is needed.'
|
|
173
|
+
: 'Replace "colorMode:" with "appearance:".';
|
|
174
|
+
} else if (fileKind === 'page theme' && ['preset', 'appearance', 'readerControls', 'corners', 'palette', 'typography'].includes(key)) {
|
|
161
175
|
message = `Frontmatter line ${lineNumber}: page themes may not define site-wide visual identity through "${key}".`;
|
|
162
176
|
fix = `Move "${key}:" to the root theme.yaml. Page themes may set only layout.textWidth, layout.contentSpacing, images, and sections.backgroundPattern.`;
|
|
163
177
|
} else if ((fileKind === 'theme' || fileKind === 'page theme') && ['logo', 'site'].includes(key)) {
|
|
@@ -186,6 +200,15 @@ export const validateContentFrontmatterStructure = (frontmatter, addIssue) =>
|
|
|
186
200
|
fileKind: 'content',
|
|
187
201
|
});
|
|
188
202
|
|
|
203
|
+
export const parseContentFrontmatter = (frontmatterBody, label = siteContentLabel) => parseYamlConfig(
|
|
204
|
+
frontmatterBody,
|
|
205
|
+
`${label} frontmatter`,
|
|
206
|
+
{
|
|
207
|
+
schema: siteSchema,
|
|
208
|
+
validateStructure: validateContentFrontmatterStructure,
|
|
209
|
+
},
|
|
210
|
+
);
|
|
211
|
+
|
|
189
212
|
export const validateConfigYamlStructure = (frontmatter, addIssue) =>
|
|
190
213
|
validateFrontmatterStructure(frontmatter, addIssue, {
|
|
191
214
|
knownTopLevelFrontmatterKeys: knownConfigTopLevelFrontmatterKeys,
|