@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.
Files changed (71) hide show
  1. package/README.md +14 -18
  2. package/astro.config.mjs +12 -0
  3. package/bin/norna-cli.mjs +6 -0
  4. package/package.json +13 -2
  5. package/schemas/category.schema.json +2 -2
  6. package/schemas/config.schema.json +45 -15
  7. package/schemas/content-frontmatter.schema.json +7 -7
  8. package/schemas/page-theme.schema.json +26 -26
  9. package/schemas/sitewide-content.schema.json +18 -18
  10. package/schemas/theme.schema.json +246 -246
  11. package/scripts/build-site.mjs +1 -0
  12. package/scripts/check-config.mjs +12 -1
  13. package/scripts/generate-search-index.mjs +57 -0
  14. package/scripts/init-site.mjs +1 -0
  15. package/scripts/lib/code-fence-metadata.mjs +223 -0
  16. package/scripts/lib/edit-source-link.mjs +49 -0
  17. package/scripts/lib/editor-language-service.mjs +32 -12
  18. package/scripts/lib/image-presentation.mjs +4 -0
  19. package/scripts/lib/navigation-model.mjs +34 -13
  20. package/scripts/lib/navigation-review.mjs +396 -0
  21. package/scripts/lib/norna-markdown-blocks.mjs +73 -26
  22. package/scripts/lib/norna-markdown-render-plugin.mjs +64 -1
  23. package/scripts/lib/page-aliases.mjs +21 -1
  24. package/scripts/lib/page-markdown.mjs +32 -1
  25. package/scripts/lib/page-move-plan.mjs +659 -0
  26. package/scripts/lib/presentation-palette-metadata.mjs +1 -1
  27. package/scripts/lib/presentation.mjs +1 -10
  28. package/scripts/lib/project-config.mjs +111 -5
  29. package/scripts/lib/public-asset-conventions.mjs +36 -2
  30. package/scripts/lib/schema-definitions.mjs +22 -5
  31. package/scripts/lib/schema-editor-metadata.mjs +19 -4
  32. package/scripts/lib/schema-value-definitions.mjs +1 -1
  33. package/scripts/lib/semantic-callouts.mjs +128 -0
  34. package/scripts/lib/site-content.mjs +2 -1
  35. package/scripts/lib/site-link-graph.mjs +33 -2
  36. package/scripts/lib/site-navigation-tree.mjs +85 -0
  37. package/scripts/lib/social-image-assets.mjs +30 -0
  38. package/scripts/lib/theme-presets.mjs +2 -2
  39. package/scripts/lib/theme-profiles.mjs +0 -4
  40. package/scripts/move-site-page.mjs +256 -0
  41. package/scripts/review-navigation.mjs +32 -0
  42. package/scripts/sync-content-sections.mjs +67 -4
  43. package/scripts/sync-site-public.mjs +24 -5
  44. package/src/components/CodeBlockCopyScript.astro +9 -4
  45. package/src/components/EditSourceLink.astro +17 -0
  46. package/src/components/ImageCarousel.astro +5 -6
  47. package/src/components/NavigationPageTree.astro +66 -55
  48. package/src/components/NavigationTreeControls.astro +77 -0
  49. package/src/components/PageAliasRedirect.astro +1 -1
  50. package/src/components/PageList.astro +33 -0
  51. package/src/components/PageSequenceNavigation.astro +37 -0
  52. package/src/components/SearchPage.astro +151 -0
  53. package/src/components/SiteNavigation.astro +35 -8
  54. package/src/components/SitePage.astro +79 -26
  55. package/src/components/SiteSection.astro +11 -3
  56. package/src/components/SiteTreeNavigation.astro +8 -1
  57. package/src/components/TreeNavigationScript.astro +207 -26
  58. package/src/layouts/BaseLayout.astro +57 -2
  59. package/src/lib/sectionContent.ts +32 -3
  60. package/src/lib/siteNavigation.ts +31 -52
  61. package/src/lib/sitePublicAssets.ts +1 -0
  62. package/src/pages/404.astro +110 -0
  63. package/src/pages/[...slug].astro +15 -5
  64. package/src/styles/content.css +242 -18
  65. package/src/styles/media.css +0 -5
  66. package/src/styles/navigation.css +31 -0
  67. package/src/styles/page-layout.css +438 -89
  68. package/src/styles/responsive.css +77 -8
  69. package/starters/basic/README.md +6 -19
  70. package/starters/basic/package.json +1 -0
  71. package/starters/basic/site/pages/000-home/content.md +13 -50
