@pantheon-systems/create-p1-starter-kit 0.7.0 → 0.8.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/package.json +7 -4
- package/template/.env.example +6 -0
- package/template/CHANGELOG.md +20 -0
- package/template/__tests__/chatbot-flag-wiring.test.ts +1 -1
- package/template/__tests__/editor-integration.test.ts +1 -1
- package/template/__tests__/editor-route-group.test.ts +44 -0
- package/template/__tests__/page-seo.test.ts +127 -0
- package/template/__tests__/paragraph-block.test.ts +37 -0
- package/template/__tests__/sanitize-richtext.test.ts +58 -0
- package/template/__tests__/seo-metadata.test.ts +105 -0
- package/template/app/[...puckPath]/page.tsx +5 -26
- package/template/app/layout.tsx +14 -0
- package/template/app/p1/{[[...p1]] → (editor)/[[...p1]]}/editor-client.tsx +20 -21
- package/template/app/p1/{[[...p1]]/page.tsx → (editor)/[[...p1]]/p1-pages.tsx} +2 -7
- package/template/app/p1/(editor)/[[...p1]]/page.tsx +5 -0
- package/template/app/p1/(editor)/layout.tsx +13 -0
- package/template/app/page.tsx +3 -21
- package/template/app/styles.css +1 -0
- package/template/ci-examples/github-actions-sync-puck-registry.yml +9 -3
- package/template/components/puck/media-figure-block.tsx +12 -0
- package/template/components/puck/paragraph-block.tsx +11 -31
- package/template/components/puck/sanitize-richtext.ts +44 -0
- package/template/lib/page-seo.ts +79 -0
- package/template/lib/seo-metadata.ts +48 -0
- package/template/package.json +9 -5
- package/template/pnpm-workspace.yaml +3 -0
- package/template/puck.config.tsx +3 -1
- package/template/scripts/__tests__/asset-stub-hooks.test.ts +116 -3
- package/template/scripts/__tests__/sync-puck-registry.test.ts +107 -1
- package/template/scripts/asset-stub-hooks.mjs +25 -1
- package/template/scripts/sync-puck-registry.ts +84 -7
- package/template/tsconfig.test.json +6 -0
- package/template/vitest.config.ts +5 -0
package/template/app/page.tsx
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
import { WelcomeBlockRender } from "../components/puck/welcome-block-render";
|
|
2
1
|
import {
|
|
3
2
|
ensureInitialized,
|
|
4
3
|
getPage,
|
|
5
4
|
listRouteTemplateKeysFromDatabase,
|
|
6
5
|
resolveDataTemplates,
|
|
7
|
-
resolveStringTemplates,
|
|
8
6
|
extractReferencedDatasourceIds,
|
|
9
7
|
loadRemoteDatasourceContext,
|
|
10
8
|
} from "@pantheon-systems/puck-css/server";
|
|
11
9
|
import type { Metadata } from "next";
|
|
10
|
+
import { WelcomeBlockRender } from "../components/puck/welcome-block-render";
|
|
11
|
+
import { resolvePageMetadata } from "../lib/page-seo";
|
|
12
12
|
import { REMOTE_DATASOURCE_FETCHERS } from "../lib/remote-datasource-fetchers";
|
|
13
13
|
import { Client } from "./[...puckPath]/client";
|
|
14
14
|
|
|
@@ -27,25 +27,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
|
|
27
27
|
if (!pageData) {
|
|
28
28
|
return { title: "P1 Starter Kit" };
|
|
29
29
|
}
|
|
30
|
-
|
|
31
|
-
const rawTitle = pageData.root.props?.title;
|
|
32
|
-
if (typeof rawTitle !== "string") {
|
|
33
|
-
return { title: rawTitle };
|
|
34
|
-
}
|
|
35
|
-
if (!rawTitle.includes("{{")) {
|
|
36
|
-
return { title: rawTitle };
|
|
37
|
-
}
|
|
38
|
-
const routeTemplateKeys = await listRouteTemplateKeysFromDatabase();
|
|
39
|
-
const referencedDatasourceIds = extractReferencedDatasourceIds(pageData);
|
|
40
|
-
const context = await loadRemoteDatasourceContext({
|
|
41
|
-
searchParams: {},
|
|
42
|
-
fetchImpl: fetch,
|
|
43
|
-
pagePath: "/",
|
|
44
|
-
routeTemplateKeys,
|
|
45
|
-
builtinFetchers: REMOTE_DATASOURCE_FETCHERS,
|
|
46
|
-
referencedDatasourceIds,
|
|
47
|
-
});
|
|
48
|
-
return { title: await resolveStringTemplates(rawTitle, context) };
|
|
30
|
+
return resolvePageMetadata({ pageData, path: "/", searchParams: {} });
|
|
49
31
|
}
|
|
50
32
|
|
|
51
33
|
export default async function HomePage() {
|
package/template/app/styles.css
CHANGED
|
@@ -12,10 +12,15 @@
|
|
|
12
12
|
# 3. Copy this file to .github/workflows/sync-puck-registry.yml.
|
|
13
13
|
#
|
|
14
14
|
# Triggers on push to any branch that touches puck.config.tsx or
|
|
15
|
-
# components/puck
|
|
16
|
-
#
|
|
17
|
-
#
|
|
15
|
+
# components/puck/**. The sync script resolves the CSS branch from the pushed
|
|
16
|
+
# git branch's name: the repo's default branch always targets the site's main
|
|
17
|
+
# CSS branch (whatever the git branch is called), any other ref matches a CSS
|
|
18
|
+
# branch by name. A push on a non-default branch with no matching CSS branch
|
|
18
19
|
# is not an error: the script logs a skip and exits 0.
|
|
20
|
+
#
|
|
21
|
+
# CSS_DEFAULT_BRANCH defaults to "main" if omitted — repos whose default
|
|
22
|
+
# branch has another name (master, trunk) need the line below (or must set
|
|
23
|
+
# the variable themselves) for default-branch pushes to sync at all.
|
|
19
24
|
name: Sync Puck Component Registry
|
|
20
25
|
|
|
21
26
|
on:
|
|
@@ -49,3 +54,4 @@ jobs:
|
|
|
49
54
|
CSS_SITE_ID: ${{ secrets.CSS_SITE_ID }}
|
|
50
55
|
CSS_REGISTRY_API_KEY: ${{ secrets.CSS_REGISTRY_API_KEY }}
|
|
51
56
|
CSS_BRANCH_ID: ${{ github.event.inputs.branch_id || github.ref_name }}
|
|
57
|
+
CSS_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { createMediaFigureBlock } from "@pantheon-systems/p1-media/server";
|
|
2
|
+
import { blockPaddingClass } from "./block-padding";
|
|
3
|
+
|
|
4
|
+
// CDN origin, not the Worker API URL; defaults to production when unset.
|
|
5
|
+
const MEDIA_BASE = process.env.NEXT_PUBLIC_MEDIA_BASE_URL;
|
|
6
|
+
|
|
7
|
+
export const mediaFigureBlock = createMediaFigureBlock({
|
|
8
|
+
mediaBaseUrl: MEDIA_BASE,
|
|
9
|
+
transform: { width: 1200, height: 630, format: "webp" },
|
|
10
|
+
className: `m-0 ${blockPaddingClass} [&>img]:block [&>img]:h-auto [&>img]:max-h-[400px] [&>img]:w-full [&>img]:max-w-4xl [&>img]:rounded-lg [&>img]:object-contain`,
|
|
11
|
+
captionClassName: "mt-3 max-w-4xl text-sm text-neutral-600",
|
|
12
|
+
});
|
|
@@ -1,21 +1,13 @@
|
|
|
1
|
-
|
|
1
|
+
"use client";
|
|
2
|
+
import { type ReactNode, isValidElement } from "react";
|
|
3
|
+
import { richtextField } from "@pantheon-systems/puck-css/fields";
|
|
2
4
|
import { blockPaddingClass } from "./block-padding";
|
|
3
|
-
import
|
|
4
|
-
|
|
5
|
-
function asMarkdownText(value: unknown): string {
|
|
6
|
-
if (typeof value === "string") return value;
|
|
7
|
-
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
8
|
-
return "";
|
|
9
|
-
}
|
|
5
|
+
import { sanitizeRichtextHtml } from "./sanitize-richtext";
|
|
10
6
|
|
|
11
7
|
export const paragraphBlock = {
|
|
12
8
|
label: "Paragraph",
|
|
13
9
|
fields: {
|
|
14
|
-
text:
|
|
15
|
-
type: "textarea" as const,
|
|
16
|
-
label: "Text",
|
|
17
|
-
contentEditable: true,
|
|
18
|
-
},
|
|
10
|
+
text: richtextField,
|
|
19
11
|
},
|
|
20
12
|
defaultProps: {
|
|
21
13
|
text: "Add your copy here. You can use multiple lines.",
|
|
@@ -24,25 +16,13 @@ export const paragraphBlock = {
|
|
|
24
16
|
if (isValidElement(text)) {
|
|
25
17
|
return <div className={blockPaddingClass}>{text}</div>;
|
|
26
18
|
}
|
|
27
|
-
const markdown = asMarkdownText(text);
|
|
28
19
|
return (
|
|
29
|
-
<div
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
href={href}
|
|
36
|
-
className="text-blue-700 underline decoration-blue-700/40 underline-offset-2 hover:decoration-blue-700"
|
|
37
|
-
>
|
|
38
|
-
{children}
|
|
39
|
-
</a>
|
|
40
|
-
),
|
|
41
|
-
}}
|
|
42
|
-
>
|
|
43
|
-
{markdown}
|
|
44
|
-
</ReactMarkdown>
|
|
45
|
-
</div>
|
|
20
|
+
<div
|
|
21
|
+
className={`${blockPaddingClass} prose max-w-prose`}
|
|
22
|
+
dangerouslySetInnerHTML={{
|
|
23
|
+
__html: typeof text === "string" ? sanitizeRichtextHtml(text) : "",
|
|
24
|
+
}}
|
|
25
|
+
/>
|
|
46
26
|
);
|
|
47
27
|
},
|
|
48
28
|
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import DOMPurify from "isomorphic-dompurify";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Sanitize richtext HTML before it is rendered via `dangerouslySetInnerHTML`.
|
|
5
|
+
*
|
|
6
|
+
* Blocks render editor-authored richtext as an HTML string on the public,
|
|
7
|
+
* server-rendered surface. This is defense-in-depth at the render boundary:
|
|
8
|
+
* it does not rely on the richtext editor's schema or on TipTap's default
|
|
9
|
+
* link-protocol allowlist to be the only thing standing between stored content
|
|
10
|
+
* and the DOM. The allowlist below matches what the richtext toolbar can
|
|
11
|
+
* actually produce (inline formatting + lists + links); anything else —
|
|
12
|
+
* `<script>`, `<img onerror>`, `javascript:`/`data:` hrefs — is stripped.
|
|
13
|
+
*
|
|
14
|
+
* Runs in both Node (SSR) and the browser via isomorphic-dompurify.
|
|
15
|
+
*/
|
|
16
|
+
const ALLOWED_TAGS = [
|
|
17
|
+
"p",
|
|
18
|
+
"br",
|
|
19
|
+
"strong",
|
|
20
|
+
"b",
|
|
21
|
+
"em",
|
|
22
|
+
"i",
|
|
23
|
+
"u",
|
|
24
|
+
"s",
|
|
25
|
+
"ul",
|
|
26
|
+
"ol",
|
|
27
|
+
"li",
|
|
28
|
+
"a",
|
|
29
|
+
"code",
|
|
30
|
+
"span",
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const ALLOWED_ATTR = ["href", "target", "rel"];
|
|
34
|
+
|
|
35
|
+
export function sanitizeRichtextHtml(html: string): string {
|
|
36
|
+
return DOMPurify.sanitize(html, {
|
|
37
|
+
ALLOWED_TAGS,
|
|
38
|
+
ALLOWED_ATTR,
|
|
39
|
+
// Explicit protocol allowlist (defense-in-depth over DOMPurify's default,
|
|
40
|
+
// which already rejects javascript:/unknown schemes): only safe link
|
|
41
|
+
// protocols, plus relative/anchor hrefs.
|
|
42
|
+
ALLOWED_URI_REGEXP: /^(?:https?:|mailto:|tel:|ftp:|#|\/|\.)/i,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { Metadata } from "next";
|
|
2
|
+
import type {
|
|
3
|
+
SeoMetadata,
|
|
4
|
+
getPage,
|
|
5
|
+
} from "@pantheon-systems/puck-css/server";
|
|
6
|
+
import {
|
|
7
|
+
loadRemoteDatasourceContext,
|
|
8
|
+
extractReferencedDatasourceIds,
|
|
9
|
+
listRouteTemplateKeysFromDatabase,
|
|
10
|
+
resolveStringTemplates,
|
|
11
|
+
} from "@pantheon-systems/puck-css/server";
|
|
12
|
+
import { REMOTE_DATASOURCE_FETCHERS } from "./remote-datasource-fetchers";
|
|
13
|
+
import { buildPageMetadata } from "./seo-metadata";
|
|
14
|
+
|
|
15
|
+
type PageData = Awaited<ReturnType<typeof getPage>>;
|
|
16
|
+
|
|
17
|
+
// Untitled pages carry the editor's defaultProps.title boilerplate
|
|
18
|
+
// (components/puck/root.tsx); never ship it as <title>/og:title.
|
|
19
|
+
const DEFAULT_EDITOR_TITLE = "My Puck Editor";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Produces the per-page <head> Metadata for a route (PCC-3407). Title and
|
|
23
|
+
* description are template-allowed properties.
|
|
24
|
+
*/
|
|
25
|
+
export async function resolvePageMetadata({
|
|
26
|
+
pageData,
|
|
27
|
+
path,
|
|
28
|
+
searchParams,
|
|
29
|
+
}: {
|
|
30
|
+
pageData: PageData;
|
|
31
|
+
path: string;
|
|
32
|
+
searchParams: Record<string, string | string[] | undefined>;
|
|
33
|
+
}): Promise<Metadata> {
|
|
34
|
+
const rootProps: Record<string, unknown> | undefined = pageData?.root.props;
|
|
35
|
+
const seo: Partial<SeoMetadata> | undefined = rootProps?._seo;
|
|
36
|
+
const rootTitle = rootProps?.title as string | undefined;
|
|
37
|
+
const rawTitle = rootTitle === DEFAULT_EDITOR_TITLE ? undefined : rootTitle;
|
|
38
|
+
const rawDescription = rootProps?.description as string | undefined;
|
|
39
|
+
|
|
40
|
+
const needsTemplates =
|
|
41
|
+
(typeof rawTitle === "string" && rawTitle.includes("{{")) ||
|
|
42
|
+
(typeof rawDescription === "string" && rawDescription.includes("{{"));
|
|
43
|
+
|
|
44
|
+
let title = rawTitle;
|
|
45
|
+
let description = rawDescription;
|
|
46
|
+
if (needsTemplates && pageData) {
|
|
47
|
+
const routeTemplateKeys = await listRouteTemplateKeysFromDatabase();
|
|
48
|
+
const referencedDatasourceIds = extractReferencedDatasourceIds(pageData);
|
|
49
|
+
const context = await loadRemoteDatasourceContext({
|
|
50
|
+
searchParams,
|
|
51
|
+
fetchImpl: fetch,
|
|
52
|
+
pagePath: path,
|
|
53
|
+
routeTemplateKeys,
|
|
54
|
+
builtinFetchers: REMOTE_DATASOURCE_FETCHERS,
|
|
55
|
+
referencedDatasourceIds,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const [resolvedTitle, resolvedDescription] = await Promise.all([
|
|
59
|
+
typeof rawTitle === "string"
|
|
60
|
+
? resolveStringTemplates(rawTitle, context)
|
|
61
|
+
: rawTitle,
|
|
62
|
+
typeof rawDescription === "string"
|
|
63
|
+
? resolveStringTemplates(rawDescription, context)
|
|
64
|
+
: rawDescription,
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
title = resolvedTitle;
|
|
68
|
+
description = resolvedDescription;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return buildPageMetadata({
|
|
72
|
+
seo: {
|
|
73
|
+
title,
|
|
74
|
+
description,
|
|
75
|
+
siteName: seo?.siteName,
|
|
76
|
+
},
|
|
77
|
+
path,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Metadata } from "next";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Head-side metadata inputs. Title, description, and canonical are derived
|
|
5
|
+
* client-side (root props, request path); only siteName arrives from the
|
|
6
|
+
* backend's SeoMetadata payload.
|
|
7
|
+
*/
|
|
8
|
+
export interface PageHeadMetadata {
|
|
9
|
+
title?: string;
|
|
10
|
+
description?: string;
|
|
11
|
+
canonicalUrl?: string;
|
|
12
|
+
siteName?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Maps head metadata to the page's <head> Metadata. Next replaces (not
|
|
17
|
+
* deep-merges) a page's openGraph over the layout's, so og:type and the env
|
|
18
|
+
* og:site_name fallback must be declared here. A relative canonical is emitted
|
|
19
|
+
* only when NEXT_PUBLIC_SITE_URL is configured to resolve it — otherwise Next
|
|
20
|
+
* would resolve it against a localhost default, and a wrong canonical is worse
|
|
21
|
+
* than none. An empty title is treated as absent.
|
|
22
|
+
*/
|
|
23
|
+
export function buildPageMetadata({
|
|
24
|
+
seo,
|
|
25
|
+
path,
|
|
26
|
+
}: {
|
|
27
|
+
seo?: PageHeadMetadata;
|
|
28
|
+
path: string;
|
|
29
|
+
}): Metadata {
|
|
30
|
+
const { description, canonicalUrl } = seo ?? {};
|
|
31
|
+
const title = seo?.title || undefined;
|
|
32
|
+
const siteName = seo?.siteName ?? process.env.NEXT_PUBLIC_SITE_NAME;
|
|
33
|
+
const canonical =
|
|
34
|
+
canonicalUrl ?? (process.env.NEXT_PUBLIC_SITE_URL ? path : undefined);
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
title,
|
|
38
|
+
description,
|
|
39
|
+
...(canonical ? { alternates: { canonical } } : {}),
|
|
40
|
+
openGraph: {
|
|
41
|
+
type: "website",
|
|
42
|
+
title,
|
|
43
|
+
description,
|
|
44
|
+
...(canonical ? { url: canonical } : {}),
|
|
45
|
+
...(siteName ? { siteName } : {}),
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
package/template/package.json
CHANGED
|
@@ -12,14 +12,17 @@
|
|
|
12
12
|
},
|
|
13
13
|
"dependencies": {
|
|
14
14
|
"@pantheon-systems/cpub-react-sdk": "^5.2.1",
|
|
15
|
-
"@pantheon-systems/css-client": "^0.
|
|
16
|
-
"@pantheon-systems/p1-ai-chat": "^0.1.
|
|
17
|
-
"@pantheon-systems/p1-
|
|
18
|
-
"@pantheon-systems/
|
|
19
|
-
"@pantheon-systems/
|
|
15
|
+
"@pantheon-systems/css-client": "^0.8.0",
|
|
16
|
+
"@pantheon-systems/p1-ai-chat": "^0.1.2",
|
|
17
|
+
"@pantheon-systems/p1-media": "^0.4.2",
|
|
18
|
+
"@pantheon-systems/p1-next-sdk": "^0.8.0",
|
|
19
|
+
"@pantheon-systems/pds-toolkit-react": "2.0.0-alpha.51",
|
|
20
|
+
"@pantheon-systems/puck-css": "^0.8.0",
|
|
20
21
|
"@puckeditor/core": "^0.21.1",
|
|
21
22
|
"@tailwindcss/postcss": "^4.2.2",
|
|
23
|
+
"@tailwindcss/typography": "^0.5.16",
|
|
22
24
|
"classnames": "^2.5.1",
|
|
25
|
+
"isomorphic-dompurify": "^3.18.0",
|
|
23
26
|
"launchdarkly-react-client-sdk": "^3.9.2",
|
|
24
27
|
"next": "^16.2.6",
|
|
25
28
|
"postcss": "^8.5.12",
|
|
@@ -32,6 +35,7 @@
|
|
|
32
35
|
"@types/node": "^20.19.30",
|
|
33
36
|
"@types/react": "^19.2.14",
|
|
34
37
|
"@types/react-dom": "^19.2.3",
|
|
38
|
+
"@vitejs/plugin-react": "^4.7.0",
|
|
35
39
|
"eslint": "^9.27.0",
|
|
36
40
|
"tsx": "^4.23.1",
|
|
37
41
|
"typescript": "^5.9.3",
|
package/template/puck.config.tsx
CHANGED
|
@@ -6,6 +6,7 @@ import { headingBlock } from "./components/puck/heading-block";
|
|
|
6
6
|
import { imageBlock } from "./components/puck/image-block";
|
|
7
7
|
import { gridBlock } from "./components/puck/grid-block";
|
|
8
8
|
import { listBlock } from "./components/puck/list-block";
|
|
9
|
+
import { mediaFigureBlock } from "./components/puck/media-figure-block";
|
|
9
10
|
import { paragraphBlock } from "./components/puck/paragraph-block";
|
|
10
11
|
import { quoteBlock } from "./components/puck/quote-block";
|
|
11
12
|
import { puckRoot } from "./components/puck/root";
|
|
@@ -20,7 +21,7 @@ export const config = {
|
|
|
20
21
|
},
|
|
21
22
|
media: {
|
|
22
23
|
title: "Media",
|
|
23
|
-
components: ["ImageBlock"],
|
|
24
|
+
components: ["ImageBlock", "MediaFigureBlock"],
|
|
24
25
|
},
|
|
25
26
|
data: {
|
|
26
27
|
title: "Data",
|
|
@@ -44,6 +45,7 @@ export const config = {
|
|
|
44
45
|
HeadingBlock: headingBlock,
|
|
45
46
|
ParagraphBlock: paragraphBlock,
|
|
46
47
|
ImageBlock: imageBlock,
|
|
48
|
+
MediaFigureBlock: mediaFigureBlock,
|
|
47
49
|
GridBlock: gridBlock,
|
|
48
50
|
QuoteBlock: quoteBlock,
|
|
49
51
|
ListBlock: listBlock,
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from "vitest";
|
|
2
|
-
import {
|
|
2
|
+
import { extractDescriptors } from "@pantheon-systems/puck-css/registry-sync";
|
|
3
|
+
import { resolve, load, ASSET_STUB_MARKER } from "../asset-stub-hooks.mjs";
|
|
4
|
+
import { filterAssetStubbedDescriptors } from "../sync-puck-registry.js";
|
|
3
5
|
|
|
4
6
|
describe("resolve", () => {
|
|
5
7
|
it("short-circuits CSS imports to an asset-stub URL without calling nextResolve", async () => {
|
|
@@ -43,13 +45,45 @@ describe("resolve", () => {
|
|
|
43
45
|
});
|
|
44
46
|
|
|
45
47
|
describe("load", () => {
|
|
46
|
-
it("returns
|
|
48
|
+
it("returns a branded stub default export for asset-stub URLs without calling nextLoad", async () => {
|
|
47
49
|
const nextLoad = vi.fn();
|
|
48
50
|
const result = await load("asset-stub:.%2Fstyles.css", {}, nextLoad);
|
|
49
|
-
expect(result).
|
|
51
|
+
expect(result.format).toBe("module");
|
|
52
|
+
expect(result.shortCircuit).toBe(true);
|
|
53
|
+
expect(result.source).toContain("__p1AssetStub");
|
|
50
54
|
expect(nextLoad).not.toHaveBeenCalled();
|
|
51
55
|
});
|
|
52
56
|
|
|
57
|
+
describe("branded sentinel", () => {
|
|
58
|
+
// The stub must be *recognizable* after import, so the CI sync can detect
|
|
59
|
+
// descriptors built from stubbed assets and skip them instead of writing
|
|
60
|
+
// content it cannot faithfully compute. A bare {} erases that provenance.
|
|
61
|
+
|
|
62
|
+
async function importStub(): Promise<Record<string, unknown>> {
|
|
63
|
+
const { source } = await load("asset-stub:.%2Flogo.png", {}, vi.fn());
|
|
64
|
+
const mod = (await import(
|
|
65
|
+
/* @vite-ignore */ `data:text/javascript,${encodeURIComponent(source as string)}`
|
|
66
|
+
)) as { default: Record<string, unknown> };
|
|
67
|
+
return mod.default;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
it("brands the default export with __p1AssetStub", async () => {
|
|
71
|
+
const stub = await importStub();
|
|
72
|
+
expect(stub.__p1AssetStub).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("returns the marker string for arbitrary property reads (placeholder.src pattern)", async () => {
|
|
76
|
+
const stub = await importStub();
|
|
77
|
+
expect(stub.src).toBe(ASSET_STUB_MARKER);
|
|
78
|
+
expect((stub as { anythingAtAll?: unknown }).anythingAtAll).toBe(ASSET_STUB_MARKER);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("stringifies to the marker so template-literal usage stays detectable", async () => {
|
|
82
|
+
const stub = await importStub();
|
|
83
|
+
expect(String(stub)).toContain(ASSET_STUB_MARKER);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
53
87
|
it("passes non-asset-stub URLs through to nextLoad unchanged", async () => {
|
|
54
88
|
const nextLoad = vi.fn().mockResolvedValue({ format: "module", source: "export default 1;", shortCircuit: true });
|
|
55
89
|
const result = await load("file:///abs/path.ts", {}, nextLoad);
|
|
@@ -62,3 +96,82 @@ describe("load", () => {
|
|
|
62
96
|
await expect(load("file:///abs/broken.ts", {}, nextLoad)).rejects.toThrow("Syntax error");
|
|
63
97
|
});
|
|
64
98
|
});
|
|
99
|
+
|
|
100
|
+
describe("integration: stubbed asset imports hash differently than bundler-resolved values", () => {
|
|
101
|
+
// The same puck.config.tsx yields different descriptor hashes depending on
|
|
102
|
+
// who loaded it — this loader stubs asset imports while the browser bundler
|
|
103
|
+
// resolves them to real values — so the CI sync and the editor perpetually
|
|
104
|
+
// disagree about whether an asset-bearing component "changed", and the
|
|
105
|
+
// CI-written descriptor content is missing the real default values entirely.
|
|
106
|
+
|
|
107
|
+
// Evaluate the module source load() actually emits — not a hand-written {} —
|
|
108
|
+
// so these tests track the real artifact if the stub's shape ever changes.
|
|
109
|
+
async function importStubbedAsset(): Promise<Record<string, unknown>> {
|
|
110
|
+
const { source } = await load("asset-stub:.%2Frandom-image.png", {}, vi.fn());
|
|
111
|
+
const mod = (await import(
|
|
112
|
+
/* @vite-ignore */ `data:text/javascript,${encodeURIComponent(source as string)}`
|
|
113
|
+
)) as { default: Record<string, unknown> };
|
|
114
|
+
return mod.default;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function configWithImageDefault(src: unknown) {
|
|
118
|
+
return {
|
|
119
|
+
components: {
|
|
120
|
+
imageBlock: {
|
|
121
|
+
label: "Image",
|
|
122
|
+
fields: { src: { type: "text", label: "Image URL" } },
|
|
123
|
+
defaultProps: { src },
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
it("`placeholder.src` (stub-derived under the loader) hashes differently than the browser's URL string", async () => {
|
|
130
|
+
const stub = await importStubbedAsset();
|
|
131
|
+
|
|
132
|
+
const [ciDescriptor] = extractDescriptors(configWithImageDefault(stub.src));
|
|
133
|
+
const [browserDescriptor] = extractDescriptors(
|
|
134
|
+
configWithImageDefault("/_next/static/media/random-image.abc123.png"),
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
expect(ciDescriptor.name).toBe(browserDescriptor.name);
|
|
138
|
+
expect(ciDescriptor.descriptorHash).not.toBe(browserDescriptor.descriptorHash);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("a whole stubbed import ({} under the stub) hashes differently than the browser's StaticImageData", async () => {
|
|
142
|
+
const stub = await importStubbedAsset();
|
|
143
|
+
|
|
144
|
+
const [ciDescriptor] = extractDescriptors(configWithImageDefault(stub));
|
|
145
|
+
const [browserDescriptor] = extractDescriptors(
|
|
146
|
+
configWithImageDefault({
|
|
147
|
+
src: "/_next/static/media/random-image.abc123.png",
|
|
148
|
+
width: 800,
|
|
149
|
+
height: 600,
|
|
150
|
+
}),
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
expect(ciDescriptor.descriptorHash).not.toBe(browserDescriptor.descriptorHash);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("control: a plain string default hashes identically no matter who loaded the config", async () => {
|
|
157
|
+
// Same shapes as above but with a value the stub loader never touches —
|
|
158
|
+
// proves the divergence is caused by asset stubbing, not by hashing noise.
|
|
159
|
+
const [first] = extractDescriptors(configWithImageDefault("/images/static-path.png"));
|
|
160
|
+
const [second] = extractDescriptors(configWithImageDefault("/images/static-path.png"));
|
|
161
|
+
|
|
162
|
+
expect(first.descriptorHash).toBe(second.descriptorHash);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("end to end: descriptors built from stubbed assets are detected and skipped, clean ones kept", async () => {
|
|
166
|
+
const stub = await importStubbedAsset();
|
|
167
|
+
|
|
168
|
+
const [viaPropertyRead] = extractDescriptors(configWithImageDefault(stub.src));
|
|
169
|
+
const [viaWholeImport] = extractDescriptors(configWithImageDefault(stub));
|
|
170
|
+
const [clean] = extractDescriptors(configWithImageDefault("/images/static-path.png"));
|
|
171
|
+
|
|
172
|
+
const { writable, skipped } = filterAssetStubbedDescriptors([viaPropertyRead, viaWholeImport, clean]);
|
|
173
|
+
|
|
174
|
+
expect(skipped).toHaveLength(2);
|
|
175
|
+
expect(writable).toEqual([clean]);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
validateEnv,
|
|
4
|
+
resolveConfigModule,
|
|
5
|
+
resolveBranchId,
|
|
6
|
+
filterAssetStubbedDescriptors,
|
|
7
|
+
NoBranchMatchError,
|
|
8
|
+
} from "../sync-puck-registry.js";
|
|
9
|
+
import { ASSET_STUB_MARKER } from "../asset-stub-hooks.mjs";
|
|
3
10
|
|
|
4
11
|
function baseEnv(overrides: Record<string, string | undefined> = {}): Record<string, string | undefined> {
|
|
5
12
|
return {
|
|
@@ -43,6 +50,19 @@ describe("validateEnv", () => {
|
|
|
43
50
|
expect(result.branchOverride).toBe("explicit");
|
|
44
51
|
});
|
|
45
52
|
|
|
53
|
+
it("reads CSS_DEFAULT_BRANCH into defaultBranchName, overriding the default", () => {
|
|
54
|
+
const result = validateEnv(baseEnv({ CSS_DEFAULT_BRANCH: "master" }));
|
|
55
|
+
expect(result.defaultBranchName).toBe("master");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("defaults defaultBranchName to 'main' when CSS_DEFAULT_BRANCH is not set", () => {
|
|
59
|
+
// Safe because the CSS main content branch is always literally named
|
|
60
|
+
// "main": a push override of "main" resolves to the same branch either
|
|
61
|
+
// by name match or by isMain, so the default only adds semantics.
|
|
62
|
+
const result = validateEnv(baseEnv());
|
|
63
|
+
expect(result.defaultBranchName).toBe("main");
|
|
64
|
+
});
|
|
65
|
+
|
|
46
66
|
it("defaults PUCK_CONFIG_PATH to puck.config.tsx", () => {
|
|
47
67
|
const result = validateEnv(baseEnv());
|
|
48
68
|
expect(result.puckConfigPath).toBe("puck.config.tsx");
|
|
@@ -122,3 +142,89 @@ describe("resolveBranchId", () => {
|
|
|
122
142
|
expect(() => resolveBranchId(branches as never, "site-123", "nonexistent")).toThrow(NoBranchMatchError);
|
|
123
143
|
});
|
|
124
144
|
});
|
|
145
|
+
|
|
146
|
+
describe("resolveBranchId default-branch semantics", () => {
|
|
147
|
+
// In CI the override is always the pushed git ref's name, so a repo whose
|
|
148
|
+
// default branch is not literally named "main" would never match the CSS
|
|
149
|
+
// main branch (whose name is always "main") — the sync silently skips on
|
|
150
|
+
// every default-branch push. When the caller also supplies the repo's
|
|
151
|
+
// default branch name, an override equal to it must resolve via isMain.
|
|
152
|
+
const branches = [
|
|
153
|
+
{ id: "b-1", siteId: "site-123", name: "main", isMain: true },
|
|
154
|
+
{ id: "b-2", siteId: "site-123", name: "staging", isMain: false },
|
|
155
|
+
];
|
|
156
|
+
|
|
157
|
+
it("resolves the isMain branch when the override is the repo's default branch name", () => {
|
|
158
|
+
expect(resolveBranchId(branches as never, "site-123", "master", "master")).toBe("b-1");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("prefers isMain over a coincidental name match for the default branch", () => {
|
|
162
|
+
const withDecoy = [...branches, { id: "b-3", siteId: "site-123", name: "master", isMain: false }];
|
|
163
|
+
expect(resolveBranchId(withDecoy as never, "site-123", "master", "master")).toBe("b-1");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("throws NoBranchMatchError for a default-branch override when no isMain branch exists", () => {
|
|
167
|
+
const noMain = [{ id: "b-2", siteId: "site-123", name: "staging", isMain: false }];
|
|
168
|
+
expect(() => resolveBranchId(noMain as never, "site-123", "master", "master")).toThrow(NoBranchMatchError);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("keeps plain name matching for overrides that are not the default branch", () => {
|
|
172
|
+
expect(resolveBranchId(branches as never, "site-123", "staging", "master")).toBe("b-2");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("keeps the silent-skip path for non-default refs that match nothing", () => {
|
|
176
|
+
expect(() => resolveBranchId(branches as never, "site-123", "feature-x", "master")).toThrow(NoBranchMatchError);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe("filterAssetStubbedDescriptors", () => {
|
|
181
|
+
// CI loads puck.config.tsx under the asset-stub loader, so any defaultProps
|
|
182
|
+
// value derived from an asset import is a branded sentinel, not the real
|
|
183
|
+
// bundler-resolved value. CI cannot faithfully describe those components —
|
|
184
|
+
// it skips them (loudly) and leaves them to the editor path.
|
|
185
|
+
|
|
186
|
+
const descriptor = (name: string, defaultProps: Record<string, unknown>) =>
|
|
187
|
+
({ name, label: name, fields: [], defaultProps, descriptorHash: "h" }) as never;
|
|
188
|
+
|
|
189
|
+
it("keeps descriptors whose defaults are plain values", () => {
|
|
190
|
+
const clean = descriptor("heroBlock", { title: "Hello", count: 3, nested: { a: [1, "x"] } });
|
|
191
|
+
const { writable, skipped } = filterAssetStubbedDescriptors([clean]);
|
|
192
|
+
expect(writable).toEqual([clean]);
|
|
193
|
+
expect(skipped).toEqual([]);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("skips a descriptor whose default carries the asset-stub marker string (placeholder.src pattern)", () => {
|
|
197
|
+
const stubbed = descriptor("imageBlock", { src: ASSET_STUB_MARKER, alt: "Mountain" });
|
|
198
|
+
const { writable, skipped } = filterAssetStubbedDescriptors([stubbed]);
|
|
199
|
+
expect(writable).toEqual([]);
|
|
200
|
+
expect(skipped.map((d: { name: string }) => d.name)).toEqual(["imageBlock"]);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("skips a descriptor whose default is a branded stub object (whole-import pattern)", () => {
|
|
204
|
+
const stubbed = descriptor("imageBlock", { src: { __p1AssetStub: true } });
|
|
205
|
+
const { skipped } = filterAssetStubbedDescriptors([stubbed]);
|
|
206
|
+
expect(skipped).toHaveLength(1);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("detects the marker arbitrarily deep in defaultProps", () => {
|
|
210
|
+
const stubbed = descriptor("gallery", { items: [{ media: { src: `prefix ${ASSET_STUB_MARKER}` } }] });
|
|
211
|
+
const { skipped } = filterAssetStubbedDescriptors([stubbed]);
|
|
212
|
+
expect(skipped).toHaveLength(1);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("partitions a mixed list preserving order of writable descriptors", () => {
|
|
216
|
+
const a = descriptor("a", { t: "1" });
|
|
217
|
+
const b = descriptor("b", { src: ASSET_STUB_MARKER });
|
|
218
|
+
const c = descriptor("c", { t: "2" });
|
|
219
|
+
const { writable, skipped } = filterAssetStubbedDescriptors([a, b, c]);
|
|
220
|
+
expect(writable.map((d: { name: string }) => d.name)).toEqual(["a", "c"]);
|
|
221
|
+
expect(skipped.map((d: { name: string }) => d.name)).toEqual(["b"]);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("does not hang on circular defaultProps", () => {
|
|
225
|
+
const circular: Record<string, unknown> = { title: "ok" };
|
|
226
|
+
circular.self = circular;
|
|
227
|
+
const { skipped } = filterAssetStubbedDescriptors([descriptor("looper", circular)]);
|
|
228
|
+
expect(skipped).toEqual([]);
|
|
229
|
+
});
|
|
230
|
+
});
|