@bitbaum/sitekit 0.3.0
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/LICENSE +21 -0
- package/README.md +129 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +12 -0
- package/dist/model.d.ts +28 -0
- package/dist/model.js +42 -0
- package/dist/provenance.d.ts +60 -0
- package/dist/provenance.js +61 -0
- package/dist/react/NavAutoScroll.d.ts +3 -0
- package/dist/react/NavAutoScroll.js +26 -0
- package/dist/react/SiteChrome.d.ts +25 -0
- package/dist/react/SiteChrome.js +21 -0
- package/dist/react/SiteNav.d.ts +22 -0
- package/dist/react/SiteNav.js +32 -0
- package/dist/react/SiteSections.d.ts +16 -0
- package/dist/react/SiteSections.js +42 -0
- package/dist/react/index.d.ts +19 -0
- package/dist/react/index.js +19 -0
- package/dist/react/link.d.ts +18 -0
- package/dist/react/link.js +11 -0
- package/dist/react/sections/BusinessSections.d.ts +41 -0
- package/dist/react/sections/BusinessSections.js +33 -0
- package/dist/react/sections/DataSections.d.ts +19 -0
- package/dist/react/sections/DataSections.js +45 -0
- package/dist/react/sections/FigureSections.d.ts +26 -0
- package/dist/react/sections/FigureSections.js +22 -0
- package/dist/react/sections/HeroSection.d.ts +13 -0
- package/dist/react/sections/HeroSection.js +9 -0
- package/dist/react/sections/Primitives.d.ts +32 -0
- package/dist/react/sections/Primitives.js +21 -0
- package/dist/react/sections/ProseSections.d.ts +25 -0
- package/dist/react/sections/ProseSections.js +22 -0
- package/dist/schema.d.ts +573 -0
- package/dist/schema.js +288 -0
- package/package.json +72 -0
package/dist/schema.js
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The content model: a site is pages, a page is sections, and sections come
|
|
3
|
+
* from a small CLOSED set of shapes.
|
|
4
|
+
*
|
|
5
|
+
* The closed set is the design, not a limitation. A generator emitting data
|
|
6
|
+
* against this schema is a task with a machine-checkable result; a generator
|
|
7
|
+
* emitting components is not. Per-site quality becomes a property of the
|
|
8
|
+
* renderers — fixing one renderer improves every site ever generated.
|
|
9
|
+
*
|
|
10
|
+
* Zod is the single source of truth here: every TypeScript type is inferred
|
|
11
|
+
* from its schema, never written twice. Extracted from substrata's
|
|
12
|
+
* `config/site-content.ts` (2026-08-26), which remains the reference consumer.
|
|
13
|
+
*/
|
|
14
|
+
import { z } from 'zod';
|
|
15
|
+
import { provenanceSchema } from './provenance.js';
|
|
16
|
+
// =====================================================================
|
|
17
|
+
// SECTION PARTS
|
|
18
|
+
// =====================================================================
|
|
19
|
+
/** A stat worth putting at the top of a page. */
|
|
20
|
+
export const siteStatSchema = z.object({
|
|
21
|
+
label: z.string().min(1),
|
|
22
|
+
value: z.string().min(1),
|
|
23
|
+
note: z.string().optional(),
|
|
24
|
+
});
|
|
25
|
+
export const siteCardSchema = z.object({
|
|
26
|
+
title: z.string().min(1),
|
|
27
|
+
body: z.string().min(1),
|
|
28
|
+
/** Short trailing line — a price, a status, a jurisdiction. */
|
|
29
|
+
meta: z.string().optional(),
|
|
30
|
+
/** Small decorative glyph above the title, e.g. an emoji. Rendered aria-hidden. */
|
|
31
|
+
icon: z.string().optional(),
|
|
32
|
+
});
|
|
33
|
+
export const siteDefinitionSchema = z.object({
|
|
34
|
+
term: z.string().min(1),
|
|
35
|
+
detail: z.string().min(1),
|
|
36
|
+
});
|
|
37
|
+
export const siteIndexEntrySchema = z.object({
|
|
38
|
+
label: z.string().min(1),
|
|
39
|
+
/** Small trailing figure — a count, a desk name. Rendered in mono. */
|
|
40
|
+
meta: z.string().optional(),
|
|
41
|
+
/** Fragment this entry jumps to; must match a section's `anchor` on the same page. */
|
|
42
|
+
anchor: z.string().min(1),
|
|
43
|
+
});
|
|
44
|
+
/** A call to action: what the button says and where it goes. */
|
|
45
|
+
export const siteActionSchema = z.object({
|
|
46
|
+
label: z.string().min(1),
|
|
47
|
+
/** A path, a fragment ('#sortiment'), or a full URL. */
|
|
48
|
+
href: z.string().min(1),
|
|
49
|
+
});
|
|
50
|
+
export const siteImageSchema = z.object({
|
|
51
|
+
src: z.string().min(1),
|
|
52
|
+
/** Required — a generated site does not get to skip accessibility. */
|
|
53
|
+
alt: z.string(),
|
|
54
|
+
});
|
|
55
|
+
/**
|
|
56
|
+
* A physical place. These are exactly the fields the provenance rules exist
|
|
57
|
+
* to protect: a plausible-but-wrong address or opening time is the one detail
|
|
58
|
+
* that actively damages the business a site is about.
|
|
59
|
+
*/
|
|
60
|
+
export const siteLocationSchema = z.object({
|
|
61
|
+
name: z.string().min(1),
|
|
62
|
+
/** Eyebrow over the name — 'Sonntags geöffnet', 'Nur Take-away'. */
|
|
63
|
+
note: z.string().optional(),
|
|
64
|
+
address: z.string().min(1),
|
|
65
|
+
phone: z.string().optional(),
|
|
66
|
+
hours: z.array(z.string().min(1)).optional(),
|
|
67
|
+
});
|
|
68
|
+
export const siteFaqItemSchema = z.object({
|
|
69
|
+
question: z.string().min(1),
|
|
70
|
+
answer: z.string().min(1),
|
|
71
|
+
});
|
|
72
|
+
/** Fragment id shared by every section a reader can jump to. */
|
|
73
|
+
const anchor = z.string().min(1).optional();
|
|
74
|
+
// =====================================================================
|
|
75
|
+
// SECTIONS
|
|
76
|
+
// =====================================================================
|
|
77
|
+
/**
|
|
78
|
+
* Opens a page in place of the standard title block. An eyebrow, one display
|
|
79
|
+
* statement, and the lead. Only ever the FIRST section of a page — the page
|
|
80
|
+
* schema enforces it, and the shell suppresses its own header when present so
|
|
81
|
+
* the two cannot both render.
|
|
82
|
+
*/
|
|
83
|
+
export const heroSectionSchema = z.object({
|
|
84
|
+
kind: z.literal('hero'),
|
|
85
|
+
eyebrow: z.string().optional(),
|
|
86
|
+
statement: z.string().min(1),
|
|
87
|
+
lead: z.array(z.string().min(1)),
|
|
88
|
+
/** CTAs. The first renders filled, the rest outlined. A publication has none; a bakery has two. */
|
|
89
|
+
actions: z.array(siteActionSchema).optional(),
|
|
90
|
+
});
|
|
91
|
+
export const proseSectionSchema = z.object({
|
|
92
|
+
kind: z.literal('prose'),
|
|
93
|
+
heading: z.string().optional(),
|
|
94
|
+
anchor,
|
|
95
|
+
paragraphs: z.array(z.string().min(1)).min(1),
|
|
96
|
+
});
|
|
97
|
+
export const statsSectionSchema = z.object({
|
|
98
|
+
kind: z.literal('stats'),
|
|
99
|
+
heading: z.string().optional(),
|
|
100
|
+
anchor,
|
|
101
|
+
stats: z.array(siteStatSchema).min(1),
|
|
102
|
+
});
|
|
103
|
+
/**
|
|
104
|
+
* One number that deserves a picture. The gap between `value` and `of` IS the
|
|
105
|
+
* message — an empty bar is the honest rendering of "nothing sourced yet",
|
|
106
|
+
* and it should look empty.
|
|
107
|
+
*/
|
|
108
|
+
export const meterSectionSchema = z.object({
|
|
109
|
+
kind: z.literal('meter'),
|
|
110
|
+
heading: z.string().optional(),
|
|
111
|
+
anchor,
|
|
112
|
+
label: z.string().min(1),
|
|
113
|
+
value: z.number().nonnegative(),
|
|
114
|
+
of: z.number().nonnegative(),
|
|
115
|
+
caption: z.string().optional(),
|
|
116
|
+
});
|
|
117
|
+
export const cardsSectionSchema = z.object({
|
|
118
|
+
kind: z.literal('cards'),
|
|
119
|
+
heading: z.string().optional(),
|
|
120
|
+
blurb: z.string().optional(),
|
|
121
|
+
anchor,
|
|
122
|
+
columns: z.union([z.literal(2), z.literal(3), z.literal(4)]).optional(),
|
|
123
|
+
cards: z.array(siteCardSchema).min(1),
|
|
124
|
+
});
|
|
125
|
+
export const definitionsSectionSchema = z.object({
|
|
126
|
+
kind: z.literal('definitions'),
|
|
127
|
+
heading: z.string().optional(),
|
|
128
|
+
blurb: z.string().optional(),
|
|
129
|
+
anchor,
|
|
130
|
+
items: z.array(siteDefinitionSchema).min(1),
|
|
131
|
+
});
|
|
132
|
+
/**
|
|
133
|
+
* A split: a figure (or just space) beside a story. The founder, the
|
|
134
|
+
* workshop, the thing that makes this business this business. The one
|
|
135
|
+
* section on a small-business site that is allowed to be personal.
|
|
136
|
+
*/
|
|
137
|
+
export const featureSectionSchema = z.object({
|
|
138
|
+
kind: z.literal('feature'),
|
|
139
|
+
heading: z.string().optional(),
|
|
140
|
+
anchor,
|
|
141
|
+
paragraphs: z.array(z.string().min(1)).min(1),
|
|
142
|
+
/** Set in quotation marks by the renderer — do not quote it yourself. */
|
|
143
|
+
quote: z.string().optional(),
|
|
144
|
+
cta: siteActionSchema.optional(),
|
|
145
|
+
image: siteImageSchema.optional(),
|
|
146
|
+
});
|
|
147
|
+
/** Where the business actually is. See `siteLocationSchema` on why this exists. */
|
|
148
|
+
export const contactSectionSchema = z.object({
|
|
149
|
+
kind: z.literal('contact'),
|
|
150
|
+
heading: z.string().optional(),
|
|
151
|
+
blurb: z.string().optional(),
|
|
152
|
+
anchor,
|
|
153
|
+
locations: z.array(siteLocationSchema).min(1),
|
|
154
|
+
});
|
|
155
|
+
export const faqSectionSchema = z.object({
|
|
156
|
+
kind: z.literal('faq'),
|
|
157
|
+
heading: z.string().optional(),
|
|
158
|
+
anchor,
|
|
159
|
+
items: z.array(siteFaqItemSchema).min(1),
|
|
160
|
+
});
|
|
161
|
+
/** Jump list for a long page. Without one, fifteen tables is a scroll, not a document. */
|
|
162
|
+
export const indexSectionSchema = z.object({
|
|
163
|
+
kind: z.literal('index'),
|
|
164
|
+
heading: z.string().optional(),
|
|
165
|
+
blurb: z.string().optional(),
|
|
166
|
+
entries: z.array(siteIndexEntrySchema).min(1),
|
|
167
|
+
});
|
|
168
|
+
export const tableSectionSchema = z.object({
|
|
169
|
+
kind: z.literal('table'),
|
|
170
|
+
heading: z.string().optional(),
|
|
171
|
+
blurb: z.string().optional(),
|
|
172
|
+
/** Fragment id, so an `index` entry can link straight here. */
|
|
173
|
+
anchor: z.string().optional(),
|
|
174
|
+
columns: z.array(z.string()).min(1),
|
|
175
|
+
rows: z.array(z.array(z.string())),
|
|
176
|
+
/** Column indices rendered in mono — codes, figures, statuses. */
|
|
177
|
+
monoColumns: z.array(z.number().int().nonnegative()).optional(),
|
|
178
|
+
/** Column index whose cell text is also a status keyword to dot-colour. */
|
|
179
|
+
statusColumn: z.number().int().nonnegative().optional(),
|
|
180
|
+
note: z.string().optional(),
|
|
181
|
+
});
|
|
182
|
+
export const siteSectionSchema = z.discriminatedUnion('kind', [
|
|
183
|
+
heroSectionSchema,
|
|
184
|
+
proseSectionSchema,
|
|
185
|
+
statsSectionSchema,
|
|
186
|
+
meterSectionSchema,
|
|
187
|
+
cardsSectionSchema,
|
|
188
|
+
definitionsSectionSchema,
|
|
189
|
+
featureSectionSchema,
|
|
190
|
+
contactSectionSchema,
|
|
191
|
+
faqSectionSchema,
|
|
192
|
+
indexSectionSchema,
|
|
193
|
+
tableSectionSchema,
|
|
194
|
+
]);
|
|
195
|
+
// =====================================================================
|
|
196
|
+
// PAGES AND CHROME
|
|
197
|
+
// =====================================================================
|
|
198
|
+
export const sitePageSchema = z
|
|
199
|
+
.object({
|
|
200
|
+
/** '' is the home page; otherwise a single path segment, e.g. 'map'. */
|
|
201
|
+
path: z.string().regex(/^[a-z0-9-]*$/, 'a single lowercase path segment, or "" for home'),
|
|
202
|
+
/** Label in the site's own navigation. Omit to keep a page out of the nav. */
|
|
203
|
+
navLabel: z.string().optional(),
|
|
204
|
+
/** <title> and the page's h1. */
|
|
205
|
+
title: z.string().min(1),
|
|
206
|
+
/** One line under the h1. */
|
|
207
|
+
intro: z.string().optional(),
|
|
208
|
+
sections: z.array(siteSectionSchema).min(1),
|
|
209
|
+
})
|
|
210
|
+
.superRefine((page, ctx) => {
|
|
211
|
+
page.sections.forEach((section, i) => {
|
|
212
|
+
if (section.kind === 'hero' && i > 0) {
|
|
213
|
+
ctx.addIssue({
|
|
214
|
+
code: 'custom',
|
|
215
|
+
path: ['sections', i],
|
|
216
|
+
message: 'a hero opens a page; it can only be the first section',
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
/** Everything the site chrome needs: the masthead, the nav, the footer. */
|
|
222
|
+
export const siteChromeSchema = z.object({
|
|
223
|
+
name: z.string().min(1),
|
|
224
|
+
tagline: z.string().min(1),
|
|
225
|
+
/** Line in the footer — who runs this and under what rules. */
|
|
226
|
+
footerNote: z.string().min(1),
|
|
227
|
+
/** Canonical host shown in the footer, e.g. 'substrata.orangecat.ch'. */
|
|
228
|
+
host: z.string().optional(),
|
|
229
|
+
});
|
|
230
|
+
/**
|
|
231
|
+
* One object a generator emits: the whole site, plus (optionally) where every
|
|
232
|
+
* field of it came from. This is the machine-checkable boundary between "a
|
|
233
|
+
* model wrote something" and "a site we can stand behind".
|
|
234
|
+
*/
|
|
235
|
+
export const siteSpecSchema = z
|
|
236
|
+
.object({
|
|
237
|
+
chrome: siteChromeSchema,
|
|
238
|
+
pages: z.array(sitePageSchema).min(1),
|
|
239
|
+
provenance: provenanceSchema.optional(),
|
|
240
|
+
})
|
|
241
|
+
.superRefine((spec, ctx) => {
|
|
242
|
+
const seen = new Set();
|
|
243
|
+
spec.pages.forEach((page, i) => {
|
|
244
|
+
if (seen.has(page.path)) {
|
|
245
|
+
ctx.addIssue({
|
|
246
|
+
code: 'custom',
|
|
247
|
+
path: ['pages', i, 'path'],
|
|
248
|
+
message: `duplicate page path '${page.path || '(home)'}'`,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
seen.add(page.path);
|
|
252
|
+
// An index entry that points at nothing is a dead link the reader will
|
|
253
|
+
// actually click — checked per page, where the fragments live. Hero
|
|
254
|
+
// actions get the same check when they point at a fragment.
|
|
255
|
+
const anchors = new Set(page.sections.flatMap((s) => ('anchor' in s && s.anchor ? [s.anchor] : [])));
|
|
256
|
+
page.sections.forEach((section, j) => {
|
|
257
|
+
if (section.kind !== 'hero' || !section.actions)
|
|
258
|
+
return;
|
|
259
|
+
section.actions.forEach((action, k) => {
|
|
260
|
+
if (action.href.startsWith('#') && !anchors.has(action.href.slice(1))) {
|
|
261
|
+
ctx.addIssue({
|
|
262
|
+
code: 'custom',
|
|
263
|
+
path: ['pages', i, 'sections', j, 'actions', k, 'href'],
|
|
264
|
+
message: `action points at '${action.href}', but no section on this page carries that anchor`,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
page.sections.forEach((section, j) => {
|
|
270
|
+
if (section.kind !== 'index')
|
|
271
|
+
return;
|
|
272
|
+
section.entries.forEach((entry, k) => {
|
|
273
|
+
if (!anchors.has(entry.anchor)) {
|
|
274
|
+
ctx.addIssue({
|
|
275
|
+
code: 'custom',
|
|
276
|
+
path: ['pages', i, 'sections', j, 'entries', k, 'anchor'],
|
|
277
|
+
message: `index entry points at '#${entry.anchor}', but no section on this page carries that anchor`,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
/** Nav entry: a label and where it goes, nothing more. */
|
|
285
|
+
export const siteNavItemSchema = z.object({
|
|
286
|
+
path: z.string(),
|
|
287
|
+
label: z.string().min(1),
|
|
288
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bitbaum/sitekit",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "A website as data: a closed union of section shapes validated at runtime, one set of React renderers, and per-field provenance so a generated site can prove it fabricated nothing.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Mao Nakamoto",
|
|
7
|
+
"homepage": "https://github.com/bitbaum/sitekit#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/bitbaum/sitekit.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/bitbaum/sitekit/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"site-generator",
|
|
17
|
+
"content-model",
|
|
18
|
+
"sections",
|
|
19
|
+
"zod",
|
|
20
|
+
"react",
|
|
21
|
+
"headless-cms"
|
|
22
|
+
],
|
|
23
|
+
"type": "module",
|
|
24
|
+
"sideEffects": false,
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=20"
|
|
27
|
+
},
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public",
|
|
30
|
+
"provenance": true
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"dist"
|
|
34
|
+
],
|
|
35
|
+
"exports": {
|
|
36
|
+
".": {
|
|
37
|
+
"types": "./dist/index.d.ts",
|
|
38
|
+
"default": "./dist/index.js"
|
|
39
|
+
},
|
|
40
|
+
"./react": {
|
|
41
|
+
"types": "./dist/react/index.d.ts",
|
|
42
|
+
"default": "./dist/react/index.js"
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"zod": "^4.5.4"
|
|
47
|
+
},
|
|
48
|
+
"peerDependencies": {
|
|
49
|
+
"react": ">=18"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@eslint/js": "^10.0.1",
|
|
53
|
+
"@types/node": "^26.4.1",
|
|
54
|
+
"@types/react": "^19.0.0",
|
|
55
|
+
"eslint": "^10.9.1",
|
|
56
|
+
"globals": "^17.12.0",
|
|
57
|
+
"prettier": "3.9.6",
|
|
58
|
+
"react": "^19.0.0",
|
|
59
|
+
"react-dom": "^19.0.0",
|
|
60
|
+
"typescript": "^6.0.3",
|
|
61
|
+
"typescript-eslint": "^8.69.0"
|
|
62
|
+
},
|
|
63
|
+
"scripts": {
|
|
64
|
+
"build": "tsc -p tsconfig.json",
|
|
65
|
+
"lint": "eslint .",
|
|
66
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
67
|
+
"test": "node --test test/*.test.js",
|
|
68
|
+
"verify": "pnpm run format:check && pnpm run lint && pnpm run typecheck && pnpm run build && pnpm test",
|
|
69
|
+
"format": "prettier --write .",
|
|
70
|
+
"format:check": "prettier --check ."
|
|
71
|
+
}
|
|
72
|
+
}
|