@tryvitris/website-harness 1.0.2 → 1.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.
Files changed (59) hide show
  1. package/README.md +76 -74
  2. package/agent/graph.yaml +769 -764
  3. package/agent/refs/admin.md +11 -11
  4. package/agent/refs/browser.md +6 -4
  5. package/agent/refs/business.md +20 -9
  6. package/agent/refs/implement.md +8 -6
  7. package/agent/refs/scheduling.md +13 -0
  8. package/bin/api/agent/evaluate.js +3 -3
  9. package/bin/api/agent/grade.js +3 -3
  10. package/bin/api/cli/api.js +11 -11
  11. package/bin/api/cli.js +13 -13
  12. package/bin/api/engine.js +4 -4
  13. package/bin/api/hosting.js +5 -5
  14. package/bin/api/index.js +1 -1
  15. package/bin/chunks/{chunk-Q6FLOYHS.js → chunk-5KRJQKHB.js} +3 -3
  16. package/bin/chunks/{chunk-YYK6367V.js → chunk-AD7WAHW2.js} +2 -2
  17. package/bin/chunks/{chunk-GO3XEQBA.js → chunk-AS74YXGM.js} +3 -3
  18. package/bin/chunks/{chunk-GM57SNLL.js → chunk-DDJ3QCRR.js} +9 -9
  19. package/bin/chunks/chunk-GPS3SAGQ.js +1020 -0
  20. package/bin/chunks/{chunk-ATPI2YRF.js → chunk-KR5ZUZV2.js} +2 -2
  21. package/bin/chunks/{chunk-YYYPSFFZ.js → chunk-MAPY3YVE.js} +2 -2
  22. package/bin/chunks/{chunk-OQGUEQ6F.js → chunk-MYX4FET4.js} +1 -1
  23. package/bin/chunks/{chunk-XJTAR7KM.js → chunk-OZD7ZWDX.js} +1 -1
  24. package/bin/chunks/{chunk-CPQQQ5YB.js → chunk-SBOUSO3S.js} +3 -3
  25. package/bin/chunks/{chunk-BMD5GVUT.js → chunk-SXPLCCO7.js} +6 -6
  26. package/bin/chunks/{chunk-QRIYA3GJ.js → chunk-WIT6364Y.js} +3 -3
  27. package/bin/chunks/{chunk-JPQCUN2J.js → chunk-WK7HJI5H.js} +1 -1
  28. package/bin/chunks/{design-context-store-BDYBQQCO.js → design-context-store-PGXCHUOF.js} +3 -3
  29. package/bin/chunks/{packets-RXFWDCK2.js → packets-B7MCJ42T.js} +3 -3
  30. package/bin/chunks/{preparation-ETEEMB5H.js → preparation-MVKICQPD.js} +3 -3
  31. package/bin/types/src/build/index.d.ts +2 -0
  32. package/bin/types/src/cli/admin.d.ts +1 -1
  33. package/bin/types/src/scheduling/config.d.ts +11 -0
  34. package/bin/types/src/scheduling/management.d.ts +2 -0
  35. package/bin/types/src/scheduling/styles.d.ts +1 -0
  36. package/bin/types/src/scheduling/widget.d.ts +2 -0
  37. package/bin/vitris-art.js +3 -3
  38. package/bin/vitris-build.js +2 -2
  39. package/bin/vitris-create-site.js +5 -5
  40. package/bin/vitris-cursor-hook.js +3 -3
  41. package/bin/vitris-eval.js +5 -5
  42. package/bin/vitris-go.js +5 -5
  43. package/bin/vitris-harvest.js +4 -4
  44. package/bin/vitris-knowledge.js +4 -4
  45. package/bin/vitris-launch-preflight.js +3 -3
  46. package/bin/vitris-motion-view.js +3 -3
  47. package/bin/vitris-references.js +2 -2
  48. package/bin/vitris-refresh.js +4 -4
  49. package/bin/vitris-stock.js +1 -1
  50. package/bin/vitris-themes.js +3 -3
  51. package/bin/vitris-upgrade.js +4 -4
  52. package/bin/vitris-verify-browser.js +3 -3
  53. package/bin/vitris-verify-performance.js +2 -2
  54. package/bin/vitris.js +13 -13
  55. package/docs/ADMIN.md +85 -85
  56. package/docs/CLI.md +314 -312
  57. package/docs/SCHEDULING.md +67 -0
  58. package/package.json +5 -4
  59. package/bin/chunks/chunk-NMTFSWPK.js +0 -332
