@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
package/scripts/build-site.mjs
CHANGED
package/scripts/check-config.mjs
CHANGED
|
@@ -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
|
+
}
|
package/scripts/init-site.mjs
CHANGED
|
@@ -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 {
|
|
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 || !['
|
|
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
|
|
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
|
-
'
|
|
623
|
-
...nornaMarkdownBlockDefinitions['
|
|
624
|
-
snippet: '```
|
|
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
|
-
'
|
|
627
|
-
...nornaMarkdownBlockDefinitions['
|
|
628
|
-
snippet: '```
|
|
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
|
-
'
|
|
631
|
-
...nornaMarkdownBlockDefinitions['
|
|
632
|
-
snippet: '```
|
|
650
|
+
'page-list': Object.freeze({
|
|
651
|
+
...nornaMarkdownBlockDefinitions['page-list'],
|
|
652
|
+
snippet: '```page-list\n```',
|
|
633
653
|
}),
|
|
634
654
|
});
|
|
@@ -19,31 +19,27 @@ const getNodeDepth = (node) => node.depth ?? 1;
|
|
|
19
19
|
|
|
20
20
|
const getTopLevelPagePath = (pagePath) => pagePath?.split('/')[0] ?? '';
|
|
21
21
|
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
if (
|
|
22
|
+
const getActiveBranchNodes = (nodes, currentPage) => {
|
|
23
|
+
if (!currentPage) return [];
|
|
24
|
+
if (currentPage.isHome) return nodes.filter((node) => node.isHome);
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
+
};
|