create-ailk 0.26.0 → 0.28.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/README.md +86 -9
- 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/env-catalog.js +20 -0
- package/dist/lib/site-modules.d.ts +8 -0
- package/dist/lib/site-modules.js +34 -0
- package/dist/module-architecture.js +25 -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 +112 -11
- 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/topology.d.ts +2 -1
- package/dist/topology.js +4 -2
- package/dist/update.d.ts +3 -1
- package/dist/update.js +90 -6
- 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 +12 -2
- package/templates/CLAUDE.md +20 -20
- package/templates/README.md +19 -19
- package/templates/apps/api/.env.example +16 -5
- package/templates/apps/api/CLAUDE.md +32 -11
- 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/api/vercel.json +7 -1
- package/templates/apps/mcp/CLAUDE.md +18 -10
- package/templates/apps/web/.env.example +27 -0
- package/templates/apps/web/CLAUDE.md +56 -8
- 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]/(auth)/sign-in/__tests__/next-path.test.ts +24 -1
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +51 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +13 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +10 -4
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/page.test.tsx +197 -4
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +23 -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 +161 -128
- 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 +223 -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 +236 -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 +322 -2
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +161 -29
- package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +51 -0
- package/templates/apps/web/app/api/hub-session/sign-in/route.ts +13 -0
- 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 +278 -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 +484 -4
- 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/hub-session/__tests__/sign-in-gate.test.tsx +22 -2
- package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +11 -8
- 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 +44 -4
- 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/sign-up-agreements.ts +17 -6
- 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 +144 -3
- package/templates/apps/web/middleware.ts +179 -12
- package/templates/apps/web/next.config.mjs +220 -3
- package/templates/apps/web/package.json +3 -2
- 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/versions.json +48 -48
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// docs-index.ts — the docs list `/sitemap.xml` and `/llms.txt` read (#10041 D8).
|
|
2
|
+
//
|
|
3
|
+
// A base file: it reads the docs directory itself rather than `docsSource`,
|
|
4
|
+
// which only a docs-blog site carries, so the sitemap and llms routes stay in
|
|
5
|
+
// the Free template and list the docs on any site that has them. It imports
|
|
6
|
+
// no docs-blog owned file (a relative import of one would drop it, and every
|
|
7
|
+
// file importing it, from the Free template).
|
|
8
|
+
//
|
|
9
|
+
// Default locale only: the docs exist in one locale (`source.config.ts` reads
|
|
10
|
+
// `content/en/docs`).
|
|
11
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
|
|
14
|
+
import { parse as parseYaml } from "yaml";
|
|
15
|
+
|
|
16
|
+
import { DOCS_PATH } from "./docs-path";
|
|
17
|
+
|
|
18
|
+
export interface DocsIndexPage {
|
|
19
|
+
url: string;
|
|
20
|
+
title: string;
|
|
21
|
+
description?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The two content-adapter types the docs route reads under the database adapter. */
|
|
25
|
+
const DOCS_TYPES = new Set(["DocsList", "DocsDetail"]);
|
|
26
|
+
|
|
27
|
+
export function isDocsType(type: string): boolean {
|
|
28
|
+
return DOCS_TYPES.has(type);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** An adapter docs row's URL: the index at `DOCS_PATH`, a doc under it. */
|
|
32
|
+
export function adapterDocsUrl(type: string, slug: string): string {
|
|
33
|
+
return type === "DocsList" || slug === "index"
|
|
34
|
+
? DOCS_PATH
|
|
35
|
+
: `${DOCS_PATH}/${slug}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function frontmatterOf(raw: string): Record<string, unknown> | null {
|
|
39
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(raw);
|
|
40
|
+
if (!match) return null;
|
|
41
|
+
const parsed: unknown = parseYaml(match[1]!);
|
|
42
|
+
return parsed && typeof parsed === "object"
|
|
43
|
+
? (parsed as Record<string, unknown>)
|
|
44
|
+
: null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Fumadocs' slug rule: the path segments without the extension, a trailing
|
|
49
|
+
* `index` dropped, a `(group)` segment dropped.
|
|
50
|
+
*/
|
|
51
|
+
function urlFor(segments: string[]): string {
|
|
52
|
+
const slugs = segments.filter((s) => !/^\(.+\)$/.test(s));
|
|
53
|
+
if (slugs.at(-1) === "index") slugs.pop();
|
|
54
|
+
return slugs.length === 0 ? DOCS_PATH : `${DOCS_PATH}/${slugs.join("/")}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function walk(dir: string, segments: string[], out: DocsIndexPage[]): void {
|
|
58
|
+
const entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) =>
|
|
59
|
+
a.name.localeCompare(b.name),
|
|
60
|
+
);
|
|
61
|
+
for (const entry of entries) {
|
|
62
|
+
if (entry.isDirectory()) {
|
|
63
|
+
walk(join(dir, entry.name), [...segments, entry.name], out);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (!entry.isFile() || !entry.name.endsWith(".mdx")) continue;
|
|
67
|
+
const data = frontmatterOf(readFileSync(join(dir, entry.name), "utf8"));
|
|
68
|
+
if (!data || typeof data.title !== "string" || data.title === "") continue;
|
|
69
|
+
out.push({
|
|
70
|
+
url: urlFor([...segments, entry.name.slice(0, -".mdx".length)]),
|
|
71
|
+
title: data.title,
|
|
72
|
+
...(typeof data.description === "string" && data.description !== ""
|
|
73
|
+
? { description: data.description }
|
|
74
|
+
: {}),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Every titled `*.mdx` page under `dir` (default: this app's
|
|
81
|
+
* `content/<default locale>/docs`), at the docs' public path. An absent
|
|
82
|
+
* directory — every Free site — is `[]`; a page with no `title` is skipped.
|
|
83
|
+
*/
|
|
84
|
+
export function listDocsPages(
|
|
85
|
+
dir: string = join(
|
|
86
|
+
process.cwd(),
|
|
87
|
+
"content",
|
|
88
|
+
process.env.NEXT_PUBLIC_DEFAULT_LOCALE ?? "en",
|
|
89
|
+
"docs",
|
|
90
|
+
),
|
|
91
|
+
): DocsIndexPage[] {
|
|
92
|
+
const pages: DocsIndexPage[] = [];
|
|
93
|
+
try {
|
|
94
|
+
walk(dir, [], pages);
|
|
95
|
+
} catch (error) {
|
|
96
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
return pages;
|
|
100
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// docs-path.ts — where the docs live (#10041 D2).
|
|
2
|
+
//
|
|
3
|
+
// A site sets `docsPath` in `content/_site.mdx` (e.g. `/working-theory/docs`);
|
|
4
|
+
// `next.config.mjs` parses it at build and bakes it into
|
|
5
|
+
// `NEXT_PUBLIC_AILK_DOCS_PATH`. The docs route itself never moves — it stays
|
|
6
|
+
// at `app/[locale]/docs/` (`DOCS_ROUTE`) — and the middleware maps the public
|
|
7
|
+
// path onto it. Every surface that names a docs URL reads `DOCS_PATH` here.
|
|
8
|
+
//
|
|
9
|
+
// Edge-safe and client-safe: this file imports nothing at runtime (the
|
|
10
|
+
// `NavItem` import is type-only), so the middleware and the client signed-in
|
|
11
|
+
// nav can both read it. It is a base file: it must never import a docs-blog
|
|
12
|
+
// owned file, or it would leave the Free template.
|
|
13
|
+
import type { NavItem } from "@working-theory/ui";
|
|
14
|
+
|
|
15
|
+
/** The internal docs route — the directory under `app/[locale]/`. Always `/docs`. */
|
|
16
|
+
export const DOCS_ROUTE = "/docs";
|
|
17
|
+
|
|
18
|
+
/** The docs' public base: the site's `docsPath`, or `/docs` when it sets none. */
|
|
19
|
+
export const DOCS_PATH: string =
|
|
20
|
+
process.env.NEXT_PUBLIC_AILK_DOCS_PATH || DOCS_ROUTE;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* `href` at the internal docs route — `/docs`, or starting `/docs/`, `/docs?`
|
|
24
|
+
* or `/docs#` — with that prefix replaced by `DOCS_PATH`. Anything else
|
|
25
|
+
* (absolute URLs, `/docsets`, a locale-prefixed path) is returned unchanged.
|
|
26
|
+
* The identity on a site with no `docsPath`.
|
|
27
|
+
*/
|
|
28
|
+
export function toPublicDocsHref(href: string): string {
|
|
29
|
+
if (DOCS_PATH === DOCS_ROUTE) return href;
|
|
30
|
+
const rest = href.slice(DOCS_ROUTE.length);
|
|
31
|
+
if (href.startsWith(DOCS_ROUTE) && (rest === "" || /^[/?#]/.test(rest))) {
|
|
32
|
+
return DOCS_PATH + rest;
|
|
33
|
+
}
|
|
34
|
+
return href;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A locale-less pathname at or under `DOCS_PATH`, mapped onto the internal
|
|
39
|
+
* route (`/docs` + the rest); `null` for anything else, and always `null` on a
|
|
40
|
+
* site with no `docsPath`.
|
|
41
|
+
*/
|
|
42
|
+
export function toInternalDocsPath(bare: string): string | null {
|
|
43
|
+
if (DOCS_PATH === DOCS_ROUTE) return null;
|
|
44
|
+
if (bare === DOCS_PATH) return DOCS_ROUTE;
|
|
45
|
+
if (bare.startsWith(`${DOCS_PATH}/`)) {
|
|
46
|
+
return DOCS_ROUTE + bare.slice(DOCS_PATH.length);
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type NavFlyout = NonNullable<NavItem["flyout"]>;
|
|
52
|
+
|
|
53
|
+
function mapPath<T extends { path: string }>(entry: T): T {
|
|
54
|
+
return { ...entry, path: toPublicDocsHref(entry.path) };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function mapFlyout(flyout: NavFlyout): NavFlyout {
|
|
58
|
+
return {
|
|
59
|
+
...flyout,
|
|
60
|
+
features: flyout.features.map(mapPath),
|
|
61
|
+
...(flyout.groups
|
|
62
|
+
? {
|
|
63
|
+
groups: flyout.groups.map((group) => ({
|
|
64
|
+
...group,
|
|
65
|
+
items: group.items.map(mapPath),
|
|
66
|
+
})),
|
|
67
|
+
}
|
|
68
|
+
: {}),
|
|
69
|
+
actions: flyout.actions.map(mapPath),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Copies of the nav items with every docs path mapped onto `DOCS_PATH` — the
|
|
75
|
+
* item's own `path` and each flyout feature, group item and action. Every
|
|
76
|
+
* other field (icons are functions) is carried by reference.
|
|
77
|
+
*/
|
|
78
|
+
export function withPublicDocsPaths(items: NavItem[]): NavItem[] {
|
|
79
|
+
return items.map((item) => ({
|
|
80
|
+
...item,
|
|
81
|
+
...(item.path !== undefined ? { path: toPublicDocsHref(item.path) } : {}),
|
|
82
|
+
...(item.flyout ? { flyout: mapFlyout(item.flyout) } : {}),
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Copies of the footer columns with every item's docs path mapped onto `DOCS_PATH`. */
|
|
87
|
+
export function withPublicDocsColumns<C extends { items: { path: string }[] }>(
|
|
88
|
+
columns: C[],
|
|
89
|
+
): C[] {
|
|
90
|
+
return columns.map((column) => ({
|
|
91
|
+
...column,
|
|
92
|
+
items: column.items.map(mapPath),
|
|
93
|
+
}));
|
|
94
|
+
}
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hub child cards (#10121) — the /alternatives (#6878 D9) and /templates
|
|
3
|
+
* (#10051 D7) shape, shared by the content hubs that list one child page
|
|
4
|
+
* type each (/for, /case-studies, /team, …).
|
|
5
|
+
*
|
|
6
|
+
* The hub adds a generated card per child page of its type that the hub's
|
|
7
|
+
* own sections do not already link: into the authored card grid that already
|
|
8
|
+
* lists its children when there is one, else as one new `items` section
|
|
9
|
+
* before any trailing closing CTA (#7745 D4). `renderPage` builds the CollectionPage
|
|
10
|
+
* ItemList JSON-LD from the same sections, so the visible cards and the
|
|
11
|
+
* ItemList always list the same pages. A child the hub already links keeps
|
|
12
|
+
* its hand-written card and gets no second one, so a hub whose MDX already
|
|
13
|
+
* links every child renders exactly as before.
|
|
14
|
+
*
|
|
15
|
+
* A prose hub (`bodyMode: "mdx-body"`) never reaches renderPage's
|
|
16
|
+
* SectionRenderer, so its generated section renders after the MDX body
|
|
17
|
+
* through renderPage's `trailing` slot.
|
|
18
|
+
*
|
|
19
|
+
* Base tree: no paid import, no `oss: false`.
|
|
20
|
+
*/
|
|
21
|
+
import type {
|
|
22
|
+
getMdxSlugs as defaultGetMdxSlugs,
|
|
23
|
+
getMdxSource as defaultGetMdxSource,
|
|
24
|
+
getPageSections as defaultGetPageSections,
|
|
25
|
+
renderPage,
|
|
26
|
+
SchemaOrgType,
|
|
27
|
+
} from "@working-theory/page-renderer";
|
|
28
|
+
import {
|
|
29
|
+
getMdxSlugs as loadMdxSlugs,
|
|
30
|
+
getMdxSource as loadMdxSource,
|
|
31
|
+
getPageSections as loadPageSections,
|
|
32
|
+
} from "@working-theory/page-renderer";
|
|
33
|
+
import { SectionRenderer } from "@working-theory/ui/sections";
|
|
34
|
+
|
|
35
|
+
import {
|
|
36
|
+
type DetailPage,
|
|
37
|
+
insertBeforeClosingCtas,
|
|
38
|
+
listDetailPages,
|
|
39
|
+
} from "./route-family";
|
|
40
|
+
|
|
41
|
+
type Section = Awaited<ReturnType<typeof defaultGetPageSections>>[number];
|
|
42
|
+
|
|
43
|
+
/** One hub: its list page, its child type, and how its body renders. */
|
|
44
|
+
export interface HubConfig {
|
|
45
|
+
/** The list page type, e.g. `UseCasesList`. */
|
|
46
|
+
listType: SchemaOrgType;
|
|
47
|
+
/** The hub's slug, translations namespace and route segment, e.g. `for`. */
|
|
48
|
+
slug: string;
|
|
49
|
+
/** The child page type, e.g. `UseCase`. */
|
|
50
|
+
childType: SchemaOrgType;
|
|
51
|
+
/** The card link label. */
|
|
52
|
+
linkLabel: string;
|
|
53
|
+
/** The generated section's id. */
|
|
54
|
+
sectionId: string;
|
|
55
|
+
/**
|
|
56
|
+
* The hub's existing body rule: `sections` always, `mdx-body` always, or
|
|
57
|
+
* `composed` — sections when the frontmatter carries `sections[]`, the MDX
|
|
58
|
+
* body otherwise.
|
|
59
|
+
*/
|
|
60
|
+
body: "sections" | "mdx-body" | "composed";
|
|
61
|
+
/** Which child pages the hub lists (default: all). */
|
|
62
|
+
includeChild?: (page: DetailPage) => boolean;
|
|
63
|
+
/**
|
|
64
|
+
* One listed page's card (default: title, description and a link to
|
|
65
|
+
* `/<slug>/<child>` labelled `linkLabel`).
|
|
66
|
+
*/
|
|
67
|
+
card?: (page: DetailPage) => HubCard;
|
|
68
|
+
/** A last pass over the hub's final sections (default: none). */
|
|
69
|
+
mapSections?: (sections: Section[]) => Section[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** One generated card. */
|
|
73
|
+
export interface HubCard {
|
|
74
|
+
title: string;
|
|
75
|
+
description?: string | undefined;
|
|
76
|
+
link?: { label: string; href: string } | undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A hub route's injectable loaders (tests pass their own). */
|
|
80
|
+
export interface HubLoaders {
|
|
81
|
+
getMdxSource?: typeof defaultGetMdxSource;
|
|
82
|
+
getPageSections?: typeof defaultGetPageSections;
|
|
83
|
+
getMdxSlugs?: typeof defaultGetMdxSlugs;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* A link's path for matching: an absolute http(s) URL's pathname, the query
|
|
88
|
+
* and hash dropped, a leading `/` ensured and trailing `/`s trimmed.
|
|
89
|
+
*/
|
|
90
|
+
function linkPath(href: string): string | null {
|
|
91
|
+
let path = href.trim();
|
|
92
|
+
if (/^https?:\/\//i.test(path)) {
|
|
93
|
+
try {
|
|
94
|
+
path = new URL(path).pathname;
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
path = path.split(/[?#]/)[0] ?? "";
|
|
100
|
+
if (!path.startsWith("/")) path = `/${path}`;
|
|
101
|
+
let end = path.length;
|
|
102
|
+
while (end > 0 && path[end - 1] === "/") end -= 1;
|
|
103
|
+
return path.slice(0, end);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** A BCP-47-shaped first path segment: `en`, `pt-BR`, `zh-Hant`. */
|
|
107
|
+
const LOCALE_SEGMENT = /^[a-z]{2,3}(-[A-Za-z0-9]{2,8})?$/;
|
|
108
|
+
|
|
109
|
+
/** The path without one leading locale segment (`/en/for/a` → `/for/a`). */
|
|
110
|
+
function withoutLocale(path: string): string {
|
|
111
|
+
const end = path.indexOf("/", 1);
|
|
112
|
+
if (end === -1) return path;
|
|
113
|
+
return LOCALE_SEGMENT.test(path.slice(1, end)) ? path.slice(end) : path;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Whether a link path names `target`, with or without a locale prefix. */
|
|
117
|
+
function linksTo(path: string, target: string): boolean {
|
|
118
|
+
return path === target || withoutLocale(path) === target;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Every link path an MDX body carries: markdown link targets and `href`
|
|
123
|
+
* attributes, the forms a prose hub links its pages with.
|
|
124
|
+
*/
|
|
125
|
+
export function bodyLinkPaths(body: string): string[] {
|
|
126
|
+
const hrefs = [
|
|
127
|
+
...body.matchAll(/\]\(\s*<?([^)\s>]+)/g),
|
|
128
|
+
...body.matchAll(/\bhref\s*=\s*\{?\s*["']([^"']+)["']/g),
|
|
129
|
+
].map((match) => match[1] ?? "");
|
|
130
|
+
return hrefs
|
|
131
|
+
.map(linkPath)
|
|
132
|
+
.filter((path): path is string => path !== null);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Every link path the sections carry: any `href` or `url` string at any
|
|
137
|
+
* depth, so an `items` card link, a `team` member's `url` and a CTA all count.
|
|
138
|
+
*/
|
|
139
|
+
export function linkedPaths(sections: readonly unknown[]): string[] {
|
|
140
|
+
const paths: string[] = [];
|
|
141
|
+
const walk = (value: unknown): void => {
|
|
142
|
+
if (Array.isArray(value)) {
|
|
143
|
+
value.forEach(walk);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (value === null || typeof value !== "object") return;
|
|
147
|
+
for (const [key, child] of Object.entries(value)) {
|
|
148
|
+
if ((key === "href" || key === "url") && typeof child === "string") {
|
|
149
|
+
const path = linkPath(child);
|
|
150
|
+
if (path !== null) paths.push(path);
|
|
151
|
+
} else {
|
|
152
|
+
walk(child);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
walk(sections);
|
|
157
|
+
return paths;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The generated section: one card per child page the hub's rendered content
|
|
162
|
+
* does not already link (`linked`, from `linkedPaths` or `bodyLinkPaths`; a
|
|
163
|
+
* locale prefix or an absolute URL still counts as a link), or `null` when
|
|
164
|
+
* there is none.
|
|
165
|
+
*/
|
|
166
|
+
export function childCardsSection(
|
|
167
|
+
linked: readonly string[],
|
|
168
|
+
pages: readonly DetailPage[],
|
|
169
|
+
config: Pick<HubConfig, "slug" | "linkLabel" | "sectionId" | "card">,
|
|
170
|
+
): Section | null {
|
|
171
|
+
// A child counts as linked at its route path or at its card's own href (a
|
|
172
|
+
// hub whose public base differs from its route, #10043).
|
|
173
|
+
const items = pages.flatMap((page) => {
|
|
174
|
+
const card = config.card?.(page) ?? defaultCard(page, config);
|
|
175
|
+
const targets = [`/${config.slug}/${page.slug}`];
|
|
176
|
+
if (card.link !== undefined) targets.push(card.link.href);
|
|
177
|
+
const linkedAlready = linked.some((path) =>
|
|
178
|
+
targets.some((t) => linksTo(path, t)),
|
|
179
|
+
);
|
|
180
|
+
return linkedAlready ? [] : [card];
|
|
181
|
+
});
|
|
182
|
+
if (items.length === 0) return null;
|
|
183
|
+
return { type: "items", id: config.sectionId, data: { items } } as Section;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function defaultCard(
|
|
187
|
+
{ slug, frontmatter }: DetailPage,
|
|
188
|
+
config: Pick<HubConfig, "slug" | "linkLabel">,
|
|
189
|
+
): HubCard {
|
|
190
|
+
const fm = frontmatter as { title?: string; description?: string };
|
|
191
|
+
return {
|
|
192
|
+
title: fm.title ?? slug,
|
|
193
|
+
description: fm.description ?? "",
|
|
194
|
+
link: { label: config.linkLabel, href: `/${config.slug}/${slug}` },
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
type CardItem = { link?: { href?: string; variant?: string } };
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Place the generated cards. When an authored `items` section already lists
|
|
202
|
+
* children of this hub (a card linking `/<hub>/…`), the cards continue that
|
|
203
|
+
* grid, taking its card link variant, so the hub shows one list. Otherwise
|
|
204
|
+
* the generated section goes before the closing CTAs (#7745 D4).
|
|
205
|
+
*/
|
|
206
|
+
export function placeChildCards(
|
|
207
|
+
declared: Section[],
|
|
208
|
+
generated: Section,
|
|
209
|
+
hubSlug: string,
|
|
210
|
+
): Section[] {
|
|
211
|
+
const prefix = `/${hubSlug}/`;
|
|
212
|
+
const listsChildren = (section: Section): boolean =>
|
|
213
|
+
section.type === "items" &&
|
|
214
|
+
((section.data as { items?: CardItem[] }).items ?? []).some((item) => {
|
|
215
|
+
const href = item.link?.href;
|
|
216
|
+
const path = href === undefined ? null : linkPath(href);
|
|
217
|
+
// Raw first: a short hub slug (`for`) itself looks like a locale.
|
|
218
|
+
return (
|
|
219
|
+
path !== null &&
|
|
220
|
+
(path.startsWith(prefix) || withoutLocale(path).startsWith(prefix))
|
|
221
|
+
);
|
|
222
|
+
});
|
|
223
|
+
let at = declared.length - 1;
|
|
224
|
+
while (at >= 0 && !listsChildren(declared[at] as Section)) at -= 1;
|
|
225
|
+
if (at === -1) return insertBeforeClosingCtas(declared, generated);
|
|
226
|
+
|
|
227
|
+
const host = declared[at] as Section & { data: { items: CardItem[] } };
|
|
228
|
+
const variant = host.data.items.find((item) => item.link?.variant)?.link
|
|
229
|
+
?.variant;
|
|
230
|
+
const added = (generated.data as { items: CardItem[] }).items.map((item) =>
|
|
231
|
+
variant === undefined ? item : { ...item, link: { ...item.link, variant } },
|
|
232
|
+
);
|
|
233
|
+
const merged = {
|
|
234
|
+
...host,
|
|
235
|
+
data: { ...host.data, items: [...host.data.items, ...added] },
|
|
236
|
+
} as Section;
|
|
237
|
+
return [...declared.slice(0, at), merged, ...declared.slice(at + 1)];
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function hasSections(frontmatter: unknown): boolean {
|
|
241
|
+
const sections = (frontmatter as { sections?: unknown }).sections;
|
|
242
|
+
return Array.isArray(sections) && sections.length > 0;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
type RenderPageInput = Parameters<typeof renderPage>[0];
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Everything a hub's `renderPage` call needs beyond its literal `type` and
|
|
249
|
+
* `slug`, which stay written out in each page.tsx where the content-render
|
|
250
|
+
* lint reads them.
|
|
251
|
+
*/
|
|
252
|
+
export async function hubPageInput(
|
|
253
|
+
config: HubConfig,
|
|
254
|
+
locale: string,
|
|
255
|
+
loaders: HubLoaders,
|
|
256
|
+
): Promise<Omit<RenderPageInput, "type" | "slug">> {
|
|
257
|
+
const load = loaders.getMdxSource ?? loadMdxSource;
|
|
258
|
+
const base = {
|
|
259
|
+
locale,
|
|
260
|
+
translationsNamespace: config.slug,
|
|
261
|
+
...(loaders.getMdxSource !== undefined && {
|
|
262
|
+
getMdxSource: loaders.getMdxSource,
|
|
263
|
+
}),
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const source = await load(config.listType, config.slug, locale);
|
|
267
|
+
const mdxBody =
|
|
268
|
+
config.body === "mdx-body" ||
|
|
269
|
+
(config.body === "composed" &&
|
|
270
|
+
(source === null || !hasSections(source.frontmatter)));
|
|
271
|
+
if (source === null) {
|
|
272
|
+
// The hub's existing empty-state rule, unchanged.
|
|
273
|
+
return {
|
|
274
|
+
...base,
|
|
275
|
+
...(mdxBody && { bodyMode: "mdx-body" as const }),
|
|
276
|
+
...(loaders.getPageSections !== undefined && {
|
|
277
|
+
getPageSections: loaders.getPageSections,
|
|
278
|
+
}),
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const declared = await (loaders.getPageSections ?? loadPageSections)(
|
|
283
|
+
source.frontmatter,
|
|
284
|
+
);
|
|
285
|
+
const pages = await listDetailPages(
|
|
286
|
+
config.childType,
|
|
287
|
+
locale,
|
|
288
|
+
load,
|
|
289
|
+
loaders.getMdxSlugs ?? loadMdxSlugs,
|
|
290
|
+
);
|
|
291
|
+
// A prose hub renders its MDX body, never its declared sections, so its
|
|
292
|
+
// existing links are read from the body and its cards stand alone after it.
|
|
293
|
+
const linked = mdxBody
|
|
294
|
+
? bodyLinkPaths(source.body)
|
|
295
|
+
: linkedPaths(declared);
|
|
296
|
+
const generated = childCardsSection(
|
|
297
|
+
linked,
|
|
298
|
+
config.includeChild ? pages.filter(config.includeChild) : pages,
|
|
299
|
+
config,
|
|
300
|
+
);
|
|
301
|
+
const placed =
|
|
302
|
+
generated === null
|
|
303
|
+
? declared
|
|
304
|
+
: mdxBody
|
|
305
|
+
? insertBeforeClosingCtas(declared, generated)
|
|
306
|
+
: placeChildCards(declared, generated, config.slug);
|
|
307
|
+
const sections = config.mapSections ? config.mapSections(placed) : placed;
|
|
308
|
+
|
|
309
|
+
return {
|
|
310
|
+
...base,
|
|
311
|
+
getPageSections: async () => sections,
|
|
312
|
+
...(mdxBody && { bodyMode: "mdx-body" as const }),
|
|
313
|
+
...(mdxBody &&
|
|
314
|
+
generated !== null && {
|
|
315
|
+
trailing: <SectionRenderer sections={[generated]} locale={locale} />,
|
|
316
|
+
}),
|
|
317
|
+
};
|
|
318
|
+
}
|
|
@@ -4,6 +4,10 @@
|
|
|
4
4
|
* the one card's frame — the mark in the tile, "Sign in to <site>", the toggle
|
|
5
5
|
* line to the hub's /sign-up (or the site's own prospect line when its
|
|
6
6
|
* messages carry one), and the Terms + Privacy acknowledgement.
|
|
7
|
+
*
|
|
8
|
+
* #9912 D1: that toggle reaches the hub's /sign-up through the same round trip
|
|
9
|
+
* as Sign in — this site's own sign-in route with `prompt=create` and the
|
|
10
|
+
* same `next` — so completing sign-up returns the buyer here.
|
|
7
11
|
*/
|
|
8
12
|
import { render, screen, within } from "@testing-library/react";
|
|
9
13
|
import en from "../../../messages/en.json";
|
|
@@ -94,7 +98,11 @@ describe("<HubSignInGate/> (#8790 AC-1; #9662)", () => {
|
|
|
94
98
|
);
|
|
95
99
|
expect(screen.getByTestId("auth-card-prospect-link")).toHaveAttribute(
|
|
96
100
|
"href",
|
|
97
|
-
"
|
|
101
|
+
"/api/hub-session/sign-in?next=%2Faccount&prompt=create",
|
|
102
|
+
);
|
|
103
|
+
expect(screen.getByTestId("account-hub-sign-in")).toHaveAttribute(
|
|
104
|
+
"href",
|
|
105
|
+
"/api/hub-session/sign-in?next=%2Faccount",
|
|
98
106
|
);
|
|
99
107
|
const footnote = screen.getByTestId("auth-card-footnote");
|
|
100
108
|
expect(footnote).toHaveTextContent(
|
|
@@ -107,6 +115,18 @@ describe("<HubSignInGate/> (#8790 AC-1; #9662)", () => {
|
|
|
107
115
|
within(footnote).getByRole("link", { name: "Privacy Policy" }),
|
|
108
116
|
).toHaveAttribute("href", "/privacy");
|
|
109
117
|
});
|
|
118
|
+
|
|
119
|
+
it("starts Sign up with the requested path as next, the Sign in button's own URL plus prompt=create (#9912 D1, AC-1)", async () => {
|
|
120
|
+
await renderGate("/account/billing");
|
|
121
|
+
expect(screen.getByTestId("auth-card-prospect-link")).toHaveAttribute(
|
|
122
|
+
"href",
|
|
123
|
+
"/api/hub-session/sign-in?next=%2Faccount%2Fbilling&prompt=create",
|
|
124
|
+
);
|
|
125
|
+
expect(screen.getByTestId("account-hub-sign-in")).toHaveAttribute(
|
|
126
|
+
"href",
|
|
127
|
+
"/api/hub-session/sign-in?next=%2Faccount%2Fbilling",
|
|
128
|
+
);
|
|
129
|
+
});
|
|
110
130
|
});
|
|
111
131
|
|
|
112
132
|
describe("<HubSignInGate/> prospect line (#8790 AC-2)", () => {
|
|
@@ -131,7 +151,7 @@ describe("<HubSignInGate/> prospect line (#8790 AC-2)", () => {
|
|
|
131
151
|
await renderGate();
|
|
132
152
|
expect(screen.getByTestId("auth-card-prospect-link")).toHaveAttribute(
|
|
133
153
|
"href",
|
|
134
|
-
"
|
|
154
|
+
"/api/hub-session/sign-in?next=%2Faccount&prompt=create",
|
|
135
155
|
);
|
|
136
156
|
});
|
|
137
157
|
|
|
@@ -17,12 +17,14 @@
|
|
|
17
17
|
* The one card (#9662, operator 2026-10-24): the single hub action sits in
|
|
18
18
|
* `AuthCard`'s frame — the site's mark in the 48px tile, "Sign in to <site>"
|
|
19
19
|
* (`SITE_NAME`, the deployment's own name, never the request's), the
|
|
20
|
-
* "Don't have an account? Sign up" line to
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
20
|
+
* "Don't have an account? Sign up" line to this site's own sign-in route with
|
|
21
|
+
* `prompt=create`, which lands on the hub's sign-up screen and returns here
|
|
22
|
+
* (#9912 D1), and the Terms + Privacy acknowledgement on this site's own
|
|
23
|
+
* policy routes. A site that fills `account.gate.prospectPrompt` +
|
|
24
|
+
* `prospectLink` + `prospectHref` replaces the toggle line with its own. The
|
|
25
|
+
* acknowledgement is built here rather than from the `(auth)` group's
|
|
26
|
+
* builder: a spoke carries no `(auth)` group (create-ailk's
|
|
27
|
+
* `SPOKE_REMOVAL_SEEDS`).
|
|
26
28
|
*
|
|
27
29
|
* Not configured (`hubSessionConfig()` is `null`: a client id, secret or
|
|
28
30
|
* session secret missing), the card says "sign-in isn't available yet" and
|
|
@@ -60,6 +62,7 @@ export async function HubSignInGate({
|
|
|
60
62
|
const next =
|
|
61
63
|
requestHeaders.get("x-pathname") ??
|
|
62
64
|
localeHref(locale, HUB_SESSION_FALLBACK_PATH);
|
|
65
|
+
const signInHref = `${HUB_SIGN_IN_ROUTE}?next=${encodeURIComponent(next)}`;
|
|
63
66
|
const home = localeHref(locale, "/");
|
|
64
67
|
// The three prospect keys are optional extensions of the typed catalog (the
|
|
65
68
|
// shipped en.json carries none — every leaf must be a non-empty string), so
|
|
@@ -78,7 +81,7 @@ export async function HubSignInGate({
|
|
|
78
81
|
: {
|
|
79
82
|
prompt: signIn("prospectPrompt"),
|
|
80
83
|
linkLabel: signIn("prospectLink"),
|
|
81
|
-
href: `${
|
|
84
|
+
href: `${signInHref}&prompt=create`,
|
|
82
85
|
};
|
|
83
86
|
|
|
84
87
|
return (
|
|
@@ -114,7 +117,7 @@ export async function HubSignInGate({
|
|
|
114
117
|
// The primary action: full width at `big` (44px).
|
|
115
118
|
<Button asChild size="big" className="w-full">
|
|
116
119
|
<Link
|
|
117
|
-
href={
|
|
120
|
+
href={signInHref}
|
|
118
121
|
variant="bare"
|
|
119
122
|
data-testid="account-hub-sign-in"
|
|
120
123
|
>
|
|
@@ -7,3 +7,12 @@
|
|
|
7
7
|
|
|
8
8
|
/** The scheduling module's booking pages (/meet, /private) exist. */
|
|
9
9
|
export const SCHEDULING_PAGES: boolean = false;
|
|
10
|
+
|
|
11
|
+
/** The marketing module's public pages exist (#10040): false on a hub. */
|
|
12
|
+
export const MARKETING_PAGES: boolean = true;
|
|
13
|
+
|
|
14
|
+
/** The docs-blog module's /docs and /blog pages exist (#10040). */
|
|
15
|
+
export const DOCS_BLOG_PAGES: boolean = false;
|
|
16
|
+
|
|
17
|
+
/** Any public page exists: robots, sitemap, llms.txt and the noindex header read this (#10040 D6/D7). */
|
|
18
|
+
export const PUBLIC_PAGES: boolean = true;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// nav-label-check.ts — find nav keys a locale's messages file does not carry
|
|
2
|
+
// (#10045 D2). Run before `next build` by `scripts/check-nav-labels.ts`.
|
|
3
|
+
//
|
|
4
|
+
// A locale's catalog is `messages/<locale>.json` plus the site overlay
|
|
5
|
+
// `messages/site/<locale>.json` (falling back to `messages/site/en.json`),
|
|
6
|
+
// matching how `i18n/request.ts` loads them, so a key the site adds in its
|
|
7
|
+
// overlay counts as present.
|
|
8
|
+
|
|
9
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
|
|
12
|
+
import { navMessageKeys } from "./nav-label-keys";
|
|
13
|
+
|
|
14
|
+
export interface MissingNavLabel {
|
|
15
|
+
key: string;
|
|
16
|
+
/** The `messages/<locale>.json` file the key is missing from. */
|
|
17
|
+
file: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function readJson(file: string): unknown {
|
|
21
|
+
return existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Every (key, messages file) pair where a used nav key has no text. */
|
|
25
|
+
export function findMissingNavLabels(
|
|
26
|
+
messagesDir: string,
|
|
27
|
+
keys: Iterable<string>,
|
|
28
|
+
): MissingNavLabel[] {
|
|
29
|
+
const wanted = [...new Set(keys)].sort();
|
|
30
|
+
const englishOverlay = join(messagesDir, "site", "en.json");
|
|
31
|
+
const missing: MissingNavLabel[] = [];
|
|
32
|
+
const catalogs = readdirSync(messagesDir, { withFileTypes: true })
|
|
33
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
|
|
34
|
+
.map((entry) => entry.name)
|
|
35
|
+
.sort();
|
|
36
|
+
for (const name of catalogs) {
|
|
37
|
+
const file = join(messagesDir, name);
|
|
38
|
+
const ownOverlay = join(messagesDir, "site", name);
|
|
39
|
+
const overlay = existsSync(ownOverlay) ? ownOverlay : englishOverlay;
|
|
40
|
+
const present = new Set([
|
|
41
|
+
...navMessageKeys(readJson(file)),
|
|
42
|
+
...navMessageKeys(readJson(overlay)),
|
|
43
|
+
]);
|
|
44
|
+
for (const key of wanted) {
|
|
45
|
+
if (!present.has(key)) missing.push({ key, file });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return missing;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** One line per missing key, naming the key and the file. */
|
|
52
|
+
export function formatMissingNavLabels(
|
|
53
|
+
missing: readonly MissingNavLabel[],
|
|
54
|
+
): string {
|
|
55
|
+
return missing
|
|
56
|
+
.map(
|
|
57
|
+
({ key, file }) =>
|
|
58
|
+
`nav key "${key}" is used by the site nav but missing from ${file} (namespace "nav")`,
|
|
59
|
+
)
|
|
60
|
+
.join("\n");
|
|
61
|
+
}
|