@mosetta/editor 0.0.0-stage → 0.0.28

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 (115) hide show
  1. package/CHANGELOG.md +185 -0
  2. package/LICENSE +21 -0
  3. package/README.md +10 -2
  4. package/mosetta.json +6 -0
  5. package/package.json +35 -3
  6. package/prefabs/workbench/prefab.json +11 -0
  7. package/prefabs/workbench/versions/1.json +120 -0
  8. package/prefabs/workbench/versions/2.json +13 -0
  9. package/prefabs/workbench/versions/3.json +19 -0
  10. package/prefabs/workbench/versions/4.json +29 -0
  11. package/prefabs/workbench/versions/5.json +29 -0
  12. package/tiles/canvas/canvas.component.tsx +2038 -0
  13. package/tiles/canvas/canvas.css +973 -0
  14. package/tiles/canvas/canvasTools.ts +66 -0
  15. package/tiles/canvas/chromeNodes.ts +35 -0
  16. package/tiles/canvas/ghosts.ts +33 -0
  17. package/tiles/canvas/insertPopover.tsx +56 -0
  18. package/tiles/canvas/popoverPlacement.ts +104 -0
  19. package/tiles/canvas/popoverSize.ts +87 -0
  20. package/tiles/canvas/providerChrome.tsx +191 -0
  21. package/tiles/canvas/scrollWatch.ts +43 -0
  22. package/tiles/canvas/settingsPopover.tsx +257 -0
  23. package/tiles/canvas/settingsTabs.ts +78 -0
  24. package/tiles/canvas/toolPanel.tsx +86 -0
  25. package/tiles/content/autoBind.ts +61 -0
  26. package/tiles/content/blockPage.css +390 -0
  27. package/tiles/content/blockPage.tsx +379 -0
  28. package/tiles/content/blockPicker.css +215 -0
  29. package/tiles/content/blockPicker.tsx +501 -0
  30. package/tiles/content/blockUpgrades.ts +145 -0
  31. package/tiles/content/blockVariants.ts +185 -0
  32. package/tiles/content/blockVocabulary.ts +77 -0
  33. package/tiles/content/catalogVersions.ts +106 -0
  34. package/tiles/content/componentPreview.css +82 -0
  35. package/tiles/content/componentPreview.tsx +249 -0
  36. package/tiles/content/content.server.ts +593 -0
  37. package/tiles/content/contentCommon.ts +133 -0
  38. package/tiles/content/disconnected.ts +56 -0
  39. package/tiles/content/distributions.css +256 -0
  40. package/tiles/content/distributions.tsx +271 -0
  41. package/tiles/content/editingAutosave.ts +125 -0
  42. package/tiles/content/editingClipboard.ts +185 -0
  43. package/tiles/content/editingConflicts.ts +223 -0
  44. package/tiles/content/editingHotkeys.ts +59 -0
  45. package/tiles/content/editingNotices.ts +105 -0
  46. package/tiles/content/formRoot.ts +18 -0
  47. package/tiles/content/fuzzySearch.ts +121 -0
  48. package/tiles/content/mediaUpload.ts +55 -0
  49. package/tiles/content/moduleSiblings.ts +9 -0
  50. package/tiles/content/mountedTree.ts +136 -0
  51. package/tiles/content/nodeSettings.css +483 -0
  52. package/tiles/content/nodeSettings.tsx +963 -0
  53. package/tiles/content/pasteText.ts +131 -0
  54. package/tiles/content/prefabFields.tsx +158 -0
  55. package/tiles/content/prefabParts.ts +105 -0
  56. package/tiles/content/previewEnvironment.ts +46 -0
  57. package/tiles/content/projectConfig.ts +46 -0
  58. package/tiles/content/projectPrefabs.ts +57 -0
  59. package/tiles/content/publishModal.css +109 -0
  60. package/tiles/content/publishModal.tsx +92 -0
  61. package/tiles/content/refsEditing.ts +412 -0
  62. package/tiles/content/scaledStage.ts +63 -0
  63. package/tiles/content/searchField.css +32 -0
  64. package/tiles/content/searchField.tsx +32 -0
  65. package/tiles/content/settingsDraft.ts +178 -0
  66. package/tiles/content/settingsIssues.tsx +124 -0
  67. package/tiles/content/themeScope.ts +72 -0
  68. package/tiles/content/themeVariables.ts +47 -0
  69. package/tiles/content/tooltipPlacement.ts +17 -0
  70. package/tiles/content/tooltips.tsx +131 -0
  71. package/tiles/content/treeMounter.ts +33 -0
  72. package/tiles/content/updatesModal.css +124 -0
  73. package/tiles/content/updatesModal.tsx +110 -0
  74. package/tiles/content/useCtrlHeld.ts +24 -0
  75. package/tiles/content/useEditingSession.ts +115 -0
  76. package/tiles/content/useMountedTree.ts +47 -0
  77. package/tiles/content/workbenchScale.css +27 -0
  78. package/tiles/hostFallback/hostFallback.component.tsx +32 -0
  79. package/tiles/hostFallback/hostFallback.css +12 -0
  80. package/tiles/hostFallback/hostFallback.editor.tsx +10 -0
  81. package/tiles/page/page.component.tsx +7 -0
  82. package/tiles/page/page.script.tsx +5 -0
  83. package/tiles/pageModules/pageModules.component.tsx +740 -0
  84. package/tiles/pageModules/pageModules.css +383 -0
  85. package/tiles/pageModules/pageModules.icon.svg +1 -0
  86. package/tiles/pageModules/servedPage.ts +465 -0
  87. package/tiles/pagesPanel/pagesPanel.component.tsx +1645 -0
  88. package/tiles/pagesPanel/pagesPanel.css +713 -0
  89. package/tiles/pagesPanel/pagesPanel.editor.tsx +23 -0
  90. package/tiles/pagesPanel/pagesSearch.ts +194 -0
  91. package/tiles/pagesPanel/routeTree.ts +98 -0
  92. package/tiles/previewBridge/landing.ts +66 -0
  93. package/tiles/previewBridge/previewAgent.ts +457 -0
  94. package/tiles/previewBridge/previewBridge.component.tsx +137 -0
  95. package/tiles/previewBridge/previewBridge.editor.tsx +10 -0
  96. package/tiles/previewBridge/previewBridgeCommon.ts +172 -0
  97. package/tiles/previewBridge/standIn.ts +22 -0
  98. package/tiles/treeView/treeView.component.tsx +514 -0
  99. package/tiles/treeView/treeView.css +303 -0
  100. package/tiles/treeView/treeView.editor.tsx +12 -0
  101. package/tiles/workbench/assistant.css +401 -0
  102. package/tiles/workbench/assistant.tsx +516 -0
  103. package/tiles/workbench/assistantChat.ts +165 -0
  104. package/tiles/workbench/dictation.ts +354 -0
  105. package/tiles/workbench/projectInfo.ts +72 -0
  106. package/tiles/workbench/recent.ts +67 -0
  107. package/tiles/workbench/workbench.component.tsx +476 -0
  108. package/tiles/workbench/workbench.css +376 -0
  109. package/tiles/workbench/workbenchAddress.ts +202 -0
  110. package/tiles/workbench/workspace.ts +775 -0
  111. package/tiles/workbenchEditor/workbenchEditor.component.tsx +461 -0
  112. package/tiles/workbenchEditor/workbenchEditor.css +370 -0
  113. package/tiles/workbenchHome/workbenchHome.component.tsx +196 -0
  114. package/tiles/workbenchHome/workbenchHome.css +271 -0
  115. package/tsconfig.json +3 -0
