@janga/norna 0.7.5 → 0.7.6
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/docs/content.md +3 -5
- package/docs/engine-development.md +1 -1
- package/docs/getting-started.md +2 -1
- package/docs/routes.md +43 -21
- package/docs/site-structure.md +3 -3
- package/examples/dog-gallery/site/content.md +0 -1
- package/examples/dog-gallery/site/routes/{dog-care → 010-dog-care}/route-content.md +0 -1
- package/package.json +3 -2
- package/scripts/deploy-site.mjs +1 -1
- package/scripts/lib/route-model.mjs +17 -0
- package/scripts/lib/site-content.mjs +14 -8
- package/scripts/show-typography.mjs +1 -1
- package/scripts/sync-content-sections.mjs +0 -12
- package/scripts/test-package-check.mjs +2 -3
- package/scripts/test-route-model.mjs +32 -0
- package/src/components/SitePage.astro +3 -4
- package/src/content.config.ts +4 -6
- package/src/lib/sitePages.ts +36 -15
- package/src/pages/[slug].astro +3 -3
- package/starters/project/README.md +2 -2
- package/starters/project/site/content.md +0 -1
- package/starters/project/site/routes/{guide → 010-guide}/route-content.md +0 -1
package/docs/content.md
CHANGED
|
@@ -5,7 +5,7 @@ page metadata, section order, section ids, image rows, and Markdown text for
|
|
|
5
5
|
the homepage.
|
|
6
6
|
|
|
7
7
|
Route pages use the same page and section model in
|
|
8
|
-
`site/routes/<route-
|
|
8
|
+
`site/routes/<NNN-route-id>/route-content.md`. See [Routes](routes.md) for the
|
|
9
9
|
route-specific rules.
|
|
10
10
|
|
|
11
11
|
Site-wide visual defaults belong in [Theme](theme.md). Typography presets and
|
|
@@ -18,8 +18,6 @@ The Astro content schema validates these top-level fields in page files:
|
|
|
18
18
|
|
|
19
19
|
- `title`: required string. Rendered as the document title.
|
|
20
20
|
- `description`: required string. Rendered as the meta description.
|
|
21
|
-
- `slug`: optional route URL slug. It is ignored on the homepage. If omitted on
|
|
22
|
-
a route page, the route folder name is used.
|
|
23
21
|
- `navigation`: optional page navigation metadata. See [Routes](routes.md).
|
|
24
22
|
- `presentation`: optional page-level presentation overrides. See
|
|
25
23
|
[Theme](theme.md) and [Typography](typography.md).
|
|
@@ -101,7 +99,7 @@ Image rows support:
|
|
|
101
99
|
|
|
102
100
|
Source image filenames must be unique across the selected page's image tree.
|
|
103
101
|
The homepage reads images from `site/images/<section-id>/`. Route pages read
|
|
104
|
-
images from `site/routes/<route-
|
|
102
|
+
images from `site/routes/<NNN-route-id>/images/<section-id>/`.
|
|
105
103
|
|
|
106
104
|
## Carousels
|
|
107
105
|
|
|
@@ -206,7 +204,7 @@ presentation:
|
|
|
206
204
|
paragraphSpacing: 0.8em
|
|
207
205
|
```
|
|
208
206
|
|
|
209
|
-
Top-level page frontmatter may contain only `title`, `description`,
|
|
207
|
+
Top-level page frontmatter may contain only `title`, `description`,
|
|
210
208
|
`navigation`, `presentation`, `frame`, and `sections`. A `gallery` key belongs
|
|
211
209
|
under one `sections[]` item:
|
|
212
210
|
|
|
@@ -160,7 +160,7 @@ and commit their updated `package-lock.json`.
|
|
|
160
160
|
## Rendering Notes
|
|
161
161
|
|
|
162
162
|
The renderer builds the homepage at `/` and optional first-level routes from
|
|
163
|
-
`site/routes/<route-
|
|
163
|
+
`site/routes/<NNN-route-id>/route-content.md`.
|
|
164
164
|
|
|
165
165
|
Navigation has two separate levels:
|
|
166
166
|
|
package/docs/getting-started.md
CHANGED
|
@@ -71,7 +71,8 @@ Commit the generated `package-lock.json` after the first install.
|
|
|
71
71
|
image rows, page/section presentation overrides, alt text, and captions.
|
|
72
72
|
4. Put source images under `site/images/<section-id>/`.
|
|
73
73
|
5. Add optional route pages under
|
|
74
|
-
`site/routes/<route-
|
|
74
|
+
`site/routes/<NNN-route-id>/route-content.md`, for example
|
|
75
|
+
`site/routes/010-about/route-content.md`.
|
|
75
76
|
6. Put static files such as `robots.txt`, `CNAME`, and favicons under
|
|
76
77
|
`site/public/`.
|
|
77
78
|
7. Run:
|
package/docs/routes.md
CHANGED
|
@@ -1,19 +1,56 @@
|
|
|
1
1
|
# Routes
|
|
2
2
|
|
|
3
3
|
`site/content.md` is the homepage and builds to `/`. Optional route pages live
|
|
4
|
-
under `site/routes/<route-
|
|
4
|
+
under `site/routes/<NNN-route-id>/route-content.md` and build to first-level
|
|
5
5
|
URLs.
|
|
6
6
|
|
|
7
7
|
Routes use the same page frontmatter, section frontmatter, image rows, and
|
|
8
8
|
Markdown section model as the homepage. See [Content](content.md) for the page
|
|
9
9
|
and section model.
|
|
10
10
|
|
|
11
|
+
## Route Directory Format
|
|
12
|
+
|
|
13
|
+
Route directories must use:
|
|
14
|
+
|
|
15
|
+
```text
|
|
16
|
+
NNN-route-id
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
`NNN` is a three-digit presentation order from `001` through `999`.
|
|
20
|
+
`route-id` becomes the route identity and URL segment.
|
|
21
|
+
|
|
22
|
+
Valid examples:
|
|
23
|
+
|
|
24
|
+
```text
|
|
25
|
+
010-getting-started
|
|
26
|
+
020-concepts
|
|
27
|
+
120-api-reference
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Invalid examples:
|
|
31
|
+
|
|
32
|
+
```text
|
|
33
|
+
10-about
|
|
34
|
+
000-home
|
|
35
|
+
010_About
|
|
36
|
+
010-About
|
|
37
|
+
010-about-
|
|
38
|
+
010-about--team
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The route id may contain only lowercase `a-z`, numbers, and single hyphens
|
|
42
|
+
between alphanumeric groups. The numeric prefix is not part of the route id or
|
|
43
|
+
URL.
|
|
44
|
+
|
|
45
|
+
Renaming `030-contact/` to `015-contact/` changes navigation order but keeps
|
|
46
|
+
the route id `contact` and URL `/contact/`.
|
|
47
|
+
|
|
11
48
|
## Route File
|
|
12
49
|
|
|
13
50
|
Add a first-level route by creating:
|
|
14
51
|
|
|
15
52
|
```text
|
|
16
|
-
site/routes/about/route-content.md
|
|
53
|
+
site/routes/010-about/route-content.md
|
|
17
54
|
```
|
|
18
55
|
|
|
19
56
|
Minimal route page:
|
|
@@ -24,7 +61,6 @@ title: About
|
|
|
24
61
|
description: About this site.
|
|
25
62
|
navigation:
|
|
26
63
|
label: About
|
|
27
|
-
order: 20
|
|
28
64
|
sections:
|
|
29
65
|
- id: intro
|
|
30
66
|
---
|
|
@@ -36,28 +72,14 @@ Text...
|
|
|
36
72
|
|
|
37
73
|
The example above builds to `/about/`.
|
|
38
74
|
|
|
39
|
-
## Slug
|
|
40
|
-
|
|
41
|
-
If `slug` is omitted, the route folder name is used. If `slug` is set, it must
|
|
42
|
-
use lowercase letters, numbers, and hyphens.
|
|
43
|
-
|
|
44
|
-
```yaml
|
|
45
|
-
slug: about-the-work
|
|
46
|
-
```
|
|
47
|
-
|
|
48
|
-
Keep the route folder and `slug` aligned unless you intentionally need a
|
|
49
|
-
different URL.
|
|
50
|
-
|
|
51
|
-
The homepage ignores `slug`.
|
|
52
|
-
|
|
53
75
|
## Navigation
|
|
54
76
|
|
|
55
77
|
`navigation` may contain:
|
|
56
78
|
|
|
57
79
|
- `include`: optional boolean. Defaults to `true`.
|
|
58
80
|
- `label`: optional string. Defaults to `title`.
|
|
59
|
-
|
|
60
|
-
|
|
81
|
+
|
|
82
|
+
Route order comes from the directory prefix. The homepage is always first.
|
|
61
83
|
|
|
62
84
|
Current route navigation is intended for small sites. This guidance may change
|
|
63
85
|
as route support matures, but the present model is:
|
|
@@ -71,10 +93,10 @@ as route support matures, but the present model is:
|
|
|
71
93
|
|
|
72
94
|
## Route Images
|
|
73
95
|
|
|
74
|
-
Route images live under the route:
|
|
96
|
+
Route images live under the physical route directory:
|
|
75
97
|
|
|
76
98
|
```text
|
|
77
|
-
site/routes/about/images/intro/image.jpg
|
|
99
|
+
site/routes/010-about/images/intro/image.jpg
|
|
78
100
|
```
|
|
79
101
|
|
|
80
102
|
Image references in route frontmatter still use only the filename:
|
package/docs/site-structure.md
CHANGED
|
@@ -15,7 +15,7 @@ site/
|
|
|
15
15
|
|-- images/
|
|
16
16
|
| `-- <section-id>/
|
|
17
17
|
|-- routes/
|
|
18
|
-
| `-- <route-
|
|
18
|
+
| `-- <NNN-route-id>/
|
|
19
19
|
| |-- route-content.md
|
|
20
20
|
| `-- images/
|
|
21
21
|
| `-- <section-id>/
|
|
@@ -44,9 +44,9 @@ Version these files in a site repository:
|
|
|
44
44
|
- `site/content.md`: homepage page file with editable content, section
|
|
45
45
|
definitions, image rows, alt text, and captions. See [Content](content.md).
|
|
46
46
|
- `site/images/<section-id>/`: original source images.
|
|
47
|
-
- `site/routes/<route-
|
|
47
|
+
- `site/routes/<NNN-route-id>/route-content.md`: optional route page files.
|
|
48
48
|
See [Routes](routes.md).
|
|
49
|
-
- `site/routes/<route-
|
|
49
|
+
- `site/routes/<NNN-route-id>/images/<section-id>/`: original source images
|
|
50
50
|
for that route page.
|
|
51
51
|
- `site/public/`: site-specific static public files.
|
|
52
52
|
- `site/.norna/generated-images.json`: generated image manifest used to
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@janga/norna",
|
|
3
3
|
"description": "A command-line publishing workflow for simple static sites.",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.7.
|
|
5
|
+
"version": "0.7.6",
|
|
6
6
|
"license": "GPL-3.0-only",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
@@ -62,8 +62,9 @@
|
|
|
62
62
|
"deploy:watch": "node bin/norna.mjs deploy:watch",
|
|
63
63
|
"doctor": "node bin/norna.mjs doctor",
|
|
64
64
|
"demo:build": "node bin/norna.mjs --site-dir examples/dog-gallery/site build",
|
|
65
|
-
"test": "npm run test:content-check && npm run test:site-public && npm run test:cli-discovery && npm run test:engine-commands && npm run test:ci-lockfile && npm run test:temporary-visibility && npm run test:fixture:build && npm run package:check",
|
|
65
|
+
"test": "npm run test:route-model && npm run test:content-check && npm run test:site-public && npm run test:cli-discovery && npm run test:engine-commands && npm run test:ci-lockfile && npm run test:temporary-visibility && npm run test:fixture:build && npm run package:check",
|
|
66
66
|
"test:fixture:build": "node bin/norna.mjs --site-dir fixtures/basic/site build",
|
|
67
|
+
"test:route-model": "node scripts/test-route-model.mjs",
|
|
67
68
|
"test:content-check": "node scripts/test-content-check.mjs",
|
|
68
69
|
"test:site-public": "node scripts/test-site-public.mjs",
|
|
69
70
|
"test:cli-discovery": "node scripts/test-cli-discovery.mjs",
|
package/scripts/deploy-site.mjs
CHANGED
|
@@ -206,7 +206,7 @@ const assertDeployableStatus = async (entries, expectedImagePaths) => {
|
|
|
206
206
|
if (unexpectedUntracked.length > 0) {
|
|
207
207
|
fail([
|
|
208
208
|
'Refusing to deploy: unexpected untracked files are present.',
|
|
209
|
-
`Only new referenced images under ${siteImagesLabel}/<section-id>/ or ${siteRoutesLabel}/<route-
|
|
209
|
+
`Only new referenced images under ${siteImagesLabel}/<section-id>/ or ${siteRoutesLabel}/<NNN-route-id>/images/<section-id>/ are staged automatically.`,
|
|
210
210
|
...unexpectedUntracked.map((entry) => `- ${formatEntry(entry)}`),
|
|
211
211
|
].join('\n'));
|
|
212
212
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export const routeDirectoryPattern = /^(?!000)(\d{3})-([a-z0-9]+(?:-[a-z0-9]+)*)$/;
|
|
2
|
+
|
|
3
|
+
export const parseRouteDirectory = (routeDirectory, label = 'route directory') => {
|
|
4
|
+
const match = routeDirectory.match(routeDirectoryPattern);
|
|
5
|
+
|
|
6
|
+
if (!match) {
|
|
7
|
+
throw new Error(
|
|
8
|
+
`Invalid ${label} "${routeDirectory}". Route directories must use the form NNN-route-id, for example 010-getting-started. The prefix must be 001-999 and route-id may contain only lowercase letters, numbers, and single hyphens.`,
|
|
9
|
+
);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
return {
|
|
13
|
+
routeDirectory,
|
|
14
|
+
routeOrder: Number.parseInt(match[1], 10),
|
|
15
|
+
routeId: match[2],
|
|
16
|
+
};
|
|
17
|
+
};
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
siteRoutesLabel,
|
|
11
11
|
siteThemeLabel,
|
|
12
12
|
} from './site-paths.mjs';
|
|
13
|
+
import { parseRouteDirectory } from './route-model.mjs';
|
|
13
14
|
|
|
14
15
|
export const supportedImageExtensions = new Set(['.jpg', '.jpeg', '.png']);
|
|
15
16
|
|
|
@@ -17,7 +18,7 @@ const h2Regex = /^##\s+.*$/gm;
|
|
|
17
18
|
const explicitHeadingIdRegex = /\s*\{#([a-z0-9-]+)\}\s*$/;
|
|
18
19
|
const inlineStyleReferenceRegex = /\[[^\]\n]+\]\{\.([a-z][a-z0-9-]*)\}/g;
|
|
19
20
|
const frontmatterDelimiterRegex = /^---\s*$/;
|
|
20
|
-
const knownContentTopLevelFrontmatterKeys = new Set(['title', 'description', '
|
|
21
|
+
const knownContentTopLevelFrontmatterKeys = new Set(['title', 'description', 'navigation', 'presentation', 'frame', 'sections']);
|
|
21
22
|
const knownThemeTopLevelFrontmatterKeys = new Set(['navigation', 'layout', 'gallery', 'typography', 'presentation', 'frame']);
|
|
22
23
|
const knownNestedFrontmatterKeys = new Set([
|
|
23
24
|
'align',
|
|
@@ -55,7 +56,6 @@ const knownNestedFrontmatterKeys = new Set([
|
|
|
55
56
|
'mobile',
|
|
56
57
|
'navigation',
|
|
57
58
|
'overrides',
|
|
58
|
-
'order',
|
|
59
59
|
'paragraphSpacing',
|
|
60
60
|
'pageWidth',
|
|
61
61
|
'preset',
|
|
@@ -85,7 +85,9 @@ export const getContentFiles = async () => {
|
|
|
85
85
|
imagesDir: siteImagesDir,
|
|
86
86
|
imagesLabel: siteImagesLabel,
|
|
87
87
|
isHome: true,
|
|
88
|
-
|
|
88
|
+
routeDirectory: null,
|
|
89
|
+
routeId: null,
|
|
90
|
+
routeOrder: 0,
|
|
89
91
|
}];
|
|
90
92
|
const routeEntries = await readdir(siteRoutesDir, { withFileTypes: true }).catch((error) => {
|
|
91
93
|
if (error?.code === 'ENOENT') {
|
|
@@ -98,19 +100,23 @@ export const getContentFiles = async () => {
|
|
|
98
100
|
for (const entry of routeEntries) {
|
|
99
101
|
if (!entry.isDirectory()) continue;
|
|
100
102
|
|
|
101
|
-
const
|
|
102
|
-
const routeDir = path.join(siteRoutesDir,
|
|
103
|
+
const routeDirectory = entry.name;
|
|
104
|
+
const routeDir = path.join(siteRoutesDir, routeDirectory);
|
|
103
105
|
const routeContentPath = path.join(routeDir, 'route-content.md');
|
|
104
106
|
|
|
105
107
|
if (!(await fileExists(routeContentPath))) continue;
|
|
106
108
|
|
|
109
|
+
const { routeId, routeOrder } = parseRouteDirectory(routeDirectory, `${siteRoutesLabel}/${routeDirectory}`);
|
|
110
|
+
|
|
107
111
|
contentFiles.push({
|
|
108
|
-
contentLabel: `${siteRoutesLabel}/${
|
|
112
|
+
contentLabel: `${siteRoutesLabel}/${routeDirectory}/route-content.md`,
|
|
109
113
|
contentPath: routeContentPath,
|
|
110
114
|
imagesDir: path.join(routeDir, 'images'),
|
|
111
|
-
imagesLabel: `${siteRoutesLabel}/${
|
|
115
|
+
imagesLabel: `${siteRoutesLabel}/${routeDirectory}/images`,
|
|
112
116
|
isHome: false,
|
|
113
|
-
|
|
117
|
+
routeDirectory,
|
|
118
|
+
routeId,
|
|
119
|
+
routeOrder,
|
|
114
120
|
});
|
|
115
121
|
}
|
|
116
122
|
|
|
@@ -359,7 +359,7 @@ const readPageTypography = async (contentFile, themeTypography) => {
|
|
|
359
359
|
|
|
360
360
|
return {
|
|
361
361
|
source: contentFile.contentLabel,
|
|
362
|
-
route: contentFile.isHome ? '/' : `/${contentFile.
|
|
362
|
+
route: contentFile.isHome ? '/' : `/${contentFile.routeId}/`,
|
|
363
363
|
pageTypography,
|
|
364
364
|
sections,
|
|
365
365
|
};
|
|
@@ -164,9 +164,6 @@ const warnAboutCarouselAspectRatios = async () => {
|
|
|
164
164
|
}
|
|
165
165
|
};
|
|
166
166
|
|
|
167
|
-
const getFrontmatterSlug = (frontmatter) =>
|
|
168
|
-
frontmatter.match(/^slug:\s*["']?([^"'\n]+)["']?\s*$/m)?.[1]?.trim() ?? null;
|
|
169
|
-
|
|
170
167
|
const validateRouteContentFiles = async () => {
|
|
171
168
|
const routeContentFiles = (await getContentFiles()).filter((contentFile) => !contentFile.isHome);
|
|
172
169
|
|
|
@@ -175,15 +172,6 @@ const validateRouteContentFiles = async () => {
|
|
|
175
172
|
validateFrontmatterIndentation(routeFrontmatter, addIssue);
|
|
176
173
|
validateContentFrontmatterStructure(routeFrontmatter, addIssue);
|
|
177
174
|
|
|
178
|
-
const slug = getFrontmatterSlug(routeFrontmatter);
|
|
179
|
-
if (slug && slug !== contentFile.routeFolder) {
|
|
180
|
-
addIssue({
|
|
181
|
-
severity: 'warning',
|
|
182
|
-
message: `${contentFile.contentLabel} has slug "${slug}" but its route folder is "${contentFile.routeFolder}".`,
|
|
183
|
-
fix: `Keep slug and route folder aligned unless the page intentionally needs to build at /${slug}/.`,
|
|
184
|
-
});
|
|
185
|
-
}
|
|
186
|
-
|
|
187
175
|
const routeFrontmatterSections = getFrontmatterSections(routeFrontmatter);
|
|
188
176
|
const routeFrontmatterIds = routeFrontmatterSections.map((section) => section.id);
|
|
189
177
|
const routeBodySections = getBodySections(routeBody).sections;
|
|
@@ -190,13 +190,12 @@ try {
|
|
|
190
190
|
packageCheckThemePath,
|
|
191
191
|
packageCheckTheme.replace('brand: Example Site', 'brand: Package Check Brand'),
|
|
192
192
|
);
|
|
193
|
-
await mkdir(path.join(siteProjectRoot, 'site', 'routes', 'about'), { recursive: true });
|
|
194
|
-
await writeFile(path.join(siteProjectRoot, 'site', 'routes', 'about', 'route-content.md'), `---
|
|
193
|
+
await mkdir(path.join(siteProjectRoot, 'site', 'routes', '010-about'), { recursive: true });
|
|
194
|
+
await writeFile(path.join(siteProjectRoot, 'site', 'routes', '010-about', 'route-content.md'), `---
|
|
195
195
|
title: About the site
|
|
196
196
|
description: Route used by package checks.
|
|
197
197
|
navigation:
|
|
198
198
|
label: About
|
|
199
|
-
order: 20
|
|
200
199
|
sections:
|
|
201
200
|
- id: about
|
|
202
201
|
---
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { parseRouteDirectory } from './lib/route-model.mjs';
|
|
3
|
+
|
|
4
|
+
assert.deepEqual(parseRouteDirectory('010-getting-started'), {
|
|
5
|
+
routeDirectory: '010-getting-started',
|
|
6
|
+
routeOrder: 10,
|
|
7
|
+
routeId: 'getting-started',
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
assert.deepEqual(parseRouteDirectory('120-api-reference'), {
|
|
11
|
+
routeDirectory: '120-api-reference',
|
|
12
|
+
routeOrder: 120,
|
|
13
|
+
routeId: 'api-reference',
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
for (const routeDirectory of [
|
|
17
|
+
'000-home',
|
|
18
|
+
'10-about',
|
|
19
|
+
'010_About',
|
|
20
|
+
'010-About',
|
|
21
|
+
'010-about-',
|
|
22
|
+
'010-about--team',
|
|
23
|
+
'010-om-oss!',
|
|
24
|
+
]) {
|
|
25
|
+
assert.throws(
|
|
26
|
+
() => parseRouteDirectory(routeDirectory),
|
|
27
|
+
/Route directories must use the form NNN-route-id/,
|
|
28
|
+
`${routeDirectory} should be rejected`,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
console.log('Route model test passed.');
|
|
@@ -16,13 +16,12 @@ interface Props {
|
|
|
16
16
|
|
|
17
17
|
const { entry } = Astro.props;
|
|
18
18
|
const siteDirectory = (process.env.NORNA_SITE_DIR ?? 'site').trim();
|
|
19
|
+
const sitePages = getSitePages(await getCollection('site'));
|
|
20
|
+
const currentPage = getSitePage(entry);
|
|
19
21
|
const siteContentLabel = entry.id.includes('-route-')
|
|
20
|
-
? `${siteDirectory || 'site'}/routes/${
|
|
22
|
+
? `${siteDirectory || 'site'}/routes/${currentPage.routeDirectory}/route-content.md`
|
|
21
23
|
: `${siteDirectory || 'site'}/content.md`;
|
|
22
24
|
const [theme] = await getCollection('theme');
|
|
23
|
-
const allEntries = await getCollection('site');
|
|
24
|
-
const sitePages = getSitePages(allEntries);
|
|
25
|
-
const currentPage = getSitePage(entry);
|
|
26
25
|
const navigationPages = getNavigationPages(sitePages);
|
|
27
26
|
const homePage = sitePages.find((page) => page.isHome) ?? currentPage;
|
|
28
27
|
const themeData = theme?.data ?? {};
|
package/src/content.config.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { glob } from 'astro/loaders';
|
|
|
3
3
|
import { pathToFileURL } from 'node:url';
|
|
4
4
|
import { z } from 'astro/zod';
|
|
5
5
|
import { siteDir, siteDirLabel } from '../scripts/lib/site-paths.mjs';
|
|
6
|
+
import { parseRouteDirectory } from '../scripts/lib/route-model.mjs';
|
|
6
7
|
import { isDateOnly } from './lib/visibility';
|
|
7
8
|
|
|
8
9
|
const siteEntryId = `${siteDirLabel
|
|
@@ -10,7 +11,6 @@ const siteEntryId = `${siteDirLabel
|
|
|
10
11
|
.replace(/[^a-zA-Z0-9-]+/g, '-')
|
|
11
12
|
.replace(/^-+|-+$/g, '') || 'site'}-content`;
|
|
12
13
|
const contentImageName = z.string().regex(/^[a-z0-9][a-z0-9.-]*\.(jpe?g|png)$/i);
|
|
13
|
-
const routeSlug = z.string().regex(/^[a-z0-9][a-z0-9-]*$/, 'Use lowercase letters, numbers, and hyphens.');
|
|
14
14
|
const colorValue = z.string().regex(
|
|
15
15
|
/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/,
|
|
16
16
|
'Use a hex color such as "#000000".',
|
|
@@ -163,7 +163,6 @@ const frame = z.object({
|
|
|
163
163
|
const pageNavigation = z.object({
|
|
164
164
|
include: z.boolean().optional(),
|
|
165
165
|
label: z.string().optional(),
|
|
166
|
-
order: z.number().int().optional(),
|
|
167
166
|
}).strict();
|
|
168
167
|
const themeNavigation = z.object({
|
|
169
168
|
brand: z.string().min(1).optional(),
|
|
@@ -182,7 +181,6 @@ const galleryItem = z.union([galleryImage, galleryCarousel]);
|
|
|
182
181
|
const siteSchema = z.object({
|
|
183
182
|
title: z.string(),
|
|
184
183
|
description: z.string(),
|
|
185
|
-
slug: routeSlug.optional(),
|
|
186
184
|
navigation: pageNavigation.optional(),
|
|
187
185
|
presentation: pagePresentation.optional(),
|
|
188
186
|
frame: frame.optional(),
|
|
@@ -214,9 +212,9 @@ const site = defineCollection({
|
|
|
214
212
|
return siteEntryId;
|
|
215
213
|
}
|
|
216
214
|
|
|
217
|
-
const
|
|
218
|
-
return
|
|
219
|
-
? `${siteEntryId.replace(/-content$/, '')}-route-${
|
|
215
|
+
const routeDirectory = entry.match(/^routes\/([^/]+)\/route-content\.md$/)?.[1];
|
|
216
|
+
return routeDirectory
|
|
217
|
+
? `${siteEntryId.replace(/-content$/, '')}-route-${parseRouteDirectory(routeDirectory, `route directory routes/${routeDirectory}`).routeDirectory}`
|
|
220
218
|
: entry.replace(/[^a-zA-Z0-9-]+/g, '-').replace(/^-+|-+$/g, '');
|
|
221
219
|
},
|
|
222
220
|
}),
|
package/src/lib/sitePages.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { CollectionEntry } from 'astro:content';
|
|
2
|
+
import { parseRouteDirectory } from '../../scripts/lib/route-model.mjs';
|
|
2
3
|
|
|
3
4
|
type SiteEntry = CollectionEntry<'site'>;
|
|
4
5
|
|
|
@@ -10,9 +11,10 @@ export type SitePage = {
|
|
|
10
11
|
label: string;
|
|
11
12
|
order: number;
|
|
12
13
|
};
|
|
14
|
+
pathSegment: string;
|
|
13
15
|
pathname: string;
|
|
14
|
-
|
|
15
|
-
|
|
16
|
+
routeDirectory: string | null;
|
|
17
|
+
routeId: string;
|
|
16
18
|
title: string;
|
|
17
19
|
};
|
|
18
20
|
|
|
@@ -25,21 +27,21 @@ const stripRouteIdPrefix = (id: string) => {
|
|
|
25
27
|
|
|
26
28
|
export const isHomePageEntry = (entry: SiteEntry) => stripRouteIdPrefix(entry.id) === null;
|
|
27
29
|
|
|
28
|
-
const
|
|
30
|
+
const getRouteDirectory = (entry: SiteEntry) => isHomePageEntry(entry) ? null : stripRouteIdPrefix(entry.id);
|
|
29
31
|
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const routeFolder = getRouteFolder(entry);
|
|
34
|
-
return entry.data.slug ?? routeFolder ?? '';
|
|
32
|
+
const getRouteMetadata = (entry: SiteEntry) => {
|
|
33
|
+
const routeDirectory = getRouteDirectory(entry);
|
|
34
|
+
return routeDirectory ? parseRouteDirectory(routeDirectory, `route directory ${routeDirectory}`) : null;
|
|
35
35
|
};
|
|
36
36
|
|
|
37
|
-
const getPagePathname = (
|
|
37
|
+
const getPagePathname = (pathSegment: string) => pathSegment ? `/${pathSegment}/` : '/';
|
|
38
38
|
|
|
39
39
|
export const getSitePage = (entry: SiteEntry): SitePage => {
|
|
40
40
|
const isHome = isHomePageEntry(entry);
|
|
41
|
-
const
|
|
42
|
-
const
|
|
41
|
+
const routeMetadata = getRouteMetadata(entry);
|
|
42
|
+
const routeDirectory = routeMetadata?.routeDirectory ?? null;
|
|
43
|
+
const routeId = routeMetadata?.routeId ?? '';
|
|
44
|
+
const pathSegment = isHome ? '' : routeId;
|
|
43
45
|
const navigation = entry.data.navigation ?? {};
|
|
44
46
|
|
|
45
47
|
return {
|
|
@@ -48,11 +50,12 @@ export const getSitePage = (entry: SiteEntry): SitePage => {
|
|
|
48
50
|
navigation: {
|
|
49
51
|
include: navigation.include ?? true,
|
|
50
52
|
label: navigation.label ?? entry.data.title,
|
|
51
|
-
order:
|
|
53
|
+
order: isHome ? 0 : routeMetadata?.routeOrder ?? 100,
|
|
52
54
|
},
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
55
|
+
pathSegment,
|
|
56
|
+
pathname: getPagePathname(pathSegment),
|
|
57
|
+
routeDirectory,
|
|
58
|
+
routeId,
|
|
56
59
|
title: entry.data.title,
|
|
57
60
|
};
|
|
58
61
|
};
|
|
@@ -60,6 +63,8 @@ export const getSitePage = (entry: SiteEntry): SitePage => {
|
|
|
60
63
|
export const getSitePages = (entries: SiteEntry[]) => {
|
|
61
64
|
const pages = entries.map(getSitePage);
|
|
62
65
|
const pathnames = new Map<string, SitePage>();
|
|
66
|
+
const routeIds = new Map<string, SitePage>();
|
|
67
|
+
const routeOrders = new Map<number, SitePage>();
|
|
63
68
|
|
|
64
69
|
for (const page of pages) {
|
|
65
70
|
const existing = pathnames.get(page.pathname);
|
|
@@ -68,6 +73,22 @@ export const getSitePages = (entries: SiteEntry[]) => {
|
|
|
68
73
|
}
|
|
69
74
|
|
|
70
75
|
pathnames.set(page.pathname, page);
|
|
76
|
+
|
|
77
|
+
if (page.isHome) continue;
|
|
78
|
+
|
|
79
|
+
const existingRouteId = routeIds.get(page.routeId);
|
|
80
|
+
if (existingRouteId) {
|
|
81
|
+
throw new Error(`Duplicate route id "${page.routeId}" from "${existingRouteId.entry.id}" and "${page.entry.id}".`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
routeIds.set(page.routeId, page);
|
|
85
|
+
|
|
86
|
+
const existingRouteOrder = routeOrders.get(page.navigation.order);
|
|
87
|
+
if (existingRouteOrder) {
|
|
88
|
+
throw new Error(`Duplicate route order "${String(page.navigation.order).padStart(3, '0')}" from "${existingRouteOrder.entry.id}" and "${page.entry.id}".`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
routeOrders.set(page.navigation.order, page);
|
|
71
92
|
}
|
|
72
93
|
|
|
73
94
|
return pages.sort((left, right) => (
|
package/src/pages/[slug].astro
CHANGED
|
@@ -10,7 +10,7 @@ export async function getStaticPaths() {
|
|
|
10
10
|
return pages
|
|
11
11
|
.filter((page) => !page.isHome)
|
|
12
12
|
.map((page) => ({
|
|
13
|
-
params: { slug: page.
|
|
13
|
+
params: { slug: page.pathSegment },
|
|
14
14
|
props: { entry: page.entry },
|
|
15
15
|
}));
|
|
16
16
|
}
|
|
@@ -23,8 +23,8 @@ if (!entry || isHomePageEntry(entry)) {
|
|
|
23
23
|
|
|
24
24
|
const currentPage = getSitePage(entry);
|
|
25
25
|
|
|
26
|
-
if (!currentPage.
|
|
27
|
-
throw new Error(`Route "${entry.id}" needs a
|
|
26
|
+
if (!currentPage.pathSegment) {
|
|
27
|
+
throw new Error(`Route "${entry.id}" needs a route id.`);
|
|
28
28
|
}
|
|
29
29
|
---
|
|
30
30
|
|
|
@@ -24,7 +24,7 @@ npm run dev -- --kill
|
|
|
24
24
|
density, typography rhythm, colors, frame colors, and inline text styles.
|
|
25
25
|
- `site/content.md`: homepage title, sections, placeholders, project summary,
|
|
26
26
|
links, install command, example usage, benefits, next steps, and license.
|
|
27
|
-
- `site/routes/guide/route-content.md`: short secondary page with realistic
|
|
27
|
+
- `site/routes/010-guide/route-content.md`: short secondary page with realistic
|
|
28
28
|
project guide content.
|
|
29
29
|
- `site/public/robots.txt`: static public file copied into the built site.
|
|
30
30
|
|
|
@@ -36,7 +36,7 @@ GitHub Pages workflow uses `npm ci`.
|
|
|
36
36
|
|
|
37
37
|
1. Replace the project name, tagline, links, install command, example usage,
|
|
38
38
|
benefits, use cases, and license in `site/content.md`.
|
|
39
|
-
2. Replace the guide examples in `site/routes/guide/route-content.md`, or
|
|
39
|
+
2. Replace the guide examples in `site/routes/010-guide/route-content.md`, or
|
|
40
40
|
delete the route if the homepage is enough.
|
|
41
41
|
3. Edit `site/theme.md` for brand name, layout density, typography rhythm,
|
|
42
42
|
colors, frame colors, and inline text styles.
|