@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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mao Nakamoto
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# @bitbaum/sitekit
|
|
2
|
+
|
|
3
|
+
A website as data.
|
|
4
|
+
|
|
5
|
+
A site is pages; a page is sections; sections come from a **closed** union of
|
|
6
|
+
shapes validated by Zod at runtime. One set of React renderers draws every
|
|
7
|
+
site. A provenance sidecar records where every generated field came from — so
|
|
8
|
+
"we fabricated nothing" is an assertion, not a promise.
|
|
9
|
+
|
|
10
|
+
Extracted from [substrata](https://github.com/bitbaum/substrata)'s hosted-site
|
|
11
|
+
model (2026-08). Decided in orangecat's ADR-0003 (the Site Factory).
|
|
12
|
+
|
|
13
|
+
## Why a closed union
|
|
14
|
+
|
|
15
|
+
A generator emitting **data against this schema** is a task with a
|
|
16
|
+
machine-checkable result; a generator emitting components is not. Per-site
|
|
17
|
+
quality becomes a property of the renderers — fixing the `cards` renderer
|
|
18
|
+
improves every site ever generated. And the schema is where review happens:
|
|
19
|
+
`validateSite()` returns `path: message` errors a model can act on directly.
|
|
20
|
+
|
|
21
|
+
The current kinds: `hero` (with optional CTAs), `prose`, `stats`, `meter`,
|
|
22
|
+
`cards`, `definitions`, `feature` (a figure beside a story), `contact`
|
|
23
|
+
(locations, address, hours — the fields provenance exists to protect), `faq`,
|
|
24
|
+
`index`, `table`. Most kinds take an optional `anchor`, and the schema rejects
|
|
25
|
+
any index entry or hero action that points at a fragment nothing carries. Extending the union is a deliberate act in THIS repo — a new
|
|
26
|
+
kind lands with its schema, its renderer, and its tests in one commit, and
|
|
27
|
+
every consumer gets it on the next version bump.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pnpm add @bitbaum/sitekit
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
ESM-only. `react >= 18` is a peer dependency of `@bitbaum/sitekit/react`; the root
|
|
36
|
+
export is framework-free (a Node generator or CI check can use it without React).
|
|
37
|
+
|
|
38
|
+
## Use
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
import { validateSite, siteNavItems, sitePageAt, pageRendersOwnHeader } from '@bitbaum/sitekit';
|
|
42
|
+
|
|
43
|
+
const result = validateSite(generatorOutput);
|
|
44
|
+
if (!result.success) throw new Error(result.errors.join('\n'));
|
|
45
|
+
const site = result.data; // fully typed SiteSpec from here on
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
```tsx
|
|
49
|
+
import Link from 'next/link';
|
|
50
|
+
import { SiteMasthead, SiteFooter, SiteSections } from '@bitbaum/sitekit/react';
|
|
51
|
+
|
|
52
|
+
<SiteMasthead chrome={site.chrome} navItems={siteNavItems(site.pages)} currentPath={path} Link={Link} />
|
|
53
|
+
<SiteSections sections={page.sections} />
|
|
54
|
+
<SiteFooter chrome={site.chrome} />
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`Link` is the one framework seam: it defaults to a plain `<a>`, which is
|
|
58
|
+
correct everywhere; a Next.js app passes `next/link` to get client-side
|
|
59
|
+
navigation back.
|
|
60
|
+
|
|
61
|
+
## Provenance — the check behind "fabricate no facts"
|
|
62
|
+
|
|
63
|
+
A closed schema invites completion: a model handed a `stats` section wants
|
|
64
|
+
numbers in it. So absence is first-class and typed. The generator emits a
|
|
65
|
+
sidecar keyed by JSON path:
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
import { assertDeliverable, notFoundPaths } from '@bitbaum/sitekit';
|
|
69
|
+
|
|
70
|
+
const provenance = {
|
|
71
|
+
'chrome.name': { kind: 'scraped', url: 'https://cafe-beispiel.ch/' },
|
|
72
|
+
'pages.0.sections.1.paragraphs.0':{ kind: 'operator' }, // a human typed it
|
|
73
|
+
'pages.0.sections.4.cards.0.meta':{ kind: 'inferred' }, // a model invented it
|
|
74
|
+
'pages.0.sections.7.rows.0.1': { kind: 'not-found' }, // we looked; it isn't public
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
assertDeliverable(provenance); // throws: names the inferred path
|
|
78
|
+
notFoundPaths(provenance); // the honest gaps — in a pitch, these ARE the pitch
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`not-found` is the difference between *unknown* and *empty*: it records that
|
|
82
|
+
the looking happened, which is what makes "here is what we could not find out
|
|
83
|
+
about you" an honest sentence instead of an excuse.
|
|
84
|
+
|
|
85
|
+
## The styling contract
|
|
86
|
+
|
|
87
|
+
The renderers emit **semantic utility classes and nothing else** — no colours,
|
|
88
|
+
faces, or radii ship in this package. Each consumer defines the tokens, which
|
|
89
|
+
is how fifty sites share one renderer without looking like one company.
|
|
90
|
+
(Uniform everywhere except aesthetics. Do NOT point consumers at
|
|
91
|
+
`@fleet/design-tokens` values — those make things look like the fleet;
|
|
92
|
+
inherit the *shape*, choose your own values.)
|
|
93
|
+
|
|
94
|
+
Classes the renderers use, to be defined by the consumer's Tailwind theme:
|
|
95
|
+
|
|
96
|
+
| Group | Classes |
|
|
97
|
+
|---|---|
|
|
98
|
+
| Text | `text-fg-primary` `text-fg-secondary` `text-fg-tertiary` `text-fg-muted` |
|
|
99
|
+
| Surfaces | `bg-surface-page` `bg-surface-raised` |
|
|
100
|
+
| Borders | `border-subtle` `border-strong` `divide-subtle` |
|
|
101
|
+
| Accent & status | `bg-accent` `decoration-accent` `bg-status-positive` `bg-status-warning` `bg-status-negative` |
|
|
102
|
+
| Type | `font-heading` `font-mono` `tracking-caps` `tracking-display` |
|
|
103
|
+
| Layout | `max-w-shell` `max-w-prose` `scrollbar-hide` |
|
|
104
|
+
|
|
105
|
+
With Tailwind 4, define them as `@theme` variables (`--color-fg-primary`,
|
|
106
|
+
`--font-heading`, …) and add the package to content scanning:
|
|
107
|
+
|
|
108
|
+
```css
|
|
109
|
+
@import 'tailwindcss';
|
|
110
|
+
@source '../node_modules/@bitbaum/sitekit/dist';
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Substrata's `app/globals.css` is the reference implementation of the contract.
|
|
114
|
+
|
|
115
|
+
## Not in this package
|
|
116
|
+
|
|
117
|
+
- **No design tokens.** See above — that is the point.
|
|
118
|
+
- **No scraper.** `siteFromUrl()` (extraction from a live site, with
|
|
119
|
+
provenance) is the planned next module; the schema is its target.
|
|
120
|
+
- **No HTTP, no framework.** Same rule as every package in this fleet.
|
|
121
|
+
|
|
122
|
+
## Verify
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
pnpm run verify # lint + typecheck + build + test — same command CI runs
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Tests import the package by **name** (`@bitbaum/sitekit`, `@bitbaum/sitekit/react`), so a broken
|
|
129
|
+
`exports` map fails in CI, not at the first consumer.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @bitbaum/sitekit — a website as data.
|
|
3
|
+
*
|
|
4
|
+
* This root export is framework-free: schemas, inferred types, validation and
|
|
5
|
+
* provenance. The React renderers live behind `@bitbaum/sitekit/react` so a generator
|
|
6
|
+
* or a CI check can depend on the model without pulling in React.
|
|
7
|
+
*/
|
|
8
|
+
export { siteSpecSchema, sitePageSchema, siteChromeSchema, siteSectionSchema, siteNavItemSchema, heroSectionSchema, proseSectionSchema, statsSectionSchema, meterSectionSchema, cardsSectionSchema, definitionsSectionSchema, featureSectionSchema, contactSectionSchema, faqSectionSchema, indexSectionSchema, tableSectionSchema, siteActionSchema, siteImageSchema, siteLocationSchema, siteFaqItemSchema, siteStatSchema, siteCardSchema, siteDefinitionSchema, siteIndexEntrySchema, type SiteSpec, type SitePage, type SiteChrome, type SiteSection, type SiteSectionKind, type SiteNavItem, type SiteStat, type SiteCard, type SiteDefinition, type SiteIndexEntry, type SiteAction, type SiteImage, type SiteLocation, type SiteFaqItem, } from './schema.js';
|
|
9
|
+
export { provenanceSchema, provenanceEntrySchema, type Provenance, type ProvenanceEntry, inferredPaths, notFoundPaths, assertDeliverable, } from './provenance.js';
|
|
10
|
+
export { validateSite, siteNavItems, pageRendersOwnHeader, sitePageAt, href } from './model.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @bitbaum/sitekit — a website as data.
|
|
3
|
+
*
|
|
4
|
+
* This root export is framework-free: schemas, inferred types, validation and
|
|
5
|
+
* provenance. The React renderers live behind `@bitbaum/sitekit/react` so a generator
|
|
6
|
+
* or a CI check can depend on the model without pulling in React.
|
|
7
|
+
*/
|
|
8
|
+
export {
|
|
9
|
+
// schemas (Zod is the SSOT; every type below is inferred from one of these)
|
|
10
|
+
siteSpecSchema, sitePageSchema, siteChromeSchema, siteSectionSchema, siteNavItemSchema, heroSectionSchema, proseSectionSchema, statsSectionSchema, meterSectionSchema, cardsSectionSchema, definitionsSectionSchema, featureSectionSchema, contactSectionSchema, faqSectionSchema, indexSectionSchema, tableSectionSchema, siteActionSchema, siteImageSchema, siteLocationSchema, siteFaqItemSchema, siteStatSchema, siteCardSchema, siteDefinitionSchema, siteIndexEntrySchema, } from './schema.js';
|
|
11
|
+
export { provenanceSchema, provenanceEntrySchema, inferredPaths, notFoundPaths, assertDeliverable, } from './provenance.js';
|
|
12
|
+
export { validateSite, siteNavItems, pageRendersOwnHeader, sitePageAt, href } from './model.js';
|
package/dist/model.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers over a validated site. No React, no framework — a Node script
|
|
3
|
+
* generating sites can use everything in this file.
|
|
4
|
+
*/
|
|
5
|
+
import { type SiteNavItem, type SitePage, type SiteSpec } from './schema.js';
|
|
6
|
+
/**
|
|
7
|
+
* Validate anything claiming to be a site — typically a generator's raw
|
|
8
|
+
* output. Structured result in the house shape; `errors` are `path: message`
|
|
9
|
+
* lines a model (or a person) can act on directly.
|
|
10
|
+
*/
|
|
11
|
+
export declare function validateSite(input: unknown): {
|
|
12
|
+
success: true;
|
|
13
|
+
data: SiteSpec;
|
|
14
|
+
} | {
|
|
15
|
+
success: false;
|
|
16
|
+
errors: string[];
|
|
17
|
+
};
|
|
18
|
+
/** Nav entries in page order. Pages without a `navLabel` are omitted. */
|
|
19
|
+
export declare function siteNavItems(pages: SitePage[]): SiteNavItem[];
|
|
20
|
+
/**
|
|
21
|
+
* True when the page opens with its own hero, in which case the shell must not
|
|
22
|
+
* also print a title block. One rule, checked in one place.
|
|
23
|
+
*/
|
|
24
|
+
export declare function pageRendersOwnHeader(page: SitePage): boolean;
|
|
25
|
+
/** @returns the page at this path, or null. */
|
|
26
|
+
export declare function sitePageAt(pages: SitePage[], path: string): SitePage | null;
|
|
27
|
+
/** An in-site link. Root is '/', everything else '/segment'. */
|
|
28
|
+
export declare function href(path?: string): string;
|
package/dist/model.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers over a validated site. No React, no framework — a Node script
|
|
3
|
+
* generating sites can use everything in this file.
|
|
4
|
+
*/
|
|
5
|
+
import { siteSpecSchema } from './schema.js';
|
|
6
|
+
/**
|
|
7
|
+
* Validate anything claiming to be a site — typically a generator's raw
|
|
8
|
+
* output. Structured result in the house shape; `errors` are `path: message`
|
|
9
|
+
* lines a model (or a person) can act on directly.
|
|
10
|
+
*/
|
|
11
|
+
export function validateSite(input) {
|
|
12
|
+
const result = siteSpecSchema.safeParse(input);
|
|
13
|
+
if (result.success)
|
|
14
|
+
return { success: true, data: result.data };
|
|
15
|
+
return {
|
|
16
|
+
success: false,
|
|
17
|
+
errors: result.error.issues.map((issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/** Nav entries in page order. Pages without a `navLabel` are omitted. */
|
|
21
|
+
export function siteNavItems(pages) {
|
|
22
|
+
return pages
|
|
23
|
+
.filter((page) => Boolean(page.navLabel))
|
|
24
|
+
.map((page) => ({ path: page.path, label: page.navLabel }));
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* True when the page opens with its own hero, in which case the shell must not
|
|
28
|
+
* also print a title block. One rule, checked in one place.
|
|
29
|
+
*/
|
|
30
|
+
export function pageRendersOwnHeader(page) {
|
|
31
|
+
return page.sections[0]?.kind === 'hero';
|
|
32
|
+
}
|
|
33
|
+
/** @returns the page at this path, or null. */
|
|
34
|
+
export function sitePageAt(pages, path) {
|
|
35
|
+
const normalised = path.replace(/^\/+|\/+$/g, '');
|
|
36
|
+
return pages.find((page) => page.path === normalised) ?? null;
|
|
37
|
+
}
|
|
38
|
+
/** An in-site link. Root is '/', everything else '/segment'. */
|
|
39
|
+
export function href(path = '') {
|
|
40
|
+
const clean = path.replace(/^\/+|\/+$/g, '');
|
|
41
|
+
return clean ? `/${clean}` : '/';
|
|
42
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where every generated field came from — the check behind "we fabricate no
|
|
3
|
+
* facts".
|
|
4
|
+
*
|
|
5
|
+
* A closed schema invites completion: a model handed a `stats` section wants
|
|
6
|
+
* numbers in it, and a plausible-but-wrong street number is the one detail
|
|
7
|
+
* that actively damages the business a pitch is meant to win. So provenance
|
|
8
|
+
* makes absence first-class and typed:
|
|
9
|
+
*
|
|
10
|
+
* - `scraped` — read from the subject's own public web, with the URL
|
|
11
|
+
* - `operator` — a human typed it and answers for it
|
|
12
|
+
* - `inferred` — a model filled it in. NEVER deliverable in a real pitch.
|
|
13
|
+
* - `not-found` — we looked and could not find it. Unknown is not empty:
|
|
14
|
+
* this is the record that the looking happened, and it is
|
|
15
|
+
* what makes "here is what we could not find out about you"
|
|
16
|
+
* an honest sentence instead of an excuse.
|
|
17
|
+
*
|
|
18
|
+
* The map is a sidecar keyed by JSON path (e.g. `pages.0.sections.2.cards.1.body`)
|
|
19
|
+
* rather than annotations woven through the content, so the renderers stay
|
|
20
|
+
* ignorant of it and the check stays one function.
|
|
21
|
+
*/
|
|
22
|
+
import { z } from 'zod';
|
|
23
|
+
export declare const provenanceEntrySchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
24
|
+
kind: z.ZodLiteral<"scraped">;
|
|
25
|
+
url: z.ZodURL;
|
|
26
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
27
|
+
kind: z.ZodLiteral<"operator">;
|
|
28
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
29
|
+
kind: z.ZodLiteral<"inferred">;
|
|
30
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
31
|
+
kind: z.ZodLiteral<"not-found">;
|
|
32
|
+
}, z.core.$strip>], "kind">;
|
|
33
|
+
export type ProvenanceEntry = z.infer<typeof provenanceEntrySchema>;
|
|
34
|
+
/** JSON path → where that field came from. */
|
|
35
|
+
export declare const provenanceSchema: z.ZodRecord<z.ZodString, z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
36
|
+
kind: z.ZodLiteral<"scraped">;
|
|
37
|
+
url: z.ZodURL;
|
|
38
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
39
|
+
kind: z.ZodLiteral<"operator">;
|
|
40
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
41
|
+
kind: z.ZodLiteral<"inferred">;
|
|
42
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
43
|
+
kind: z.ZodLiteral<"not-found">;
|
|
44
|
+
}, z.core.$strip>], "kind">>;
|
|
45
|
+
export type Provenance = z.infer<typeof provenanceSchema>;
|
|
46
|
+
/** Fields a model invented. A deliverable site has none. */
|
|
47
|
+
export declare function inferredPaths(provenance: Provenance): string[];
|
|
48
|
+
/**
|
|
49
|
+
* Fields we looked for and could not find. These are not defects — shown
|
|
50
|
+
* honestly, they are the pitch: what we could not learn about you in five
|
|
51
|
+
* minutes is what your customers also cannot.
|
|
52
|
+
*/
|
|
53
|
+
export declare function notFoundPaths(provenance: Provenance): string[];
|
|
54
|
+
/**
|
|
55
|
+
* Throws unless every field is scraped, operator-entered, or honestly absent.
|
|
56
|
+
* Call this before anything generated is shown to, or handed to, a real
|
|
57
|
+
* business. The error names every offending path so the fix is a lookup, not
|
|
58
|
+
* a hunt.
|
|
59
|
+
*/
|
|
60
|
+
export declare function assertDeliverable(provenance: Provenance): void;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where every generated field came from — the check behind "we fabricate no
|
|
3
|
+
* facts".
|
|
4
|
+
*
|
|
5
|
+
* A closed schema invites completion: a model handed a `stats` section wants
|
|
6
|
+
* numbers in it, and a plausible-but-wrong street number is the one detail
|
|
7
|
+
* that actively damages the business a pitch is meant to win. So provenance
|
|
8
|
+
* makes absence first-class and typed:
|
|
9
|
+
*
|
|
10
|
+
* - `scraped` — read from the subject's own public web, with the URL
|
|
11
|
+
* - `operator` — a human typed it and answers for it
|
|
12
|
+
* - `inferred` — a model filled it in. NEVER deliverable in a real pitch.
|
|
13
|
+
* - `not-found` — we looked and could not find it. Unknown is not empty:
|
|
14
|
+
* this is the record that the looking happened, and it is
|
|
15
|
+
* what makes "here is what we could not find out about you"
|
|
16
|
+
* an honest sentence instead of an excuse.
|
|
17
|
+
*
|
|
18
|
+
* The map is a sidecar keyed by JSON path (e.g. `pages.0.sections.2.cards.1.body`)
|
|
19
|
+
* rather than annotations woven through the content, so the renderers stay
|
|
20
|
+
* ignorant of it and the check stays one function.
|
|
21
|
+
*/
|
|
22
|
+
import { z } from 'zod';
|
|
23
|
+
export const provenanceEntrySchema = z.discriminatedUnion('kind', [
|
|
24
|
+
z.object({ kind: z.literal('scraped'), url: z.url() }),
|
|
25
|
+
z.object({ kind: z.literal('operator') }),
|
|
26
|
+
z.object({ kind: z.literal('inferred') }),
|
|
27
|
+
z.object({ kind: z.literal('not-found') }),
|
|
28
|
+
]);
|
|
29
|
+
/** JSON path → where that field came from. */
|
|
30
|
+
export const provenanceSchema = z.record(z.string(), provenanceEntrySchema);
|
|
31
|
+
function pathsOf(provenance, kind) {
|
|
32
|
+
return Object.entries(provenance)
|
|
33
|
+
.filter(([, entry]) => entry.kind === kind)
|
|
34
|
+
.map(([path]) => path)
|
|
35
|
+
.sort();
|
|
36
|
+
}
|
|
37
|
+
/** Fields a model invented. A deliverable site has none. */
|
|
38
|
+
export function inferredPaths(provenance) {
|
|
39
|
+
return pathsOf(provenance, 'inferred');
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Fields we looked for and could not find. These are not defects — shown
|
|
43
|
+
* honestly, they are the pitch: what we could not learn about you in five
|
|
44
|
+
* minutes is what your customers also cannot.
|
|
45
|
+
*/
|
|
46
|
+
export function notFoundPaths(provenance) {
|
|
47
|
+
return pathsOf(provenance, 'not-found');
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Throws unless every field is scraped, operator-entered, or honestly absent.
|
|
51
|
+
* Call this before anything generated is shown to, or handed to, a real
|
|
52
|
+
* business. The error names every offending path so the fix is a lookup, not
|
|
53
|
+
* a hunt.
|
|
54
|
+
*/
|
|
55
|
+
export function assertDeliverable(provenance) {
|
|
56
|
+
const inferred = inferredPaths(provenance);
|
|
57
|
+
if (inferred.length > 0) {
|
|
58
|
+
throw new Error(`site is not deliverable: ${inferred.length} field(s) were inferred rather than ` +
|
|
59
|
+
`sourced or left honestly absent:\n ${inferred.join('\n ')}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
/**
|
|
3
|
+
* The one client-side behaviour in the chrome, isolated so everything else
|
|
4
|
+
* stays server-renderable.
|
|
5
|
+
*
|
|
6
|
+
* With eight sections the nav scrolls sideways on a phone, and the active
|
|
7
|
+
* item is frequently off-screen at rest — a visitor arriving on a deep page
|
|
8
|
+
* would see a row of links with no indication that any of them is the page
|
|
9
|
+
* they are on. Scrolling it into view on mount fixes that; there is no
|
|
10
|
+
* CSS-only way to do it.
|
|
11
|
+
*
|
|
12
|
+
* Deliberately takes ONLY serialisable props. The nav itself (with its
|
|
13
|
+
* injected Link component) must stay a server component — a component
|
|
14
|
+
* function cannot cross the server→client boundary, which is exactly the
|
|
15
|
+
* build error this split prevents.
|
|
16
|
+
*/
|
|
17
|
+
import { useEffect } from 'react';
|
|
18
|
+
export function NavAutoScroll({ currentPath }) {
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
const active = document.querySelector('[data-sitekit-nav] [aria-current="page"]');
|
|
21
|
+
// `nearest` keeps the page itself still — `center` would scroll the whole
|
|
22
|
+
// document to the top of the masthead on every navigation.
|
|
23
|
+
active?.scrollIntoView({ inline: 'nearest', block: 'nearest' });
|
|
24
|
+
}, [currentPath]);
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Masthead and footer.
|
|
3
|
+
*
|
|
4
|
+
* The masthead is a rule, not a bar. It sticks so the nav stays reachable on
|
|
5
|
+
* a long page, but it carries no shadow, no fill beyond the page's own
|
|
6
|
+
* surface, and no logo — a hairline and a wordmark are enough, and anything
|
|
7
|
+
* heavier competes with the document.
|
|
8
|
+
*
|
|
9
|
+
* There is deliberately no "hosted on" or "built by" line. A generated site
|
|
10
|
+
* belongs to its subject; its footer has no business advertising anyone else.
|
|
11
|
+
*/
|
|
12
|
+
import type { SiteChrome as SiteChromeSpec, SiteNavItem } from '../schema.js';
|
|
13
|
+
import { type LinkLike } from './link.js';
|
|
14
|
+
interface Props {
|
|
15
|
+
chrome: SiteChromeSpec;
|
|
16
|
+
navItems: SiteNavItem[];
|
|
17
|
+
currentPath: string;
|
|
18
|
+
/** Framework link component (e.g. next/link). Defaults to a plain anchor. */
|
|
19
|
+
Link?: LinkLike;
|
|
20
|
+
}
|
|
21
|
+
export declare function SiteMasthead({ chrome, navItems, currentPath, Link }: Props): import("react").JSX.Element;
|
|
22
|
+
export declare function SiteFooter({ chrome }: {
|
|
23
|
+
chrome: SiteChromeSpec;
|
|
24
|
+
}): import("react").JSX.Element;
|
|
25
|
+
export {};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Masthead and footer.
|
|
4
|
+
*
|
|
5
|
+
* The masthead is a rule, not a bar. It sticks so the nav stays reachable on
|
|
6
|
+
* a long page, but it carries no shadow, no fill beyond the page's own
|
|
7
|
+
* surface, and no logo — a hairline and a wordmark are enough, and anything
|
|
8
|
+
* heavier competes with the document.
|
|
9
|
+
*
|
|
10
|
+
* There is deliberately no "hosted on" or "built by" line. A generated site
|
|
11
|
+
* belongs to its subject; its footer has no business advertising anyone else.
|
|
12
|
+
*/
|
|
13
|
+
import { href } from '../model.js';
|
|
14
|
+
import { DefaultLink } from './link.js';
|
|
15
|
+
import { SiteNav } from './SiteNav.js';
|
|
16
|
+
export function SiteMasthead({ chrome, navItems, currentPath, Link = DefaultLink }) {
|
|
17
|
+
return (_jsx("header", { className: "sticky top-0 z-30 border-b border-subtle bg-surface-page/85 backdrop-blur", children: _jsx("div", { className: "mx-auto max-w-shell px-4 sm:px-6 lg:px-8", children: _jsxs("div", { className: "flex items-center justify-between gap-6 py-2", children: [_jsx(Link, { href: href(), className: "inline-flex min-h-11 shrink-0 items-center rounded focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent", children: _jsx("span", { className: "font-heading text-lg font-semibold tracking-display text-fg-primary", children: chrome.name }) }), _jsx(SiteNav, { items: navItems, currentPath: currentPath, Link: Link })] }) }) }));
|
|
18
|
+
}
|
|
19
|
+
export function SiteFooter({ chrome }) {
|
|
20
|
+
return (_jsx("footer", { className: "mt-24 border-t border-subtle", children: _jsxs("div", { className: "mx-auto max-w-shell px-4 py-12 sm:px-6 lg:px-8", children: [_jsx("p", { className: "max-w-prose text-sm leading-relaxed text-fg-secondary", children: chrome.footerNote }), _jsxs("div", { className: "mt-8 flex flex-col gap-2 border-t border-subtle pt-6 font-mono text-xs uppercase tracking-caps text-fg-muted sm:flex-row sm:items-center sm:justify-between", children: [chrome.host && _jsx("span", { children: chrome.host }), _jsxs("span", { className: "normal-case tracking-normal", children: ["\u00A9 ", chrome.name] })] })] }) }));
|
|
21
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The masthead's section nav.
|
|
3
|
+
*
|
|
4
|
+
* A server component, deliberately: the injected `Link` is a component
|
|
5
|
+
* function, and functions cannot cross into a 'use client' boundary. The one
|
|
6
|
+
* client behaviour — scrolling the active item into view — lives in
|
|
7
|
+
* `NavAutoScroll`, which takes only a string.
|
|
8
|
+
*/
|
|
9
|
+
import type { SiteNavItem } from '../schema.js';
|
|
10
|
+
import { type LinkLike } from './link.js';
|
|
11
|
+
interface Props {
|
|
12
|
+
/**
|
|
13
|
+
* Deliberately NOT `SitePage[]`. Everything the nav receives ends up in the
|
|
14
|
+
* payload of every page it renders on.
|
|
15
|
+
*/
|
|
16
|
+
items: SiteNavItem[];
|
|
17
|
+
currentPath: string;
|
|
18
|
+
/** Framework link component (e.g. next/link). Defaults to a plain anchor. */
|
|
19
|
+
Link?: LinkLike;
|
|
20
|
+
}
|
|
21
|
+
export declare function SiteNav({ items, currentPath, Link }: Props): import("react").JSX.Element;
|
|
22
|
+
export {};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* The masthead's section nav.
|
|
4
|
+
*
|
|
5
|
+
* A server component, deliberately: the injected `Link` is a component
|
|
6
|
+
* function, and functions cannot cross into a 'use client' boundary. The one
|
|
7
|
+
* client behaviour — scrolling the active item into view — lives in
|
|
8
|
+
* `NavAutoScroll`, which takes only a string.
|
|
9
|
+
*/
|
|
10
|
+
import { href } from '../model.js';
|
|
11
|
+
import { DefaultLink } from './link.js';
|
|
12
|
+
import { NavAutoScroll } from './NavAutoScroll.js';
|
|
13
|
+
export function SiteNav({ items, currentPath, Link = DefaultLink }) {
|
|
14
|
+
return (_jsxs("nav", { "data-sitekit-nav": true, "aria-label": "Sections", className: "scrollbar-hide -mx-1 flex flex-nowrap items-center gap-x-1 overflow-x-auto", children: [items.map((item) => {
|
|
15
|
+
const isCurrent = item.path === currentPath;
|
|
16
|
+
return (_jsx(Link, { href: href(item.path), "aria-current": isCurrent ? 'page' : undefined, className: [
|
|
17
|
+
// min-h-11 is the 44px touch floor. `px-2 py-1` on text-xs came out
|
|
18
|
+
// around 28px, which is a fiddly target on a phone and below the
|
|
19
|
+
// floor the rest of the fleet holds. The masthead row drops from
|
|
20
|
+
// py-4 to py-2 to pay for it, so the sticky header does NOT get
|
|
21
|
+
// taller — see the comment in SiteChrome.
|
|
22
|
+
'inline-flex min-h-11 shrink-0 items-center rounded px-2 font-mono text-xs uppercase tracking-caps transition-colors',
|
|
23
|
+
// A consumer's CSS reset can remove the UA focus ring, and this
|
|
24
|
+
// package shipped nothing to replace it — so keyboard users had no
|
|
25
|
+
// visible position in the nav on every site that installs it.
|
|
26
|
+
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent',
|
|
27
|
+
isCurrent
|
|
28
|
+
? 'text-fg-primary underline decoration-accent decoration-2 underline-offset-8'
|
|
29
|
+
: 'text-fg-tertiary hover:text-fg-primary',
|
|
30
|
+
].join(' '), children: item.label }, item.path || 'home'));
|
|
31
|
+
}), _jsx(NavAutoScroll, { currentPath: currentPath })] }));
|
|
32
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dispatches a page's sections to their renderers, and numbers them.
|
|
3
|
+
*
|
|
4
|
+
* One renderer for every site is the point: it is what stops fifty customer
|
|
5
|
+
* websites becoming fifty stylesheets, and it is why a site inherits
|
|
6
|
+
* typography, dark mode and reading measure without its owner thinking about
|
|
7
|
+
* any of them. Section shapes are the closed set in the schema.
|
|
8
|
+
*
|
|
9
|
+
* Numbering counts only sections that carry a heading, so an unheaded lead
|
|
10
|
+
* paragraph or a bare table does not consume a number and leave a gap in the
|
|
11
|
+
* sequence a reader can see.
|
|
12
|
+
*/
|
|
13
|
+
import type { SiteSection } from '../schema.js';
|
|
14
|
+
export declare function SiteSections({ sections }: {
|
|
15
|
+
sections: SiteSection[];
|
|
16
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { HeroSection } from './sections/HeroSection.js';
|
|
3
|
+
import { MeterSection, StatsSection } from './sections/FigureSections.js';
|
|
4
|
+
import { CardsSection, DefinitionsSection, ProseSection } from './sections/ProseSections.js';
|
|
5
|
+
import { IndexSection, TableSection } from './sections/DataSections.js';
|
|
6
|
+
import { ContactSection, FaqSection, FeatureSection } from './sections/BusinessSections.js';
|
|
7
|
+
/** True when a section shows a heading, and therefore takes the next number. */
|
|
8
|
+
function isNumbered(section) {
|
|
9
|
+
return section.kind !== 'hero' && section.kind !== 'table' && Boolean(section.heading);
|
|
10
|
+
}
|
|
11
|
+
export function SiteSections({ sections }) {
|
|
12
|
+
let counter = 0;
|
|
13
|
+
return (_jsx("div", { className: "space-y-16", children: sections.map((section, key) => {
|
|
14
|
+
const index = isNumbered(section) ? ++counter : undefined;
|
|
15
|
+
switch (section.kind) {
|
|
16
|
+
case 'hero':
|
|
17
|
+
return _jsx(HeroSection, { section: section }, key);
|
|
18
|
+
case 'prose':
|
|
19
|
+
return _jsx(ProseSection, { section: section, index: index }, key);
|
|
20
|
+
case 'stats':
|
|
21
|
+
return _jsx(StatsSection, { section: section, index: index }, key);
|
|
22
|
+
case 'meter':
|
|
23
|
+
return _jsx(MeterSection, { section: section, index: index }, key);
|
|
24
|
+
case 'cards':
|
|
25
|
+
return _jsx(CardsSection, { section: section, index: index }, key);
|
|
26
|
+
case 'definitions':
|
|
27
|
+
return _jsx(DefinitionsSection, { section: section, index: index }, key);
|
|
28
|
+
case 'feature':
|
|
29
|
+
return _jsx(FeatureSection, { section: section, index: index }, key);
|
|
30
|
+
case 'contact':
|
|
31
|
+
return _jsx(ContactSection, { section: section, index: index }, key);
|
|
32
|
+
case 'faq':
|
|
33
|
+
return _jsx(FaqSection, { section: section, index: index }, key);
|
|
34
|
+
case 'index':
|
|
35
|
+
return _jsx(IndexSection, { section: section, index: index }, key);
|
|
36
|
+
case 'table':
|
|
37
|
+
return _jsx(TableSection, { section: section }, key);
|
|
38
|
+
default:
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}) }));
|
|
42
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @bitbaum/sitekit/react — the one set of renderers every generated site shares.
|
|
3
|
+
*
|
|
4
|
+
* Emits SEMANTIC utility classes only (`text-fg-primary`, `bg-surface-page`,
|
|
5
|
+
* `font-heading`, …) and ships no colours, faces or radii: each consumer
|
|
6
|
+
* defines those tokens in its own stylesheet. Uniform system, divergent
|
|
7
|
+
* aesthetics — the class contract is in the README.
|
|
8
|
+
*/
|
|
9
|
+
export { SiteSections } from './SiteSections.js';
|
|
10
|
+
export { SiteMasthead, SiteFooter } from './SiteChrome.js';
|
|
11
|
+
export { SiteNav } from './SiteNav.js';
|
|
12
|
+
export { NavAutoScroll } from './NavAutoScroll.js';
|
|
13
|
+
export { DefaultLink, type LinkLike, type LinkProps } from './link.js';
|
|
14
|
+
export { SectionHeading, Blurb, SectionBody } from './sections/Primitives.js';
|
|
15
|
+
export { HeroSection } from './sections/HeroSection.js';
|
|
16
|
+
export { ProseSection, CardsSection, DefinitionsSection } from './sections/ProseSections.js';
|
|
17
|
+
export { StatsSection, MeterSection } from './sections/FigureSections.js';
|
|
18
|
+
export { IndexSection, TableSection } from './sections/DataSections.js';
|
|
19
|
+
export { FeatureSection, ContactSection, FaqSection } from './sections/BusinessSections.js';
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @bitbaum/sitekit/react — the one set of renderers every generated site shares.
|
|
3
|
+
*
|
|
4
|
+
* Emits SEMANTIC utility classes only (`text-fg-primary`, `bg-surface-page`,
|
|
5
|
+
* `font-heading`, …) and ships no colours, faces or radii: each consumer
|
|
6
|
+
* defines those tokens in its own stylesheet. Uniform system, divergent
|
|
7
|
+
* aesthetics — the class contract is in the README.
|
|
8
|
+
*/
|
|
9
|
+
export { SiteSections } from './SiteSections.js';
|
|
10
|
+
export { SiteMasthead, SiteFooter } from './SiteChrome.js';
|
|
11
|
+
export { SiteNav } from './SiteNav.js';
|
|
12
|
+
export { NavAutoScroll } from './NavAutoScroll.js';
|
|
13
|
+
export { DefaultLink } from './link.js';
|
|
14
|
+
export { SectionHeading, Blurb, SectionBody } from './sections/Primitives.js';
|
|
15
|
+
export { HeroSection } from './sections/HeroSection.js';
|
|
16
|
+
export { ProseSection, CardsSection, DefinitionsSection } from './sections/ProseSections.js';
|
|
17
|
+
export { StatsSection, MeterSection } from './sections/FigureSections.js';
|
|
18
|
+
export { IndexSection, TableSection } from './sections/DataSections.js';
|
|
19
|
+
export { FeatureSection, ContactSection, FaqSection } from './sections/BusinessSections.js';
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one framework seam in the package.
|
|
3
|
+
*
|
|
4
|
+
* The chrome needs to render links, and every framework has its own Link with
|
|
5
|
+
* its own prefetching. Rather than depending on any of them, components that
|
|
6
|
+
* link accept a `Link` component and default to a plain anchor — correct
|
|
7
|
+
* everywhere, and a Next.js consumer passes `next/link` to get client-side
|
|
8
|
+
* navigation back.
|
|
9
|
+
*/
|
|
10
|
+
import { type ComponentType, type ReactNode } from 'react';
|
|
11
|
+
export interface LinkProps {
|
|
12
|
+
href: string;
|
|
13
|
+
className?: string;
|
|
14
|
+
'aria-current'?: 'page';
|
|
15
|
+
children?: ReactNode;
|
|
16
|
+
}
|
|
17
|
+
export type LinkLike = ComponentType<LinkProps>;
|
|
18
|
+
export declare const DefaultLink: LinkLike;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one framework seam in the package.
|
|
3
|
+
*
|
|
4
|
+
* The chrome needs to render links, and every framework has its own Link with
|
|
5
|
+
* its own prefetching. Rather than depending on any of them, components that
|
|
6
|
+
* link accept a `Link` component and default to a plain anchor — correct
|
|
7
|
+
* everywhere, and a Next.js consumer passes `next/link` to get client-side
|
|
8
|
+
* navigation back.
|
|
9
|
+
*/
|
|
10
|
+
import { createElement } from 'react';
|
|
11
|
+
export const DefaultLink = (props) => createElement('a', props);
|