@v-office/website-headless 0.0.2 → 0.1.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@v-office/website-headless",
3
- "version": "0.0.2",
4
- "description": "Framework-free behavior layer for v-office websites, never UI: the checkout flow as a subscribable store (createCheckoutFlow), validation and insurance rules, the checkout's structural requirements, the derived site-manifest Astro integration and progressive-enhancement engines for server-rendered markup",
3
+ "version": "0.1.0",
4
+ "description": "Framework-free behavior layer for v-office websites, never UI: the checkout flow as a subscribable store (createCheckoutFlow), validation and insurance rules, the checkout's structural requirements, the live search, availability and quote runners over a caller-owned SDK, and progressive-enhancement engines for server-rendered markup",
5
5
  "files": [
6
6
  "dist",
7
7
  "README.md"
@@ -11,7 +11,6 @@
11
11
  "types": "./dist/index.d.mts",
12
12
  "exports": {
13
13
  ".": "./dist/index.mjs",
14
- "./manifest": "./dist/manifest/index.mjs",
15
14
  "./enhance": "./dist/enhance/index.mjs",
16
15
  "./package.json": "./package.json"
17
16
  },
@@ -20,7 +19,7 @@
20
19
  },
21
20
  "devDependencies": {
22
21
  "@types/node": "26.1.2",
23
- "@v-office/website-sdk": "2.17.1",
22
+ "@v-office/website-sdk": "2.18.0",
24
23
  "jsdom": "^30.0.1",
25
24
  "oxfmt": "0.62.0",
26
25
  "oxlint": "1.77.0",
@@ -1,122 +0,0 @@
1
- //#region src/manifest/surfaces.d.ts
2
- /**
3
- * The FIVE platform UI surfaces — each a component that carries real logic —
4
- * and the two deterministic signals that tell how a site consumes each one:
5
- *
6
- * - `component`: the named export of @v-office/website-components. A site
7
- * that imports it renders the surface as IMPORTED (updates are pin bumps).
8
- * - `hooks`: named exports of @v-office/website-headless that only a UI for
9
- * this surface would import. A site that imports one of them WITHOUT
10
- * importing the component has rendered its own UI — the surface is CUSTOM
11
- * (behavior arrives by pin bump; a new capability is offered, never forced).
12
- * - `bindings`: framework bindings of that behavior exported by
13
- * @v-office/website-components next to the component (e.g. the React hook
14
- * over the flow). Importing one without the component is CUSTOM as well.
15
- *
16
- * Surfaces with no hooks yet (the search trio) can only be detected as imported
17
- * or absent. That is fine: a custom surface without a headless hook never
18
- * receives a platform-initiated change, so the distinction has no consumer.
19
- * The moment headless gains behavior for one (e.g. a search hook), its name is
20
- * added here in the same release and every adopting site reports `custom`.
21
- *
22
- * Everything else a site renders (rental detail, FAQ, consent, brand pages) is
23
- * the site's own markup and is not a platform surface. Decided 2026-09-03.
24
- */
25
- declare const PLATFORM_SURFACES: {
26
- /** Entry to search: dates + guests → the search page. */
27
- readonly searchBar: {
28
- readonly component: "SearchBar";
29
- readonly hooks: readonly [];
30
- readonly bindings: readonly [];
31
- };
32
- /** Curated rentals on brand pages, driven by the search logic. */
33
- readonly showcase: {
34
- readonly component: "Showcase";
35
- readonly hooks: readonly [];
36
- readonly bindings: readonly [];
37
- };
38
- /** The search page's results, including the map. */
39
- readonly searchResults: {
40
- readonly component: "SearchResults";
41
- readonly hooks: readonly [];
42
- readonly bindings: readonly [];
43
- };
44
- /** Calendar + price quote + cancellation-policy choice on the rental page; entry to the booking flow. */
45
- readonly quote: {
46
- readonly component: "Quote";
47
- readonly hooks: readonly ["fetchQuote", "pricePolicyBranches", "policyFreeCancellationUntil"];
48
- readonly bindings: readonly [];
49
- };
50
- /** The booking flow (checkout): stay → quote → selections → § 7 gate → book → pay. Its data-part vocabulary is `checkout-*`. */
51
- readonly booking: {
52
- readonly component: "Booking";
53
- readonly hooks: readonly ["createCheckoutFlow"];
54
- readonly bindings: readonly ["useCheckoutFlow"];
55
- };
56
- };
57
- type PlatformSurface = keyof typeof PLATFORM_SURFACES;
58
- //#endregion
59
- //#region src/manifest/index.d.ts
60
- type ComponentState = "imported" | "custom";
61
- interface SiteManifest {
62
- manifestVersion: 3;
63
- builtAt: string;
64
- /** Installed versions of the tracked packages. */
65
- packages: Record<string, string>;
66
- /** Per platform surface the site renders: imported from the package, or the site's own UI over the hook. */
67
- components: Record<string, ComponentState>;
68
- }
69
- interface ImportSets {
70
- components: Set<string>;
71
- headless: Set<string>;
72
- }
73
- /** Add every platform import found in `code` to `into`. Shared by the source scan and the bundler plugin. */
74
- declare function collectImportsFromCode(code: string, into: ImportSets): void;
75
- declare const emptyImports: () => ImportSets;
76
- /** Signal 1: what the source under src/ contains. */
77
- declare function collectImports(root: string): ImportSets;
78
- declare function deriveComponentStates(imports: ImportSets): Record<string, ComponentState>;
79
- /** Which marked surfaces the built output ships. */
80
- declare function renderedSurfaces(distDir: string): Set<string>;
81
- interface CrossCheck {
82
- /** The manifest's states: derived from what the bundler reached. */
83
- states: Record<string, ComponentState>;
84
- warnings: string[];
85
- /** Non-empty = the build must fail. */
86
- errors: string[];
87
- }
88
- declare function crossCheck(source: ImportSets, bundled: ImportSets | null, rendered: Set<string>): CrossCheck;
89
- /** Everything without the bundler signal — used by tests and by anything that runs outside a build. */
90
- declare function buildSiteManifest(root: string): SiteManifest;
91
- /**
92
- * The Astro integration:
93
- *
94
- * import { siteManifest } from "@v-office/website-headless/manifest";
95
- * integrations: [react(), siteManifest()]
96
- *
97
- * Typed structurally so this package needs no astro or vite dependency.
98
- */
99
- /** Structural subset of Astro's `AstroIntegration`, so this package needs no
100
- * astro dependency. `plugins` is `any[]`: Vite's `PluginOption` type must accept
101
- * what we pass, and pinning Vite's types here would couple us to its versions. */
102
- type SiteManifestIntegration = {
103
- name: string;
104
- hooks: {
105
- "astro:config:setup": (ctx: {
106
- config: {
107
- root: URL;
108
- };
109
- updateConfig: (cfg: {
110
- vite: {
111
- plugins: any[];
112
- };
113
- }) => unknown;
114
- }) => void;
115
- "astro:build:done": (ctx: {
116
- dir: URL;
117
- }) => void;
118
- };
119
- };
120
- declare function siteManifest(): SiteManifestIntegration;
121
- //#endregion
122
- export { ComponentState, CrossCheck, ImportSets, PLATFORM_SURFACES, type PlatformSurface, SiteManifest, SiteManifestIntegration, buildSiteManifest, collectImports, collectImportsFromCode, crossCheck, deriveComponentStates, emptyImports, renderedSurfaces, siteManifest };
@@ -1,256 +0,0 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
- import { fileURLToPath } from "node:url";
4
- //#region src/manifest/surfaces.ts
5
- /**
6
- * The FIVE platform UI surfaces — each a component that carries real logic —
7
- * and the two deterministic signals that tell how a site consumes each one:
8
- *
9
- * - `component`: the named export of @v-office/website-components. A site
10
- * that imports it renders the surface as IMPORTED (updates are pin bumps).
11
- * - `hooks`: named exports of @v-office/website-headless that only a UI for
12
- * this surface would import. A site that imports one of them WITHOUT
13
- * importing the component has rendered its own UI — the surface is CUSTOM
14
- * (behavior arrives by pin bump; a new capability is offered, never forced).
15
- * - `bindings`: framework bindings of that behavior exported by
16
- * @v-office/website-components next to the component (e.g. the React hook
17
- * over the flow). Importing one without the component is CUSTOM as well.
18
- *
19
- * Surfaces with no hooks yet (the search trio) can only be detected as imported
20
- * or absent. That is fine: a custom surface without a headless hook never
21
- * receives a platform-initiated change, so the distinction has no consumer.
22
- * The moment headless gains behavior for one (e.g. a search hook), its name is
23
- * added here in the same release and every adopting site reports `custom`.
24
- *
25
- * Everything else a site renders (rental detail, FAQ, consent, brand pages) is
26
- * the site's own markup and is not a platform surface. Decided 2026-09-03.
27
- */
28
- const PLATFORM_SURFACES = {
29
- /** Entry to search: dates + guests → the search page. */
30
- searchBar: {
31
- component: "SearchBar",
32
- hooks: [],
33
- bindings: []
34
- },
35
- /** Curated rentals on brand pages, driven by the search logic. */
36
- showcase: {
37
- component: "Showcase",
38
- hooks: [],
39
- bindings: []
40
- },
41
- /** The search page's results, including the map. */
42
- searchResults: {
43
- component: "SearchResults",
44
- hooks: [],
45
- bindings: []
46
- },
47
- /** Calendar + price quote + cancellation-policy choice on the rental page; entry to the booking flow. */
48
- quote: {
49
- component: "Quote",
50
- hooks: [
51
- "fetchQuote",
52
- "pricePolicyBranches",
53
- "policyFreeCancellationUntil"
54
- ],
55
- bindings: []
56
- },
57
- /** The booking flow (checkout): stay → quote → selections → § 7 gate → book → pay. Its data-part vocabulary is `checkout-*`. */
58
- booking: {
59
- component: "Booking",
60
- hooks: ["createCheckoutFlow"],
61
- bindings: ["useCheckoutFlow"]
62
- }
63
- };
64
- //#endregion
65
- //#region src/manifest/index.ts
66
- /**
67
- * The site manifest — the machine-readable answer to "what IS this site",
68
- * written by every build as `dist/site-manifest.json` and reported by the
69
- * build worker to the controller with the build's finalize call (process/05).
70
- *
71
- * EVERYTHING IN IT IS DERIVED, nothing is declared: package versions come
72
- * from the installed packages, component states from the site's imports (see
73
- * ./surfaces.ts). An AI never writes the manifest, so it cannot be wrong in
74
- * the ways a declaration can.
75
- *
76
- * THREE INDEPENDENT SIGNALS, cross-checked on every build:
77
- * 1. a text scan of the imports under src/ — what the code contains
78
- * 2. the imports the bundler actually resolved — what the build reaches
79
- * (a Vite plugin the integration registers; dead files never enter it,
80
- * re-exports are followed)
81
- * 3. the `data-part` markers in the built output — what a guest gets
82
- * (HTML and client chunks; the booking flow is an island)
83
- * The manifest states what the build reached (2). Disagreements with (1) are
84
- * printed. A booking flow rendered without the platform's booking behavior
85
- * (3 without 2) FAILS the build: behavior is never forked per site.
86
- *
87
- * This module is an ASTRO INTEGRATION and uses node:fs — it is exported ONLY
88
- * through the "./manifest" subpath so client bundles never touch it.
89
- */
90
- const TRACKED_PACKAGES = [
91
- "@v-office/website-sdk",
92
- "@v-office/website-headless",
93
- "@v-office/website-components",
94
- "astro",
95
- "react",
96
- "tailwindcss"
97
- ];
98
- const COMPONENTS_PACKAGE = "@v-office/website-components";
99
- const HEADLESS_PACKAGE = "@v-office/website-headless";
100
- const SOURCE_EXTENSIONS = /* @__PURE__ */ new Set([
101
- ".ts",
102
- ".tsx",
103
- ".js",
104
- ".jsx",
105
- ".mjs",
106
- ".astro"
107
- ]);
108
- function installedVersion(root, name) {
109
- const file = path.join(root, "node_modules", ...name.split("/"), "package.json");
110
- if (!fs.existsSync(file)) return void 0;
111
- return JSON.parse(fs.readFileSync(file, "utf8")).version;
112
- }
113
- function* filesUnder(dir, extensions) {
114
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
115
- const full = path.join(dir, entry.name);
116
- if (entry.isDirectory()) {
117
- if (entry.name !== "node_modules") yield* filesUnder(full, extensions);
118
- } else if (extensions.has(path.extname(entry.name))) yield full;
119
- }
120
- }
121
- /** `import { a, b as c } from "pkg"` and `export { a } from "pkg"` (a local barrel) →
122
- * named imports per package. Type-only forms render nothing and are skipped.
123
- * Namespace imports carry no names and are not detected — the cross-check reports
124
- * them as a source/bundler disagreement. */
125
- const IMPORT_RE = /(?:import|export)\s+(type\s+)?(?:[\w$]+\s*,\s*)?\{([^}]*)\}\s*from\s*["']([^"']+)["']/g;
126
- const packageOf = (specifier) => specifier === COMPONENTS_PACKAGE || specifier.startsWith(`${COMPONENTS_PACKAGE}/`) ? "components" : specifier === HEADLESS_PACKAGE || specifier.startsWith(`${HEADLESS_PACKAGE}/`) ? "headless" : null;
127
- /** Add every platform import found in `code` to `into`. Shared by the source scan and the bundler plugin. */
128
- function collectImportsFromCode(code, into) {
129
- for (const m of code.matchAll(IMPORT_RE)) {
130
- const [, typeOnly, names, specifier] = m;
131
- if (typeOnly) continue;
132
- const target = packageOf(specifier);
133
- if (!target) continue;
134
- for (const part of names.split(",")) {
135
- const name = part.trim().replace(/^type\s+/, "").split(/\s+as\s+/)[0].trim();
136
- if (name) into[target].add(name);
137
- }
138
- }
139
- }
140
- const emptyImports = () => ({
141
- components: /* @__PURE__ */ new Set(),
142
- headless: /* @__PURE__ */ new Set()
143
- });
144
- /** Signal 1: what the source under src/ contains. */
145
- function collectImports(root) {
146
- const sets = emptyImports();
147
- const src = path.join(root, "src");
148
- if (!fs.existsSync(src)) return sets;
149
- for (const file of filesUnder(src, SOURCE_EXTENSIONS)) collectImportsFromCode(fs.readFileSync(file, "utf8"), sets);
150
- return sets;
151
- }
152
- function deriveComponentStates(imports) {
153
- const states = {};
154
- for (const [surface, { component, hooks, bindings }] of Object.entries(PLATFORM_SURFACES)) if (imports.components.has(component)) states[surface] = "imported";
155
- else if (hooks.some((h) => imports.headless.has(h)) || bindings.some((b) => imports.components.has(b))) states[surface] = "custom";
156
- return states;
157
- }
158
- /**
159
- * Surfaces whose markup carries a `data-part` vocabulary, and the one marker
160
- * every rendering of the surface must contain. Looked for in the built output —
161
- * HTML for server-rendered markup, client JS chunks for islands whose shell is
162
- * a loading state (Krentz's booking flow is `client:load` and fetches the quote live).
163
- */
164
- const OUTPUT_MARKERS = { booking: "checkout-submit" };
165
- const OUTPUT_EXTENSIONS = /* @__PURE__ */ new Set([
166
- ".html",
167
- ".js",
168
- ".mjs"
169
- ]);
170
- /** Which marked surfaces the built output ships. */
171
- function renderedSurfaces(distDir) {
172
- const found = /* @__PURE__ */ new Set();
173
- if (!fs.existsSync(distDir)) return found;
174
- for (const file of filesUnder(distDir, OUTPUT_EXTENSIONS)) {
175
- const text = fs.readFileSync(file, "utf8");
176
- for (const [surface, marker] of Object.entries(OUTPUT_MARKERS)) if (text.includes(marker)) found.add(surface);
177
- }
178
- return found;
179
- }
180
- function crossCheck(source, bundled, rendered) {
181
- const fromSource = deriveComponentStates(source);
182
- const states = bundled ? deriveComponentStates(bundled) : fromSource;
183
- const warnings = [];
184
- const errors = [];
185
- if (bundled) for (const surface of /* @__PURE__ */ new Set([...Object.keys(fromSource), ...Object.keys(states)])) {
186
- const a = fromSource[surface];
187
- const b = states[surface];
188
- if (a && !b) warnings.push(`${surface}: the source imports it (${a}) but no page reaches that code — dead file? The manifest reports the build, not the file.`);
189
- else if (!a && b) warnings.push(`${surface}: the build resolves it (${b}) but the source scan did not see a named import — namespace import or re-export; the build wins.`);
190
- else if (a && b && a !== b) warnings.push(`${surface}: source says ${a}, build says ${b}; the build wins.`);
191
- }
192
- for (const surface of rendered) if (!states[surface]) {
193
- const { hooks, bindings, component } = PLATFORM_SURFACES[surface];
194
- errors.push(`${surface}: the built output ships its data-part markers, but the build imports neither the ${component} component nor its behavior (${[...hooks, ...bindings].join(", ")}) from the platform packages. A ${surface} without the platform's behavior is not allowed — behavior is never forked per site.`);
195
- }
196
- for (const surface of Object.keys(OUTPUT_MARKERS)) if (states[surface] && !rendered.has(surface)) warnings.push(`${surface}: the code has it (${states[surface]}) but the built output ships no ${OUTPUT_MARKERS[surface]} marker — nothing renders it.`);
197
- return {
198
- states,
199
- warnings,
200
- errors
201
- };
202
- }
203
- function packagesOf(root) {
204
- const packages = {};
205
- for (const name of TRACKED_PACKAGES) {
206
- const version = installedVersion(root, name);
207
- if (version) packages[name] = version;
208
- }
209
- return packages;
210
- }
211
- /** Everything without the bundler signal — used by tests and by anything that runs outside a build. */
212
- function buildSiteManifest(root) {
213
- return {
214
- manifestVersion: 3,
215
- builtAt: (/* @__PURE__ */ new Date()).toISOString(),
216
- packages: packagesOf(root),
217
- components: deriveComponentStates(collectImports(root))
218
- };
219
- }
220
- function siteManifest() {
221
- let root = process.cwd();
222
- const bundled = emptyImports();
223
- return {
224
- name: "@v-office/website-headless/manifest",
225
- hooks: {
226
- "astro:config:setup": ({ config, updateConfig }) => {
227
- root = fileURLToPath(config.root);
228
- const src = path.join(root, "src") + path.sep;
229
- updateConfig({ vite: { plugins: [{
230
- name: "voffice-manifest-imports",
231
- transform(code, id) {
232
- if (id.startsWith(src) && !id.includes("/node_modules/")) collectImportsFromCode(code, bundled);
233
- return null;
234
- }
235
- }] } });
236
- },
237
- "astro:build:done": ({ dir }) => {
238
- const dist = fileURLToPath(dir);
239
- const check = crossCheck(collectImports(root), bundled, renderedSurfaces(dist));
240
- for (const w of check.warnings) console.warn(`[site-manifest] ${w}`);
241
- if (check.errors.length) throw new Error(`[site-manifest] build refused:\n - ${check.errors.join("\n - ")}`);
242
- const manifest = {
243
- manifestVersion: 3,
244
- builtAt: (/* @__PURE__ */ new Date()).toISOString(),
245
- packages: packagesOf(root),
246
- components: check.states
247
- };
248
- fs.writeFileSync(path.join(dist, "site-manifest.json"), JSON.stringify(manifest, null, 2) + "\n");
249
- const states = Object.values(manifest.components);
250
- console.log(`[site-manifest] ${Object.keys(manifest.packages).length} packages, ${states.length} surfaces (${states.filter((s) => s === "imported").length} imported, ${states.filter((s) => s === "custom").length} custom); source scan, bundler and built output agree${check.warnings.length ? ` with ${check.warnings.length} warning(s)` : ""}`);
251
- }
252
- }
253
- };
254
- }
255
- //#endregion
256
- export { PLATFORM_SURFACES, buildSiteManifest, collectImports, collectImportsFromCode, crossCheck, deriveComponentStates, emptyImports, renderedSurfaces, siteManifest };