@@ -0,0 +1,593 @@
1
+ // The editor's content persistence server module: the real API behind the visual editor.
2
+ // Runs in a Worker Loader isolate; all disk access goes through the opt-in content transport to the
3
+ // provider. This module is the TRUSTED writer: it includes DevContentTransport in its
4
+ // services — that line IS the write capability (the framework blesses nothing by default).
5
+ import {
6
+ ContentError,
7
+ ContentRepository,
8
+ ContentSchemas,
9
+ ContentTransportError,
10
+ CONTENT_REPOSITORY_TOKEN,
11
+ CONTENT_TRANSPORT_TOKEN,
12
+ createContentSchemas,
13
+ DevContentTransport,
14
+ ICatalogEntry,
15
+ IDistributionEntry,
16
+ ILibraryPrefabEntry,
17
+ IModuleDescriptor,
18
+ IBlueprintNodeDoc,
19
+ ILoadedContent,
20
+ ILoadedDraft,
21
+ IPageDoc,
22
+ IPrefabDoc,
23
+ pageAsPrefab,
24
+ prefabAsPage,
25
+ prefabReference,
26
+ publishedFormOf,
27
+ } from '@mosetta/content';
28
+ import type { ContentKind } from '@mosetta/content';
29
+ import {
30
+ DiInject,
31
+ DiServerAction,
32
+ defineServerSide,
33
+ ICreateSchema,
34
+ IServerActionPayload,
35
+ ServerAction,
36
+ TILE_HOSTS_TOKEN,
37
+ } from '@mosetta/server-action/server';
38
+ import type { ICaller, IServerActionAccess, ITileHostBase } from '@mosetta/server-action/server';
39
+ import { mergeTileHosts } from '@mosetta/core';
40
+ import { DiService, SingleDiToken } from '@mosetta/di';
41
+ import type { ZodType } from 'zod';
42
+ import { projectNamespaceOf, unitOf } from '@mosetta/content';
43
+ import type { IContentTransport } from '@mosetta/content';
44
+ import { CONFIG_PATH } from '@mosetta/content/paths';
45
+ import { blockSchemas, settingsIssueLine, treeSettingsIssues } from '@mosetta/settings/check';
46
+ import type { ICatalogSettingsEntry } from '@mosetta/settings/check';
47
+ import {
48
+ ContentActionResult,
49
+ createPageDraftToken,
50
+ createPageToken,
51
+ deletePageToken,
52
+ discardPageDraftToken,
53
+ forkPageDraftToken,
54
+ IKindInput,
55
+ IPageDraftListItem,
56
+ IPageListEntry,
57
+ IPrefabTree,
58
+ listComponentsToken,
59
+ listPagesToken,
60
+ loadPageDraftToken,
61
+ loadPageToken,
62
+ prefabTreesToken,
63
+ projectConfigToken,
64
+ publishPageToken,
65
+ refreshLiveSiteToken,
66
+ savePageDraftToken,
67
+ } from './contentCommon';
68
+
69
+ // ---- catalog: the public tile host descriptors (modules.json) of every tile host of the site ----
70
+
71
+ const CATALOG_TOKEN = new SingleDiToken<CatalogService>('EDITOR_CATALOG_TOKEN');
72
+
73
+ const facetKey = (d: IModuleDescriptor) => `${d.reference.namespace}/${d.reference.name}/${d.reference.version}`;
74
+
75
+ const withSlash = (base: string): string => (base.endsWith('/') ? base : `${base}/`);
76
+
77
+ @DiService(CATALOG_TOKEN)
78
+ class CatalogService {
79
+ /** the site's tile hosts, the primary first */
80
+ private readonly hosts: readonly ITileHostBase[];
81
+
82
+ private readonly fetchImpl: typeof fetch;
83
+
84
+ constructor(
85
+ @DiInject(TILE_HOSTS_TOKEN)
86
+ hosts: readonly ITileHostBase[],
87
+ ) {
88
+ this.hosts = hosts.map(({ key, publicBase }) => ({ key, publicBase: withSlash(publicBase) }));
89
+ this.fetchImpl = fetch.bind(globalThis);
90
+ }
91
+
92
+ /**
93
+ * Every tile the site may place, merged by the factory's own rule (a brand — one tile host, a tile
94
+ * past the primary — with its integrity). The primary's descriptor is the editor's own: without
95
+ * it nothing is offered. Another host that cannot be read is left out, said in the log.
96
+ */
97
+ async descriptors(): Promise<IModuleDescriptor[]> {
98
+ const parts = await Promise.all(this.hosts.map(async ({ key, publicBase }, index) => {
99
+ const res = await this.fetchImpl(`${publicBase}modules.json`);
100
+ if (res.ok) return { key, public: (await res.json()) as IModuleDescriptor[], private: [] };
101
+ await res.body?.cancel();
102
+ if (index === 0) throw new Error(`catalog fetch failed: ${res.status}`);
103
+ if (res.status !== 404) console.warn(`[mosetta editor] tile host ${key} answered ${res.status} — its tiles are not offered`);
104
+ return { key, public: [], private: [] };
105
+ }));
106
+ const merged = mergeTileHosts(parts);
107
+ for (const warning of merged.warnings) console.warn(`[mosetta editor] ${warning}`);
108
+ return [...merged.public.entities];
109
+ }
110
+
111
+ /** where a file of an entity's folder is fetched — beside the entity's own artifact, at its tile host */
112
+ private fileUrl(entity: IModuleDescriptor, file: unknown): string | undefined {
113
+ const key = (entity as { provider?: string }).provider;
114
+ const base = (key ? this.hosts.find((h) => h.key === key) : undefined) ?? this.hosts[0];
115
+ return typeof file === 'string' && file && base ? `${base.publicBase}modules/${entity.id}/${file}` : undefined;
116
+ }
117
+
118
+ /** palette entries: requested types (default: components), each paired with its editor facet */
119
+ async entries(types?: string[]): Promise<ICatalogEntry[]> {
120
+ const all = await this.descriptors();
121
+ const editors = new Set(all.filter((d) => d.reference.type === 'editor').map(facetKey));
122
+ const wanted = new Set(types && types.length ? types : ['component']);
123
+ return all
124
+ .filter((d) => wanted.has(d.reference.type))
125
+ .map((d) => {
126
+ const iconUrl = this.fileUrl(d, (d as { icon?: unknown }).icon);
127
+ return { ...d, hasEditor: editors.has(facetKey(d)), ...(iconUrl ? { iconUrl } : {}) };
128
+ });
129
+ }
130
+
131
+ /** the prefabs the libraries bring — placed LIVE, by a reference that names no version */
132
+ async libraryPrefabs(): Promise<ILibraryPrefabEntry[]> {
133
+ return (await this.descriptors())
134
+ .filter((d) => d.reference.type === 'prefab')
135
+ .map((d) => ({
136
+ namespace: d.reference.namespace,
137
+ name: d.reference.name,
138
+ version: d.reference.version,
139
+ reference: prefabReference(d.reference.namespace, d.reference.name),
140
+ }));
141
+ }
142
+
143
+ /** the distributions the catalog describes — what each calls itself and its pictures */
144
+ async distributions(): Promise<IDistributionEntry[]> {
145
+ return (await this.descriptors())
146
+ .filter((d) => d.reference.type === 'distribution')
147
+ .map((d) => {
148
+ const own = d as unknown as { title?: unknown; description?: unknown; source?: unknown; icon?: unknown; cover?: unknown; internal?: unknown; base?: unknown; changes?: unknown };
149
+ const text = (value: unknown) => (typeof value === 'string' && value ? value : undefined);
150
+ const iconUrl = this.fileUrl(d, own.icon);
151
+ const coverUrl = this.fileUrl(d, own.cover);
152
+ return {
153
+ id: d.id,
154
+ namespace: d.reference.namespace,
155
+ version: d.reference.version,
156
+ title: text(own.title) ?? d.reference.namespace,
157
+ ...(text(own.description) ? { description: text(own.description) } : {}),
158
+ ...(text(own.source) ? { source: text(own.source) } : {}),
159
+ ...(iconUrl ? { iconUrl } : {}),
160
+ ...(coverUrl ? { coverUrl } : {}),
161
+ ...(own.internal === true ? { internal: true } : {}),
162
+ ...(own.base === true ? { base: true } : {}),
163
+ ...(text(own.changes) ? { changes: text(own.changes) } : {}),
164
+ };
165
+ });
166
+ }
167
+
168
+ async knownReferences(): Promise<Set<string>> {
169
+ return new Set((await this.descriptors()).map((d) => d.id));
170
+ }
171
+ }
172
+
173
+ // ---- shared action machinery ----
174
+
175
+ function collectNodeReferences(node: IBlueprintNodeDoc, into: Set<string>): void {
176
+ into.add(node.reference);
177
+ for (const child of node.children ?? []) collectNodeReferences(child, into);
178
+ }
179
+
180
+ function pageReferences(page: IPageDoc): string[] {
181
+ const refs = new Set<string>();
182
+ collectNodeReferences(page, refs);
183
+ if (page.fallback) refs.add(page.fallback);
184
+ return [...refs];
185
+ }
186
+
187
+ /**
188
+ * A tree's references checked the way each kind exists: a module against the catalog; a live
189
+ * PREFAB is content (the project's) or the distribution's and never in the catalog — a missing one
190
+ * renders the node's children without it. Inside a prefab a prefab is refused: one level only.
191
+ */
192
+ function referenceIssues(refs: readonly string[], known: ReadonlySet<string>, kind: ContentKind): string[] {
193
+ return refs.flatMap((ref) => {
194
+ if (unitOf(ref) !== null) return kind === 'prefabs' ? [`a prefab cannot hold a prefab: ${ref}`] : [];
195
+ return known.has(ref) ? [] : [`unknown tile reference: ${ref}`];
196
+ });
197
+ }
198
+
199
+ /** the document as it is stored: a prefab edited in a page's shape goes back to being a prefab */
200
+ const storedFormOf = (kind: ContentKind, page: IPageDoc): unknown => (kind === 'prefabs' ? pageAsPrefab(page) : page);
201
+
202
+ /** the document as the editor edits it: a prefab in a page's shape */
203
+ const editedFormOf = (kind: ContentKind, doc: unknown): IPageDoc => (kind === 'prefabs' ? prefabAsPage(doc as IPrefabDoc) : doc as IPageDoc);
204
+
205
+ class PublishValidationError extends Error {
206
+ constructor(readonly issues: string[]) {
207
+ super(`publish validation failed: ${issues.join('; ')}`);
208
+ }
209
+ }
210
+
211
+ /**
212
+ * An EDITOR of the project — as the host vouches: the platform's Worker names the roles a person
213
+ * holds in the project (`roles: ['editor']`), and `mosetta dev` names the developer at the machine
214
+ * the same way. The content is its editors' to read and write — nobody else's.
215
+ */
216
+ const isEditor = (caller: ICaller | null): boolean => Array.isArray(caller?.roles) && (caller.roles as unknown[]).includes('editor');
217
+
218
+ // Base for every content action: editors only; builds domain schemas from the injected zod builder
219
+ // and maps domain failures to structured results (the editor UI branches on `code`; transport 5xx
220
+ // stays for real bugs).
221
+ abstract class ContentAction<TIn extends {}, TOut extends {}> extends ServerAction<TIn, ContentActionResult<TOut>> {
222
+ // set on every request: the glue invokes createSchema() BEFORE action(), so run() can deep-validate
223
+ // stored documents (e.g. on publish) with the same injected zod the input was parsed with.
224
+ protected schemas!: ContentSchemas;
225
+
226
+ createSchema({ builder }: ICreateSchema): ZodType<TIn> {
227
+ this.schemas = createContentSchemas(builder);
228
+ return this.inputSchema(builder, this.schemas);
229
+ }
230
+
231
+ authorize({ caller }: IServerActionAccess<TIn>): boolean {
232
+ return isEditor(caller);
233
+ }
234
+
235
+ protected abstract inputSchema(builder: ICreateSchema['builder'], schemas: ContentSchemas): ZodType<TIn>;
236
+
237
+ protected abstract run(input: TIn): Promise<TOut>;
238
+
239
+ async action({ input }: IServerActionPayload<TIn>): Promise<ContentActionResult<TOut>> {
240
+ try {
241
+ return { ok: true, ...(await this.run(input)) };
242
+ } catch (e) {
243
+ if (e instanceof ContentError) return { ok: false, code: e.code, message: e.message };
244
+ if (e instanceof PublishValidationError) {
245
+ return { ok: false, code: 'PUBLISH_VALIDATION', message: e.message, issues: e.issues };
246
+ }
247
+ if (e instanceof ContentTransportError) {
248
+ return { ok: false, code: e.status === 409 ? 'IMMUTABLE' : 'TRANSPORT', message: e.message };
249
+ }
250
+ throw e; // unexpected → the glue's 500
251
+ }
252
+ }
253
+ }
254
+
255
+ // ---- pages and prefabs ----
256
+
257
+ @DiServerAction(listPagesToken)
258
+ class ListPagesAction extends ContentAction<IKindInput, { pages: IPageListEntry[] }> {
259
+ constructor(@DiInject(CONTENT_REPOSITORY_TOKEN) private readonly content: ContentRepository) {
260
+ super();
261
+ }
262
+
263
+ protected inputSchema(builder: ICreateSchema['builder'], s: ContentSchemas) {
264
+ return builder.object({ kind: s.contentKind.optional() });
265
+ }
266
+
267
+ // The listing carries ROUTES: patterns are versioned with the documents. The live one is read
268
+ // off the publish markers (the route table itself), a draft's comes with its summary — one
269
+ // listing and the entities side by side, no document opened twice. A prefab has no route.
270
+ protected async run({ kind = 'pages' }: IKindInput) {
271
+ const [entities, live] = await Promise.all([
272
+ this.content.list(kind),
273
+ kind === 'pages' ? this.content.livePatterns().catch(() => new Map<string, string>()) : Promise.resolve(new Map<string, string>()),
274
+ ]);
275
+ const pages: IPageListEntry[] = entities.map((entity) => ({
276
+ name: entity.name,
277
+ meta: entity.meta,
278
+ drafts: entity.drafts.map(({ pattern, ...draft }): IPageDraftListItem => ({ ...draft, pattern: kind === 'pages' ? pattern ?? null : null })),
279
+ livePattern: kind === 'pages' && entity.meta.live != null ? live.get(entity.name) ?? null : null,
280
+ }));
281
+ return { pages };
282
+ }
283
+ }
284
+
285
+ type TLoadInput = { name: string; rev?: number } & IKindInput;
286
+
287
+ @DiServerAction(loadPageToken)
288
+ class LoadPageAction extends ContentAction<TLoadInput, { page: ILoadedContent<IPageDoc> }> {
289
+ constructor(@DiInject(CONTENT_REPOSITORY_TOKEN) private readonly content: ContentRepository) {
290
+ super();
291
+ }
292
+
293
+ protected inputSchema(builder: ICreateSchema['builder'], s: ContentSchemas) {
294
+ return builder.object({ name: s.entityName, rev: s.revision.optional(), kind: s.contentKind.optional() });
295
+ }
296
+
297
+ protected async run({ name, rev, kind = 'pages' }: TLoadInput) {
298
+ const loaded = await this.content.load<unknown>(kind, name, rev);
299
+ return { page: { ...loaded, doc: editedFormOf(kind, loaded.doc) } };
300
+ }
301
+ }
302
+
303
+ type TDraftInput = { name: string; draftId: string } & IKindInput;
304
+
305
+ @DiServerAction(loadPageDraftToken)
306
+ class LoadPageDraftAction extends ContentAction<TDraftInput, { draft: ILoadedDraft<IPageDoc> }> {
307
+ constructor(@DiInject(CONTENT_REPOSITORY_TOKEN) private readonly content: ContentRepository) {
308
+ super();
309
+ }
310
+
311
+ protected inputSchema(builder: ICreateSchema['builder'], s: ContentSchemas) {
312
+ return builder.object({ name: s.entityName, draftId: s.draftId, kind: s.contentKind.optional() });
313
+ }
314
+
315
+ protected async run({ name, draftId, kind = 'pages' }: TDraftInput) {
316
+ const loaded = await this.content.loadDraft<unknown>(kind, name, draftId);
317
+ return { draft: { ...loaded, doc: editedFormOf(kind, loaded.doc) } };
318
+ }
319
+ }
320
+
321
+ type TPageInput = { name: string; page: IPageDoc } & IKindInput;
322
+
323
+ @DiServerAction(createPageToken)
324
+ class CreatePageAction extends ContentAction<TPageInput, { draftId: string }> {
325
+ constructor(@DiInject(CONTENT_REPOSITORY_TOKEN) private readonly content: ContentRepository) {
326
+ super();
327
+ }
328
+
329
+ protected inputSchema(builder: ICreateSchema['builder'], s: ContentSchemas) {
330
+ return builder.object({ name: s.entityName, page: s.pageDoc, kind: s.contentKind.optional() }) as unknown as ZodType<TPageInput>;
331
+ }
332
+
333
+ protected async run({ name, page, kind = 'pages' }: TPageInput) {
334
+ return { draftId: await this.content.create(kind, name, storedFormOf(kind, page), kind === 'prefabs' ? 'unit' : undefined) };
335
+ }
336
+ }
337
+
338
+ type TSaveInput = { name: string; draftId: string; page: IPageDoc } & IKindInput;
339
+
340
+ @DiServerAction(savePageDraftToken)
341
+ class SavePageDraftAction extends ContentAction<TSaveInput, { warnings: string[] }> {
342
+ constructor(
343
+ @DiInject(CONTENT_REPOSITORY_TOKEN) private readonly content: ContentRepository,
344
+ @DiInject(CATALOG_TOKEN) private readonly catalog: CatalogService,
345
+ ) {
346
+ super();
347
+ }
348
+
349
+ protected inputSchema(builder: ICreateSchema['builder'], s: ContentSchemas) {
350
+ return builder.object({
351
+ name: s.entityName,
352
+ draftId: s.draftId,
353
+ page: s.pageDoc,
354
+ kind: s.contentKind.optional(),
355
+ }) as unknown as ZodType<TSaveInput>;
356
+ }
357
+
358
+ // save is SOFT on referential integrity (a draft may be half-done, a module may not be built yet):
359
+ // unknown references come back as warnings; publish is where they become errors. Concurrency is
360
+ // NOT soft: a page whose seq does not follow the stored draft's comes back as CONFLICT.
361
+ protected async run({ name, draftId, page, kind = 'pages' }: TSaveInput) {
362
+ const known = await this.catalog.knownReferences();
363
+ const warnings = referenceIssues(pageReferences(page), known, kind);
364
+ await this.content.saveDraft(kind, name, draftId, storedFormOf(kind, page));
365
+ return { warnings };
366
+ }
367
+ }
368
+
369
+ type TCreateDraftInput = { name: string; fromRev?: number } & IKindInput;
370
+
371
+ @DiServerAction(createPageDraftToken)
372
+ class CreatePageDraftAction extends ContentAction<TCreateDraftInput, { draftId: string; page: IPageDoc }> {
373
+ constructor(@DiInject(CONTENT_REPOSITORY_TOKEN) private readonly content: ContentRepository) {
374
+ super();
375
+ }
376
+
377
+ protected inputSchema(builder: ICreateSchema['builder'], s: ContentSchemas) {
378
+ return builder.object({ name: s.entityName, fromRev: s.revision.optional(), kind: s.contentKind.optional() });
379
+ }
380
+
381
+ protected async run({ name, fromRev, kind = 'pages' }: TCreateDraftInput) {
382
+ const { id, doc } = await this.content.createDraft(kind, name, fromRev);
383
+ return { draftId: id, page: editedFormOf(kind, doc) };
384
+ }
385
+ }
386
+
387
+ @DiServerAction(forkPageDraftToken)
388
+ class ForkPageDraftAction extends ContentAction<TPageInput, { draftId: string; page: IPageDoc }> {
389
+ constructor(@DiInject(CONTENT_REPOSITORY_TOKEN) private readonly content: ContentRepository) {
390
+ super();
391
+ }
392
+
393
+ protected inputSchema(builder: ICreateSchema['builder'], s: ContentSchemas) {
394
+ return builder.object({ name: s.entityName, page: s.pageDoc, kind: s.contentKind.optional() }) as unknown as ZodType<TPageInput>;
395
+ }
396
+
397
+ protected async run({ name, page, kind = 'pages' }: TPageInput) {
398
+ const { id, doc } = await this.content.forkDraft(kind, name, storedFormOf(kind, page));
399
+ return { draftId: id, page: editedFormOf(kind, doc) };
400
+ }
401
+ }
402
+
403
+ type TPublishInput = TDraftInput & { title?: string };
404
+
405
+ @DiServerAction(publishPageToken)
406
+ class PublishPageAction extends ContentAction<TPublishInput, { live: number }> {
407
+ constructor(
408
+ @DiInject(CONTENT_REPOSITORY_TOKEN) private readonly content: ContentRepository,
409
+ @DiInject(CATALOG_TOKEN) private readonly catalog: CatalogService,
410
+ @DiInject(CONTENT_TRANSPORT_TOKEN) private readonly transport: IContentTransport,
411
+ ) {
412
+ super();
413
+ }
414
+
415
+ /** a prefab of the project the page places must be published — a draft-only one would show nothing */
416
+ private async unpublishedPrefabs(refs: readonly string[]): Promise<string[]> {
417
+ const namespace = projectNamespaceOf(await this.transport.read(CONFIG_PATH).catch(() => null));
418
+ const own = refs.map(unitOf).filter((unit) => unit !== null && unit.namespace === namespace);
419
+ if (!own.length) return [];
420
+ const live = new Set((await this.content.list('prefabs')).filter((e) => e.meta.live != null).map((e) => e.name));
421
+ return own.filter((unit) => !live.has(unit!.name)).map((unit) => `the prefab ${unit!.name} is not published`);
422
+ }
423
+
424
+ protected inputSchema(builder: ICreateSchema['builder'], s: ContentSchemas) {
425
+ return builder.object({ name: s.entityName, draftId: s.draftId, kind: s.contentKind.optional(), title: builder.string().max(80).optional() });
426
+ }
427
+
428
+ // publish is STRICT: what would go live must re-validate against the schema (the draft may have
429
+ // been hand-edited on disk), every reference must exist in the catalog, every block's settings
430
+ // must fit what its block describes (its catalog entry's settings schema — a draft may hold
431
+ // anything while it is worked on, the live site only what fits), and a page's route pattern must
432
+ // be unique among the OTHER live pages. Validated in its PUBLISHED form: the working fields (undo
433
+ // stacks, seq, …) are stripped by the publish itself.
434
+ protected async run({ name, draftId, kind = 'pages', title }: TPublishInput) {
435
+ const { doc: stored } = await this.content.loadDraft<object>(kind, name, draftId);
436
+ const doc = publishedFormOf(stored);
437
+ const issues: string[] = [];
438
+
439
+ const shape = (kind === 'prefabs' ? this.schemas.prefabDoc : this.schemas.pageDoc).safeParse(doc);
440
+ if (!shape.success) issues.push(...shape.error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`));
441
+
442
+ const descriptors = await this.catalog.descriptors();
443
+ const known = new Set(descriptors.map((d) => d.id));
444
+ const edited = editedFormOf(kind, doc);
445
+ const refs = pageReferences(edited);
446
+ issues.push(...referenceIssues(refs, known, kind));
447
+ issues.push(...treeSettingsIssues(edited, blockSchemas(descriptors as readonly ICatalogSettingsEntry[])).map(settingsIssueLine));
448
+
449
+ if (kind === 'pages') {
450
+ // the live routes are the publish markers — read side by side with the prefab check
451
+ const pattern = (doc as IPageDoc).pattern;
452
+ const [prefabIssues, live] = await Promise.all([this.unpublishedPrefabs(refs), this.content.livePatterns()]);
453
+ issues.push(...prefabIssues);
454
+ for (const [other, otherPattern] of live) {
455
+ if (other !== name && otherPattern === pattern) issues.push(`pattern ${pattern} is already live on page ${other}`);
456
+ }
457
+ }
458
+
459
+ if (issues.length) throw new PublishValidationError(issues);
460
+ return { live: await this.content.publish(kind, name, draftId, title) };
461
+ }
462
+ }
463
+
464
+ @DiServerAction(prefabTreesToken)
465
+ class PrefabTreesAction extends ContentAction<{ names: string[] }, { prefabs: IPrefabTree[] }> {
466
+ constructor(@DiInject(CONTENT_REPOSITORY_TOKEN) private readonly content: ContentRepository) {
467
+ super();
468
+ }
469
+
470
+ protected inputSchema(builder: ICreateSchema['builder'], s: ContentSchemas) {
471
+ return builder.object({ names: builder.array(s.entityName).max(100) });
472
+ }
473
+
474
+ protected async run({ names }: { names: string[] }) {
475
+ const loaded = await Promise.all([...new Set(names)].map(async (name): Promise<IPrefabTree | null> => {
476
+ try {
477
+ const { rev, doc } = await this.content.load<IPrefabDoc>('prefabs', name);
478
+ return { name, rev, doc: prefabAsPage(doc) };
479
+ } catch {
480
+ return null; // not there, or never published: nothing to show for it
481
+ }
482
+ }));
483
+ return { prefabs: loaded.filter((p): p is IPrefabTree => p !== null) };
484
+ }
485
+ }
486
+
487
+ /** the project's content config as it is — a project-wide block (i18n) reads its languages */
488
+ @DiServerAction(projectConfigToken)
489
+ class ProjectConfigAction extends ContentAction<{}, { config: Record<string, unknown> }> {
490
+ constructor(@DiInject(CONTENT_TRANSPORT_TOKEN) private readonly transport: IContentTransport) {
491
+ super();
492
+ }
493
+
494
+ protected inputSchema(builder: ICreateSchema['builder']) {
495
+ return builder.object({});
496
+ }
497
+
498
+ protected async run() {
499
+ const config = await this.transport.read(CONFIG_PATH).catch(() => null);
500
+ return { config: config && typeof config === 'object' && !Array.isArray(config) ? config as Record<string, unknown> : {} };
501
+ }
502
+ }
503
+
504
+ @DiServerAction(refreshLiveSiteToken)
505
+ class RefreshLiveSiteAction extends ContentAction<{}, { refreshed: boolean }> {
506
+ constructor(@DiInject(CONTENT_REPOSITORY_TOKEN) private readonly content: ContentRepository) {
507
+ super();
508
+ }
509
+
510
+ protected inputSchema(builder: ICreateSchema['builder']) {
511
+ return builder.object({});
512
+ }
513
+
514
+ protected async run() {
515
+ return { refreshed: await this.content.refreshLiveSite() };
516
+ }
517
+ }
518
+
519
+ @DiServerAction(discardPageDraftToken)
520
+ class DiscardPageDraftAction extends ContentAction<TDraftInput, {}> {
521
+ constructor(@DiInject(CONTENT_REPOSITORY_TOKEN) private readonly content: ContentRepository) {
522
+ super();
523
+ }
524
+
525
+ protected inputSchema(builder: ICreateSchema['builder'], s: ContentSchemas) {
526
+ return builder.object({ name: s.entityName, draftId: s.draftId, kind: s.contentKind.optional() });
527
+ }
528
+
529
+ protected async run({ name, draftId, kind = 'pages' }: TDraftInput) {
530
+ await this.content.discardDraft(kind, name, draftId);
531
+ return {};
532
+ }
533
+ }
534
+
535
+ type TNameInput = { name: string } & IKindInput;
536
+
537
+ @DiServerAction(deletePageToken)
538
+ class DeletePageAction extends ContentAction<TNameInput, {}> {
539
+ constructor(@DiInject(CONTENT_REPOSITORY_TOKEN) private readonly content: ContentRepository) {
540
+ super();
541
+ }
542
+
543
+ protected inputSchema(builder: ICreateSchema['builder'], s: ContentSchemas) {
544
+ return builder.object({ name: s.entityName, kind: s.contentKind.optional() });
545
+ }
546
+
547
+ protected async run({ name, kind = 'pages' }: TNameInput) {
548
+ await this.content.delete(kind, name);
549
+ return {};
550
+ }
551
+ }
552
+
553
+ // ---- catalog ----
554
+
555
+ @DiServerAction(listComponentsToken)
556
+ class ListComponentsAction extends ContentAction<{ types?: string[] }, { components: ICatalogEntry[]; distributions: IDistributionEntry[]; prefabs: ILibraryPrefabEntry[] }> {
557
+ constructor(@DiInject(CATALOG_TOKEN) private readonly catalog: CatalogService) {
558
+ super();
559
+ }
560
+
561
+ protected inputSchema(builder: ICreateSchema['builder']) {
562
+ return builder.object({ types: builder.array(builder.string()).optional() });
563
+ }
564
+
565
+ protected async run({ types }: { types?: string[] }) {
566
+ const [components, distributions, prefabs] = await Promise.all([this.catalog.entries(types), this.catalog.distributions(), this.catalog.libraryPrefabs()]);
567
+ return { components, distributions, prefabs };
568
+ }
569
+ }
570
+
571
+ // DevContentTransport in `services` is the OPT-IN write capability:
572
+ // the editor is the trusted writer; a module without this line cannot resolve the transport.
573
+ export default defineServerSide({
574
+ services: [
575
+ DevContentTransport,
576
+ ContentRepository,
577
+ CatalogService,
578
+ ListPagesAction,
579
+ LoadPageAction,
580
+ LoadPageDraftAction,
581
+ CreatePageAction,
582
+ SavePageDraftAction,
583
+ CreatePageDraftAction,
584
+ ForkPageDraftAction,
585
+ PublishPageAction,
586
+ ProjectConfigAction,
587
+ RefreshLiveSiteAction,
588
+ PrefabTreesAction,
589
+ DiscardPageDraftAction,
590
+ DeletePageAction,
591
+ ListComponentsAction,
592
+ ],
593
+ });