@kernhq/module-quire 0.14.0 → 0.16.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 (74) hide show
  1. package/dist/contract/models.d.ts +209 -0
  2. package/dist/contract/models.d.ts.map +1 -1
  3. package/dist/contract/models.js +151 -0
  4. package/dist/contract/models.js.map +1 -1
  5. package/dist/contract/permissions.d.ts.map +1 -1
  6. package/dist/contract/permissions.js +32 -0
  7. package/dist/contract/permissions.js.map +1 -1
  8. package/dist/contract/properties.d.ts +3 -3
  9. package/dist/contract/router.d.ts +509 -8
  10. package/dist/contract/router.d.ts.map +1 -1
  11. package/dist/contract/router.js +211 -1
  12. package/dist/contract/router.js.map +1 -1
  13. package/dist/server/_impl.d.ts +573 -1269
  14. package/dist/server/_impl.d.ts.map +1 -1
  15. package/dist/server/_impl.js +180 -0
  16. package/dist/server/_impl.js.map +1 -1
  17. package/dist/server/export/markdown.d.ts.map +1 -1
  18. package/dist/server/export/markdown.js +84 -1
  19. package/dist/server/export/markdown.js.map +1 -1
  20. package/dist/server/render.d.ts +190 -1
  21. package/dist/server/render.d.ts.map +1 -1
  22. package/dist/server/render.js +413 -0
  23. package/dist/server/render.js.map +1 -1
  24. package/dist/server/schema.d.ts +260 -1
  25. package/dist/server/schema.d.ts.map +1 -1
  26. package/dist/server/schema.js +108 -1
  27. package/dist/server/schema.js.map +1 -1
  28. package/dist/server/services/databases.d.ts +5 -5
  29. package/dist/server/services/index.d.ts +6 -0
  30. package/dist/server/services/index.d.ts.map +1 -1
  31. package/dist/server/services/index.js +27 -3
  32. package/dist/server/services/index.js.map +1 -1
  33. package/dist/server/services/macros.d.ts +83 -0
  34. package/dist/server/services/macros.d.ts.map +1 -0
  35. package/dist/server/services/macros.js +488 -0
  36. package/dist/server/services/macros.js.map +1 -0
  37. package/dist/server/services/objects.d.ts +61 -0
  38. package/dist/server/services/objects.d.ts.map +1 -0
  39. package/dist/server/services/objects.js +110 -0
  40. package/dist/server/services/objects.js.map +1 -0
  41. package/dist/server/services/publications.d.ts +2 -1
  42. package/dist/server/services/publications.d.ts.map +1 -1
  43. package/dist/server/services/publications.js +43 -4
  44. package/dist/server/services/publications.js.map +1 -1
  45. package/dist/server/services/templates.d.ts +135 -0
  46. package/dist/server/services/templates.d.ts.map +1 -0
  47. package/dist/server/services/templates.js +897 -0
  48. package/dist/server/services/templates.js.map +1 -0
  49. package/dist/server/services/unfurl.d.ts +154 -0
  50. package/dist/server/services/unfurl.d.ts.map +1 -0
  51. package/dist/server/services/unfurl.js +593 -0
  52. package/dist/server/services/unfurl.js.map +1 -0
  53. package/dist/server/services/versions.d.ts +12 -0
  54. package/dist/server/services/versions.d.ts.map +1 -1
  55. package/dist/server/services/versions.js +3 -1
  56. package/dist/server/services/versions.js.map +1 -1
  57. package/migrations/0011_templates.sql +157 -0
  58. package/migrations/meta/_journal.json +7 -0
  59. package/package.json +5 -5
  60. package/src/client/components/NewSpaceDialog.svelte +77 -8
  61. package/src/client/components/PageEditor.svelte +80 -0
  62. package/src/client/components/PagePicker.svelte +264 -0
  63. package/src/client/components/SaveAsTemplateDialog.svelte +502 -0
  64. package/src/client/components/SidebarSpaces.svelte +33 -1
  65. package/src/client/components/TemplatePicker.svelte +437 -0
  66. package/src/client/i18n.ts +327 -0
  67. package/src/client/index.ts +19 -0
  68. package/src/client/mock.ts +274 -0
  69. package/src/client/pages/PageView.svelte +50 -0
  70. package/src/client/pages/SpacePage.svelte +20 -4
  71. package/src/client/query.ts +17 -0
  72. package/src/contract/models.ts +191 -0
  73. package/src/contract/permissions.ts +33 -0
  74. package/src/contract/router.ts +228 -0
