create-ailk 0.27.0 → 0.28.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +84 -7
- package/dist/add-module-core.js +2 -1
- package/dist/add-module.d.ts +2 -1
- package/dist/add-module.js +9 -4
- package/dist/add-route.js +46 -19
- package/dist/body-heading.d.ts +56 -0
- package/dist/body-heading.js +394 -0
- package/dist/cli.js +28 -1
- package/dist/content-payloads.js +2 -2
- package/dist/derive-architecture.js +8 -2
- package/dist/index.d.ts +2 -1
- package/dist/index.js +4 -1
- package/dist/lib/auth-secret-file.d.ts +6 -2
- package/dist/lib/auth-secret-file.js +46 -20
- package/dist/lib/chrome-readers.d.ts +21 -5
- package/dist/lib/chrome-readers.js +128 -20
- package/dist/lib/declared-env.d.ts +6 -0
- package/dist/lib/declared-env.js +2 -0
- package/dist/lib/env-catalog.js +20 -0
- package/dist/lib/import-graph.d.ts +16 -0
- package/dist/lib/import-graph.js +53 -0
- package/dist/lib/site-modules.d.ts +8 -0
- package/dist/lib/site-modules.js +34 -0
- package/dist/module-architecture.js +23 -14
- package/dist/paid-sections.d.ts +17 -0
- package/dist/paid-sections.js +39 -0
- package/dist/programmatic.d.ts +16 -0
- package/dist/programmatic.js +73 -4
- package/dist/remove-module.d.ts +98 -0
- package/dist/remove-module.js +620 -0
- package/dist/seed-flows.d.ts +2 -2
- package/dist/seed-flows.js +16 -9
- package/dist/site-roster.js +4 -1
- package/dist/starter-grid.d.ts +26 -0
- package/dist/starter-grid.js +34 -0
- package/dist/surfaces.d.ts +26 -0
- package/dist/surfaces.js +105 -9
- package/dist/sync-routes.d.ts +7 -0
- package/dist/sync-routes.js +53 -6
- package/dist/template-pages.d.ts +36 -0
- package/dist/template-pages.js +129 -0
- package/dist/template-record.d.ts +13 -4
- package/dist/template-record.js +8 -4
- package/dist/topology.d.ts +2 -1
- package/dist/topology.js +4 -2
- package/dist/update.d.ts +34 -2
- package/dist/update.js +395 -48
- package/package.json +3 -3
- package/release-scope.json +2 -1
- package/site-templates/aliases/hub.TEMPLATE-MANIFEST.json +115 -0
- package/templates/.env.example +5 -2
- package/templates/apps/api/.env.example +9 -5
- package/templates/apps/api/CLAUDE.md +14 -9
- package/templates/apps/api/src/__tests__/vercel-handler.test.ts +37 -0
- package/templates/apps/api/src/vercel-handler.ts +8 -1
- package/templates/apps/mcp/CLAUDE.md +18 -10
- package/templates/apps/web/.env.example +25 -11
- package/templates/apps/web/CLAUDE.md +45 -4
- package/templates/apps/web/__tests__/setup.ts +7 -0
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +7 -7
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +61 -2
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +81 -0
- package/templates/apps/web/app/[locale]/(authed)/account/connect/__tests__/page.test.tsx +197 -0
- package/templates/apps/web/app/[locale]/(authed)/account/connect/page.tsx +152 -0
- package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +76 -21
- package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +46 -16
- package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +20 -3
- package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +180 -2
- package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +122 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/case-studies/[slug]/page.tsx +20 -3
- package/templates/apps/web/app/[locale]/case-studies/__tests__/hub-children.test.tsx +65 -0
- package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/case-studies/page.tsx +23 -11
- package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +11 -1
- package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/consent.actions.ts +44 -6
- package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +4 -4
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +269 -42
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +88 -30
- package/templates/apps/web/app/[locale]/courses/[slug]/page.tsx +20 -3
- package/templates/apps/web/app/[locale]/courses/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/courses/page.tsx +22 -6
- package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +15 -2
- package/templates/apps/web/app/[locale]/events/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +8 -2
- package/templates/apps/web/app/[locale]/events/page.tsx +23 -7
- package/templates/apps/web/app/[locale]/features/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/features/page.tsx +23 -25
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +95 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/save.actions.test.ts +193 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/email-moment.ts +92 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +10 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +513 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +122 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.no-commerce.test.tsx +195 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/landing.test.tsx +293 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +397 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/checkout-seam.ts +74 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +264 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/save.actions.ts +75 -0
- package/templates/apps/web/app/[locale]/for/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +5 -1
- package/templates/apps/web/app/[locale]/for/page.tsx +23 -25
- package/templates/apps/web/app/[locale]/how-to/[slug]/page.tsx +20 -3
- package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/integrations/[slug]/page.tsx +25 -4
- package/templates/apps/web/app/[locale]/integrations/__tests__/hub-children.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +8 -2
- package/templates/apps/web/app/[locale]/integrations/page.tsx +23 -11
- package/templates/apps/web/app/[locale]/layout.tsx +157 -139
- package/templates/apps/web/app/[locale]/page.tsx +51 -3
- package/templates/apps/web/app/[locale]/partners/[slug]/page.tsx +16 -3
- package/templates/apps/web/app/[locale]/partners/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/partners/page.tsx +22 -6
- package/templates/apps/web/app/[locale]/portfolio/__tests__/hub-children.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/portfolio/page.tsx +25 -24
- package/templates/apps/web/app/[locale]/pricing/[slug]/__tests__/page.test.tsx +321 -0
- package/templates/apps/web/app/[locale]/pricing/[slug]/page.tsx +87 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/pricing-pages.test.tsx +215 -0
- package/templates/apps/web/app/[locale]/pricing/page.tsx +56 -341
- package/templates/apps/web/app/[locale]/products/[slug]/page.tsx +21 -4
- package/templates/apps/web/app/[locale]/products/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +8 -2
- package/templates/apps/web/app/[locale]/products/page.tsx +22 -6
- package/templates/apps/web/app/[locale]/projects/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +4 -0
- package/templates/apps/web/app/[locale]/projects/page.tsx +18 -11
- package/templates/apps/web/app/[locale]/resources/[slug]/page.tsx +25 -4
- package/templates/apps/web/app/[locale]/resources/__tests__/hub-children.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/resources/page.tsx +20 -10
- package/templates/apps/web/app/[locale]/services/[slug]/[child]/page.tsx +68 -0
- package/templates/apps/web/app/[locale]/services/[slug]/page.tsx +64 -9
- package/templates/apps/web/app/[locale]/services/__tests__/hub-children.test.tsx +158 -0
- package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +12 -2
- package/templates/apps/web/app/[locale]/services/__tests__/service-detail-sections.test.tsx +224 -0
- package/templates/apps/web/app/[locale]/services/__tests__/service-families.test.tsx +368 -0
- package/templates/apps/web/app/[locale]/services/page.tsx +45 -19
- package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/team/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/team/page.tsx +25 -24
- package/templates/apps/web/app/[locale]/tech-articles/[slug]/page.tsx +24 -3
- package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/templates/[slug]/__tests__/page.test.tsx +203 -0
- package/templates/apps/web/app/[locale]/templates/[slug]/page.tsx +81 -0
- package/templates/apps/web/app/[locale]/templates/__tests__/page.test.tsx +237 -0
- package/templates/apps/web/app/[locale]/templates/page.tsx +108 -0
- package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/whitepapers/[slug]/page.tsx +24 -3
- package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +204 -2
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +151 -33
- package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/__tests__/route.test.ts +233 -7
- package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +92 -41
- package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +77 -39
- package/templates/apps/web/app/llms.txt/route.ts +41 -3
- package/templates/apps/web/app/robots.txt/route.ts +5 -1
- package/templates/apps/web/app/sitemap.xml/route.ts +45 -3
- package/templates/apps/web/auth.ts +102 -50
- package/templates/apps/web/components/signed-in-nav.tsx +8 -1
- package/templates/apps/web/lib/__tests__/bot-check.test.ts +254 -0
- package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +212 -13
- package/templates/apps/web/lib/__tests__/deck-page.test.ts +13 -1
- package/templates/apps/web/lib/__tests__/docs-index.test.ts +93 -0
- package/templates/apps/web/lib/__tests__/docs-path.test.ts +168 -0
- package/templates/apps/web/lib/__tests__/hub-children.fixtures.ts +279 -0
- package/templates/apps/web/lib/__tests__/nav-label-keys.test.ts +138 -0
- package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +178 -2
- package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +183 -0
- package/templates/apps/web/lib/__tests__/page-chrome.test.ts +412 -0
- package/templates/apps/web/lib/__tests__/pricing-page.test.ts +142 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +202 -10
- package/templates/apps/web/lib/__tests__/route-family.test.ts +57 -0
- package/templates/apps/web/lib/__tests__/service-listing.test.ts +111 -0
- package/templates/apps/web/lib/__tests__/services-path.test.ts +96 -0
- package/templates/apps/web/lib/__tests__/site-header.test.ts +151 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +148 -6
- package/templates/apps/web/lib/__tests__/telemetry-scrub.test.ts +84 -0
- package/templates/apps/web/lib/__tests__/template-listing.test.ts +158 -0
- package/templates/apps/web/lib/auth-secret.ts +57 -0
- package/templates/apps/web/lib/bot-check.ts +138 -0
- package/templates/apps/web/lib/consent-visitor.ts +81 -7
- package/templates/apps/web/lib/deck-page.ts +6 -3
- package/templates/apps/web/lib/docs-index.ts +100 -0
- package/templates/apps/web/lib/docs-path.ts +94 -0
- package/templates/apps/web/lib/hub-children.tsx +318 -0
- package/templates/apps/web/lib/modules.generated.ts +9 -0
- package/templates/apps/web/lib/nav-label-check.ts +61 -0
- package/templates/apps/web/lib/nav-label-keys.ts +78 -0
- package/templates/apps/web/lib/newsletter.actions.ts +31 -6
- package/templates/apps/web/lib/offerings-pricing.ts +135 -6
- package/templates/apps/web/lib/page-chrome.ts +367 -0
- package/templates/apps/web/lib/posting-title.ts +26 -4
- package/templates/apps/web/lib/pricing-page.ts +430 -0
- package/templates/apps/web/lib/relay.ts +31 -106
- package/templates/apps/web/lib/route-family.ts +69 -0
- package/templates/apps/web/lib/service-listing.ts +89 -0
- package/templates/apps/web/lib/services-path.ts +97 -0
- package/templates/apps/web/lib/site-header.ts +94 -0
- package/templates/apps/web/lib/telemetry-scrub.ts +68 -3
- package/templates/apps/web/lib/template-listing.ts +169 -0
- package/templates/apps/web/messages/en.json +86 -30
- package/templates/apps/web/middleware.ts +171 -11
- package/templates/apps/web/next.config.mjs +211 -2
- package/templates/apps/web/package.json +1 -1
- package/templates/apps/web/scripts/assert-content-bundled.ts +28 -6
- package/templates/apps/web/scripts/check-nav-labels.ts +39 -0
- package/templates/apps/web/sentry.server.config.ts +30 -2
- package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -9
- package/templates/apps/web/src/lib/rate-limit.ts +68 -27
- package/templates/apps/web/src/lib/routes.ts +15 -1
- package/templates/tools/declared-env.ts +7 -0
- package/versions.json +48 -48
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The site's header settings (#10046) — `readSiteHeader` over a fixture
|
|
3
|
+
* `_site.mdx` set through `AILK_CONTENT_ROOT` (AC-2), and the pure
|
|
4
|
+
* `signedOutHeaderActions` composition over the generated auth pair (AC-3).
|
|
5
|
+
*/
|
|
6
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
|
|
10
|
+
import type * as SiteHeaderModule from "../site-header";
|
|
11
|
+
|
|
12
|
+
let root: string;
|
|
13
|
+
const previousRoot = process.env.AILK_CONTENT_ROOT;
|
|
14
|
+
let mod: typeof SiteHeaderModule;
|
|
15
|
+
let warn: jest.SpyInstance;
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
root = mkdtempSync(join(tmpdir(), "site-header-"));
|
|
19
|
+
process.env.AILK_CONTENT_ROOT = root;
|
|
20
|
+
// A fresh module per test so the warn-once flag starts clear.
|
|
21
|
+
jest.isolateModules(() => {
|
|
22
|
+
mod = require("../site-header") as typeof SiteHeaderModule;
|
|
23
|
+
});
|
|
24
|
+
warn = jest.spyOn(console, "warn").mockImplementation(() => undefined);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
warn.mockRestore();
|
|
29
|
+
rmSync(root, { recursive: true, force: true });
|
|
30
|
+
if (previousRoot === undefined) delete process.env.AILK_CONTENT_ROOT;
|
|
31
|
+
else process.env.AILK_CONTENT_ROOT = previousRoot;
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
function writeSite(frontmatter: string): void {
|
|
35
|
+
writeFileSync(join(root, "_site.mdx"), `---\n${frontmatter}\n---\n`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe("readSiteHeader (#10046 AC-2)", () => {
|
|
39
|
+
it("returns the parsed header for a valid one", () => {
|
|
40
|
+
writeSite(
|
|
41
|
+
[
|
|
42
|
+
"siteName: Acme",
|
|
43
|
+
"header:",
|
|
44
|
+
" cta:",
|
|
45
|
+
" label: Start here",
|
|
46
|
+
" href: /diagnostic",
|
|
47
|
+
" signIn: hidden",
|
|
48
|
+
].join("\n"),
|
|
49
|
+
);
|
|
50
|
+
expect(mod.readSiteHeader()).toEqual({
|
|
51
|
+
cta: { label: "Start here", href: "/diagnostic" },
|
|
52
|
+
signIn: "hidden",
|
|
53
|
+
});
|
|
54
|
+
expect(warn).not.toHaveBeenCalled();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("returns undefined silently when _site.mdx is absent", () => {
|
|
58
|
+
expect(mod.readSiteHeader()).toBeUndefined();
|
|
59
|
+
expect(warn).not.toHaveBeenCalled();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("returns undefined silently when _site.mdx has no header", () => {
|
|
63
|
+
writeSite("siteName: Acme");
|
|
64
|
+
expect(mod.readSiteHeader()).toBeUndefined();
|
|
65
|
+
expect(warn).not.toHaveBeenCalled();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("returns undefined and warns once, naming the field and never the value", () => {
|
|
69
|
+
writeSite(
|
|
70
|
+
["siteName: Acme", "header:", " cta:", " label: Go", " href: javascript:x"].join("\n"),
|
|
71
|
+
);
|
|
72
|
+
expect(mod.readSiteHeader()).toBeUndefined();
|
|
73
|
+
expect(mod.readSiteHeader()).toBeUndefined();
|
|
74
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
75
|
+
const message = String(warn.mock.calls[0]?.[0]);
|
|
76
|
+
expect(message).toContain("cta");
|
|
77
|
+
expect(message).not.toContain("javascript");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("a bad header.style never drops the cta, and is not this reader's warning (#10049 AC-4)", () => {
|
|
81
|
+
writeSite(
|
|
82
|
+
[
|
|
83
|
+
"siteName: Acme",
|
|
84
|
+
"header:",
|
|
85
|
+
" style: minimal",
|
|
86
|
+
" cta:",
|
|
87
|
+
" label: Start here",
|
|
88
|
+
" href: /diagnostic",
|
|
89
|
+
].join("\n"),
|
|
90
|
+
);
|
|
91
|
+
expect(mod.readSiteHeader()).toEqual({
|
|
92
|
+
cta: { label: "Start here", href: "/diagnostic" },
|
|
93
|
+
});
|
|
94
|
+
expect(warn).not.toHaveBeenCalled();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("signedOutHeaderActions (#10046 AC-3)", () => {
|
|
99
|
+
const cta = { label: "Start here", href: "/diagnostic" };
|
|
100
|
+
const hubPair = [
|
|
101
|
+
{ labelKey: "signIn", path: "/sign-in", variant: "outline-strong" as const },
|
|
102
|
+
{ labelKey: "signUp", path: "/sign-up", variant: "primary" as const },
|
|
103
|
+
];
|
|
104
|
+
const ctaAction = { labelKey: "headerCta", path: "/diagnostic", variant: "primary" };
|
|
105
|
+
|
|
106
|
+
function frozenCopy<T>(value: T): T {
|
|
107
|
+
return JSON.parse(JSON.stringify(value)) as T;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
it("with no header, or signIn without a cta, returns the pair unchanged", () => {
|
|
111
|
+
const before = frozenCopy(hubPair);
|
|
112
|
+
expect(mod.signedOutHeaderActions(undefined, hubPair)).toEqual(hubPair);
|
|
113
|
+
expect(mod.signedOutHeaderActions({ signIn: "hidden" }, hubPair)).toEqual(hubPair);
|
|
114
|
+
expect(hubPair).toEqual(before);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("with a cta, returns a ghost Sign in then the cta, and drops Sign up", () => {
|
|
118
|
+
const before = frozenCopy(hubPair);
|
|
119
|
+
expect(mod.signedOutHeaderActions({ cta }, hubPair)).toEqual([
|
|
120
|
+
{ labelKey: "signIn", path: "/sign-in", variant: "ghost" },
|
|
121
|
+
ctaAction,
|
|
122
|
+
]);
|
|
123
|
+
expect(hubPair).toEqual(before);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("with a cta and signIn hidden, returns only the cta", () => {
|
|
127
|
+
const before = frozenCopy(hubPair);
|
|
128
|
+
expect(mod.signedOutHeaderActions({ cta, signIn: "hidden" }, hubPair)).toEqual([ctaAction]);
|
|
129
|
+
expect(hubPair).toEqual(before);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("on a hub spoke, restyles the /account Sign in link", () => {
|
|
133
|
+
const spokePair = [{ labelKey: "signIn", path: "/account", variant: "outline-strong" as const }];
|
|
134
|
+
const before = frozenCopy(spokePair);
|
|
135
|
+
expect(mod.signedOutHeaderActions({ cta }, spokePair)).toEqual([
|
|
136
|
+
{ labelKey: "signIn", path: "/account", variant: "ghost" },
|
|
137
|
+
ctaAction,
|
|
138
|
+
]);
|
|
139
|
+
expect(spokePair).toEqual(before);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("on a spoke without sign-in, returns only the cta", () => {
|
|
143
|
+
const none: typeof hubPair = [];
|
|
144
|
+
expect(mod.signedOutHeaderActions({ cta }, none)).toEqual([ctaAction]);
|
|
145
|
+
expect(none).toEqual([]);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("exports the label key the cta action carries", () => {
|
|
149
|
+
expect(mod.HEADER_CTA_LABEL_KEY).toBe("headerCta");
|
|
150
|
+
});
|
|
151
|
+
});
|
|
@@ -30,7 +30,17 @@
|
|
|
30
30
|
* reads the same files with the same frontmatter schema the manifest is
|
|
31
31
|
* compiled from. See docs/internal/decisions/scaffold-structured-data-check.md.
|
|
32
32
|
*/
|
|
33
|
-
import {
|
|
33
|
+
import {
|
|
34
|
+
copyFileSync,
|
|
35
|
+
existsSync,
|
|
36
|
+
mkdirSync,
|
|
37
|
+
mkdtempSync,
|
|
38
|
+
readdirSync,
|
|
39
|
+
readFileSync,
|
|
40
|
+
rmSync,
|
|
41
|
+
writeFileSync,
|
|
42
|
+
} from "node:fs";
|
|
43
|
+
import { tmpdir } from "node:os";
|
|
34
44
|
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
35
45
|
|
|
36
46
|
import { render } from "@testing-library/react";
|
|
@@ -61,6 +71,7 @@ import { parse as parseYaml } from "yaml";
|
|
|
61
71
|
|
|
62
72
|
// Registers the site's `_site.mdx` identity at module load, as the `[locale]`
|
|
63
73
|
// layout does in production (#6597).
|
|
74
|
+
import { MARKETING_PAGES } from "../modules.generated";
|
|
64
75
|
import { readSiteIdentity } from "../site-identity";
|
|
65
76
|
|
|
66
77
|
// Detail routes call notFound() on a missing page; a thrown error is how the
|
|
@@ -537,21 +548,51 @@ function routeIdentity(
|
|
|
537
548
|
return null;
|
|
538
549
|
}
|
|
539
550
|
|
|
551
|
+
/**
|
|
552
|
+
* The hub's own slug a `<dir>/[slug]` route must not render (#10044 D13):
|
|
553
|
+
* when `<dir>/page.tsx` renders a fixed `renderPage({ type, slug: "<literal>" })`
|
|
554
|
+
* of the SAME type as the dynamic route, that page is the hub's own, served at
|
|
555
|
+
* `<dir>` — `/pricing`'s `pricing.mdx`, never `/pricing/pricing`. Keyed on
|
|
556
|
+
* the parent directory, not the type alone: `launch-service` renders a
|
|
557
|
+
* `Service` that `/services/[slug]` must still render.
|
|
558
|
+
*/
|
|
559
|
+
function hubOwnSlug(file: string, type: string): string | null {
|
|
560
|
+
if (basename(dirname(file)) !== "[slug]") return null;
|
|
561
|
+
const parent = join(dirname(dirname(file)), "page.tsx");
|
|
562
|
+
if (!existsSync(parent)) return null;
|
|
563
|
+
const identity = routeIdentity(readFileSync(parent, "utf8"));
|
|
564
|
+
return identity !== null && identity.type === type ? identity.slug : null;
|
|
565
|
+
}
|
|
566
|
+
|
|
540
567
|
type PageModule = {
|
|
541
568
|
default: (props: {
|
|
542
|
-
params: Promise<{
|
|
569
|
+
params: Promise<{
|
|
570
|
+
locale: string;
|
|
571
|
+
slug?: string;
|
|
572
|
+
detail?: string;
|
|
573
|
+
child?: string;
|
|
574
|
+
}>;
|
|
543
575
|
}) => Promise<ReactElement>;
|
|
544
576
|
};
|
|
545
577
|
|
|
578
|
+
/** #10043 D7 — a Service under its category, `/services/<parent>/<slug>`. */
|
|
579
|
+
const NESTED_SERVICE_ROUTE = "services/[slug]/[child]";
|
|
580
|
+
|
|
546
581
|
async function renderRoute(
|
|
547
582
|
file: string,
|
|
548
583
|
route: string,
|
|
549
584
|
slug: string | undefined,
|
|
585
|
+
parent?: string,
|
|
550
586
|
): Promise<HTMLElement> {
|
|
551
587
|
// Resolved at test time from the walk above — a static import would dangle
|
|
552
588
|
// in a scaffold that gated the surface off.
|
|
553
589
|
const mod = require(file) as PageModule;
|
|
554
|
-
const params: {
|
|
590
|
+
const params: {
|
|
591
|
+
locale: string;
|
|
592
|
+
slug?: string;
|
|
593
|
+
detail?: string;
|
|
594
|
+
child?: string;
|
|
595
|
+
} = {
|
|
555
596
|
locale: DEFAULT_LOCALE,
|
|
556
597
|
};
|
|
557
598
|
// #6897 D2 — params are built from the route's OWN dynamic segments: the
|
|
@@ -561,6 +602,10 @@ async function renderRoute(
|
|
|
561
602
|
if (route === "[slug]/[detail]") {
|
|
562
603
|
params.slug = MEET_PLACEHOLDER_HUB;
|
|
563
604
|
if (slug !== undefined) params.detail = slug;
|
|
605
|
+
} else if (route === NESTED_SERVICE_ROUTE) {
|
|
606
|
+
// #10043 D7 — the category rides `slug`, the service `child`.
|
|
607
|
+
if (parent !== undefined) params.slug = parent;
|
|
608
|
+
if (slug !== undefined) params.child = slug;
|
|
564
609
|
} else if (slug !== undefined) {
|
|
565
610
|
params.slug = slug;
|
|
566
611
|
}
|
|
@@ -578,6 +623,14 @@ describe("structured data — every route emits a valid JSON-LD script", () => {
|
|
|
578
623
|
it.each(routes)("$route", async ({ route, file }) => {
|
|
579
624
|
const label = `/${DEFAULT_LOCALE}${route === "" ? "" : "/" + route}`;
|
|
580
625
|
const src = readFileSync(file, "utf8");
|
|
626
|
+
// #10040 decision 1 — without the marketing module `/` redirects to
|
|
627
|
+
// /account before any content: no HomePage seed, no JSON-LD, and its
|
|
628
|
+
// page.tsx still names renderPage, so it is skipped here rather than
|
|
629
|
+
// listed in NO_STRUCTURED_DATA_ROUTES (which refuses a renderPage route).
|
|
630
|
+
if (route === "" && !MARKETING_PAGES) {
|
|
631
|
+
console.info(`structured-data: ${label} not checked — no marketing module, / redirects to /account (#10040)`);
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
581
634
|
const exempt = exemption(route, src);
|
|
582
635
|
const identity = routeIdentity(src);
|
|
583
636
|
if (exempt !== undefined) {
|
|
@@ -608,6 +661,8 @@ describe("structured data — every route emits a valid JSON-LD script", () => {
|
|
|
608
661
|
// site, not a route with nothing to check. A `[slug]` route with no
|
|
609
662
|
// pages of its type is simply empty — say so, do not fail.
|
|
610
663
|
let slugs: string[];
|
|
664
|
+
// #10043 D7 — a Service's category, for the nested route's params.
|
|
665
|
+
const parents = new Map<string, string>();
|
|
611
666
|
if (identity.slug !== null) {
|
|
612
667
|
const page = await adapter.getPageSource(
|
|
613
668
|
type,
|
|
@@ -621,9 +676,22 @@ describe("structured data — every route emits a valid JSON-LD script", () => {
|
|
|
621
676
|
}
|
|
622
677
|
slugs = [identity.slug];
|
|
623
678
|
} else {
|
|
624
|
-
|
|
625
|
-
|
|
679
|
+
// The hub's own page shares this route's type but is served at the hub
|
|
680
|
+
// (#10044 D13), so it is never rendered here.
|
|
681
|
+
const hubSlug = hubOwnSlug(file, type);
|
|
682
|
+
const pages = (await adapter.listDetail(type, DEFAULT_LOCALE)).filter(
|
|
683
|
+
(p: ContentPage) => p.slug !== hubSlug,
|
|
626
684
|
);
|
|
685
|
+
for (const p of pages) if (p.parent !== undefined) parents.set(p.slug, p.parent);
|
|
686
|
+
// #10043 D7 — a Service with a `parent` is served at the nested route
|
|
687
|
+
// only (its flat URL redirects there); every other one at `[slug]`.
|
|
688
|
+
const served =
|
|
689
|
+
route === NESTED_SERVICE_ROUTE
|
|
690
|
+
? pages.filter((p: ContentPage) => p.parent !== undefined)
|
|
691
|
+
: route === "services/[slug]"
|
|
692
|
+
? pages.filter((p: ContentPage) => p.parent === undefined)
|
|
693
|
+
: pages;
|
|
694
|
+
slugs = served.map((p: ContentPage) => p.slug);
|
|
627
695
|
if (slugs.length === 0) {
|
|
628
696
|
console.info(
|
|
629
697
|
`structured-data: ${label} has no ${type} content in ${DEFAULT_LOCALE}; every slug serves 404 in production — nothing to check`,
|
|
@@ -638,13 +706,16 @@ describe("structured data — every route emits a valid JSON-LD script", () => {
|
|
|
638
706
|
file,
|
|
639
707
|
route,
|
|
640
708
|
identity.slug === null ? slug : undefined,
|
|
709
|
+
parents.get(slug),
|
|
641
710
|
);
|
|
642
711
|
const blocks = extractJsonLdBlocks(container);
|
|
643
712
|
const pageLabel =
|
|
644
713
|
identity.slug === null
|
|
645
714
|
? route === "[slug]/[detail]"
|
|
646
715
|
? `/${DEFAULT_LOCALE}/${MEET_PLACEHOLDER_HUB}/${slug}`
|
|
647
|
-
:
|
|
716
|
+
: route === NESTED_SERVICE_ROUTE
|
|
717
|
+
? `/${DEFAULT_LOCALE}/services/${parents.get(slug)}/${slug}`
|
|
718
|
+
: `${label.replace(/\[slug\]$/, "")}${slug}`
|
|
648
719
|
: label;
|
|
649
720
|
if (blocks.length === 0) {
|
|
650
721
|
failures.push(
|
|
@@ -661,6 +732,77 @@ describe("structured data — every route emits a valid JSON-LD script", () => {
|
|
|
661
732
|
});
|
|
662
733
|
});
|
|
663
734
|
|
|
735
|
+
// ── 1b. A hub whose own page shares its detail type (#10044 D13) ───────────
|
|
736
|
+
|
|
737
|
+
describe("structured data — a hub's own page is not rendered at its detail route (#10044 AC-6)", () => {
|
|
738
|
+
const dynamicRoutes = walkRoutes(ROUTE_ROOT).flatMap(({ route, file }) => {
|
|
739
|
+
const identity = routeIdentity(readFileSync(file, "utf8"));
|
|
740
|
+
return identity !== null && identity.slug === null
|
|
741
|
+
? [{ route, file, type: identity.type }]
|
|
742
|
+
: [];
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
it("drops exactly Pricing/pricing, and only when the pricing route family is present", () => {
|
|
746
|
+
const dropped = dynamicRoutes.flatMap(({ file, type }) => {
|
|
747
|
+
const slug = hubOwnSlug(file, type);
|
|
748
|
+
return slug === null ? [] : [`${type}/${slug}`];
|
|
749
|
+
});
|
|
750
|
+
const hasPricingFamily = dynamicRoutes.some(
|
|
751
|
+
({ route }) => route === "pricing/[slug]",
|
|
752
|
+
);
|
|
753
|
+
expect(dropped).toEqual(hasPricingFamily ? ["Pricing/pricing"] : []);
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
// Only where the route family ships (a scaffold may gate /pricing off).
|
|
757
|
+
const pricingDetail = dynamicRoutes.find(
|
|
758
|
+
({ route }) => route === "pricing/[slug]",
|
|
759
|
+
);
|
|
760
|
+
(pricingDetail === undefined ? it.skip : it)(
|
|
761
|
+
"renders and validates an offering pricing page, never the hub's own file",
|
|
762
|
+
async () => {
|
|
763
|
+
const root = mkdtempSync(join(tmpdir(), "ailk-10044-sd-"));
|
|
764
|
+
const prior = process.env.AILK_CONTENT_ROOT;
|
|
765
|
+
try {
|
|
766
|
+
mkdirSync(join(root, DEFAULT_LOCALE, "Pricing"), { recursive: true });
|
|
767
|
+
copyFileSync(join(CONTENT_ROOT, "_site.mdx"), join(root, "_site.mdx"));
|
|
768
|
+
writeFileSync(
|
|
769
|
+
join(root, DEFAULT_LOCALE, "Pricing", "pricing.mdx"),
|
|
770
|
+
'---\ntype: "Pricing"\nslug: "pricing"\ntitle: "Pricing"\ndescription: "Plans."\n---\n',
|
|
771
|
+
);
|
|
772
|
+
writeFileSync(
|
|
773
|
+
join(root, DEFAULT_LOCALE, "Pricing", "website-rebuild.mdx"),
|
|
774
|
+
'---\ntype: "Pricing"\nslug: "website-rebuild"\ntitle: "Website Rebuild"\ndescription: "What a rebuild costs."\nplans: ["ss-website-rebuild"]\n---\n',
|
|
775
|
+
);
|
|
776
|
+
process.env.AILK_CONTENT_ROOT = root;
|
|
777
|
+
const fixtureAdapter = createMdxAdapter({
|
|
778
|
+
contentRoot: root,
|
|
779
|
+
defaultLocale: DEFAULT_LOCALE,
|
|
780
|
+
});
|
|
781
|
+
const hubSlug = hubOwnSlug(pricingDetail!.file, "Pricing");
|
|
782
|
+
const slugs = (await fixtureAdapter.listDetail("Pricing", DEFAULT_LOCALE))
|
|
783
|
+
.map((p: ContentPage) => p.slug)
|
|
784
|
+
.filter((slug: string) => slug !== hubSlug);
|
|
785
|
+
expect(slugs).toEqual(["website-rebuild"]);
|
|
786
|
+
|
|
787
|
+
const label = `/${DEFAULT_LOCALE}/pricing/website-rebuild`;
|
|
788
|
+
const container = await renderRoute(
|
|
789
|
+
pricingDetail!.file,
|
|
790
|
+
"pricing/[slug]",
|
|
791
|
+
"website-rebuild",
|
|
792
|
+
);
|
|
793
|
+
const blocks = extractJsonLdBlocks(container);
|
|
794
|
+
expect(blocks.length).toBeGreaterThan(0);
|
|
795
|
+
expect(validateFor(blocks, "Pricing", label)).toBeNull();
|
|
796
|
+
expect(identityFailure(blocks, label)).toBeNull();
|
|
797
|
+
} finally {
|
|
798
|
+
if (prior === undefined) delete process.env.AILK_CONTENT_ROOT;
|
|
799
|
+
else process.env.AILK_CONTENT_ROOT = prior;
|
|
800
|
+
rmSync(root, { recursive: true, force: true });
|
|
801
|
+
}
|
|
802
|
+
},
|
|
803
|
+
);
|
|
804
|
+
});
|
|
805
|
+
|
|
664
806
|
// ── 2a. exemption(route, src) — the generated-checkout routes (#8513) ──────
|
|
665
807
|
//
|
|
666
808
|
// Proof that the two conditional entries above key on the FILE, not the
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// #9162 (PR #9152's security review) — the deliverable grant id in the PATH of
|
|
2
|
+
// `<hub>/v1/deliverables/<grantId>`, which `deliverables.openGrant` fetches
|
|
3
|
+
// server-side, is redacted before a URL reaches Sentry. The query-param scrub
|
|
4
|
+
// has its own suite, `__tests__/telemetry-scrub.test.ts`.
|
|
5
|
+
|
|
6
|
+
import { scrubTelemetryUrl } from "../telemetry-scrub";
|
|
7
|
+
|
|
8
|
+
const GRANT = "dgrant_01920000-0000-7000-8000-000000000001";
|
|
9
|
+
|
|
10
|
+
describe("scrubTelemetryUrl — the /v1/deliverables/<grantId> path (#9162)", () => {
|
|
11
|
+
it("redacts the grant id of an absolute hub URL", () => {
|
|
12
|
+
expect(
|
|
13
|
+
scrubTelemetryUrl(`https://hub.example.com/v1/deliverables/${GRANT}`),
|
|
14
|
+
).toBe("https://hub.example.com/v1/deliverables/:grantId");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("redacts the grant id of a relative URL", () => {
|
|
18
|
+
expect(scrubTelemetryUrl(`/v1/deliverables/${GRANT}`)).toBe(
|
|
19
|
+
"/v1/deliverables/:grantId",
|
|
20
|
+
);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("keeps the query string and fragment around the redacted id", () => {
|
|
24
|
+
expect(
|
|
25
|
+
scrubTelemetryUrl(
|
|
26
|
+
`https://hub.example.com/v1/deliverables/${GRANT}?x=1#top`,
|
|
27
|
+
),
|
|
28
|
+
).toBe("https://hub.example.com/v1/deliverables/:grantId?x=1#top");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("redacts the id and strips a token query param together", () => {
|
|
32
|
+
const out = scrubTelemetryUrl(
|
|
33
|
+
`https://hub.example.com/v1/deliverables/${GRANT}?g=${GRANT}&x=1`,
|
|
34
|
+
);
|
|
35
|
+
expect(out).toBe("https://hub.example.com/v1/deliverables/:grantId?x=1");
|
|
36
|
+
expect(out).not.toContain(GRANT);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("redacts an id the api-client percent-encoded", () => {
|
|
40
|
+
const out = scrubTelemetryUrl(
|
|
41
|
+
`https://hub.example.com/v1/deliverables/${encodeURIComponent("a b%c")}`,
|
|
42
|
+
);
|
|
43
|
+
expect(out).toBe("https://hub.example.com/v1/deliverables/:grantId");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("redacts the id under an API base path", () => {
|
|
47
|
+
expect(
|
|
48
|
+
scrubTelemetryUrl(`https://hub.example.com/api/v1/deliverables/${GRANT}`),
|
|
49
|
+
).toBe("https://hub.example.com/api/v1/deliverables/:grantId");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("redacts the id inside a span description (`METHOD url`)", () => {
|
|
53
|
+
expect(
|
|
54
|
+
scrubTelemetryUrl(`GET https://hub.example.com/v1/deliverables/${GRANT}`),
|
|
55
|
+
).toBe("GET https://hub.example.com/v1/deliverables/:grantId");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("leaves the ungated open read's slug alone — a slug is not a credential", () => {
|
|
59
|
+
for (const url of [
|
|
60
|
+
"https://hub.example.com/v1/deliverables/open/q3",
|
|
61
|
+
"/v1/deliverables/open/q3?x=1",
|
|
62
|
+
"GET https://hub.example.com/v1/deliverables/open/q3",
|
|
63
|
+
]) {
|
|
64
|
+
expect(scrubTelemetryUrl(url)).toBe(url);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("redacts a grant id that only starts with `open`", () => {
|
|
69
|
+
expect(scrubTelemetryUrl("/v1/deliverables/openly_a_grant")).toBe(
|
|
70
|
+
"/v1/deliverables/:grantId",
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("leaves other paths unchanged", () => {
|
|
75
|
+
for (const url of [
|
|
76
|
+
"https://hub.example.com/v1/deliverables",
|
|
77
|
+
"https://hub.example.com/v1/deliverables/",
|
|
78
|
+
"/v1/leads/submit",
|
|
79
|
+
`/reports/q3/file`,
|
|
80
|
+
]) {
|
|
81
|
+
expect(scrubTelemetryUrl(url)).toBe(url);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The template-listing mapping (#10051 D6, AC-6) — a TemplatePage's
|
|
3
|
+
* frontmatter to a hub card, an action label and the detail page's
|
|
4
|
+
* generated sections. Pure; no I/O.
|
|
5
|
+
*/
|
|
6
|
+
import { PageSectionsSchema } from "@working-theory/validation";
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
templateActionLabel,
|
|
10
|
+
templateCard,
|
|
11
|
+
templateDetailSections,
|
|
12
|
+
type TemplateLabels,
|
|
13
|
+
} from "../template-listing";
|
|
14
|
+
|
|
15
|
+
const labels: TemplateLabels = {
|
|
16
|
+
getIt: "Get it",
|
|
17
|
+
startFree: "Start free",
|
|
18
|
+
viewDemo: "View demo",
|
|
19
|
+
viewTemplate: "View template",
|
|
20
|
+
included: "What's included",
|
|
21
|
+
demoBadge: "Live demo",
|
|
22
|
+
moreCategories: (count) => `+${count} more`,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const full = {
|
|
26
|
+
slug: "a",
|
|
27
|
+
frontmatter: {
|
|
28
|
+
title: "Template A",
|
|
29
|
+
description: "Description A.",
|
|
30
|
+
summary: "Summary A.",
|
|
31
|
+
thumbnail: "/img/a.png",
|
|
32
|
+
categories: ["accounting", "not-a-category", "legal"],
|
|
33
|
+
demoUrl: "https://a.salessmyth.com",
|
|
34
|
+
getItUrl: "/contact",
|
|
35
|
+
price: 0,
|
|
36
|
+
includes: [
|
|
37
|
+
{ title: "Base site", description: "Pages and structured data." },
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const bare = {
|
|
43
|
+
slug: "b",
|
|
44
|
+
frontmatter: {
|
|
45
|
+
title: "Template B",
|
|
46
|
+
description: "Description B.",
|
|
47
|
+
getItUrl: "https://buy.example.com/b",
|
|
48
|
+
includes: [
|
|
49
|
+
{ title: "Base site", description: "Pages and structured data." },
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
describe("templateCard", () => {
|
|
55
|
+
it("maps every present field", () => {
|
|
56
|
+
expect(templateCard(full, labels)).toEqual({
|
|
57
|
+
title: "Template A",
|
|
58
|
+
description: "Summary A.",
|
|
59
|
+
image: { src: "/img/a.png", alt: "Template A" },
|
|
60
|
+
eyebrow: "Accounting · Legal",
|
|
61
|
+
badge: "Live demo",
|
|
62
|
+
link: { label: "View template", href: "/templates/a" },
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("omits image, eyebrow and badge when their fields are absent, and falls back to description", () => {
|
|
67
|
+
expect(templateCard(bare, labels)).toEqual({
|
|
68
|
+
title: "Template B",
|
|
69
|
+
description: "Description B.",
|
|
70
|
+
link: { label: "View template", href: "/templates/b" },
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("omits the eyebrow when every category slug is unknown", () => {
|
|
75
|
+
const card = templateCard(
|
|
76
|
+
{ slug: "c", frontmatter: { ...bare.frontmatter, categories: ["nope"] } },
|
|
77
|
+
labels,
|
|
78
|
+
);
|
|
79
|
+
expect(card).not.toHaveProperty("eyebrow");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("shows at most three category names, then +N more", () => {
|
|
83
|
+
const card = templateCard(
|
|
84
|
+
{
|
|
85
|
+
slug: "d",
|
|
86
|
+
frontmatter: {
|
|
87
|
+
...bare.frontmatter,
|
|
88
|
+
categories: ["accounting", "legal", "consulting", "hvac", "roofing"],
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
labels,
|
|
92
|
+
);
|
|
93
|
+
expect(card.eyebrow).toBe("Accounting · Legal · Consulting · +2 more");
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("templateActionLabel", () => {
|
|
98
|
+
it("is Start free only for a price of exactly 0", () => {
|
|
99
|
+
expect(templateActionLabel(0, labels)).toBe("Start free");
|
|
100
|
+
expect(templateActionLabel(undefined, labels)).toBe("Get it");
|
|
101
|
+
expect(templateActionLabel(5, labels)).toBe("Get it");
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("templateDetailSections", () => {
|
|
106
|
+
it("builds a hero and a What's included section", () => {
|
|
107
|
+
const [hero, included] = templateDetailSections(full.frontmatter, labels);
|
|
108
|
+
expect(hero).toMatchObject({
|
|
109
|
+
type: "hero",
|
|
110
|
+
data: {
|
|
111
|
+
headline: "Template A",
|
|
112
|
+
subheadline: "Summary A.",
|
|
113
|
+
eyebrow: "Accounting · Legal",
|
|
114
|
+
image: { src: "/img/a.png", alt: "Template A" },
|
|
115
|
+
primaryCta: { label: "Start free", href: "/contact" },
|
|
116
|
+
secondaryCta: {
|
|
117
|
+
label: "View demo",
|
|
118
|
+
href: "https://a.salessmyth.com",
|
|
119
|
+
variant: "outline",
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
expect(included).toEqual({
|
|
124
|
+
type: "items",
|
|
125
|
+
id: "template-included",
|
|
126
|
+
data: {
|
|
127
|
+
headline: "What's included",
|
|
128
|
+
variant: "minimal",
|
|
129
|
+
items: [
|
|
130
|
+
{ title: "Base site", description: "Pages and structured data." },
|
|
131
|
+
],
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("omits the demo link, image and eyebrow when absent", () => {
|
|
137
|
+
const [hero] = templateDetailSections(bare.frontmatter, labels);
|
|
138
|
+
const data = (hero as { data: Record<string, unknown> }).data;
|
|
139
|
+
expect(data.primaryCta).toEqual({
|
|
140
|
+
label: "Get it",
|
|
141
|
+
href: "https://buy.example.com/b",
|
|
142
|
+
});
|
|
143
|
+
expect(data).not.toHaveProperty("secondaryCta");
|
|
144
|
+
expect(data).not.toHaveProperty("image");
|
|
145
|
+
expect(data).not.toHaveProperty("eyebrow");
|
|
146
|
+
expect(data.subheadline).toBe("Description B.");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it.each([
|
|
150
|
+
["full", full.frontmatter],
|
|
151
|
+
["bare", bare.frontmatter],
|
|
152
|
+
])("the %s result parses PageSectionsSchema", (_label, frontmatter) => {
|
|
153
|
+
expect(
|
|
154
|
+
PageSectionsSchema.safeParse(templateDetailSections(frontmatter, labels))
|
|
155
|
+
.success,
|
|
156
|
+
).toBe(true);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* auth-secret.ts — `resolveSecret()`, moved verbatim out of `auth.ts`
|
|
3
|
+
* (#8556, spec docs/specs/9965-sweep-a-trust-or-authz-spec.md D7) so a
|
|
4
|
+
* consumer that only needs the secret does not load Better Auth, which is
|
|
5
|
+
* ESM-only. `auth.ts` re-exports it, so every existing import is unchanged.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Resolve the secret used to sign session cookies AND — since #5962 § D9 — the
|
|
10
|
+
* email-verification JWTs.
|
|
11
|
+
*
|
|
12
|
+
* THROWS in production when BETTER_AUTH_SECRET is unset (§ D16). Better Auth's
|
|
13
|
+
* own `validateSecret` throws only when the secret equals ITS default; this
|
|
14
|
+
* repo's literal is a different string, so it cleared that check and produced
|
|
15
|
+
* nothing but a low-entropy warning — while sitting in a repository whose
|
|
16
|
+
* `oss`-tier tree ships publicly. Anyone who can read this file could forge
|
|
17
|
+
* both a session cookie and a verification token on any deployment that
|
|
18
|
+
* omitted the variable.
|
|
19
|
+
*
|
|
20
|
+
* This changes boot behaviour: a production deployment with no
|
|
21
|
+
* BETTER_AUTH_SECRET boots today and does not after this. That is the correct
|
|
22
|
+
* failure — a site signing sessions with a published string is not usefully
|
|
23
|
+
* "up" — and `apps/web/.env.example` has always documented the variable as
|
|
24
|
+
* REQUIRED in production. The change makes the file honest rather than adding
|
|
25
|
+
* a requirement.
|
|
26
|
+
*
|
|
27
|
+
* Exported (#8146 D5) so the hub's sign-in continuation seals its ref under the
|
|
28
|
+
* same resolved secret instead of re-reading the variable: the second consumer
|
|
29
|
+
* after `betterAuth({ secret })`.
|
|
30
|
+
*/
|
|
31
|
+
export function resolveSecret(): string {
|
|
32
|
+
const configured = process.env.BETTER_AUTH_SECRET;
|
|
33
|
+
if (configured) return configured;
|
|
34
|
+
|
|
35
|
+
// SERVING, not BUILDING. `next build` collects page data with
|
|
36
|
+
// NODE_ENV=production, and that evaluates `app/api/auth/[...all]/route.ts`,
|
|
37
|
+
// which resolves the lazy `auth` proxy — so an unqualified production throw
|
|
38
|
+
// fails the BUILD of any tree that has not set the variable. That is much
|
|
39
|
+
// broader than the risk: a build produces an artifact, it does not serve a
|
|
40
|
+
// session, and it would break CI, preview builds, and `pnpm build` on a
|
|
41
|
+
// freshly scaffolded site that has not configured auth at all.
|
|
42
|
+
//
|
|
43
|
+
// Next sets NEXT_PHASE to PHASE_PRODUCTION_BUILD ("phase-production-build")
|
|
44
|
+
// for the duration of the build, so excluding that phase keeps the guard
|
|
45
|
+
// exactly where it was aimed: a production process that is about to answer
|
|
46
|
+
// requests. Such a process still throws on the first access to `auth`, which
|
|
47
|
+
// is the intended failure — a site signing sessions with a string published
|
|
48
|
+
// in this repository is not usefully "up".
|
|
49
|
+
const isBuildPhase = process.env.NEXT_PHASE === "phase-production-build";
|
|
50
|
+
if (process.env.NODE_ENV === "production" && !isBuildPhase) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
"BETTER_AUTH_SECRET is required in production: it signs session cookies and email-verification tokens, and the development fallback is published in this repository.",
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return "ailk-dev-fallback-secret-published-in-this-repo-never-use-in-production";
|
|
57
|
+
}
|