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,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The route-family helper (#10051 D5, AC-6) — the generic pieces a content
|
|
3
|
+
* hub uses: list every detail page of a type, and slot a generated section in
|
|
4
|
+
* before the page's closing CTAs.
|
|
5
|
+
*/
|
|
6
|
+
import { insertBeforeClosingCtas, listDetailPages } from "../route-family";
|
|
7
|
+
|
|
8
|
+
jest.mock("@working-theory/page-renderer", () => ({
|
|
9
|
+
getMdxSlugs: jest.fn(async () => ["c", "a", "gone", "b"]),
|
|
10
|
+
getMdxSource: jest.fn(),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
describe("listDetailPages", () => {
|
|
14
|
+
it("returns every loaded page sorted by slug, dropping a null load", async () => {
|
|
15
|
+
const load = jest.fn(async (_type: string, slug: string) =>
|
|
16
|
+
slug === "gone" ? null : { frontmatter: { title: slug.toUpperCase() } },
|
|
17
|
+
);
|
|
18
|
+
const pages = await listDetailPages("TemplatePage", "en", load as never);
|
|
19
|
+
expect(pages).toEqual([
|
|
20
|
+
{ slug: "a", frontmatter: { title: "A" } },
|
|
21
|
+
{ slug: "b", frontmatter: { title: "B" } },
|
|
22
|
+
{ slug: "c", frontmatter: { title: "C" } },
|
|
23
|
+
]);
|
|
24
|
+
expect(load).toHaveBeenCalledWith("TemplatePage", "a", "en");
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("insertBeforeClosingCtas", () => {
|
|
29
|
+
const section = (type: string, id: string) => ({ type, id }) as never;
|
|
30
|
+
|
|
31
|
+
it("places the section before a trailing run of two cta sections", () => {
|
|
32
|
+
const result = insertBeforeClosingCtas(
|
|
33
|
+
[section("hero", "h"), section("cta", "c1"), section("cta", "c2")],
|
|
34
|
+
section("items", "new"),
|
|
35
|
+
);
|
|
36
|
+
expect(result.map((s: { id: string }) => s.id)).toEqual([
|
|
37
|
+
"h",
|
|
38
|
+
"new",
|
|
39
|
+
"c1",
|
|
40
|
+
"c2",
|
|
41
|
+
]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("appends when the page does not end in a cta", () => {
|
|
45
|
+
const result = insertBeforeClosingCtas(
|
|
46
|
+
[section("cta", "c1"), section("hero", "h")],
|
|
47
|
+
section("items", "new"),
|
|
48
|
+
);
|
|
49
|
+
expect(result.map((s: { id: string }) => s.id)).toEqual(["c1", "h", "new"]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("appends to an empty page", () => {
|
|
53
|
+
expect(insertBeforeClosingCtas([], section("items", "new"))).toHaveLength(
|
|
54
|
+
1,
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment node
|
|
3
|
+
*/
|
|
4
|
+
// #10043 D8, AC-6 — the services listing: a service's public path, a
|
|
5
|
+
// category's list of its services and the hub's re-pointed links.
|
|
6
|
+
import { PageSectionsSchema } from "@working-theory/validation";
|
|
7
|
+
|
|
8
|
+
type Mod = typeof import("../service-listing");
|
|
9
|
+
|
|
10
|
+
function load(value: string | undefined): Mod {
|
|
11
|
+
const saved = process.env.NEXT_PUBLIC_AILK_SERVICES_PATH;
|
|
12
|
+
if (value === undefined) delete process.env.NEXT_PUBLIC_AILK_SERVICES_PATH;
|
|
13
|
+
else process.env.NEXT_PUBLIC_AILK_SERVICES_PATH = value;
|
|
14
|
+
let mod: Mod | undefined;
|
|
15
|
+
try {
|
|
16
|
+
jest.isolateModules(() => {
|
|
17
|
+
mod = require("../service-listing") as Mod;
|
|
18
|
+
});
|
|
19
|
+
} finally {
|
|
20
|
+
if (saved === undefined) delete process.env.NEXT_PUBLIC_AILK_SERVICES_PATH;
|
|
21
|
+
else process.env.NEXT_PUBLIC_AILK_SERVICES_PATH = saved;
|
|
22
|
+
}
|
|
23
|
+
return mod!;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const labels = {
|
|
27
|
+
childrenHeading: "Services in this area",
|
|
28
|
+
viewService: "View service",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const pages = [
|
|
32
|
+
{
|
|
33
|
+
slug: "a",
|
|
34
|
+
frontmatter: {
|
|
35
|
+
title: "A",
|
|
36
|
+
description: "A desc.",
|
|
37
|
+
summary: "A sum.",
|
|
38
|
+
parent: "ai",
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
{ slug: "ai", frontmatter: { title: "AI", description: "AI desc." } },
|
|
42
|
+
{
|
|
43
|
+
slug: "b",
|
|
44
|
+
frontmatter: { title: "B", description: "B desc.", parent: "ai" },
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
slug: "c",
|
|
48
|
+
frontmatter: { title: "C", description: "C desc.", parent: "other" },
|
|
49
|
+
},
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
describe("service-listing with services.path /capabilities", () => {
|
|
53
|
+
const mod = load("/capabilities");
|
|
54
|
+
|
|
55
|
+
it("builds the public path under the base and the category", () => {
|
|
56
|
+
expect(mod.servicePublicPath("x")).toBe("/capabilities/x");
|
|
57
|
+
expect(mod.servicePublicPath("x", "ai")).toBe("/capabilities/ai/x");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("lists a category's services in slug order, summary else description", () => {
|
|
61
|
+
const children = mod.childServices(pages, "ai");
|
|
62
|
+
const section = mod.serviceChildrenSection(children, labels);
|
|
63
|
+
expect(section.data.headline).toBe("Services in this area");
|
|
64
|
+
expect(section.data.items).toEqual([
|
|
65
|
+
{
|
|
66
|
+
title: "A",
|
|
67
|
+
description: "A sum.",
|
|
68
|
+
link: { label: "View service", href: "/capabilities/ai/a" },
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
title: "B",
|
|
72
|
+
description: "B desc.",
|
|
73
|
+
link: { label: "View service", href: "/capabilities/ai/b" },
|
|
74
|
+
},
|
|
75
|
+
]);
|
|
76
|
+
expect(PageSectionsSchema.safeParse([section]).success).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("re-points a hub section link onto the base and leaves an absolute URL", () => {
|
|
80
|
+
const sections = [
|
|
81
|
+
{
|
|
82
|
+
type: "items",
|
|
83
|
+
id: "s",
|
|
84
|
+
data: {
|
|
85
|
+
items: [
|
|
86
|
+
{ title: "X", link: { label: "Go", href: "/services/x" } },
|
|
87
|
+
{
|
|
88
|
+
title: "Y",
|
|
89
|
+
link: { label: "Go", href: "https://example.com/services" },
|
|
90
|
+
},
|
|
91
|
+
],
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
];
|
|
95
|
+
const mapped = mod.withPublicServicesSections(sections);
|
|
96
|
+
expect(mapped[0]?.data.items.map((i) => i.link.href)).toEqual([
|
|
97
|
+
"/capabilities/x",
|
|
98
|
+
"https://example.com/services",
|
|
99
|
+
]);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("service-listing with no services.path", () => {
|
|
104
|
+
const mod = load(undefined);
|
|
105
|
+
|
|
106
|
+
it("returns the same sections array", () => {
|
|
107
|
+
const sections = [{ type: "items", id: "s", data: { items: [] } }];
|
|
108
|
+
expect(mod.withPublicServicesSections(sections)).toBe(sections);
|
|
109
|
+
expect(mod.servicePublicPath("x", "ai")).toBe("/services/ai/x");
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment node
|
|
3
|
+
*/
|
|
4
|
+
// #10043 D5, AC-7 — the services-path mappers. `SERVICES_PATH` is read at
|
|
5
|
+
// module load, so each setting loads the module afresh.
|
|
6
|
+
import type { NavItem } from "@working-theory/ui";
|
|
7
|
+
import { Menu as Icon } from "lucide-react";
|
|
8
|
+
|
|
9
|
+
type Mod = typeof import("../services-path");
|
|
10
|
+
|
|
11
|
+
function load(value: string | undefined): Mod {
|
|
12
|
+
const saved = process.env.NEXT_PUBLIC_AILK_SERVICES_PATH;
|
|
13
|
+
if (value === undefined) delete process.env.NEXT_PUBLIC_AILK_SERVICES_PATH;
|
|
14
|
+
else process.env.NEXT_PUBLIC_AILK_SERVICES_PATH = value;
|
|
15
|
+
let mod: Mod | undefined;
|
|
16
|
+
try {
|
|
17
|
+
jest.isolateModules(() => {
|
|
18
|
+
mod = require("../services-path") as Mod;
|
|
19
|
+
});
|
|
20
|
+
} finally {
|
|
21
|
+
if (saved === undefined) delete process.env.NEXT_PUBLIC_AILK_SERVICES_PATH;
|
|
22
|
+
else process.env.NEXT_PUBLIC_AILK_SERVICES_PATH = saved;
|
|
23
|
+
}
|
|
24
|
+
return mod!;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("services-path with services.path /capabilities", () => {
|
|
28
|
+
const mod = load("/capabilities");
|
|
29
|
+
|
|
30
|
+
it.each([
|
|
31
|
+
["/services", "/capabilities"],
|
|
32
|
+
["/services/x", "/capabilities/x"],
|
|
33
|
+
["/services?a=1", "/capabilities?a=1"],
|
|
34
|
+
["/services#top", "/capabilities#top"],
|
|
35
|
+
["/servicesx", "/servicesx"],
|
|
36
|
+
["/docs", "/docs"],
|
|
37
|
+
["https://example.com/services", "https://example.com/services"],
|
|
38
|
+
])("toPublicServicesHref(%s) is %s", (href, expected) => {
|
|
39
|
+
expect(mod.toPublicServicesHref(href)).toBe(expected);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it.each([
|
|
43
|
+
["/capabilities", "/services"],
|
|
44
|
+
["/capabilities/ai/x", "/services/ai/x"],
|
|
45
|
+
["/capabilitiesx", null],
|
|
46
|
+
["/services/x", null],
|
|
47
|
+
])("toInternalServicesPath(%s) is %p", (bare, expected) => {
|
|
48
|
+
expect(mod.toInternalServicesPath(bare)).toBe(expected);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("maps a nav item's path, a flyout feature and a flyout action", () => {
|
|
52
|
+
const items: NavItem[] = [
|
|
53
|
+
{ labelKey: "services", path: "/services" },
|
|
54
|
+
{
|
|
55
|
+
labelKey: "more",
|
|
56
|
+
flyout: {
|
|
57
|
+
features: [{ labelKey: "x", path: "/services/x" }],
|
|
58
|
+
actions: [{ labelKey: "all", path: "/services", icon: Icon }],
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
{ labelKey: "docs", path: "/docs" },
|
|
62
|
+
{ labelKey: "other", path: "/servicesx" },
|
|
63
|
+
];
|
|
64
|
+
const mapped = mod.withPublicServicesPaths(items);
|
|
65
|
+
expect(mapped[0]?.path).toBe("/capabilities");
|
|
66
|
+
expect(mapped[1]?.flyout?.features[0]?.path).toBe("/capabilities/x");
|
|
67
|
+
expect(mapped[1]?.flyout?.actions[0]?.path).toBe("/capabilities");
|
|
68
|
+
expect(mapped[2]?.path).toBe("/docs");
|
|
69
|
+
expect(mapped[3]?.path).toBe("/servicesx");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("maps a footer item", () => {
|
|
73
|
+
const columns = [
|
|
74
|
+
{
|
|
75
|
+
headingKey: "h",
|
|
76
|
+
items: [
|
|
77
|
+
{ labelKey: "x", path: "/services/x" },
|
|
78
|
+
{ labelKey: "d", path: "/docs" },
|
|
79
|
+
],
|
|
80
|
+
},
|
|
81
|
+
];
|
|
82
|
+
expect(
|
|
83
|
+
mod.withPublicServicesColumns(columns)[0]?.items.map((i) => i.path),
|
|
84
|
+
).toEqual(["/capabilities/x", "/docs"]);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("services-path with no services.path", () => {
|
|
89
|
+
const mod = load(undefined);
|
|
90
|
+
|
|
91
|
+
it("is the identity", () => {
|
|
92
|
+
expect(mod.SERVICES_PATH).toBe("/services");
|
|
93
|
+
expect(mod.toPublicServicesHref("/services/x")).toBe("/services/x");
|
|
94
|
+
expect(mod.toInternalServicesPath("/services/x")).toBeNull();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -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
|