@atelic-action/ui 0.17.2 → 1.0.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 (184) hide show
  1. package/README.md +428 -44
  2. package/bin/atelic-check-base-path.ts +5 -0
  3. package/bin/atelic-clean-sitemap.ts +5 -0
  4. package/bin/atelic-contact-canary.ts +5 -0
  5. package/bin/atelic-print-sheets.sh +1 -1
  6. package/bin/atelic-seal-document.mjs +1 -1
  7. package/bin/atelic-serve-static.ts +1 -1
  8. package/dist/gate/index.d.ts +1 -1
  9. package/dist/gate/index.js +1 -1
  10. package/dist/gate/middleware.d.ts +4 -1
  11. package/dist/gate/middleware.js +14 -5
  12. package/dist/gate/wall.d.ts +25 -0
  13. package/dist/gate/wall.js +41 -2
  14. package/dist/routing/basePath.d.ts +24 -0
  15. package/dist/routing/basePath.js +54 -0
  16. package/dist/vite/index.d.ts +3 -2
  17. package/dist/vite/index.js +3 -2
  18. package/dist/vite/marketing-config.d.ts +50 -0
  19. package/dist/vite/marketing-config.js +83 -0
  20. package/package.json +164 -5
  21. package/src/app/AppProvider.tsx +41 -0
  22. package/src/app/AuthGuard.tsx +27 -0
  23. package/src/app/CardErrorBoundary.tsx +39 -0
  24. package/src/app/ErrorFallback.tsx +39 -0
  25. package/src/app/GoogleGlyph.tsx +22 -0
  26. package/src/app/config.ts +54 -0
  27. package/src/app/configure.ts +18 -0
  28. package/src/app/context/AppContext.tsx +52 -0
  29. package/src/app/context/SidebarContext.tsx +124 -0
  30. package/src/app/context/UserContext.tsx +139 -0
  31. package/src/app/hooks/useDebounce.ts +12 -0
  32. package/src/app/hooks/useIsDesktop.ts +31 -0
  33. package/src/app/hooks/useWakeLock.ts +49 -0
  34. package/src/app/index.ts +64 -0
  35. package/src/app/layout/ActionButton.tsx +67 -0
  36. package/src/app/layout/AppLayout.tsx +110 -0
  37. package/src/app/layout/AppPage.tsx +268 -0
  38. package/src/app/layout/Breadcrumbs.tsx +57 -0
  39. package/src/app/layout/DesktopRail.tsx +168 -0
  40. package/src/app/layout/FormActionButtons.tsx +69 -0
  41. package/src/app/layout/MobileMenu.tsx +156 -0
  42. package/src/app/layout/MobileTabBar.tsx +43 -0
  43. package/src/app/layout/PageError.tsx +30 -0
  44. package/src/app/layout/PageSkeleton.tsx +65 -0
  45. package/src/app/layout/SectionCards.tsx +33 -0
  46. package/src/app/layout/UserMenu.tsx +104 -0
  47. package/src/app/layout/identity.ts +22 -0
  48. package/src/app/layout/index.ts +20 -0
  49. package/src/app/layout/nav.ts +52 -0
  50. package/src/app/lib/dates.ts +57 -0
  51. package/src/app/lib/env.ts +37 -0
  52. package/src/app/lib/format.ts +61 -0
  53. package/src/app/lib/http.ts +205 -0
  54. package/src/app/lib/lastAuth.ts +45 -0
  55. package/src/app/lib/oauth.ts +32 -0
  56. package/src/app/lib/publicRoutes.ts +33 -0
  57. package/src/app/lib/queryClient.ts +38 -0
  58. package/src/app/lib/search.ts +17 -0
  59. package/src/app/lib/session.ts +36 -0
  60. package/src/app/lib/tokenRefresh.ts +137 -0
  61. package/src/app/lib/transformKeys.ts +60 -0
  62. package/src/app/lib/units.ts +228 -0
  63. package/src/app/lib/unwrap.ts +48 -0
  64. package/src/app/lib/utils.ts +10 -0
  65. package/src/app/stores/authStore.ts +80 -0
  66. package/src/app/stores/storage.ts +40 -0
  67. package/src/app/stores/unitsStore.ts +54 -0
  68. package/src/app/types/index.ts +72 -0
  69. package/src/app/types/query.ts +11 -0
  70. package/src/app/ui/accordion.tsx +54 -0
  71. package/src/app/ui/alert-dialog.tsx +114 -0
  72. package/src/app/ui/alert.tsx +49 -0
  73. package/src/app/ui/avatar.tsx +45 -0
  74. package/src/app/ui/badge-list.tsx +51 -0
  75. package/src/app/ui/badge.tsx +33 -0
  76. package/src/app/ui/button.tsx +49 -0
  77. package/src/app/ui/callout.tsx +22 -0
  78. package/src/app/ui/card.tsx +55 -0
  79. package/src/app/ui/checkbox.tsx +26 -0
  80. package/src/app/ui/clear-button.tsx +42 -0
  81. package/src/app/ui/command.tsx +140 -0
  82. package/src/app/ui/country-dropdown.tsx +184 -0
  83. package/src/app/ui/dialog.tsx +104 -0
  84. package/src/app/ui/dropdown-menu.tsx +185 -0
  85. package/src/app/ui/form.tsx +172 -0
  86. package/src/app/ui/index.ts +36 -0
  87. package/src/app/ui/input.tsx +22 -0
  88. package/src/app/ui/label.tsx +19 -0
  89. package/src/app/ui/option-select.tsx +56 -0
  90. package/src/app/ui/pagination.tsx +90 -0
  91. package/src/app/ui/popover.tsx +29 -0
  92. package/src/app/ui/progress.tsx +25 -0
  93. package/src/app/ui/search-input.tsx +63 -0
  94. package/src/app/ui/select.tsx +151 -0
  95. package/src/app/ui/separator.tsx +24 -0
  96. package/src/app/ui/sheet.tsx +119 -0
  97. package/src/app/ui/slider.tsx +23 -0
  98. package/src/app/ui/switch.tsx +27 -0
  99. package/src/app/ui/textarea.tsx +21 -0
  100. package/src/app/ui/tooltip.tsx +32 -0
  101. package/src/artifact/ArtifactShell.tsx +2 -2
  102. package/src/artifact/Gate.tsx +2 -2
  103. package/src/artifact/Writeup.tsx +7 -7
  104. package/src/chrome/SiteShell.tsx +141 -0
  105. package/src/chrome/index.ts +2 -0
  106. package/src/components/Disclosure.tsx +80 -14
  107. package/src/components/GateLogin.tsx +1 -1
  108. package/src/components/Watermark.tsx +1 -1
  109. package/src/components/index.ts +6 -2
  110. package/src/gate/index.ts +1 -1
  111. package/src/gate/middleware.ts +17 -6
  112. package/src/gate/wall.ts +60 -2
  113. package/src/lib/basePathAudit.ts +96 -0
  114. package/src/lib/displayDate.ts +25 -0
  115. package/src/lib/honeypot.ts +11 -0
  116. package/src/lib/hours.ts +28 -0
  117. package/src/lib/sitemap.ts +34 -0
  118. package/src/lib/trackEvent.ts +26 -0
  119. package/src/lib/useOpenHeight.ts +28 -0
  120. package/src/primitives/Button.tsx +3 -1
  121. package/src/primitives/Chip.tsx +3 -1
  122. package/src/primitives/ImageFrame.tsx +48 -0
  123. package/src/primitives/ProseBlocks.tsx +67 -0
  124. package/src/primitives/QuoteCard.tsx +20 -0
  125. package/src/primitives/StarRow.tsx +37 -0
  126. package/src/primitives/TickList.tsx +24 -0
  127. package/src/primitives/index.ts +5 -0
  128. package/src/routing/BasePathProvider.tsx +47 -0
  129. package/src/routing/basePath.ts +57 -0
  130. package/src/routing/index.ts +8 -0
  131. package/src/scripts/check-base-path.ts +130 -0
  132. package/src/scripts/clean-sitemap.ts +27 -0
  133. package/src/scripts/contact-canary.ts +98 -0
  134. package/src/scripts/serve-static.ts +29 -3
  135. package/src/sections/BlogIndex.tsx +57 -0
  136. package/src/sections/BlogPost.tsx +65 -0
  137. package/src/sections/CTABanner.tsx +93 -0
  138. package/src/sections/CTASection.tsx +60 -0
  139. package/src/sections/ComparisonTable.tsx +114 -0
  140. package/src/sections/ContactForm.tsx +163 -0
  141. package/src/sections/ContactInfoCard.tsx +92 -0
  142. package/src/sections/CredentialList.tsx +28 -0
  143. package/src/sections/DeviceShowcase.tsx +39 -0
  144. package/src/sections/ExpandList.tsx +58 -0
  145. package/src/sections/FAQBrowser.tsx +283 -0
  146. package/src/sections/FeatureDetail.tsx +60 -0
  147. package/src/sections/FeatureGrid.tsx +83 -0
  148. package/src/sections/Gallery.tsx +84 -0
  149. package/src/sections/Hero.tsx +110 -0
  150. package/src/sections/HoursTable.tsx +23 -0
  151. package/src/sections/JumpRail.tsx +35 -0
  152. package/src/sections/LegalPage.tsx +43 -0
  153. package/src/sections/LocationSection.tsx +66 -0
  154. package/src/sections/Locations.tsx +71 -0
  155. package/src/sections/LogoMarquee.tsx +27 -0
  156. package/src/sections/MapEmbed.tsx +75 -0
  157. package/src/sections/MediaBand.tsx +20 -0
  158. package/src/sections/MenuList.tsx +44 -0
  159. package/src/sections/PageHero.tsx +25 -0
  160. package/src/sections/PressQuotes.tsx +51 -0
  161. package/src/sections/PricingBand.tsx +43 -0
  162. package/src/sections/PricingCard.tsx +36 -0
  163. package/src/sections/ProcessSteps.tsx +45 -0
  164. package/src/sections/ProfileCards.tsx +73 -0
  165. package/src/sections/RevealSlider.tsx +156 -0
  166. package/src/sections/ReviewCarousel.tsx +223 -0
  167. package/src/sections/ReviewWall.tsx +220 -0
  168. package/src/sections/SocialStrip.tsx +83 -0
  169. package/src/sections/SplitSection.tsx +56 -0
  170. package/src/sections/index.ts +76 -0
  171. package/src/sections/siteConfig.ts +201 -0
  172. package/src/styles/app.css +673 -0
  173. package/src/styles/artifact.css +472 -399
  174. package/src/styles/base.css +49 -47
  175. package/src/styles/chrome.css +71 -71
  176. package/src/styles/components.css +150 -140
  177. package/src/styles/gate.css +29 -29
  178. package/src/styles/primitives.css +190 -25
  179. package/src/styles/sections.css +2652 -0
  180. package/src/styles/star-rating.css +2 -2
  181. package/src/vite/index.ts +9 -2
  182. package/src/vite/marketing-config.ts +128 -0
  183. package/src/components/Collapsible.tsx +0 -43
  184. /package/src/scripts/{build-gate-payload.mjs → seal-document.mjs} +0 -0
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Fails the build when dist/client or vercel.json disagrees with the base
3
+ * path. The last step of a marketing site's `bun run build`; the rules and
4
+ * their reasons live in src/lib/basePathAudit.ts, and the setting itself is
5
+ * `basePath` in the site's src/site.config.ts.
6
+ *
7
+ * A `VITE_BASE_PATH` override that differs from the config is a rehearsal
8
+ * build (CI proves a site under "/proto" that way): its HTML is held to the
9
+ * override, but vercel.json still describes the committed config, so a
10
+ * rewrite mismatch only warns, except on Vercel, where it would ship.
11
+ *
12
+ * Usage: atelic-check-base-path (from the site's root)
13
+ * Exits 1 on any problem.
14
+ */
15
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
16
+ import { join, resolve } from "node:path";
17
+ import { pathToFileURL } from "node:url";
18
+ import {
19
+ cssRefs,
20
+ htmlRefs,
21
+ type Rewrite,
22
+ rewriteProblems,
23
+ unbasedRefs,
24
+ } from "../lib/basePathAudit";
25
+ import { normalizeBasePath } from "../routing/basePath";
26
+ import { SITE_CONFIG, siteConfigPath } from "./site-config";
27
+
28
+ /** Where the build writes the pages, from the site's root. */
29
+ export const BUILD_ROOT = "dist/client";
30
+
31
+ export interface BasePathInput {
32
+ /** The `basePath` the site's config commits to. */
33
+ configured: string | undefined;
34
+ /** `VITE_BASE_PATH`, when the build was a rehearsal under another base. */
35
+ override: string | undefined;
36
+ /** Every built HTML and CSS file, by its path, with its text. */
37
+ files: Array<{ path: string; text: string }>;
38
+ /** The site's vercel.json, parsed. */
39
+ vercel: { rewrites?: Rewrite[] };
40
+ /** Whether the build runs on Vercel, where a rehearsal mismatch would ship. */
41
+ onVercel: boolean;
42
+ }
43
+
44
+ export interface BasePathReport {
45
+ base: string;
46
+ rehearsal: boolean;
47
+ checked: number;
48
+ problems: string[];
49
+ warnings: string[];
50
+ }
51
+
52
+ /** The audit, kept pure so it can be tested without a build. */
53
+ export function auditBasePath(input: BasePathInput): BasePathReport {
54
+ const configured = normalizeBasePath(input.configured);
55
+ const base = normalizeBasePath(input.override ?? input.configured);
56
+ const rehearsal = input.override !== undefined && base !== configured;
57
+
58
+ const problems: string[] = [];
59
+ const warnings: string[] = [];
60
+ let checked = 0;
61
+ for (const file of input.files) {
62
+ const refs = file.path.endsWith(".html")
63
+ ? htmlRefs(file.text)
64
+ : file.path.endsWith(".css")
65
+ ? cssRefs(file.text)
66
+ : undefined;
67
+ if (!refs) continue;
68
+ checked++;
69
+ for (const ref of unbasedRefs(refs, base)) {
70
+ problems.push(`${file.path}: ${ref} lacks the base ${base}`);
71
+ }
72
+ }
73
+
74
+ const rewrites = rewriteProblems(input.vercel, base).map((problem) => `vercel.json: ${problem}`);
75
+ if (rehearsal && !input.onVercel) {
76
+ warnings.push(...rewrites.map((problem) => `rehearsal build, not deployable: ${problem}`));
77
+ } else {
78
+ problems.push(...rewrites);
79
+ }
80
+ return { base, rehearsal, checked, problems, warnings };
81
+ }
82
+
83
+ function builtFiles(dir: string, root: string): Array<{ path: string; text: string }> {
84
+ return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
85
+ const path = join(dir, entry.name);
86
+ if (entry.isDirectory()) return builtFiles(path, root);
87
+ if (!path.endsWith(".html") && !path.endsWith(".css")) return [];
88
+ return [
89
+ { path: join(BUILD_ROOT, path.slice(root.length + 1)), text: readFileSync(path, "utf8") },
90
+ ];
91
+ });
92
+ }
93
+
94
+ /** The command: audits the build of the site in `cwd`. */
95
+ export async function main(cwd: string = process.cwd()): Promise<void> {
96
+ const configPath = siteConfigPath(cwd);
97
+ if (!existsSync(configPath)) {
98
+ console.error(`[base-path] no ${SITE_CONFIG} in ${cwd}. Run this from the root of a site.`);
99
+ process.exit(1);
100
+ }
101
+ const { site } = (await import(pathToFileURL(configPath).href)) as {
102
+ site?: { basePath?: string };
103
+ };
104
+ const root = resolve(cwd, BUILD_ROOT);
105
+ if (!existsSync(root)) {
106
+ console.error(`[base-path] no ${BUILD_ROOT} in ${cwd}. Build the site first.`);
107
+ process.exit(1);
108
+ }
109
+ const vercelPath = resolve(cwd, "vercel.json");
110
+ const report = auditBasePath({
111
+ configured: site?.basePath,
112
+ override: process.env.VITE_BASE_PATH,
113
+ files: builtFiles(root, root),
114
+ vercel: existsSync(vercelPath) ? JSON.parse(readFileSync(vercelPath, "utf8")) : {},
115
+ onVercel: Boolean(process.env.VERCEL),
116
+ });
117
+
118
+ for (const warning of report.warnings) console.warn(`[base-path] ${warning}`);
119
+ if (report.problems.length > 0) {
120
+ for (const problem of report.problems) console.error(`[base-path] ${problem}`);
121
+ const count = report.problems.length;
122
+ console.error(
123
+ `[base-path] ${count} problem${count === 1 ? "" : "s"}; see the @atelic-action/ui README, Sections, Serving Under a Base Path`,
124
+ );
125
+ process.exit(1);
126
+ }
127
+ console.log(
128
+ `[base-path] ${report.base || "root"}: ${report.checked} HTML and CSS files checked${report.rehearsal ? " (rehearsal)" : ""}`,
129
+ );
130
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Strips `#fragment` and trailing slash duplicates out of the built
3
+ * dist/client/sitemap.xml. A step of a marketing site's `bun run build`, after
4
+ * vite writes the sitemap; the logic and its reasoning live in
5
+ * src/lib/sitemap.ts. A site with a base path ships no sitemap
6
+ * (`marketingConfig` turns it off), so there is nothing to clean.
7
+ *
8
+ * Usage: atelic-clean-sitemap (from the site's root)
9
+ */
10
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
11
+ import { resolve } from "node:path";
12
+ import { dedupeSitemap } from "../lib/sitemap";
13
+
14
+ /** Where the build writes the sitemap, from the site's root. */
15
+ export const SITEMAP = "dist/client/sitemap.xml";
16
+
17
+ /** The command: cleans the sitemap of the build in `cwd`, if it built one. */
18
+ export function main(cwd: string = process.cwd()): void {
19
+ const path = resolve(cwd, SITEMAP);
20
+ if (!existsSync(path)) {
21
+ console.log(`[sitemap] ${SITEMAP} not built (base path set), nothing to clean`);
22
+ return;
23
+ }
24
+ const { xml, removed } = dedupeSitemap(readFileSync(path, "utf8"));
25
+ writeFileSync(path, xml);
26
+ console.log(`[sitemap] ${SITEMAP} (${removed} duplicate URL${removed === 1 ? "" : "s"} removed)`);
27
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * The contact relay canary: submits a token bearing test message to the live
3
+ * site's /api/contact and asserts the whole pipe worked. A pass means the
4
+ * function is deployed, its env is present, the Resend key is valid, the
5
+ * sending domain is verified, Resend accepted the message (200 plus an id),
6
+ * and the client destination the function is configured with is the one
7
+ * expected. The canary lands in CONTACT_CANARY_TO, never the client's inbox.
8
+ *
9
+ * Env: SITE_URL, CONTACT_EXPECTED_TO, CONTACT_CANARY_TOKEN.
10
+ * Usage: atelic-contact-canary (a site's daily contact-canary workflow runs it)
11
+ * Exits 1 on any failure, naming the failed assertion.
12
+ *
13
+ * A SITE_URL on the reserved `.invalid` TLD (`https://canary.invalid`) is the
14
+ * explicit off switch, for a repo with no live site (a template): the run
15
+ * passes without submitting anything. An unset SITE_URL still fails, so a
16
+ * site that never configured the canary is caught, not silently skipped.
17
+ */
18
+
19
+ /** The environment the canary reads: SITE_URL, CONTACT_EXPECTED_TO, and CONTACT_CANARY_TOKEN. */
20
+ export type CanaryEnv = Record<string, string | undefined>;
21
+
22
+ export interface CanaryResult {
23
+ verdict: "pass" | "skip" | "fail";
24
+ message: string;
25
+ }
26
+
27
+ const fail = (reason: string): CanaryResult => ({ verdict: "fail", message: `FAIL: ${reason}` });
28
+
29
+ /**
30
+ * Runs the canary against the site the environment names. `fetcher` is the
31
+ * fetch to send with, so a test can stand in for the network.
32
+ */
33
+ export async function runCanary(
34
+ env: CanaryEnv,
35
+ fetcher: typeof fetch = fetch,
36
+ now: Date = new Date(),
37
+ ): Promise<CanaryResult> {
38
+ const site = env.SITE_URL?.replace(/\/$/, "");
39
+ if (!site) return fail("SITE_URL is not set");
40
+ let host: string;
41
+ try {
42
+ host = new URL(site).hostname;
43
+ } catch {
44
+ return fail(`SITE_URL is not a URL: ${site}`);
45
+ }
46
+ if (host.endsWith(".invalid")) {
47
+ return { verdict: "skip", message: `skipped: SITE_URL is ${site}, the explicit off switch` };
48
+ }
49
+ const expectedTo = env.CONTACT_EXPECTED_TO;
50
+ const token = env.CONTACT_CANARY_TOKEN;
51
+ if (!expectedTo) return fail("CONTACT_EXPECTED_TO is not set");
52
+ if (!token) return fail("CONTACT_CANARY_TOKEN is not set");
53
+
54
+ const endpoint = `${site}/api/contact`;
55
+ let response: Response;
56
+ try {
57
+ response = await fetcher(endpoint, {
58
+ method: "POST",
59
+ headers: { "Content-Type": "application/json" },
60
+ signal: AbortSignal.timeout(20000),
61
+ body: JSON.stringify({
62
+ name: "Contact canary",
63
+ email: "canary@atelic.me",
64
+ topic: "Canary",
65
+ message: `Automated contact relay check at ${now.toISOString()}. If you are reading this in the client inbox, CONTACT_CANARY_TO is misconfigured.`,
66
+ company: "",
67
+ canary: token,
68
+ }),
69
+ });
70
+ } catch (error) {
71
+ return fail(`request failed: ${error instanceof Error ? error.message : String(error)}`);
72
+ }
73
+
74
+ const body = (await response.json().catch(() => ({}))) as {
75
+ ok?: boolean;
76
+ id?: string | null;
77
+ to?: string;
78
+ error?: string;
79
+ };
80
+ if (response.status !== 200 || body.ok !== true) {
81
+ return fail(`relay returned ${response.status}${body.error ? `: ${body.error}` : ""}`);
82
+ }
83
+ if (!body.id) return fail("Resend accepted the message without an id (canary path not taken?)");
84
+ if (body.to !== expectedTo) {
85
+ return fail(`configured destination is ${body.to ?? "(none)"}, expected ${expectedTo}`);
86
+ }
87
+ return { verdict: "pass", message: `ok: Resend id ${body.id}, destination ${body.to}` };
88
+ }
89
+
90
+ /** The command: runs the canary the environment configures. */
91
+ export async function main(): Promise<void> {
92
+ const result = await runCanary(process.env);
93
+ if (result.verdict === "fail") {
94
+ console.error(`[contact-canary] ${result.message}`);
95
+ process.exit(1);
96
+ }
97
+ console.log(`[contact-canary] ${result.message}`);
98
+ }
@@ -5,10 +5,35 @@
5
5
  * never runs a server. The build it serves is the site's, found from the
6
6
  * working directory.
7
7
  *
8
+ * A marketing build served under a base path (its config's `basePath`, or the
9
+ * `VITE_BASE_PATH` rehearsal override) is served both ways it is served for
10
+ * real: under the prefix, as the cut's own Vercel host does through its two
11
+ * rewrites, and without it, as the host owner's proxy forwards each request
12
+ * with the prefix stripped (moved from template-marketing in 0.19.0).
13
+ *
8
14
  * Usage: atelic-serve-static (PORT sets the port, 4173 unless given)
9
15
  */
10
16
  import { existsSync, statSync } from "node:fs";
11
17
  import { join, normalize } from "node:path";
18
+ import { pathToFileURL } from "node:url";
19
+ import { normalizeBasePath, stripBase } from "../routing/basePath";
20
+ import { siteConfigPath } from "./site-config";
21
+
22
+ /**
23
+ * The base path the build in `cwd` was made under: `VITE_BASE_PATH` when set,
24
+ * else the `basePath` its src/site.config.ts exports, else the root. A site
25
+ * with no config, or one whose config carries no base, is served at the root.
26
+ */
27
+ export async function siteBasePath(
28
+ cwd: string,
29
+ env: Record<string, string | undefined> = process.env,
30
+ ): Promise<string> {
31
+ if (env.VITE_BASE_PATH !== undefined) return normalizeBasePath(env.VITE_BASE_PATH);
32
+ const path = siteConfigPath(cwd);
33
+ if (!existsSync(path)) return "";
34
+ const module = (await import(pathToFileURL(path).href)) as { site?: { basePath?: string } };
35
+ return normalizeBasePath(module.site?.basePath);
36
+ }
12
37
 
13
38
  /** The file a request path names under `root`, by Vercel's clean URL rules; none on a miss. */
14
39
  export function resolveFile(root: string, pathname: string): string | undefined {
@@ -24,9 +49,10 @@ export function resolveFile(root: string, pathname: string): string | undefined
24
49
  }
25
50
 
26
51
  /** The command: serves dist/client of the working directory until stopped. */
27
- export function main(cwd: string = process.cwd()): void {
52
+ export async function main(cwd: string = process.cwd()): Promise<void> {
28
53
  const root = join(cwd, "dist", "client");
29
54
  const port = Number(process.env.PORT ?? 4173);
55
+ const base = await siteBasePath(cwd);
30
56
 
31
57
  Bun.serve({
32
58
  // Loopback only: this serves the build to the local browser under test and
@@ -34,7 +60,7 @@ export function main(cwd: string = process.cwd()): void {
34
60
  hostname: "127.0.0.1",
35
61
  port,
36
62
  fetch(request) {
37
- const file = resolveFile(root, new URL(request.url).pathname);
63
+ const file = resolveFile(root, stripBase(base, new URL(request.url).pathname));
38
64
  if (file) return new Response(Bun.file(file));
39
65
  return new Response(Bun.file(join(root, "404.html")), {
40
66
  status: 404,
@@ -43,5 +69,5 @@ export function main(cwd: string = process.cwd()): void {
43
69
  },
44
70
  });
45
71
 
46
- console.log(`serving ${root} on http://127.0.0.1:${port}`);
72
+ console.log(`serving ${root} on http://127.0.0.1:${port}${base ? ` (and under ${base})` : ""}`);
47
73
  }
@@ -0,0 +1,57 @@
1
+ import { ArrowUpRight } from "lucide-react";
2
+ import { displayDate } from "../lib/displayDate";
3
+ import { useWithBase } from "../routing/BasePathProvider";
4
+
5
+ export interface BlogIndexItem {
6
+ slug: string;
7
+ title: string;
8
+ description?: string;
9
+ publishedAt: string;
10
+ /** Reading time in minutes, when the caller has computed one. */
11
+ readingMinutes?: number;
12
+ }
13
+
14
+ export interface BlogIndexProps {
15
+ items: BlogIndexItem[];
16
+ /** Base path the slugs hang from. Defaults to "/blog". */
17
+ basePath?: string;
18
+ id?: string;
19
+ className?: string;
20
+ }
21
+
22
+ /**
23
+ * The blog listing: every post, newest first, as a plain readable stack
24
+ * rather than a card grid. These are articles, not products, and a list
25
+ * that reads top to bottom suits a small archive better than a gallery
26
+ * that pads it out.
27
+ *
28
+ * Every entry is a real anchor to a prerendered page, which is what lets
29
+ * the build's link crawler discover the posts without an explicit page
30
+ * list in the vite config.
31
+ */
32
+ export function BlogIndex({ items, basePath = "/blog", id, className }: BlogIndexProps) {
33
+ const withBase = useWithBase();
34
+ return (
35
+ <section className={["section", "blog-index", className].filter(Boolean).join(" ")} id={id}>
36
+ <div className="wrap">
37
+ <ol className="bi-list">
38
+ {items.map((item) => (
39
+ <li className="bi-item" key={item.slug}>
40
+ <a className="bi-link" href={withBase(`${basePath}/${item.slug}`)}>
41
+ <span className="bi-meta">
42
+ <time dateTime={item.publishedAt}>{displayDate(item.publishedAt)}</time>
43
+ {item.readingMinutes ? <span> · {item.readingMinutes} min read</span> : null}
44
+ </span>
45
+ <h2 className="bi-title">
46
+ {item.title}
47
+ <ArrowUpRight className="bi-arrow" size={20} strokeWidth={2} aria-hidden="true" />
48
+ </h2>
49
+ {item.description && <p className="bi-excerpt">{item.description}</p>}
50
+ </a>
51
+ </li>
52
+ ))}
53
+ </ol>
54
+ </div>
55
+ </section>
56
+ );
57
+ }
@@ -0,0 +1,65 @@
1
+ import { ArrowLeft } from "lucide-react";
2
+ import { displayDate } from "../lib/displayDate";
3
+ import { type ProseBlock, ProseBlocks } from "../primitives/ProseBlocks";
4
+ import { useWithBase } from "../routing/BasePathProvider";
5
+
6
+ export interface BlogPostDoc {
7
+ title: string;
8
+ description?: string;
9
+ publishedAt: string;
10
+ updatedAt?: string | null;
11
+ author?: string;
12
+ blocks: ProseBlock[];
13
+ }
14
+
15
+ export interface BlogPostProps {
16
+ doc: BlogPostDoc;
17
+ /** Where the "all posts" link goes. Defaults to "/blog". */
18
+ basePath?: string;
19
+ backLabel?: string;
20
+ id?: string;
21
+ className?: string;
22
+ }
23
+
24
+ /**
25
+ * A single post: byline, dateline, body, and a way back to the archive.
26
+ *
27
+ * The article's own `h1` lives here rather than in a PageHero, because a post
28
+ * title is the content rather than a page banner, and a hero would push 800
29
+ * words of the thing someone came to read below the fold.
30
+ *
31
+ * `dateModified` renders only when the source carries one. A "last updated"
32
+ * line invented at port time would be a freshness claim about writing nobody
33
+ * has touched.
34
+ */
35
+ export function BlogPost({
36
+ doc,
37
+ basePath = "/blog",
38
+ backLabel = "All posts",
39
+ id,
40
+ className,
41
+ }: BlogPostProps) {
42
+ const withBase = useWithBase();
43
+ return (
44
+ <article className={["section", "blog-post", className].filter(Boolean).join(" ")} id={id}>
45
+ <div className="wrap bp-wrap">
46
+ <a className="bp-back" href={withBase(basePath)}>
47
+ <ArrowLeft size={15} strokeWidth={2} aria-hidden="true" />
48
+ {backLabel}
49
+ </a>
50
+
51
+ <h1 className="bp-title">{doc.title}</h1>
52
+
53
+ <p className="bp-meta">
54
+ {doc.author && <span className="bp-author">{doc.author}</span>}
55
+ <time dateTime={doc.publishedAt}>{displayDate(doc.publishedAt)}</time>
56
+ {doc.updatedAt && doc.updatedAt !== doc.publishedAt && (
57
+ <span className="bp-updated">Updated {displayDate(doc.updatedAt)}</span>
58
+ )}
59
+ </p>
60
+
61
+ <ProseBlocks blocks={doc.blocks} className="bp-body" />
62
+ </div>
63
+ </article>
64
+ );
65
+ }
@@ -0,0 +1,93 @@
1
+ import type { ReactNode } from "react";
2
+ import { Eyebrow, Lead } from "../primitives";
3
+ import { Button } from "../primitives/Button";
4
+ import { useWithBase } from "../routing/BasePathProvider";
5
+
6
+ export interface CTABannerCTA {
7
+ label: string;
8
+ href: string;
9
+ newTab?: boolean;
10
+ }
11
+
12
+ export interface CTABannerProps {
13
+ id?: string;
14
+ eyebrow?: string;
15
+ /** Headline; use <em className="serif"> spans for the script accent. */
16
+ title: ReactNode;
17
+ lead?: ReactNode;
18
+ /**
19
+ * Full-bleed background image behind the content. Omit it and the banner
20
+ * renders the token gradient fallback (the Hero treatment), so the section
21
+ * works before photography exists.
22
+ */
23
+ image?: { src: string; alt?: string };
24
+ primaryCta?: CTABannerCTA;
25
+ secondaryCta?: CTABannerCTA;
26
+ /**
27
+ * Wash over the image so the copy pops: "dark" (light text over a dark
28
+ * gradient, the hero treatment) or "light" (ink text over a surface wash,
29
+ * for bright imagery). The wash fades toward the far side so the image
30
+ * still reads.
31
+ */
32
+ tone?: "dark" | "light";
33
+ }
34
+
35
+ /**
36
+ * Mid-page full-bleed CTA banner: the image spans the viewport as a backdrop
37
+ * with eyebrow, title, lead, and actions overlaid. The section-scale sibling
38
+ * of Hero — same treatment, banner height, usable anywhere in the page flow.
39
+ */
40
+ export function CTABanner({
41
+ id,
42
+ eyebrow,
43
+ title,
44
+ lead,
45
+ image,
46
+ primaryCta,
47
+ secondaryCta,
48
+ tone = "dark",
49
+ }: CTABannerProps) {
50
+ const withBase = useWithBase();
51
+ const onDark = tone === "dark";
52
+ return (
53
+ <section className={["cta-banner", tone].join(" ")} id={id}>
54
+ {image ? (
55
+ <img
56
+ className="cta-banner-media"
57
+ src={withBase(image.src)}
58
+ alt={image.alt ?? ""}
59
+ loading="lazy"
60
+ />
61
+ ) : (
62
+ <div className="cta-banner-media cta-banner-fallback" aria-hidden="true" />
63
+ )}
64
+ <div className="cta-banner-wash" aria-hidden="true" />
65
+ <div className="wrap">
66
+ <div className="cta-banner-content">
67
+ {eyebrow && <Eyebrow onDark={onDark}>{eyebrow}</Eyebrow>}
68
+ <h2>{title}</h2>
69
+ {lead && <Lead>{lead}</Lead>}
70
+ {(primaryCta || secondaryCta) && (
71
+ <div className="cta-banner-actions">
72
+ {primaryCta && (
73
+ <Button href={primaryCta.href} newTab={primaryCta.newTab} arrow>
74
+ {primaryCta.label}
75
+ </Button>
76
+ )}
77
+ {secondaryCta && (
78
+ <Button
79
+ variant="ghost"
80
+ onDark={onDark}
81
+ href={secondaryCta.href}
82
+ newTab={secondaryCta.newTab}
83
+ >
84
+ {secondaryCta.label}
85
+ </Button>
86
+ )}
87
+ </div>
88
+ )}
89
+ </div>
90
+ </div>
91
+ </section>
92
+ );
93
+ }
@@ -0,0 +1,60 @@
1
+ import type { ReactNode } from "react";
2
+ import { Eyebrow, Lead } from "../primitives";
3
+ import { Button } from "../primitives/Button";
4
+ import type { SiteConfig } from "./siteConfig";
5
+
6
+ export interface CTASectionProps {
7
+ /** The site's primary action, `site.cta`; a whole `SiteConfig` passes as it is. */
8
+ site: Pick<SiteConfig, "cta">;
9
+ eyebrow?: string;
10
+ title: ReactNode;
11
+ lead?: ReactNode;
12
+ /**
13
+ * Overrides the primary action; by default the site's primary CTA
14
+ * (site.cta) renders so every page closes with the same conversion
15
+ * action in the same style.
16
+ */
17
+ primary?: { label: string; href: string; newTab?: boolean };
18
+ /** Page-contextual secondary action; always a ghost button, always second. */
19
+ secondary?: { label: string; href: string; newTab?: boolean };
20
+ surface?: "default" | "alt";
21
+ }
22
+
23
+ /** The standard closing CTA: primary action from the site config, ghost secondary. */
24
+ export function CTASection({
25
+ site,
26
+ eyebrow,
27
+ title,
28
+ lead,
29
+ primary,
30
+ secondary,
31
+ surface = "alt",
32
+ }: CTASectionProps) {
33
+ const classes = ["section", "final-cta", surface === "alt" && "surface-alt"]
34
+ .filter(Boolean)
35
+ .join(" ");
36
+ const primaryAction = primary ?? {
37
+ label: site.cta.label,
38
+ href: site.cta.href,
39
+ newTab: site.cta.external,
40
+ };
41
+ return (
42
+ <section className={classes}>
43
+ <div className="wrap center">
44
+ {eyebrow && <Eyebrow center>{eyebrow}</Eyebrow>}
45
+ <h2>{title}</h2>
46
+ {lead && <Lead className="cta-lead">{lead}</Lead>}
47
+ <div className="cta-row center">
48
+ <Button href={primaryAction.href} newTab={primaryAction.newTab} size="lg" arrow>
49
+ {primaryAction.label}
50
+ </Button>
51
+ {secondary && (
52
+ <Button href={secondary.href} newTab={secondary.newTab} variant="ghost" size="lg">
53
+ {secondary.label}
54
+ </Button>
55
+ )}
56
+ </div>
57
+ </div>
58
+ </section>
59
+ );
60
+ }