@janga/norna 0.7.22 → 0.7.23
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/astro.config.mjs +3 -0
- package/bin/norna-cli.mjs +2 -2
- package/package.json +17 -3
- package/schemas/category.schema.json +2 -2
- package/schemas/config.schema.json +22 -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 +383 -250
- package/scripts/check-config.mjs +24 -4
- package/scripts/deploy-site.mjs +0 -1
- package/scripts/dev-local.mjs +175 -112
- package/scripts/lib/content-sync-apply.mjs +34 -0
- package/scripts/lib/content-sync-plan.mjs +179 -0
- package/scripts/lib/editor-language-service.mjs +128 -0
- package/scripts/lib/heading-ids.mjs +1 -1
- package/scripts/lib/image-presentation.mjs +2 -0
- package/scripts/lib/markdown-links.mjs +182 -0
- package/scripts/lib/navigation-model.mjs +25 -14
- package/scripts/lib/norna-markdown-blocks.mjs +1 -0
- package/scripts/lib/page-aliases.mjs +164 -0
- package/scripts/lib/page-markdown.mjs +56 -6
- package/scripts/lib/presentation.mjs +19 -37
- package/scripts/lib/project-config.mjs +50 -29
- package/scripts/lib/schema-definitions.mjs +24 -13
- package/scripts/lib/schema-editor-metadata.mjs +43 -30
- package/scripts/lib/schema-value-definitions.mjs +6 -2
- package/scripts/lib/site-content.mjs +26 -4
- package/scripts/lib/site-link-graph.mjs +326 -0
- package/scripts/lib/site-page-urls.mjs +10 -0
- package/scripts/lib/sitemap.mjs +34 -0
- package/scripts/lib/theme-presets.mjs +51 -23
- package/scripts/lib/theme-profiles.mjs +7 -5
- package/scripts/lib/yaml-config.mjs +6 -2
- package/scripts/sync-content-sections.mjs +116 -213
- package/scripts/sync-site-public.mjs +23 -2
- package/src/components/CodeBlockCopyScript.astro +92 -0
- package/src/components/DisplaySettings.astro +4 -4
- package/src/components/ImageCarousel.astro +7 -3
- package/src/components/NavigationPageTree.astro +44 -60
- package/src/components/PageAliasRedirect.astro +49 -0
- package/src/components/PageContentsNavigation.astro +21 -0
- package/src/components/SectionNavigationScript.astro +62 -6
- package/src/components/SiteNavigation.astro +15 -3
- package/src/components/SitePage.astro +42 -2
- package/src/components/SiteSection.astro +5 -0
- package/src/components/SiteTreeNavigation.astro +0 -1
- package/src/content.config.ts +5 -2
- package/src/layouts/BaseLayout.astro +14 -8
- package/src/lib/readerPreferencesScript.mjs +2 -2
- package/src/lib/sitePages.ts +3 -2
- package/src/pages/[...slug].astro +35 -8
- package/src/styles/content.css +494 -0
- package/src/styles/foundations.css +284 -0
- package/src/styles/global.css +6 -2510
- package/src/styles/media.css +523 -0
- package/src/styles/navigation.css +495 -0
- package/src/styles/page-layout.css +437 -0
- package/src/styles/responsive.css +525 -0
- package/starters/basic/README.md +1 -1
|
@@ -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,11 @@ 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
|
+
createSiteLinkGraph,
|
|
26
|
+
getSitePublicFiles,
|
|
27
|
+
} from './lib/site-link-graph.mjs';
|
|
18
28
|
import {
|
|
19
29
|
siteThemeLabel,
|
|
20
30
|
siteThemePath,
|
|
@@ -25,8 +35,6 @@ const shouldWrite = args.has('--write');
|
|
|
25
35
|
const shouldCheck = args.has('--check') || !shouldWrite;
|
|
26
36
|
const skipPrompt = args.has('--yes');
|
|
27
37
|
const issues = [];
|
|
28
|
-
const imageMoves = [];
|
|
29
|
-
const referencedImagePaths = new Set();
|
|
30
38
|
|
|
31
39
|
const addIssue = ({ severity, message, fix, sectionId, sectionLabel }) => {
|
|
32
40
|
issues.push({ severity, message, fix, sectionId, sectionLabel });
|
|
@@ -50,7 +58,7 @@ const addSectionIssue = (contentFile, section, issue) => addIssue({
|
|
|
50
58
|
|
|
51
59
|
const getMovePlanLines = (moves) => [
|
|
52
60
|
'Planned image moves:',
|
|
53
|
-
...moves.map((move) => `- ${
|
|
61
|
+
...moves.map((move) => `- ${getImageCandidateLabel({ contentFile: move.sourceContentFile, imagePath: move.from })} -> ${getExpectedImageLabel(move.contentFile, move.imageName)}`),
|
|
54
62
|
];
|
|
55
63
|
|
|
56
64
|
const promptForWrite = async (moves) => {
|
|
@@ -166,157 +174,6 @@ const getAspectRatioLabel = ({ width, height }) => {
|
|
|
166
174
|
return `${width / divisor}:${height / divisor}`;
|
|
167
175
|
};
|
|
168
176
|
|
|
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
177
|
const warnAboutCarouselAspectRatios = async (contentFile, section, block, sourcePathsByImage) => {
|
|
321
178
|
if (block.type !== 'image-carousel') return;
|
|
322
179
|
|
|
@@ -378,19 +235,30 @@ for (const warning of siteStructure.warnings) {
|
|
|
378
235
|
});
|
|
379
236
|
}
|
|
380
237
|
const allImageFiles = [];
|
|
238
|
+
const managedImageReferences = [];
|
|
381
239
|
const contentFileContexts = [];
|
|
382
|
-
const globalImageCandidatesByName = new Map();
|
|
383
|
-
const globalExpectedReferencesByPath = new Map();
|
|
384
240
|
|
|
385
241
|
for (const contentFile of contentFiles) {
|
|
386
|
-
const { frontmatter, body } = await readSiteFile(contentFile.contentPath, contentFile.contentLabel);
|
|
242
|
+
const { frontmatter, frontmatterBody, body, source } = await readSiteFile(contentFile.contentPath, contentFile.contentLabel);
|
|
387
243
|
const bodyLineOffset = frontmatter.split(/\r?\n/).length - 1;
|
|
244
|
+
const issueCountBeforeFrontmatter = issues.length;
|
|
388
245
|
validateFrontmatterIndentation(frontmatter, addIssue);
|
|
389
246
|
validateContentFrontmatterStructure(frontmatter, addIssue);
|
|
247
|
+
let frontmatterData = {};
|
|
248
|
+
if (!issues.slice(issueCountBeforeFrontmatter).some(({ severity }) => severity === 'error')) {
|
|
249
|
+
try {
|
|
250
|
+
frontmatterData = parseContentFrontmatter(frontmatterBody, contentFile.contentLabel);
|
|
251
|
+
} catch (error) {
|
|
252
|
+
addContentIssue(contentFile, {
|
|
253
|
+
severity: 'error',
|
|
254
|
+
message: error instanceof Error ? error.message : String(error),
|
|
255
|
+
fix: 'Correct the page frontmatter using the content reference and project-local schema help.',
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
}
|
|
390
259
|
|
|
391
|
-
const page = await
|
|
260
|
+
const page = await parsePageMarkdownSource(source, {
|
|
392
261
|
label: contentFile.contentLabel,
|
|
393
|
-
lineOffset: bodyLineOffset,
|
|
394
262
|
});
|
|
395
263
|
const {
|
|
396
264
|
headingIssues: headingIdentifierIssues,
|
|
@@ -421,9 +289,7 @@ for (const contentFile of contentFiles) {
|
|
|
421
289
|
|
|
422
290
|
for (const [imageName, candidates] of imageCandidatesByName) {
|
|
423
291
|
for (const imagePath of candidates) {
|
|
424
|
-
|
|
425
|
-
allImageFiles.push(candidate);
|
|
426
|
-
addGlobalImageCandidate(globalImageCandidatesByName, imageName, candidate);
|
|
292
|
+
allImageFiles.push({ contentFile, imageName, imagePath });
|
|
427
293
|
}
|
|
428
294
|
}
|
|
429
295
|
|
|
@@ -498,13 +364,14 @@ for (const contentFile of contentFiles) {
|
|
|
498
364
|
}
|
|
499
365
|
|
|
500
366
|
for (const reference of section.managedImages) {
|
|
501
|
-
|
|
502
|
-
addExpectedReference(globalExpectedReferencesByPath, expectedPath, { contentFile, section, reference });
|
|
367
|
+
managedImageReferences.push({ contentFile, section, reference });
|
|
503
368
|
}
|
|
504
369
|
}
|
|
505
370
|
|
|
506
371
|
contentFileContexts.push({
|
|
507
372
|
contentFile,
|
|
373
|
+
frontmatterData,
|
|
374
|
+
page,
|
|
508
375
|
sections,
|
|
509
376
|
validSections,
|
|
510
377
|
blockResultsBySection,
|
|
@@ -512,6 +379,44 @@ for (const contentFile of contentFiles) {
|
|
|
512
379
|
});
|
|
513
380
|
}
|
|
514
381
|
|
|
382
|
+
const siteLinkGraph = createSiteLinkGraph({
|
|
383
|
+
pageDocuments: contentFileContexts.map(({ contentFile, frontmatterData, page }) => ({
|
|
384
|
+
contentFile,
|
|
385
|
+
data: frontmatterData,
|
|
386
|
+
document: page,
|
|
387
|
+
})),
|
|
388
|
+
publicFiles: await getSitePublicFiles(),
|
|
389
|
+
siteStructure,
|
|
390
|
+
});
|
|
391
|
+
for (const issue of siteLinkGraph.diagnostics) {
|
|
392
|
+
addContentIssue(issue.reference?.sourceContentFile ?? issue.contentFile, issue);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const imageSyncPlan = createImageSyncPlan({
|
|
396
|
+
imageCandidates: allImageFiles,
|
|
397
|
+
references: managedImageReferences,
|
|
398
|
+
reportMisplaced: !shouldWrite,
|
|
399
|
+
});
|
|
400
|
+
const {
|
|
401
|
+
moves: imageMoves,
|
|
402
|
+
referencedImagePaths,
|
|
403
|
+
resolvedPathByReference,
|
|
404
|
+
} = imageSyncPlan;
|
|
405
|
+
const plannerIssuesByReference = new Map();
|
|
406
|
+
const plannerConflictIssues = [];
|
|
407
|
+
|
|
408
|
+
for (const plannerIssue of imageSyncPlan.issues) {
|
|
409
|
+
if (plannerIssue.phase === 'conflict') {
|
|
410
|
+
plannerConflictIssues.push(plannerIssue);
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (!plannerIssuesByReference.has(plannerIssue.reference)) {
|
|
415
|
+
plannerIssuesByReference.set(plannerIssue.reference, []);
|
|
416
|
+
}
|
|
417
|
+
plannerIssuesByReference.get(plannerIssue.reference).push(plannerIssue);
|
|
418
|
+
}
|
|
419
|
+
|
|
515
420
|
for (const context of contentFileContexts) {
|
|
516
421
|
const {
|
|
517
422
|
contentFile,
|
|
@@ -560,13 +465,11 @@ for (const context of contentFileContexts) {
|
|
|
560
465
|
|
|
561
466
|
const sourcePathsByImage = new Map();
|
|
562
467
|
for (const reference of section.managedImages) {
|
|
563
|
-
const
|
|
564
|
-
contentFile,
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
globalExpectedReferencesByPath,
|
|
569
|
-
);
|
|
468
|
+
for (const plannerIssue of plannerIssuesByReference.get(reference) ?? []) {
|
|
469
|
+
addSectionIssue(plannerIssue.contentFile, plannerIssue.section, plannerIssue.issue);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const sourcePath = resolvedPathByReference.get(reference);
|
|
570
473
|
if (sourcePath) {
|
|
571
474
|
sourcePathsByImage.set(reference.image, sourcePath);
|
|
572
475
|
}
|
|
@@ -578,13 +481,15 @@ for (const context of contentFileContexts) {
|
|
|
578
481
|
}
|
|
579
482
|
}
|
|
580
483
|
|
|
484
|
+
for (const plannerIssue of plannerConflictIssues) {
|
|
485
|
+
addSectionIssue(plannerIssue.contentFile, plannerIssue.section, plannerIssue.issue);
|
|
486
|
+
}
|
|
487
|
+
|
|
581
488
|
const unreferencedImages = allImageFiles
|
|
582
489
|
.filter(({ imagePath }) => !referencedImagePaths.has(imagePath))
|
|
583
490
|
.map(({ contentFile, imagePath }) => `${contentFile.imagesLabel}/${toPosixPath(path.relative(contentFile.imagesDir, imagePath))}`)
|
|
584
491
|
.sort((left, right) => left.localeCompare(right, 'sv'));
|
|
585
492
|
|
|
586
|
-
addConflictingMoveIssues();
|
|
587
|
-
|
|
588
493
|
if (hasErrors()) {
|
|
589
494
|
printReport(shouldWrite ? 'Content sync failed.' : 'Content check failed.', unreferencedImages);
|
|
590
495
|
process.exit(1);
|
|
@@ -609,47 +514,45 @@ if (!canWrite) {
|
|
|
609
514
|
process.exit(1);
|
|
610
515
|
}
|
|
611
516
|
|
|
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
|
-
}
|
|
517
|
+
const applyResult = await applyImageSyncPlan(imageMoves);
|
|
518
|
+
for (const move of applyResult.completedMoves) {
|
|
519
|
+
console.log(`Moved image "${move.imageName}" to ${move.contentFile.imagesLabel}/.`);
|
|
520
|
+
}
|
|
643
521
|
|
|
522
|
+
if (applyResult.failedMove) {
|
|
523
|
+
const move = applyResult.failedMove;
|
|
524
|
+
const error = applyResult.error;
|
|
525
|
+
const sourceLabel = getImageCandidateLabel({ contentFile: move.sourceContentFile, imagePath: move.from });
|
|
526
|
+
const destinationLabel = getExpectedImageLabel(move.contentFile, move.imageName);
|
|
527
|
+
const lines = [
|
|
528
|
+
'',
|
|
529
|
+
`Content sync stopped after ${applyResult.completedMoves.length} of ${imageMoves.length} image moves.`,
|
|
530
|
+
];
|
|
531
|
+
|
|
532
|
+
if (error?.code === 'EXDEV') {
|
|
644
533
|
lines.push(
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
...getMovePlanLines(remainingMoves).slice(1),
|
|
648
|
-
'',
|
|
649
|
-
'Fix the reported filesystem problem, then run content:sync again. Already completed moves will be kept.',
|
|
534
|
+
`Cannot move "${move.imageName}" because the source and destination are on different filesystems.`,
|
|
535
|
+
`Move it manually from ${sourceLabel} to ${destinationLabel}.`,
|
|
650
536
|
);
|
|
537
|
+
} else {
|
|
538
|
+
lines.push(
|
|
539
|
+
`Could not move "${move.imageName}" from ${sourceLabel} to ${destinationLabel}.`,
|
|
540
|
+
`Filesystem error${error?.code ? ` (${error.code})` : ''}: ${error?.message ?? String(error)}`,
|
|
541
|
+
);
|
|
542
|
+
}
|
|
651
543
|
|
|
652
|
-
|
|
653
|
-
|
|
544
|
+
if (applyResult.completedMoves.length > 0) {
|
|
545
|
+
lines.push('', 'Completed moves:', ...getMovePlanLines(applyResult.completedMoves).slice(1));
|
|
654
546
|
}
|
|
547
|
+
|
|
548
|
+
lines.push(
|
|
549
|
+
'',
|
|
550
|
+
'Remaining moves:',
|
|
551
|
+
...getMovePlanLines(applyResult.remainingMoves).slice(1),
|
|
552
|
+
'',
|
|
553
|
+
'Fix the reported filesystem problem, then run content:sync again. Already completed moves will be kept.',
|
|
554
|
+
);
|
|
555
|
+
|
|
556
|
+
console.error(lines.join('\n'));
|
|
557
|
+
process.exit(1);
|
|
655
558
|
}
|
|
@@ -1,11 +1,14 @@
|
|
|
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
13
|
const keepAstroPublicEntries = new Set(['images']);
|
|
11
14
|
|
|
@@ -21,6 +24,21 @@ const readDirectory = async (directory) => {
|
|
|
21
24
|
}
|
|
22
25
|
};
|
|
23
26
|
|
|
27
|
+
const sourceEntries = await readDirectory(sitePublicDir);
|
|
28
|
+
const sitemapConflict = sourceEntries.find(({ name }) => name.toLowerCase() === sitemapFilename);
|
|
29
|
+
if (sitemapConflict) {
|
|
30
|
+
throw new Error([
|
|
31
|
+
`${sitePublicLabel}/${sitemapConflict.name} conflicts with Norna's generated ${sitemapFilename}.`,
|
|
32
|
+
`Remove that source file. Norna generates ${sitemapFilename} from the public page tree and the URL in site/config.yaml.`,
|
|
33
|
+
].join('\n'));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const siteStructure = await getSiteStructure();
|
|
37
|
+
const sitemapXml = createSitemapXml({
|
|
38
|
+
siteStructure,
|
|
39
|
+
siteUrl: projectConfig.site.url,
|
|
40
|
+
});
|
|
41
|
+
|
|
24
42
|
await mkdir(astroPublicDir, { recursive: true });
|
|
25
43
|
|
|
26
44
|
for (const entry of await readDirectory(astroPublicDir)) {
|
|
@@ -31,7 +49,7 @@ for (const entry of await readDirectory(astroPublicDir)) {
|
|
|
31
49
|
await rm(path.join(astroPublicDir, entry.name), { force: true, recursive: true });
|
|
32
50
|
}
|
|
33
51
|
|
|
34
|
-
for (const entry of
|
|
52
|
+
for (const entry of sourceEntries) {
|
|
35
53
|
await cp(
|
|
36
54
|
path.join(sitePublicDir, entry.name),
|
|
37
55
|
path.join(astroPublicDir, entry.name),
|
|
@@ -39,4 +57,7 @@ for (const entry of await readDirectory(sitePublicDir)) {
|
|
|
39
57
|
);
|
|
40
58
|
}
|
|
41
59
|
|
|
60
|
+
await writeFile(path.join(astroPublicDir, sitemapFilename), sitemapXml);
|
|
61
|
+
|
|
42
62
|
console.log(`Synced ${sitePublicLabel}/ to ${astroPublicLabel}/.`);
|
|
63
|
+
console.log(`Generated ${astroPublicLabel}/${sitemapFilename} for ${siteStructure.contentFiles.length} public page${siteStructure.contentFiles.length === 1 ? '' : 's'}.`);
|
|
@@ -0,0 +1,92 @@
|
|
|
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 wrapper = document.createElement('div');
|
|
69
|
+
wrapper.className = 'code-block';
|
|
70
|
+
pre.before(wrapper);
|
|
71
|
+
wrapper.append(pre);
|
|
72
|
+
|
|
73
|
+
const button = template.content.firstElementChild?.cloneNode(true);
|
|
74
|
+
if (!(button instanceof HTMLButtonElement)) return;
|
|
75
|
+
wrapper.append(button);
|
|
76
|
+
button.addEventListener('click', async () => {
|
|
77
|
+
if (button.dataset.copyBusy === 'true') return;
|
|
78
|
+
button.dataset.copyBusy = 'true';
|
|
79
|
+
try {
|
|
80
|
+
await copyText(code.textContent ?? '');
|
|
81
|
+
showState(button, 'copied');
|
|
82
|
+
} catch {
|
|
83
|
+
showState(button, 'failed');
|
|
84
|
+
} finally {
|
|
85
|
+
delete button.dataset.copyBusy;
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
template.remove();
|
|
91
|
+
})();
|
|
92
|
+
</script>
|
|
@@ -20,9 +20,9 @@ interface Props {
|
|
|
20
20
|
const { controls, defaults } = Astro.props;
|
|
21
21
|
const labels = projectConfig.locale.labels;
|
|
22
22
|
const appearanceOptions = [
|
|
23
|
-
{ value: 'system', label: labels.
|
|
24
|
-
{ value: 'light', label: labels.
|
|
25
|
-
{ value: 'dark', label: labels.
|
|
23
|
+
{ value: 'system', label: labels.appearanceSystem },
|
|
24
|
+
{ value: 'light', label: labels.appearanceLight },
|
|
25
|
+
{ value: 'dark', label: labels.appearanceDark },
|
|
26
26
|
] as const;
|
|
27
27
|
const widthOptions = [
|
|
28
28
|
{ value: 'narrow', label: labels.readingWidthNarrow },
|
|
@@ -51,7 +51,7 @@ const widthOptions = [
|
|
|
51
51
|
|
|
52
52
|
{controls.appearance && (
|
|
53
53
|
<fieldset>
|
|
54
|
-
<legend>{labels.
|
|
54
|
+
<legend>{labels.appearance}</legend>
|
|
55
55
|
<div class="display-settings-segmented">
|
|
56
56
|
{appearanceOptions.map((option) => (
|
|
57
57
|
<label>
|
|
@@ -17,7 +17,7 @@ interface Props {
|
|
|
17
17
|
desktop: number;
|
|
18
18
|
mobile: number;
|
|
19
19
|
};
|
|
20
|
-
maxAvailableHeightPercent
|
|
20
|
+
maxAvailableHeightPercent?: {
|
|
21
21
|
desktop: number;
|
|
22
22
|
mobile: number;
|
|
23
23
|
};
|
|
@@ -36,8 +36,12 @@ const carouselAspectRatio = getCarouselAspectRatio(images);
|
|
|
36
36
|
const carouselStyle = carouselAspectRatio
|
|
37
37
|
? [
|
|
38
38
|
`--image-carousel-aspect-ratio: ${carouselAspectRatio}`,
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
...(maxAvailableHeightPercent
|
|
40
|
+
? [
|
|
41
|
+
`--image-carousel-width-from-height-desktop: ${carouselAspectRatio * maxAvailableHeightPercent.desktop}svh`,
|
|
42
|
+
`--image-carousel-width-from-height-mobile: ${carouselAspectRatio * maxAvailableHeightPercent.mobile}svh`,
|
|
43
|
+
]
|
|
44
|
+
: []),
|
|
41
45
|
].join('; ')
|
|
42
46
|
: undefined;
|
|
43
47
|
---
|