pagelathe 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/LICENSE +202 -0
- package/dist/bin.js +10 -0
- package/dist/chunk-LRG7WLGI.js +1020 -0
- package/dist/index.js +9 -0
- package/dist/registry/app/astro.config.mjs +7 -0
- package/dist/registry/app/package.json +29 -0
- package/dist/registry/app/public/favicon.svg +4 -0
- package/dist/registry/app/scripts/vendor-sections.mjs +50 -0
- package/dist/registry/app/src/components/SectionRenderer.astro +23 -0
- package/dist/registry/app/src/components/sections/.gitkeep +0 -0
- package/dist/registry/app/src/content/_schema.ts +27 -0
- package/dist/registry/app/src/content/landing/index.yaml +135 -0
- package/dist/registry/app/src/content.config.ts +10 -0
- package/dist/registry/app/src/layouts/Base.astro +24 -0
- package/dist/registry/app/src/pages/index.astro +13 -0
- package/dist/registry/app/src/styles/global.css +20 -0
- package/dist/registry/app/tsconfig.json +1 -0
- package/dist/registry/sections/codeDemo/schema.ts +46 -0
- package/dist/registry/sections/codeDemo/section.astro +55 -0
- package/dist/registry/sections/features/schema.ts +44 -0
- package/dist/registry/sections/features/section.astro +25 -0
- package/dist/registry/sections/finalCta/schema.ts +30 -0
- package/dist/registry/sections/finalCta/section.astro +18 -0
- package/dist/registry/sections/footer/schema.ts +44 -0
- package/dist/registry/sections/footer/section.astro +29 -0
- package/dist/registry/sections/header/schema.ts +35 -0
- package/dist/registry/sections/header/section.astro +28 -0
- package/dist/registry/sections/hero/schema.ts +50 -0
- package/dist/registry/sections/hero/section.astro +56 -0
- package/dist/registry/sections/package.json +23 -0
- package/dist/registry/sections/pricing/schema.ts +55 -0
- package/dist/registry/sections/pricing/section.astro +29 -0
- package/dist/registry/sections/src/a11y.ts +34 -0
- package/dist/registry/sections/src/env.d.ts +7 -0
- package/dist/registry/sections/src/manifest.ts +41 -0
- package/dist/registry/sections/src/page.ts +48 -0
- package/dist/registry/sections/src/registry.ts +62 -0
- package/dist/registry/sections/src/render-harness.ts +11 -0
- package/dist/registry/sections/test/chrome.test.ts +29 -0
- package/dist/registry/sections/test/codeDemo.test.ts +16 -0
- package/dist/registry/sections/test/features.test.ts +18 -0
- package/dist/registry/sections/test/finalCta.test.ts +15 -0
- package/dist/registry/sections/test/hero.test.ts +30 -0
- package/dist/registry/sections/test/page.test.ts +58 -0
- package/dist/registry/sections/test/pricing.test.ts +22 -0
- package/dist/registry/sections/test/registry-complete.test.ts +38 -0
- package/dist/registry/sections/test/registry.test.ts +12 -0
- package/dist/registry/sections/test/validate.test.ts +29 -0
- package/dist/registry/sections/tsconfig.json +8 -0
- package/dist/registry/sections/vitest.config.ts +7 -0
- package/package.json +56 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { SectionManifest } from "../src/manifest.js";
|
|
3
|
+
|
|
4
|
+
export const navLinkSchema = z.object({ label: z.string().min(1), href: z.string().min(1) });
|
|
5
|
+
|
|
6
|
+
export const propsSchema = z.object({
|
|
7
|
+
brand: z.string().min(1),
|
|
8
|
+
links: z.array(navLinkSchema).max(6).default([]),
|
|
9
|
+
github: z.object({ href: z.string().min(1), stars: z.string().optional() }).optional(),
|
|
10
|
+
cta: z.object({ label: z.string().min(1), href: z.string().min(1) }).optional(),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export const entrySchema = z.object({
|
|
14
|
+
type: z.literal("header"),
|
|
15
|
+
id: z.string().min(1),
|
|
16
|
+
props: propsSchema,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export const manifest: SectionManifest = {
|
|
20
|
+
type: "header",
|
|
21
|
+
title: "Header / Navbar",
|
|
22
|
+
description: "Top navigation with brand, links, GitHub star badge, and a co-equal docs/CTA link.",
|
|
23
|
+
required: true,
|
|
24
|
+
archetypes: ["sdk-infra", "technical-app", "general"],
|
|
25
|
+
componentFile: "section.astro",
|
|
26
|
+
defaultProps: {
|
|
27
|
+
brand: "Branchy",
|
|
28
|
+
links: [
|
|
29
|
+
{ label: "Docs", href: "/docs" },
|
|
30
|
+
{ label: "Pricing", href: "#pricing" },
|
|
31
|
+
],
|
|
32
|
+
github: { href: "https://github.com", stars: "4.2k" },
|
|
33
|
+
cta: { label: "Start free", href: "#get-started" },
|
|
34
|
+
} satisfies z.input<typeof propsSchema>,
|
|
35
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { z } from "zod";
|
|
3
|
+
import type { propsSchema } from "./schema.js";
|
|
4
|
+
|
|
5
|
+
type Props = z.infer<typeof propsSchema>;
|
|
6
|
+
const { brand, links, github, cta } = Astro.props as Props;
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
<header class="sticky top-0 z-50 border-b border-white/10 bg-black/60 backdrop-blur" data-section="header">
|
|
10
|
+
<nav class="mx-auto flex max-w-6xl items-center justify-between px-6 py-4" aria-label="Primary">
|
|
11
|
+
<a href="/" class="text-sm font-bold text-white">{brand}</a>
|
|
12
|
+
<div class="flex items-center gap-6">
|
|
13
|
+
{links.map((l) => (
|
|
14
|
+
<a href={l.href} class="hidden text-sm text-white/70 hover:text-white sm:inline">{l.label}</a>
|
|
15
|
+
))}
|
|
16
|
+
{github && (
|
|
17
|
+
<a href={github.href} class="text-sm text-white/70 hover:text-white" aria-label="Star on GitHub">
|
|
18
|
+
★ {github.stars ?? "GitHub"}
|
|
19
|
+
</a>
|
|
20
|
+
)}
|
|
21
|
+
{cta && (
|
|
22
|
+
<a href={cta.href} class="rounded-[var(--radius)] bg-[var(--color-primary)] px-4 py-2 text-sm font-semibold text-white hover:opacity-90">
|
|
23
|
+
{cta.label}
|
|
24
|
+
</a>
|
|
25
|
+
)}
|
|
26
|
+
</div>
|
|
27
|
+
</nav>
|
|
28
|
+
</header>
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { SectionManifest } from "../src/manifest.js";
|
|
3
|
+
|
|
4
|
+
export const ctaSchema = z.object({
|
|
5
|
+
label: z.string().min(1),
|
|
6
|
+
href: z.string().min(1),
|
|
7
|
+
style: z.enum(["primary", "secondary", "ghost"]).default("primary"),
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
export const propsSchema = z.object({
|
|
11
|
+
variant: z.enum(["code-snippet", "product-ui"]).default("code-snippet"),
|
|
12
|
+
eyebrow: z.string().optional(),
|
|
13
|
+
headline: z.string().min(1),
|
|
14
|
+
subhead: z.string().min(1),
|
|
15
|
+
ctas: z.array(ctaSchema).min(1).max(3),
|
|
16
|
+
/** Shown when variant === "code-snippet". */
|
|
17
|
+
code: z.object({ lang: z.string().min(1), source: z.string().min(1) }).optional(),
|
|
18
|
+
/** Shown when variant === "product-ui". */
|
|
19
|
+
image: z.object({ src: z.string().min(1), alt: z.string().min(1) }).optional(),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export const entrySchema = z.object({
|
|
23
|
+
type: z.literal("hero"),
|
|
24
|
+
id: z.string().min(1),
|
|
25
|
+
props: propsSchema,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export const manifest: SectionManifest = {
|
|
29
|
+
type: "hero",
|
|
30
|
+
title: "Hero",
|
|
31
|
+
description: "Above-the-fold value statement with CTAs; adaptive code or product-UI variant.",
|
|
32
|
+
required: true,
|
|
33
|
+
archetypes: ["sdk-infra", "technical-app", "general"],
|
|
34
|
+
variants: ["code-snippet", "product-ui"],
|
|
35
|
+
componentFile: "section.astro",
|
|
36
|
+
defaultProps: {
|
|
37
|
+
variant: "code-snippet",
|
|
38
|
+
eyebrow: "Open source",
|
|
39
|
+
headline: "Postgres branching for teams",
|
|
40
|
+
subhead: "Spin up an isolated database branch per pull request in one command.",
|
|
41
|
+
ctas: [
|
|
42
|
+
{ label: "Start free", href: "#get-started", style: "primary" },
|
|
43
|
+
{ label: "View on GitHub", href: "https://github.com", style: "secondary" },
|
|
44
|
+
],
|
|
45
|
+
code: {
|
|
46
|
+
lang: "bash",
|
|
47
|
+
source: "npx branchy create --from main\n# → branch ready in 1.2s",
|
|
48
|
+
},
|
|
49
|
+
} satisfies z.input<typeof propsSchema>,
|
|
50
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { codeToHtml } from "shiki";
|
|
3
|
+
import type { z } from "zod";
|
|
4
|
+
import type { propsSchema } from "./schema.js";
|
|
5
|
+
|
|
6
|
+
type Props = z.infer<typeof propsSchema>;
|
|
7
|
+
const { variant, eyebrow, headline, subhead, ctas, code, image } = Astro.props as Props;
|
|
8
|
+
|
|
9
|
+
const ctaClass: Record<string, string> = {
|
|
10
|
+
primary: "bg-[var(--color-primary)] text-white hover:opacity-90",
|
|
11
|
+
secondary: "border border-white/15 text-white hover:bg-white/5",
|
|
12
|
+
ghost: "text-white/70 hover:text-white",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
let codeHtml = "";
|
|
16
|
+
if (variant === "code-snippet" && code) {
|
|
17
|
+
codeHtml = await codeToHtml(code.source, { lang: code.lang, theme: "github-dark" });
|
|
18
|
+
}
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
<section class="relative mx-auto max-w-6xl px-6 py-24" data-section="hero">
|
|
22
|
+
<div class="grid items-center gap-12 lg:grid-cols-2">
|
|
23
|
+
<div>
|
|
24
|
+
{eyebrow && <p class="mb-3 text-sm font-medium text-[var(--color-primary)]">{eyebrow}</p>}
|
|
25
|
+
<h1 class="text-4xl font-bold tracking-tight text-white sm:text-5xl">{headline}</h1>
|
|
26
|
+
<p class="mt-5 text-lg text-white/70">{subhead}</p>
|
|
27
|
+
<div class="mt-8 flex flex-wrap gap-3">
|
|
28
|
+
{ctas.map((c) => (
|
|
29
|
+
<a
|
|
30
|
+
href={c.href}
|
|
31
|
+
class={`inline-flex items-center rounded-[var(--radius)] px-5 py-2.5 text-sm font-semibold transition ${ctaClass[c.style]}`}
|
|
32
|
+
>
|
|
33
|
+
{c.label}
|
|
34
|
+
</a>
|
|
35
|
+
))}
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
<div class="min-w-0">
|
|
39
|
+
{variant === "code-snippet" && code && (
|
|
40
|
+
<div
|
|
41
|
+
class="overflow-x-auto rounded-[var(--radius)] border border-white/10 bg-black/40 p-4 text-sm [&_pre]:!bg-transparent"
|
|
42
|
+
set:html={codeHtml}
|
|
43
|
+
/>
|
|
44
|
+
)}
|
|
45
|
+
{variant === "product-ui" && image && (
|
|
46
|
+
<img
|
|
47
|
+
src={image.src}
|
|
48
|
+
alt={image.alt}
|
|
49
|
+
class="w-full rounded-[var(--radius)] border border-white/10"
|
|
50
|
+
loading="eager"
|
|
51
|
+
decoding="async"
|
|
52
|
+
/>
|
|
53
|
+
)}
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
</section>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pagelathe/sections",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/registry.ts",
|
|
8
|
+
"./manifest": "./src/manifest.ts",
|
|
9
|
+
"./page": "./src/page.ts"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"typecheck": "tsc --noEmit",
|
|
13
|
+
"test": "vitest run"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"zod": "^3.24.1"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"astro": "^6.4.4",
|
|
20
|
+
"shiki": "^1.24.0",
|
|
21
|
+
"vitest": "^3.2.6"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { SectionManifest } from "../src/manifest.js";
|
|
3
|
+
|
|
4
|
+
export const tierSchema = z.object({
|
|
5
|
+
name: z.string().min(1),
|
|
6
|
+
price: z.string().min(1),
|
|
7
|
+
period: z.string().optional(),
|
|
8
|
+
description: z.string().min(1),
|
|
9
|
+
features: z.array(z.string().min(1)).min(1),
|
|
10
|
+
cta: z.object({ label: z.string().min(1), href: z.string().min(1) }),
|
|
11
|
+
featured: z.boolean().default(false),
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
export const propsSchema = z.object({
|
|
15
|
+
heading: z.string().min(1),
|
|
16
|
+
subhead: z.string().optional(),
|
|
17
|
+
tiers: z.array(tierSchema).min(1).max(4),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export const entrySchema = z.object({
|
|
21
|
+
type: z.literal("pricing"),
|
|
22
|
+
id: z.string().min(1),
|
|
23
|
+
props: propsSchema,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
export const manifest: SectionManifest = {
|
|
27
|
+
type: "pricing",
|
|
28
|
+
title: "Pricing",
|
|
29
|
+
description: "Transparent pricing tiers with honest constraints (rate limits, free tier).",
|
|
30
|
+
required: true,
|
|
31
|
+
archetypes: ["sdk-infra", "technical-app", "general"],
|
|
32
|
+
componentFile: "section.astro",
|
|
33
|
+
defaultProps: {
|
|
34
|
+
heading: "Honest pricing",
|
|
35
|
+
subhead: "Start free. No credit card.",
|
|
36
|
+
tiers: [
|
|
37
|
+
{
|
|
38
|
+
name: "Open source",
|
|
39
|
+
price: "$0",
|
|
40
|
+
description: "Self-host, unlimited branches.",
|
|
41
|
+
features: ["Self-hosted", "Community support", "Apache-2.0"],
|
|
42
|
+
cta: { label: "Get started", href: "#get-started" },
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: "Team",
|
|
46
|
+
price: "$29",
|
|
47
|
+
period: "/mo",
|
|
48
|
+
description: "Managed cloud for small teams.",
|
|
49
|
+
features: ["10 projects", "Daily backups", "Email support"],
|
|
50
|
+
cta: { label: "Start trial", href: "#trial" },
|
|
51
|
+
featured: true,
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
} satisfies z.input<typeof propsSchema>,
|
|
55
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { z } from "zod";
|
|
3
|
+
import type { propsSchema } from "./schema.js";
|
|
4
|
+
|
|
5
|
+
type Props = z.infer<typeof propsSchema>;
|
|
6
|
+
const { heading, subhead, tiers } = Astro.props as Props;
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
<section id="pricing" class="mx-auto max-w-6xl px-6 py-24" data-section="pricing">
|
|
10
|
+
<div class="mx-auto max-w-2xl text-center">
|
|
11
|
+
<h2 class="text-3xl font-bold tracking-tight text-white">{heading}</h2>
|
|
12
|
+
{subhead && <p class="mt-3 text-white/60">{subhead}</p>}
|
|
13
|
+
</div>
|
|
14
|
+
<div class="mt-12 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
|
15
|
+
{tiers.map((t) => (
|
|
16
|
+
<div class={`flex flex-col rounded-[var(--radius)] border p-7 ${t.featured ? "border-[var(--color-primary)] bg-white/[0.03]" : "border-white/10"}`}>
|
|
17
|
+
<h3 class="text-sm font-semibold text-white/80">{t.name}</h3>
|
|
18
|
+
<p class="mt-3"><span class="text-3xl font-bold text-white">{t.price}</span>{t.period && <span class="text-white/50">{t.period}</span>}</p>
|
|
19
|
+
<p class="mt-2 text-sm text-white/60">{t.description}</p>
|
|
20
|
+
<ul class="mt-5 flex-1 space-y-2">
|
|
21
|
+
{t.features.map((f) => <li class="text-sm text-white/70">✓ {f}</li>)}
|
|
22
|
+
</ul>
|
|
23
|
+
<a href={t.cta.href} class={`mt-6 rounded-[var(--radius)] px-4 py-2.5 text-center text-sm font-semibold ${t.featured ? "bg-[var(--color-primary)] text-white hover:opacity-90" : "border border-white/15 text-white hover:bg-white/5"}`}>
|
|
24
|
+
{t.cta.label}
|
|
25
|
+
</a>
|
|
26
|
+
</div>
|
|
27
|
+
))}
|
|
28
|
+
</div>
|
|
29
|
+
</section>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export interface A11yExpectations {
|
|
2
|
+
/** Require at least one landmark element from this list (e.g. ["nav"]). */
|
|
3
|
+
landmarks?: string[];
|
|
4
|
+
/** Every <img> must have a non-empty alt attribute. */
|
|
5
|
+
imagesHaveAlt?: boolean;
|
|
6
|
+
/** Every <a> must have non-whitespace text or an aria-label. */
|
|
7
|
+
linksHaveText?: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Lightweight, dependency-free structural a11y assertions over rendered HTML.
|
|
11
|
+
* Returns an array of human-readable violation messages (empty == pass). */
|
|
12
|
+
export function checkA11y(html: string, expect: A11yExpectations): string[] {
|
|
13
|
+
const violations: string[] = [];
|
|
14
|
+
for (const tag of expect.landmarks ?? []) {
|
|
15
|
+
if (!new RegExp(`<${tag}[\\s>]`, "i").test(html)) {
|
|
16
|
+
violations.push(`missing <${tag}> landmark`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
if (expect.imagesHaveAlt) {
|
|
20
|
+
for (const img of html.match(/<img\b[^>]*>/gi) ?? []) {
|
|
21
|
+
if (!/\balt\s*=\s*["'][^"']+["']/i.test(img)) violations.push(`<img> without alt: ${img}`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if (expect.linksHaveText) {
|
|
25
|
+
for (const m of html.matchAll(/<a\b([^>]*)>([\s\S]*?)<\/a>/gi)) {
|
|
26
|
+
const attrs = m[1] ?? "";
|
|
27
|
+
const inner = (m[2] ?? "").replace(/<[^>]+>/g, "").trim();
|
|
28
|
+
if (inner.length === 0 && !/\baria-label\s*=/i.test(attrs)) {
|
|
29
|
+
violations.push(`<a> without text or aria-label`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return violations;
|
|
34
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ZodTypeAny } from "zod";
|
|
2
|
+
|
|
3
|
+
export type Archetype = "sdk-infra" | "technical-app" | "general";
|
|
4
|
+
|
|
5
|
+
export type PrimaryGoal = "signup" | "github_star" | "docs" | "contact" | "waitlist" | "purchase";
|
|
6
|
+
|
|
7
|
+
/** Canonical M2 section types. Extending the registry adds to this union. */
|
|
8
|
+
export type SectionType =
|
|
9
|
+
| "header"
|
|
10
|
+
| "hero"
|
|
11
|
+
| "features"
|
|
12
|
+
| "codeDemo"
|
|
13
|
+
| "pricing"
|
|
14
|
+
| "finalCta"
|
|
15
|
+
| "footer";
|
|
16
|
+
|
|
17
|
+
export interface SectionManifest {
|
|
18
|
+
/** Stable id; equals the section directory name. */
|
|
19
|
+
type: SectionType;
|
|
20
|
+
/** Human title for docs/registry listings. */
|
|
21
|
+
title: string;
|
|
22
|
+
/** One-line description of the section's purpose. */
|
|
23
|
+
description: string;
|
|
24
|
+
/** Required-by-default in a generated page. */
|
|
25
|
+
required: boolean;
|
|
26
|
+
/** Archetypes that include this section by default. */
|
|
27
|
+
archetypes: Archetype[];
|
|
28
|
+
/** Optional named variants (e.g. hero: code-snippet | product-ui). */
|
|
29
|
+
variants?: string[];
|
|
30
|
+
/** Component file name within the section directory. Always "section.astro". */
|
|
31
|
+
componentFile: string;
|
|
32
|
+
/** Default props — MUST validate against the section's propsSchema. Used by
|
|
33
|
+
* the init starter document and (M3) as a structured-output seed/example. */
|
|
34
|
+
defaultProps: unknown;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface RegistryEntry {
|
|
38
|
+
manifest: SectionManifest;
|
|
39
|
+
propsSchema: ZodTypeAny;
|
|
40
|
+
entrySchema: ZodTypeAny;
|
|
41
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { z, type ZodTypeAny } from "zod";
|
|
2
|
+
|
|
3
|
+
export const primaryGoalSchema = z.enum([
|
|
4
|
+
"signup",
|
|
5
|
+
"github_star",
|
|
6
|
+
"docs",
|
|
7
|
+
"contact",
|
|
8
|
+
"waitlist",
|
|
9
|
+
"purchase",
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
export const archetypeSchema = z.enum(["sdk-infra", "technical-app", "general"]);
|
|
13
|
+
|
|
14
|
+
export const metaSchema = z.object({
|
|
15
|
+
title: z.string().min(1),
|
|
16
|
+
description: z.string().min(1),
|
|
17
|
+
locales: z.array(z.string().min(2)).min(1).default(["en"]),
|
|
18
|
+
primaryGoal: primaryGoalSchema.default("signup"),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export const themeSchema = z.object({
|
|
22
|
+
tokens: z
|
|
23
|
+
.object({
|
|
24
|
+
colorPrimary: z.string().min(1).default("#3A6463"),
|
|
25
|
+
radius: z.string().min(1).default("0.5rem"),
|
|
26
|
+
font: z.string().min(1).default("Inter"),
|
|
27
|
+
})
|
|
28
|
+
.default({}),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Assemble the full page-document schema from the per-section entry schemas.
|
|
33
|
+
* `entrySchemas` is the list of each section's `{ type, id, props }` object
|
|
34
|
+
* schema; their literal `type` discriminators form the section union.
|
|
35
|
+
*/
|
|
36
|
+
export function buildPageDocumentSchema(entrySchemas: [ZodTypeAny, ...ZodTypeAny[]]) {
|
|
37
|
+
const sectionUnion =
|
|
38
|
+
entrySchemas.length === 1
|
|
39
|
+
? entrySchemas[0]
|
|
40
|
+
: // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
41
|
+
z.discriminatedUnion("type", entrySchemas as any);
|
|
42
|
+
return z.object({
|
|
43
|
+
meta: metaSchema,
|
|
44
|
+
theme: themeSchema.default({ tokens: {} }),
|
|
45
|
+
archetype: archetypeSchema.default("general"),
|
|
46
|
+
sections: z.array(sectionUnion).min(1),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { buildPageDocumentSchema } from "./page.js";
|
|
2
|
+
import type { RegistryEntry, SectionType } from "./manifest.js";
|
|
3
|
+
import * as hero from "../hero/schema.js";
|
|
4
|
+
import * as header from "../header/schema.js";
|
|
5
|
+
import * as footer from "../footer/schema.js";
|
|
6
|
+
import * as features from "../features/schema.js";
|
|
7
|
+
import * as codeDemo from "../codeDemo/schema.js";
|
|
8
|
+
import * as pricing from "../pricing/schema.js";
|
|
9
|
+
import * as finalCta from "../finalCta/schema.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Each section module (registry/sections/<type>/schema.ts) exports
|
|
13
|
+
* `propsSchema`, `manifest`, and `entrySchema`. Section tasks append their
|
|
14
|
+
* module to this list — this is the single enumeration point (shadcn-style).
|
|
15
|
+
*/
|
|
16
|
+
export const sectionModules: RegistryEntry[] = [
|
|
17
|
+
{ manifest: hero.manifest, propsSchema: hero.propsSchema, entrySchema: hero.entrySchema },
|
|
18
|
+
{ manifest: header.manifest, propsSchema: header.propsSchema, entrySchema: header.entrySchema },
|
|
19
|
+
{ manifest: footer.manifest, propsSchema: footer.propsSchema, entrySchema: footer.entrySchema },
|
|
20
|
+
{
|
|
21
|
+
manifest: features.manifest,
|
|
22
|
+
propsSchema: features.propsSchema,
|
|
23
|
+
entrySchema: features.entrySchema,
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
manifest: codeDemo.manifest,
|
|
27
|
+
propsSchema: codeDemo.propsSchema,
|
|
28
|
+
entrySchema: codeDemo.entrySchema,
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
manifest: pricing.manifest,
|
|
32
|
+
propsSchema: pricing.propsSchema,
|
|
33
|
+
entrySchema: pricing.entrySchema,
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
manifest: finalCta.manifest,
|
|
37
|
+
propsSchema: finalCta.propsSchema,
|
|
38
|
+
entrySchema: finalCta.entrySchema,
|
|
39
|
+
},
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
export const registry: Partial<Record<SectionType, RegistryEntry>> = Object.fromEntries(
|
|
43
|
+
sectionModules.map((m) => [m.manifest.type, m]),
|
|
44
|
+
) as Partial<Record<SectionType, RegistryEntry>>;
|
|
45
|
+
|
|
46
|
+
export function listSections(): RegistryEntry[] {
|
|
47
|
+
return [...sectionModules];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function getSection(type: string): RegistryEntry | undefined {
|
|
51
|
+
return registry[type as SectionType];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const entrySchemas = sectionModules.map((m) => m.entrySchema);
|
|
55
|
+
|
|
56
|
+
/** Full page-document schema, assembled from every registered section's entry
|
|
57
|
+
* schema (the discriminated union over section `type`). */
|
|
58
|
+
export const pageDocumentSchema = buildPageDocumentSchema(
|
|
59
|
+
entrySchemas as [(typeof entrySchemas)[number], ...typeof entrySchemas],
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
export type PageDocument = import("zod").infer<typeof pageDocumentSchema>;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { experimental_AstroContainer as AstroContainer } from "astro/container";
|
|
2
|
+
import type { AstroComponentFactory } from "astro/runtime/server/index.js";
|
|
3
|
+
|
|
4
|
+
/** Render a section component to an HTML string with the given props. */
|
|
5
|
+
export async function renderToHtml(
|
|
6
|
+
Component: AstroComponentFactory,
|
|
7
|
+
props: Record<string, unknown>,
|
|
8
|
+
): Promise<string> {
|
|
9
|
+
const container = await AstroContainer.create();
|
|
10
|
+
return container.renderToString(Component, { props });
|
|
11
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import * as header from "../header/schema.js";
|
|
3
|
+
import * as footer from "../footer/schema.js";
|
|
4
|
+
import Header from "../header/section.astro";
|
|
5
|
+
import Footer from "../footer/section.astro";
|
|
6
|
+
import { renderToHtml } from "../src/render-harness.js";
|
|
7
|
+
import { checkA11y } from "../src/a11y.js";
|
|
8
|
+
|
|
9
|
+
describe("header", () => {
|
|
10
|
+
it("validates defaultProps", () => {
|
|
11
|
+
expect(header.propsSchema.safeParse(header.manifest.defaultProps).success).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
it("renders a nav landmark and brand", async () => {
|
|
14
|
+
const html = await renderToHtml(Header, header.propsSchema.parse(header.manifest.defaultProps));
|
|
15
|
+
expect(html).toContain("Branchy");
|
|
16
|
+
expect(checkA11y(html, { landmarks: ["nav"], linksHaveText: true })).toEqual([]);
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe("footer", () => {
|
|
21
|
+
it("validates defaultProps", () => {
|
|
22
|
+
expect(footer.propsSchema.safeParse(footer.manifest.defaultProps).success).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
it("renders a footer landmark and copyright", async () => {
|
|
25
|
+
const html = await renderToHtml(Footer, footer.propsSchema.parse(footer.manifest.defaultProps));
|
|
26
|
+
expect(html).toContain("Apache-2.0");
|
|
27
|
+
expect(checkA11y(html, { landmarks: ["footer"], linksHaveText: true })).toEqual([]);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { propsSchema, manifest } from "../codeDemo/schema.js";
|
|
3
|
+
import CodeDemo from "../codeDemo/section.astro";
|
|
4
|
+
import { renderToHtml } from "../src/render-harness.js";
|
|
5
|
+
|
|
6
|
+
describe("codeDemo", () => {
|
|
7
|
+
it("validates defaultProps", () => {
|
|
8
|
+
expect(propsSchema.safeParse(manifest.defaultProps).success).toBe(true);
|
|
9
|
+
});
|
|
10
|
+
it("renders a tab per snippet and highlights code (shiki <pre>)", async () => {
|
|
11
|
+
const html = await renderToHtml(CodeDemo, propsSchema.parse(manifest.defaultProps));
|
|
12
|
+
expect(html).toContain('role="tablist"');
|
|
13
|
+
expect((html.match(/role="tab"/g) ?? []).length).toBe(3);
|
|
14
|
+
expect(html).toContain("<pre");
|
|
15
|
+
});
|
|
16
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { propsSchema, manifest } from "../features/schema.js";
|
|
3
|
+
import Features from "../features/section.astro";
|
|
4
|
+
import { renderToHtml } from "../src/render-harness.js";
|
|
5
|
+
|
|
6
|
+
describe("features", () => {
|
|
7
|
+
it("validates defaultProps and requires >=2 items", () => {
|
|
8
|
+
expect(propsSchema.safeParse(manifest.defaultProps).success).toBe(true);
|
|
9
|
+
expect(
|
|
10
|
+
propsSchema.safeParse({ heading: "H", items: [{ title: "x", body: "y" }] }).success,
|
|
11
|
+
).toBe(false);
|
|
12
|
+
});
|
|
13
|
+
it("renders all feature titles", async () => {
|
|
14
|
+
const html = await renderToHtml(Features, propsSchema.parse(manifest.defaultProps));
|
|
15
|
+
expect(html).toContain("Branch per PR");
|
|
16
|
+
expect(html).toContain("1-second reset");
|
|
17
|
+
});
|
|
18
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { propsSchema, manifest } from "../finalCta/schema.js";
|
|
3
|
+
import FinalCta from "../finalCta/section.astro";
|
|
4
|
+
import { renderToHtml } from "../src/render-harness.js";
|
|
5
|
+
|
|
6
|
+
describe("finalCta", () => {
|
|
7
|
+
it("validates defaultProps", () => {
|
|
8
|
+
expect(propsSchema.safeParse(manifest.defaultProps).success).toBe(true);
|
|
9
|
+
});
|
|
10
|
+
it("renders headline and CTA", async () => {
|
|
11
|
+
const html = await renderToHtml(FinalCta, propsSchema.parse(manifest.defaultProps));
|
|
12
|
+
expect(html).toContain("Ship your first branch today");
|
|
13
|
+
expect(html).toContain("Start free");
|
|
14
|
+
});
|
|
15
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { propsSchema, manifest, entrySchema } from "../hero/schema.js";
|
|
3
|
+
import Hero from "../hero/section.astro";
|
|
4
|
+
import { renderToHtml } from "../src/render-harness.js";
|
|
5
|
+
import { checkA11y } from "../src/a11y.js";
|
|
6
|
+
|
|
7
|
+
describe("hero schema", () => {
|
|
8
|
+
it("validates its own defaultProps", () => {
|
|
9
|
+
expect(propsSchema.safeParse(manifest.defaultProps).success).toBe(true);
|
|
10
|
+
});
|
|
11
|
+
it("requires at least one CTA", () => {
|
|
12
|
+
const bad = { ...(manifest.defaultProps as object), ctas: [] };
|
|
13
|
+
expect(propsSchema.safeParse(bad).success).toBe(false);
|
|
14
|
+
});
|
|
15
|
+
it("entrySchema pins the type literal", () => {
|
|
16
|
+
expect(
|
|
17
|
+
entrySchema.safeParse({ type: "header", id: "x", props: manifest.defaultProps }).success,
|
|
18
|
+
).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("hero render", () => {
|
|
23
|
+
it("renders headline + CTA and passes basic a11y", async () => {
|
|
24
|
+
const props = propsSchema.parse(manifest.defaultProps);
|
|
25
|
+
const html = await renderToHtml(Hero, props);
|
|
26
|
+
expect(html).toContain("Postgres branching for teams");
|
|
27
|
+
expect(html).toContain("Start free");
|
|
28
|
+
expect(checkA11y(html, { linksHaveText: true, imagesHaveAlt: true })).toEqual([]);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { metaSchema, themeSchema, archetypeSchema, buildPageDocumentSchema } from "../src/page.js";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
|
|
5
|
+
describe("metaSchema", () => {
|
|
6
|
+
it("defaults locales and primaryGoal", () => {
|
|
7
|
+
const m = metaSchema.parse({ title: "T", description: "D" });
|
|
8
|
+
expect(m.locales).toEqual(["en"]);
|
|
9
|
+
expect(m.primaryGoal).toBe("signup");
|
|
10
|
+
});
|
|
11
|
+
it("rejects empty title", () => {
|
|
12
|
+
expect(metaSchema.safeParse({ title: "", description: "D" }).success).toBe(false);
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("themeSchema", () => {
|
|
17
|
+
it("fills token defaults", () => {
|
|
18
|
+
const t = themeSchema.parse({ tokens: {} });
|
|
19
|
+
expect(t.tokens.radius).toBe("0.5rem");
|
|
20
|
+
expect(t.tokens.font).toBe("Inter");
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("buildPageDocumentSchema", () => {
|
|
25
|
+
const entry = z.object({
|
|
26
|
+
type: z.literal("hero"),
|
|
27
|
+
id: z.string(),
|
|
28
|
+
props: z.object({ headline: z.string() }),
|
|
29
|
+
});
|
|
30
|
+
const doc = buildPageDocumentSchema([entry]);
|
|
31
|
+
|
|
32
|
+
it("accepts a valid one-section document", () => {
|
|
33
|
+
const r = doc.safeParse({
|
|
34
|
+
meta: { title: "T", description: "D" },
|
|
35
|
+
sections: [{ type: "hero", id: "h1", props: { headline: "Hi" } }],
|
|
36
|
+
});
|
|
37
|
+
expect(r.success).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
it("rejects an empty sections array", () => {
|
|
40
|
+
const r = doc.safeParse({ meta: { title: "T", description: "D" }, sections: [] });
|
|
41
|
+
expect(r.success).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
it("rejects an unknown section type", () => {
|
|
44
|
+
const r = doc.safeParse({
|
|
45
|
+
meta: { title: "T", description: "D" },
|
|
46
|
+
sections: [{ type: "nope", id: "x", props: {} }],
|
|
47
|
+
});
|
|
48
|
+
expect(r.success).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("archetypeSchema", () => {
|
|
53
|
+
it("accepts the three archetypes", () => {
|
|
54
|
+
for (const a of ["sdk-infra", "technical-app", "general"]) {
|
|
55
|
+
expect(archetypeSchema.safeParse(a).success).toBe(true);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
});
|