@@ -0,0 +1,67 @@
1
+ # Standard website booking
2
+
3
+ This unreleased source adds a standard customer booking page backed by the business admin's authoritative appointments. The flow is service, optional location/staff, time, contact details, policy/review, then confirmation or an approval-pending receipt. Both admin and website bookings use the same availability calculations, native resource reservations and calendar export queue.
4
+
5
+ This slice supports **test bookings only**. Customer emails, calendar attachments and private cancellation/rescheduling links are now implemented. Local emails are captured; sending requires explicit admin/provider setup and allowed test recipients. Production activation, deposits and structured address/intake remain later work. Published admin `0.1.0-alpha.1` does not contain these routes; release and pin compatible packages before installation. No deployment, publication or remote migration is performed by this feature.
6
+
7
+ ## Connection
8
+
9
+ The compatible admin must enable scheduling, apply migrations through `0006_public_booking.sql`, configure staff/hours, select Test mode, publish services and provide customer policy text in Booking settings. Read its packaged `agent/refs/public-booking.md` for exact Origin, server enablement, Turnstile, rate limits and calendar setup.
10
+
11
+ Retain existing `launch.json` fields and add public metadata:
12
+
13
+ ```json
14
+ {
15
+ "publicOrigin": "https://www.BUSINESS.example",
16
+ "scheduling": {
17
+ "version": 1,
18
+ "enabled": true,
19
+ "apiOrigin": "https://admin.BUSINESS.example",
20
+ "siteId": "EXACT_ADMIN_SITE_ID",
21
+ "installationId": "EXACT_ADMIN_INSTALLATION_ID",
22
+ "route": "/book"
23
+ }
24
+ }
25
+ ```
26
+
27
+ Use exact HTTPS origins without paths/trailing slashes, or explicitly configured loopback HTTP for isolated acceptance. Never include admin/provider credentials. The compiler rejects unknown connection fields, unsafe routes and collisions with an authored booking route.
28
+
29
+ Build normally with the compatible `vitris-build --site .`. The compiler generates the booking route if absent. To author its surrounding layout, create that route with exactly one `<div data-vitris-booking></div>`. An existing route without the marker is a conflict; authored content is never replaced silently. Preserve the component's standard internal layout and interaction contract. It uses approved site color/font tokens; agents compose surrounding content and links.
30
+
31
+ Add header and service actions linking to `/book`, optionally `/book?service=VERIFIED_SERVICE_ID`. Keep existing external booking links until the replacement is reviewed. Inline/modal booking is not implemented. Only the booking route receives its content-addressed component assets; sites without scheduling receive no booking runtime.
32
+
33
+ ## Behavior and limits
34
+
35
+ The component reads services, prices, staff, policy and availability dynamically from admin. These operational changes need no website rebuild. It checks schema, site/installation identity and exact website origin. A production-connected build served on an unapproved preview host refuses to connect; use an explicit loopback connection for local testing.
36
+
37
+ Public routes are `/api/booking/v1/catalog`, `/availability` and `/appointments` on the admin origin, using exact-origin CORS and `credentials:omit`. They grant no admin authority and expose no customer records, staff accounts, private resources, internal availability or external event contents. Guest commands use their own transactional ledger, not an impersonated staff account. Bookings do not become leads or analytics enquiries automatically.
38
+
39
+ Times use the location's IANA timezone, with explicit offsets. A time is unreserved until committed. Current price/policy accompany availability; changed selections conflict rather than silently charging or showing obsolete terms. Required calendars refresh before commit. Native reservations, receipt, guest history and captured jobs commit together. Instant services confirm immediately; approval requests reserve time until their recorded deadline. Calendar failures after commitment retain the native appointment and durable export job.
40
+
41
+ The UI preserves details after conflicts. Uncertain responses offer Retry same booking with the same in-memory UUID/content. Do not close an uncertain request and create another; contact the business if that page is lost. Receipts describe the original acceptance and are not current appointment-management authority. No customer data or retry authority is persisted in browser storage. Booking works independently of optional analytics consent.
42
+
43
+ Receipts offer an `.ics` snapshot. Importing it does not provide ongoing sync or external busy-time access. Email state distinguishes capture, queued work, blocked/uncertain sending and provider acceptance; acceptance does not prove inbox delivery. Customer-site services requiring structured addresses are excluded until that intake capability is implemented.
44
+
45
+ ## Confirmation emails and customer management
46
+
47
+ Review admin migration 0007 and its packaged `agent/refs/customer-notifications.md`. In Booking settings, explicitly enable customer emails and customer links, and set cancellation/rescheduling notice. Rules are snapshotted for new appointments. Local capture mode never sends mail; Send mode requires a separate restricted Cloudflare Email Service binding, sender/domain setup, a notification encryption key ring and an explicit test-recipient allowlist. These are server values, never website metadata.
48
+
49
+ The compiler generates the standard management page at the booking route plus `/manage` (normally `/book/manage`). An authored page there needs exactly one `data-vitris-customer-management` mount. Its indexing is forced off, it omits the harness beacon/web analytics, and its first head script removes URL fragments before other scripts. Avoid custom analytics or third-party scripts on this private route. Set the admin's `BOOKING_MANAGE_PATH` to the exact generated path for a custom booking route.
50
+
51
+ Email links contain appointment-only capabilities in fragments. Opening a link does not change the appointment; the customer explicitly opens its details and confirms a cancellation or replacement time. A short-lived bearer session and retry proof stay only in memory. Neither links nor sessions belong in query strings, referrers, logs or browser persistence. Links can be reopened until expiry; after a session expires, use the original valid email link. Changing recipients or closing an appointment revokes authority.
52
+
53
+ Customer rescheduling keeps service, staff, location, price, duration and approval rules, and checks current availability/calendar state. Pending approval stays pending with its original deadline. Failed replacements retain the original reservation. Changes use revisions and idempotency, including recovery from a lost cancellation response after access is revoked. Pausing new booking still permits eligible cancellation; rescheduling respects the scheduling pause. Expired or unavailable links direct the customer to contact the business; public email recovery/resend is not implemented.
54
+
55
+ The admin Customer emails tab previews captured messages and calendar attachments. It can review blocked/unknown sends; unknown sends require explicit acknowledgment before retry because they may already have been accepted. Captured messages never turn into sent messages merely by changing settings. Sender/domain and actual inbox acceptance remain separate from local fixtures.
56
+
57
+ Outside the explicitly isolated loopback fixture, Turnstile is mandatory. The component loads the official script at review with action `booking`; the server verifies success, action and hostname. Configure any CSP to allow the booking API for `connect-src` and Turnstile scripts/frames per [Cloudflare's CSP guidance](https://developers.cloudflare.com/turnstile/reference/content-security-policy/). Review real challenge execution separately from fixtures.
58
+
59
+ ## Local acceptance
60
+
61
+ With sibling `website-admin-harness` and `vitris-harness` sources, run `npm run booking:preview` from the admin repository. Customer booking is **http://localhost:4392/book**; admin is **http://localhost:4391/#scheduling** (Open local demo). Both share real local workerd/D1 with explicitly simulated Google/Outlook HTTP. The loopback challenge fixture is labeled, and no real email/account is connected. Data resets on shutdown. Its shared IP allows five new attempts/hour; restarting resets ephemeral rate records.
62
+
63
+ Run website `npm run typecheck`, `npm test`, and `npm run verify:package`; run admin `npm run check`. Paired Chromium acceptance requires both source checkouts and proves guest booking appears in admin and the simulated calendar, duplicate/lost-response recovery, approval, current terms, empty states, mobile/keyboard behavior and accessibility. The admin check also installs packed packages outside both workspaces for a separate browser flow. Neither suite silently skips this integration when the sibling source is absent. Keep evidence ignored.
64
+
65
+ Real OAuth/challenge acceptance, notification onboarding, production mode, schema backup/recovery and customer rollout require separate reviewed releases and deployment authorization. A simulated receipt is not proof of deployed readiness.
66
+
67
+ Business-configurable reminder emails are managed by the admin harness in Booking settings. It supports up to three lead times and reuses the confirmation sender, calendar attachments and customer-management page. Reminder eligibility follows current business settings and confirmed appointment status; no website credentials or new website component are required. See the admin packaged `agent/refs/customer-notifications.md` and additive migration 0008 for setup and missed-reminder behavior.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tryvitris/website-harness",
3
- "version": "1.0.2",
3
+ "version": "1.1.0",
4
4
  "description": "Vitris agent harness for building and verifying customer websites, with a static compiler, media tools, and optional Cloudflare backend.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -107,6 +107,7 @@
107
107
  "design-system/knowledge",
108
108
  "docs/CLI.md",
109
109
  "docs/ADMIN.md",
110
+ "docs/SCHEDULING.md",
110
111
  "docs/DISTRIBUTION.md",
111
112
  "docs/ENGINE.md",
112
113
  "docs/CLOUDFLARE.md",
@@ -150,7 +151,7 @@
150
151
  "typecheck": "tsc --noEmit",
151
152
  "test:admin": "node --experimental-strip-types --test src/admin-integration.test.ts",
152
153
  "verify:admin": "node --experimental-strip-types scripts/verify-admin.ts",
153
- "test": "node --experimental-strip-types --test --test-concurrency=2 src/admin-integration.test.ts src/icons.test.ts src/template-icon-migration.test.ts src/engine.test.ts src/upgrade.test.ts src/hosting.test.ts src/render.test.ts src/analytics.test.ts src/cookie-compliance.test.ts src/schema.test.ts src/patch.test.ts src/jobs.test.ts src/onboard.test.ts src/github-preview.test.ts src/agent.test.ts src/workflow.test.ts src/design-run.test.ts src/creative-profile.test.ts src/creative-efficiency.test.ts src/refine.test.ts src/refine-document-checks.test.ts src/critic-calibration.test.ts src/cli.test.ts src/jev.test.ts src/lifecycle.test.ts src/theme-lifecycle-policy.test.ts src/template-adaptation.test.ts src/adaptation-checkpoint.test.ts src/template-motion.test.ts src/template-motion-browser.test.ts src/experience-browser.test.ts src/jev-capabilities.test.ts src/jev-phase8.test.ts src/jev-prepilot.test.ts src/jev-phase0.test.ts src/jev-sept20.test.ts src/trace.test.ts src/cursor-hook.test.ts src/design-intelligence.test.ts src/design-context-integration.test.ts src/package-bin.test.ts src/knowledge-layers.test.ts src/launch-preflight.test.ts src/page-transfer.test.ts src/worker-publish.test.ts src/worker-preview.test.ts src/worker-security.test.ts src/harness-upgrades.test.ts src/stock-media.test.ts src/harness-distribution.test.ts src/storyboard-media.test.ts src/added-media.test.ts src/lead-forms.test.ts src/composition.test.ts src/reference-library.test.ts src/art-direction.test.ts src/scroll-motion.test.ts src/scroll-motion-browser.test.ts src/artistic-motion-browser.test.ts src/page-composition.test.ts src/reference-quality.test.ts src/production-quality.test.ts src/browser-focus.test.ts src/browser-work-queue.test.ts src/quality-subject.test.ts src/preview-cli.test.ts src/theme-selection.test.ts src/site-inputs.test.ts src/fieldwell.test.ts src/still-field.test.ts src/current-craft.test.ts src/copper-chair.test.ts src/clearline.test.ts src/template-cloud.test.ts src/contact-forms.test.ts src/contact-forms-operations.test.ts src/contact-forms-browser.test.ts src/preserve.test.ts src/release-safety.test.ts src/performance-media.test.ts src/lighthouse-browser.test.ts src/browser-timeout.test.ts src/browser-output-contract.test.ts src/template-motion-snapshot.test.ts src/typography.test.ts",
154
+ "test": "node --experimental-strip-types --test --test-concurrency=2 src/admin-integration.test.ts src/icons.test.ts src/template-icon-migration.test.ts src/engine.test.ts src/upgrade.test.ts src/hosting.test.ts src/render.test.ts src/analytics.test.ts src/cookie-compliance.test.ts src/schema.test.ts src/patch.test.ts src/jobs.test.ts src/onboard.test.ts src/github-preview.test.ts src/agent.test.ts src/workflow.test.ts src/design-run.test.ts src/creative-profile.test.ts src/creative-efficiency.test.ts src/refine.test.ts src/refine-document-checks.test.ts src/critic-calibration.test.ts src/cli.test.ts src/jev.test.ts src/lifecycle.test.ts src/theme-lifecycle-policy.test.ts src/template-adaptation.test.ts src/adaptation-checkpoint.test.ts src/template-motion.test.ts src/template-motion-browser.test.ts src/experience-browser.test.ts src/jev-capabilities.test.ts src/jev-phase8.test.ts src/jev-prepilot.test.ts src/jev-phase0.test.ts src/jev-sept20.test.ts src/trace.test.ts src/cursor-hook.test.ts src/design-intelligence.test.ts src/design-context-integration.test.ts src/package-bin.test.ts src/knowledge-layers.test.ts src/launch-preflight.test.ts src/page-transfer.test.ts src/worker-publish.test.ts src/worker-preview.test.ts src/worker-security.test.ts src/harness-upgrades.test.ts src/stock-media.test.ts src/harness-distribution.test.ts src/storyboard-media.test.ts src/added-media.test.ts src/lead-forms.test.ts src/composition.test.ts src/reference-library.test.ts src/art-direction.test.ts src/scroll-motion.test.ts src/scroll-motion-browser.test.ts src/artistic-motion-browser.test.ts src/page-composition.test.ts src/reference-quality.test.ts src/production-quality.test.ts src/browser-focus.test.ts src/browser-work-queue.test.ts src/quality-subject.test.ts src/preview-cli.test.ts src/theme-selection.test.ts src/site-inputs.test.ts src/fieldwell.test.ts src/still-field.test.ts src/current-craft.test.ts src/copper-chair.test.ts src/clearline.test.ts src/template-cloud.test.ts src/contact-forms.test.ts src/contact-forms-operations.test.ts src/contact-forms-browser.test.ts src/preserve.test.ts src/release-safety.test.ts src/performance-media.test.ts src/lighthouse-browser.test.ts src/browser-timeout.test.ts src/browser-output-contract.test.ts src/template-motion-snapshot.test.ts src/typography.test.ts src/scheduling.test.ts",
154
155
  "eval:jev": "node --experimental-strip-types scripts/eval-jev.ts",
155
156
  "benchmark": "node --experimental-strip-types scripts/benchmark.ts",
156
157
  "refresh-agent": "node --experimental-strip-types scripts/refresh-agent.ts",
@@ -195,8 +196,8 @@
195
196
  },
