@iterant/site-runtime 3.0.2
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/README.md +30 -0
- package/bin/site-runtime.mjs +46 -0
- package/docs/runtime-contract.md +324 -0
- package/package.json +84 -0
- package/scripts/scan-bespoke-siblings.mjs +191 -0
- package/scripts/scan-copy.mjs +204 -0
- package/scripts/scan-island-imports.mjs +207 -0
- package/scripts/verify.mjs +283 -0
- package/src/components/seo-json.tsx +157 -0
- package/src/components/seo.tsx +294 -0
- package/src/config/preset.ts +198 -0
- package/src/content/collections.ts +71 -0
- package/src/content/schema.ts +239 -0
- package/src/index.ts +22 -0
- package/src/integrations/iterant-plugins.mjs +83 -0
- package/src/integrations/new-file-reload.mjs +95 -0
- package/src/integrations/preview-error-shell.mjs +145 -0
- package/src/layouts/LayoutCore.astro +182 -0
- package/src/layouts/layout-core.ts +141 -0
- package/src/lib/bespoke-pages.ts +60 -0
- package/src/lib/chrome-schemas.ts +266 -0
- package/src/lib/chrome.ts +23 -0
- package/src/lib/content-paths.ts +16 -0
- package/src/lib/content-values.ts +201 -0
- package/src/lib/hreflang.ts +123 -0
- package/src/lib/locales.ts +92 -0
- package/src/lib/sitemap/get-sitemap-paths.ts +65 -0
- package/src/lib/sitemap/index.ts +19 -0
- package/src/lib/sitemap/routes.ts +141 -0
- package/src/lib/sitemap/shared.ts +95 -0
- package/src/lib/sitemap/sitemap-with-custom-pages-plugin.ts +74 -0
- package/src/routes/UnderConstruction.astro +43 -0
- package/src/routes/index.ts +11 -0
- package/src/routes/llms-txt.ts +68 -0
- package/src/routes/robots-txt.ts +56 -0
- package/src/version.ts +9 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { defineCollection } from "astro:content";
|
|
2
|
+
import { glob } from "astro/loaders";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_CHROME_DIR,
|
|
6
|
+
DEFAULT_PAGES_DIR,
|
|
7
|
+
loaderBase,
|
|
8
|
+
} from "../lib/content-paths";
|
|
9
|
+
import { entryIdFromFile } from "../lib/locales";
|
|
10
|
+
import { createContentSchemas, type ContentSchemaOptions } from "./schema";
|
|
11
|
+
|
|
12
|
+
// The `pages` + `chrome` collections a brand site runs on. Astro requires the
|
|
13
|
+
// collection definition to live in the repo's own src/content.config.ts, so
|
|
14
|
+
// that file stays a shim over this factory:
|
|
15
|
+
//
|
|
16
|
+
// import { createCollections } from "@iterant/site-runtime/content";
|
|
17
|
+
// import { CHROME_COMPONENT_PROPS } from "@iterant/site-runtime/chrome-schemas";
|
|
18
|
+
// import { REGISTERED_SECTION_PROPS } from "./lib/section-schemas";
|
|
19
|
+
//
|
|
20
|
+
// export const collections = createCollections({
|
|
21
|
+
// registeredSectionProps: REGISTERED_SECTION_PROPS,
|
|
22
|
+
// chromeComponentProps: CHROME_COMPONENT_PROPS,
|
|
23
|
+
// });
|
|
24
|
+
//
|
|
25
|
+
// The grammar, the entry shape and the loader wiring are platform-owned; which
|
|
26
|
+
// section and chrome types exist stays brand-owned.
|
|
27
|
+
|
|
28
|
+
export interface CreateCollectionsOptions extends ContentSchemaOptions {
|
|
29
|
+
/** Page entry directory, relative to the project root. A repo that moves it
|
|
30
|
+
* must pass the same value to `iterantStarter`, or the sitemap and the
|
|
31
|
+
* collection read different directories. */
|
|
32
|
+
pagesDir?: string;
|
|
33
|
+
/** Directory holding chrome.json and its locale siblings. */
|
|
34
|
+
chromeDir?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function createCollections({
|
|
38
|
+
registeredSectionProps,
|
|
39
|
+
chromeComponentProps,
|
|
40
|
+
pagesDir = DEFAULT_PAGES_DIR,
|
|
41
|
+
chromeDir = DEFAULT_CHROME_DIR,
|
|
42
|
+
}: CreateCollectionsOptions) {
|
|
43
|
+
const { pageEntrySchema, chromeEntrySchema } = createContentSchemas({
|
|
44
|
+
registeredSectionProps,
|
|
45
|
+
chromeComponentProps,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const pages = defineCollection({
|
|
49
|
+
// generateId preserves dots (the default slugifies `home.es` → `homees`),
|
|
50
|
+
// so locale sibling ids keep the platform's `<page>.<locale>` grammar.
|
|
51
|
+
loader: glob({
|
|
52
|
+
pattern: "**/*.json",
|
|
53
|
+
base: loaderBase(pagesDir),
|
|
54
|
+
generateId: ({ entry }) => entryIdFromFile(entry),
|
|
55
|
+
}),
|
|
56
|
+
schema: pageEntrySchema,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const chrome = defineCollection({
|
|
60
|
+
// The base chrome plus its locale siblings (chrome.es.json); LayoutCore
|
|
61
|
+
// resolves per request path with fallback to the base.
|
|
62
|
+
loader: glob({
|
|
63
|
+
pattern: ["chrome.json", "chrome.*.json"],
|
|
64
|
+
base: loaderBase(chromeDir),
|
|
65
|
+
generateId: ({ entry }) => entryIdFromFile(entry),
|
|
66
|
+
}),
|
|
67
|
+
schema: chromeEntrySchema,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
return { pages, chrome };
|
|
71
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { z } from "astro/zod";
|
|
2
|
+
|
|
3
|
+
import { chromeIdIssues } from "../lib/chrome-schemas";
|
|
4
|
+
import { contentPropsSchema } from "../lib/content-values";
|
|
5
|
+
import { translationProvenanceSchema } from "../lib/locales";
|
|
6
|
+
|
|
7
|
+
// Page content lives HERE, as JSON — components render it, they never
|
|
8
|
+
// contain it. One entry per page; `components` is the ordered list of
|
|
9
|
+
// sections on the page. All visible copy sits in each component's `props`
|
|
10
|
+
// in the content-value grammar (../lib/content-values.ts).
|
|
11
|
+
//
|
|
12
|
+
// {
|
|
13
|
+
// "route": "/pricing",
|
|
14
|
+
// "meta": { "title": "Pricing — Brand", "description": "For SEO" },
|
|
15
|
+
// "components": [
|
|
16
|
+
// {
|
|
17
|
+
// "id": "pricing-hero",
|
|
18
|
+
// "type": "hero",
|
|
19
|
+
// "props": { "heading": { "type": "text", "value": "…" } }
|
|
20
|
+
// }
|
|
21
|
+
// ]
|
|
22
|
+
// }
|
|
23
|
+
//
|
|
24
|
+
// - `id` is a stable kebab-case anchor, unique per page. The visual editor
|
|
25
|
+
// and translation address content THROUGH it — never rename an id after
|
|
26
|
+
// creation.
|
|
27
|
+
// - `mode` (default "registry"): registry pages render through
|
|
28
|
+
// src/pages/[...slug].astro + the brand's section registry, so every `type`
|
|
29
|
+
// must be registered. Bespoke pages set `"mode": "bespoke"` — their
|
|
30
|
+
// components are the page's own React components, and a bespoke .astro
|
|
31
|
+
// shell threads the entry into the page.
|
|
32
|
+
// - `draft: true` renders in dev only.
|
|
33
|
+
// - The sitemap picks `route` up automatically
|
|
34
|
+
// (../lib/sitemap/get-sitemap-paths.ts reads the pages directory from disk).
|
|
35
|
+
//
|
|
36
|
+
// WHICH section and chrome types exist is brand-owned: the repo passes its own
|
|
37
|
+
// registries into these factories. The grammar around them is platform-owned.
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A brand-owned props registry: component type → the zod schema its `props`
|
|
41
|
+
* must satisfy. `REGISTERED_SECTION_PROPS` and `CHROME_COMPONENT_PROPS` in a
|
|
42
|
+
* brand repo are both this shape.
|
|
43
|
+
*/
|
|
44
|
+
export type PropsSchemaRegistry = Record<string, z.ZodTypeAny>;
|
|
45
|
+
|
|
46
|
+
export interface ContentSchemaOptions {
|
|
47
|
+
/** The repo's `REGISTERED_SECTION_PROPS` (src/lib/section-schemas.ts). */
|
|
48
|
+
registeredSectionProps: PropsSchemaRegistry;
|
|
49
|
+
/** The repo's `CHROME_COMPONENT_PROPS` (re-exported from this package). */
|
|
50
|
+
chromeComponentProps: PropsSchemaRegistry;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const isoDateSchema = z
|
|
54
|
+
.string()
|
|
55
|
+
.regex(
|
|
56
|
+
/^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})?)?$/,
|
|
57
|
+
"expected an ISO date (2026-08-09) or ISO 8601 datetime",
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The page and chrome entry schemas for one brand's registries. Both
|
|
62
|
+
* collections share a single component schema, so a chrome entry is held to
|
|
63
|
+
* the same grammar as a page section and additionally to the chrome mount
|
|
64
|
+
* contract (chromeIdIssues + CHROME_COMPONENT_PROPS).
|
|
65
|
+
*/
|
|
66
|
+
export function createContentSchemas({
|
|
67
|
+
registeredSectionProps,
|
|
68
|
+
chromeComponentProps,
|
|
69
|
+
}: ContentSchemaOptions) {
|
|
70
|
+
const isRegisteredSection = (type: string) => type in registeredSectionProps;
|
|
71
|
+
const isChromeComponent = (type: string) => type in chromeComponentProps;
|
|
72
|
+
|
|
73
|
+
const componentSchema = z
|
|
74
|
+
.object({
|
|
75
|
+
id: z
|
|
76
|
+
.string()
|
|
77
|
+
.regex(
|
|
78
|
+
/^[a-z0-9][a-z0-9-]*$/,
|
|
79
|
+
'component ids are stable kebab-case anchors, e.g. "pricing-hero"',
|
|
80
|
+
),
|
|
81
|
+
type: z.string().min(1),
|
|
82
|
+
order: z.number().int().optional(), // defaults to array position
|
|
83
|
+
locked: z.boolean().optional(),
|
|
84
|
+
hidden: z.boolean().optional(),
|
|
85
|
+
// Non-rendering, platform-owned (figma provenance, warnings); never read by the renderer.
|
|
86
|
+
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
87
|
+
props: contentPropsSchema,
|
|
88
|
+
})
|
|
89
|
+
.strict()
|
|
90
|
+
.superRefine((component, ctx) => {
|
|
91
|
+
if (isRegisteredSection(component.type)) {
|
|
92
|
+
const result = registeredSectionProps[component.type].safeParse(
|
|
93
|
+
component.props,
|
|
94
|
+
);
|
|
95
|
+
if (!result.success) {
|
|
96
|
+
for (const issue of result.error.issues) {
|
|
97
|
+
ctx.addIssue({ ...issue, path: ["props", ...issue.path] });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
type Component = z.infer<typeof componentSchema>;
|
|
104
|
+
|
|
105
|
+
const componentListSchema = (
|
|
106
|
+
components: Component[],
|
|
107
|
+
ctx: z.RefinementCtx,
|
|
108
|
+
) => {
|
|
109
|
+
const seen = new Set<string>();
|
|
110
|
+
components.forEach((component, index) => {
|
|
111
|
+
if (seen.has(component.id)) {
|
|
112
|
+
ctx.addIssue({
|
|
113
|
+
code: "custom",
|
|
114
|
+
path: [index, "id"],
|
|
115
|
+
message: `duplicate component id "${component.id}" — ids must be unique per entry`,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
seen.add(component.id);
|
|
119
|
+
});
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const pageEntrySchema = z
|
|
123
|
+
.object({
|
|
124
|
+
version: z.number().int().default(1),
|
|
125
|
+
schemaVersion: z.string().default("1.0"),
|
|
126
|
+
route: z
|
|
127
|
+
.string()
|
|
128
|
+
.regex(/^\/[a-z0-9\-/]*$/, "route must start with / and be lowercase"),
|
|
129
|
+
// Page display name (distinct from the SEO meta.title). Optional and
|
|
130
|
+
// additive; the head <title> falls back to it when meta.title is empty.
|
|
131
|
+
title: z.string().optional(),
|
|
132
|
+
mode: z.enum(["registry", "bespoke"]).default("registry"),
|
|
133
|
+
// Chrome opt-out (starter 2.13.0): default true renders the site-level
|
|
134
|
+
// navbar/footer from chrome.json as before. Set false on a page whose
|
|
135
|
+
// design ships its own nav/footer (a full-design import) so the layout
|
|
136
|
+
// mounts no chrome. Additive — existing entries omit it and keep chrome.
|
|
137
|
+
chrome: z.boolean().default(true),
|
|
138
|
+
meta: z
|
|
139
|
+
.object({
|
|
140
|
+
title: z.string(),
|
|
141
|
+
description: z.string().optional(),
|
|
142
|
+
// Social/share image. src must be an absolute URL (crawlers 403 on
|
|
143
|
+
// signed S3 URLs — publish CDN URLs only). assetId links back to the
|
|
144
|
+
// brand asset; alt is the accessible/og:image:alt text.
|
|
145
|
+
ogImage: z
|
|
146
|
+
.object({
|
|
147
|
+
src: z.string().url(),
|
|
148
|
+
assetId: z.string().optional(),
|
|
149
|
+
alt: z.string().optional(),
|
|
150
|
+
})
|
|
151
|
+
.strict()
|
|
152
|
+
.optional(),
|
|
153
|
+
// What the page IS, for structured data (starter 2.17.0). The layout
|
|
154
|
+
// resolves the entry by route and emits the matching schema.org node
|
|
155
|
+
// deterministically (see ../components/seo.tsx) — never hand-author
|
|
156
|
+
// JSON-LD for types this covers. Absent means a plain WebPage.
|
|
157
|
+
pageType: z
|
|
158
|
+
.enum(["page", "article", "product", "about", "contact"])
|
|
159
|
+
.optional(),
|
|
160
|
+
// ISO dates (YYYY-MM-DD or full ISO 8601) for article-shaped pages;
|
|
161
|
+
// emitted as Article datePublished/dateModified. Set datePublished
|
|
162
|
+
// when the page first ships; touch dateModified on substantial
|
|
163
|
+
// content updates, not routine tweaks.
|
|
164
|
+
datePublished: isoDateSchema.optional(),
|
|
165
|
+
dateModified: isoDateSchema.optional(),
|
|
166
|
+
})
|
|
167
|
+
.strict(),
|
|
168
|
+
draft: z.boolean().default(false),
|
|
169
|
+
components: z.array(componentSchema).superRefine(componentListSchema),
|
|
170
|
+
// Locale siblings (<page>.<locale>.json, starter 2.8.0) carry per-leaf
|
|
171
|
+
// translation provenance. Platform-maintained, renderer-ignored — kept
|
|
172
|
+
// loose on purpose so agent-side evolution never fails a brand build.
|
|
173
|
+
_translation: translationProvenanceSchema.optional(),
|
|
174
|
+
})
|
|
175
|
+
.strict()
|
|
176
|
+
.superRefine((page, ctx) => {
|
|
177
|
+
if (page.mode !== "registry") return;
|
|
178
|
+
page.components.forEach((component, index) => {
|
|
179
|
+
if (!isRegisteredSection(component.type)) {
|
|
180
|
+
ctx.addIssue({
|
|
181
|
+
code: "custom",
|
|
182
|
+
path: ["components", index, "type"],
|
|
183
|
+
message: `"${component.type}" is not a registered section type (registered: ${Object.keys(registeredSectionProps).join(", ")}). Bespoke components need "mode": "bespoke" plus a bespoke page shell.`,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// Site-level chrome copy (src/content/chrome.json): the navbar and footer
|
|
190
|
+
// are structural — built once per brand, rendered by the layout on every
|
|
191
|
+
// page — but their COPY (link labels, hrefs, footer text) is copy like any
|
|
192
|
+
// other, so it lives here in the same component grammar. Chrome components
|
|
193
|
+
// read their props via ../lib/chrome.ts.
|
|
194
|
+
// Validate a chrome component's props against its chrome-specific schema.
|
|
195
|
+
// Mirrors componentSchema's registered-section superRefine, but keyed off
|
|
196
|
+
// chromeComponentProps (navbar/footer) — NOT registeredSectionProps, so
|
|
197
|
+
// chrome types never become page-insertable registry sections. chrome.json
|
|
198
|
+
// renders ONLY navbar + footer (the layout mounts those two ids), so any
|
|
199
|
+
// other id or an id/type mismatch is a hard error (chromeIdIssues) — extra
|
|
200
|
+
// bands belong in page-adjacent sections, never here where nothing would
|
|
201
|
+
// mount them.
|
|
202
|
+
const chromeComponentListSchema = (
|
|
203
|
+
components: Component[],
|
|
204
|
+
ctx: z.RefinementCtx,
|
|
205
|
+
) => {
|
|
206
|
+
componentListSchema(components, ctx);
|
|
207
|
+
for (const issue of chromeIdIssues(components)) {
|
|
208
|
+
ctx.addIssue({
|
|
209
|
+
code: "custom",
|
|
210
|
+
path: [issue.index, "id"],
|
|
211
|
+
message: issue.message,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
components.forEach((component, index) => {
|
|
215
|
+
if (!isChromeComponent(component.type)) return;
|
|
216
|
+
const result = chromeComponentProps[component.type].safeParse(
|
|
217
|
+
component.props,
|
|
218
|
+
);
|
|
219
|
+
if (!result.success) {
|
|
220
|
+
for (const issue of result.error.issues) {
|
|
221
|
+
ctx.addIssue({ ...issue, path: [index, "props", ...issue.path] });
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const chromeEntrySchema = z
|
|
228
|
+
.object({
|
|
229
|
+
components: z
|
|
230
|
+
.array(componentSchema)
|
|
231
|
+
.superRefine(chromeComponentListSchema),
|
|
232
|
+
_translation: translationProvenanceSchema.optional(),
|
|
233
|
+
})
|
|
234
|
+
.strict();
|
|
235
|
+
|
|
236
|
+
return { componentSchema, pageEntrySchema, chromeEntrySchema };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export type ContentSchemas = ReturnType<typeof createContentSchemas>;
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// The package's pure surface: the content grammar, the collection schema
|
|
2
|
+
// factories, locale/hreflang machinery, the chrome prop schemas, the bespoke
|
|
3
|
+
// page helpers, the layout's data types, and the runtime version. Nothing here
|
|
4
|
+
// imports an `astro:*` virtual module, so this entry is safe to import outside
|
|
5
|
+
// an Astro build (the platform agent validates brand content through it).
|
|
6
|
+
//
|
|
7
|
+
// Astro-bound surfaces have their own entries: `/content` (collections),
|
|
8
|
+
// `/layout`, `/seo`, `/routes`, `/sitemap`, `/config`, `/integrations/*`.
|
|
9
|
+
export * from "./content/schema";
|
|
10
|
+
export * from "./layouts/layout-core";
|
|
11
|
+
export * from "./lib/bespoke-pages";
|
|
12
|
+
export * from "./lib/chrome-schemas";
|
|
13
|
+
export * from "./lib/content-paths";
|
|
14
|
+
export * from "./lib/content-values";
|
|
15
|
+
export * from "./lib/hreflang";
|
|
16
|
+
export * from "./lib/locales";
|
|
17
|
+
export * from "./version";
|
|
18
|
+
export type {
|
|
19
|
+
BuildGraphArgs,
|
|
20
|
+
OrganizationInfo,
|
|
21
|
+
PageType,
|
|
22
|
+
} from "./components/seo";
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
// Central-plugin loader (starter ≥2.4.0) — the ONLY plugin code in brand
|
|
4
|
+
// repos. Platform plugins (visual editor, dev-soft-reload, …) are served by
|
|
5
|
+
// plugins.iterant.ai and CI/CD'd from the monorepo
|
|
6
|
+
// (.github/workflows/plugins-deploy.yml): promoting a plugin there reaches
|
|
7
|
+
// every brand's next preview load, with zero brand-repo commits. This file
|
|
8
|
+
// replaces the vendored-IIFE mechanism (integrations/visual-editor-dev.mjs +
|
|
9
|
+
// vendor/, retired in 2.4.0) and is deliberately too dumb to need changing.
|
|
10
|
+
//
|
|
11
|
+
// Dev-only. Two jobs:
|
|
12
|
+
//
|
|
13
|
+
// 1. Rebroadcast Vite HMR lifecycle as DOM events — remote plugins are not in
|
|
14
|
+
// the dev module graph, so they cannot touch import.meta.hot:
|
|
15
|
+
// - `vite:beforeFullReload` → cancelable `iterant:before-full-reload`.
|
|
16
|
+
// A plugin calling preventDefault() makes this loader throw inside the
|
|
17
|
+
// hot callback (vitejs/vite#6695), which cancels Vite's
|
|
18
|
+
// location.reload() — how dev-soft-reload morphs instead of flashing.
|
|
19
|
+
// - `vite:afterUpdate` → `vite:hmr-complete` (the visual-editor runtime's
|
|
20
|
+
// existing overlay-reapply contract).
|
|
21
|
+
//
|
|
22
|
+
// 2. Inject the plugin script tags: dev-soft-reload on every page;
|
|
23
|
+
// visual-editor only inside an iframe with ?editor=1 (and production
|
|
24
|
+
// builds contain zero plugin bytes — the loader is dev-command-gated and
|
|
25
|
+
// scripts/verify.mjs keeps enforcing the editor markers after every
|
|
26
|
+
// build).
|
|
27
|
+
//
|
|
28
|
+
// Fails soft by construction: plugin host unreachable → plain preview with
|
|
29
|
+
// native reloads and no editor; the page itself never breaks.
|
|
30
|
+
|
|
31
|
+
const BOOTSTRAP = `
|
|
32
|
+
const HOST =
|
|
33
|
+
import.meta.env.PUBLIC_ITERANT_PLUGINS_HOST || "https://plugins.iterant.ai";
|
|
34
|
+
const STARTER =
|
|
35
|
+
document.querySelector('meta[name="it-astro-starter-version"]')?.content ??
|
|
36
|
+
"";
|
|
37
|
+
|
|
38
|
+
function loadPlugin(name, id) {
|
|
39
|
+
if (document.getElementById(id)) return;
|
|
40
|
+
const script = document.createElement("script");
|
|
41
|
+
script.id = id;
|
|
42
|
+
script.src =
|
|
43
|
+
HOST + "/" + name + "/stable/plugin.js?starter=" +
|
|
44
|
+
encodeURIComponent(STARTER);
|
|
45
|
+
document.head.appendChild(script);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
loadPlugin("dev-soft-reload", "__iterant-dev-soft-reload");
|
|
49
|
+
if (
|
|
50
|
+
window.parent !== window &&
|
|
51
|
+
new URLSearchParams(window.location.search).get("editor") === "1"
|
|
52
|
+
) {
|
|
53
|
+
loadPlugin("visual-editor", "__visual-editor");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (import.meta.hot) {
|
|
57
|
+
import.meta.hot.on("vite:beforeFullReload", () => {
|
|
58
|
+
const event = new CustomEvent("iterant:before-full-reload", {
|
|
59
|
+
cancelable: true,
|
|
60
|
+
});
|
|
61
|
+
window.dispatchEvent(event);
|
|
62
|
+
if (event.defaultPrevented) {
|
|
63
|
+
throw "[iterant-plugins] full reload handled by a plugin";
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
import.meta.hot.on("vite:afterUpdate", () => {
|
|
67
|
+
window.dispatchEvent(new CustomEvent("vite:hmr-complete"));
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
`;
|
|
71
|
+
|
|
72
|
+
/** @returns {import("astro").AstroIntegration} */
|
|
73
|
+
export default function iterantPlugins() {
|
|
74
|
+
return {
|
|
75
|
+
name: "iterant-plugins",
|
|
76
|
+
hooks: {
|
|
77
|
+
"astro:config:setup": ({ command, injectScript }) => {
|
|
78
|
+
if (command !== "dev") return;
|
|
79
|
+
injectScript("page", BOOTSTRAP);
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { utimes } from "node:fs";
|
|
3
|
+
|
|
4
|
+
// Dev-only new-file hot-reload (2026-07-23). Vite's HMR only reacts to files
|
|
5
|
+
// already present in a module graph. A freshly CREATED component under src/
|
|
6
|
+
// matches no module node — the import-analysis recovery that would pull it in
|
|
7
|
+
// is client-graph-only (`if (ssr) return` in Vite's import-analysis path), so
|
|
8
|
+
// the `add` event broadcasts nothing and the preview never refreshes until a
|
|
9
|
+
// manual reload. Edits to existing modules are unaffected.
|
|
10
|
+
//
|
|
11
|
+
// This environment-API `hotUpdate` plugin catches any create/delete under src/
|
|
12
|
+
// that resolved to zero modules and broadcasts a full-reload. The dev-soft-
|
|
13
|
+
// reload preview plugin turns that into a soft morph instead of a flash, and a
|
|
14
|
+
// route caught mid-build (5xx) self-heals on the next event.
|
|
15
|
+
//
|
|
16
|
+
// Broadcasts are coalesced on a short debounce so a bulk write storm (an
|
|
17
|
+
// import scaffolding several files, a rename, a branch checkout) settles into
|
|
18
|
+
// one reload rather than one per file. Assets that can't affect a rendered
|
|
19
|
+
// module are skipped.
|
|
20
|
+
//
|
|
21
|
+
// The same blind spot starves Tailwind (2026-07-30): @tailwindcss/vite only
|
|
22
|
+
// re-scans the filesystem when a CSS root's build dependencies change mtime,
|
|
23
|
+
// so a component created mid-session contributes no new utility classes (its
|
|
24
|
+
// novel arbitrary values render unstyled) until someone edits a stylesheet.
|
|
25
|
+
// Before broadcasting, this plugin therefore bumps the mtime of every project
|
|
26
|
+
// CSS module, driving the same invalidate-and-rescan path as a real edit.
|
|
27
|
+
|
|
28
|
+
const RELOAD_DEBOUNCE_MS = 100;
|
|
29
|
+
|
|
30
|
+
// Obvious binary/asset extensions that never resolve to a source module —
|
|
31
|
+
// dropping an image or font under src/ shouldn't trigger a reload.
|
|
32
|
+
const NON_SOURCE =
|
|
33
|
+
/\.(png|jpe?g|gif|svg|webp|avif|ico|woff2?|ttf|otf|eot|mp4|webm|mov|pdf|zip)$/i;
|
|
34
|
+
|
|
35
|
+
/** @returns {import("astro").AstroIntegration} */
|
|
36
|
+
export default function newFileReload() {
|
|
37
|
+
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
|
38
|
+
let timer;
|
|
39
|
+
return {
|
|
40
|
+
name: "new-file-reload",
|
|
41
|
+
hooks: {
|
|
42
|
+
"astro:config:setup": ({ command, updateConfig }) => {
|
|
43
|
+
if (command !== "dev") return;
|
|
44
|
+
updateConfig({
|
|
45
|
+
vite: {
|
|
46
|
+
plugins: [
|
|
47
|
+
{
|
|
48
|
+
name: "iterant-new-file-reload",
|
|
49
|
+
hotUpdate({ type, file, modules, server }) {
|
|
50
|
+
// A create/delete that resolved to a module is already
|
|
51
|
+
// handled by the module-graph HMR path.
|
|
52
|
+
if (type === "update" || modules.length > 0) return;
|
|
53
|
+
// hotUpdate fires once per environment (client + ssr); act on
|
|
54
|
+
// the client pass only so one event broadcasts one reload.
|
|
55
|
+
if (this.environment.name !== "client") return;
|
|
56
|
+
if (!file.includes("/src/")) return;
|
|
57
|
+
if (NON_SOURCE.test(file)) return;
|
|
58
|
+
// Coalesce a burst of qualifying events into a single reload:
|
|
59
|
+
// reset the timer on each one and fire once the tree settles.
|
|
60
|
+
clearTimeout(timer);
|
|
61
|
+
timer = setTimeout(() => {
|
|
62
|
+
// Tailwind v4 only re-scans the filesystem when a CSS
|
|
63
|
+
// root's build dependencies change mtime (requiresBuild in
|
|
64
|
+
// @tailwindcss/vite); a brand-new source file changes no
|
|
65
|
+
// watched module, so its novel classes (arbitrary values
|
|
66
|
+
// especially) stay missing from the generated CSS until
|
|
67
|
+
// something touches the stylesheet. Bump the mtime of every
|
|
68
|
+
// CSS module in the graph so the watcher runs the same
|
|
69
|
+
// invalidate-and-rescan path as a real edit.
|
|
70
|
+
const touched = new Set();
|
|
71
|
+
for (const environment of Object.values(
|
|
72
|
+
server.environments,
|
|
73
|
+
)) {
|
|
74
|
+
for (const mod of environment.moduleGraph.idToModuleMap.values()) {
|
|
75
|
+
const cssFile = mod.file;
|
|
76
|
+
if (!cssFile || !cssFile.endsWith(".css")) continue;
|
|
77
|
+
if (cssFile.includes("/node_modules/")) continue;
|
|
78
|
+
if (touched.has(cssFile)) continue;
|
|
79
|
+
touched.add(cssFile);
|
|
80
|
+
const now = new Date();
|
|
81
|
+
utimes(cssFile, now, now, () => {});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
server.ws.send({ type: "full-reload", path: "*" });
|
|
85
|
+
}, RELOAD_DEBOUNCE_MS);
|
|
86
|
+
return [];
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
// Dev-only branded fallback for broken routes (2026-07-06): mid-build there
|
|
4
|
+
// is a window where a route exists but its page component doesn't (the shell
|
|
5
|
+
// lands before page.tsx) or is mid-edit with a syntax error — and the Astro
|
|
6
|
+
// dev server serves its raw stack-trace error page into the customer's
|
|
7
|
+
// preview iframe. Astro middleware can't catch this class (the module fails
|
|
8
|
+
// to load BEFORE middleware runs), so this intercepts at the HTTP layer:
|
|
9
|
+
// buffer HTML responses and swap any 5xx body for the starter-owned
|
|
10
|
+
// /under-construction shell (navbar + footer + "in progress"). The 5xx
|
|
11
|
+
// status is preserved on purpose — verify probes and the save gate must
|
|
12
|
+
// still see the failure; only the human-facing body changes.
|
|
13
|
+
|
|
14
|
+
// vite is astro's dependency, not this package's, so the dev-server type is
|
|
15
|
+
// derived from the hook astro hands it to. One vite in the type graph, whichever
|
|
16
|
+
// version the installed astro ships.
|
|
17
|
+
/**
|
|
18
|
+
* @typedef {Parameters<
|
|
19
|
+
* NonNullable<import("astro").AstroIntegration["hooks"]["astro:server:setup"]>
|
|
20
|
+
* >[0]["server"]} ViteDevServer
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const FALLBACK_PATH = "/under-construction";
|
|
24
|
+
|
|
25
|
+
// Served when /under-construction itself can't render (e.g. Layout broken).
|
|
26
|
+
const MINIMAL_FALLBACK = `<!doctype html>
|
|
27
|
+
<html lang="en"><head><meta charset="utf-8"><title>Page in progress</title></head>
|
|
28
|
+
<body style="display:flex;min-height:100vh;align-items:center;justify-content:center;font-family:system-ui,sans-serif">
|
|
29
|
+
<p>This page is in progress — check back in a moment.</p>
|
|
30
|
+
</body></html>`;
|
|
31
|
+
|
|
32
|
+
/** @returns {import("astro").AstroIntegration} */
|
|
33
|
+
export default function previewErrorShell() {
|
|
34
|
+
return {
|
|
35
|
+
name: "preview-error-shell",
|
|
36
|
+
hooks: {
|
|
37
|
+
"astro:config:setup": ({ command, updateConfig }) => {
|
|
38
|
+
if (command !== "dev") return;
|
|
39
|
+
updateConfig({
|
|
40
|
+
vite: {
|
|
41
|
+
plugins: [
|
|
42
|
+
{
|
|
43
|
+
name: "preview-error-shell",
|
|
44
|
+
configureServer(server) {
|
|
45
|
+
server.middlewares.use((req, res, next) => {
|
|
46
|
+
const accept = String(req.headers.accept ?? "");
|
|
47
|
+
if (
|
|
48
|
+
req.method !== "GET" ||
|
|
49
|
+
!accept.includes("text/html") ||
|
|
50
|
+
String(req.url ?? "").startsWith(FALLBACK_PATH)
|
|
51
|
+
) {
|
|
52
|
+
return next();
|
|
53
|
+
}
|
|
54
|
+
intercept(server, res);
|
|
55
|
+
next();
|
|
56
|
+
});
|
|
57
|
+
// Astro's own dev handler registers earlier in the connect
|
|
58
|
+
// stack and ends the response before later layers run —
|
|
59
|
+
// hoist this layer to the front so it wraps every HTML
|
|
60
|
+
// response (the accept filter keeps it off asset requests).
|
|
61
|
+
const layer = server.middlewares.stack.pop();
|
|
62
|
+
if (layer) server.middlewares.stack.unshift(layer);
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Buffer the response; replay it verbatim unless it resolves to a 5xx, in
|
|
75
|
+
* which case serve the branded fallback body instead. Buffering trades dev
|
|
76
|
+
* streaming for the swap — fine for a preview.
|
|
77
|
+
*
|
|
78
|
+
* @param {ViteDevServer} server
|
|
79
|
+
* @param {import("node:http").ServerResponse} res
|
|
80
|
+
*/
|
|
81
|
+
function intercept(server, res) {
|
|
82
|
+
/** @type {Array<string | Buffer>} */
|
|
83
|
+
const chunks = [];
|
|
84
|
+
/** @type {unknown[] | null} */
|
|
85
|
+
let headArgs = null;
|
|
86
|
+
const original = {
|
|
87
|
+
writeHead: res.writeHead.bind(res),
|
|
88
|
+
write: res.write.bind(res),
|
|
89
|
+
end: res.end.bind(res),
|
|
90
|
+
};
|
|
91
|
+
res.writeHead = /** @type {any} */ (
|
|
92
|
+
(/** @type {unknown[]} */ ...args) => {
|
|
93
|
+
headArgs = args;
|
|
94
|
+
return res;
|
|
95
|
+
}
|
|
96
|
+
);
|
|
97
|
+
res.write = /** @type {any} */ (
|
|
98
|
+
(/** @type {any} */ chunk) => {
|
|
99
|
+
if (chunk) chunks.push(chunk);
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
);
|
|
103
|
+
res.end = /** @type {any} */ (
|
|
104
|
+
(/** @type {any} */ chunk) => {
|
|
105
|
+
if (chunk && typeof chunk !== "function") chunks.push(chunk);
|
|
106
|
+
const status = headArgs
|
|
107
|
+
? Number(headArgs[0]) || res.statusCode
|
|
108
|
+
: res.statusCode;
|
|
109
|
+
if (status < 500) {
|
|
110
|
+
res.writeHead = original.writeHead;
|
|
111
|
+
res.write = original.write;
|
|
112
|
+
res.end = original.end;
|
|
113
|
+
if (headArgs) original.writeHead(.../** @type {[number]} */ (headArgs));
|
|
114
|
+
for (const c of chunks) original.write(c);
|
|
115
|
+
return original.end();
|
|
116
|
+
}
|
|
117
|
+
void serveFallback(server, original, status);
|
|
118
|
+
return res;
|
|
119
|
+
}
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* @param {ViteDevServer} server
|
|
125
|
+
* @param {{ writeHead: Function, write: Function, end: Function }} original
|
|
126
|
+
* @param {number} status
|
|
127
|
+
*/
|
|
128
|
+
async function serveFallback(server, original, status) {
|
|
129
|
+
let html = MINIMAL_FALLBACK;
|
|
130
|
+
try {
|
|
131
|
+
const port = server.config.server.port;
|
|
132
|
+
const resp = await fetch(`http://127.0.0.1:${port}${FALLBACK_PATH}`, {
|
|
133
|
+
headers: { accept: "text/html" },
|
|
134
|
+
});
|
|
135
|
+
if (resp.ok) html = await resp.text();
|
|
136
|
+
} catch {
|
|
137
|
+
// fall through to the minimal shell
|
|
138
|
+
}
|
|
139
|
+
original.writeHead(status, {
|
|
140
|
+
"content-type": "text/html",
|
|
141
|
+
"x-preview-fallback": "1",
|
|
142
|
+
});
|
|
143
|
+
original.write(html);
|
|
144
|
+
original.end();
|
|
145
|
+
}
|