@@ -0,0 +1,897 @@
1
+ import { KernError, uuidv7 } from '@kernhq/kernel';
2
+ import { and, asc, eq, isNull, or } from 'drizzle-orm';
3
+ import { TEMPLATE_STARTER_KEYS, TemplateSpaceBody } from '../../contract/index.js';
4
+ import { pageDocFromBase64, pageDocFromState } from '../document.js';
5
+ import { pageDocToYState } from '../import/ydoc.js';
6
+ import { textFromPageDoc } from '../render.js';
7
+ import { pages, pageVersions, templates } from '../schema.js';
8
+ import { documentNameOf } from './pages.js';
9
+ /**
10
+ * How deep `fillNode` will descend, and how many pages a space template may hold.
11
+ *
12
+ * The depth cap is not decoration. `doc` is a JSONB column, so what comes back is whatever was
13
+ * written there — and a recursive walk over an adversarially nested document is a stack overflow,
14
+ * which in Node is a process, not an exception. The page cap is the same argument at the other end:
15
+ * a space template that made four thousand pages in one request would be an outage wearing a
16
+ * feature's clothes.
17
+ */
18
+ const MAX_DOC_DEPTH = 64;
19
+ const MAX_TEMPLATE_PAGES = 200;
20
+ // =====================================================================================
21
+ // The five starters
22
+ // =====================================================================================
23
+ /**
24
+ * The starters' own words, in every locale the platform ships.
25
+ *
26
+ * `en` is the reference and the other four are typed against its keys, so a missing translation is
27
+ * a compile error rather than an English heading in an Arabic page. The strings are deliberately
28
+ * plain — a starter is scaffolding somebody writes over, and prompts they have to delete are worse
29
+ * than headings they can type under.
30
+ */
31
+ const en = {
32
+ 'meeting-notes.name': 'Meeting notes',
33
+ 'meeting-notes.description': 'Who was there, what was decided, and who does what next.',
34
+ 'meeting-notes.attendees': 'Attendees',
35
+ 'meeting-notes.agenda': 'Agenda',
36
+ 'meeting-notes.notes': 'Notes',
37
+ 'meeting-notes.decisions': 'Decisions',
38
+ 'meeting-notes.actions': 'Actions',
39
+ 'decision-record.name': 'Decision record',
40
+ 'decision-record.description': 'One decision, why it was taken, and what it commits you to.',
41
+ 'decision-record.status': 'Status',
42
+ 'decision-record.status_value': 'Proposed',
43
+ 'decision-record.context': 'Context',
44
+ 'decision-record.decision': 'Decision',
45
+ 'decision-record.consequences': 'Consequences',
46
+ 'decision-record.alternatives': 'Alternatives considered',
47
+ 'requirements.name': 'Requirements',
48
+ 'requirements.description': 'What a piece of work has to do, and what it deliberately does not.',
49
+ 'requirements.summary': 'Summary',
50
+ 'requirements.goals': 'Goals',
51
+ 'requirements.out_of_scope': 'Out of scope',
52
+ 'requirements.requirements': 'Requirements',
53
+ 'requirements.questions': 'Open questions',
54
+ 'retrospective.name': 'Retrospective',
55
+ 'retrospective.description': 'What went well, what got in the way, and what to change.',
56
+ 'retrospective.well': 'What went well',
57
+ 'retrospective.blocked': 'What got in the way',
58
+ 'retrospective.try': 'What we will try',
59
+ 'retrospective.actions': 'Actions',
60
+ 'how-to.name': 'How-to',
61
+ 'how-to.description': 'A task somebody can follow from start to finish.',
62
+ 'how-to.before': 'Before you start',
63
+ 'how-to.steps': 'Steps',
64
+ 'how-to.check': 'Check it worked',
65
+ 'how-to.trouble': 'If something goes wrong',
66
+ };
67
+ const ar = {
68
+ 'meeting-notes.name': 'محضر اجتماع',
69
+ 'meeting-notes.description': 'من حضر، وما الذي تقرّر، ومن يفعل ماذا بعد ذلك.',
70
+ 'meeting-notes.attendees': 'الحاضرون',
71
+ 'meeting-notes.agenda': 'جدول الأعمال',
72
+ 'meeting-notes.notes': 'الملاحظات',
73
+ 'meeting-notes.decisions': 'القرارات',
74
+ 'meeting-notes.actions': 'الإجراءات',
75
+ 'decision-record.name': 'سجل قرار',
76
+ 'decision-record.description': 'قرار واحد، ولماذا اتُّخذ، وما الذي يلزمك به.',
77
+ 'decision-record.status': 'الحالة',
78
+ 'decision-record.status_value': 'مقترح',
79
+ 'decision-record.context': 'السياق',
80
+ 'decision-record.decision': 'القرار',
81
+ 'decision-record.consequences': 'النتائج',
82
+ 'decision-record.alternatives': 'البدائل التي نُظر فيها',
83
+ 'requirements.name': 'المتطلبات',
84
+ 'requirements.description': 'ما يجب أن ينجزه العمل، وما لا ينجزه عن قصد.',
85
+ 'requirements.summary': 'الملخص',
86
+ 'requirements.goals': 'الأهداف',
87
+ 'requirements.out_of_scope': 'خارج النطاق',
88
+ 'requirements.requirements': 'المتطلبات',
89
+ 'requirements.questions': 'أسئلة مفتوحة',
90
+ 'retrospective.name': 'مراجعة استعادية',
91
+ 'retrospective.description': 'ما سار جيدًا، وما أعاق العمل، وما الذي نغيّره.',
92
+ 'retrospective.well': 'ما سار جيدًا',
93
+ 'retrospective.blocked': 'ما أعاق العمل',
94
+ 'retrospective.try': 'ما سنجرّبه',
95
+ 'retrospective.actions': 'الإجراءات',
96
+ 'how-to.name': 'دليل عملي',
97
+ 'how-to.description': 'مهمة يمكن لأي شخص تنفيذها من أولها إلى آخرها.',
98
+ 'how-to.before': 'قبل أن تبدأ',
99
+ 'how-to.steps': 'الخطوات',
100
+ 'how-to.check': 'تأكّد من نجاحها',
101
+ 'how-to.trouble': 'إذا حدث خطأ',
102
+ };
103
+ const de = {
104
+ 'meeting-notes.name': 'Besprechungsnotizen',
105
+ 'meeting-notes.description': 'Wer da war, was entschieden wurde und wer als Nächstes was tut.',
106
+ 'meeting-notes.attendees': 'Teilnehmende',
107
+ 'meeting-notes.agenda': 'Tagesordnung',
108
+ 'meeting-notes.notes': 'Notizen',
109
+ 'meeting-notes.decisions': 'Entscheidungen',
110
+ 'meeting-notes.actions': 'Aufgaben',
111
+ 'decision-record.name': 'Entscheidungsprotokoll',
112
+ 'decision-record.description': 'Eine Entscheidung, warum sie getroffen wurde und wozu sie verpflichtet.',
113
+ 'decision-record.status': 'Status',
114
+ 'decision-record.status_value': 'Vorgeschlagen',
115
+ 'decision-record.context': 'Kontext',
116
+ 'decision-record.decision': 'Entscheidung',
117
+ 'decision-record.consequences': 'Folgen',
118
+ 'decision-record.alternatives': 'Geprüfte Alternativen',
119
+ 'requirements.name': 'Anforderungen',
120
+ 'requirements.description': 'Was eine Arbeit leisten muss – und was bewusst nicht.',
121
+ 'requirements.summary': 'Zusammenfassung',
122
+ 'requirements.goals': 'Ziele',
123
+ 'requirements.out_of_scope': 'Nicht im Umfang',
124
+ 'requirements.requirements': 'Anforderungen',
125
+ 'requirements.questions': 'Offene Fragen',
126
+ 'retrospective.name': 'Retrospektive',
127
+ 'retrospective.description': 'Was gut lief, was im Weg stand und was sich ändern soll.',
128
+ 'retrospective.well': 'Was gut lief',
129
+ 'retrospective.blocked': 'Was im Weg stand',
130
+ 'retrospective.try': 'Was wir ausprobieren',
131
+ 'retrospective.actions': 'Aufgaben',
132
+ 'how-to.name': 'Anleitung',
133
+ 'how-to.description': 'Eine Aufgabe, die jemand von Anfang bis Ende durchführen kann.',
134
+ 'how-to.before': 'Bevor Sie beginnen',
135
+ 'how-to.steps': 'Schritte',
136
+ 'how-to.check': 'Ergebnis prüfen',
137
+ 'how-to.trouble': 'Wenn etwas schiefgeht',
138
+ };
139
+ const fa = {
140
+ 'meeting-notes.name': 'یادداشت جلسه',
141
+ 'meeting-notes.description': 'چه کسانی بودند، چه تصمیمی گرفته شد و بعد چه کسی چه می‌کند.',
142
+ 'meeting-notes.attendees': 'حاضران',
143
+ 'meeting-notes.agenda': 'دستور جلسه',
144
+ 'meeting-notes.notes': 'یادداشت‌ها',
145
+ 'meeting-notes.decisions': 'تصمیم‌ها',
146
+ 'meeting-notes.actions': 'کارها',
147
+ 'decision-record.name': 'سند تصمیم',
148
+ 'decision-record.description': 'یک تصمیم، دلیل گرفتن آن، و آنچه شما را به آن متعهد می‌کند.',
149
+ 'decision-record.status': 'وضعیت',
150
+ 'decision-record.status_value': 'پیشنهادی',
151
+ 'decision-record.context': 'زمینه',
152
+ 'decision-record.decision': 'تصمیم',
153
+ 'decision-record.consequences': 'پیامدها',
154
+ 'decision-record.alternatives': 'گزینه‌های بررسی‌شده',
155
+ 'requirements.name': 'نیازمندی‌ها',
156
+ 'requirements.description': 'کاری که باید انجام شود، و آنچه عمداً انجام نمی‌شود.',
157
+ 'requirements.summary': 'خلاصه',
158
+ 'requirements.goals': 'هدف‌ها',
159
+ 'requirements.out_of_scope': 'خارج از دامنه',
160
+ 'requirements.requirements': 'نیازمندی‌ها',
161
+ 'requirements.questions': 'پرسش‌های باز',
162
+ 'retrospective.name': 'بازنگری',
163
+ 'retrospective.description': 'چه چیزی خوب پیش رفت، چه چیزی مانع شد و چه چیزی را تغییر می‌دهیم.',
164
+ 'retrospective.well': 'چه چیزی خوب پیش رفت',
165
+ 'retrospective.blocked': 'چه چیزی مانع شد',
166
+ 'retrospective.try': 'چه چیزی را می‌آزماییم',
167
+ 'retrospective.actions': 'کارها',
168
+ 'how-to.name': 'راهنمای گام‌به‌گام',
169
+ 'how-to.description': 'کاری که هر کس بتواند از آغاز تا پایان دنبالش کند.',
170
+ 'how-to.before': 'پیش از شروع',
171
+ 'how-to.steps': 'گام‌ها',
172
+ 'how-to.check': 'درستی کار را بررسی کنید',
173
+ 'how-to.trouble': 'اگر چیزی اشتباه پیش رفت',
174
+ };
175
+ const tr = {
176
+ 'meeting-notes.name': 'Toplantı notları',
177
+ 'meeting-notes.description': 'Kimler vardı, ne karara bağlandı ve sırada kim ne yapıyor.',
178
+ 'meeting-notes.attendees': 'Katılanlar',
179
+ 'meeting-notes.agenda': 'Gündem',
180
+ 'meeting-notes.notes': 'Notlar',
181
+ 'meeting-notes.decisions': 'Kararlar',
182
+ 'meeting-notes.actions': 'İşler',
183
+ 'decision-record.name': 'Karar kaydı',
184
+ 'decision-record.description': 'Tek bir karar, neden alındığı ve neye bağladığı.',
185
+ 'decision-record.status': 'Durum',
186
+ 'decision-record.status_value': 'Önerildi',
187
+ 'decision-record.context': 'Bağlam',
188
+ 'decision-record.decision': 'Karar',
189
+ 'decision-record.consequences': 'Sonuçlar',
190
+ 'decision-record.alternatives': 'Değerlendirilen seçenekler',
191
+ 'requirements.name': 'Gereksinimler',
192
+ 'requirements.description': 'Bir işin yapması gerekenler ve bilerek yapmadıkları.',
193
+ 'requirements.summary': 'Özet',
194
+ 'requirements.goals': 'Hedefler',
195
+ 'requirements.out_of_scope': 'Kapsam dışı',
196
+ 'requirements.requirements': 'Gereksinimler',
197
+ 'requirements.questions': 'Açık sorular',
198
+ 'retrospective.name': 'Retrospektif',
199
+ 'retrospective.description': 'Ne iyi gitti, ne engel oldu ve neyi değiştireceğiz.',
200
+ 'retrospective.well': 'Ne iyi gitti',
201
+ 'retrospective.blocked': 'Ne engel oldu',
202
+ 'retrospective.try': 'Ne deneyeceğiz',
203
+ 'retrospective.actions': 'İşler',
204
+ 'how-to.name': 'Nasıl yapılır',
205
+ 'how-to.description': 'Birinin baştan sona izleyebileceği bir iş.',
206
+ 'how-to.before': 'Başlamadan önce',
207
+ 'how-to.steps': 'Adımlar',
208
+ 'how-to.check': 'Çalıştığını doğrulayın',
209
+ 'how-to.trouble': 'Bir şey ters giderse',
210
+ };
211
+ const STARTER_TEXT = { ar, de, en, fa, tr };
212
+ /**
213
+ * The reader's own language, or English.
214
+ *
215
+ * `principal.locale` is whatever core stored, which may be a region tag (`en-GB`, `pt-BR`), so the
216
+ * base subtag is what is looked up. A locale this module has no table for falls back rather than
217
+ * throwing: a page in the wrong language is a disappointment, and a 500 when somebody presses "New
218
+ * page" is a broken product.
219
+ */
220
+ function stringsFor(locale) {
221
+ const base = (locale ?? 'en').toLowerCase().split(/[-_]/)[0] ?? 'en';
222
+ return STARTER_TEXT[base] ?? en;
223
+ }
224
+ /** A paragraph, empty when there is nothing to put in it — an empty one is a line to type on. */
225
+ const p = (text) => text ? { type: 'paragraph', content: [{ type: 'text', text }] } : { type: 'paragraph' };
226
+ const h = (text) => ({
227
+ type: 'heading',
228
+ attrs: { level: 2 },
229
+ content: [{ type: 'text', text }],
230
+ });
231
+ const bullets = () => ({
232
+ type: 'bulletList',
233
+ content: [{ type: 'listItem', content: [p()] }],
234
+ });
235
+ const numbered = () => ({
236
+ type: 'orderedList',
237
+ content: [{ type: 'listItem', content: [p()] }],
238
+ });
239
+ const tasks = () => ({
240
+ type: 'taskList',
241
+ content: [{ type: 'taskItem', attrs: { checked: false }, content: [p()] }],
242
+ });
243
+ /**
244
+ * The line every starter opens with.
245
+ *
246
+ * `{{date}}` and `{{author}}` are the two built-ins the plan names, and putting them at the top of
247
+ * all five is what makes the feature legible: somebody who has never read this file sees the braces
248
+ * become a date the first time they make a page, and now knows what a variable is.
249
+ */
250
+ const byline = () => p('{{date}} · {{author}}');
251
+ /**
252
+ * Every node in these bodies is one `renderPageDoc` draws and `buildPageExtensions` produces.
253
+ *
254
+ * That is the editor-schema rule, and a template is where breaking it costs most: a starter using a
255
+ * node the renderer has no case for would export blank, publish blank and print blank, for every
256
+ * page anybody ever made from it. Nothing checks it here — `render.test.ts` checks the renderer
257
+ * against the schema, and these use only paragraph, heading, bulletList, orderedList, listItem,
258
+ * taskList, taskItem and text.
259
+ *
260
+ * **None of them declares a variable, and that is a decision rather than an omission.** A declared
261
+ * variable has a `label` and, for a `select`, a list of `options` — an author's own words, in the
262
+ * author's own language. A shipped starter has no author, so every one of those strings would have
263
+ * to join the table above and be translated five ways to ask somebody for a sprint number. The two
264
+ * built-ins are filled from the request and need no words at all, which is why a starter can use
265
+ * them and nothing else.
266
+ */
267
+ const STARTERS = {
268
+ 'meeting-notes': {
269
+ icon: 'users',
270
+ body: (s) => [
271
+ byline(),
272
+ h(s['meeting-notes.attendees']),
273
+ bullets(),
274
+ h(s['meeting-notes.agenda']),
275
+ bullets(),
276
+ h(s['meeting-notes.notes']),
277
+ p(),
278
+ h(s['meeting-notes.decisions']),
279
+ bullets(),
280
+ h(s['meeting-notes.actions']),
281
+ tasks(),
282
+ ],
283
+ },
284
+ 'decision-record': {
285
+ icon: 'flag',
286
+ body: (s) => [
287
+ byline(),
288
+ h(s['decision-record.status']),
289
+ p(s['decision-record.status_value']),
290
+ h(s['decision-record.context']),
291
+ p(),
292
+ h(s['decision-record.decision']),
293
+ p(),
294
+ h(s['decision-record.consequences']),
295
+ p(),
296
+ h(s['decision-record.alternatives']),
297
+ bullets(),
298
+ ],
299
+ },
300
+ requirements: {
301
+ icon: 'target',
302
+ body: (s) => [
303
+ byline(),
304
+ h(s['requirements.summary']),
305
+ p(),
306
+ h(s['requirements.goals']),
307
+ bullets(),
308
+ h(s['requirements.out_of_scope']),
309
+ bullets(),
310
+ h(s['requirements.requirements']),
311
+ tasks(),
312
+ h(s['requirements.questions']),
313
+ bullets(),
314
+ ],
315
+ },
316
+ retrospective: {
317
+ icon: 'refresh-cw',
318
+ body: (s) => [
319
+ byline(),
320
+ h(s['retrospective.well']),
321
+ bullets(),
322
+ h(s['retrospective.blocked']),
323
+ bullets(),
324
+ h(s['retrospective.try']),
325
+ bullets(),
326
+ h(s['retrospective.actions']),
327
+ tasks(),
328
+ ],
329
+ },
330
+ 'how-to': {
331
+ icon: 'wrench',
332
+ body: (s) => [
333
+ byline(),
334
+ h(s['how-to.before']),
335
+ bullets(),
336
+ h(s['how-to.steps']),
337
+ numbered(),
338
+ h(s['how-to.check']),
339
+ p(),
340
+ h(s['how-to.trouble']),
341
+ p(),
342
+ ],
343
+ },
344
+ };
345
+ /** A starter as the picker draws it, in one language. */
346
+ function starterChoice(key, s) {
347
+ return {
348
+ id: null,
349
+ key,
350
+ builtIn: true,
351
+ kind: 'page',
352
+ spaceId: null,
353
+ name: s[`${key}.name`],
354
+ description: s[`${key}.description`],
355
+ icon: STARTERS[key].icon,
356
+ variables: [],
357
+ updatedAt: null,
358
+ };
359
+ }
360
+ /** A starter's body, built fresh every time — the constants above are shared and never mutated. */
361
+ function starterDoc(key, s) {
362
+ return { type: 'doc', content: STARTERS[key].body(s) };
363
+ }
364
+ // =====================================================================================
365
+ // Variables
366
+ // =====================================================================================
367
+ /**
368
+ * What a placeholder looks like: `{{name}}`, with optional spaces inside the braces.
369
+ *
370
+ * The name is the same grammar `TemplateVariableName` enforces — lowercase, digits, underscores —
371
+ * so a template cannot declare a name this pattern would not find, and nothing else in a page can
372
+ * accidentally look like one.
373
+ */
374
+ const PLACEHOLDER = /\{\{\s*([a-z][a-z0-9_]*)\s*\}\}/g;
375
+ /**
376
+ * Replace the placeholders in one string.
377
+ *
378
+ * The replacement is a **function**, which is the whole point. `String.replace` with a string
379
+ * replacement reads `$&`, `$1` and `$'` in the *replacement* as back-references, so somebody
380
+ * answering a template's question with `$&` would get their own placeholder back, and `$'` would
381
+ * paste the rest of the paragraph. A function is handed the match and its return value is used
382
+ * verbatim.
383
+ *
384
+ * An unknown name is left exactly as it was written rather than removed. A template one release
385
+ * ahead of its server, or a body somebody typed braces into by hand, should show a placeholder the
386
+ * author can see and fix — deleting text somebody wrote is the worse of the two failures.
387
+ *
388
+ * The scan is single-pass, so a value containing `{{other}}` is characters and not a second
389
+ * substitution: `replace` never re-reads what it has written.
390
+ */
391
+ function fillText(text, values) {
392
+ return text.replace(PLACEHOLDER, (whole, name) => values.get(name) ?? whole);
393
+ }
394
+ /**
395
+ * The same document with its text filled in — a **copy**, never the original.
396
+ *
397
+ * Cloning matters for two different reasons at once. The starter bodies are module-level constants
398
+ * shared by every request in the process, so filling one in place would leak one workspace's answers
399
+ * into the next workspace's page. And a stored `doc` is JSONB the caller may hold a reference to.
400
+ *
401
+ * Attributes are copied and never substituted. A `{{…}}` inside a link's `href` or an image's
402
+ * `fileId` is not prose, and filling it would let a template's answer decide where a link points —
403
+ * which is a template author writing a URL somebody else's answer completes.
404
+ */
405
+ function fillNode(node, values, depth) {
406
+ const out = {};
407
+ if (typeof node.type === 'string')
408
+ out.type = node.type;
409
+ if (typeof node.text === 'string')
410
+ out.text = fillText(node.text, values);
411
+ if (node.attrs && typeof node.attrs === 'object')
412
+ out.attrs = { ...node.attrs };
413
+ if (Array.isArray(node.marks))
414
+ out.marks = node.marks.map((mark) => ({ ...mark }));
415
+ if (Array.isArray(node.content) && depth < MAX_DOC_DEPTH)
416
+ out.content = node.content
417
+ .filter((child) => typeof child === 'object' && child !== null)
418
+ .map((child) => fillNode(child, values, depth + 1));
419
+ return out;
420
+ }
421
+ /** A whole document with its text filled in. An empty or malformed one stays empty. */
422
+ export function fillPageDoc(doc, values) {
423
+ if (!doc || !Array.isArray(doc.content))
424
+ return { type: 'doc', content: [] };
425
+ return {
426
+ type: 'doc',
427
+ content: doc.content
428
+ .filter((node) => typeof node === 'object' && node !== null)
429
+ .map((node) => fillNode(node, values, 1)),
430
+ };
431
+ }
432
+ /**
433
+ * Everything a placeholder may become: what the module fills, then what somebody typed.
434
+ *
435
+ * The built-ins go in first and the answers second, which is the wrong order for precedence and the
436
+ * right one here: `TemplateVariableName` refuses the reserved names outright, so nothing a template
437
+ * declares can collide with one and the ordering never decides anything. It is written this way so
438
+ * that if that refusal is ever relaxed, an author's own variable wins over ours rather than being
439
+ * silently overwritten by it.
440
+ *
441
+ * `{{workspace}}` is reserved and **not filled**. Reserving it is what keeps adding it later a
442
+ * non-breaking change — an author who had taken the name would otherwise find their template
443
+ * quietly rendering something else — and filling it needs a workspace name this module would have
444
+ * to ask core for on every instantiation. Until something needs it, an unfilled reserved name
445
+ * behaves exactly as an unknown one: it stays on the page, visible.
446
+ */
447
+ function valuesFor(principal, locale, spaceName, declared, supplied) {
448
+ const now = new Date();
449
+ const values = new Map();
450
+ // Through Intl, so a Persian workspace gets Persian digits and an Arabic one an Arabic calendar
451
+ // — the same rule every number and date in Kern follows.
452
+ const tag = locale || 'en';
453
+ values.set('date', new Intl.DateTimeFormat(tag, { dateStyle: 'long' }).format(now));
454
+ values.set('time', new Intl.DateTimeFormat(tag, { timeStyle: 'short' }).format(now));
455
+ values.set('author', principal.name || principal.email || '');
456
+ values.set('space', spaceName);
457
+ for (const variable of declared) {
458
+ const given = supplied[variable.name];
459
+ const value = given === undefined || given === '' ? (variable.default ?? '') : given;
460
+ if (variable.required && value === '')
461
+ throw KernError.badRequest(`"${variable.label}" is needed before this can be made`);
462
+ values.set(variable.name, value);
463
+ }
464
+ return values;
465
+ }
466
+ /**
467
+ * Whether this failure is that named constraint, **looked for in `cause` and not in the message**.
468
+ *
469
+ * Drizzle wraps every failed statement in a `DrizzleQueryError` whose text is the SQL and its
470
+ * parameters; the Postgres error — with its `23505` and the name of the index it violated — is one
471
+ * link down the `cause` chain. A `String(err).includes('…_uq')` therefore never matches, silently,
472
+ * and the friendly conflict it was meant to raise turns into a raw driver error with the whole
473
+ * statement in it. That is what this shipped as until the test asked for the conflict by name.
474
+ */
475
+ function violates(err, constraint) {
476
+ let at = err;
477
+ for (let depth = 0; at && depth < 6; depth += 1) {
478
+ const candidate = at;
479
+ if (candidate.code === '23505' &&
480
+ (candidate.constraint === constraint || (candidate.message ?? '').includes(constraint)))
481
+ return true;
482
+ at = candidate.cause;
483
+ }
484
+ return false;
485
+ }
486
+ /** Two variables of the same name is a form with two fields writing to one placeholder. */
487
+ function checkVariables(variables) {
488
+ const seen = new Set();
489
+ for (const variable of variables) {
490
+ if (seen.has(variable.name))
491
+ throw KernError.badRequest(`Two of these fields are called "${variable.name}"`);
492
+ seen.add(variable.name);
493
+ if (variable.type === 'select' && variable.options.length === 0)
494
+ throw KernError.badRequest(`"${variable.label}" is a list with nothing in it`);
495
+ }
496
+ }
497
+ // =====================================================================================
498
+ // The service
499
+ // =====================================================================================
500
+ export function toTemplate(row) {
501
+ return {
502
+ id: row.id,
503
+ workspaceId: row.workspaceId,
504
+ spaceId: row.spaceId,
505
+ kind: row.kind,
506
+ key: row.key,
507
+ builtIn: row.builtIn,
508
+ name: row.name,
509
+ description: row.description,
510
+ icon: row.icon,
511
+ doc: (row.doc ?? {}),
512
+ variables: (row.variables ?? []),
513
+ createdBy: row.createdBy,
514
+ createdAt: row.createdAt.toISOString(),
515
+ updatedAt: row.updatedAt.toISOString(),
516
+ };
517
+ }
518
+ const toChoice = (row) => ({
519
+ id: row.id,
520
+ key: row.key,
521
+ builtIn: row.builtIn,
522
+ kind: row.kind,
523
+ spaceId: row.spaceId,
524
+ name: row.name,
525
+ description: row.description,
526
+ icon: row.icon,
527
+ variables: (row.variables ?? []),
528
+ updatedAt: row.updatedAt.toISOString(),
529
+ });
530
+ export function quireTemplates(kernel, access, pagesSvc, spacesSvc) {
531
+ /**
532
+ * A page's prose, as a document.
533
+ *
534
+ * The live collaborative document first, because that is what the person looking at the page can
535
+ * see — saving a template from a page and getting last week's published copy would be baffling.
536
+ * The newest stored version is the fallback, for a page whose document the collab service has
537
+ * forgotten or has never held.
538
+ */
539
+ async function docOfPage(tx, workspaceId, pageId) {
540
+ const live = await kernel
541
+ .call('collab.document.state', {
542
+ name: documentNameOf({ workspaceId, id: pageId }),
543
+ })
544
+ .catch(() => null);
545
+ const fromLive = pageDocFromBase64(live?.state ?? null);
546
+ if (fromLive)
547
+ return fromLive;
548
+ const [version] = await tx
549
+ .select({ state: pageVersions.state })
550
+ .from(pageVersions)
551
+ .where(and(eq(pageVersions.workspaceId, workspaceId), eq(pageVersions.pageId, pageId)))
552
+ .orderBy(asc(pageVersions.id))
553
+ .limit(1);
554
+ return version ? pageDocFromState(version.state) : null;
555
+ }
556
+ /**
557
+ * The space's tree as a template body — only the pages this person may read.
558
+ *
559
+ * That filter is the whole security question this procedure has. A space template copies other
560
+ * people's prose into something anybody who may create a page can then read, so a page a
561
+ * page-scoped DENY has closed to the author must not travel into it. A skipped page takes its
562
+ * descendants with it: a child whose parent was left out has nowhere to hang, and lifting it to
563
+ * the top would put a restricted page's child in the template anyway.
564
+ *
565
+ * Rows of a database are excluded for the same reason `pages.tree` excludes them — five hundred
566
+ * rows under one node is not a template, it is a copy of a database without its columns.
567
+ */
568
+ async function treeOfSpace(tx, principal, workspaceId, spaceId) {
569
+ const rows = await tx
570
+ .select({
571
+ id: pages.id,
572
+ parentId: pages.parentId,
573
+ title: pages.title,
574
+ icon: pages.icon,
575
+ })
576
+ .from(pages)
577
+ .where(and(eq(pages.workspaceId, workspaceId), eq(pages.spaceId, spaceId), isNull(pages.deletedAt), isNull(pages.archivedAt), isNull(pages.databaseId)))
578
+ .orderBy(asc(pages.position));
579
+ if (rows.length > MAX_TEMPLATE_PAGES)
580
+ throw KernError.badRequest(`A space template holds at most ${MAX_TEMPLATE_PAGES} pages, and this space has ${rows.length}`);
581
+ const parentOf = new Map(rows.map((row) => [row.id, row.parentId]));
582
+ const ancestorsOf = (id) => {
583
+ const chain = [];
584
+ const seen = new Set([id]);
585
+ let at = parentOf.get(id) ?? null;
586
+ while (at !== null && !seen.has(at)) {
587
+ chain.push(at);
588
+ seen.add(at);
589
+ at = parentOf.get(at) ?? null;
590
+ }
591
+ return chain;
592
+ };
593
+ const readable = new Set();
594
+ for (const row of rows)
595
+ if (await access.canPage(principal, 'quire.page.view', workspaceId, {
596
+ pageId: row.id,
597
+ spaceId,
598
+ ancestorIds: ancestorsOf(row.id),
599
+ }))
600
+ readable.add(row.id);
601
+ const build = async (parentId, depth) => {
602
+ if (depth > 16)
603
+ return [];
604
+ const out = [];
605
+ for (const row of rows) {
606
+ if (row.parentId !== parentId)
607
+ continue;
608
+ // A page the author may not read is left out, and so is everything under it.
609
+ if (!readable.has(row.id))
610
+ continue;
611
+ out.push({
612
+ title: row.title,
613
+ icon: row.icon,
614
+ doc: ((await docOfPage(tx, workspaceId, row.id)) ?? { type: 'doc', content: [] }),
615
+ children: await build(row.id, depth + 1),
616
+ });
617
+ }
618
+ return out;
619
+ };
620
+ return build(null, 0);
621
+ }
622
+ /** Write a document into a page that has just been made, and mirror its text for search. */
623
+ async function writeBody(tx, workspaceId, pageId, doc) {
624
+ if ((doc.content ?? []).length === 0)
625
+ return;
626
+ const state = pageDocToYState(doc);
627
+ await kernel.call('collab.document.replace', {
628
+ name: documentNameOf({ workspaceId, id: pageId }),
629
+ state: state.toString('base64'),
630
+ });
631
+ /*
632
+ * The mirrored column, so a page made from a template is findable before anybody edits it.
633
+ *
634
+ * No version row is written, deliberately. `VersionKind` has `auto`, `publish`, `restore` and
635
+ * `import`, and none of them is what this is — labelling it `import` would put "Imported" in a
636
+ * history the reader is meant to trust. The first version is taken the first time somebody
637
+ * writes in the page, which is the same moment it would be for a blank one.
638
+ */
639
+ await tx
640
+ .update(pages)
641
+ .set({ text: textFromPageDoc(doc) })
642
+ .where(and(eq(pages.workspaceId, workspaceId), eq(pages.id, pageId)));
643
+ }
644
+ return {
645
+ /** The row, for a caller that needs its scope before deciding anything. */
646
+ async row(tx, workspaceId, templateId) {
647
+ const [row] = await tx
648
+ .select()
649
+ .from(templates)
650
+ .where(and(eq(templates.workspaceId, workspaceId), eq(templates.id, templateId)))
651
+ .limit(1);
652
+ if (!row)
653
+ throw KernError.notFound('Template');
654
+ return row;
655
+ },
656
+ /**
657
+ * What may be made here: the starters, plus this workspace's own.
658
+ *
659
+ * The override is applied by key: a row carrying `retrospective` takes the shipped
660
+ * retrospective's place in the list rather than appearing after it. That is what makes the
661
+ * copy-on-write story visible — a workspace that has edited one starter sees five entries, not
662
+ * six — and it is why the partial-unique index on `(workspace_id, key)` exists.
663
+ *
664
+ * Starters are page templates, so `kind: 'space'` answers rows only. That is not a gap: a space
665
+ * template is a shape of somebody's own organisation, and there is no such thing as a generic
666
+ * one worth shipping.
667
+ */
668
+ async list(tx, workspaceId, kind, spaceId, locale) {
669
+ const rows = await tx
670
+ .select()
671
+ .from(templates)
672
+ .where(and(eq(templates.workspaceId, workspaceId), eq(templates.kind, kind),
673
+ // Workspace-wide always, plus this space's own when a space was named.
674
+ spaceId
675
+ ? or(isNull(templates.spaceId), eq(templates.spaceId, spaceId))
676
+ : isNull(templates.spaceId)))
677
+ .orderBy(asc(templates.name));
678
+ const byKey = new Map(rows.filter((row) => row.key !== null).map((row) => [row.key, row]));
679
+ const out = [];
680
+ if (kind === 'page') {
681
+ const s = stringsFor(locale);
682
+ for (const key of TEMPLATE_STARTER_KEYS) {
683
+ const override = byKey.get(key);
684
+ out.push(override ? toChoice(override) : starterChoice(key, s));
685
+ }
686
+ }
687
+ // Everything else, in name order. A row whose key names a starter this release no longer
688
+ // ships is an ordinary template and lands here, which is the read side of the rule that
689
+ // `Template.key` is a string rather than the enum.
690
+ for (const row of rows)
691
+ if (row.key === null || !TEMPLATE_STARTER_KEYS.includes(row.key))
692
+ out.push(toChoice(row));
693
+ return out;
694
+ },
695
+ async get(tx, workspaceId, templateId) {
696
+ return toTemplate(await this.row(tx, workspaceId, templateId));
697
+ },
698
+ /**
699
+ * Save a page — or a space's whole tree — as a template.
700
+ *
701
+ * The body is read here rather than taken from the caller, and that is the point of the
702
+ * procedure: a client that could post a document would make "save as template" a way to write
703
+ * arbitrary prose into something everybody in the space is then offered.
704
+ */
705
+ async createFromPage(tx, principal, workspaceId, input) {
706
+ checkVariables(input.variables);
707
+ // The check constraint holds the pair, but a 23514 reaching a person as "violates constraint
708
+ // templates_key_matches_built_in" is not an error anybody can act on.
709
+ if (input.kind === 'space' && input.spaceId !== null)
710
+ throw KernError.badRequest('A space template makes a space, so it cannot live inside one');
711
+ const doc = await this.bodyFor(tx, principal, workspaceId, input.kind, input.sourceId);
712
+ const [row] = await tx
713
+ .insert(templates)
714
+ .values({
715
+ id: uuidv7(),
716
+ workspaceId,
717
+ spaceId: input.spaceId,
718
+ kind: input.kind,
719
+ key: input.key,
720
+ builtIn: input.key !== null,
721
+ name: input.name,
722
+ description: input.description,
723
+ icon: input.icon,
724
+ doc,
725
+ variables: input.variables,
726
+ createdBy: principal.userId,
727
+ })
728
+ .returning()
729
+ .catch((err) => {
730
+ // The partial-unique index on `(workspace_id, key)`: one override per starter.
731
+ if (violates(err, 'templates_ws_key_uq'))
732
+ throw KernError.conflict('This workspace already has its own version of that template');
733
+ throw err;
734
+ });
735
+ return toTemplate(row);
736
+ },
737
+ /** The body a template of this kind takes from this source, checked and read. */
738
+ async bodyFor(tx, principal, workspaceId, kind, sourceId) {
739
+ if (kind === 'space') {
740
+ await access.spaceRow(tx, workspaceId, sourceId);
741
+ return { pages: await treeOfSpace(tx, principal, workspaceId, sourceId) };
742
+ }
743
+ const doc = await docOfPage(tx, workspaceId, sourceId);
744
+ if (!doc || (doc.content ?? []).length === 0)
745
+ throw KernError.badRequest('There is nothing written on this page to make a template from');
746
+ return doc;
747
+ },
748
+ async update(tx, principal, workspaceId, templateId, patch) {
749
+ const existing = await this.row(tx, workspaceId, templateId);
750
+ if (patch.variables)
751
+ checkVariables(patch.variables);
752
+ if (patch.spaceId !== undefined && existing.kind === 'space' && patch.spaceId !== null)
753
+ throw KernError.badRequest('A space template makes a space, so it cannot live inside one');
754
+ const doc = patch.sourceId === undefined
755
+ ? undefined
756
+ : await this.bodyFor(tx, principal, workspaceId, existing.kind, patch.sourceId);
757
+ const [row] = await tx
758
+ .update(templates)
759
+ .set({
760
+ ...(patch.name === undefined ? {} : { name: patch.name }),
761
+ ...(patch.description === undefined ? {} : { description: patch.description }),
762
+ ...(patch.icon === undefined ? {} : { icon: patch.icon }),
763
+ ...(patch.spaceId === undefined ? {} : { spaceId: patch.spaceId }),
764
+ ...(patch.variables === undefined ? {} : { variables: patch.variables }),
765
+ ...(doc === undefined ? {} : { doc }),
766
+ updatedAt: new Date(),
767
+ })
768
+ .where(and(eq(templates.workspaceId, workspaceId), eq(templates.id, templateId)))
769
+ .returning();
770
+ if (!row)
771
+ throw KernError.notFound('Template');
772
+ return toTemplate(row);
773
+ },
774
+ /** Gone. For a row that replaced a starter, the shipped one is back the moment this returns. */
775
+ async remove(tx, workspaceId, templateId) {
776
+ const deleted = await tx
777
+ .delete(templates)
778
+ .where(and(eq(templates.workspaceId, workspaceId), eq(templates.id, templateId)))
779
+ .returning({ id: templates.id });
780
+ if (deleted.length === 0)
781
+ throw KernError.notFound('Template');
782
+ },
783
+ /**
784
+ * What `instantiate` is about to make, whichever of the two ways it was addressed.
785
+ *
786
+ * Resolved in one place so the handler never has to hold "a row or a starter" in its head, and
787
+ * so the refusal for "both" and "neither" is written once.
788
+ */
789
+ async resolve(tx, workspaceId, locale, templateId, starterKey) {
790
+ if ((templateId === null) === (starterKey === null))
791
+ throw KernError.badRequest('Name a template or a starter, and not both');
792
+ if (starterKey !== null) {
793
+ const s = stringsFor(locale);
794
+ /*
795
+ * A workspace that has edited this starter has a row standing in for it, and addressing the
796
+ * starter by key has to reach that row — otherwise the picker would offer the customised one
797
+ * and pressing it would make the shipped one.
798
+ */
799
+ const [override] = await tx
800
+ .select()
801
+ .from(templates)
802
+ .where(and(eq(templates.workspaceId, workspaceId), eq(templates.key, starterKey)))
803
+ .limit(1);
804
+ if (override)
805
+ return {
806
+ kind: override.kind,
807
+ name: override.name,
808
+ doc: (override.doc ?? {}),
809
+ variables: (override.variables ?? []),
810
+ };
811
+ return {
812
+ kind: 'page',
813
+ name: s[`${starterKey}.name`],
814
+ doc: starterDoc(starterKey, s),
815
+ variables: [],
816
+ };
817
+ }
818
+ const row = await this.row(tx, workspaceId, templateId);
819
+ return {
820
+ kind: row.kind,
821
+ name: row.name,
822
+ doc: (row.doc ?? {}),
823
+ variables: (row.variables ?? []),
824
+ };
825
+ },
826
+ /**
827
+ * Make a page from a page template.
828
+ *
829
+ * The page is created exactly as `pages.create` makes one — same ranks, same parent rules — and
830
+ * then given a body. A template is a starting point, not a different kind of page.
831
+ */
832
+ async instantiatePage(tx, principal, workspaceId, resolved, input) {
833
+ const space = await access.spaceRow(tx, workspaceId, input.spaceId);
834
+ const values = valuesFor(principal, principal.locale, space.name, resolved.variables, input.values);
835
+ const title = fillText(input.title || resolved.name, values).slice(0, 300);
836
+ const page = await pagesSvc.create(tx, principal, workspaceId, {
837
+ spaceId: input.spaceId,
838
+ parentId: input.parentId,
839
+ title,
840
+ kind: 'page',
841
+ icon: null,
842
+ afterId: input.afterId,
843
+ });
844
+ await writeBody(tx, workspaceId, page.id, fillPageDoc(resolved.doc, values));
845
+ return { spaceId: input.spaceId, pageId: page.id, pageCount: 1 };
846
+ },
847
+ /**
848
+ * Make a whole space from a space template.
849
+ *
850
+ * The tree is written depth-first with `pages.create`, so every page gets a real rank among its
851
+ * siblings rather than a rank this file invents — the one thing that must not be re-implemented,
852
+ * because two orderings of the same tree is a sidebar that disagrees with itself.
853
+ */
854
+ async instantiateSpace(tx, principal, workspaceId, resolved, input) {
855
+ const parsed = TemplateSpaceBody.safeParse(resolved.doc);
856
+ if (!parsed.success)
857
+ throw KernError.badRequest('This template does not hold a space');
858
+ const values = valuesFor(principal, principal.locale, input.name, resolved.variables, input.values);
859
+ const space = await spacesSvc.create(tx, principal, workspaceId, {
860
+ key: input.key,
861
+ name: fillText(input.name, values).slice(0, 120),
862
+ description: '',
863
+ icon: null,
864
+ visibility: 'open',
865
+ });
866
+ let made = 0;
867
+ let first = null;
868
+ const write = async (nodes, parentId) => {
869
+ let afterId = null;
870
+ for (const node of nodes) {
871
+ if (made >= MAX_TEMPLATE_PAGES)
872
+ throw KernError.badRequest(`A space template makes at most ${MAX_TEMPLATE_PAGES} pages`);
873
+ const page = await pagesSvc.create(tx, principal, workspaceId, {
874
+ spaceId: space.id,
875
+ parentId,
876
+ title: fillText(node.title, values).slice(0, 300),
877
+ kind: 'page',
878
+ icon: node.icon,
879
+ afterId,
880
+ });
881
+ made += 1;
882
+ first ??= page.id;
883
+ afterId = page.id;
884
+ await writeBody(tx, workspaceId, page.id, fillPageDoc(node.doc, values));
885
+ await write(node.children, page.id);
886
+ }
887
+ };
888
+ await write(parsed.data.pages, null);
889
+ // Opening a space means opening its home page, so the first page of the template is it. A
890
+ // template with no pages leaves it null rather than pointing at nothing.
891
+ if (first)
892
+ await spacesSvc.update(tx, workspaceId, space.id, { homepageId: first });
893
+ return { spaceId: space.id, pageId: first, pageCount: made };
894
+ },
895
+ };
896
+ }
897
+ //# sourceMappingURL=templates.js.map