@janga/norna 0.7.4 → 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 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-folder>/route-content.md`. See [Routes](routes.md) for the
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-folder>/images/<section-id>/`.
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`, `slug`,
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-folder>/route-content.md`.
163
+ `site/routes/<NNN-route-id>/route-content.md`.
164
164
 
165
165
  Navigation has two separate levels:
166
166
 
@@ -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-folder>/route-content.md`.
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-folder>/route-content.md` and build to first-level
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
- - `order`: optional integer. Defaults to `0` for the homepage and `100` for
60
- route pages.
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:
@@ -15,7 +15,7 @@ site/
15
15
  |-- images/
16
16
  | `-- <section-id>/
17
17
  |-- routes/
18
- | `-- <route-folder>/
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-folder>/route-content.md`: optional route page files.
47
+ - `site/routes/<NNN-route-id>/route-content.md`: optional route page files.
48
48
  See [Routes](routes.md).
49
- - `site/routes/<route-folder>/images/<section-id>/`: original source images
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
@@ -3,7 +3,6 @@ title: Local Dog Example
3
3
  description: A small norna example with free dog images.
4
4
  navigation:
5
5
  label: Dog site!
6
- order: 10
7
6
  sections:
8
7
  - id: black-dogs
9
8
  presentation:
@@ -3,7 +3,6 @@ title: Dog care
3
3
  description: A small route for testing multi-page norna navigation.
4
4
  navigation:
5
5
  label: Dog care
6
- order: 20
7
6
  sections:
8
7
  - id: daily-rhythm
9
8
  presentation:
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.4",
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",
@@ -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-folder>/images/<section-id>/ are staged automatically.`,
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', 'slug', 'navigation', 'presentation', 'frame', 'sections']);
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
- routeFolder: null,
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 routeFolder = entry.name;
102
- const routeDir = path.join(siteRoutesDir, routeFolder);
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}/${routeFolder}/route-content.md`,
112
+ contentLabel: `${siteRoutesLabel}/${routeDirectory}/route-content.md`,
109
113
  contentPath: routeContentPath,
110
114
  imagesDir: path.join(routeDir, 'images'),
111
- imagesLabel: `${siteRoutesLabel}/${routeFolder}/images`,
115
+ imagesLabel: `${siteRoutesLabel}/${routeDirectory}/images`,
112
116
  isHome: false,
113
- routeFolder,
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.routeFolder}/`,
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/${entry.id.split('-route-').at(-1)}/route-content.md`
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 ?? {};
@@ -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 routeFolder = entry.match(/^routes\/([^/]+)\/route-content\.md$/)?.[1];
218
- return routeFolder
219
- ? `${siteEntryId.replace(/-content$/, '')}-route-${routeFolder}`
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
  }),
@@ -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
- routeFolder: string | null;
15
- slug: string;
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 getRouteFolder = (entry: SiteEntry) => isHomePageEntry(entry) ? null : stripRouteIdPrefix(entry.id);
30
+ const getRouteDirectory = (entry: SiteEntry) => isHomePageEntry(entry) ? null : stripRouteIdPrefix(entry.id);
29
31
 
30
- const getPageSlug = (entry: SiteEntry) => {
31
- if (isHomePageEntry(entry)) return '';
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 = (slug: string) => slug ? `/${slug}/` : '/';
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 routeFolder = getRouteFolder(entry);
42
- const slug = getPageSlug(entry);
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: navigation.order ?? (isHome ? 0 : 100),
53
+ order: isHome ? 0 : routeMetadata?.routeOrder ?? 100,
52
54
  },
53
- pathname: getPagePathname(slug),
54
- routeFolder,
55
- slug,
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) => (
@@ -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.slug },
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.slug) {
27
- throw new Error(`Route "${entry.id}" needs a slug or route folder name.`);
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.
@@ -3,7 +3,6 @@ title: Your project
3
3
  description: A compact project site starter for an open source developer tool.
4
4
  navigation:
5
5
  label: Home
6
- order: 0
7
6
  sections:
8
7
  - id: intro
9
8
  - id: links
@@ -3,7 +3,6 @@ title: Guide
3
3
  description: Adapt this compact guide for normal project use.
4
4
  navigation:
5
5
  label: Guide
6
- order: 10
7
6
  sections:
8
7
  - id: quickstart
9
8
  - id: configuration