@iterant/site-runtime 3.1.3 → 3.4.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/docs/runtime-contract.md +151 -17
- package/package.json +8 -4
- package/src/components/RelatedLinks.astro +78 -0
- package/src/content/collections.ts +21 -2
- package/src/content/schema.ts +61 -0
- package/src/index.ts +1 -0
- package/src/layouts/LayoutCore.astro +31 -7
- package/src/layouts/layout-contract.ts +2 -1
- package/src/lib/content-values.ts +85 -2
- package/src/lib/database.ts +155 -0
- package/src/lib/markdown.ts +17 -0
package/docs/runtime-contract.md
CHANGED
|
@@ -50,7 +50,7 @@ runtime and says so.
|
|
|
50
50
|
|
|
51
51
|
<!-- generated: available libraries -->
|
|
52
52
|
|
|
53
|
-
_Generated from package.json by scripts/generate-kit-table.mjs. Runtime 3.
|
|
53
|
+
_Generated from package.json by scripts/generate-kit-table.mjs. Runtime 3.4.0._
|
|
54
54
|
|
|
55
55
|
**Toolchain** (this package owns the version; do NOT declare these):
|
|
56
56
|
|
|
@@ -68,18 +68,19 @@ _Generated from package.json by scripts/generate-kit-table.mjs. Runtime 3.1.3._
|
|
|
68
68
|
|
|
69
69
|
**Curated kit** (import directly; declare only to override):
|
|
70
70
|
|
|
71
|
-
| Package | Version | What it is
|
|
72
|
-
| -------------------------- | ------- |
|
|
73
|
-
| `lucide-react` | 1.31.0 | icons
|
|
74
|
-
| `motion` | 13.0.0 | animation
|
|
75
|
-
| `radix-ui` | 1.6.7 | unstyled accessible primitives
|
|
76
|
-
| `@radix-ui/react-slot` | 1.3.3 | prop forwarding for composable components
|
|
77
|
-
| `embla-carousel-react` | 8.6.0 | carousels
|
|
78
|
-
| `clsx` | 2.1.1 | conditional class names
|
|
79
|
-
| `tailwind-merge` | 3.6.0 | conflicting-utility resolution
|
|
80
|
-
| `class-variance-authority` | 0.7.1 | component variants
|
|
81
|
-
| `tw-animate-css` | 1.4.0 | the animation utilities Tailwind v4 dropped
|
|
82
|
-
| `github-slugger` | 2.0.0 | heading and anchor slugs
|
|
71
|
+
| Package | Version | What it is |
|
|
72
|
+
| -------------------------- | ------- | ----------------------------------------------------- |
|
|
73
|
+
| `lucide-react` | 1.31.0 | icons |
|
|
74
|
+
| `motion` | 13.0.0 | animation |
|
|
75
|
+
| `radix-ui` | 1.6.7 | unstyled accessible primitives |
|
|
76
|
+
| `@radix-ui/react-slot` | 1.3.3 | prop forwarding for composable components |
|
|
77
|
+
| `embla-carousel-react` | 8.6.0 | carousels |
|
|
78
|
+
| `clsx` | 2.1.1 | conditional class names |
|
|
79
|
+
| `tailwind-merge` | 3.6.0 | conflicting-utility resolution |
|
|
80
|
+
| `class-variance-authority` | 0.7.1 | component variants |
|
|
81
|
+
| `tw-animate-css` | 1.4.0 | the animation utilities Tailwind v4 dropped |
|
|
82
|
+
| `github-slugger` | 2.0.0 | heading and anchor slugs |
|
|
83
|
+
| `micromark` | 4.0.2 | the CommonMark compiler markdown wrappers render with |
|
|
83
84
|
|
|
84
85
|
<!-- /generated -->
|
|
85
86
|
|
|
@@ -102,7 +103,9 @@ The grammar (`content-values.ts`, schema-enforced):
|
|
|
102
103
|
`{"type":"image","src":"…","alt":"…"}`,
|
|
103
104
|
`{"type":"video","src":"…","poster":"…"}`, `{"type":"svg","markup":"…"}`,
|
|
104
105
|
`{"type":"color","value":"…"}`. Repeated content is
|
|
105
|
-
`{"type":"array","items":[{…}]}`.
|
|
106
|
+
`{"type":"array","items":[{…}]}`. Flowing body copy that needs inline
|
|
107
|
+
structure is `{"type":"markdown","value":"…"}` (3.3.0, see the markdown
|
|
108
|
+
section below). A link's `text` is optional in bespoke
|
|
106
109
|
content (an anchor can wrap its label); authored sections and chrome
|
|
107
110
|
require it (`labelledLinkSchema`). Videos carry no copy and are never
|
|
108
111
|
translated.
|
|
@@ -116,7 +119,8 @@ The grammar (`content-values.ts`, schema-enforced):
|
|
|
116
119
|
- Components render bindings so tools can find the copy in the DOM:
|
|
117
120
|
`data-component={id}` plus `data-component-type` on the section root;
|
|
118
121
|
`data-editable="<field>"`, `data-path="props.<field>"` and
|
|
119
|
-
`data-edit-type="text|link|image|color"` on each copy-bearing
|
|
122
|
+
`data-edit-type="text|link|image|color|markdown"` on each copy-bearing
|
|
123
|
+
element. Paths
|
|
120
124
|
are wrapper-level (`props.heading`, `props.features.0.title`), never
|
|
121
125
|
leaf-level. Array containers carry `data-array-container="<field>"`, items
|
|
122
126
|
`data-array-item={index}`.
|
|
@@ -127,6 +131,44 @@ The grammar (`content-values.ts`, schema-enforced):
|
|
|
127
131
|
- `site-runtime scan-copy` finds hardcoded copy in component code. Like every
|
|
128
132
|
gate, never edit or weaken it.
|
|
129
133
|
|
|
134
|
+
### Markdown bodies (3.3.0)
|
|
135
|
+
|
|
136
|
+
A `{"type":"text"}` value is an opaque string, so a link mid-sentence was
|
|
137
|
+
inexpressible before this wrapper existed: a link was always its own leaf (a
|
|
138
|
+
CTA, a nav item, a card). `{"type":"markdown","value":"…"}` holds flowing body
|
|
139
|
+
copy with inline structure (links, emphasis, headings, lists, images) as
|
|
140
|
+
CommonMark, stored as written.
|
|
141
|
+
|
|
142
|
+
- **One wrapper is one body.** The whole value is a single edit and
|
|
143
|
+
translation target. Never split a body into per-paragraph wrappers to
|
|
144
|
+
imitate the old grammar; the point is that a sentence's link lives inside
|
|
145
|
+
the sentence that reads around it.
|
|
146
|
+
- **Raw HTML is refused by the schema.** Write CommonMark. HTML that should
|
|
147
|
+
DISPLAY as copy (a technical article showing `<div>`) goes in a code span or
|
|
148
|
+
fence, which the schema skips. The compiler additionally renders with raw
|
|
149
|
+
HTML disabled, so anything that predates the schema displays as literal
|
|
150
|
+
text instead of becoming elements.
|
|
151
|
+
- **Render with the runtime's compiler**, never your own:
|
|
152
|
+
|
|
153
|
+
```tsx
|
|
154
|
+
import { renderMarkdown } from "@iterant/site-runtime/markdown";
|
|
155
|
+
import { readMarkdown } from "@iterant/site-runtime/content-values";
|
|
156
|
+
|
|
157
|
+
const body = readMarkdown(data.body); // raw CommonMark | null
|
|
158
|
+
<div
|
|
159
|
+
class="prose"
|
|
160
|
+
data-editable="body"
|
|
161
|
+
data-path="props.body"
|
|
162
|
+
data-edit-type="markdown"
|
|
163
|
+
set:html={body === null ? "" : renderMarkdown(body)}
|
|
164
|
+
/>;
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
- **Headings start at `##`.** The body renders below the page's own h1; the
|
|
168
|
+
compiler does not demote levels for you.
|
|
169
|
+
- Hrefs inside the value follow the same rules as link wrappers: root-relative
|
|
170
|
+
internal routes, absolute URLs for external targets.
|
|
171
|
+
|
|
130
172
|
### Reading leaves in section code
|
|
131
173
|
|
|
132
174
|
A section prop typed `ContentLeaf` is a union, and reading a variant field off
|
|
@@ -180,6 +222,98 @@ shape, id rules, uniqueness, the registry check on `mode: "registry"` pages, the
|
|
|
180
222
|
chrome mount contract, locale entry ids and `_translation` all live in the
|
|
181
223
|
package and change with a version bump.
|
|
182
224
|
|
|
225
|
+
### The links manifest (3.3.0)
|
|
226
|
+
|
|
227
|
+
`createCollections` also returns a third `links` collection reading the
|
|
228
|
+
machine-owned `src/content/links.json`: the internal-links manifest the
|
|
229
|
+
platform writes and nothing else may. Never hand-edit it, never let the agent
|
|
230
|
+
edit it; link corrections happen upstream in the platform's link graph. The
|
|
231
|
+
wire shape:
|
|
232
|
+
|
|
233
|
+
```json
|
|
234
|
+
{
|
|
235
|
+
"version": 1,
|
|
236
|
+
"routes": {
|
|
237
|
+
"/pricing": {
|
|
238
|
+
"label": "Related",
|
|
239
|
+
"links": [
|
|
240
|
+
{ "href": "/guide", "text": "The guide", "placement": "related_aside" }
|
|
241
|
+
]
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
`LayoutCore` looks the current route up in `routes` and mounts the slice as a
|
|
248
|
+
`<nav data-iterant-links>` inside the shell's slot, below the page content and
|
|
249
|
+
above the shell-rendered footer. An absent file, an empty `routes` record, or
|
|
250
|
+
a route with no slice all render zero bytes for the block, and nothing enters
|
|
251
|
+
the shared CSS bundle, so apart from the two version metas the built pages
|
|
252
|
+
match a repo pinned before 3.3.0: the file ships dark until the platform
|
|
253
|
+
writes it. Each slice
|
|
254
|
+
carries its own already-localized `label` (a locale sibling's route key is the
|
|
255
|
+
full prefixed route, `/es/pricing`, with no fallback to the base route), so
|
|
256
|
+
the package holds no copy and performs no localization. The schema tolerates
|
|
257
|
+
unknown keys at every level (`placement` is written but unread), so an
|
|
258
|
+
additive platform-side field can never fail an already-pinned repo's build.
|
|
259
|
+
"The one `@source` line" below does not grow: the component ships no utility
|
|
260
|
+
classes, only a scoped style block on the data attribute.
|
|
261
|
+
|
|
262
|
+
### Reading the brand Database (3.4.0)
|
|
263
|
+
|
|
264
|
+
A page can render rows the customer maintains in the brand Database, read at
|
|
265
|
+
request time:
|
|
266
|
+
|
|
267
|
+
```ts
|
|
268
|
+
import { readDatabase } from "@iterant/site-runtime/database";
|
|
269
|
+
|
|
270
|
+
const { rows, total } = await readDatabase("articles", {
|
|
271
|
+
sort: [{ column: "published_at", dir: "desc" }],
|
|
272
|
+
limit: 20,
|
|
273
|
+
});
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
Server side only (a page's frontmatter, an SSR route). The site holds no
|
|
277
|
+
database binding and writes no SQL: it names a table and the platform decides
|
|
278
|
+
what that means. A table is invisible until the platform grants it site read,
|
|
279
|
+
and the grant carries the filter that says which rows are public (a blog
|
|
280
|
+
publishes `status = published`) plus an optional list of readable fields. A
|
|
281
|
+
narrower `filter` here can only narrow that further. The platform's own row
|
|
282
|
+
columns (`_id`, `_created_at`, `_updated_at`, `_v`) are not part of a grant
|
|
283
|
+
unless it says so, so a page that keys rows by `_id` needs the grant to publish
|
|
284
|
+
it; without that they are neither readable, filterable nor sortable.
|
|
285
|
+
|
|
286
|
+
Nothing is configured in the repo. A published site gets the endpoint and the
|
|
287
|
+
token from the platform-generated publish gate; `astro dev` gets them from
|
|
288
|
+
`ITERANT_SITE_DB_URL` and `ITERANT_SITE_DB_TOKEN`, which the platform sets on
|
|
289
|
+
the dev server. A site with neither, an ungranted table and an unreachable
|
|
290
|
+
platform all read as `{rows: [], total: 0}` with a console warning: a listing
|
|
291
|
+
that renders empty beats a build that dies on a database blip.
|
|
292
|
+
|
|
293
|
+
### Choosing a shell (3.2.0)
|
|
294
|
+
|
|
295
|
+
A repo may own more than one frame. A replicated site whose second page carried
|
|
296
|
+
different chrome keeps that page's whole shell beside the site's, under a scope
|
|
297
|
+
suffix (`shell-frame-pricing.tsx`), and `components/layout/shells.tsx` GLOBS
|
|
298
|
+
them, so a frame registers by existing rather than by being listed.
|
|
299
|
+
|
|
300
|
+
A page entry names the one it wants:
|
|
301
|
+
|
|
302
|
+
```json
|
|
303
|
+
{ "route": "/pricing", "shell": "pricing", "meta": { "title": "Pricing" } }
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
The layout reads it off the entry and threads it to `shell.astro` as `shellId`,
|
|
307
|
+
so a route states it once in content instead of every `.astro` restating it. A
|
|
308
|
+
route may still pass `shell` directly, and the prop wins over the entry, the
|
|
309
|
+
same rule the structured-data props follow.
|
|
310
|
+
|
|
311
|
+
Omit it and nothing changes: the seam falls back to the route's own scope and
|
|
312
|
+
then to the site's frame, which is what every page written before 3.2.0 does.
|
|
313
|
+
The name is not validated against the registry, because the shells a repo owns
|
|
314
|
+
are a build-time glob that no schema can see; a name matching nothing renders
|
|
315
|
+
the site's frame rather than failing the build.
|
|
316
|
+
|
|
183
317
|
## Pages: entry-driven vs bespoke
|
|
184
318
|
|
|
185
319
|
Every page has a JSON entry holding its route, SEO meta and content. What
|
|
@@ -268,8 +402,8 @@ import { SITE_SHELL } from "@/site-shell";
|
|
|
268
402
|
Every shell keeps importing `../layouts/Layout.astro` unchanged. The core owns
|
|
269
403
|
the head (charset, viewport, favicon, generator, version meta, SEO, JSON-LD,
|
|
270
404
|
hreflang, the `head` slot), resolves locale-aware chrome and the page entry, and
|
|
271
|
-
renders the brand shell around the page slot with the `chrome`, `navbar
|
|
272
|
-
`footer` props. It never imports a stylesheet and never imports chrome
|
|
405
|
+
renders the brand shell around the page slot with the `chrome`, `navbar`,
|
|
406
|
+
`footer` and `shellId` props. It never imports a stylesheet and never imports chrome
|
|
273
407
|
components: `shell.astro` mounts those, which is what keeps their `client:load`
|
|
274
408
|
directive traceable.
|
|
275
409
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@iterant/site-runtime",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.4.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The platform layer every Iterant brand site runs on: content grammar, collection schemas, SEO head and JSON-LD, layout core, Astro config preset, dev integrations and the verify gates.",
|
|
6
6
|
"scripts": {
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"kit-table": "node scripts/generate-kit-table.mjs",
|
|
11
11
|
"lint": "eslint . --max-warnings 0",
|
|
12
12
|
"lint:fix": "eslint --fix .",
|
|
13
|
+
"prepublishOnly": "node scripts/check-packed.mjs",
|
|
13
14
|
"preversion": "node scripts/generate-kit-table.mjs",
|
|
14
15
|
"test": "vitest run && node scripts/check-fixture.mjs",
|
|
15
16
|
"test:fixture": "node scripts/check-fixture.mjs",
|
|
@@ -30,6 +31,7 @@
|
|
|
30
31
|
"!scripts/check-fixture.mjs",
|
|
31
32
|
"!scripts/check-packed.mjs",
|
|
32
33
|
"!scripts/generate-kit-table.mjs",
|
|
34
|
+
"!scripts/packed-content.mjs",
|
|
33
35
|
"!src/lib/__fixtures__"
|
|
34
36
|
],
|
|
35
37
|
"exports": {
|
|
@@ -37,6 +39,8 @@
|
|
|
37
39
|
"./content": "./src/content/collections.ts",
|
|
38
40
|
"./content/schema": "./src/content/schema.ts",
|
|
39
41
|
"./content-values": "./src/lib/content-values.ts",
|
|
42
|
+
"./database": "./src/lib/database.ts",
|
|
43
|
+
"./markdown": "./src/lib/markdown.ts",
|
|
40
44
|
"./locales": "./src/lib/locales.ts",
|
|
41
45
|
"./hreflang": "./src/lib/hreflang.ts",
|
|
42
46
|
"./chrome": "./src/lib/chrome.ts",
|
|
@@ -76,7 +80,8 @@
|
|
|
76
80
|
"tailwind-merge",
|
|
77
81
|
"class-variance-authority",
|
|
78
82
|
"tw-animate-css",
|
|
79
|
-
"github-slugger"
|
|
83
|
+
"github-slugger",
|
|
84
|
+
"micromark"
|
|
80
85
|
]
|
|
81
86
|
},
|
|
82
87
|
"dependencies": {
|
|
@@ -92,6 +97,7 @@
|
|
|
92
97
|
"embla-carousel-react": "8.6.0",
|
|
93
98
|
"github-slugger": "2.0.0",
|
|
94
99
|
"lucide-react": "1.31.0",
|
|
100
|
+
"micromark": "4.0.2",
|
|
95
101
|
"motion": "13.0.0",
|
|
96
102
|
"radix-ui": "1.6.7",
|
|
97
103
|
"react": "19.2.8",
|
|
@@ -108,8 +114,6 @@
|
|
|
108
114
|
"@types/node": "24.3.1",
|
|
109
115
|
"@types/react": "19.2.14",
|
|
110
116
|
"@types/react-dom": "19.2.3",
|
|
111
|
-
"@workspace/eslint-config": "workspace:*",
|
|
112
|
-
"@workspace/typescript-config": "workspace:*",
|
|
113
117
|
"typescript": "5.9.2",
|
|
114
118
|
"vitest": "^4.0.13",
|
|
115
119
|
"wrangler": "^4.107.0"
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
---
|
|
2
|
+
// The machine-fed related-links block (ILV-6). Props are one route's slice of
|
|
3
|
+
// the machine-owned src/content/links.json manifest; the manifest carries
|
|
4
|
+
// every string, the heading included, so this file holds no copy of its own
|
|
5
|
+
// and never re-sorts the array (ordering is the builder's contract).
|
|
6
|
+
//
|
|
7
|
+
// The wrapper is a <nav> ON PURPOSE: the canonical-corpus scrape excludes nav
|
|
8
|
+
// and footer tags, which is what keeps machine-placed links from ever being
|
|
9
|
+
// re-ingested as their own ranking evidence. The element choice is
|
|
10
|
+
// load-bearing; related-links.test.ts pins it.
|
|
11
|
+
//
|
|
12
|
+
// No class attribute anywhere: tailwind-surface.test.ts pins exactly one
|
|
13
|
+
// shipped file with utility classes, and this must not become a second.
|
|
14
|
+
// Styling is an inline block INSIDE the conditional, every selector rooted at
|
|
15
|
+
// the data attribute, colors from currentColor and custom-property fallbacks
|
|
16
|
+
// only, so the block inherits each brand's own cascade. Inline on purpose: a
|
|
17
|
+
// hoisted scoped style would enter the shared CSS bundle (and move its hash)
|
|
18
|
+
// even on a brand with no manifest, breaking the absent-file byte-identity
|
|
19
|
+
// that makes the renderer safe to ship ahead of any written manifest.
|
|
20
|
+
|
|
21
|
+
interface Props {
|
|
22
|
+
links?: { href: string; text: string }[];
|
|
23
|
+
label?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const { links, label } = Astro.props;
|
|
27
|
+
|
|
28
|
+
// set:html, not a style-tag expression: Astro treats style content as raw
|
|
29
|
+
// text, so an interpolation inside the tag would ship its braces literally.
|
|
30
|
+
const RELATED_LINKS_CSS = `
|
|
31
|
+
[data-iterant-links] {
|
|
32
|
+
margin: 3rem auto 0;
|
|
33
|
+
padding: 1.5rem 1rem 2rem;
|
|
34
|
+
max-width: var(--it-content-max-width, 72rem);
|
|
35
|
+
}
|
|
36
|
+
[data-iterant-links] h2 {
|
|
37
|
+
margin: 0 0 0.75rem;
|
|
38
|
+
font-size: 0.875rem;
|
|
39
|
+
font-weight: 600;
|
|
40
|
+
letter-spacing: 0.05em;
|
|
41
|
+
text-transform: uppercase;
|
|
42
|
+
opacity: 0.7;
|
|
43
|
+
}
|
|
44
|
+
[data-iterant-links] ul {
|
|
45
|
+
margin: 0;
|
|
46
|
+
padding: 0;
|
|
47
|
+
list-style: none;
|
|
48
|
+
display: flex;
|
|
49
|
+
flex-wrap: wrap;
|
|
50
|
+
gap: 0.5rem 1.5rem;
|
|
51
|
+
}
|
|
52
|
+
[data-iterant-links] a {
|
|
53
|
+
color: currentColor;
|
|
54
|
+
text-decoration: underline;
|
|
55
|
+
text-underline-offset: 0.2em;
|
|
56
|
+
text-decoration-color: var(--it-link-underline, currentColor);
|
|
57
|
+
}
|
|
58
|
+
[data-iterant-links] a:hover {
|
|
59
|
+
opacity: 0.75;
|
|
60
|
+
}
|
|
61
|
+
`;
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
{
|
|
65
|
+
links && links.length > 0 && (
|
|
66
|
+
<nav data-iterant-links aria-label={label}>
|
|
67
|
+
{label && <h2>{label}</h2>}
|
|
68
|
+
<ul>
|
|
69
|
+
{links.map((link) => (
|
|
70
|
+
<li>
|
|
71
|
+
<a href={link.href}>{link.text}</a>
|
|
72
|
+
</li>
|
|
73
|
+
))}
|
|
74
|
+
</ul>
|
|
75
|
+
<style is:inline set:html={RELATED_LINKS_CSS} />
|
|
76
|
+
</nav>
|
|
77
|
+
)
|
|
78
|
+
}
|
|
@@ -8,7 +8,11 @@ import {
|
|
|
8
8
|
} from "../lib/content-paths";
|
|
9
9
|
import { entryIdFromFile } from "../lib/locales";
|
|
10
10
|
import { withDevQuarantine } from "./resilience";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
createContentSchemas,
|
|
13
|
+
linksManifestSchema,
|
|
14
|
+
type ContentSchemaOptions,
|
|
15
|
+
} from "./schema";
|
|
12
16
|
|
|
13
17
|
// The `pages` + `chrome` collections a brand site runs on. Astro requires the
|
|
14
18
|
// collection definition to live in the repo's own src/content.config.ts, so
|
|
@@ -71,5 +75,20 @@ export function createCollections({
|
|
|
71
75
|
schema: chromeEntrySchema,
|
|
72
76
|
});
|
|
73
77
|
|
|
74
|
-
|
|
78
|
+
const links = defineCollection({
|
|
79
|
+
// The machine-owned links.json manifest (ILV-6), written only by the
|
|
80
|
+
// platform's sync channel. An absent file is a first-class valid state
|
|
81
|
+
// (the loader matches nothing and the layout renders nothing), the same
|
|
82
|
+
// ships-dark discipline chrome.json follows. NOT withDevQuarantine: the
|
|
83
|
+
// quarantine stand-in is page-shaped, and an invalid manifest is a
|
|
84
|
+
// platform bug that should fail verify loudly.
|
|
85
|
+
loader: glob({
|
|
86
|
+
pattern: "links.json",
|
|
87
|
+
base: loaderBase(chromeDir),
|
|
88
|
+
generateId: ({ entry }) => entryIdFromFile(entry),
|
|
89
|
+
}),
|
|
90
|
+
schema: linksManifestSchema,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
return { pages, chrome, links };
|
|
75
94
|
}
|
package/src/content/schema.ts
CHANGED
|
@@ -135,6 +135,26 @@ export function createContentSchemas({
|
|
|
135
135
|
// design ships its own nav/footer (a full-design import) so the layout
|
|
136
136
|
// mounts no chrome. Additive — existing entries omit it and keep chrome.
|
|
137
137
|
chrome: z.boolean().default(true),
|
|
138
|
+
// Which SHELL this page renders in (site-runtime 3.2.0). A replicated
|
|
139
|
+
// site can carry more than one frame: a page whose chrome differed from
|
|
140
|
+
// the site's kept its own under a scope suffix, and the shell registry
|
|
141
|
+
// globs whatever sits beside it rather than listing them, so a shell
|
|
142
|
+
// registers by existing. Naming one here is how a page WRITTEN LATER
|
|
143
|
+
// picks from what the site already has; a replicated page needs no name,
|
|
144
|
+
// because its own shell is the one its route resolves to.
|
|
145
|
+
//
|
|
146
|
+
// Additive and unvalidated against the registry ON PURPOSE: the shells a
|
|
147
|
+
// repo owns are a glob resolved at build time, so a schema cannot know
|
|
148
|
+
// them, and a name that matches nothing falls back to the site's shell
|
|
149
|
+
// rather than failing a brand's build. Existing entries omit it and
|
|
150
|
+
// render exactly as they do today.
|
|
151
|
+
shell: z
|
|
152
|
+
.string()
|
|
153
|
+
.regex(
|
|
154
|
+
/^[a-z0-9]+(?:-[a-z0-9]+)*$/,
|
|
155
|
+
"shell must be a lowercase scope name (letters, digits, single hyphens)",
|
|
156
|
+
)
|
|
157
|
+
.optional(),
|
|
138
158
|
meta: z
|
|
139
159
|
.object({
|
|
140
160
|
title: z.string(),
|
|
@@ -237,3 +257,44 @@ export function createContentSchemas({
|
|
|
237
257
|
}
|
|
238
258
|
|
|
239
259
|
export type ContentSchemas = ReturnType<typeof createContentSchemas>;
|
|
260
|
+
|
|
261
|
+
// The machine-owned src/content/links.json manifest (ILV-6): the renderer-side
|
|
262
|
+
// mirror of the platform's document contract (the Django builder is the
|
|
263
|
+
// authority; a key rename happens in both places together or not at all).
|
|
264
|
+
// Every object level is deliberately NON-strict: pins move brand by brand
|
|
265
|
+
// while the platform writes one manifest shape fleet-wide, so an additive
|
|
266
|
+
// field must be invisible to an older runtime, never a build failure delivered
|
|
267
|
+
// through a content file. A breaking shape change still fails parse loudly.
|
|
268
|
+
// `placement` is serialized by the builder but unread here, which is why it
|
|
269
|
+
// does not appear below.
|
|
270
|
+
export const linksManifestSchema = z.object({
|
|
271
|
+
version: z.number().int().positive(),
|
|
272
|
+
routes: z.record(
|
|
273
|
+
z.string(),
|
|
274
|
+
z.object({
|
|
275
|
+
// Always emitted by the builder, already locale-resolved per route; the
|
|
276
|
+
// renderer never localizes and holds no fallback copy.
|
|
277
|
+
label: z
|
|
278
|
+
.string()
|
|
279
|
+
.regex(/\S/, { message: "label cannot be blank" })
|
|
280
|
+
.optional(),
|
|
281
|
+
links: z.array(
|
|
282
|
+
z.object({
|
|
283
|
+
// Page targets are root-relative routes (Page.url_path); KB and
|
|
284
|
+
// trust targets keep their absolute crawled URL. The lookahead
|
|
285
|
+
// rejects protocol-relative //host hrefs, which would render an
|
|
286
|
+
// external link disguised as a same-site route.
|
|
287
|
+
href: z.string().regex(/^(\/(?!\/)|https?:\/\/)/i, {
|
|
288
|
+
message:
|
|
289
|
+
"expected a root-relative route or an absolute http(s) URL",
|
|
290
|
+
}),
|
|
291
|
+
text: z
|
|
292
|
+
.string()
|
|
293
|
+
.regex(/\S/, { message: "link text cannot be blank" }),
|
|
294
|
+
}),
|
|
295
|
+
),
|
|
296
|
+
}),
|
|
297
|
+
),
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
export type LinksManifest = z.infer<typeof linksManifestSchema>;
|
package/src/index.ts
CHANGED
|
@@ -12,6 +12,7 @@ export * from "./lib/bespoke-pages";
|
|
|
12
12
|
export * from "./lib/chrome-schemas";
|
|
13
13
|
export * from "./lib/content-paths";
|
|
14
14
|
export * from "./lib/content-values";
|
|
15
|
+
export * from "./lib/database";
|
|
15
16
|
export * from "./lib/hreflang";
|
|
16
17
|
export * from "./lib/locales";
|
|
17
18
|
export * from "./version";
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
import { getCollection, getEntry } from "astro:content";
|
|
3
3
|
import type { AstroComponentFactory } from "astro/runtime/server/index.js";
|
|
4
|
+
import RelatedLinks from "../components/RelatedLinks.astro";
|
|
4
5
|
import { SEO, type PageType } from "../components/seo";
|
|
5
6
|
import type { SeoJsonSchema } from "../components/seo-json";
|
|
6
7
|
import type { HreflangAlternate } from "../lib/hreflang";
|
|
@@ -65,6 +66,12 @@ interface Props {
|
|
|
65
66
|
pageType?: PageType;
|
|
66
67
|
datePublished?: string;
|
|
67
68
|
dateModified?: string;
|
|
69
|
+
// Which shell to render this page in (site-runtime 3.2.0). Read from the
|
|
70
|
+
// page entry's `shell` when a shell does not pass one, the same
|
|
71
|
+
// prop-wins-over-entry rule the structured-data props above follow. A
|
|
72
|
+
// replicated site can own several frames; this is what lets a page written
|
|
73
|
+
// later choose one instead of always taking the site's.
|
|
74
|
+
shell?: string;
|
|
68
75
|
}
|
|
69
76
|
|
|
70
77
|
const {
|
|
@@ -86,6 +93,7 @@ const {
|
|
|
86
93
|
pageType,
|
|
87
94
|
datePublished,
|
|
88
95
|
dateModified,
|
|
96
|
+
shell,
|
|
89
97
|
} = Astro.props;
|
|
90
98
|
|
|
91
99
|
const site = Astro.site;
|
|
@@ -117,16 +125,32 @@ const { navbar, footer } = pickChromeComponents(chromeEntry?.data.components);
|
|
|
117
125
|
// article dates reach the JSON-LD graph without every bespoke shell having
|
|
118
126
|
// to thread them. Shells that pass the props explicitly still win. Routes
|
|
119
127
|
// without an entry (404, under-construction) fall back to a plain WebPage.
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
);
|
|
128
|
+
const routePath = routePathFromPathname(Astro.url.pathname);
|
|
129
|
+
const pageEntry = findPageEntryByRoute(await getCollection("pages"), routePath, {
|
|
130
|
+
includeDrafts: !import.meta.env.PROD,
|
|
131
|
+
});
|
|
125
132
|
const structuredData = resolveStructuredData(
|
|
126
133
|
{ pageType, datePublished, dateModified },
|
|
127
134
|
pageEntry?.data.meta,
|
|
128
135
|
);
|
|
129
136
|
|
|
137
|
+
// The shell this page renders in, resolved from the same entry, so a page names
|
|
138
|
+
// its shell once in content rather than every route restating it. Undefined
|
|
139
|
+
// leaves the seam to fall back the way it already does: the route's own scope,
|
|
140
|
+
// then the site's shell. A repo whose shell.astro predates this ignores the
|
|
141
|
+
// prop, which is what makes the forward safe to ship ahead of any emit.
|
|
142
|
+
const shellId = shell ?? pageEntry?.data.shell;
|
|
143
|
+
|
|
144
|
+
// Machine-fed related links (ILV-6): the machine-owned src/content/links.json
|
|
145
|
+
// manifest, resolved by this page's own route key. A locale sibling looks up
|
|
146
|
+
// its full prefixed route; there is no fallback to the base route, because the
|
|
147
|
+
// builder computes each sibling's slice from its own edges and drops
|
|
148
|
+
// cross-language targets. The slice's label arrives already locale-resolved,
|
|
149
|
+
// so the `locale` above is not consulted. Absent file, absent route, or an
|
|
150
|
+
// empty slice all render nothing, the chrome.json ships-dark discipline.
|
|
151
|
+
const linksEntry = await getEntry("links", "links");
|
|
152
|
+
const linksSlice = linksEntry?.data.routes[routePath];
|
|
153
|
+
|
|
130
154
|
// Version identity: the installed package version IS the site's runtime
|
|
131
155
|
// version. `it-astro-starter-version` keeps emitting the same value while the
|
|
132
156
|
// plugin loader and the platform's page indexer still read that name, and
|
|
@@ -175,8 +199,8 @@ const structuredData = resolveStructuredData(
|
|
|
175
199
|
<slot name="head" />
|
|
176
200
|
</head>
|
|
177
201
|
<body class={siteShell.bodyClass} style={siteShell.bodyStyle}>
|
|
178
|
-
<Shell chrome={chrome} navbar={navbar} footer={footer}>
|
|
179
|
-
<slot />
|
|
202
|
+
<Shell chrome={chrome} navbar={navbar} footer={footer} shellId={shellId}>
|
|
203
|
+
<slot /><RelatedLinks links={linksSlice?.links} label={linksSlice?.label} />
|
|
180
204
|
</Shell>
|
|
181
205
|
</body>
|
|
182
206
|
</html>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/** The layout surface GENERATORS write against, machine-readable (3.
|
|
1
|
+
/** The layout surface GENERATORS write against, machine-readable (3.2.0).
|
|
2
2
|
* Platform code that emits brand-repo routes imports this and tests its
|
|
3
3
|
* emissions against it: a prop or slot an emission uses that is not listed
|
|
4
4
|
* here is dropped silently at render, which is exactly how the locale-twin
|
|
@@ -24,6 +24,7 @@ export const LAYOUT_CONTRACT = {
|
|
|
24
24
|
"pageType",
|
|
25
25
|
"datePublished",
|
|
26
26
|
"dateModified",
|
|
27
|
+
"shell",
|
|
27
28
|
],
|
|
28
29
|
/** Injected by the repo's Layout shim; never passed by a route. */
|
|
29
30
|
shimInjected: ["siteConfig", "siteShell", "Shell"],
|
|
@@ -9,6 +9,7 @@ import { z } from "astro/zod";
|
|
|
9
9
|
// hrefs, srcs, ids, or config values.
|
|
10
10
|
//
|
|
11
11
|
// { "type": "text", "value": "Visible prose" }
|
|
12
|
+
// { "type": "markdown", "value": "Prose with [inline links](/pricing)." }
|
|
12
13
|
// { "type": "link", "text": "Start free", "href": "/signup" }
|
|
13
14
|
// { "type": "image", "src": "/images/x.webp", "alt": "Description" }
|
|
14
15
|
// { "type": "video", "src": "/media/x.mp4", "poster": "/images/x.webp" }
|
|
@@ -26,6 +27,18 @@ export interface TextContent {
|
|
|
26
27
|
value: string;
|
|
27
28
|
}
|
|
28
29
|
|
|
30
|
+
/** Flowing body copy that needs inline structure a text wrapper cannot hold:
|
|
31
|
+
* links mid-sentence, emphasis, headings, lists, images. The value is
|
|
32
|
+
* CommonMark, stored as written; the renderer (lib/markdown.ts) compiles it
|
|
33
|
+
* at build time with raw HTML disabled, so markup in the value displays as
|
|
34
|
+
* literal text rather than becoming elements. One wrapper is one body: the
|
|
35
|
+
* whole value is a single edit/translation target, which is what keeps a
|
|
36
|
+
* sentence's link inside the sentence that reads around it. */
|
|
37
|
+
export interface MarkdownContent {
|
|
38
|
+
type: "markdown";
|
|
39
|
+
value: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
29
42
|
export interface LinkContent {
|
|
30
43
|
type: "link";
|
|
31
44
|
/** The link's own label. Optional, because a link does not always have one:
|
|
@@ -93,6 +106,7 @@ export interface ArrayContent {
|
|
|
93
106
|
|
|
94
107
|
export type ContentValue =
|
|
95
108
|
| TextContent
|
|
109
|
+
| MarkdownContent
|
|
96
110
|
| LinkContent
|
|
97
111
|
| ImageContent
|
|
98
112
|
| VideoContent
|
|
@@ -133,6 +147,57 @@ export const textContentSchema = z
|
|
|
133
147
|
})
|
|
134
148
|
.strict();
|
|
135
149
|
|
|
150
|
+
// Raw HTML is refused at the schema level, not silently neutralized at render
|
|
151
|
+
// time: an entry carrying `<script>` or `<div>` is a modeling error the author
|
|
152
|
+
// should see at build, and the renderer's escaping is the backstop, not the
|
|
153
|
+
// contract.
|
|
154
|
+
//
|
|
155
|
+
// The gate matches ELEMENT NAMES HTML actually defines, not every `<word>`.
|
|
156
|
+
// CommonMark reads `<int>`, `Promise<T>` and `x<y and a>b` as raw html too,
|
|
157
|
+
// and the safe renderer escapes each one back into exactly the words the
|
|
158
|
+
// author typed, so refusing them rejected correct prose while claiming the
|
|
159
|
+
// author had written markup. A name HTML does not define cannot be a mistaken
|
|
160
|
+
// attempt to render an element, which is the only thing worth refusing.
|
|
161
|
+
//
|
|
162
|
+
// Code is stripped before the check, so markup shown AS copy stays legal,
|
|
163
|
+
// which is what the error message promises: fenced (``` and ~~~), indented,
|
|
164
|
+
// and inline spans, whose closing run must match the opening run (a ``-span
|
|
165
|
+
// legitimately contains a lone backtick). Autolinks (`<https://…>`) never
|
|
166
|
+
// match because a tag name cannot contain `:`.
|
|
167
|
+
const _HTML_ELEMENTS = new Set(
|
|
168
|
+
`a abbr address area article aside audio b base bdi bdo blockquote body br
|
|
169
|
+
button canvas caption cite code col colgroup data datalist dd del details
|
|
170
|
+
dfn dialog div dl dt em embed fieldset figcaption figure footer form h1 h2
|
|
171
|
+
h3 h4 h5 h6 head header hgroup hr html i iframe img input ins kbd label
|
|
172
|
+
legend li link main map mark menu meta meter nav noscript object ol optgroup
|
|
173
|
+
option output p param picture pre progress q rp rt ruby s samp script search
|
|
174
|
+
section select slot small source span strong style sub summary sup table
|
|
175
|
+
tbody td template textarea tfoot th thead time title tr track u ul var video
|
|
176
|
+
wbr svg path circle rect g defs use`.split(/\s+/),
|
|
177
|
+
);
|
|
178
|
+
const _CODE = /(`+)[\s\S]*?\1|~~~[\s\S]*?~~~|(?:^|\n)(?: {4}|\t)[^\n]*/g;
|
|
179
|
+
const _TAG = /<\/?([a-zA-Z][a-zA-Z0-9-]*)(?:[\s/>]|$)/g;
|
|
180
|
+
|
|
181
|
+
export function carriesRawHtml(value: string): boolean {
|
|
182
|
+
const prose = value.replace(_CODE, " ");
|
|
183
|
+
for (const [, name] of prose.matchAll(_TAG)) {
|
|
184
|
+
if (_HTML_ELEMENTS.has(name.toLowerCase())) return true;
|
|
185
|
+
}
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export const markdownContentSchema = z
|
|
190
|
+
.object({
|
|
191
|
+
type: z.literal("markdown"),
|
|
192
|
+
value: z
|
|
193
|
+
.string()
|
|
194
|
+
.refine(
|
|
195
|
+
(value) => !carriesRawHtml(value),
|
|
196
|
+
"raw HTML is not part of the markdown grammar: write CommonMark (links, emphasis, headings, lists, images); HTML to display as copy belongs in a code span",
|
|
197
|
+
),
|
|
198
|
+
})
|
|
199
|
+
.strict();
|
|
200
|
+
|
|
136
201
|
export const linkContentSchema = z
|
|
137
202
|
.object({
|
|
138
203
|
type: z.literal("link"),
|
|
@@ -224,6 +289,7 @@ const propKeySchema = z
|
|
|
224
289
|
export const contentLeafSchema: z.ZodType<ContentLeaf> = z.lazy(() =>
|
|
225
290
|
z.union([
|
|
226
291
|
textContentSchema,
|
|
292
|
+
markdownContentSchema,
|
|
227
293
|
linkContentSchema,
|
|
228
294
|
imageContentSchema,
|
|
229
295
|
videoContentSchema,
|
|
@@ -258,7 +324,7 @@ export const arrayOf = <T extends z.ZodRawShape>(shape: T) =>
|
|
|
258
324
|
.strict();
|
|
259
325
|
|
|
260
326
|
// ---------------------------------------------------------------------------
|
|
261
|
-
// Leaf accessors. Section code receives ContentLeaf,
|
|
327
|
+
// Leaf accessors. Section code receives ContentLeaf, an eight-way union, and
|
|
262
328
|
// the natural-but-wrong move is reading a variant's field off the union
|
|
263
329
|
// (`leaf.value` renders fine and fails astro check). These are the one
|
|
264
330
|
// documented way to read a leaf: narrow-and-extract, never throw, degrade to
|
|
@@ -269,6 +335,10 @@ export function isTextContent(leaf: ContentLeaf): leaf is TextContent {
|
|
|
269
335
|
return typeof leaf === "object" && leaf !== null && leaf.type === "text";
|
|
270
336
|
}
|
|
271
337
|
|
|
338
|
+
export function isMarkdownContent(leaf: ContentLeaf): leaf is MarkdownContent {
|
|
339
|
+
return typeof leaf === "object" && leaf !== null && leaf.type === "markdown";
|
|
340
|
+
}
|
|
341
|
+
|
|
272
342
|
export function isLinkContent(leaf: ContentLeaf): leaf is LinkContent {
|
|
273
343
|
return typeof leaf === "object" && leaf !== null && leaf.type === "link";
|
|
274
344
|
}
|
|
@@ -296,7 +366,8 @@ export function isArrayContent(leaf: ContentLeaf): leaf is ArrayContent {
|
|
|
296
366
|
/**
|
|
297
367
|
* The renderable text of a leaf: a text wrapper's value, or a bare config
|
|
298
368
|
* scalar stringified (a count or token an entry legitimately interpolates
|
|
299
|
-
* into copy). Structured wrappers (link, image, video, svg, color,
|
|
369
|
+
* into copy). Structured wrappers (markdown, link, image, video, svg, color,
|
|
370
|
+
* array)
|
|
300
371
|
* have no text reading and come back empty — read them with their own
|
|
301
372
|
* accessor.
|
|
302
373
|
*/
|
|
@@ -307,6 +378,18 @@ export function readText(leaf: ContentLeaf | null | undefined): string {
|
|
|
307
378
|
return String(leaf);
|
|
308
379
|
}
|
|
309
380
|
|
|
381
|
+
/** The markdown wrapper's raw CommonMark source, or null. Rendering it is
|
|
382
|
+
* lib/markdown.ts's `renderMarkdown`; the accessor hands back source so
|
|
383
|
+
* non-rendering callers (search text, excerpts) are not forced through the
|
|
384
|
+
* compiler. */
|
|
385
|
+
export function readMarkdown(
|
|
386
|
+
leaf: ContentLeaf | null | undefined,
|
|
387
|
+
): string | null {
|
|
388
|
+
return leaf !== null && leaf !== undefined && isMarkdownContent(leaf)
|
|
389
|
+
? leaf.value
|
|
390
|
+
: null;
|
|
391
|
+
}
|
|
392
|
+
|
|
310
393
|
/** The link wrapper (text, href, optional target), or null. */
|
|
311
394
|
export function readLink(
|
|
312
395
|
leaf: ContentLeaf | null | undefined,
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// Reading the brand's Database from a page, at request time (epic DBX, decision
|
|
2
|
+
// DBX-D11).
|
|
3
|
+
//
|
|
4
|
+
// A published site holds no database binding and composes no SQL. It asks the
|
|
5
|
+
// platform's public read route for rows of ONE table, and the platform decides
|
|
6
|
+
// what that means: the table has to carry a `site_read` grant, the grant's
|
|
7
|
+
// filters are added server side, and its column allowlist is what comes back.
|
|
8
|
+
// So a page cannot read a draft by asking differently, and nothing here needs to
|
|
9
|
+
// know how the data is stored.
|
|
10
|
+
//
|
|
11
|
+
// Where the endpoint and token come from: the publish gate, a module the
|
|
12
|
+
// platform generates on every publish and puts in front of Astro's entry. It
|
|
13
|
+
// assigns them to `globalThis.__ITERANT_SITE_DB__` at module scope. In `astro
|
|
14
|
+
// dev` there is no gate, so the pair arrives as environment variables instead.
|
|
15
|
+
//
|
|
16
|
+
// Every failure answers with no rows and a console warning, never a throw: a
|
|
17
|
+
// listing that renders empty is a page the customer can still see, and a build
|
|
18
|
+
// that fails on a database blip is not.
|
|
19
|
+
|
|
20
|
+
const GLOBAL_KEY = "__ITERANT_SITE_DB__";
|
|
21
|
+
|
|
22
|
+
const URL_ENV = "ITERANT_SITE_DB_URL";
|
|
23
|
+
|
|
24
|
+
const TOKEN_ENV = "ITERANT_SITE_DB_TOKEN";
|
|
25
|
+
|
|
26
|
+
/** How long one read may take before the page renders without it. */
|
|
27
|
+
const TIMEOUT_MS = 10_000;
|
|
28
|
+
|
|
29
|
+
export type DatabaseFilterOp =
|
|
30
|
+
| "eq"
|
|
31
|
+
| "ne"
|
|
32
|
+
| "contains"
|
|
33
|
+
| "gt"
|
|
34
|
+
| "lt"
|
|
35
|
+
| "isnull";
|
|
36
|
+
|
|
37
|
+
export type DatabaseFilter = {
|
|
38
|
+
column: string;
|
|
39
|
+
op: DatabaseFilterOp;
|
|
40
|
+
value?: string | number | boolean | null;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type DatabaseSort = {
|
|
44
|
+
column: string;
|
|
45
|
+
dir: "asc" | "desc";
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type DatabaseQuery = {
|
|
49
|
+
/** Columns to read. Omitted means every column the grant allows. */
|
|
50
|
+
select?: string[];
|
|
51
|
+
/** Narrows the granted rows further. It can never widen them. */
|
|
52
|
+
filter?: DatabaseFilter[];
|
|
53
|
+
sort?: DatabaseSort[];
|
|
54
|
+
/** Rows per page, up to the platform's own ceiling of 200. */
|
|
55
|
+
limit?: number;
|
|
56
|
+
offset?: number;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export type DatabaseRow = Record<string, string | number | null>;
|
|
60
|
+
|
|
61
|
+
export type DatabaseResult = {
|
|
62
|
+
rows: DatabaseRow[];
|
|
63
|
+
/** Every row matching the query, not just this page. */
|
|
64
|
+
total: number;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
type SiteDatabaseHandoff = {
|
|
68
|
+
/** The complete read endpoint, brand and all. The site never assembles a
|
|
69
|
+
* platform URL of its own. */
|
|
70
|
+
url: string;
|
|
71
|
+
token: string;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const EMPTY: DatabaseResult = { rows: [], total: 0 };
|
|
75
|
+
|
|
76
|
+
function fromEnvironment(): SiteDatabaseHandoff | null {
|
|
77
|
+
const environment = (
|
|
78
|
+
globalThis as { process?: { env?: Record<string, string | undefined> } }
|
|
79
|
+
).process?.env;
|
|
80
|
+
const url = environment?.[URL_ENV];
|
|
81
|
+
const token = environment?.[TOKEN_ENV];
|
|
82
|
+
return url && token ? { url, token } : null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function handoff(): SiteDatabaseHandoff | null {
|
|
86
|
+
const baked = (globalThis as Record<string, unknown>)[GLOBAL_KEY];
|
|
87
|
+
if (baked && typeof baked === "object") {
|
|
88
|
+
const { url, token } = baked as Partial<SiteDatabaseHandoff>;
|
|
89
|
+
if (typeof url === "string" && typeof token === "string") {
|
|
90
|
+
return { url, token };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return fromEnvironment();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function warn(message: string, detail?: unknown): DatabaseResult {
|
|
97
|
+
console.warn(`[site-runtime] readDatabase: ${message}`, detail ?? "");
|
|
98
|
+
return EMPTY;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Read rows of one granted table.
|
|
103
|
+
*
|
|
104
|
+
* ```ts
|
|
105
|
+
* const { rows } = await readDatabase("articles", {
|
|
106
|
+
* sort: [{ column: "published_at", dir: "desc" }],
|
|
107
|
+
* limit: 20,
|
|
108
|
+
* });
|
|
109
|
+
* ```
|
|
110
|
+
*
|
|
111
|
+
* Call it in a page's frontmatter (server side). The rows are read on every
|
|
112
|
+
* request, so publishing a row shows it on the next page load with no rebuild.
|
|
113
|
+
*/
|
|
114
|
+
export async function readDatabase(
|
|
115
|
+
table: string,
|
|
116
|
+
query: DatabaseQuery = {},
|
|
117
|
+
): Promise<DatabaseResult> {
|
|
118
|
+
const pair = handoff();
|
|
119
|
+
if (!pair) {
|
|
120
|
+
return warn(
|
|
121
|
+
`no database is connected to this site, so "${table}" read as empty. ` +
|
|
122
|
+
`A published site gets its connection from the publish gate; ` +
|
|
123
|
+
`a dev server gets it from ${URL_ENV} and ${TOKEN_ENV}.`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
let response: Response;
|
|
127
|
+
try {
|
|
128
|
+
response = await fetch(pair.url, {
|
|
129
|
+
method: "POST",
|
|
130
|
+
headers: {
|
|
131
|
+
authorization: `Bearer ${pair.token}`,
|
|
132
|
+
"content-type": "application/json",
|
|
133
|
+
},
|
|
134
|
+
body: JSON.stringify({ table, ...query }),
|
|
135
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
136
|
+
});
|
|
137
|
+
} catch (error) {
|
|
138
|
+
return warn(`"${table}" could not be reached`, error);
|
|
139
|
+
}
|
|
140
|
+
if (!response.ok) {
|
|
141
|
+
const detail = await response.text().catch(() => "");
|
|
142
|
+
return warn(`"${table}" answered ${response.status}`, detail.slice(0, 400));
|
|
143
|
+
}
|
|
144
|
+
let body: unknown;
|
|
145
|
+
try {
|
|
146
|
+
body = await response.json();
|
|
147
|
+
} catch (error) {
|
|
148
|
+
return warn(`"${table}" answered with a body that is not JSON`, error);
|
|
149
|
+
}
|
|
150
|
+
const page = body as Partial<DatabaseResult>;
|
|
151
|
+
if (!Array.isArray(page.rows)) {
|
|
152
|
+
return warn(`"${table}" answered without rows`, body);
|
|
153
|
+
}
|
|
154
|
+
return { rows: page.rows, total: Number(page.total ?? page.rows.length) };
|
|
155
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { micromark } from "micromark";
|
|
2
|
+
|
|
3
|
+
// The one compiler for {type:"markdown"} content values (content-values.ts).
|
|
4
|
+
// Runs at build time inside Astro's static render, so the browser ships HTML,
|
|
5
|
+
// never a markdown runtime. micromark's default is the safety contract the
|
|
6
|
+
// grammar depends on: raw HTML in the source is character-encoded into
|
|
7
|
+
// visible text, not parsed into elements — the schema refuses element-like
|
|
8
|
+
// tags up front, and this default is the backstop for anything that predates
|
|
9
|
+
// or evades the schema. Do not pass allowDangerousHtml here, ever.
|
|
10
|
+
//
|
|
11
|
+
// Headings: a markdown body renders inside a section, below the page's own
|
|
12
|
+
// h1, so authors start at `##`. The compiler does not rewrite heading levels;
|
|
13
|
+
// a body that opens with `#` is an authoring error the review pass catches,
|
|
14
|
+
// not something to silently demote.
|
|
15
|
+
export function renderMarkdown(value: string): string {
|
|
16
|
+
return micromark(value);
|
|
17
|
+
}
|