@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,12 +1,18 @@
|
|
|
1
|
-
import { mkdir, rename, stat } from 'node:fs/promises';
|
|
2
1
|
import { createInterface } from 'node:readline/promises';
|
|
3
2
|
import { stdin as input, stdout as output } from 'node:process';
|
|
4
3
|
import path from 'node:path';
|
|
4
|
+
import { applyImageSyncPlan } from './lib/content-sync-apply.mjs';
|
|
5
|
+
import {
|
|
6
|
+
createImageSyncPlan,
|
|
7
|
+
getExpectedImageLabel,
|
|
8
|
+
getImageCandidateLabel,
|
|
9
|
+
} from './lib/content-sync-plan.mjs';
|
|
5
10
|
import {
|
|
6
11
|
getImageCandidatesByName,
|
|
7
12
|
getDeprecatedInlineStyleReferences,
|
|
8
13
|
readSiteFile,
|
|
9
14
|
readThemeFile,
|
|
15
|
+
parseContentFrontmatter,
|
|
10
16
|
toPosixPath,
|
|
11
17
|
validateContentFrontmatterStructure,
|
|
12
18
|
validateFrontmatterIndentation,
|
|
@@ -14,7 +20,17 @@ import {
|
|
|
14
20
|
} from './lib/site-content.mjs';
|
|
15
21
|
import { getSiteStructure } from './lib/site-structure.mjs';
|
|
16
22
|
import { readImageDimensions } from './lib/image-dimensions.mjs';
|
|
17
|
-
import {
|
|
23
|
+
import { parsePageMarkdownSource } from './lib/page-markdown.mjs';
|
|
24
|
+
import {
|
|
25
|
+
getDirectChildPages,
|
|
26
|
+
getListedSiteNavigationTree,
|
|
27
|
+
} from './lib/site-navigation-tree.mjs';
|
|
28
|
+
import {
|
|
29
|
+
createSiteLinkGraph,
|
|
30
|
+
getSitePublicFiles,
|
|
31
|
+
} from './lib/site-link-graph.mjs';
|
|
32
|
+
import { getGeneratedSiteRoutes } from './lib/page-aliases.mjs';
|
|
33
|
+
import projectConfig from './lib/project-config.mjs';
|
|
18
34
|
import {
|
|
19
35
|
siteThemeLabel,
|
|
20
36
|
siteThemePath,
|
|
@@ -25,8 +41,6 @@ const shouldWrite = args.has('--write');
|
|
|
25
41
|
const shouldCheck = args.has('--check') || !shouldWrite;
|
|
26
42
|
const skipPrompt = args.has('--yes');
|
|
27
43
|
const issues = [];
|
|
28
|
-
const imageMoves = [];
|
|
29
|
-
const referencedImagePaths = new Set();
|
|
30
44
|
|
|
31
45
|
const addIssue = ({ severity, message, fix, sectionId, sectionLabel }) => {
|
|
32
46
|
issues.push({ severity, message, fix, sectionId, sectionLabel });
|
|
@@ -50,7 +64,7 @@ const addSectionIssue = (contentFile, section, issue) => addIssue({
|
|
|
50
64
|
|
|
51
65
|
const getMovePlanLines = (moves) => [
|
|
52
66
|
'Planned image moves:',
|
|
53
|
-
...moves.map((move) => `- ${
|
|
67
|
+
...moves.map((move) => `- ${getImageCandidateLabel({ contentFile: move.sourceContentFile, imagePath: move.from })} -> ${getExpectedImageLabel(move.contentFile, move.imageName)}`),
|
|
54
68
|
];
|
|
55
69
|
|
|
56
70
|
const promptForWrite = async (moves) => {
|
|
@@ -166,157 +180,6 @@ const getAspectRatioLabel = ({ width, height }) => {
|
|
|
166
180
|
return `${width / divisor}:${height / divisor}`;
|
|
167
181
|
};
|
|
168
182
|
|
|
169
|
-
const getExpectedImagePath = (contentFile, imageName) =>
|
|
170
|
-
path.join(contentFile.imagesDir, imageName);
|
|
171
|
-
|
|
172
|
-
const getExpectedImageLabel = (contentFile, imageName) =>
|
|
173
|
-
`${contentFile.imagesLabel}/${imageName}`;
|
|
174
|
-
|
|
175
|
-
const getGlobalImageCandidates = (globalImageCandidatesByName, imageName, expectedPath) =>
|
|
176
|
-
(globalImageCandidatesByName.get(imageName) ?? []).filter(({ imagePath }) => imagePath !== expectedPath);
|
|
177
|
-
|
|
178
|
-
const getReferenceLabel = (contentFile, section) => `${contentFile.contentLabel} [${section.id ?? 'page title'}]`;
|
|
179
|
-
|
|
180
|
-
const getRootLabel = (contentFile) => contentFile.imagesLabel.replace(/\/images$/, '');
|
|
181
|
-
|
|
182
|
-
const getCandidateLabel = ({ contentFile, imagePath }) =>
|
|
183
|
-
`${contentFile.imagesLabel}/${toPosixPath(path.relative(contentFile.imagesDir, imagePath))}`;
|
|
184
|
-
|
|
185
|
-
const addExpectedReference = (expectedReferencesByPath, expectedPath, expectedReference) => {
|
|
186
|
-
if (!expectedReferencesByPath.has(expectedPath)) {
|
|
187
|
-
expectedReferencesByPath.set(expectedPath, []);
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
expectedReferencesByPath.get(expectedPath).push(expectedReference);
|
|
191
|
-
};
|
|
192
|
-
|
|
193
|
-
const addGlobalImageCandidate = (globalImageCandidatesByName, imageName, candidate) => {
|
|
194
|
-
if (!globalImageCandidatesByName.has(imageName)) {
|
|
195
|
-
globalImageCandidatesByName.set(imageName, []);
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
globalImageCandidatesByName.get(imageName).push(candidate);
|
|
199
|
-
};
|
|
200
|
-
|
|
201
|
-
const addConflictingMoveIssues = () => {
|
|
202
|
-
const movesBySource = new Map();
|
|
203
|
-
const movesByDestination = new Map();
|
|
204
|
-
|
|
205
|
-
for (const move of imageMoves) {
|
|
206
|
-
const sourceKey = move.from;
|
|
207
|
-
const destinationKey = move.to;
|
|
208
|
-
|
|
209
|
-
if (!movesBySource.has(sourceKey)) {
|
|
210
|
-
movesBySource.set(sourceKey, []);
|
|
211
|
-
}
|
|
212
|
-
movesBySource.get(sourceKey).push(move);
|
|
213
|
-
|
|
214
|
-
if (!movesByDestination.has(destinationKey)) {
|
|
215
|
-
movesByDestination.set(destinationKey, []);
|
|
216
|
-
}
|
|
217
|
-
movesByDestination.get(destinationKey).push(move);
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
for (const moves of movesBySource.values()) {
|
|
221
|
-
const destinations = new Set(moves.map((move) => move.to));
|
|
222
|
-
if (destinations.size <= 1) continue;
|
|
223
|
-
|
|
224
|
-
const firstMove = moves[0];
|
|
225
|
-
addSectionIssue(firstMove.contentFile, firstMove.section, {
|
|
226
|
-
severity: 'error',
|
|
227
|
-
message: `Cannot relocate "${firstMove.imageName}" because the same source file is referenced from multiple destinations: ${moves.map((move) => getExpectedImageLabel(move.contentFile, move.imageName)).join(', ')}.`,
|
|
228
|
-
fix: 'Duplicate the image manually or rename one of the image files so each move has a single destination.',
|
|
229
|
-
});
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
for (const moves of movesByDestination.values()) {
|
|
233
|
-
const sources = new Set(moves.map((move) => move.from));
|
|
234
|
-
if (sources.size <= 1) continue;
|
|
235
|
-
|
|
236
|
-
const firstMove = moves[0];
|
|
237
|
-
addSectionIssue(firstMove.contentFile, firstMove.section, {
|
|
238
|
-
severity: 'error',
|
|
239
|
-
message: `Cannot relocate "${firstMove.imageName}" because multiple source files would move to ${getExpectedImageLabel(firstMove.contentFile, firstMove.imageName)}: ${moves.map((move) => getCandidateLabel(move.sourceContentFile ? { contentFile: move.sourceContentFile, imagePath: move.from } : move)).join(', ')}.`,
|
|
240
|
-
fix: 'Move the intended file manually or rename files so the destination is unambiguous.',
|
|
241
|
-
});
|
|
242
|
-
}
|
|
243
|
-
};
|
|
244
|
-
|
|
245
|
-
const validateImageReference = async (
|
|
246
|
-
contentFile,
|
|
247
|
-
section,
|
|
248
|
-
reference,
|
|
249
|
-
globalImageCandidatesByName,
|
|
250
|
-
expectedReferencesByPath,
|
|
251
|
-
) => {
|
|
252
|
-
const imageName = reference.image;
|
|
253
|
-
const expectedPath = getExpectedImagePath(contentFile, imageName);
|
|
254
|
-
const expectedLabel = getExpectedImageLabel(contentFile, imageName);
|
|
255
|
-
const expectedExists = await stat(expectedPath).then((entry) => entry.isFile()).catch(() => false);
|
|
256
|
-
|
|
257
|
-
if (expectedExists) {
|
|
258
|
-
referencedImagePaths.add(expectedPath);
|
|
259
|
-
return expectedPath;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
const candidates = getGlobalImageCandidates(globalImageCandidatesByName, imageName, expectedPath);
|
|
263
|
-
if (candidates.length === 0) {
|
|
264
|
-
addSectionIssue(contentFile, section, {
|
|
265
|
-
severity: 'error',
|
|
266
|
-
message: `Image "${imageName}" does not exist at ${expectedLabel} or anywhere under any page image root.`,
|
|
267
|
-
fix: `Add the source image directly to ${contentFile.imagesLabel}/ or remove the Norna-managed image reference.`,
|
|
268
|
-
});
|
|
269
|
-
return null;
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
if (candidates.length > 1) {
|
|
273
|
-
addSectionIssue(contentFile, section, {
|
|
274
|
-
severity: 'error',
|
|
275
|
-
message: `Cannot relocate "${imageName}". Multiple files with this filename were found: ${candidates.map(getCandidateLabel).join(', ')}.`,
|
|
276
|
-
fix: 'Move the intended file manually or rename files so the move is unambiguous.',
|
|
277
|
-
});
|
|
278
|
-
return null;
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
const sourceCandidate = candidates[0];
|
|
282
|
-
const sourcePath = sourceCandidate.imagePath;
|
|
283
|
-
const referencesAtCurrentLocation = (expectedReferencesByPath.get(sourcePath) ?? [])
|
|
284
|
-
.filter((expectedReference) => expectedReference.contentFile !== contentFile);
|
|
285
|
-
|
|
286
|
-
if (referencesAtCurrentLocation.length > 0) {
|
|
287
|
-
addSectionIssue(contentFile, section, {
|
|
288
|
-
severity: 'error',
|
|
289
|
-
message: `Cannot relocate "${imageName}" from ${getCandidateLabel(sourceCandidate)} because it is still referenced from ${referencesAtCurrentLocation.map((expectedReference) => getReferenceLabel(expectedReference.contentFile, expectedReference.section)).join(', ')}.`,
|
|
290
|
-
fix: 'Remove the extra reference, duplicate the image file manually, or rename one of the image files so the intended move is unambiguous.',
|
|
291
|
-
});
|
|
292
|
-
return null;
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
if (!imageMoves.some((move) => move.from === sourcePath && move.to === expectedPath)) {
|
|
296
|
-
imageMoves.push({
|
|
297
|
-
imageName,
|
|
298
|
-
from: sourcePath,
|
|
299
|
-
to: expectedPath,
|
|
300
|
-
contentFile,
|
|
301
|
-
sourceContentFile: sourceCandidate.contentFile,
|
|
302
|
-
section,
|
|
303
|
-
});
|
|
304
|
-
}
|
|
305
|
-
referencedImagePaths.add(sourcePath);
|
|
306
|
-
|
|
307
|
-
if (!shouldWrite) {
|
|
308
|
-
addSectionIssue(contentFile, section, {
|
|
309
|
-
severity: 'error',
|
|
310
|
-
message: `Image "${imageName}" is used here but is located in ${getCandidateLabel(sourceCandidate)}.`,
|
|
311
|
-
fix: sourceCandidate.contentFile === contentFile
|
|
312
|
-
? 'Run norna content:sync to move it directly into the current page image root.'
|
|
313
|
-
: `Run norna content:sync to move it from ${getRootLabel(sourceCandidate.contentFile)} to ${getRootLabel(contentFile)}.`,
|
|
314
|
-
});
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
return sourcePath;
|
|
318
|
-
};
|
|
319
|
-
|
|
320
183
|
const warnAboutCarouselAspectRatios = async (contentFile, section, block, sourcePathsByImage) => {
|
|
321
184
|
if (block.type !== 'image-carousel') return;
|
|
322
185
|
|
|
@@ -378,19 +241,30 @@ for (const warning of siteStructure.warnings) {
|
|
|
378
241
|
});
|
|
379
242
|
}
|
|
380
243
|
const allImageFiles = [];
|
|
244
|
+
const managedImageReferences = [];
|
|
381
245
|
const contentFileContexts = [];
|
|
382
|
-
const globalImageCandidatesByName = new Map();
|
|
383
|
-
const globalExpectedReferencesByPath = new Map();
|
|
384
246
|
|
|
385
247
|
for (const contentFile of contentFiles) {
|
|
386
|
-
const { frontmatter, body } = await readSiteFile(contentFile.contentPath, contentFile.contentLabel);
|
|
248
|
+
const { frontmatter, frontmatterBody, body, source } = await readSiteFile(contentFile.contentPath, contentFile.contentLabel);
|
|
387
249
|
const bodyLineOffset = frontmatter.split(/\r?\n/).length - 1;
|
|
250
|
+
const issueCountBeforeFrontmatter = issues.length;
|
|
388
251
|
validateFrontmatterIndentation(frontmatter, addIssue);
|
|
389
252
|
validateContentFrontmatterStructure(frontmatter, addIssue);
|
|
253
|
+
let frontmatterData = {};
|
|
254
|
+
if (!issues.slice(issueCountBeforeFrontmatter).some(({ severity }) => severity === 'error')) {
|
|
255
|
+
try {
|
|
256
|
+
frontmatterData = parseContentFrontmatter(frontmatterBody, contentFile.contentLabel);
|
|
257
|
+
} catch (error) {
|
|
258
|
+
addContentIssue(contentFile, {
|
|
259
|
+
severity: 'error',
|
|
260
|
+
message: error instanceof Error ? error.message : String(error),
|
|
261
|
+
fix: 'Correct the page frontmatter using the content reference and project-local schema help.',
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
}
|
|
390
265
|
|
|
391
|
-
const page = await
|
|
266
|
+
const page = await parsePageMarkdownSource(source, {
|
|
392
267
|
label: contentFile.contentLabel,
|
|
393
|
-
lineOffset: bodyLineOffset,
|
|
394
268
|
});
|
|
395
269
|
const {
|
|
396
270
|
headingIssues: headingIdentifierIssues,
|
|
@@ -421,9 +295,7 @@ for (const contentFile of contentFiles) {
|
|
|
421
295
|
|
|
422
296
|
for (const [imageName, candidates] of imageCandidatesByName) {
|
|
423
297
|
for (const imagePath of candidates) {
|
|
424
|
-
|
|
425
|
-
allImageFiles.push(candidate);
|
|
426
|
-
addGlobalImageCandidate(globalImageCandidatesByName, imageName, candidate);
|
|
298
|
+
allImageFiles.push({ contentFile, imageName, imagePath });
|
|
427
299
|
}
|
|
428
300
|
}
|
|
429
301
|
|
|
@@ -497,14 +369,31 @@ for (const contentFile of contentFiles) {
|
|
|
497
369
|
});
|
|
498
370
|
}
|
|
499
371
|
|
|
372
|
+
for (const error of section.calloutErrors) {
|
|
373
|
+
addSectionIssue(contentFile, section, {
|
|
374
|
+
severity: 'error',
|
|
375
|
+
message: error.message,
|
|
376
|
+
fix: error.fix,
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
for (const error of section.codeFenceErrors) {
|
|
381
|
+
addSectionIssue(contentFile, section, {
|
|
382
|
+
severity: 'error',
|
|
383
|
+
message: error.message,
|
|
384
|
+
fix: error.fix,
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
|
|
500
388
|
for (const reference of section.managedImages) {
|
|
501
|
-
|
|
502
|
-
addExpectedReference(globalExpectedReferencesByPath, expectedPath, { contentFile, section, reference });
|
|
389
|
+
managedImageReferences.push({ contentFile, section, reference });
|
|
503
390
|
}
|
|
504
391
|
}
|
|
505
392
|
|
|
506
393
|
contentFileContexts.push({
|
|
507
394
|
contentFile,
|
|
395
|
+
frontmatterData,
|
|
396
|
+
page,
|
|
508
397
|
sections,
|
|
509
398
|
validSections,
|
|
510
399
|
blockResultsBySection,
|
|
@@ -512,6 +401,85 @@ for (const contentFile of contentFiles) {
|
|
|
512
401
|
});
|
|
513
402
|
}
|
|
514
403
|
|
|
404
|
+
const contentContextByPageDirectory = new Map(contentFileContexts.map((context) => [
|
|
405
|
+
context.contentFile.pageDirectory,
|
|
406
|
+
context,
|
|
407
|
+
]));
|
|
408
|
+
const listedNavigationTree = getListedSiteNavigationTree(siteStructure.nodes.map((node) => {
|
|
409
|
+
const context = contentContextByPageDirectory.get(node.pageDirectory);
|
|
410
|
+
return {
|
|
411
|
+
node: {
|
|
412
|
+
...node,
|
|
413
|
+
navigation: {
|
|
414
|
+
listed: node.kind === 'category'
|
|
415
|
+
? true
|
|
416
|
+
: context?.frontmatterData.navigation?.listed ?? true,
|
|
417
|
+
},
|
|
418
|
+
},
|
|
419
|
+
};
|
|
420
|
+
}));
|
|
421
|
+
|
|
422
|
+
for (const context of contentFileContexts) {
|
|
423
|
+
const childPages = getDirectChildPages(listedNavigationTree, context.contentFile.pagePath);
|
|
424
|
+
if (childPages.length > 0) continue;
|
|
425
|
+
|
|
426
|
+
for (const section of context.sections) {
|
|
427
|
+
if (!context.validSections.has(section)) continue;
|
|
428
|
+
for (const block of section.blocks) {
|
|
429
|
+
if (block.type !== 'page-list') continue;
|
|
430
|
+
addSectionIssue(context.contentFile, section, {
|
|
431
|
+
severity: 'error',
|
|
432
|
+
message: `page-list on line ${block.line} has no listed direct child pages to display. Navigation categories are not pages.`,
|
|
433
|
+
fix: 'Add a listed direct child page or remove the block.',
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const siteLinkGraph = createSiteLinkGraph({
|
|
440
|
+
pageDocuments: contentFileContexts.map(({ contentFile, frontmatterData, page }) => ({
|
|
441
|
+
contentFile,
|
|
442
|
+
data: frontmatterData,
|
|
443
|
+
document: page,
|
|
444
|
+
})),
|
|
445
|
+
publicFiles: await getSitePublicFiles(),
|
|
446
|
+
siteStructure,
|
|
447
|
+
generatedRoutes: getGeneratedSiteRoutes({ searchEnabled: projectConfig.search.enabled }),
|
|
448
|
+
});
|
|
449
|
+
for (const issue of siteLinkGraph.diagnostics) {
|
|
450
|
+
const issueContentFile = issue.reference?.sourceContentFile ?? issue.contentFile;
|
|
451
|
+
if (issueContentFile) {
|
|
452
|
+
addContentIssue(issueContentFile, issue);
|
|
453
|
+
} else {
|
|
454
|
+
addIssue(issue);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const imageSyncPlan = createImageSyncPlan({
|
|
459
|
+
imageCandidates: allImageFiles,
|
|
460
|
+
references: managedImageReferences,
|
|
461
|
+
reportMisplaced: !shouldWrite,
|
|
462
|
+
});
|
|
463
|
+
const {
|
|
464
|
+
moves: imageMoves,
|
|
465
|
+
referencedImagePaths,
|
|
466
|
+
resolvedPathByReference,
|
|
467
|
+
} = imageSyncPlan;
|
|
468
|
+
const plannerIssuesByReference = new Map();
|
|
469
|
+
const plannerConflictIssues = [];
|
|
470
|
+
|
|
471
|
+
for (const plannerIssue of imageSyncPlan.issues) {
|
|
472
|
+
if (plannerIssue.phase === 'conflict') {
|
|
473
|
+
plannerConflictIssues.push(plannerIssue);
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
if (!plannerIssuesByReference.has(plannerIssue.reference)) {
|
|
478
|
+
plannerIssuesByReference.set(plannerIssue.reference, []);
|
|
479
|
+
}
|
|
480
|
+
plannerIssuesByReference.get(plannerIssue.reference).push(plannerIssue);
|
|
481
|
+
}
|
|
482
|
+
|
|
515
483
|
for (const context of contentFileContexts) {
|
|
516
484
|
const {
|
|
517
485
|
contentFile,
|
|
@@ -536,7 +504,7 @@ for (const context of contentFileContexts) {
|
|
|
536
504
|
addSectionIssue(contentFile, section, {
|
|
537
505
|
severity: 'warning',
|
|
538
506
|
message: `Markdown image "${reference.target}" references a local image that is not managed by Norna.`,
|
|
539
|
-
fix: 'Use
|
|
507
|
+
fix: 'Use an image-stack, image-carousel, or card-list block for site images that should be validated, processed and synced.',
|
|
540
508
|
});
|
|
541
509
|
}
|
|
542
510
|
|
|
@@ -552,21 +520,19 @@ for (const context of contentFileContexts) {
|
|
|
552
520
|
if (block.type === 'image-carousel' && block.images.length < 2) {
|
|
553
521
|
addSectionIssue(contentFile, section, {
|
|
554
522
|
severity: 'error',
|
|
555
|
-
message: `
|
|
556
|
-
fix: 'Add another image entry, or use
|
|
523
|
+
message: `image-carousel on line ${block.line} contains ${block.images.length} image. An image carousel needs at least two images.`,
|
|
524
|
+
fix: 'Add another image entry, or use image-stack for a single image.',
|
|
557
525
|
});
|
|
558
526
|
}
|
|
559
527
|
}
|
|
560
528
|
|
|
561
529
|
const sourcePathsByImage = new Map();
|
|
562
530
|
for (const reference of section.managedImages) {
|
|
563
|
-
const
|
|
564
|
-
contentFile,
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
globalExpectedReferencesByPath,
|
|
569
|
-
);
|
|
531
|
+
for (const plannerIssue of plannerIssuesByReference.get(reference) ?? []) {
|
|
532
|
+
addSectionIssue(plannerIssue.contentFile, plannerIssue.section, plannerIssue.issue);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const sourcePath = resolvedPathByReference.get(reference);
|
|
570
536
|
if (sourcePath) {
|
|
571
537
|
sourcePathsByImage.set(reference.image, sourcePath);
|
|
572
538
|
}
|
|
@@ -578,13 +544,15 @@ for (const context of contentFileContexts) {
|
|
|
578
544
|
}
|
|
579
545
|
}
|
|
580
546
|
|
|
547
|
+
for (const plannerIssue of plannerConflictIssues) {
|
|
548
|
+
addSectionIssue(plannerIssue.contentFile, plannerIssue.section, plannerIssue.issue);
|
|
549
|
+
}
|
|
550
|
+
|
|
581
551
|
const unreferencedImages = allImageFiles
|
|
582
552
|
.filter(({ imagePath }) => !referencedImagePaths.has(imagePath))
|
|
583
553
|
.map(({ contentFile, imagePath }) => `${contentFile.imagesLabel}/${toPosixPath(path.relative(contentFile.imagesDir, imagePath))}`)
|
|
584
554
|
.sort((left, right) => left.localeCompare(right, 'sv'));
|
|
585
555
|
|
|
586
|
-
addConflictingMoveIssues();
|
|
587
|
-
|
|
588
556
|
if (hasErrors()) {
|
|
589
557
|
printReport(shouldWrite ? 'Content sync failed.' : 'Content check failed.', unreferencedImages);
|
|
590
558
|
process.exit(1);
|
|
@@ -609,47 +577,45 @@ if (!canWrite) {
|
|
|
609
577
|
process.exit(1);
|
|
610
578
|
}
|
|
611
579
|
|
|
612
|
-
const
|
|
613
|
-
for (const
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
await rename(move.from, move.to);
|
|
617
|
-
completedMoves.push(move);
|
|
618
|
-
console.log(`Moved image "${move.imageName}" to ${move.contentFile.imagesLabel}/.`);
|
|
619
|
-
} catch (error) {
|
|
620
|
-
const sourceLabel = getCandidateLabel({ contentFile: move.sourceContentFile, imagePath: move.from });
|
|
621
|
-
const destinationLabel = getExpectedImageLabel(move.contentFile, move.imageName);
|
|
622
|
-
const remainingMoves = imageMoves.slice(index);
|
|
623
|
-
const lines = [
|
|
624
|
-
'',
|
|
625
|
-
`Content sync stopped after ${completedMoves.length} of ${imageMoves.length} image moves.`,
|
|
626
|
-
];
|
|
627
|
-
|
|
628
|
-
if (error?.code === 'EXDEV') {
|
|
629
|
-
lines.push(
|
|
630
|
-
`Cannot move "${move.imageName}" because the source and destination are on different filesystems.`,
|
|
631
|
-
`Move it manually from ${sourceLabel} to ${destinationLabel}.`,
|
|
632
|
-
);
|
|
633
|
-
} else {
|
|
634
|
-
lines.push(
|
|
635
|
-
`Could not move "${move.imageName}" from ${sourceLabel} to ${destinationLabel}.`,
|
|
636
|
-
`Filesystem error${error?.code ? ` (${error.code})` : ''}: ${error?.message ?? String(error)}`,
|
|
637
|
-
);
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
if (completedMoves.length > 0) {
|
|
641
|
-
lines.push('', 'Completed moves:', ...getMovePlanLines(completedMoves).slice(1));
|
|
642
|
-
}
|
|
580
|
+
const applyResult = await applyImageSyncPlan(imageMoves);
|
|
581
|
+
for (const move of applyResult.completedMoves) {
|
|
582
|
+
console.log(`Moved image "${move.imageName}" to ${move.contentFile.imagesLabel}/.`);
|
|
583
|
+
}
|
|
643
584
|
|
|
585
|
+
if (applyResult.failedMove) {
|
|
586
|
+
const move = applyResult.failedMove;
|
|
587
|
+
const error = applyResult.error;
|
|
588
|
+
const sourceLabel = getImageCandidateLabel({ contentFile: move.sourceContentFile, imagePath: move.from });
|
|
589
|
+
const destinationLabel = getExpectedImageLabel(move.contentFile, move.imageName);
|
|
590
|
+
const lines = [
|
|
591
|
+
'',
|
|
592
|
+
`Content sync stopped after ${applyResult.completedMoves.length} of ${imageMoves.length} image moves.`,
|
|
593
|
+
];
|
|
594
|
+
|
|
595
|
+
if (error?.code === 'EXDEV') {
|
|
644
596
|
lines.push(
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
597
|
+
`Cannot move "${move.imageName}" because the source and destination are on different filesystems.`,
|
|
598
|
+
`Move it manually from ${sourceLabel} to ${destinationLabel}.`,
|
|
599
|
+
);
|
|
600
|
+
} else {
|
|
601
|
+
lines.push(
|
|
602
|
+
`Could not move "${move.imageName}" from ${sourceLabel} to ${destinationLabel}.`,
|
|
603
|
+
`Filesystem error${error?.code ? ` (${error.code})` : ''}: ${error?.message ?? String(error)}`,
|
|
650
604
|
);
|
|
605
|
+
}
|
|
651
606
|
|
|
652
|
-
|
|
653
|
-
|
|
607
|
+
if (applyResult.completedMoves.length > 0) {
|
|
608
|
+
lines.push('', 'Completed moves:', ...getMovePlanLines(applyResult.completedMoves).slice(1));
|
|
654
609
|
}
|
|
610
|
+
|
|
611
|
+
lines.push(
|
|
612
|
+
'',
|
|
613
|
+
'Remaining moves:',
|
|
614
|
+
...getMovePlanLines(applyResult.remainingMoves).slice(1),
|
|
615
|
+
'',
|
|
616
|
+
'Fix the reported filesystem problem, then run content:sync again. Already completed moves will be kept.',
|
|
617
|
+
);
|
|
618
|
+
|
|
619
|
+
console.error(lines.join('\n'));
|
|
620
|
+
process.exit(1);
|
|
655
621
|
}
|
|
@@ -1,13 +1,19 @@
|
|
|
1
|
-
import { cp, mkdir, readdir, rm } from 'node:fs/promises';
|
|
1
|
+
import { cp, mkdir, readdir, rm, writeFile } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import projectConfig from './lib/project-config.mjs';
|
|
4
|
+
import { createSitemapXml, sitemapFilename } from './lib/sitemap.mjs';
|
|
3
5
|
import {
|
|
4
6
|
astroPublicDir,
|
|
5
7
|
astroPublicLabel,
|
|
6
8
|
sitePublicDir,
|
|
7
9
|
sitePublicLabel,
|
|
8
10
|
} from './lib/site-paths.mjs';
|
|
11
|
+
import { getSiteStructure } from './lib/site-structure.mjs';
|
|
9
12
|
|
|
10
|
-
const keepAstroPublicEntries = new Set([
|
|
13
|
+
const keepAstroPublicEntries = new Set([
|
|
14
|
+
'images',
|
|
15
|
+
...(projectConfig.search.enabled ? ['pagefind'] : []),
|
|
16
|
+
]);
|
|
11
17
|
|
|
12
18
|
const readDirectory = async (directory) => {
|
|
13
19
|
try {
|
|
@@ -21,6 +27,37 @@ const readDirectory = async (directory) => {
|
|
|
21
27
|
}
|
|
22
28
|
};
|
|
23
29
|
|
|
30
|
+
const sourceEntries = await readDirectory(sitePublicDir);
|
|
31
|
+
const generatedPublicFiles = [
|
|
32
|
+
{
|
|
33
|
+
filename: sitemapFilename,
|
|
34
|
+
explanation: `Norna generates ${sitemapFilename} from the public page tree and the URL in site/config.yaml.`,
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
filename: '404.html',
|
|
38
|
+
explanation: 'Norna generates 404.html as the site\'s localized missing-page response.',
|
|
39
|
+
},
|
|
40
|
+
...(projectConfig.search.enabled ? [{
|
|
41
|
+
filename: 'pagefind',
|
|
42
|
+
explanation: 'Norna generates pagefind/ from the rendered editorial content when search is enabled.',
|
|
43
|
+
}] : []),
|
|
44
|
+
];
|
|
45
|
+
for (const generatedFile of generatedPublicFiles) {
|
|
46
|
+
const conflict = sourceEntries.find(({ name }) => name.toLowerCase() === generatedFile.filename);
|
|
47
|
+
if (!conflict) continue;
|
|
48
|
+
|
|
49
|
+
throw new Error([
|
|
50
|
+
`${sitePublicLabel}/${conflict.name} conflicts with Norna's generated ${generatedFile.filename}.`,
|
|
51
|
+
`Remove that source file. ${generatedFile.explanation}`,
|
|
52
|
+
].join('\n'));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const siteStructure = await getSiteStructure();
|
|
56
|
+
const sitemapXml = createSitemapXml({
|
|
57
|
+
siteStructure,
|
|
58
|
+
siteUrl: projectConfig.site.url,
|
|
59
|
+
});
|
|
60
|
+
|
|
24
61
|
await mkdir(astroPublicDir, { recursive: true });
|
|
25
62
|
|
|
26
63
|
for (const entry of await readDirectory(astroPublicDir)) {
|
|
@@ -31,7 +68,7 @@ for (const entry of await readDirectory(astroPublicDir)) {
|
|
|
31
68
|
await rm(path.join(astroPublicDir, entry.name), { force: true, recursive: true });
|
|
32
69
|
}
|
|
33
70
|
|
|
34
|
-
for (const entry of
|
|
71
|
+
for (const entry of sourceEntries) {
|
|
35
72
|
await cp(
|
|
36
73
|
path.join(sitePublicDir, entry.name),
|
|
37
74
|
path.join(astroPublicDir, entry.name),
|
|
@@ -39,4 +76,7 @@ for (const entry of await readDirectory(sitePublicDir)) {
|
|
|
39
76
|
);
|
|
40
77
|
}
|
|
41
78
|
|
|
79
|
+
await writeFile(path.join(astroPublicDir, sitemapFilename), sitemapXml);
|
|
80
|
+
|
|
42
81
|
console.log(`Synced ${sitePublicLabel}/ to ${astroPublicLabel}/.`);
|
|
82
|
+
console.log(`Generated ${astroPublicLabel}/${sitemapFilename} for ${siteStructure.contentFiles.length} public page${siteStructure.contentFiles.length === 1 ? '' : 's'}.`);
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { Check, Copy, X } from '@lucide/astro';
|
|
3
|
+
import projectConfig from '../../scripts/lib/project-config.mjs';
|
|
4
|
+
|
|
5
|
+
const labels = {
|
|
6
|
+
copy: projectConfig.locale.labels.copyCode,
|
|
7
|
+
copied: projectConfig.locale.labels.codeCopied,
|
|
8
|
+
failed: projectConfig.locale.labels.codeCopyFailed,
|
|
9
|
+
};
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
<template data-code-copy-template>
|
|
13
|
+
<button class="code-block-copy" type="button" aria-label={labels.copy} title={labels.copy}>
|
|
14
|
+
<Copy aria-hidden="true" data-code-copy-icon="copy" size={18} stroke-width={2} />
|
|
15
|
+
<Check aria-hidden="true" data-code-copy-icon="copied" hidden size={18} stroke-width={2} />
|
|
16
|
+
<X aria-hidden="true" data-code-copy-icon="failed" hidden size={18} stroke-width={2} />
|
|
17
|
+
<span class="visually-hidden" aria-live="polite" data-code-copy-status></span>
|
|
18
|
+
</button>
|
|
19
|
+
</template>
|
|
20
|
+
|
|
21
|
+
<script is:inline define:vars={{ labels }}>
|
|
22
|
+
(() => {
|
|
23
|
+
const template = document.querySelector('[data-code-copy-template]');
|
|
24
|
+
if (!(template instanceof HTMLTemplateElement)) return;
|
|
25
|
+
|
|
26
|
+
const resetTimers = new WeakMap();
|
|
27
|
+
const copyText = async (text) => {
|
|
28
|
+
if (navigator.clipboard?.writeText) {
|
|
29
|
+
try {
|
|
30
|
+
await navigator.clipboard.writeText(text);
|
|
31
|
+
return;
|
|
32
|
+
} catch {}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const input = document.createElement('textarea');
|
|
36
|
+
input.value = text;
|
|
37
|
+
input.setAttribute('readonly', '');
|
|
38
|
+
input.style.cssText = 'position:fixed;inset:0 auto auto 0;opacity:0;pointer-events:none';
|
|
39
|
+
document.body.append(input);
|
|
40
|
+
input.select();
|
|
41
|
+
const copied = document.execCommand('copy');
|
|
42
|
+
input.remove();
|
|
43
|
+
if (!copied) throw new Error('The browser rejected the copy command.');
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const showState = (button, state) => {
|
|
47
|
+
button.querySelectorAll('[data-code-copy-icon]').forEach((icon) => {
|
|
48
|
+
icon.hidden = icon.getAttribute('data-code-copy-icon') !== state;
|
|
49
|
+
});
|
|
50
|
+
const status = button.querySelector('[data-code-copy-status]');
|
|
51
|
+
if (status) status.textContent = state === 'copied' ? labels.copied : labels.failed;
|
|
52
|
+
button.dataset.copyState = state;
|
|
53
|
+
|
|
54
|
+
clearTimeout(resetTimers.get(button));
|
|
55
|
+
resetTimers.set(button, setTimeout(() => {
|
|
56
|
+
button.querySelectorAll('[data-code-copy-icon]').forEach((icon) => {
|
|
57
|
+
icon.hidden = icon.getAttribute('data-code-copy-icon') !== 'copy';
|
|
58
|
+
});
|
|
59
|
+
if (status) status.textContent = '';
|
|
60
|
+
delete button.dataset.copyState;
|
|
61
|
+
}, 2000));
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
document.querySelectorAll('#main-content .section-markdown pre > code').forEach((code) => {
|
|
65
|
+
const pre = code.parentElement;
|
|
66
|
+
if (!pre || pre.parentElement?.classList.contains('code-block')) return;
|
|
67
|
+
|
|
68
|
+
const codeExample = pre.parentElement?.classList.contains('norna-code-example')
|
|
69
|
+
? pre.parentElement
|
|
70
|
+
: null;
|
|
71
|
+
const wrapper = codeExample ?? document.createElement('div');
|
|
72
|
+
wrapper.classList.add('code-block');
|
|
73
|
+
if (!codeExample) {
|
|
74
|
+
pre.before(wrapper);
|
|
75
|
+
wrapper.append(pre);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const button = template.content.firstElementChild?.cloneNode(true);
|
|
79
|
+
if (!(button instanceof HTMLButtonElement)) return;
|
|
80
|
+
wrapper.append(button);
|
|
81
|
+
button.addEventListener('click', async () => {
|
|
82
|
+
if (button.dataset.copyBusy === 'true') return;
|
|
83
|
+
button.dataset.copyBusy = 'true';
|
|
84
|
+
try {
|
|
85
|
+
await copyText(code.textContent ?? '');
|
|
86
|
+
showState(button, 'copied');
|
|
87
|
+
} catch {
|
|
88
|
+
showState(button, 'failed');
|
|
89
|
+
} finally {
|
|
90
|
+
delete button.dataset.copyBusy;
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
template.remove();
|
|
96
|
+
})();
|
|
97
|
+
</script>
|