196
197
  "dependencies": {
197
198
  "@axe-core/playwright": "4.11.0",
198
- "@tryvitris/website-admin-contracts": "0.1.0-alpha.1",
199
- "@tryvitris/website-admin-harness": "0.1.0-alpha.1",
199
+ "@tryvitris/website-admin-contracts": "0.1.0-alpha.2",
200
+ "@tryvitris/website-admin-harness": "0.1.0-alpha.2",
200
201
  "@typesafe-ai/sdk": "^0.6.0",
201
202
  "blake3-wasm": "^2.1.5",
202
203
  "chrome-launcher": "1.2.1",
@@ -1,332 +0,0 @@
1
- import {
2
- COOKIE_COMPLIANCE_PATH,
3
- beaconJs,
4
- compileAllPages,
5
- compileSite,
6
- defaultTitleFromPath,
7
- fileToPath,
8
- motionAssetHref,
9
- motionJs,
10
- parseFrontmatter,
11
- robotsTxt,
12
- siteCss,
13
- siteIslands,
14
- sitemapXml
15
- } from "./chunk-JTXFTNEA.js";
16
- import {
17
- mergeTheme,
18
- themeCss
19
- } from "./chunk-GE6UUEG2.js";
20
- import {
21
- escapeHtml
22
- } from "./chunk-7FS67SRF.js";
23
-
24
- // src/build/index.ts
25
- import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
26
- import { join, relative } from "node:path";
27
- import { createHash } from "node:crypto";
28
-
29
- // src/forms/frontend.ts
30
- import { parseFragment } from "parse5";
31
-
32
- // src/forms/config.ts
33
- var safeId = (s) => typeof s === "string" && /^[a-zA-Z0-9_-]{1,80}$/.test(s);
34
- var safeEmail = (s) => {
35
- if (typeof s !== "string" || s.length > 254 || !/^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,63}$/.test(s)) return false;
36
- const [local, domain] = s.split("@");
37
- return !!local && local.length <= 64 && !local.startsWith(".") && !local.endsWith(".") && !local.includes("..") && !!domain && domain.split(".").every((label) => label.length <= 63 && /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/.test(label));
38
- };
39
- function approvedOrigin(value) {
40
- const u = new URL(value);
41
- if (u.origin !== value || u.hostname.includes("*") || u.username || u.password || u.protocol !== "https:" && !(u.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(u.hostname))) throw new Error("Expected exact HTTPS origin (HTTP only on loopback)");
42
- return u.origin;
43
- }
44
- function validateFormConfig(value) {
45
- const c = value;
46
- if (!c || !safeId(c.id) || !["disabled", "test", "live"].includes(c.mode)) throw new Error("Invalid form ID or mode");
47
- if (!Array.isArray(c.origins) || !c.origins.length || c.origins.length > 10 || new Set(c.origins).size !== c.origins.length) throw new Error("One to ten unique exact origins required");
48
- c.origins.forEach(approvedOrigin);
49
- if (c.mode === "live" && c.origins.some((o) => new URL(o).protocol !== "https:" || ["localhost", "127.0.0.1", "[::1]"].includes(new URL(o).hostname))) throw new Error("Live forms cannot approve loopback origins");
50
- if (!Array.isArray(c.fields) || !c.fields.length || c.fields.length > 32 || new Set(c.fields.map((f) => f.name)).size !== c.fields.length) throw new Error("One to 32 unique fields required");
51
- const reserved = /* @__PURE__ */ new Set(["form_id", "tenant_id", "idempotency_key", "page_path", "vid", "company_url", "cf-turnstile-response", "recipients", "to", "from", "sender"]);
52
- const maps = /* @__PURE__ */ new Set();
53
- for (const f of c.fields) {
54
- if (!safeId(f.name) || reserved.has(f.name) || !["text", "email", "tel", "textarea", "select", "checkbox", "radio"].includes(f.type) || !Number.isInteger(f.maxLength) || f.maxLength < 1 || f.maxLength > 8e3 || f.minLength !== void 0 && (!Number.isInteger(f.minLength) || f.minLength < 0 || f.minLength > f.maxLength) || f.label !== void 0 && (typeof f.label !== "string" || f.label.length > 200) || f.required !== void 0 && typeof f.required !== "boolean") throw new Error("Invalid field schema");
55
- if (f.map !== void 0) {
56
- if (!["name", "email", "phone", "message"].includes(f.map) || maps.has(f.map)) throw new Error("Invalid or duplicate mapping");
57
- maps.add(f.map);
58
- }
59
- if ((["select", "checkbox", "radio"].includes(f.type) || f.options !== void 0) && (!Array.isArray(f.options) || !f.options.length || f.options.length > 40 || f.options.some((s) => typeof s !== "string" || !s || s.length > f.maxLength) || new Set(f.options).size !== f.options.length)) throw new Error("Choice fields need bounded options");
60
- }
61
- if (!c.fields.some((f) => f.map === "email" && f.type === "email" && f.required)) throw new Error("A required visitor email mapping is needed");
62
- if (!Array.isArray(c.recipients) || c.recipients.length > 5 || c.recipients.some((r) => !safeEmail(r)) || new Set(c.recipients).size !== c.recipients.length) throw new Error("Invalid notification recipients");
63
- if (!c.turnstile || typeof c.turnstile.sitekey !== "string" || !/^[a-zA-Z0-9_-]{1,100}$/.test(c.turnstile.sitekey) || !safeId(c.turnstile.secretRef) || !safeId(c.turnstile.action) || c.turnstile.action.length > 32) throw new Error("Invalid Turnstile configuration");
64
- return c;
65
- }
66
-
67
- // src/forms/frontend.ts
68
- function inspectForms(html) {
69
- const inventory = [];
70
- const walk = (node, visit) => {
71
- if ("tagName" in node) visit(node);
72
- if ("childNodes" in node) node.childNodes.forEach((n) => walk(n, visit));
73
- };
74
- walk(parseFragment(html), (form) => {
75
- if (form.tagName !== "form") return;
76
- const fields = [], unsupported = [];
77
- let eligible = form.attrs.some((a) => a.name === "data-lead-form");
78
- if (form.attrs.some((a) => ["data-lead-custom", "data-no-enhance"].includes(a.name))) unsupported.push("Custom submission handler needs explicit migration");
79
- walk(form, (el) => {
80
- const attr = (name2) => el.attrs.find((a) => a.name === name2)?.value;
81
- if (attr("data-vitrisid") === "emailSendButton") eligible = true;
82
- if (!["input", "textarea", "select"].includes(el.tagName)) return;
83
- const name = attr("name"), type = el.tagName === "input" ? attr("type") || "text" : el.tagName;
84
- if (["hidden", "submit", "button", "reset"].includes(type)) {
85
- if (type === "hidden" && name && !["vid", "page_path", "company_url", "form_id", "tenant_id", "idempotency_key", "cf-turnstile-response"].includes(name)) unsupported.push(`Unmapped hidden field: ${name}`);
86
- return;
87
- }
88
- if (!name || !safeId(name) || !["text", "email", "tel", "textarea", "select", "checkbox", "radio"].includes(type)) {
89
- unsupported.push(`Unsupported field: ${name || "(unnamed)"} (${type})`);
90
- return;
91
- }
92
- if (attr("multiple") !== void 0 && type !== "checkbox") unsupported.push(`Unsupported multiple field: ${name}`);
93
- const options = [];
94
- if (type === "select") walk(el, (opt) => {
95
- if (opt.tagName === "option") {
96
- const value = opt.attrs.find((a) => a.name === "value")?.value ?? opt.childNodes.filter((n) => "value" in n).map((n) => n.value).join("");
97
- if (value) options.push(value);
98
- }
99
- });
100
- if (type === "checkbox" || type === "radio") options.push(attr("value") || "on");
101
- const old = fields.find((f) => f.name === name);
102
- if (old) {
103
- if (old.type === type && ["checkbox", "radio"].includes(type)) {
104
- old.options.push(...options);
105
- old.required = old.required || attr("required") !== void 0;
106
- } else unsupported.push(`Duplicate field: ${name}`);
107
- return;
108
- }
109
- fields.push({ name, type, label: attr("aria-label") || name, required: attr("required") !== void 0, minLength: Number(attr("minlength") || 0), maxLength: Math.min(8e3, Number(attr("maxlength") || (type === "textarea" ? 8e3 : 255))), ...["select", "checkbox", "radio"].includes(type) ? { options } : {}, ...["name", "email", "message", "phone"].includes(name) ? { map: name } : ["number", "telephone", "phone_number", "phoneNumber"].includes(name) ? { map: "phone" } : {} });
110
- });
111
- inventory.push({ index: inventory.length, eligible, fields, unsupported });
112
- });
113
- return inventory;
114
- }
115
- function connectForms(html, backend, pagePath, forms) {
116
- const bindings = forms.filter((f) => f.pagePath === pagePath);
117
- if (!bindings.length) return html;
118
- approvedOrigin(backend);
119
- const inventory = inspectForms(html);
120
- const seen = /* @__PURE__ */ new Set();
121
- for (const f of bindings) {
122
- const index = f.index ?? 0, item = inventory[index];
123
- if (!safeId(f.id) || !safeId(f.action) || f.action.length > 32 || !/^[a-zA-Z0-9_-]{1,100}$/.test(f.sitekey) || !item || !item.eligible || item.unsupported.length || seen.has(index)) throw new Error(`Cannot connect ${pagePath} form ${index}: ${item?.unsupported.join(", ") || "explicit contact-form marker and valid unique binding required"}`);
124
- seen.add(index);
125
- }
126
- const located = [];
127
- const walk = (node) => {
128
- if ("tagName" in node && node.tagName === "form") located.push(node);
129
- if ("childNodes" in node) node.childNodes.forEach(walk);
130
- };
131
- walk(parseFragment(html, { sourceCodeLocationInfo: true }));
132
- const edits = [];
133
- for (const binding of bindings) {
134
- const form = located[binding.index ?? 0], location = form.sourceCodeLocation;
135
- if (!location?.startTag || !location.endTag) throw new Error("Unclosed contact form requires source repair");
136
- const start = location.startTag.startOffset, end = location.endTag.endOffset;
137
- let open = html.slice(start, location.startTag.endOffset);
138
- const remove = /* @__PURE__ */ new Set(["action", "method", "novalidate", "data-preserve-forms", "data-vitris-form", "data-vitris-sitekey", "data-vitris-action", "data-vitris-enhanced", "data-lead-form"]);
139
- for (const [name, span] of Object.entries(location.attrs || {}).sort((a, b) => b[1].startOffset - a[1].startOffset)) if (remove.has(name)) open = open.slice(0, span.startOffset - start) + open.slice(span.endOffset - start);
140
- let inner = html.slice(location.startTag.endOffset, location.endTag.startOffset);
141
- const inputs = [];
142
- const collect = (node) => {
143
- if ("tagName" in node && node.tagName === "input") inputs.push(node);
144
- if ("childNodes" in node) node.childNodes.forEach(collect);
145
- };
146
- collect(form);
147
- for (const input of inputs.sort((a, b) => (b.sourceCodeLocation?.startOffset || 0) - (a.sourceCodeLocation?.startOffset || 0))) {
148
- const name = input.attrs.find((a) => a.name === "name")?.value;
149
- if (["form_id", "idempotency_key", "cf-turnstile-response"].includes(name || "") && input.sourceCodeLocation) {
150
- const s = input.sourceCodeLocation.startOffset - location.startTag.endOffset, e = input.sourceCodeLocation.endOffset - location.startTag.endOffset;
151
- inner = inner.slice(0, s) + inner.slice(e);
152
- }
153
- }
154
- const has = (name) => inputs.some((input) => input.attrs.some((a) => a.name === "name" && a.value === name));
155
- open = open.replace(/>$/, ` method="post" action="${escapeHtml(backend)}/api/leads?form_id=${binding.id}" data-lead-form data-vitris-enhanced data-preserve-forms="live" data-vitris-form="${binding.id}" data-vitris-sitekey="${binding.sitekey}" data-vitris-action="${binding.action}">`);
156
- const value = `${open}<input type="hidden" name="form_id" value="${binding.id}">${has("page_path") ? "" : `<input type="hidden" name="page_path" value="${escapeHtml(pagePath)}">`}${has("company_url") ? "" : '<input type="hidden" name="company_url" value="">'}${inner}<noscript><p>This form needs JavaScript and a security check. Please use the listed contact details.</p></noscript>${html.slice(location.endTag.startOffset, end)}`;
157
- edits.push({ start, end, value });
158
- }
159
- for (const edit of edits.sort((a, b) => b.start - a.start)) html = html.slice(0, edit.start) + edit.value + html.slice(edit.end);
160
- return html;
161
- }
162
-
163
- // src/build/index.ts
164
- async function walkHtmlFiles(dir, base = dir) {
165
- const out = [];
166
- let entries;
167
- try {
168
- entries = await readdir(dir, { withFileTypes: true });
169
- } catch {
170
- return out;
171
- }
172
- for (const entry of entries) {
173
- const full = join(dir, entry.name);
174
- if (entry.isDirectory()) {
175
- out.push(...await walkHtmlFiles(full, base));
176
- } else if (entry.isFile() && entry.name.endsWith(".html")) {
177
- out.push(relative(base, full).replace(/\\/g, "/"));
178
- }
179
- }
180
- return out;
181
- }
182
- async function loadSiteCatalog(siteDir) {
183
- const catalog = {};
184
- try {
185
- const entries = await readdir(join(siteDir, "components"), { withFileTypes: true });
186
- for (const entry of entries) {
187
- if (!entry.isDirectory()) continue;
188
- const name = String(entry.name);
189
- const dir = join(siteDir, "components", name);
190
- let html;
191
- let css;
192
- try {
193
- html = await readFile(join(dir, "template.html"), "utf8");
194
- css = await readFile(join(dir, "styles.css"), "utf8");
195
- } catch {
196
- continue;
197
- }
198
- let island = null;
199
- try {
200
- island = await readFile(join(dir, "island.js"), "utf8");
201
- } catch {
202
- island = null;
203
- }
204
- catalog[name] = { html, css, island };
205
- }
206
- } catch {
207
- return catalog;
208
- }
209
- return catalog;
210
- }
211
- async function loadSitePages(siteDir) {
212
- const pagesDir = join(siteDir, "pages");
213
- const files = await walkHtmlFiles(pagesDir);
214
- const pages = [];
215
- for (const file of files.sort()) {
216
- const raw = await readFile(join(pagesDir, file), "utf8");
217
- const { meta, body } = parseFrontmatter(raw);
218
- const path = fileToPath(file);
219
- pages.push({
220
- path,
221
- file,
222
- title: meta.title || defaultTitleFromPath(path),
223
- description: meta.description,
224
- indexing: path === "/404" ? "noindex" : meta.indexing === "noindex" || meta.indexing === "index" ? meta.indexing : void 0,
225
- lang: meta.lang,
226
- body
227
- });
228
- }
229
- return pages;
230
- }
231
- async function loadSiteDir(dir) {
232
- const site = JSON.parse(await readFile(join(dir, "site.json"), "utf8"));
233
- let theme = {};
234
- try {
235
- theme = JSON.parse(await readFile(join(dir, "theme", "defaults.json"), "utf8"));
236
- } catch {
237
- theme = {};
238
- }
239
- const pages = await loadSitePages(dir);
240
- const catalog = await loadSiteCatalog(dir);
241
- return { site, theme, pages, catalog };
242
- }
243
- async function buildSite(input) {
244
- const backendOrigin = input.backendOrigin || input.kernelOrigin || "";
245
- const theme = mergeTheme(input.theme);
246
- const themeHref = input.themeHref ?? "/theme.css";
247
- const envName = process.env.VITRIS_SITE_ENVIRONMENT;
248
- const site = envName === "preview" || envName === "production" ? { ...input.site, environment: envName } : input.site;
249
- const compiled = compileAllPages(site, input.pages, input.catalog, backendOrigin);
250
- const pageAssets = {};
251
- const routeAssets = /* @__PURE__ */ new Map();
252
- const asset = (body, ext) => {
253
- const path = `/assets/immutable/${createHash("sha256").update(body).digest("hex")}.${ext}`;
254
- routeAssets.set(path, body);
255
- return path;
256
- };
257
- for (const page of compiled) {
258
- const js = siteIslands([page], input.catalog);
259
- pageAssets[page.path] = { css: asset(siteCss([page], input.catalog, site), "css"), js: js ? asset(js, "js") : null };
260
- }
261
- if (backendOrigin) pageAssets[COOKIE_COMPLIANCE_PATH] = { css: asset(siteCss([], input.catalog, site), "css"), js: null };
262
- const files = compileSite({
263
- site,
264
- pages: input.pages,
265
- catalog: input.catalog,
266
- kernelOrigin: backendOrigin,
267
- themeHref,
268
- publicOrigin: input.publicOrigin,
269
- pageAssets,
270
- webAnalyticsToken: input.webAnalyticsToken
271
- }, compiled);
272
- const written = [];
273
- await mkdir(input.outDir, { recursive: true });
274
- await mkdir(join(input.outDir, "assets/immutable"), { recursive: true });
275
- for (const [path, body] of routeAssets) {
276
- await writeFile(join(input.outDir, path.slice(1)), body, "utf8");
277
- written.push(path.slice(1));
278
- }
279
- for (const file of files) {
280
- const rel = file.path === "/" ? "index.html" : `${file.path.replace(/^\//, "").replace(/\/$/, "")}/index.html`;
281
- const dest = join(input.outDir, rel);
282
- await mkdir(join(dest, ".."), { recursive: true });
283
- await writeFile(dest, connectForms(file.html, backendOrigin, file.path, input.forms || []), "utf8");
284
- written.push(rel);
285
- }
286
- await writeFile(join(input.outDir, "site.css"), siteCss(compiled, input.catalog, site), "utf8");
287
- await writeFile(join(input.outDir, "theme.css"), themeCss(theme, site.typography), "utf8");
288
- if (backendOrigin) {
289
- await writeFile(join(input.outDir, "beacon.js"), beaconJs, "utf8");
290
- written.push("beacon.js");
291
- }
292
- await writeFile(join(input.outDir, "robots.txt"), robotsTxt(input.publicOrigin), "utf8");
293
- await writeFile(
294
- join(input.outDir, "sitemap.xml"),
295
- sitemapXml(
296
- compiled.map((p) => p.path),
297
- input.publicOrigin,
298
- [COOKIE_COMPLIANCE_PATH, ...site.indexing === "noindex" ? compiled.map((page) => page.path) : compiled.filter((page) => page.indexing === "noindex").map((page) => page.path)],
299
- Boolean(backendOrigin)
300
- ),
301
- "utf8"
302
- );
303
- const islands = siteIslands(compiled, input.catalog);
304
- if (islands) await writeFile(join(input.outDir, "islands.js"), islands, "utf8");
305
- if (compiled.some((page) => page.usesMotion)) {
306
- await writeFile(join(input.outDir, motionAssetHref.slice(1)), motionJs, "utf8");
307
- written.push(motionAssetHref.slice(1));
308
- }
309
- if (islands) written.push("islands.js");
310
- written.push("site.css", "theme.css", "robots.txt", "sitemap.xml");
311
- return written;
312
- }
313
- async function fetchLiveContent(kernelOrigin, slug, token) {
314
- const res = await fetch(`${kernelOrigin.replace(/\/$/, "")}/api/deploy/${encodeURIComponent(slug)}`, {
315
- headers: { authorization: `Bearer ${token}` }
316
- });
317
- if (!res.ok) return null;
318
- return await res.json();
319
- }
320
-
321
- export {
322
- safeId,
323
- approvedOrigin,
324
- validateFormConfig,
325
- inspectForms,
326
- connectForms,
327
- loadSiteCatalog,
328
- loadSitePages,
329
- loadSiteDir,
330
- buildSite,
331
- fetchLiveContent
332
- };