@simmalugnt-se/payload-editor-assistant 0.1.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.
Files changed (102) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +32 -0
  3. package/dist/capabilities.d.ts +10 -0
  4. package/dist/capabilities.js +43 -0
  5. package/dist/collections/approvals.d.ts +2 -0
  6. package/dist/collections/approvals.js +36 -0
  7. package/dist/collections/audit.d.ts +2 -0
  8. package/dist/collections/audit.js +40 -0
  9. package/dist/components/EditorAssistantAction.d.ts +1 -0
  10. package/dist/components/EditorAssistantAction.js +17 -0
  11. package/dist/components/EditorAssistantAside.d.ts +1 -0
  12. package/dist/components/EditorAssistantAside.js +335 -0
  13. package/dist/components/EditorAssistantDocumentBridge.d.ts +1 -0
  14. package/dist/components/EditorAssistantDocumentBridge.js +48 -0
  15. package/dist/components/EditorAssistantProvider.d.ts +8 -0
  16. package/dist/components/EditorAssistantProvider.js +14 -0
  17. package/dist/components/assistant-api.d.ts +9 -0
  18. package/dist/components/assistant-api.js +34 -0
  19. package/dist/components/assistant-context.d.ts +66 -0
  20. package/dist/components/assistant-context.js +69 -0
  21. package/dist/config/denied-fields.d.ts +3 -0
  22. package/dist/config/denied-fields.js +34 -0
  23. package/dist/config/validate.d.ts +15 -0
  24. package/dist/config/validate.js +86 -0
  25. package/dist/domain/allowlist.d.ts +12 -0
  26. package/dist/domain/allowlist.js +14 -0
  27. package/dist/domain/audit.d.ts +17 -0
  28. package/dist/domain/audit.js +25 -0
  29. package/dist/domain/content.d.ts +30 -0
  30. package/dist/domain/content.js +117 -0
  31. package/dist/domain/decide.d.ts +30 -0
  32. package/dist/domain/decide.js +162 -0
  33. package/dist/domain/guard.d.ts +13 -0
  34. package/dist/domain/guard.js +29 -0
  35. package/dist/domain/local-api.d.ts +16 -0
  36. package/dist/domain/local-api.js +3 -0
  37. package/dist/domain/navigate.d.ts +25 -0
  38. package/dist/domain/navigate.js +85 -0
  39. package/dist/domain/propose.d.ts +26 -0
  40. package/dist/domain/propose.js +150 -0
  41. package/dist/domain/relations.d.ts +12 -0
  42. package/dist/domain/relations.js +103 -0
  43. package/dist/endpoints/chat.d.ts +3 -0
  44. package/dist/endpoints/chat.js +88 -0
  45. package/dist/endpoints/csrf.d.ts +6 -0
  46. package/dist/endpoints/csrf.js +57 -0
  47. package/dist/endpoints/decide.d.ts +3 -0
  48. package/dist/endpoints/decide.js +33 -0
  49. package/dist/endpoints/propose.d.ts +3 -0
  50. package/dist/endpoints/propose.js +32 -0
  51. package/dist/exports/client.d.ts +4 -0
  52. package/dist/exports/client.js +4 -0
  53. package/dist/form/compile.d.ts +17 -0
  54. package/dist/form/compile.js +168 -0
  55. package/dist/form/diff.d.ts +9 -0
  56. package/dist/form/diff.js +155 -0
  57. package/dist/form/empty.d.ts +2 -0
  58. package/dist/form/empty.js +79 -0
  59. package/dist/form/hash.d.ts +2 -0
  60. package/dist/form/hash.js +22 -0
  61. package/dist/form/operations.d.ts +52 -0
  62. package/dist/form/operations.js +52 -0
  63. package/dist/form/path.d.ts +5 -0
  64. package/dist/form/path.js +79 -0
  65. package/dist/form/shape.d.ts +19 -0
  66. package/dist/form/shape.js +137 -0
  67. package/dist/index.d.ts +5 -0
  68. package/dist/index.js +3 -0
  69. package/dist/package-name.d.ts +7 -0
  70. package/dist/package-name.js +7 -0
  71. package/dist/plugin.d.ts +3 -0
  72. package/dist/plugin.js +110 -0
  73. package/dist/runtime/agent.d.ts +42 -0
  74. package/dist/runtime/agent.js +262 -0
  75. package/dist/runtime/context.d.ts +37 -0
  76. package/dist/runtime/context.js +92 -0
  77. package/dist/runtime/inflight.d.ts +2 -0
  78. package/dist/runtime/inflight.js +11 -0
  79. package/dist/runtime/language.d.ts +10 -0
  80. package/dist/runtime/language.js +27 -0
  81. package/dist/runtime/propose-loop.d.ts +7 -0
  82. package/dist/runtime/propose-loop.js +11 -0
  83. package/dist/runtime/rate-limit.d.ts +1 -0
  84. package/dist/runtime/rate-limit.js +14 -0
  85. package/dist/runtime/system-prompt.d.ts +3 -0
  86. package/dist/runtime/system-prompt.js +110 -0
  87. package/dist/runtime/tools.d.ts +56 -0
  88. package/dist/runtime/tools.js +189 -0
  89. package/dist/runtime/voice.d.ts +11 -0
  90. package/dist/runtime/voice.js +55 -0
  91. package/dist/schema/discover.d.ts +35 -0
  92. package/dist/schema/discover.js +93 -0
  93. package/dist/schema/fields.d.ts +21 -0
  94. package/dist/schema/fields.js +139 -0
  95. package/dist/schema/project.d.ts +15 -0
  96. package/dist/schema/project.js +111 -0
  97. package/dist/styles/assistant.css +362 -0
  98. package/dist/types.d.ts +61 -0
  99. package/dist/types.js +3 -0
  100. package/dist/view.d.ts +22 -0
  101. package/dist/view.js +72 -0
  102. package/package.json +73 -0
