@mosetta/editor 0.0.0-stage → 0.0.29

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 +190 -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,376 @@
1
+ /* The shell: a sidebar column and the content beside it, both the full height of the page. */
2
+ .workbench {
3
+ --wb-sidebar: 232px;
4
+ --wb-border: #e4e4e7;
5
+ --wb-frame: #e8e8ea;
6
+ --wb-ink: #18181b;
7
+ --wb-muted: #71717a;
8
+ --wb-blue: #2563eb;
9
+ --wb-tab-radius: 9px;
10
+
11
+ box-sizing: border-box;
12
+ display: flex;
13
+ width: 100%;
14
+ height: 100vh;
15
+ min-height: 0;
16
+ overflow: hidden;
17
+ font: var(--mosetta-wb-font)/1.45 ui-sans-serif, system-ui, sans-serif;
18
+ color: var(--wb-ink);
19
+ background: #ffffff;
20
+ }
21
+
22
+ .workbench--bare {
23
+ display: block;
24
+ height: auto;
25
+ padding: 16px;
26
+ color: var(--wb-muted);
27
+ }
28
+
29
+ /* a pane kept mounted but out of sight: a canvas behind another tab, the editor behind Home */
30
+ .workbench__pane--hidden {
31
+ display: none !important;
32
+ }
33
+
34
+ /* ---- the sidebar ---------------------------------------------------------------------------- */
35
+
36
+ .workbench__sidebar {
37
+ display: flex;
38
+ flex: none;
39
+ flex-direction: column;
40
+ width: var(--wb-sidebar);
41
+ min-height: 0;
42
+ padding: 10px 10px 12px;
43
+ border-right: 1px solid var(--wb-border);
44
+ background: #fafafa;
45
+ }
46
+
47
+ .workbench__sidebar-head {
48
+ display: flex;
49
+ align-items: center;
50
+ gap: 2px;
51
+ margin-bottom: 10px;
52
+ }
53
+
54
+ /* the two small buttons beside the project: pin, fold — quiet, but always in sight */
55
+ .workbench__sidebar-button {
56
+ display: flex;
57
+ align-items: center;
58
+ justify-content: center;
59
+ flex: none;
60
+ width: 26px;
61
+ height: 26px;
62
+ padding: 0;
63
+ border: 0;
64
+ border-radius: 6px;
65
+ background: none;
66
+ color: #a1a1aa;
67
+ cursor: pointer;
68
+ }
69
+
70
+ .workbench__sidebar-button:hover {
71
+ background: #e4e4e7;
72
+ color: var(--wb-ink);
73
+ }
74
+
75
+ .workbench__sidebar-button--on {
76
+ color: var(--wb-blue);
77
+ }
78
+
79
+ .workbench__sidebar-button svg {
80
+ width: var(--mosetta-wb-icon-sm);
81
+ height: var(--mosetta-wb-icon-sm);
82
+ }
83
+
84
+ .workbench__project {
85
+ display: flex;
86
+ align-items: center;
87
+ gap: 10px;
88
+ flex: 1;
89
+ min-width: 0;
90
+ padding: 6px 8px;
91
+ border: 0;
92
+ border-radius: 8px;
93
+ background: none;
94
+ font: inherit;
95
+ font-weight: 600;
96
+ color: var(--wb-ink);
97
+ text-align: left;
98
+ cursor: pointer;
99
+ }
100
+
101
+ .workbench__project:hover {
102
+ background: #efeff1;
103
+ }
104
+
105
+ .workbench__project-name {
106
+ min-width: 0;
107
+ overflow: hidden;
108
+ text-overflow: ellipsis;
109
+ white-space: nowrap;
110
+ }
111
+
112
+ /* the way back to the platform's projects list — quiet, above the project it leaves */
113
+ .workbench__back {
114
+ display: flex;
115
+ box-sizing: border-box;
116
+ align-items: center;
117
+ gap: 6px;
118
+ width: 100%;
119
+ margin-bottom: 6px;
120
+ padding: 4px 8px;
121
+ border-radius: 7px;
122
+ color: var(--wb-muted);
123
+ font-size: var(--mosetta-wb-font-sm);
124
+ text-decoration: none;
125
+ }
126
+
127
+ .workbench__back:hover {
128
+ background: #efeff1;
129
+ color: var(--wb-ink);
130
+ }
131
+
132
+ .workbench__back svg {
133
+ flex: none;
134
+ width: var(--mosetta-wb-icon);
135
+ height: var(--mosetta-wb-icon);
136
+ }
137
+
138
+ .workbench__sidebar--compact .workbench__back {
139
+ justify-content: center;
140
+ padding: 6px 0;
141
+ }
142
+
143
+ .workbench__nav {
144
+ display: flex;
145
+ flex-direction: column;
146
+ gap: 2px;
147
+ }
148
+
149
+ /* ---- compact: the icons alone, every word in a tooltip ---------------------------------------- */
150
+
151
+ .workbench__sidebar--compact {
152
+ width: 56px;
153
+ padding: 10px 8px 12px;
154
+ align-items: center;
155
+ }
156
+
157
+ .workbench__sidebar--compact .workbench__sidebar-head {
158
+ flex-direction: column;
159
+ gap: 4px;
160
+ margin-bottom: 4px;
161
+ }
162
+
163
+ .workbench__sidebar--compact .workbench__project {
164
+ justify-content: center;
165
+ padding: 6px;
166
+ }
167
+
168
+ .workbench__sidebar--compact .workbench__project-name,
169
+ .workbench__sidebar--compact .workbench__nav-label,
170
+ .workbench__sidebar--compact .workbench__account-login,
171
+ .workbench__sidebar--compact .workbench__account-out {
172
+ display: none;
173
+ }
174
+
175
+ .workbench__sidebar--compact .workbench__sidebar-button--unfold {
176
+ margin-bottom: 8px;
177
+ }
178
+
179
+ .workbench__sidebar--compact .workbench__nav {
180
+ width: 100%;
181
+ }
182
+
183
+ .workbench__sidebar--compact .workbench__nav-item {
184
+ justify-content: center;
185
+ padding: 8px 0;
186
+ }
187
+
188
+ .workbench__sidebar--compact .workbench__account {
189
+ justify-content: center;
190
+ width: 100%;
191
+ padding: 8px 0 0;
192
+ }
193
+
194
+ .workbench__nav-item,
195
+ .workbench__recent-item {
196
+ display: flex;
197
+ box-sizing: border-box;
198
+ align-items: center;
199
+ gap: 9px;
200
+ /* the column's width, padding included — wider, an item stuck out past the column on the right
201
+ and the recent list (which scrolls, so it clips) cut its words' ellipsis off */
202
+ width: 100%;
203
+ padding: 6px 8px;
204
+ border: 0;
205
+ border-radius: 7px;
206
+ background: none;
207
+ font: inherit;
208
+ color: #3f3f46;
209
+ text-align: left;
210
+ cursor: pointer;
211
+ }
212
+
213
+ .workbench__nav-item:hover,
214
+ .workbench__recent-item:hover {
215
+ background: #efeff1;
216
+ color: var(--wb-ink);
217
+ }
218
+
219
+ .workbench__nav-item--active,
220
+ .workbench__nav-item--active:hover {
221
+ background: #e4e4e7;
222
+ color: var(--wb-ink);
223
+ font-weight: 600;
224
+ }
225
+
226
+ .workbench__nav-icon {
227
+ display: flex;
228
+ flex: none;
229
+ color: var(--wb-muted);
230
+ }
231
+
232
+ .workbench__nav-item--active .workbench__nav-icon {
233
+ color: var(--wb-ink);
234
+ }
235
+
236
+ .workbench__nav-icon svg {
237
+ width: var(--mosetta-wb-icon);
238
+ height: var(--mosetta-wb-icon);
239
+ }
240
+
241
+ .workbench__recent {
242
+ display: flex;
243
+ flex-direction: column;
244
+ gap: 1px;
245
+ min-height: 0;
246
+ margin-top: 18px;
247
+ overflow-y: auto;
248
+ }
249
+
250
+ .workbench__recent-title {
251
+ padding: 0 8px 4px;
252
+ font-size: var(--mosetta-wb-font-2xs);
253
+ font-weight: 600;
254
+ letter-spacing: 0.06em;
255
+ text-transform: uppercase;
256
+ color: var(--wb-muted);
257
+ }
258
+
259
+ .workbench__recent-item {
260
+ padding: 4px 8px;
261
+ font-size: var(--mosetta-wb-font-sm);
262
+ }
263
+
264
+ .workbench__recent-label {
265
+ min-width: 0;
266
+ overflow: hidden;
267
+ text-overflow: ellipsis;
268
+ white-space: nowrap;
269
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
270
+ }
271
+
272
+ .workbench__recent-item[data-recent-kind='prefabs'] .workbench__recent-label {
273
+ font-family: inherit;
274
+ }
275
+
276
+ /* the account at the bottom, as every dashboard has it */
277
+ .workbench__account {
278
+ display: flex;
279
+ align-items: center;
280
+ gap: 9px;
281
+ margin-top: auto;
282
+ padding: 8px 8px 0;
283
+ border-top: 1px solid var(--wb-border);
284
+ }
285
+
286
+ .workbench__account-mosaic {
287
+ flex: none;
288
+ border-radius: 7px;
289
+ }
290
+
291
+ .workbench__account-login {
292
+ min-width: 0;
293
+ overflow: hidden;
294
+ text-overflow: ellipsis;
295
+ white-space: nowrap;
296
+ font-weight: 500;
297
+ }
298
+
299
+ .workbench__account-out {
300
+ margin-left: auto;
301
+ font-size: var(--mosetta-wb-font-xs);
302
+ color: var(--wb-muted);
303
+ text-decoration: none;
304
+ }
305
+
306
+ .workbench__account-out:hover {
307
+ color: var(--wb-ink);
308
+ text-decoration: underline;
309
+ }
310
+
311
+ /* ---- the content ---------------------------------------------------------------------------- */
312
+
313
+ .workbench__main {
314
+ display: flex;
315
+ flex: 1;
316
+ flex-direction: column;
317
+ min-width: 0;
318
+ min-height: 0;
319
+ }
320
+
321
+ .workbench__list {
322
+ display: flex;
323
+ flex: 1;
324
+ flex-direction: column;
325
+ min-height: 0;
326
+ }
327
+
328
+ /* An opened module sits behind the renderer's scope wrappers (`display: contents`), so the flex
329
+ item is the module's own root a couple of levels down: the rules below reach every level —
330
+ a contents-wrapper ignores flex properties, the module's root takes them. */
331
+ .workbench__list > *,
332
+ .workbench__list > * > *,
333
+ .workbench__list > * > * > * {
334
+ flex: 1;
335
+ min-height: 0;
336
+ }
337
+
338
+ .workbench__button {
339
+ display: inline-flex;
340
+ align-items: center;
341
+ gap: 6px;
342
+ padding: 6px 12px;
343
+ border: 1px solid var(--wb-blue);
344
+ border-radius: 7px;
345
+ background: var(--wb-blue);
346
+ font: inherit;
347
+ font-size: var(--mosetta-wb-font-sm);
348
+ font-weight: 500;
349
+ color: #ffffff;
350
+ text-decoration: none;
351
+ cursor: pointer;
352
+ }
353
+
354
+ .workbench__button:hover {
355
+ background: #1d4ed8;
356
+ border-color: #1d4ed8;
357
+ }
358
+
359
+ .workbench__button--ghost {
360
+ border-color: #d4d4d8;
361
+ background: #ffffff;
362
+ color: var(--wb-ink);
363
+ }
364
+
365
+ .workbench__button--ghost:hover {
366
+ background: #f4f4f5;
367
+ border-color: #d4d4d8;
368
+ }
369
+
370
+ /* the sidebar's ways are links now (they move in place): they dress as the buttons they replaced */
371
+ .workbench__project,
372
+ .workbench__nav-item,
373
+ .workbench__recent-item {
374
+ color: inherit;
375
+ text-decoration: none;
376
+ }
@@ -0,0 +1,202 @@
1
+ // The workbench's ADDRESS: pure functions between a URL and what the workbench shows.
2
+ //
3
+ // /workbench → Home
4
+ // /workbench/pages → the pages, as a tree of routes
5
+ // /workbench/prefabs → the prefabs
6
+ // /workbench/pages/<page> → the editor, this page in front — `?draft=<id>` names the
7
+ // draft being edited, `?rev=<n>` the revision being inspected
8
+ // /workbench/prefabs/<name> → the same for a prefab
9
+ // ?open=<page>__<draft>~<page>__r.<n>~prefab.<name>__<draft>
10
+ // → EVERY open tab, in order (the one in front included), so a
11
+ // reload — or the same link on another machine — brings the
12
+ // same tabs back. A page's STORAGE NAME is its id here: the
13
+ // route is what the screen shows, the name is what stays put.
14
+ // ?tree=<px> → how wide the tree column is for the document in front: a
15
+ // prefab wants a wide outline, a page a narrow one
16
+ // ?tool=<id>&tool-state=<text> → the editor tool open over the document in front (ADR-0062)
17
+ // and what of it is open — the tool's own words
18
+ //
19
+ // The workbench is THREE pages (ADR-0051 §4): Home under `/workbench/{...rest}`, a list under
20
+ // `/workbench/{kind}`, the editor under `/workbench/{kind}/{name}` — the base is a pattern without
21
+ // its parameters, so a project that puts its editor elsewhere keeps working.
22
+ import type { ContentKind } from '@mosetta/content';
23
+
24
+ export const OPEN_PARAM = 'open';
25
+ export const DRAFT_PARAM = 'draft';
26
+ export const REVISION_PARAM = 'rev';
27
+ /** the tree column's width (px) for the document in front — a preference of this document */
28
+ export const TREE_PARAM = 'tree';
29
+ /** the editor tool open over the document in front, and the tool's own state */
30
+ export const TOOL_PARAM = 'tool';
31
+ export const TOOL_STATE_PARAM = 'tool-state';
32
+
33
+ /** an editor tool open over a document: which, and what the tool says of itself */
34
+ export interface IToolAddress {
35
+ readonly tool: string;
36
+ readonly state: string | null;
37
+ }
38
+
39
+ const KIND_SEGMENT: Record<ContentKind, string> = { pages: 'pages', prefabs: 'prefabs' };
40
+ const ENTRY_SEPARATOR = '~';
41
+ const FIELD_SEPARATOR = '__';
42
+ const PREFAB_ENTRY_PREFIX = 'prefab.';
43
+ /** a revision in the draft's place: `r.3` — a draft id never holds a dot */
44
+ const REVISION_RE = /^r\.([1-9]\d{0,8})$/;
45
+ const REST_TAIL_RE = /\/\{\.\.\.[^}]*\}$/;
46
+ /** the tabs of the editor, remembered by this browser tab while the author is away in a list */
47
+ export const TABS_MEMORY_KEY = 'mosetta-workbench-tabs';
48
+
49
+ /** one document a tab shows: a page's (or a prefab's) draft, or one of its revisions */
50
+ export interface IOpenDoc {
51
+ readonly kind: ContentKind;
52
+ /** the page's (or prefab's) storage name */
53
+ readonly page: string;
54
+ readonly draftId?: string;
55
+ readonly revision?: number;
56
+ }
57
+
58
+ export type TWorkbenchView
59
+ = | { readonly view: 'home' }
60
+ | { readonly view: 'list'; readonly kind: ContentKind }
61
+ | ({ readonly view: 'editor' } & IOpenDoc)
62
+ // the distributions the blocks come from (ADR-0059) — the list page shows them
63
+ | { readonly view: 'distributions' };
64
+
65
+ /** the address segment of the tile palettes view: `/workbench/palettes` */
66
+ export const DISTRIBUTIONS_SEGMENT = 'palettes';
67
+
68
+ /** what the editor's address says: the document in front, every open tab, the tree's width, the tool open */
69
+ export interface IEditorAddress {
70
+ readonly doc: IOpenDoc;
71
+ readonly open: readonly IOpenDoc[];
72
+ readonly tree: number | null;
73
+ readonly tool: IToolAddress | null;
74
+ }
75
+
76
+ export function sameDoc(a: IOpenDoc, b: IOpenDoc): boolean {
77
+ return a.kind === b.kind && a.page === b.page && a.draftId === b.draftId && a.revision === b.revision;
78
+ }
79
+
80
+ /** a document named by the path alone — the version to show is still to be decided */
81
+ export const isUnversioned = (doc: IOpenDoc): boolean => doc.draftId === undefined && doc.revision === undefined;
82
+
83
+ export const kindOfSegment = (segment: string): ContentKind | null => (
84
+ Object.entries(KIND_SEGMENT).find(([, s]) => s === segment)?.[0] as ContentKind | undefined
85
+ ) ?? null;
86
+
87
+ /** the path split the way the router reads it: decoded segments, empty ones dropped */
88
+ function segmentsOf(pathname: string): string[] {
89
+ return pathname.split('/').filter(Boolean).map((segment) => {
90
+ try {
91
+ return decodeURIComponent(segment);
92
+ } catch {
93
+ return segment;
94
+ }
95
+ });
96
+ }
97
+
98
+ /**
99
+ * Where the workbench lives: the page's pattern minus its parameters — `/workbench` for
100
+ * `/workbench/{...rest}`, `/workbench/{kind}` and `/workbench/{kind}/{name}` alike. Without a
101
+ * pattern (a realm the server did not stamp), the first segment of the path serves —
102
+ * `/workbench` for `/workbench/pages/home`.
103
+ */
104
+ export function basePathOf(pattern: string | null, pathname: string): string {
105
+ if (pattern) {
106
+ const literal: string[] = [];
107
+ for (const segment of segmentsOf(pattern.replace(REST_TAIL_RE, ''))) {
108
+ if (segment.startsWith('{')) break;
109
+ literal.push(segment);
110
+ }
111
+ return `/${literal.join('/')}`;
112
+ }
113
+ const [first] = segmentsOf(pathname);
114
+ return first ? `/${first}` : '/';
115
+ }
116
+
117
+ /** the path (with the version query) of a view — the `open` parameter is added by the caller */
118
+ export function pathOfView(base: string, view: TWorkbenchView): { pathname: string; version: Record<string, string> } {
119
+ const root = base === '/' ? '' : base;
120
+ if (view.view === 'home') return { pathname: root || '/', version: {} };
121
+ if (view.view === 'list') return { pathname: `${root}/${KIND_SEGMENT[view.kind]}`, version: {} };
122
+ if (view.view === 'distributions') return { pathname: `${root}/${DISTRIBUTIONS_SEGMENT}`, version: {} };
123
+ const version: Record<string, string> = view.draftId !== undefined
124
+ ? { [DRAFT_PARAM]: view.draftId }
125
+ : view.revision !== undefined ? { [REVISION_PARAM]: String(view.revision) } : {};
126
+ return { pathname: `${root}/${KIND_SEGMENT[view.kind]}/${encodeURIComponent(view.page)}`, version };
127
+ }
128
+
129
+ /** `page__draft~…`, or null when nothing is open (the parameter leaves the address) */
130
+ export function serializeOpenDocs(docs: readonly IOpenDoc[]): string | null {
131
+ const entries = docs.filter((doc) => !isUnversioned(doc));
132
+ if (entries.length === 0) return null;
133
+ return entries
134
+ .map((doc) => [
135
+ doc.kind === 'prefabs' ? `${PREFAB_ENTRY_PREFIX}${doc.page}` : doc.page,
136
+ doc.revision !== undefined ? `r.${doc.revision}` : doc.draftId,
137
+ ].join(FIELD_SEPARATOR))
138
+ .join(ENTRY_SEPARATOR);
139
+ }
140
+
141
+ /** tolerant of hand-mangled addresses: a malformed entry is dropped, the rest survive */
142
+ export function parseOpenDocs(value: string | null): IOpenDoc[] {
143
+ if (!value) return [];
144
+ return value.split(ENTRY_SEPARATOR).flatMap((entry): IOpenDoc[] => {
145
+ const [named, held, ...rest] = entry.split(FIELD_SEPARATOR);
146
+ if (!named || !held || rest.length !== 0) return [];
147
+ const prefab = named.startsWith(PREFAB_ENTRY_PREFIX);
148
+ const page = prefab ? named.slice(PREFAB_ENTRY_PREFIX.length) : named;
149
+ if (!page) return [];
150
+ const revision = REVISION_RE.exec(held);
151
+ // a draft id never holds a dot: `r.0`, `r.x` are neither
152
+ if (!revision && held.includes('.')) return [];
153
+ return [{
154
+ kind: prefab ? 'prefabs' : 'pages',
155
+ page,
156
+ ...(revision ? { revision: Number(revision[1]) } : { draftId: held }),
157
+ }];
158
+ });
159
+ }
160
+
161
+ /** the view a location names; an address outside the base, or one that names nothing known, is Home */
162
+ export function parseWorkbenchPath(pathname: string, base: string, query: URLSearchParams): TWorkbenchView {
163
+ const baseSegments = segmentsOf(base);
164
+ const segments = segmentsOf(pathname);
165
+ if (baseSegments.some((segment, i) => segments[i] !== segment)) return { view: 'home' };
166
+ const [section, page, ...rest] = segments.slice(baseSegments.length);
167
+ if (!section) return { view: 'home' };
168
+ if (section === DISTRIBUTIONS_SEGMENT && !page) return { view: 'distributions' };
169
+ const kind = kindOfSegment(section);
170
+ if (!kind || rest.length > 0) return { view: 'home' };
171
+ if (!page) return { view: 'list', kind };
172
+ const draftId = query.get(DRAFT_PARAM);
173
+ const rev = query.get(REVISION_PARAM);
174
+ const revision = rev !== null && /^[1-9]\d{0,8}$/.test(rev) ? Number(rev) : null;
175
+ return {
176
+ view: 'editor',
177
+ kind,
178
+ page,
179
+ ...(draftId ? { draftId } : revision !== null ? { revision } : {}),
180
+ };
181
+ }
182
+
183
+ /** the tree's width named by a query, or null */
184
+ const treeOf = (query: URLSearchParams): number | null => {
185
+ const raw = query.get(TREE_PARAM);
186
+ const width = raw === null ? NaN : Number(raw);
187
+ return Number.isInteger(width) && width > 0 ? width : null;
188
+ };
189
+
190
+ /** the editor's address at a location — null when the location is not the editor's */
191
+ export function parseEditorAddress(pathname: string, base: string, query: URLSearchParams): IEditorAddress | null {
192
+ const view = parseWorkbenchPath(pathname, base, query);
193
+ if (view.view !== 'editor') return null;
194
+ const { view: _view, ...doc } = view;
195
+ const tool = query.get(TOOL_PARAM);
196
+ return {
197
+ doc,
198
+ open: parseOpenDocs(query.get(OPEN_PARAM)),
199
+ tree: treeOf(query),
200
+ tool: tool ? { tool, state: query.get(TOOL_STATE_PARAM) || null } : null,
201
+ };
202
+ }