@@ -14,3 +14,4 @@ await runScript('scripts/sync-content-sections.mjs', ['--check']);
14
14
  await runScript('scripts/sync-site-public.mjs');
15
15
  await runScript('scripts/generate-images.mjs');
16
16
  await runAstroInherit(['build']);
17
+ await runScript('scripts/generate-search-index.mjs');
@@ -1,7 +1,8 @@
1
1
  import path from 'node:path';
2
2
  import { siteConfigLabel, sitePagesDir, sitePublicLabel, siteThemeLabel } from './lib/site-paths.mjs';
3
3
  import { getLogoAssets, getPublicAssetInspection } from './lib/logo-assets.mjs';
4
- import { logoAssetFilenames } from './lib/public-asset-conventions.mjs';
4
+ import { logoAssetFilenames, socialImageAssetFilenames } from './lib/public-asset-conventions.mjs';
5
+ import { getSocialImageAssets } from './lib/social-image-assets.mjs';
5
6
  import { readSitewideContent } from './lib/sitewide-content.mjs';
6
7
  import { assertSectionBackgroundPatternCompatibility } from './lib/presentation.mjs';
7
8
  import { readThemeConfig, validatePageThemeFiles } from './lib/theme-config.mjs';
@@ -43,6 +44,7 @@ try {
43
44
  }
44
45
  }
45
46
  const logoAssets = getLogoAssets();
47
+ const socialImageAssets = getSocialImageAssets();
46
48
  const publicAssetInspection = getPublicAssetInspection();
47
49
  const logoAssetPaths = logoAssetFilenames.map((filename) => `${sitePublicLabel}/${filename}`);
48
50
  for (const issue of publicAssetInspection.suspicious) {
@@ -56,6 +58,13 @@ try {
56
58
  ].join('\n'));
57
59
  }
58
60
 