@@ -0,0 +1,189 @@
1
+ import { writeAudit } from "../domain/audit.js";
2
+ import { findContent, findContentById } from "../domain/content.js";
3
+ import { canNavigate, navigateAdmin } from "../domain/navigate.js";
4
+ import { proposeChange } from "../domain/propose.js";
5
+ import { discoverSchema, entityFields } from "../schema/discover.js";
6
+ import { projectRecord } from "../schema/project.js";
7
+ export const TOOL_JSON_SCHEMAS = {
8
+ "schema.discover": {
9
+ type: "object",
10
+ properties: {},
11
+ },
12
+ "content.find": {
13
+ type: "object",
14
+ additionalProperties: false,
15
+ properties: {
16
+ collection: { type: "string" },
17
+ query: { type: "string" },
18
+ },
19
+ },
20
+ "content.findById": {
21
+ type: "object",
22
+ additionalProperties: false,
23
+ properties: {
24
+ collection: { type: "string" },
25
+ global: { type: "string" },
26
+ documentId: { type: ["string", "number"] },
27
+ locale: { type: "string" },
28
+ },
29
+ },
30
+ "form.read": {
31
+ type: "object",
32
+ properties: {},
33
+ },
34
+ "admin.navigate": {
35
+ type: "object",
36
+ additionalProperties: false,
37
+ properties: {
38
+ collection: { type: "string" },
39
+ global: { type: "string" },
40
+ documentId: { type: ["string", "number"] },
41
+ query: { type: "string" },
42
+ },
43
+ },
44
+ "form.propose": {
45
+ type: "object",
46
+ properties: {
47
+ operations: {
48
+ description: 'Array of patches. Use dotted paths such as layout.0.headline. Example item: {"op":"set","path":"layout.0.headline","value":"Välkommen"}.',
49
+ },
50
+ patches: {},
51
+ changes: {},
52
+ },
53
+ },
54
+ "draft.create": {
55
+ type: "object",
56
+ additionalProperties: false,
57
+ properties: {
58
+ collection: { type: "string" },
59
+ operations: { type: "array" },
60
+ },
61
+ required: ["collection", "operations"],
62
+ },
63
+ };
64
+ export const TOOL_DESCRIPTIONS = {
65
+ "schema.discover": "List allowlisted collections, globals, fields, and constraints. Never invent fields.",
66
+ "content.find": "Search or list allowlisted documents by title, slug, or id. Omit query to list recent documents. Returns identity only, not a summary.",
67
+ "content.findById": "Read one allowlisted document or global. Pass locale to read another language of the same document. For the currently open form, prefer form.read.",
68
+ "form.read": "Read the currently open unsaved form in the current language. No document ID needed.",
69
+ "form.propose": 'Stage a structured patch against the open unsaved form in the current language. Call this as soon as you have the new field values. Do not ask the editor to confirm in chat; they approve in the panel. Example: {"op":"set","path":"layout.0.headline","value":"Välkommen"}. Requires editor approval.',
70
+ "admin.navigate": "Navigate Admin to an allowlisted collection, global, or unique document match.",
71
+ "draft.create": 'Stage creation of one draft document. Requires editor approval. Never publishes. The document starts empty: set top-level fields with {"op":"set","path":"title","value":"Start"}, and add each layout block with {"op":"blocks.insert","path":"layout","index":0,"blockType":"hero","data":{"headline":"Välkommen"}} using only block types and fields from the quoted context. Rich text fields take plain text.',
72
+ };
73
+ /** Provider APIs reject dots in tool names (`^[a-zA-Z0-9_-]+$`). Capability IDs stay dotted. */
74
+ export function toProviderToolName(capability) {
75
+ return capability.replaceAll(".", "_");
76
+ }
77
+ export function fromProviderToolName(name) {
78
+ const match = Object.keys(TOOL_JSON_SCHEMAS).find((capability) => toProviderToolName(capability) === name);
79
+ return match ?? name;
80
+ }
81
+ export async function executeTool(name, input, ctx) {
82
+ switch (name) {
83
+ case "schema.discover":
84
+ return {
85
+ ok: true,
86
+ capability: name,
87
+ data: discoverSchema(ctx.req, ctx.options),
88
+ };
89
+ case "content.find":
90
+ return wrap(name, await findContent(ctx.req, ctx.options, {
91
+ collection: asString(input.collection),
92
+ query: asString(input.query) ?? "",
93
+ locale: ctx.bridge.locale,
94
+ }));
95
+ case "content.findById":
96
+ return wrap(name, await findContentById(ctx.req, ctx.options, {
97
+ collection: asString(input.collection) ?? ctx.bridge.collection,
98
+ global: asString(input.global) ?? ctx.bridge.global,
99
+ documentId: input.documentId ?? ctx.bridge.documentId,
100
+ locale: asString(input.locale) ?? ctx.bridge.locale,
101
+ }));
102
+ case "form.read": {
103
+ if (!ctx.bridge.collection && !ctx.bridge.global) {
104
+ return {
105
+ ok: false,
106
+ capability: name,
107
+ error: "tool_denied",
108
+ message: "No document is open. Open an allowlisted document to read the form.",
109
+ };
110
+ }
111
+ const fields = entityFields(ctx.req, ctx.options, {
112
+ collection: ctx.bridge.collection,
113
+ global: ctx.bridge.global,
114
+ });
115
+ const projected = projectRecord(ctx.formData ?? {}, fields, ctx.options.redact, ctx.bridge.collection ?? ctx.bridge.global);
116
+ return { ok: true, capability: name, data: projected };
117
+ }
118
+ case "admin.navigate": {
119
+ if (!canNavigate(ctx.options)) {
120
+ return {
121
+ ok: false,
122
+ capability: name,
123
+ error: "tool_denied",
124
+ message: "Nothing is allowlisted.",
125
+ };
126
+ }
127
+ const result = await navigateAdmin(ctx.req, ctx.options, {
128
+ collection: asString(input.collection),
129
+ global: asString(input.global),
130
+ documentId: input.documentId,
131
+ query: asString(input.query),
132
+ locale: ctx.bridge.locale,
133
+ });
134
+ if (!result.ok) {
135
+ return { ok: false, capability: name, error: result.error, message: result.message };
136
+ }
137
+ await writeAudit(ctx.req, ctx.options, {
138
+ capability: name,
139
+ risk: "auto",
140
+ outcome: "executed",
141
+ targetCollection: asString(input.collection),
142
+ targetGlobal: asString(input.global),
143
+ locale: ctx.bridge.locale,
144
+ });
145
+ if ("href" in result) {
146
+ return { ok: true, capability: name, navigate: { href: result.href, label: result.label } };
147
+ }
148
+ return { ok: true, capability: name, navigate: { candidates: result.candidates } };
149
+ }
150
+ case "form.propose":
151
+ case "draft.create": {
152
+ const proposed = await proposeChange(ctx.req, ctx.options, {
153
+ capability: name,
154
+ collection: name === "draft.create" ? asString(input.collection) : ctx.bridge.collection,
155
+ global: name === "form.propose" ? ctx.bridge.global : undefined,
156
+ documentId: name === "form.propose" ? ctx.bridge.documentId : undefined,
157
+ locale: ctx.bridge.locale,
158
+ language: ctx.bridge.language,
159
+ operations: input.operations ?? input.patches ?? input.changes ?? input.ops,
160
+ formData: name === "form.propose" ? ctx.formData : undefined,
161
+ });
162
+ if (!proposed.ok) {
163
+ return { ok: false, capability: name, error: proposed.error, message: proposed.message };
164
+ }
165
+ return {
166
+ ok: true,
167
+ capability: name,
168
+ pendingApproval: {
169
+ title: proposed.title,
170
+ summary: proposed.summary,
171
+ approvalId: proposed.approvalId,
172
+ nonce: proposed.nonce,
173
+ capability: name,
174
+ },
175
+ };
176
+ }
177
+ default:
178
+ return { ok: false, capability: name, error: "tool_denied", message: "Unknown tool." };
179
+ }
180
+ }
181
+ function wrap(capability, result) {
182
+ if (!result.ok) {
183
+ return { ok: false, capability, error: result.error, message: result.message };
184
+ }
185
+ return { ok: true, capability, data: "results" in result ? result.results : result.data };
186
+ }
187
+ function asString(value) {
188
+ return typeof value === "string" && value.trim() ? value : undefined;
189
+ }
@@ -0,0 +1,11 @@
1
+ export declare function editorFacingText(input: {
2
+ text: string;
3
+ locale: string;
4
+ pendingApproval?: {
5
+ capability: "form.propose" | "draft.create";
6
+ };
7
+ expectedProposal?: boolean;
8
+ }): string;
9
+ export declare function stripInternalSpeech(text: string, locale: string): string;
10
+ export declare function approvalCopy(locale: string, capability: "form.propose" | "draft.create"): string;
11
+ export declare function couldNotStageCopy(locale: string): string;
@@ -0,0 +1,55 @@
1
+ import { claimedUnstagedChange } from "./propose-loop.js";
2
+ const INTERNAL = /\b(form\.propose|form_propose|draft\.create|draft_create|schema\.discover|content\.find|tool call|tool_denied|allowlist|blockType|capability|JSON|quoted CMS|I've applied|I have applied|I should apply|operations)\b/i;
3
+ const ENGLISH_META = /\b(despite|therefore|however|the tool|I should|I've|has been issued|appropriate turn|proposed values)\b/i;
4
+ export function editorFacingText(input) {
5
+ if (input.pendingApproval) {
6
+ return approvalCopy(input.locale, input.pendingApproval.capability);
7
+ }
8
+ const cleaned = stripInternalSpeech(input.text, input.locale);
9
+ if (input.expectedProposal ||
10
+ claimedUnstagedChange(cleaned) ||
11
+ claimedUnstagedChange(input.text)) {
12
+ return couldNotStageCopy(input.locale);
13
+ }
14
+ return cleaned || doneCopy(input.locale);
15
+ }
16
+ export function stripInternalSpeech(text, locale) {
17
+ const swedish = isSwedish(locale);
18
+ const kept = splitSentences(text).filter((sentence) => {
19
+ if (INTERNAL.test(sentence)) {
20
+ return false;
21
+ }
22
+ if (swedish && ENGLISH_META.test(sentence) && !/[åäöÅÄÖ]/.test(sentence)) {
23
+ return false;
24
+ }
25
+ return true;
26
+ });
27
+ return kept.join(" ").replace(/\s+/g, " ").trim();
28
+ }
29
+ export function approvalCopy(locale, capability) {
30
+ if (isSwedish(locale)) {
31
+ return capability === "draft.create"
32
+ ? "Utkastet ligger i panelen. Godkänn det så skapas det."
33
+ : "Förslaget ligger i panelen. Godkänn det så fylls fälten i. Spara sedan som vanligt.";
34
+ }
35
+ return capability === "draft.create"
36
+ ? "The draft is ready in the panel. Approve it to create it."
37
+ : "The change is ready in the panel. Approve it to fill the fields, then save as usual.";
38
+ }
39
+ export function couldNotStageCopy(locale) {
40
+ return isSwedish(locale)
41
+ ? "Jag kunde inte lägga förslaget i panelen. Försök igen."
42
+ : "I could not stage the change in the panel. Try again.";
43
+ }
44
+ function doneCopy(locale) {
45
+ return isSwedish(locale) ? "Klart." : "Done.";
46
+ }
47
+ function isSwedish(locale) {
48
+ return locale === "sv" || locale.startsWith("sv-");
49
+ }
50
+ function splitSentences(text) {
51
+ return text
52
+ .split(/(?<=[.!?])\s+|\n+/)
53
+ .map((part) => part.trim())
54
+ .filter(Boolean);
55
+ }
@@ -0,0 +1,35 @@
1
+ import type { PayloadRequest } from "payload";
2
+ import type { ValidatedEditorAssistantOptions } from "../config/validate.ts";
3
+ import { type FieldNode } from "./fields.ts";
4
+ export type DiscoveredEntity = {
5
+ kind: "collection" | "global";
6
+ slug: string;
7
+ label?: string;
8
+ drafts?: boolean;
9
+ upload?: boolean;
10
+ capabilities: string[];
11
+ fields: FieldNode[];
12
+ };
13
+ export type EntityConfig = {
14
+ slug: string;
15
+ labels?: {
16
+ singular?: unknown;
17
+ plural?: unknown;
18
+ };
19
+ label?: unknown;
20
+ fields?: unknown[];
21
+ versions?: unknown;
22
+ upload?: unknown;
23
+ admin?: {
24
+ useAsTitle?: string;
25
+ };
26
+ };
27
+ export declare function discoverSchema(req: PayloadRequest, options: ValidatedEditorAssistantOptions): DiscoveredEntity[];
28
+ export declare function findCollectionConfig(req: PayloadRequest, slug: string): EntityConfig | undefined;
29
+ export declare function findGlobalConfig(req: PayloadRequest, slug: string): EntityConfig | undefined;
30
+ export declare function entityFields(req: PayloadRequest, options: ValidatedEditorAssistantOptions, target: {
31
+ collection?: string;
32
+ global?: string;
33
+ }): FieldNode[];
34
+ export declare function hasDrafts(versions: unknown): boolean;
35
+ export declare function configuredLocales(req: PayloadRequest): string[];
@@ -0,0 +1,93 @@
1
+ import { isDeniedCollectionSlug } from "../config/denied-fields.js";
2
+ import { walkFields } from "./fields.js";
3
+ export function discoverSchema(req, options) {
4
+ const entities = [];
5
+ for (const [slug, allowlist] of Object.entries(options.collections)) {
6
+ if (isDeniedCollectionSlug(slug)) {
7
+ continue;
8
+ }
9
+ const config = findCollectionConfig(req, slug);
10
+ if (!config) {
11
+ continue;
12
+ }
13
+ entities.push({
14
+ kind: "collection",
15
+ slug,
16
+ label: asString(config.labels?.plural) ?? asString(config.labels?.singular) ?? slug,
17
+ drafts: hasDrafts(config.versions),
18
+ upload: Boolean(config.upload),
19
+ capabilities: allowlist.capabilities,
20
+ fields: walkFields(config.fields, "", allowlist.fields, options.denyFields),
21
+ });
22
+ }
23
+ for (const [slug, allowlist] of Object.entries(options.globals)) {
24
+ const config = findGlobalConfig(req, slug);
25
+ if (!config) {
26
+ continue;
27
+ }
28
+ entities.push({
29
+ kind: "global",
30
+ slug,
31
+ label: asString(config.label) ?? slug,
32
+ capabilities: allowlist.capabilities,
33
+ fields: walkFields(config.fields, "", allowlist.fields, options.denyFields),
34
+ });
35
+ }
36
+ return entities;
37
+ }
38
+ export function findCollectionConfig(req, slug) {
39
+ const collections = req.payload.config.collections;
40
+ return collections?.find((collection) => collection.slug === slug);
41
+ }
42
+ export function findGlobalConfig(req, slug) {
43
+ const globals = req.payload.config.globals;
44
+ return globals?.find((global) => global.slug === slug);
45
+ }
46
+ export function entityFields(req, options, target) {
47
+ if (target.collection) {
48
+ const allowlist = options.collections[target.collection];
49
+ const config = findCollectionConfig(req, target.collection);
50
+ if (!allowlist || !config) {
51
+ return [];
52
+ }
53
+ return walkFields(config.fields, "", allowlist.fields, options.denyFields);
54
+ }
55
+ if (target.global) {
56
+ const allowlist = options.globals[target.global];
57
+ const config = findGlobalConfig(req, target.global);
58
+ if (!allowlist || !config) {
59
+ return [];
60
+ }
61
+ return walkFields(config.fields, "", allowlist.fields, options.denyFields);
62
+ }
63
+ return [];
64
+ }
65
+ export function hasDrafts(versions) {
66
+ if (!versions || typeof versions !== "object") {
67
+ return false;
68
+ }
69
+ return Boolean(versions.drafts);
70
+ }
71
+ function asString(value) {
72
+ return typeof value === "string" && value.trim() ? value : undefined;
73
+ }
74
+ export function configuredLocales(req) {
75
+ const localization = req.payload.config.localization;
76
+ if (!localization || typeof localization !== "object") {
77
+ return [];
78
+ }
79
+ const locales = localization.locales;
80
+ if (!Array.isArray(locales)) {
81
+ return [];
82
+ }
83
+ return locales.flatMap((entry) => {
84
+ if (typeof entry === "string" && entry.trim()) {
85
+ return [entry];
86
+ }
87
+ if (entry && typeof entry === "object" && "code" in entry) {
88
+ const code = entry.code;
89
+ return typeof code === "string" && code.trim() ? [code] : [];
90
+ }
91
+ return [];
92
+ });
93
+ }
@@ -0,0 +1,21 @@
1
+ import type { FieldAllowlist } from "../types.ts";
2
+ export type FieldNode = {
3
+ name: string;
4
+ type: string;
5
+ path: string;
6
+ required?: boolean;
7
+ localized?: boolean;
8
+ relationTo?: string[];
9
+ hasMany?: boolean;
10
+ options?: string[];
11
+ label?: string;
12
+ description?: string;
13
+ fields?: FieldNode[];
14
+ blocks?: Array<{
15
+ slug: string;
16
+ fields: FieldNode[];
17
+ }>;
18
+ };
19
+ export declare function isFieldAllowed(name: string, path: string, allowlist: FieldAllowlist | undefined, denyFields: string[]): boolean;
20
+ export declare function walkFields(fields: unknown[] | undefined, parentPath: string, allowlist: FieldAllowlist | undefined, denyFields: string[]): FieldNode[];
21
+ export declare function findFieldNode(nodes: FieldNode[], path: string): FieldNode | undefined;
@@ -0,0 +1,139 @@
1
+ import { isDeniedFieldName } from "../config/denied-fields.js";
2
+ const SKIP_TYPES = new Set(["ui", "join", "row", "collapsible", "tabs"]);
3
+ export function isFieldAllowed(name, path, allowlist, denyFields) {
4
+ if (isDeniedFieldName(name) || denyFields.includes(name)) {
5
+ return false;
6
+ }
7
+ if (!allowlist || allowlist === true) {
8
+ return true;
9
+ }
10
+ if ("include" in allowlist) {
11
+ return allowlist.include.some((entry) => entry === name || entry === path || path === entry || path.startsWith(`${entry}.`));
12
+ }
13
+ if ("exclude" in allowlist) {
14
+ return !allowlist.exclude.includes(name);
15
+ }
16
+ return true;
17
+ }
18
+ export function walkFields(fields, parentPath, allowlist, denyFields) {
19
+ const nodes = [];
20
+ for (const raw of fields ?? []) {
21
+ nodes.push(...walkField(raw, parentPath, allowlist, denyFields));
22
+ }
23
+ return nodes;
24
+ }
25
+ function walkField(field, parentPath, allowlist, denyFields) {
26
+ if (!field || typeof field !== "object") {
27
+ return [];
28
+ }
29
+ const type = field.type ?? "unknown";
30
+ if (type === "tabs") {
31
+ const nested = [];
32
+ for (const tab of field.tabs ?? []) {
33
+ const tabPath = tab.name ? joinPath(parentPath, tab.name) : parentPath;
34
+ nested.push(...walkFields(tab.fields, tabPath, allowlist, denyFields));
35
+ }
36
+ return nested;
37
+ }
38
+ if (type === "ui" || type === "join") {
39
+ return [];
40
+ }
41
+ if (type === "row" || type === "collapsible") {
42
+ return walkFields(field.fields, parentPath, allowlist, denyFields);
43
+ }
44
+ if (!field.name) {
45
+ return walkFields(field.fields, parentPath, allowlist, denyFields);
46
+ }
47
+ const path = joinPath(parentPath, field.name);
48
+ if (!isFieldAllowed(field.name, path, allowlist, denyFields)) {
49
+ return [];
50
+ }
51
+ const node = {
52
+ name: field.name,
53
+ type,
54
+ path,
55
+ required: field.required,
56
+ localized: field.localized,
57
+ hasMany: field.hasMany,
58
+ relationTo: normalizeRelationTo(field.relationTo),
59
+ options: optionValues(field.options),
60
+ label: asLabel(field.label) ?? field.name,
61
+ description: asLabel(field.admin?.description),
62
+ };
63
+ if (type === "group" || type === "array") {
64
+ node.fields = walkFields(field.fields, path, allowlist, denyFields);
65
+ }
66
+ if (type === "blocks") {
67
+ node.blocks = (field.blocks ?? [])
68
+ .filter((block) => Boolean(block.slug))
69
+ .map((block) => ({
70
+ slug: block.slug,
71
+ fields: walkFields(block.fields, path, allowlist, denyFields),
72
+ }));
73
+ }
74
+ if (SKIP_TYPES.has(type) && node.fields) {
75
+ return node.fields;
76
+ }
77
+ return [node];
78
+ }
79
+ export function findFieldNode(nodes, path) {
80
+ for (const node of nodes) {
81
+ if (node.path === path) {
82
+ return node;
83
+ }
84
+ if (node.fields) {
85
+ const nested = findFieldNode(node.fields, path);
86
+ if (nested) {
87
+ return nested;
88
+ }
89
+ }
90
+ if (node.blocks) {
91
+ for (const block of node.blocks) {
92
+ const nested = findFieldNode(block.fields, path);
93
+ if (nested) {
94
+ return nested;
95
+ }
96
+ }
97
+ }
98
+ }
99
+ return undefined;
100
+ }
101
+ function joinPath(parent, name) {
102
+ return parent ? `${parent}.${name}` : name;
103
+ }
104
+ function normalizeRelationTo(value) {
105
+ if (!value) {
106
+ return undefined;
107
+ }
108
+ return Array.isArray(value) ? value : [value];
109
+ }
110
+ function optionValues(options) {
111
+ if (!Array.isArray(options)) {
112
+ return undefined;
113
+ }
114
+ const values = options
115
+ .map((option) => {
116
+ if (typeof option === "string") {
117
+ return option;
118
+ }
119
+ if (option && typeof option === "object" && "value" in option) {
120
+ return String(option.value);
121
+ }
122
+ return undefined;
123
+ })
124
+ .filter((value) => Boolean(value));
125
+ return values.length > 0 ? values : undefined;
126
+ }
127
+ function asLabel(value) {
128
+ if (typeof value === "string" && value.trim()) {
129
+ return value;
130
+ }
131
+ if (value && typeof value === "object") {
132
+ for (const entry of Object.values(value)) {
133
+ if (typeof entry === "string" && entry.trim()) {
134
+ return entry;
135
+ }
136
+ }
137
+ }
138
+ return undefined;
139
+ }
@@ -0,0 +1,15 @@
1
+ import type { FieldAllowlist } from "../types.ts";
2
+ import { type FieldNode } from "./fields.ts";
3
+ export declare function projectRecord(data: Record<string, unknown> | null | undefined, fields: FieldNode[], redact?: (input: {
4
+ slug: string;
5
+ data: Record<string, unknown>;
6
+ }) => Record<string, unknown>, slug?: string): {
7
+ data: Record<string, unknown>;
8
+ truncated: boolean;
9
+ };
10
+ export declare function projectIdentity(doc: Record<string, unknown>, locale?: string): Record<string, unknown>;
11
+ export declare function capRecord(data: Record<string, unknown>): {
12
+ data: Record<string, unknown>;
13
+ truncated: boolean;
14
+ };
15
+ export declare function filterRootKeys(data: Record<string, unknown>, allowlist: FieldAllowlist | undefined, denyFields: string[]): Record<string, unknown>;
@@ -0,0 +1,111 @@
1
+ import { isDeniedFieldName } from "../config/denied-fields.js";
2
+ import { isFieldAllowed } from "./fields.js";
3
+ const MAX_CHARS = 16_000;
4
+ export function projectRecord(data, fields, redact, slug = "") {
5
+ const projected = {};
6
+ if (!data) {
7
+ return { data: projected, truncated: false };
8
+ }
9
+ for (const field of fields) {
10
+ if (!(field.name in data)) {
11
+ continue;
12
+ }
13
+ projected[field.name] = projectValue(data[field.name], field);
14
+ }
15
+ const redacted = redact ? redact({ slug, data: projected }) : projected;
16
+ return capRecord(redacted);
17
+ }
18
+ export function projectIdentity(doc, locale) {
19
+ const identity = {
20
+ id: doc.id,
21
+ };
22
+ for (const key of ["title", "slug", "name", "label", "_status"]) {
23
+ if (key in doc && !isDeniedFieldName(key)) {
24
+ identity[key] = doc[key];
25
+ }
26
+ }
27
+ if (locale) {
28
+ identity.locale = locale;
29
+ }
30
+ return identity;
31
+ }
32
+ export function capRecord(data) {
33
+ const encoded = JSON.stringify(data);
34
+ if (encoded.length <= MAX_CHARS) {
35
+ return { data, truncated: false };
36
+ }
37
+ return {
38
+ data: {
39
+ incomplete: true,
40
+ notice: "Result truncated because it exceeded the size cap.",
41
+ preview: encoded.slice(0, MAX_CHARS),
42
+ },
43
+ truncated: true,
44
+ };
45
+ }
46
+ export function filterRootKeys(data, allowlist, denyFields) {
47
+ const next = {};
48
+ for (const [key, value] of Object.entries(data)) {
49
+ if (isFieldAllowed(key, key, allowlist, denyFields)) {
50
+ next[key] = value;
51
+ }
52
+ }
53
+ return next;
54
+ }
55
+ function projectValue(value, field) {
56
+ if (value === null || value === undefined) {
57
+ return value;
58
+ }
59
+ if (field.type === "array" && Array.isArray(value)) {
60
+ return value.map((item) => item && typeof item === "object" && !Array.isArray(item)
61
+ ? projectRecord(item, field.fields ?? []).data
62
+ : item);
63
+ }
64
+ if (field.type === "blocks" && Array.isArray(value)) {
65
+ return value.map((item) => {
66
+ if (!item || typeof item !== "object" || Array.isArray(item)) {
67
+ return item;
68
+ }
69
+ const record = item;
70
+ const blockType = typeof record.blockType === "string" ? record.blockType : "";
71
+ const block = field.blocks?.find((entry) => entry.slug === blockType);
72
+ const projected = projectRecord(record, block?.fields ?? []).data;
73
+ return {
74
+ id: record.id,
75
+ blockType,
76
+ ...projected,
77
+ };
78
+ });
79
+ }
80
+ if (field.type === "group" && value && typeof value === "object" && !Array.isArray(value)) {
81
+ return projectRecord(value, field.fields ?? []).data;
82
+ }
83
+ if (field.type === "relationship" || field.type === "upload") {
84
+ return projectRelation(value);
85
+ }
86
+ return value;
87
+ }
88
+ function projectRelation(value) {
89
+ if (Array.isArray(value)) {
90
+ return value.map(projectRelation);
91
+ }
92
+ if (typeof value === "string" || typeof value === "number") {
93
+ return value;
94
+ }
95
+ if (value && typeof value === "object") {
96
+ const record = value;
97
+ if ("relationTo" in record && "value" in record) {
98
+ return { relationTo: record.relationTo, value: idOf(record.value) };
99
+ }
100
+ if ("id" in record) {
101
+ return record.id;
102
+ }
103
+ }
104
+ return value;
105
+ }
106
+ function idOf(value) {
107
+ if (value && typeof value === "object" && "id" in value) {
108
+ return value.id;
109
+ }
110
+ return value;
111
+ }