@caelo-cms/shared 0.10.22 → 0.10.23
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/dist/ai-tools.d.ts +290 -214
- package/dist/ai-tools.d.ts.map +1 -1
- package/dist/ai-tools.js +341 -264
- package/dist/ai-tools.js.map +1 -1
- package/dist/auth-forms.d.ts.map +1 -1
- package/dist/auth-forms.js +4 -1
- package/dist/auth-forms.js.map +1 -1
- package/dist/base-css.d.ts +24 -0
- package/dist/base-css.d.ts.map +1 -0
- package/dist/base-css.js +28 -0
- package/dist/base-css.js.map +1 -0
- package/dist/build-page.d.ts +330 -0
- package/dist/build-page.d.ts.map +1 -0
- package/dist/build-page.js +282 -0
- package/dist/build-page.js.map +1 -0
- package/dist/content.d.ts +322 -9
- package/dist/content.d.ts.map +1 -1
- package/dist/content.js +354 -11
- package/dist/content.js.map +1 -1
- package/dist/css-gradient-scan.d.ts +14 -0
- package/dist/css-gradient-scan.d.ts.map +1 -0
- package/dist/css-gradient-scan.js +81 -0
- package/dist/css-gradient-scan.js.map +1 -0
- package/dist/css-var-scan.d.ts +56 -0
- package/dist/css-var-scan.d.ts.map +1 -0
- package/dist/css-var-scan.js +97 -0
- package/dist/css-var-scan.js.map +1 -0
- package/dist/design-manifest.d.ts +36 -0
- package/dist/design-manifest.d.ts.map +1 -0
- package/dist/design-manifest.js +90 -0
- package/dist/design-manifest.js.map +1 -0
- package/dist/fonts.d.ts +89 -0
- package/dist/fonts.d.ts.map +1 -0
- package/dist/fonts.js +241 -0
- package/dist/fonts.js.map +1 -0
- package/dist/genesis-inventory.d.ts +32 -0
- package/dist/genesis-inventory.d.ts.map +1 -0
- package/dist/genesis-inventory.js +186 -0
- package/dist/genesis-inventory.js.map +1 -0
- package/dist/genesis.d.ts +62 -0
- package/dist/genesis.d.ts.map +1 -0
- package/dist/genesis.js +78 -0
- package/dist/genesis.js.map +1 -0
- package/dist/i18n.d.ts +44 -1
- package/dist/i18n.d.ts.map +1 -1
- package/dist/i18n.js +72 -6
- package/dist/i18n.js.map +1 -1
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +27 -0
- package/dist/index.js.map +1 -1
- package/dist/interactions.d.ts +23 -0
- package/dist/interactions.d.ts.map +1 -0
- package/dist/interactions.js +44 -0
- package/dist/interactions.js.map +1 -0
- package/dist/media.d.ts +101 -16
- package/dist/media.d.ts.map +1 -1
- package/dist/media.js +126 -15
- package/dist/media.js.map +1 -1
- package/dist/page-log.d.ts +94 -0
- package/dist/page-log.d.ts.map +1 -0
- package/dist/page-log.js +111 -0
- package/dist/page-log.js.map +1 -0
- package/dist/preview-compose.d.ts +79 -0
- package/dist/preview-compose.d.ts.map +1 -1
- package/dist/preview-compose.js +155 -25
- package/dist/preview-compose.js.map +1 -1
- package/dist/proposal-status.d.ts +40 -0
- package/dist/proposal-status.d.ts.map +1 -0
- package/dist/proposal-status.js +34 -0
- package/dist/proposal-status.js.map +1 -0
- package/dist/responsive-images.d.ts +64 -0
- package/dist/responsive-images.d.ts.map +1 -0
- package/dist/responsive-images.js +98 -0
- package/dist/responsive-images.js.map +1 -0
- package/dist/safe-keys.d.ts +9 -0
- package/dist/safe-keys.d.ts.map +1 -0
- package/dist/safe-keys.js +20 -0
- package/dist/safe-keys.js.map +1 -0
- package/dist/seo.d.ts +8 -0
- package/dist/seo.d.ts.map +1 -1
- package/dist/seo.js +3 -1
- package/dist/seo.js.map +1 -1
- package/dist/skills.d.ts +14 -68
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +19 -113
- package/dist/skills.js.map +1 -1
- package/dist/strip-cdata.d.ts +7 -0
- package/dist/strip-cdata.d.ts.map +1 -0
- package/dist/strip-cdata.js +48 -0
- package/dist/strip-cdata.js.map +1 -0
- package/dist/structured-sets.d.ts +6 -16
- package/dist/structured-sets.d.ts.map +1 -1
- package/dist/structured-sets.js +5 -16
- package/dist/structured-sets.js.map +1 -1
- package/dist/subagents.d.ts +105 -3
- package/dist/subagents.d.ts.map +1 -1
- package/dist/subagents.js +224 -41
- package/dist/subagents.js.map +1 -1
- package/dist/template-engine.d.ts +85 -0
- package/dist/template-engine.d.ts.map +1 -0
- package/dist/template-engine.js +403 -0
- package/dist/template-engine.js.map +1 -0
- package/dist/theme-importers/auto-detect.d.ts +26 -0
- package/dist/theme-importers/auto-detect.d.ts.map +1 -0
- package/dist/theme-importers/auto-detect.js +42 -0
- package/dist/theme-importers/auto-detect.js.map +1 -0
- package/dist/theme-importers/css-comments.d.ts +12 -0
- package/dist/theme-importers/css-comments.d.ts.map +1 -0
- package/dist/theme-importers/css-comments.js +15 -0
- package/dist/theme-importers/css-comments.js.map +1 -0
- package/dist/theme-importers/dtcg.d.ts +46 -0
- package/dist/theme-importers/dtcg.d.ts.map +1 -0
- package/dist/theme-importers/dtcg.js +111 -0
- package/dist/theme-importers/dtcg.js.map +1 -0
- package/dist/theme-importers/loose.d.ts +3 -0
- package/dist/theme-importers/loose.d.ts.map +1 -0
- package/dist/theme-importers/loose.js +76 -0
- package/dist/theme-importers/loose.js.map +1 -0
- package/dist/theme-importers/shadcn.d.ts +24 -0
- package/dist/theme-importers/shadcn.d.ts.map +1 -0
- package/dist/theme-importers/shadcn.js +135 -0
- package/dist/theme-importers/shadcn.js.map +1 -0
- package/dist/theme-importers/style-dictionary.d.ts +17 -0
- package/dist/theme-importers/style-dictionary.d.ts.map +1 -0
- package/dist/theme-importers/style-dictionary.js +125 -0
- package/dist/theme-importers/style-dictionary.js.map +1 -0
- package/dist/theme-importers/tailwind.d.ts +3 -0
- package/dist/theme-importers/tailwind.d.ts.map +1 -0
- package/dist/theme-importers/tailwind.js +218 -0
- package/dist/theme-importers/tailwind.js.map +1 -0
- package/dist/theme-literal-binding.d.ts +37 -0
- package/dist/theme-literal-binding.d.ts.map +1 -0
- package/dist/theme-literal-binding.js +138 -0
- package/dist/theme-literal-binding.js.map +1 -0
- package/dist/theme-normalize.d.ts +31 -0
- package/dist/theme-normalize.d.ts.map +1 -0
- package/dist/theme-normalize.js +587 -0
- package/dist/theme-normalize.js.map +1 -0
- package/dist/theme-ramp.d.ts +55 -0
- package/dist/theme-ramp.d.ts.map +1 -0
- package/dist/theme-ramp.js +149 -0
- package/dist/theme-ramp.js.map +1 -0
- package/dist/theme-render.d.ts +105 -0
- package/dist/theme-render.d.ts.map +1 -0
- package/dist/theme-render.js +441 -0
- package/dist/theme-render.js.map +1 -0
- package/dist/themes-errors.d.ts +109 -0
- package/dist/themes-errors.d.ts.map +1 -0
- package/dist/themes-errors.js +170 -0
- package/dist/themes-errors.js.map +1 -0
- package/dist/themes.d.ts +343 -0
- package/dist/themes.d.ts.map +1 -0
- package/dist/themes.js +697 -0
- package/dist/themes.js.map +1 -0
- package/dist/version.d.ts +7 -4
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +6 -3
- package/dist/version.js.map +1 -1
- package/package.json +10 -2
- package/src/__tests__/redos-hardening.test.ts +160 -0
- package/src/ai-tools-add-module-modes.test.ts +106 -0
- package/src/ai-tools-position.test.ts +134 -0
- package/src/ai-tools.test.ts +81 -0
- package/src/ai-tools.ts +1179 -0
- package/src/auth-forms.ts +36 -0
- package/src/base-css.ts +30 -0
- package/src/build-page.test.ts +228 -0
- package/src/build-page.ts +319 -0
- package/src/cap-failures.ts +67 -0
- package/src/content.test.ts +170 -0
- package/src/content.ts +620 -0
- package/src/context.ts +43 -0
- package/src/css-gradient-scan.ts +88 -0
- package/src/css-var-scan.test.ts +96 -0
- package/src/css-var-scan.ts +144 -0
- package/src/derive-module-type.test.ts +80 -0
- package/src/design-manifest.ts +93 -0
- package/src/fonts.test.ts +157 -0
- package/src/fonts.ts +296 -0
- package/src/genesis-inventory.test.ts +86 -0
- package/src/genesis-inventory.ts +215 -0
- package/src/genesis-sanitize.test.ts +35 -0
- package/src/genesis.ts +87 -0
- package/src/i18n.test.ts +274 -0
- package/src/i18n.ts +269 -0
- package/src/index.test.ts +10 -0
- package/src/index.ts +59 -0
- package/src/interactions.ts +48 -0
- package/src/logger.ts +147 -0
- package/src/media.test.ts +160 -0
- package/src/media.ts +355 -0
- package/src/page-log.test.ts +163 -0
- package/src/page-log.ts +124 -0
- package/src/preview-compose.test.ts +602 -0
- package/src/preview-compose.ts +709 -0
- package/src/preview-scanner.test.ts +96 -0
- package/src/preview-scanner.ts +214 -0
- package/src/proposal-status.test.ts +69 -0
- package/src/proposal-status.ts +40 -0
- package/src/responsive-images.test.ts +104 -0
- package/src/responsive-images.ts +151 -0
- package/src/result.ts +29 -0
- package/src/safe-keys.ts +21 -0
- package/src/seo.test.ts +234 -0
- package/src/seo.ts +261 -0
- package/src/skills.ts +48 -0
- package/src/snapshots.test.ts +80 -0
- package/src/snapshots.ts +81 -0
- package/src/strip-cdata.test.ts +41 -0
- package/src/strip-cdata.ts +50 -0
- package/src/structured-sets.ts +180 -0
- package/src/subagents.test.ts +262 -0
- package/src/subagents.ts +432 -0
- package/src/template-engine.test.ts +379 -0
- package/src/template-engine.ts +520 -0
- package/src/theme-gradient.test.ts +92 -0
- package/src/theme-importers/__tests__/proto-pollution.test.ts +54 -0
- package/src/theme-importers/auto-detect.ts +84 -0
- package/src/theme-importers/css-comments.ts +15 -0
- package/src/theme-importers/dtcg.ts +106 -0
- package/src/theme-importers/loose.ts +76 -0
- package/src/theme-importers/shadcn.ts +133 -0
- package/src/theme-importers/style-dictionary.ts +125 -0
- package/src/theme-importers/tailwind.ts +217 -0
- package/src/theme-literal-binding.test.ts +71 -0
- package/src/theme-literal-binding.ts +159 -0
- package/src/theme-motion.test.ts +115 -0
- package/src/theme-normalize-envelope.test.ts +43 -0
- package/src/theme-normalize-gradient.test.ts +135 -0
- package/src/theme-normalize.ts +661 -0
- package/src/theme-ramp.ts +187 -0
- package/src/theme-render-sanitize.test.ts +45 -0
- package/src/theme-render.test.ts +119 -0
- package/src/theme-render.ts +487 -0
- package/src/theme-shadow.test.ts +56 -0
- package/src/themes-errors.ts +199 -0
- package/src/themes.ts +842 -0
- package/src/translation.test.ts +160 -0
- package/src/translation.ts +295 -0
- package/src/version.ts +66 -0
package/src/result.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Discriminated-union Result type. Used at the Query API boundary where errors are
|
|
5
|
+
* expected (validation, RLS denials, unknown operations) and throwing is reserved
|
|
6
|
+
* for genuinely exceptional conditions (lost DB connection, logic bug).
|
|
7
|
+
*
|
|
8
|
+
* Per CLAUDE.md §4: "errors are values where it matters".
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type Ok<T> = { readonly ok: true; readonly value: T };
|
|
12
|
+
export type Err<E> = { readonly ok: false; readonly error: E };
|
|
13
|
+
export type Result<T, E> = Ok<T> | Err<E>;
|
|
14
|
+
|
|
15
|
+
export function ok<T>(value: T): Ok<T> {
|
|
16
|
+
return { ok: true, value };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function err<E>(error: E): Err<E> {
|
|
20
|
+
return { ok: false, error };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function isOk<T, E>(result: Result<T, E>): result is Ok<T> {
|
|
24
|
+
return result.ok;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function isErr<T, E>(result: Result<T, E>): result is Err<E> {
|
|
28
|
+
return !result.ok;
|
|
29
|
+
}
|
package/src/safe-keys.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Keys that, when used as a dynamic property name on a plain object,
|
|
5
|
+
* mutate the prototype chain instead of the object's own data — the
|
|
6
|
+
* prototype-pollution vector. Any code that assigns into an object with
|
|
7
|
+
* a key derived from external input (imported CSS variable names, dotted
|
|
8
|
+
* write paths from forms/AI) must reject these first.
|
|
9
|
+
*/
|
|
10
|
+
const UNSAFE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* True when `key` would pollute the prototype chain if used as a dynamic
|
|
14
|
+
* assignment target (`obj[key] = …`). Callers skip or reject such keys.
|
|
15
|
+
*
|
|
16
|
+
* @param key The candidate property name (e.g. a parsed token name or a
|
|
17
|
+
* path segment).
|
|
18
|
+
*/
|
|
19
|
+
export function isUnsafeKey(key: string): boolean {
|
|
20
|
+
return UNSAFE_KEYS.has(key);
|
|
21
|
+
}
|
package/src/seo.test.ts
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "bun:test";
|
|
4
|
+
import {
|
|
5
|
+
injectSeoIntoHead,
|
|
6
|
+
renderSeoHead,
|
|
7
|
+
resolveCanonicalUrl,
|
|
8
|
+
seoAutofillInputSchema,
|
|
9
|
+
seoOptimizeInputSchema,
|
|
10
|
+
seoSetInputSchema,
|
|
11
|
+
siteDefaultsSetSeoInputSchema,
|
|
12
|
+
} from "./seo.js";
|
|
13
|
+
|
|
14
|
+
describe("resolveCanonicalUrl", () => {
|
|
15
|
+
it("uses the explicit override when provided", () => {
|
|
16
|
+
expect(
|
|
17
|
+
resolveCanonicalUrl({
|
|
18
|
+
siteBaseUrl: "https://example.com",
|
|
19
|
+
pageSlug: "anything",
|
|
20
|
+
pageLocale: "en",
|
|
21
|
+
override: "https://canonical.example.com/x",
|
|
22
|
+
}),
|
|
23
|
+
).toBe("https://canonical.example.com/x");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("renders home as the root path", () => {
|
|
27
|
+
expect(
|
|
28
|
+
resolveCanonicalUrl({
|
|
29
|
+
siteBaseUrl: "https://example.com",
|
|
30
|
+
pageSlug: "home",
|
|
31
|
+
pageLocale: "en",
|
|
32
|
+
override: null,
|
|
33
|
+
}),
|
|
34
|
+
).toBe("https://example.com/");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("trims trailing slash on the base URL", () => {
|
|
38
|
+
expect(
|
|
39
|
+
resolveCanonicalUrl({
|
|
40
|
+
siteBaseUrl: "https://example.com/",
|
|
41
|
+
pageSlug: "about",
|
|
42
|
+
pageLocale: "en",
|
|
43
|
+
override: null,
|
|
44
|
+
}),
|
|
45
|
+
).toBe("https://example.com/about/");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("v0.2.85 — pageUrlStyle='no-extension'", () => {
|
|
49
|
+
it("omits the trailing slash for non-home pages", () => {
|
|
50
|
+
expect(
|
|
51
|
+
resolveCanonicalUrl({
|
|
52
|
+
siteBaseUrl: "https://example.com",
|
|
53
|
+
pageSlug: "about",
|
|
54
|
+
pageLocale: "en",
|
|
55
|
+
override: null,
|
|
56
|
+
pageUrlStyle: "no-extension",
|
|
57
|
+
}),
|
|
58
|
+
).toBe("https://example.com/about");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("keeps the root URL for the home page", () => {
|
|
62
|
+
expect(
|
|
63
|
+
resolveCanonicalUrl({
|
|
64
|
+
siteBaseUrl: "https://example.com",
|
|
65
|
+
pageSlug: "home",
|
|
66
|
+
pageLocale: "en",
|
|
67
|
+
override: null,
|
|
68
|
+
pageUrlStyle: "no-extension",
|
|
69
|
+
}),
|
|
70
|
+
).toBe("https://example.com/");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("subdirectory locale strategy keeps the locale prefix, no trailing slash on slug", () => {
|
|
74
|
+
expect(
|
|
75
|
+
resolveCanonicalUrl({
|
|
76
|
+
siteBaseUrl: "https://example.com",
|
|
77
|
+
pageSlug: "about",
|
|
78
|
+
pageLocale: "de",
|
|
79
|
+
override: null,
|
|
80
|
+
pageUrlStyle: "no-extension",
|
|
81
|
+
localeConfig: {
|
|
82
|
+
code: "de",
|
|
83
|
+
urlStrategy: "subdirectory",
|
|
84
|
+
urlHost: null,
|
|
85
|
+
isDefault: false,
|
|
86
|
+
},
|
|
87
|
+
}),
|
|
88
|
+
).toBe("https://example.com/de/about");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("default style preserves pre-v0.2.85 trailing-slash behavior", () => {
|
|
92
|
+
expect(
|
|
93
|
+
resolveCanonicalUrl({
|
|
94
|
+
siteBaseUrl: "https://example.com",
|
|
95
|
+
pageSlug: "about",
|
|
96
|
+
pageLocale: "en",
|
|
97
|
+
override: null,
|
|
98
|
+
}),
|
|
99
|
+
).toBe("https://example.com/about/");
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("renderSeoHead", () => {
|
|
105
|
+
const base = {
|
|
106
|
+
title: "Welcome",
|
|
107
|
+
metaDescription: "A sample description.",
|
|
108
|
+
canonical: "https://example.com/",
|
|
109
|
+
noindex: false,
|
|
110
|
+
ogImageUrl: null,
|
|
111
|
+
hreflang: [] as { locale: string; url: string }[],
|
|
112
|
+
organization: {},
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
it("emits canonical + og:type + og:url for the simplest valid input", () => {
|
|
116
|
+
const head = renderSeoHead(base);
|
|
117
|
+
expect(head).toContain("<title>Welcome</title>");
|
|
118
|
+
expect(head).toContain('<meta name="description" content="A sample description." />');
|
|
119
|
+
expect(head).toContain('<link rel="canonical" href="https://example.com/" />');
|
|
120
|
+
expect(head).toContain('<meta property="og:type" content="website" />');
|
|
121
|
+
expect(head).toContain('<meta property="og:url" content="https://example.com/" />');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("emits noindex meta only when set", () => {
|
|
125
|
+
expect(renderSeoHead(base)).not.toContain('content="noindex"');
|
|
126
|
+
expect(renderSeoHead({ ...base, noindex: true })).toContain(
|
|
127
|
+
'<meta name="robots" content="noindex" />',
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("emits og:image and twitter summary_large_image when provided", () => {
|
|
132
|
+
const head = renderSeoHead({ ...base, ogImageUrl: "https://example.com/_assets/x/orig.png" });
|
|
133
|
+
expect(head).toContain(
|
|
134
|
+
'<meta property="og:image" content="https://example.com/_assets/x/orig.png" />',
|
|
135
|
+
);
|
|
136
|
+
expect(head).toContain('<meta name="twitter:card" content="summary_large_image" />');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("emits hreflang per row + x-default when any rows are present", () => {
|
|
140
|
+
const head = renderSeoHead({
|
|
141
|
+
...base,
|
|
142
|
+
hreflang: [
|
|
143
|
+
{ locale: "de", url: "https://example.com/de/" },
|
|
144
|
+
{ locale: "fr", url: "https://example.com/fr/" },
|
|
145
|
+
],
|
|
146
|
+
});
|
|
147
|
+
expect(head).toContain('<link rel="alternate" hreflang="de" href="https://example.com/de/" />');
|
|
148
|
+
expect(head).toContain('<link rel="alternate" hreflang="fr" href="https://example.com/fr/" />');
|
|
149
|
+
expect(head).toContain(
|
|
150
|
+
'<link rel="alternate" hreflang="x-default" href="https://example.com/" />',
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("emits a JSON-LD WebPage block; encodes the < character to avoid script-tag breaks", () => {
|
|
155
|
+
const head = renderSeoHead({
|
|
156
|
+
...base,
|
|
157
|
+
title: "Tag <foo>",
|
|
158
|
+
organization: { name: "Caelo Inc.", url: "https://caelo.example" },
|
|
159
|
+
});
|
|
160
|
+
expect(head).toContain('<script type="application/ld+json">');
|
|
161
|
+
expect(head).not.toContain("<foo>"); // angle bracket inside script ld+json should be escaped
|
|
162
|
+
expect(head).toContain('"publisher"');
|
|
163
|
+
expect(head).toContain('"Caelo Inc."');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("HTML-encodes attribute values to block injection", () => {
|
|
167
|
+
const head = renderSeoHead({
|
|
168
|
+
...base,
|
|
169
|
+
title: 'Quote "test"',
|
|
170
|
+
metaDescription: "<script>alert(1)</script>",
|
|
171
|
+
});
|
|
172
|
+
expect(head).toContain('content="Quote "test""');
|
|
173
|
+
expect(head).toContain("<script>alert(1)</script>");
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe("injectSeoIntoHead", () => {
|
|
178
|
+
it("strips a layout-supplied <title> and injects the head block before </head>", () => {
|
|
179
|
+
const html = "<html><head><title>OldTitle</title></head><body>x</body></html>";
|
|
180
|
+
const out = injectSeoIntoHead(html, "<title>NewTitle</title>");
|
|
181
|
+
expect(out).not.toContain("OldTitle");
|
|
182
|
+
expect(out).toContain("NewTitle");
|
|
183
|
+
expect(out.indexOf("NewTitle")).toBeLessThan(out.indexOf("</head>"));
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("falls back to prepending when the document has no </head>", () => {
|
|
187
|
+
expect(injectSeoIntoHead("<body>x</body>", "<title>X</title>")).toContain("<title>X</title>");
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe("schemas", () => {
|
|
192
|
+
it("seoAutofillInputSchema rejects empty meta description", () => {
|
|
193
|
+
const r = seoAutofillInputSchema.safeParse({
|
|
194
|
+
pageId: "11111111-1111-4111-8111-111111111111",
|
|
195
|
+
metaDescription: "",
|
|
196
|
+
});
|
|
197
|
+
expect(r.success).toBe(false);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("seoOptimizeInputSchema accepts context near the cap", () => {
|
|
201
|
+
const r = seoOptimizeInputSchema.safeParse({
|
|
202
|
+
pageId: "11111111-1111-4111-8111-111111111111",
|
|
203
|
+
metaDescription: "A description.",
|
|
204
|
+
context: "x".repeat(3500),
|
|
205
|
+
});
|
|
206
|
+
expect(r.success).toBe(true);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("seoOptimizeInputSchema rejects context above the cap", () => {
|
|
210
|
+
const r = seoOptimizeInputSchema.safeParse({
|
|
211
|
+
pageId: "11111111-1111-4111-8111-111111111111",
|
|
212
|
+
metaDescription: "A description.",
|
|
213
|
+
context: "x".repeat(4001),
|
|
214
|
+
});
|
|
215
|
+
expect(r.success).toBe(false);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("seoSetInputSchema rejects priority above 1", () => {
|
|
219
|
+
const r = seoSetInputSchema.safeParse({
|
|
220
|
+
pageId: "11111111-1111-4111-8111-111111111111",
|
|
221
|
+
priority: 1.1,
|
|
222
|
+
});
|
|
223
|
+
expect(r.success).toBe(false);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("siteDefaultsSetSeoInputSchema rejects non-URL siteBaseUrl", () => {
|
|
227
|
+
const r = siteDefaultsSetSeoInputSchema.safeParse({
|
|
228
|
+
siteBaseUrl: "not a url",
|
|
229
|
+
sitemapEnabled: true,
|
|
230
|
+
organizationJson: {},
|
|
231
|
+
});
|
|
232
|
+
expect(r.success).toBe(false);
|
|
233
|
+
});
|
|
234
|
+
});
|
package/src/seo.ts
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Phase 8 — SEO primitives. Per-page SEO is structured fields only;
|
|
5
|
+
* the renderer projects them into <head> meta + canonical + JSON-LD.
|
|
6
|
+
* Per CLAUDE.md §2 "no raw HTML into <head>" — every Zod schema below
|
|
7
|
+
* is on `.strict()` so the AI cannot smuggle additional keys.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import { isHomeSlug } from "./i18n.js";
|
|
12
|
+
|
|
13
|
+
export const CHANGEFREQ_VALUES = [
|
|
14
|
+
"always",
|
|
15
|
+
"hourly",
|
|
16
|
+
"daily",
|
|
17
|
+
"weekly",
|
|
18
|
+
"monthly",
|
|
19
|
+
"yearly",
|
|
20
|
+
"never",
|
|
21
|
+
] as const;
|
|
22
|
+
export type Changefreq = (typeof CHANGEFREQ_VALUES)[number];
|
|
23
|
+
|
|
24
|
+
/** Recommended length caps — server enforces, client trims with feedback. */
|
|
25
|
+
export const SEO_TITLE_RECOMMENDED_MAX = 60;
|
|
26
|
+
export const SEO_DESCRIPTION_RECOMMENDED_MAX = 160;
|
|
27
|
+
|
|
28
|
+
export const SEO_DESCRIPTION_HARD_MAX = 320;
|
|
29
|
+
export const SEO_CANONICAL_MAX = 2048;
|
|
30
|
+
|
|
31
|
+
export const seoSetInputSchema = z
|
|
32
|
+
.object({
|
|
33
|
+
pageId: z.string().uuid(),
|
|
34
|
+
metaDescription: z.string().max(SEO_DESCRIPTION_HARD_MAX).optional(),
|
|
35
|
+
ogImageAssetId: z.string().uuid().nullable().optional(),
|
|
36
|
+
canonicalUrl: z.string().max(SEO_CANONICAL_MAX).nullable().optional(),
|
|
37
|
+
noindex: z.boolean().optional(),
|
|
38
|
+
changefreq: z.enum(CHANGEFREQ_VALUES).optional(),
|
|
39
|
+
priority: z.number().min(0).max(1).optional(),
|
|
40
|
+
})
|
|
41
|
+
.strict();
|
|
42
|
+
export type SeoSetInput = z.infer<typeof seoSetInputSchema>;
|
|
43
|
+
|
|
44
|
+
export const seoAutofillInputSchema = z
|
|
45
|
+
.object({
|
|
46
|
+
pageId: z.string().uuid(),
|
|
47
|
+
metaDescription: z.string().min(1).max(SEO_DESCRIPTION_HARD_MAX),
|
|
48
|
+
ogImageAssetId: z.string().uuid().nullable().optional(),
|
|
49
|
+
})
|
|
50
|
+
.strict();
|
|
51
|
+
export type SeoAutofillInput = z.infer<typeof seoAutofillInputSchema>;
|
|
52
|
+
|
|
53
|
+
export const seoOptimizeInputSchema = z
|
|
54
|
+
.object({
|
|
55
|
+
pageId: z.string().uuid(),
|
|
56
|
+
metaDescription: z.string().min(1).max(SEO_DESCRIPTION_HARD_MAX),
|
|
57
|
+
ogImageAssetId: z.string().uuid().nullable().optional(),
|
|
58
|
+
/** Optional user-supplied context (keyword research, intent shifts) recorded in audit. */
|
|
59
|
+
context: z.string().max(4000).optional(),
|
|
60
|
+
})
|
|
61
|
+
.strict();
|
|
62
|
+
export type SeoOptimizeInput = z.infer<typeof seoOptimizeInputSchema>;
|
|
63
|
+
|
|
64
|
+
export const siteDefaultsSetSeoInputSchema = z
|
|
65
|
+
.object({
|
|
66
|
+
siteBaseUrl: z
|
|
67
|
+
.string()
|
|
68
|
+
.min(1)
|
|
69
|
+
.max(2048)
|
|
70
|
+
.url("siteBaseUrl must be an absolute URL (https://example.com)"),
|
|
71
|
+
sitemapEnabled: z.boolean(),
|
|
72
|
+
organizationJson: z
|
|
73
|
+
.object({
|
|
74
|
+
name: z.string().max(256).optional(),
|
|
75
|
+
url: z.string().max(2048).optional(),
|
|
76
|
+
logo: z.string().max(2048).optional(),
|
|
77
|
+
sameAs: z.array(z.string().max(2048)).max(20).optional(),
|
|
78
|
+
})
|
|
79
|
+
.strict()
|
|
80
|
+
.default({}),
|
|
81
|
+
})
|
|
82
|
+
.strict();
|
|
83
|
+
export type SiteDefaultsSetSeoInput = z.infer<typeof siteDefaultsSetSeoInputSchema>;
|
|
84
|
+
|
|
85
|
+
export interface PageSeoRow {
|
|
86
|
+
pageId: string;
|
|
87
|
+
metaDescription: string;
|
|
88
|
+
ogImageAssetId: string | null;
|
|
89
|
+
canonicalUrl: string | null;
|
|
90
|
+
noindex: boolean;
|
|
91
|
+
changefreq: Changefreq;
|
|
92
|
+
priority: number;
|
|
93
|
+
autofilledAt: string | null;
|
|
94
|
+
optimizedAt: string | null;
|
|
95
|
+
updatedAt: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface SiteSeoSettings {
|
|
99
|
+
siteBaseUrl: string;
|
|
100
|
+
sitemapEnabled: boolean;
|
|
101
|
+
organization: {
|
|
102
|
+
name?: string;
|
|
103
|
+
url?: string;
|
|
104
|
+
logo?: string;
|
|
105
|
+
sameAs?: string[];
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Resolve the canonical URL for a page. If `pages_seo.canonical_url`
|
|
111
|
+
* is set it wins; otherwise build `<siteBaseUrl>/<page-path>` from
|
|
112
|
+
* the page's slug + locale. Locale strategy is single-locale (no
|
|
113
|
+
* prefix) until P9 i18n.
|
|
114
|
+
*/
|
|
115
|
+
export function resolveCanonicalUrl(args: {
|
|
116
|
+
siteBaseUrl: string;
|
|
117
|
+
pageSlug: string;
|
|
118
|
+
pageLocale: string;
|
|
119
|
+
override: string | null;
|
|
120
|
+
/** P9 — when present, the locale's url_strategy decides the URL shape. */
|
|
121
|
+
localeConfig?: {
|
|
122
|
+
code: string;
|
|
123
|
+
urlStrategy: "none" | "subdirectory" | "subdomain" | "domain";
|
|
124
|
+
urlHost: string | null;
|
|
125
|
+
isDefault: boolean;
|
|
126
|
+
};
|
|
127
|
+
/**
|
|
128
|
+
* v0.2.85 — page emission style. 'directory' (default) → URLs end
|
|
129
|
+
* in `/<slug>/`; 'no-extension' → URLs end in `/<slug>` (no
|
|
130
|
+
* trailing slash) to match what the bucket actually serves when
|
|
131
|
+
* pages are emitted as bare slugs.
|
|
132
|
+
*/
|
|
133
|
+
pageUrlStyle?: "directory" | "no-extension";
|
|
134
|
+
/**
|
|
135
|
+
* 0184 — explicit homepage designation (`locales.home_page_id`). When
|
|
136
|
+
* true this page IS the locale root and canonicalizes to `<base>/`
|
|
137
|
+
* regardless of its slug. Uses the same predicate (`isHomeSlug`) as
|
|
138
|
+
* the magic-slug fallback so canonical agrees with hreflang + the
|
|
139
|
+
* emitted output path.
|
|
140
|
+
*/
|
|
141
|
+
isHomePage?: boolean;
|
|
142
|
+
}): string {
|
|
143
|
+
if (args.override && args.override.length > 0) return args.override;
|
|
144
|
+
const base = args.siteBaseUrl.replace(/\/$/, "");
|
|
145
|
+
const isHome = args.isHomePage === true || isHomeSlug(args.pageSlug);
|
|
146
|
+
const cleanSlug = isHome ? "" : args.pageSlug;
|
|
147
|
+
const style = args.pageUrlStyle ?? "directory";
|
|
148
|
+
// Tail = the slug portion of the canonical URL, with the trailing
|
|
149
|
+
// shape dictated by the page-emission style. Home pages always
|
|
150
|
+
// resolve to the base URL (no tail) regardless of style.
|
|
151
|
+
const tail = cleanSlug ? (style === "no-extension" ? cleanSlug : `${cleanSlug}/`) : "";
|
|
152
|
+
const cfg = args.localeConfig;
|
|
153
|
+
if (cfg && cfg.urlStrategy !== "none") {
|
|
154
|
+
if (cfg.urlStrategy === "subdirectory") {
|
|
155
|
+
return tail ? `${base}/${cfg.code}/${tail}` : `${base}/${cfg.code}/`;
|
|
156
|
+
}
|
|
157
|
+
if ((cfg.urlStrategy === "subdomain" || cfg.urlStrategy === "domain") && cfg.urlHost) {
|
|
158
|
+
const protocol = base.startsWith("http://") ? "http://" : "https://";
|
|
159
|
+
return tail ? `${protocol}${cfg.urlHost}/${tail}` : `${protocol}${cfg.urlHost}/`;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return tail ? `${base}/${tail}` : `${base}/`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export interface SeoMetaInput {
|
|
166
|
+
title: string;
|
|
167
|
+
metaDescription: string;
|
|
168
|
+
canonical: string;
|
|
169
|
+
noindex: boolean;
|
|
170
|
+
ogImageUrl: string | null;
|
|
171
|
+
hreflang: { locale: string; url: string }[];
|
|
172
|
+
organization: SiteSeoSettings["organization"];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Render the <head> meta block for a page. Returns the HTML string
|
|
177
|
+
* the renderer injects just before </head>. Order is canonical
|
|
178
|
+
* (W3C-recommended): charset / viewport stay in the layout; meta
|
|
179
|
+
* description + canonical + robots + Open Graph + Twitter card +
|
|
180
|
+
* hreflang + JSON-LD.
|
|
181
|
+
*
|
|
182
|
+
* No raw HTML escape hatch — every variable is HTML-attribute-encoded.
|
|
183
|
+
*/
|
|
184
|
+
export function renderSeoHead(input: SeoMetaInput): string {
|
|
185
|
+
const enc = (s: string): string =>
|
|
186
|
+
s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
|
187
|
+
|
|
188
|
+
const lines: string[] = [];
|
|
189
|
+
lines.push(`<title>${enc(input.title)}</title>`);
|
|
190
|
+
if (input.metaDescription) {
|
|
191
|
+
lines.push(`<meta name="description" content="${enc(input.metaDescription)}" />`);
|
|
192
|
+
}
|
|
193
|
+
lines.push(`<link rel="canonical" href="${enc(input.canonical)}" />`);
|
|
194
|
+
if (input.noindex) {
|
|
195
|
+
lines.push(`<meta name="robots" content="noindex" />`);
|
|
196
|
+
}
|
|
197
|
+
// Open Graph
|
|
198
|
+
lines.push(`<meta property="og:title" content="${enc(input.title)}" />`);
|
|
199
|
+
if (input.metaDescription) {
|
|
200
|
+
lines.push(`<meta property="og:description" content="${enc(input.metaDescription)}" />`);
|
|
201
|
+
}
|
|
202
|
+
lines.push(`<meta property="og:type" content="website" />`);
|
|
203
|
+
lines.push(`<meta property="og:url" content="${enc(input.canonical)}" />`);
|
|
204
|
+
if (input.ogImageUrl) {
|
|
205
|
+
lines.push(`<meta property="og:image" content="${enc(input.ogImageUrl)}" />`);
|
|
206
|
+
}
|
|
207
|
+
// Twitter card
|
|
208
|
+
lines.push(
|
|
209
|
+
`<meta name="twitter:card" content="${input.ogImageUrl ? "summary_large_image" : "summary"}" />`,
|
|
210
|
+
);
|
|
211
|
+
// Hreflang per locale + x-default. P8 ships zero rows; P9 i18n
|
|
212
|
+
// populates pages_hreflang and the existing renderer iterates.
|
|
213
|
+
for (const h of input.hreflang) {
|
|
214
|
+
lines.push(`<link rel="alternate" hreflang="${enc(h.locale)}" href="${enc(h.url)}" />`);
|
|
215
|
+
}
|
|
216
|
+
if (input.hreflang.length > 0) {
|
|
217
|
+
lines.push(`<link rel="alternate" hreflang="x-default" href="${enc(input.canonical)}" />`);
|
|
218
|
+
}
|
|
219
|
+
// JSON-LD WebPage block referencing the Organization (when set).
|
|
220
|
+
const ld: Record<string, unknown> = {
|
|
221
|
+
"@context": "https://schema.org",
|
|
222
|
+
"@type": "WebPage",
|
|
223
|
+
name: input.title,
|
|
224
|
+
url: input.canonical,
|
|
225
|
+
};
|
|
226
|
+
if (input.metaDescription) ld.description = input.metaDescription;
|
|
227
|
+
if (input.ogImageUrl) ld.image = input.ogImageUrl;
|
|
228
|
+
if (input.organization.name) {
|
|
229
|
+
ld.publisher = {
|
|
230
|
+
"@type": "Organization",
|
|
231
|
+
name: input.organization.name,
|
|
232
|
+
...(input.organization.url ? { url: input.organization.url } : {}),
|
|
233
|
+
...(input.organization.logo
|
|
234
|
+
? { logo: { "@type": "ImageObject", url: input.organization.logo } }
|
|
235
|
+
: {}),
|
|
236
|
+
...(input.organization.sameAs?.length ? { sameAs: input.organization.sameAs } : {}),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
lines.push(
|
|
240
|
+
`<script type="application/ld+json">${JSON.stringify(ld).replace(/</g, "\\u003c")}</script>`,
|
|
241
|
+
);
|
|
242
|
+
return lines.join("\n");
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Inject `renderSeoHead`'s output just before `</head>`. Replaces any
|
|
247
|
+
* existing `<title>` so the page-level title wins over a layout-
|
|
248
|
+
* supplied default. Pre-existing meta tags from the layout are left
|
|
249
|
+
* alone — authors can still drop e.g. `<meta name="theme-color">` in
|
|
250
|
+
* the layout HTML.
|
|
251
|
+
*/
|
|
252
|
+
export function injectSeoIntoHead(html: string, headBlock: string): string {
|
|
253
|
+
// Strip a layout-supplied <title> if present; we'll re-emit it in
|
|
254
|
+
// the headBlock at the right position.
|
|
255
|
+
const titleStripped = html.replace(/<title\b[^>]*>[\s\S]*?<\/title>/i, "");
|
|
256
|
+
if (titleStripped.includes("</head>")) {
|
|
257
|
+
return titleStripped.replace("</head>", `${headBlock}\n</head>`);
|
|
258
|
+
}
|
|
259
|
+
// No closing head tag — prepend the block to the document.
|
|
260
|
+
return `${headBlock}\n${titleStripped}`;
|
|
261
|
+
}
|
package/src/skills.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Skill metadata shapes shared across the Query API + chat-runner.
|
|
5
|
+
*
|
|
6
|
+
* Skills use PROGRESSIVE DISCLOSURE (the Anthropic Agent Skills shape): a
|
|
7
|
+
* static `## Skills` index (slug + description) sits in the cached system
|
|
8
|
+
* prompt, and the model pulls a skill's full body into the conversation on
|
|
9
|
+
* demand via the `load_skill` tool (its body then lives in the append-only
|
|
10
|
+
* message history for the rest of the chat).
|
|
11
|
+
*
|
|
12
|
+
* `skillAutoEngagementHints` no longer drives SELECTION — the model
|
|
13
|
+
* self-selects from the descriptions. The hints now only shape how prominently
|
|
14
|
+
* a skill is presented in the index: `alwaysOn` skills get an "always applies"
|
|
15
|
+
* callout, `chipTrigger` skills a "when element chips are attached" callout.
|
|
16
|
+
* (The old keyword/chip/always-on MATCHER + engagement resolver were removed
|
|
17
|
+
* once progressive disclosure replaced them.)
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { z } from "zod";
|
|
21
|
+
|
|
22
|
+
export const skillAutoEngagementHints = z
|
|
23
|
+
.object({
|
|
24
|
+
/**
|
|
25
|
+
* Lowercase substrings that once boosted the auto-matcher. The matcher is
|
|
26
|
+
* gone (progressive disclosure); retained in the schema so seeded rows that
|
|
27
|
+
* still carry keywords parse under `.strict()`. No runtime effect today.
|
|
28
|
+
*/
|
|
29
|
+
keywords: z.array(z.string().min(1).max(80)).default([]),
|
|
30
|
+
/** Listed under the index's "when element chips are attached" callout. */
|
|
31
|
+
chipTrigger: z.boolean().default(false),
|
|
32
|
+
/** Listed under the index's "always applies" callout (e.g. brand-voice-guard). */
|
|
33
|
+
alwaysOn: z.boolean().default(false),
|
|
34
|
+
})
|
|
35
|
+
.strict();
|
|
36
|
+
export type SkillAutoEngagementHints = z.infer<typeof skillAutoEngagementHints>;
|
|
37
|
+
|
|
38
|
+
/** How a loaded skill came to be engaged this chat. */
|
|
39
|
+
export type EngagementSource = "auto" | "manual" | "pinned";
|
|
40
|
+
|
|
41
|
+
/** A skill the model has loaded (via load_skill) in the current chat. */
|
|
42
|
+
export interface ChatEngagement {
|
|
43
|
+
readonly skillId: string;
|
|
44
|
+
readonly slug: string;
|
|
45
|
+
readonly displayName: string;
|
|
46
|
+
readonly source: EngagementSource;
|
|
47
|
+
readonly rationale: string;
|
|
48
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "bun:test";
|
|
4
|
+
import {
|
|
5
|
+
moduleImpactInput,
|
|
6
|
+
revertModuleInput,
|
|
7
|
+
revertPageInput,
|
|
8
|
+
revertSiteInput,
|
|
9
|
+
revertTemplateInput,
|
|
10
|
+
snapshotGetInput,
|
|
11
|
+
snapshotsListInput,
|
|
12
|
+
} from "./snapshots.js";
|
|
13
|
+
|
|
14
|
+
const UUID = "11111111-1111-4111-8111-111111111111";
|
|
15
|
+
const UUID_2 = "22222222-2222-4222-8222-222222222222";
|
|
16
|
+
|
|
17
|
+
describe("snapshotsListInput", () => {
|
|
18
|
+
it("defaults limit to 50 when omitted", () => {
|
|
19
|
+
const r = snapshotsListInput.safeParse({});
|
|
20
|
+
expect(r.success).toBe(true);
|
|
21
|
+
if (!r.success) return;
|
|
22
|
+
expect(r.data.limit).toBe(50);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("rejects unknown keys (strict)", () => {
|
|
26
|
+
const r = snapshotsListInput.safeParse({ limit: 10, unexpected: 1 });
|
|
27
|
+
expect(r.success).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("clamps limit between 1 and 200", () => {
|
|
31
|
+
expect(snapshotsListInput.safeParse({ limit: 0 }).success).toBe(false);
|
|
32
|
+
expect(snapshotsListInput.safeParse({ limit: 201 }).success).toBe(false);
|
|
33
|
+
expect(snapshotsListInput.safeParse({ limit: 200 }).success).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("accepts an ISO timestamp for `before`", () => {
|
|
37
|
+
const r = snapshotsListInput.safeParse({ before: "2026-04-26T10:00:00.000Z" });
|
|
38
|
+
expect(r.success).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("revert input schemas", () => {
|
|
43
|
+
it("revertSiteInput requires a uuid snapshotId", () => {
|
|
44
|
+
expect(revertSiteInput.safeParse({ snapshotId: UUID }).success).toBe(true);
|
|
45
|
+
expect(revertSiteInput.safeParse({ snapshotId: "not-a-uuid" }).success).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("revertModuleInput requires both moduleId and snapshotId", () => {
|
|
49
|
+
expect(revertModuleInput.safeParse({ moduleId: UUID, snapshotId: UUID_2 }).success).toBe(true);
|
|
50
|
+
expect(revertModuleInput.safeParse({ moduleId: UUID }).success).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("rejects unknown keys on revertPageInput", () => {
|
|
54
|
+
const r = revertPageInput.safeParse({
|
|
55
|
+
pageId: UUID,
|
|
56
|
+
snapshotId: UUID_2,
|
|
57
|
+
extra: "noop",
|
|
58
|
+
});
|
|
59
|
+
expect(r.success).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("revertTemplateInput rejects an unknown key", () => {
|
|
63
|
+
const r = revertTemplateInput.safeParse({
|
|
64
|
+
templateId: UUID,
|
|
65
|
+
snapshotId: UUID_2,
|
|
66
|
+
extra: 1,
|
|
67
|
+
});
|
|
68
|
+
expect(r.success).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("get / impact inputs", () => {
|
|
73
|
+
it("snapshotGetInput accepts a uuid", () => {
|
|
74
|
+
expect(snapshotGetInput.safeParse({ snapshotId: UUID }).success).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
it("moduleImpactInput accepts a uuid and rejects other keys", () => {
|
|
77
|
+
expect(moduleImpactInput.safeParse({ moduleId: UUID }).success).toBe(true);
|
|
78
|
+
expect(moduleImpactInput.safeParse({ moduleId: UUID, foo: 1 }).success).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
});
|