61
+ if (socialImageAssets.length > 1) {
62
+ throw new Error([
63
+ `Found multiple social sharing images in ${sitePublicLabel}. Keep exactly one of ${socialImageAssetFilenames.join(', ')}.`,
64
+ ...socialImageAssets.map(({ filename }) => `- ${sitePublicLabel}/${filename}`),
65
+ ].join('\n'));
66
+ }
67
+
59
68
  if (logoAssets.length === 0) {
60
69
  if (sitewideContent.logo) {
61
70
  throw new Error(`Site-wide logo is configured, but no logo file was found. Add exactly one of ${logoAssetPaths.join(', ')}, or remove logo.`);
@@ -67,6 +76,7 @@ try {
67
76
  console.log('Config check passed.');
68
77
  console.log(`Site URL: ${projectConfig.site.url}`);
69
78
  console.log(`Base path: ${projectConfig.site.basePath}`);
79
+ console.log(`Edit links: ${projectConfig.editLink?.baseUrl ?? '(disabled)'}`);
70
80
  console.log(`Theme preset: ${themeConfig.preset ?? '(none)'}`);
71
81
  console.log(`Page width: ${projectConfig.layout.pageWidth}`);
72
82
  console.log(`Gutter: desktop ${projectConfig.layout.gutter.desktop}, mobile ${projectConfig.layout.gutter.mobile}`);
@@ -81,6 +91,7 @@ try {
81
91
  console.log(`Font family: ${projectConfig.typography.fontFamily}`);
82
92
  console.log(`Language: ${projectConfig.locale.lang}`);
83
93
  console.log(`Navigation mode: ${projectConfig.navigation.mode}`);
94
+ console.log(`Static search: ${projectConfig.search.enabled ? 'enabled' : 'disabled'}`);
84
95
  console.log(`Scroll behavior: ${projectConfig.navigation.scrollBehavior}`);
85
96
  } catch (error) {
86
97
  console.error('Config check failed.');
@@ -0,0 +1,57 @@
1
+ import { cp, mkdir, readdir, rm } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import * as pagefind from 'pagefind';
4
+ import projectConfig from './lib/project-config.mjs';
5
+ import {
6
+ astroDistDir,
7
+ astroPublicDir,
8
+ } from './lib/site-paths.mjs';
9
+
10
+ const searchDirectoryName = 'pagefind';
11
+ const distSearchDirectory = path.join(astroDistDir, searchDirectoryName);
12
+ const localSearchDirectory = path.join(astroPublicDir, searchDirectoryName);
13
+
14
+ if (!projectConfig.search.enabled) {
15
+ await rm(localSearchDirectory, { force: true, recursive: true });
16
+ console.log('Static search is disabled.');
17
+ } else {
18
+ await rm(distSearchDirectory, { force: true, recursive: true });
19
+
20
+ try {
21
+ const { errors: createErrors, index } = await pagefind.createIndex();
22
+ if (!index || createErrors.length > 0) {
23
+ throw new Error(createErrors.join('\n') || 'Pagefind did not create an index.');
24
+ }
25
+
26
+ const { errors: indexingErrors, page_count: scannedPageCount } = await index.addDirectory({
27
+ path: astroDistDir,
28
+ });
29
+ if (indexingErrors.length > 0) throw new Error(indexingErrors.join('\n'));
30
+ if (scannedPageCount === 0) {
31
+ throw new Error('No rendered pages contained searchable editorial content.');
32
+ }
33
+
34
+ const { errors: writeErrors } = await index.writeFiles({
35
+ outputPath: distSearchDirectory,
36
+ });
37
+ if (writeErrors.length > 0) throw new Error(writeErrors.join('\n'));
38
+ const fragmentDirectory = path.join(distSearchDirectory, 'fragment');
39
+ const indexedPageCount = (await readdir(fragmentDirectory))
40
+ .filter((filename) => filename.endsWith('.pf_fragment'))
41
+ .length;
42
+ if (indexedPageCount === 0) {
43
+ throw new Error('No rendered pages contained searchable editorial content.');
44
+ }
45
+
46
+ await rm(localSearchDirectory, { force: true, recursive: true });
47
+ await mkdir(astroPublicDir, { recursive: true });
48
+ await cp(distSearchDirectory, localSearchDirectory, { recursive: true });
49
+
50
+ console.log(`Generated static search index for ${indexedPageCount} page${indexedPageCount === 1 ? '' : 's'}.`);
51
+ } catch (error) {
52
+ const message = error instanceof Error ? error.message : String(error);
53
+ throw new Error(`Could not generate the static search index. ${message}`);
54
+ } finally {
55
+ await pagefind.close();
56
+ }
57
+ }
@@ -165,6 +165,7 @@ const nornaScripts = {
165
165
  'norna:config:check': cliCommand('config:check'),
166
166
  'norna:content:check': cliCommand('content:check'),
167
167
  'norna:sync': cliCommand('content:sync'),
168
+ 'norna:navigation:review': cliCommand('navigation:review'),
168
169
  'norna:theme:presets': cliCommand('theme:presets'),
169
170
  'norna:theme:export': cliCommand('theme:export'),
170
171
  'norna:typography:profiles': cliCommand('typography profiles'),
@@ -0,0 +1,223 @@
1
+ const titlePrefix = 'title="';
2
+ const lineSelectorPattern = /^\{(\d+(?:-\d+)?(?:,\d+(?:-\d+)?)*)\}$/;
3
+
4
+ const invalidMetadata = (message, fix) => ({
5
+ error: {
6
+ code: 'invalid-code-fence-metadata',
7
+ fix,
8
+ message,
9
+ },
10
+ });
11
+
12
+ const parseTitle = (source) => {
13
+ let title = '';
14
+ let index = titlePrefix.length;
15
+
16
+ while (index < source.length) {
17
+ const character = source[index];
18
+ if (character === '"') {
19
+ return {
20
+ rest: source.slice(index + 1),
21
+ title,
22
+ };
23
+ }
24
+
25
+ if (character === '\\') {
26
+ const escaped = source[index + 1];
27
+ if (escaped !== '"' && escaped !== '\\') {
28
+ return invalidMetadata(
29
+ `Code title contains unsupported escape "\\${escaped ?? ''}".`,
30
+ 'Only escape a double quote (\\") or backslash (\\\\) inside the title.',
31
+ );
32
+ }
33
+ title += escaped;
34
+ index += 2;
35
+ continue;
36
+ }
37
+
38
+ title += character;
39
+ index += 1;
40
+ }
41
+
42
+ return invalidMetadata(
43
+ 'Code title is missing its closing double quote.',
44
+ 'Close the title, for example title="src/config.js".',
45
+ );
46
+ };
47
+
48
+ const parseHighlightedLines = (selector, lineCount) => {
49
+ const match = selector.match(lineSelectorPattern);
50
+ if (!match) {
51
+ return invalidMetadata(
52
+ `Invalid code line selector "${selector}".`,
53
+ 'Use positive line numbers and inclusive ranges without spaces, for example {2,4-6}.',
54
+ );
55
+ }
56
+
57
+ const highlightedLines = new Set();
58
+ for (const part of match[1].split(',')) {
59
+ const [startSource, endSource = startSource] = part.split('-');
60
+ const start = Number.parseInt(startSource, 10);
61
+ const end = Number.parseInt(endSource, 10);
62
+
63
+ if (start < 1 || end < start) {
64
+ return invalidMetadata(
65
+ `Invalid code line range "${part}".`,
66
+ 'Use positive line numbers with the lower number first, for example {2,4-6}.',
67
+ );
68
+ }
69
+ if (Number.isInteger(lineCount) && end > lineCount) {
70
+ return invalidMetadata(
71
+ `Code line selector "${part}" refers to line ${end}, but the block has ${lineCount} ${lineCount === 1 ? 'line' : 'lines'}.`,
72
+ `Select only lines 1-${lineCount}.`,
73
+ );
74
+ }
75
+
76
+ for (let line = start; line <= end; line += 1) {
77
+ if (highlightedLines.has(line)) {
78
+ return invalidMetadata(
79
+ `Code line ${line} is selected more than once.`,
80
+ 'Remove overlapping or repeated line numbers from the selector.',
81
+ );
82
+ }
83
+ highlightedLines.add(line);
84
+ }
85
+ }
86
+
87
+ return { highlightedLines: [...highlightedLines].sort((left, right) => left - right) };
88
+ };
89
+
90
+ export const parseCodeFenceMetadata = (rawMetadata, options = {}) => {
91
+ const source = String(rawMetadata ?? '').trim();
92
+ if (!source) return { highlightedLines: [], title: null };
93
+
94
+ let rest = source;
95
+ let title = null;
96
+ if (rest.startsWith(titlePrefix)) {
97
+ const titleResult = parseTitle(rest);
98
+ if (titleResult.error) return titleResult;
99
+ if (!titleResult.title.trim()) {
100
+ return invalidMetadata(
101
+ 'Code title cannot be empty.',
102
+ 'Remove title="" or provide a short filename or label.',
103
+ );
104
+ }
105
+ title = titleResult.title;
106
+ rest = titleResult.rest;
107
+ if (rest && !rest.startsWith(' ')) {
108
+ return invalidMetadata(
109
+ 'Code title must be separated from the line selector by one space.',
110
+ 'Write metadata as title="src/config.js" {2,4-6}.',
111
+ );
112
+ }
113
+ rest = rest.trim();
114
+ }
115
+
116
+ if (!rest) return { highlightedLines: [], title };
117
+ if (!rest.startsWith('{')) {
118
+ return invalidMetadata(
119
+ `Unknown code fence metadata "${rest}".`,
120
+ 'Use an optional title followed by an optional line selector: title="src/config.js" {2,4-6}.',
121
+ );
122
+ }
123
+ if (/^\{[^}]*\}\s+/.test(rest)) {
124
+ return invalidMetadata(
125
+ 'The code line selector must come after the optional title.',
126
+ 'Write metadata as title="src/config.js" {2,4-6}.',
127
+ );
128
+ }
129
+
130
+ const linesResult = parseHighlightedLines(rest, options.lineCount);
131
+ if (linesResult.error) return linesResult;
132
+ return {
133
+ highlightedLines: linesResult.highlightedLines,
134
+ title,
135
+ };
136
+ };
137
+
138
+ const formatDiagnostic = ({ error, label, line, offset }) => ({
139
+ ...error,
140
+ line,
141
+ message: `${label} line ${line}: ${error.message}`,
142
+ offset,
143
+ });
144
+
145
+ export const getCodeFenceMetadataDiagnostics = (tree, options = {}) => {
146
+ const diagnostics = [];
147
+ const label = options.label ?? 'Markdown';
148
+ const lineOffset = options.lineOffset ?? 0;
149
+ const excludedLanguages = options.excludedLanguages ?? new Set();
150
+
151
+ const visit = (node) => {
152
+ if (!node || typeof node !== 'object') return;
153
+ if (node.type === 'code' && !excludedLanguages.has(node.lang)) {
154
+ const metadataWithoutLanguage = typeof node.lang === 'string'
155
+ && (node.lang.startsWith('title=') || node.lang.startsWith('{'));
156
+ const result = metadataWithoutLanguage
157
+ ? invalidMetadata(
158
+ 'Code fence metadata requires a language before it.',
159
+ 'Add a language first, for example ```js title="src/config.js" {2}.',
160
+ )
161
+ : parseCodeFenceMetadata(node.meta, {
162
+ lineCount: String(node.value ?? '').split('\n').length,
163
+ });
164
+ if (result.error) {
165
+ const line = lineOffset + (node.position?.start.line ?? 1);
166
+ diagnostics.push(formatDiagnostic({
167
+ error: result.error,
168
+ label,
169
+ line,
170
+ offset: node.position?.start.offset ?? 0,
171
+ }));
172
+ }
173
+ }
174
+
175
+ for (const child of node.children ?? []) visit(child);
176
+ };
177
+
178
+ visit(tree);
179
+ return diagnostics;
180
+ };
181
+
182
+ const getTransformerMetadata = (context) => {
183
+ if (!context.meta.nornaCodeFence) {
184
+ const result = parseCodeFenceMetadata(context.options.meta?.__raw, {
185
+ lineCount: context.tokens?.length,
186
+ });
187
+ if (result.error) throw new Error(`${result.error.message} ${result.error.fix}`);
188
+ context.meta.nornaCodeFence = result;
189
+ }
190
+ return context.meta.nornaCodeFence;
191
+ };
192
+
193
+ export const nornaCodeFenceTransformer = {
194
+ name: 'norna-code-fence-metadata',
195
+ line(node, line) {
196
+ const metadata = getTransformerMetadata(this);
197
+ if (!metadata.highlightedLines.includes(line)) return;
198
+ this.addClassToHast(node, 'norna-code-line-highlighted');
199
+ node.properties.dataLine = String(line);
200
+ },
201
+ pre(node) {
202
+ const metadata = getTransformerMetadata(this);
203
+ if (metadata.highlightedLines.length > 0) {
204
+ this.addClassToHast(node, 'norna-code-has-highlighted-lines');
205
+ }
206
+ if (!metadata.title) return;
207
+
208
+ return {
209
+ type: 'element',
210
+ tagName: 'figure',
211
+ properties: { className: ['norna-code-example'] },
212
+ children: [
213
+ {
214
+ type: 'element',
215
+ tagName: 'figcaption',
216
+ properties: { className: ['norna-code-title'] },
217
+ children: [{ type: 'text', value: metadata.title }],
218
+ },
219
+ node,
220
+ ],
221
+ };
222
+ },
223
+ };
@@ -0,0 +1,49 @@
1
+ const assertHttpUrl = (value, label) => {
2
+ if (typeof value !== 'string' || value.trim() === '') {
3
+ throw new Error(`${label} must be a non-empty absolute URL.`);
4
+ }
5
+
6
+ let url;
7
+ try {
8
+ url = new URL(value.trim());
9
+ } catch {
10
+ throw new Error(`${label} must be an absolute URL such as "https://github.com/owner/repository/edit/main/".`);
11
+ }
12
+
13
+ if (!['http:', 'https:'].includes(url.protocol)) {
14
+ throw new Error(`${label} must use http or https.`);
15
+ }
16
+ if (url.username || url.password) {
17
+ throw new Error(`${label} must not contain credentials.`);
18
+ }
19
+ if (url.search || url.hash) {
20
+ throw new Error(`${label} must not contain a query string or fragment.`);
21
+ }
22
+
23
+ return url;
24
+ };
25
+
26
+ export const normalizeEditLinkBaseUrl = (value, label = 'editLink.baseUrl') => {
27
+ const url = assertHttpUrl(value, label);
28
+ if (!url.pathname.endsWith('/')) url.pathname = `${url.pathname}/`;
29
+ return url.href;
30
+ };
31
+
32
+ export const getEditSourceUrl = ({ baseUrl, sourcePath }) => {
33
+ const normalizedBaseUrl = normalizeEditLinkBaseUrl(baseUrl);
34
+ if (typeof sourcePath !== 'string' || sourcePath.trim() === '') {
35
+ throw new Error('Edit-source path must be a non-empty project-relative path.');
36
+ }
37
+
38
+ const normalizedSourcePath = sourcePath.trim().replaceAll('\\', '/');
39
+ const segments = normalizedSourcePath.split('/');
40
+ if (
41
+ normalizedSourcePath.startsWith('/')
42
+ || segments.some((segment) => segment === '' || segment === '.' || segment === '..')
43
+ ) {
44
+ throw new Error(`Edit-source path "${sourcePath}" must be a project-relative path without traversal segments.`);
45
+ }
46
+
47
+ const encodedPath = segments.map((segment) => encodeURIComponent(segment)).join('/');
48
+ return new URL(encodedPath, normalizedBaseUrl).href;
49
+ };
@@ -6,7 +6,11 @@ import {
6
6
  nornaMarkdownBlockDefinitions,
7
7
  } from './norna-markdown-blocks.mjs';
8
8
  import { resolveNavigationModel } from './navigation-model.mjs';
9
- import { inspectPublicAssetFilenames, logoAssetFilenames } from './public-asset-conventions.mjs';
9
+ import {
10
+ inspectPublicAssetFilenames,
11
+ logoAssetFilenames,
12
+ socialImageAssetFilenames,
13
+ } from './public-asset-conventions.mjs';
10
14
  import { parsePageMarkdownSource } from './page-markdown.mjs';
11
15
  import { siteSchema } from './schema-definitions.mjs';
12
16
  import { homePageDirectory } from './site-conventions.mjs';
@@ -217,6 +221,18 @@ export const getSitePublicAssetStatus = async (documentPath) => {
217
221
  });
218
222
  }
219
223
  }
224
+ if (inspection.socialImages.length > 1) {
225
+ const message = `Multiple social sharing images were found: ${inspection.socialImages.join(', ')}. Keep exactly one of ${socialImageAssetFilenames.join(', ')} in site/public.`;
226
+ for (const filename of inspection.socialImages) {
227
+ issues.push({
228
+ absolutePath: path.join(publicDirectory, filename),
229
+ code: 'multiple-social-image-files',
230
+ filename,
231
+ message,
232
+ severity: 'error',
233
+ });
234
+ }
235
+ }
220
236
  if (inspection.logos.length === 0 && sitewideLogo?.logoConfigured) {
221
237
  const publicLabel = toPosixPath(path.relative(path.dirname(siteRoot), publicDirectory));
222
238
  issues.push({
@@ -464,7 +480,7 @@ export const getImageCompletionContext = async ({ documentPath, source, line })
464
480
  const page = getPageContext(siteRoot, documentPath);
465
481
  if (!page) return null;
466
482
  const fence = getOpenMarkdownFenceAtLine(source, line);
467
- if (!fence || !['norna-image-stack', 'norna-image-carousel', 'norna-card-list'].includes(fence.type)) return null;
483
+ if (!fence || !['image-stack', 'image-carousel', 'card-list'].includes(fence.type)) return null;
468
484
 
469
485
  const currentLine = source.replace(/\r\n?/g, '\n').split('\n')[line] ?? '';
470
486
  if (!/^\s*(?:-\s+)?image:\s*[^\s]*$/.test(currentLine)) return null;
@@ -563,7 +579,7 @@ export const getMarkdownDiagnostics = async ({ documentPath, source }) => {
563
579
  code: 'local-markdown-image',
564
580
  severity: 'warning',
565
581
  line: reference.line,
566
- message: `Local Markdown image "${reference.target}" is not managed by Norna. Use norna-image-stack, norna-image-carousel, or norna-card-list for validated and synchronized site images.`,
582
+ message: `Local Markdown image "${reference.target}" is not managed by Norna. Use image-stack, image-carousel, or card-list for validated and synchronized site images.`,
567
583
  });
568
584
  }
569
585
 
@@ -619,16 +635,20 @@ export const getMarkdownDiagnostics = async ({ documentPath, source }) => {
619
635
  };
620
636
 
621
637
  export const nornaBlockDefinitions = Object.freeze({
622
- 'norna-image-stack': Object.freeze({
623
- ...nornaMarkdownBlockDefinitions['norna-image-stack'],
624
- snippet: '```norna-image-stack\n- image: ${1:filename.jpg}\n alt: ${2:Alternative text}\n caption: ${3:Caption}\n```',
638
+ 'image-stack': Object.freeze({
639
+ ...nornaMarkdownBlockDefinitions['image-stack'],
640
+ snippet: '```image-stack\n- image: ${1:filename.jpg}\n alt: ${2:Alternative text}\n caption: ${3:Caption}\n```',
641
+ }),
642
+ 'image-carousel': Object.freeze({
643
+ ...nornaMarkdownBlockDefinitions['image-carousel'],
644
+ snippet: '```image-carousel\n- image: ${1:first.jpg}\n alt: ${2:Alternative text}\n- image: ${3:second.jpg}\n alt: ${4:Alternative text}\n```',
625
645
  }),
626
- 'norna-image-carousel': Object.freeze({
627
- ...nornaMarkdownBlockDefinitions['norna-image-carousel'],
628
- snippet: '```norna-image-carousel\n- image: ${1:first.jpg}\n alt: ${2:Alternative text}\n- image: ${3:second.jpg}\n alt: ${4:Alternative text}\n```',
646
+ 'card-list': Object.freeze({
647
+ ...nornaMarkdownBlockDefinitions['card-list'],
648
+ snippet: '```card-list\nlayout: ${1|image-top,image-left,image-right|}\nflow: ${2|grid,stack|}\nsize: ${3|s,m,l,xl|}\n\n- title: ${4:Card title}\n text: ${5:Card text}\n image: ${6:filename.jpg}\n```',
629
649
  }),
630
- 'norna-card-list': Object.freeze({
631
- ...nornaMarkdownBlockDefinitions['norna-card-list'],
632
- snippet: '```norna-card-list\nlayout: ${1|image-top,image-left,image-right|}\nflow: ${2|grid,stack|}\nsize: ${3|s,m,l,xl|}\n\n- title: ${4:Card title}\n text: ${5:Card text}\n image: ${6:filename.jpg}\n```',
650
+ 'page-list': Object.freeze({
651
+ ...nornaMarkdownBlockDefinitions['page-list'],
652
+ snippet: '```page-list\n```',
633
653
  }),
634
654
  });
@@ -1,2 +1,6 @@
1
1
  export const imagePresentationNames = Object.freeze(['prose-aligned', 'centered-fit']);
2
2
  export const defaultImagePresentation = 'prose-aligned';
3
+ export const defaultImageMaxAvailableHeightPercent = Object.freeze({
4
+ desktop: 74,
5
+ mobile: 68,
6
+ });
@@ -19,31 +19,27 @@ const getNodeDepth = (node) => node.depth ?? 1;
19
19
 
20
20
  const getTopLevelPagePath = (pagePath) => pagePath?.split('/')[0] ?? '';
21
21
 
22
- const branchNeedsPageRail = (nodes, pagePath) => {
23
- const rootPath = getTopLevelPagePath(pagePath);
24
- if (!rootPath) return false;
22
+ const getActiveBranchNodes = (nodes, currentPage) => {
23
+ if (!currentPage) return [];
24
+ if (currentPage.isHome) return nodes.filter((node) => node.isHome);
25
25
 
26
- return nodes.some((node) => (
27
- (node.pagePath === rootPath && node.kind === 'category')
26
+ const rootPath = getTopLevelPagePath(currentPage.pagePath);
27
+ return nodes.filter((node) => (
28
+ node.pagePath === rootPath
28
29
  || node.pagePath?.startsWith(`${rootPath}/`)
29
30
  ));
30
31
  };
31
32
 
32
- export const getAutomaticNavigationMode = (nodes, currentPage = null) => {
33
+ export const getAutomaticNavigationMode = (nodes) => {
33
34
  const listedNodes = getListedNodes(nodes);
34
35
  if (listedNodes.length <= 1) return 'sections';
35
36
 
36
- if (currentPage) {
37
- if (currentPage.isHome) return 'top';
38
- return branchNeedsPageRail(listedNodes, currentPage.pagePath) ? 'tree' : 'top';
39
- }
40
-
41
37
  return listedNodes.some((node) => node.kind === 'category' || getNodeDepth(node) > 1)
42
38
  ? 'tree'
43
39
  : 'top';
44
40
  };
45
41
 
46
- export const resolveNavigationModel = ({ mode = 'automatic', nodes, currentPage = null }) => {
42
+ export const resolveNavigationModel = ({ mode = 'automatic', nodes }) => {
47
43
  const requestedMode = assertNavigationMode(mode);
48
44
  const listedNodes = getListedNodes(nodes);
49
45
  const hasCategories = listedNodes.some((node) => node.kind === 'category');
@@ -57,7 +53,7 @@ export const resolveNavigationModel = ({ mode = 'automatic', nodes, currentPage
57
53
 
58
54
  return Object.freeze({
59
55
  mode: requestedMode === 'automatic'
60
- ? getAutomaticNavigationMode(listedNodes, currentPage)
56
+ ? getAutomaticNavigationMode(listedNodes)
61
57
  : requestedMode,
62
58
  requestedMode,
63
59
  listedNodeCount: listedNodes.length,
@@ -66,3 +62,28 @@ export const resolveNavigationModel = ({ mode = 'automatic', nodes, currentPage
66
62
  maximumDepth,
67
63
  });
68
64
  };
65
+
66
+ export const resolvePageContentsPlacement = ({
67
+ navigationMode,
68
+ nodes,
69
+ currentPage,
70
+ headingCount,
71
+ }) => {
72
+ const listedNodes = getListedNodes(nodes);
73
+ const activeBranchNodes = getActiveBranchNodes(listedNodes, currentPage);
74
+ const activeBranchDepth = activeBranchNodes.reduce((maximum, node) => (
75
+ Math.max(maximum, getNodeDepth(node))
76
+ ), 0);
77
+ const hasPageContents = headingCount >= 2;
78
+ const placement = navigationMode !== 'tree' || !hasPageContents
79
+ ? 'none'
80
+ : activeBranchDepth <= 2
81
+ ? 'page-tree'
82
+ : 'contents-rail';
83
+
84
+ return Object.freeze({
85
+ activeBranchDepth,
86
+ hasPageContents,
87
+ placement,
88
+ });
89
+ };