@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
@@ -24,6 +24,11 @@ import type {
24
24
  RecentEntry,
25
25
  RowRef,
26
26
  Space,
27
+ Template,
28
+ TemplateChoice,
29
+ TemplateKind,
30
+ TemplateResult,
31
+ TemplateVariable,
27
32
  TransferCounts,
28
33
  View,
29
34
  ViewConfig,
@@ -1136,6 +1141,91 @@ export function createMockQuireApi() {
1136
1141
  row.finishedAt = new Date().toISOString()
1137
1142
  }
1138
1143
 
1144
+ /**
1145
+ * The five starters, **in English only**.
1146
+ *
1147
+ * The real ones are constants on the server with a five-locale table beside them, resolved against
1148
+ * the reader's own locale — `server/services/templates.ts` explains why they cannot live in this
1149
+ * package's message bundle. This mock has no server and no principal, so it carries the English
1150
+ * names and nothing else: it is the demo interface, and a demo of the picker is a demo of the
1151
+ * shape rather than of the translation. Anything that needs to see a starter in Persian needs a
1152
+ * server.
1153
+ */
1154
+ const STARTER_NAMES: Array<[string, string, string, string]> = [
1155
+ ['meeting-notes', 'users', 'Meeting notes', 'Who was there, what was decided, and who does what next.'],
1156
+ [
1157
+ 'decision-record',
1158
+ 'flag',
1159
+ 'Decision record',
1160
+ 'One decision, why it was taken, and what it commits you to.',
1161
+ ],
1162
+ [
1163
+ 'requirements',
1164
+ 'target',
1165
+ 'Requirements',
1166
+ 'What a piece of work has to do, and what it deliberately does not.',
1167
+ ],
1168
+ [
1169
+ 'retrospective',
1170
+ 'refresh-cw',
1171
+ 'Retrospective',
1172
+ 'What went well, what got in the way, and what to change.',
1173
+ ],
1174
+ ['how-to', 'wrench', 'How-to', 'A task somebody can follow from start to finish.'],
1175
+ ]
1176
+
1177
+ /**
1178
+ * One template somebody in this workspace already saved, so the picker is not five shipped entries
1179
+ * and nothing else — the interesting half of the feature is the one a colleague made.
1180
+ */
1181
+ const templateRows: Template[] = [
1182
+ {
1183
+ id: uid(700),
1184
+ workspaceId: '' as Template['workspaceId'],
1185
+ spaceId: uid(1),
1186
+ kind: 'page',
1187
+ key: null,
1188
+ builtIn: false,
1189
+ name: 'Weekly review',
1190
+ description: 'What moved this week, and what is stuck.',
1191
+ icon: 'calendar-days',
1192
+ doc: {
1193
+ type: 'doc',
1194
+ content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Week {{week}} · {{author}}' }] }],
1195
+ },
1196
+ variables: [
1197
+ {
1198
+ name: 'week',
1199
+ label: 'Which week',
1200
+ type: 'text',
1201
+ options: [],
1202
+ default: null,
1203
+ required: true,
1204
+ },
1205
+ ],
1206
+ createdBy: COLLEAGUE as Template['createdBy'],
1207
+ createdAt: iso(9e6),
1208
+ updatedAt: iso(9e6),
1209
+ },
1210
+ ]
1211
+
1212
+ const templateChoice = (row: Template): TemplateChoice => ({
1213
+ id: row.id,
1214
+ key: row.key,
1215
+ builtIn: row.builtIn,
1216
+ kind: row.kind,
1217
+ spaceId: row.spaceId,
1218
+ name: row.name,
1219
+ description: row.description,
1220
+ icon: row.icon,
1221
+ variables: row.variables,
1222
+ updatedAt: row.updatedAt,
1223
+ })
1224
+
1225
+ /** The same single-pass, text-only substitution the server does — see `services/templates.ts`. */
1226
+ const fill = (text: string, values: Record<string, string>) =>
1227
+ text.replace(/\{\{\s*([a-z][a-z0-9_]*)\s*\}\}/g, (whole, name: string) => values[name] ?? whole)
1228
+
1139
1229
  return {
1140
1230
  spaces: {
1141
1231
  list: async ({ includeArchived = false }: { includeArchived?: boolean } = {}) =>
@@ -1698,6 +1788,190 @@ export function createMockQuireApi() {
1698
1788
  },
1699
1789
  },
1700
1790
 
1791
+ /**
1792
+ * What somebody writes with.
1793
+ *
1794
+ * The starters and the rows come back as one list with the override rule applied, exactly as the
1795
+ * server does it — a row carrying a starter's key stands in that starter's place rather than
1796
+ * appearing beside it, which is the whole shape a picker has to draw.
1797
+ *
1798
+ * `instantiate` makes a real page in the tree. It cannot write a *body*: there is no collab
1799
+ * service behind `dev:mock`, and a page's prose lives there rather than in this list. So the
1800
+ * title is substituted and the page appears where it should, and the body is the one thing a
1801
+ * demo of this feature cannot show.
1802
+ */
1803
+ templates: {
1804
+ list: async ({
1805
+ kind = 'page',
1806
+ spaceId = null,
1807
+ }: {
1808
+ kind?: TemplateKind
1809
+ spaceId?: string | null
1810
+ } = {}) => {
1811
+ const rows = templateRows.filter(
1812
+ (row) => row.kind === kind && (row.spaceId === null || row.spaceId === spaceId),
1813
+ )
1814
+ const byKey = new Map(rows.filter((row) => row.key).map((row) => [row.key as string, row]))
1815
+ const out: TemplateChoice[] = []
1816
+ if (kind === 'page')
1817
+ for (const [key, icon, name, description] of STARTER_NAMES) {
1818
+ const override = byKey.get(key)
1819
+ out.push(
1820
+ override
1821
+ ? templateChoice(override)
1822
+ : {
1823
+ id: null,
1824
+ key,
1825
+ builtIn: true,
1826
+ kind: 'page',
1827
+ spaceId: null,
1828
+ name,
1829
+ description,
1830
+ icon,
1831
+ variables: [],
1832
+ updatedAt: null,
1833
+ },
1834
+ )
1835
+ }
1836
+ for (const row of rows)
1837
+ if (!row.key || !STARTER_NAMES.some(([key]) => key === row.key)) out.push(templateChoice(row))
1838
+ return out
1839
+ },
1840
+
1841
+ get: async ({ templateId }: { templateId: string }) => {
1842
+ const row = templateRows.find((t) => t.id === templateId)
1843
+ if (!row) throw notFound('Template')
1844
+ return row
1845
+ },
1846
+
1847
+ createFromPage: async (input: {
1848
+ kind?: TemplateKind
1849
+ sourceId: string
1850
+ spaceId?: string | null
1851
+ name: string
1852
+ description?: string
1853
+ icon?: string | null
1854
+ variables?: TemplateVariable[]
1855
+ key?: string | null
1856
+ }) => {
1857
+ if (input.key && templateRows.some((t) => t.key === input.key))
1858
+ throw Object.assign(new Error('This workspace already has its own version of that template'), {
1859
+ code: 'CONFLICT',
1860
+ })
1861
+ const source = input.kind === 'space' ? null : found(input.sourceId)
1862
+ const row: Template = {
1863
+ id: nextId(),
1864
+ workspaceId: '' as Template['workspaceId'],
1865
+ spaceId: input.kind === 'space' ? null : (input.spaceId ?? null),
1866
+ kind: input.kind ?? 'page',
1867
+ key: input.key ?? null,
1868
+ builtIn: Boolean(input.key),
1869
+ name: input.name,
1870
+ description: input.description ?? '',
1871
+ icon: input.icon ?? null,
1872
+ // No collab service, so there is no body to read — the title stands in for the prose, which
1873
+ // is enough for the picker and honest about what a mock can know.
1874
+ doc:
1875
+ input.kind === 'space'
1876
+ ? { pages: [] }
1877
+ : {
1878
+ type: 'doc',
1879
+ content: [{ type: 'paragraph', content: [{ type: 'text', text: source?.title ?? '' }] }],
1880
+ },
1881
+ variables: input.variables ?? [],
1882
+ createdBy: ME as Template['createdBy'],
1883
+ createdAt: new Date().toISOString(),
1884
+ updatedAt: new Date().toISOString(),
1885
+ }
1886
+ templateRows.push(row)
1887
+ return row
1888
+ },
1889
+
1890
+ update: async ({ templateId, ...patch }: { templateId: string } & Partial<Template>) => {
1891
+ const row = templateRows.find((t) => t.id === templateId)
1892
+ if (!row) throw notFound('Template')
1893
+ Object.assign(row, patch, { updatedAt: new Date().toISOString() })
1894
+ return row
1895
+ },
1896
+
1897
+ remove: async ({ templateId }: { templateId: string }) => {
1898
+ const at = templateRows.findIndex((t) => t.id === templateId)
1899
+ if (at < 0) throw notFound('Template')
1900
+ templateRows.splice(at, 1)
1901
+ return { ok: true as const }
1902
+ },
1903
+
1904
+ instantiate: async (input: {
1905
+ templateId?: string | null
1906
+ starterKey?: string | null
1907
+ spaceId?: string | null
1908
+ parentId?: string | null
1909
+ afterId?: string | null
1910
+ title?: string
1911
+ key?: string | null
1912
+ name?: string
1913
+ values?: Record<string, string>
1914
+ }): Promise<TemplateResult> => {
1915
+ const row = input.templateId
1916
+ ? templateRows.find((t) => t.id === input.templateId)
1917
+ : templateRows.find((t) => t.key === input.starterKey)
1918
+ const starter = STARTER_NAMES.find(([key]) => key === input.starterKey)
1919
+ if (!row && !starter) throw notFound('Template')
1920
+
1921
+ const values: Record<string, string> = {
1922
+ date: new Date().toLocaleDateString(),
1923
+ time: new Date().toLocaleTimeString(),
1924
+ author: 'You',
1925
+ space: spaces.find((s) => s.id === input.spaceId)?.name ?? '',
1926
+ ...(input.values ?? {}),
1927
+ }
1928
+ for (const variable of row?.variables ?? [])
1929
+ if (variable.required && !values[variable.name])
1930
+ throw Object.assign(new Error(`"${variable.label}" is needed before this can be made`), {
1931
+ code: 'BAD_REQUEST',
1932
+ })
1933
+
1934
+ if (row?.kind === 'space') {
1935
+ if (!input.key || !input.name)
1936
+ throw Object.assign(new Error('A space template needs a name and an address'), {
1937
+ code: 'BAD_REQUEST',
1938
+ })
1939
+ const space: Space = {
1940
+ id: nextId(),
1941
+ workspaceId: '' as Space['workspaceId'],
1942
+ key: input.key,
1943
+ name: fill(input.name, values),
1944
+ description: '',
1945
+ icon: null,
1946
+ visibility: 'open',
1947
+ homepageId: null,
1948
+ createdBy: ME as Space['createdBy'],
1949
+ createdAt: new Date().toISOString(),
1950
+ updatedAt: new Date().toISOString(),
1951
+ archivedAt: null,
1952
+ }
1953
+ spaces.push(space)
1954
+ return { spaceId: space.id, pageId: null, pageCount: 0 }
1955
+ }
1956
+
1957
+ const spaceId = input.spaceId
1958
+ if (!spaceId) throw Object.assign(new Error('A page template needs a space'), { code: 'BAD_REQUEST' })
1959
+ const siblings = pages
1960
+ .filter((p) => p.spaceId === spaceId && p.parentId === (input.parentId ?? null) && !p.deletedAt)
1961
+ .sort((a, b) => (a._order < b._order ? -1 : 1))
1962
+ const title = fill(input.title || row?.name || starter?.[2] || '', values)
1963
+ const created = page(++seq, spaceId, title, `${siblings.at(-1)?._order ?? 'a'}m`, null, {
1964
+ icon: row?.icon ?? starter?.[1] ?? null,
1965
+ })
1966
+ created.id = uid(seq)
1967
+ created.parentId = input.parentId ?? null
1968
+ created.createdAt = new Date().toISOString()
1969
+ created.updatedAt = created.createdAt
1970
+ pages.push(created)
1971
+ return { spaceId, pageId: created.id, pageCount: 1 }
1972
+ },
1973
+ },
1974
+
1701
1975
  publishing: {
1702
1976
  publish: async ({ pageId, label = null }: { pageId: string; label?: string | null }) => {
1703
1977
  const row = found(pageId)
@@ -27,6 +27,7 @@ import FavoriteStar from '../components/FavoriteStar.svelte'
27
27
  import PageEditor from '../components/PageEditor.svelte'
28
28
  import PageLabels from '../components/PageLabels.svelte'
29
29
  import PublishDialog from '../components/PublishDialog.svelte'
30
+ import SaveAsTemplateDialog from '../components/SaveAsTemplateDialog.svelte'
30
31
  import VersionHistory from '../components/VersionHistory.svelte'
31
32
  import { type CoreApi, toPerson } from '../core-api.js'
32
33
  import DatabaseView from '../database/DatabaseView.svelte'
@@ -78,6 +79,19 @@ const membersQuery = createQuery(() => ({
78
79
  queryFn: () => core.workspaces.members.list({ workspaceId, limit: 200 }),
79
80
  }))
80
81
 
82
+ /*
83
+ * The space by name, for the one sentence that needs it: "offered only in Handbook".
84
+ *
85
+ * The same query key the sidebar already holds, so this is a cache read rather than a request —
86
+ * and the key is what the URL carries, which is not a thing to show somebody in a sentence.
87
+ */
88
+ const spacesQuery = createQuery(() => ({
89
+ queryKey: quireKeys.spaces(workspaceId),
90
+ enabled: Boolean(workspaceId),
91
+ queryFn: () => api.spaces.list({ workspaceId, includeArchived: false }),
92
+ }))
93
+ const spaceName = $derived((spacesQuery.data ?? []).find((space) => space.key === spaceKey)?.name ?? spaceKey)
94
+
81
95
  const query = createQuery(() => ({
82
96
  queryKey: quireKeys.page(workspaceId, pageId),
83
97
  enabled: Boolean(workspaceId && pageId),
@@ -276,6 +290,14 @@ const canPublish = $derived(canQuire('pagePublish'))
276
290
  */
277
291
  let exportOpen = $state(false)
278
292
 
293
+ /**
294
+ * Saving this page as something to write with again.
295
+ *
296
+ * A flag and a menu entry, exactly like the export above: what a template needs to know is the
297
+ * dialog's business, and it reads the starters and the space list itself when it opens.
298
+ */
299
+ let templateOpen = $state(false)
300
+
279
301
  const publicationsQuery = createQuery(() => ({
280
302
  queryKey: quireKeys.publications(workspaceId, doc?.spaceId ?? ''),
281
303
  enabled: canPublish && Boolean(workspaceId && doc?.spaceId),
@@ -621,6 +643,23 @@ async function undoTrash(workspace: string, id: string, spaceId: string, title:
621
643
  },
622
644
  ]
623
645
  : []),
646
+ /*
647
+ * `space.manage`, not `page.edit`, matching what `templates.createFromPage` asks — and the
648
+ * asymmetry is the point. A template changes what everybody in the space is offered the
649
+ * next time they make a page, which is the space's furniture rather than this page's
650
+ * content, exactly as a label is. Somebody who may write here is not thereby somebody who
651
+ * may add to everybody else's picker.
652
+ */
653
+ ...(canQuire('spaceManage')
654
+ ? [
655
+ {
656
+ id: 'save-template',
657
+ label: t('template_menu'),
658
+ icon: 'clipboard-list',
659
+ onSelect: () => (templateOpen = true),
660
+ },
661
+ ]
662
+ : []),
624
663
  {
625
664
  id: 'watch',
626
665
  label: watching ? t('watch_stop') : t('watch'),
@@ -793,6 +832,17 @@ async function undoTrash(workspace: string, id: string, spaceId: string, title:
793
832
  page={{ id: doc.id, title: doc.title }}
794
833
  />
795
834
 
835
+ {#if canQuire('spaceManage')}
836
+ <SaveAsTemplateDialog
837
+ bind:open={templateOpen}
838
+ {workspaceId}
839
+ spaceId={doc.spaceId}
840
+ {spaceName}
841
+ pageId={doc.id}
842
+ pageTitle={doc.title}
843
+ />
844
+ {/if}
845
+
796
846
  <!--
797
847
  The body says nothing about numbers until it knows them. Naming a count before the tree has
798
848
  loaded would be the same silent lie in a smaller size — "it goes to the trash" for a page that
@@ -2,6 +2,7 @@
2
2
  import { Button, EmptyState, navigation, Skeleton, session } from '@kernhq/ui'
3
3
  import { createQuery } from '@tanstack/svelte-query'
4
4
  import { getQuireApi } from '../api-instance.js'
5
+ import TemplatePicker from '../components/TemplatePicker.svelte'
5
6
  import { t } from '../i18n.js'
6
7
  import { canQuire } from '../permissions.js'
7
8
  import { quireKeys } from '../query.js'
@@ -57,6 +58,16 @@ const firstPageId = $derived(
57
58
  )
58
59
 
59
60
  let creating = $state(false)
61
+ /**
62
+ * The first page of an empty space is the one page most worth offering a template for — a handbook
63
+ * that starts as a how-to is a handbook somebody kept writing. The picker still opens on **Blank
64
+ * page**, so nothing about starting from nothing got slower.
65
+ */
66
+ let pickerOpen = $state(false)
67
+
68
+ const open = (pageId: string) =>
69
+ void navigation.go(`/${workspaceSlug}/quire/${encodeURIComponent(spaceKey)}/${encodeURIComponent(pageId)}`)
70
+
60
71
  async function createFirst() {
61
72
  if (!space || creating) return
62
73
  creating = true
@@ -70,9 +81,7 @@ async function createFirst() {
70
81
  icon: null,
71
82
  afterId: null,
72
83
  })
73
- void navigation.go(
74
- `/${workspaceSlug}/quire/${encodeURIComponent(spaceKey)}/${encodeURIComponent(created.id)}`,
75
- )
84
+ open(created.id)
76
85
  } finally {
77
86
  creating = false
78
87
  }
@@ -96,11 +105,18 @@ async function createFirst() {
96
105
  <EmptyState icon="file-text" title={t('space_empty')} description={t('space_empty_desc')}>
97
106
  {#snippet actions()}
98
107
  {#if canQuire('pageCreate')}
99
- <Button disabled={creating} onclick={createFirst}>{t('new_page')}</Button>
108
+ <Button aria-busy={creating} onclick={() => (pickerOpen = true)}>{t('new_page')}</Button>
100
109
  {/if}
101
110
  {/snippet}
102
111
  </EmptyState>
103
112
  </div>
113
+ <TemplatePicker
114
+ bind:open={pickerOpen}
115
+ {workspaceId}
116
+ spaceId={space.id}
117
+ onBlank={() => void createFirst()}
118
+ onMade={(result) => result.pageId && open(result.pageId)}
119
+ />
104
120
  {/if}
105
121
 
106
122
  <style>
@@ -54,6 +54,23 @@ export const quireKeys = {
54
54
  ['quire', 'publication', workspaceId, spaceId] as const,
55
55
  site: (workspaceId: string, slug: string) => ['quire', 'publication', workspaceId, 'site', slug] as const,
56
56
 
57
+ /**
58
+ * What may be made here.
59
+ *
60
+ * `template` is the entity `templates.createFromPage|update|remove` announce, so somebody saving a
61
+ * page as a template puts it in everybody else's picker without a reload — which matters more here
62
+ * than for most lists, because the whole point of a template is that a colleague finds it.
63
+ *
64
+ * Keyed by space *and* kind: a space-scoped template is offered in one space and not the next, so
65
+ * one key for the workspace would serve the Handbook's list to Engineering. `spaceId` is null for
66
+ * the workspace-wide question the "New space" picker asks, which is a different answer and
67
+ * therefore a different key.
68
+ */
69
+ templates: (workspaceId: string, kind: string, spaceId: string | null) =>
70
+ ['quire', 'template', workspaceId, kind, spaceId ?? 'workspace'] as const,
71
+ template: (workspaceId: string, templateId: string) =>
72
+ ['quire', 'template', workspaceId, templateId] as const,
73
+
57
74
  /** the schema — properties and views — which every open tab of a database is drawing */
58
75
  database: (workspaceId: string, databaseId: string) =>
59
76
  ['quire', 'database', workspaceId, databaseId] as const,
@@ -473,4 +473,195 @@ export const ImportJob = z.object({
473
473
  })
474
474
  export type ImportJob = z.infer<typeof ImportJob>
475
475
 
476
+ // =====================================================================================
477
+ // Templates
478
+ // =====================================================================================
479
+
480
+ /**
481
+ * What a template makes.
482
+ *
483
+ * `page` — one page; the body is a page doc.
484
+ * `space` — a whole space; the body is a tree of pages, each with its own body.
485
+ *
486
+ * Two rather than one with a "just make the root" flag, because they are made from different places
487
+ * and answer different questions: a page template is offered on "New page" inside a space that
488
+ * already exists, and a space template is what somebody reaches for when there is no space yet.
489
+ */
490
+ export const TemplateKind = z.enum(['page', 'space'])
491
+ export type TemplateKind = z.infer<typeof TemplateKind>
492
+
493
+ /**
494
+ * The starters this module ships.
495
+ *
496
+ * **They are constants in the module, not rows in a customer's database**, and the key is what a
497
+ * constant has instead of an id. `migrations/0011_templates.sql` argues the choice at length; the
498
+ * short of it is that a migration runs once per *database* and has no workspace to seed into, that a
499
+ * seeded row is frozen at the release that wrote it, and that a row holds one language in a product
500
+ * that ships five.
501
+ *
502
+ * A workspace edits a starter by **overriding** it: a `Template` row carrying one of these keys takes
503
+ * that starter's place in the picker rather than sitting beside it, so the first edit writes one row,
504
+ * a workspace that never touches them has none, and resetting is deleting the row.
505
+ *
506
+ * `Template.key` is deliberately **not** this enum — see the comment there.
507
+ */
508
+ export const TemplateStarterKey = z.enum([
509
+ 'meeting-notes',
510
+ 'decision-record',
511
+ 'requirements',
512
+ 'retrospective',
513
+ 'how-to',
514
+ ])
515
+ export type TemplateStarterKey = z.infer<typeof TemplateStarterKey>
516
+ export const TEMPLATE_STARTER_KEYS = TemplateStarterKey.options
517
+
518
+ /**
519
+ * What a variable's type decides, which is **the control somebody is shown and not the storage**.
520
+ *
521
+ * Every filled value is substituted into prose as text, whatever the type: `{{sprint}}` in a heading
522
+ * becomes characters in a heading. The type is what turns "type the date" into a date picker and
523
+ * "type one of these four" into a menu — the difference between a form somebody fills correctly and
524
+ * one they fill approximately.
525
+ */
526
+ export const TemplateVariableType = z.enum(['text', 'number', 'date', 'select', 'user'])
527
+ export type TemplateVariableType = z.infer<typeof TemplateVariableType>
528
+
529
+ /**
530
+ * The names a template author may not take, because the module already fills them.
531
+ *
532
+ * `date` and `author` are the two the plan names. The other three are reserved now rather than
533
+ * later, and that is the whole reason the list is longer than it needs to be today: adding
534
+ * `{{time}}` in a future release would silently change what an existing template renders if some
535
+ * author had already declared a variable of that name. Reserving a name costs an author one
536
+ * synonym; taking one back costs somebody a document that used to be right.
537
+ */
538
+ export const TEMPLATE_BUILT_IN_VARIABLES = ['date', 'time', 'author', 'space', 'workspace'] as const
539
+
540
+ /**
541
+ * What appears between the braces.
542
+ *
543
+ * ASCII, lowercase, no spaces — the same shape as `Space.key` and for the same reason: this string
544
+ * is matched against the body, so `{{Sprint}}` and `{{sprint}}` being two variables is exactly the
545
+ * near-duplicate that `labels_ws_space_name_uq` refuses next door. An author's own language belongs
546
+ * in `label`, which is the half a person actually reads.
547
+ */
548
+ export const TemplateVariableName = z
549
+ .string()
550
+ .min(1)
551
+ .max(40)
552
+ .regex(/^[a-z][a-z0-9_]*$/, 'lowercase letters, digits and underscores, starting with a letter')
553
+ .refine(
554
+ (name) => !(TEMPLATE_BUILT_IN_VARIABLES as readonly string[]).includes(name),
555
+ 'that name is filled by the module itself',
556
+ )
557
+
558
+ /**
559
+ * One thing somebody is asked before the page is made.
560
+ *
561
+ * Every field but `name`, `label` and `type` defaults, so a variable written by an older client
562
+ * still parses. Note what that does to the *output* type: it makes them required, so anything
563
+ * constructing a variable supplies all six — which is the point, and the reason a widened contract
564
+ * is additive for parsing and breaking for constructing.
565
+ */
566
+ export const TemplateVariable = z.object({
567
+ name: TemplateVariableName,
568
+ /** what the person filling it in reads; their own language goes here, not in `name` */
569
+ label: z.string().min(1).max(120),
570
+ type: TemplateVariableType,
571
+ /** the menu, for `select`; empty for every other type */
572
+ options: z.array(z.string().min(1).max(120)).max(50).default([]),
573
+ /** what the field starts with — text whatever the type, because substitution is textual */
574
+ default: z.string().max(2000).nullable().default(null),
575
+ /** whether the page can be made without it */
576
+ required: z.boolean().default(false),
577
+ })
578
+ export type TemplateVariable = z.infer<typeof TemplateVariable>
579
+
580
+ /**
581
+ * One page of a space template, and everything under it.
582
+ *
583
+ * Recursive because a space is a tree and flattening it into a list with parent pointers would mean
584
+ * inventing local ids that exist only inside the column — an identifier nothing outside this
585
+ * document ever resolves. The depth is bounded by the space it was made from.
586
+ *
587
+ * The type is written out and the schema annotated with it, rather than left to inference, because
588
+ * a self-referencing `const` has no type TypeScript can name on its own and the package emits
589
+ * declarations.
590
+ */
591
+ export type TemplateSpaceNode = {
592
+ title: string
593
+ icon: string | null
594
+ doc: Record<string, unknown>
595
+ children: TemplateSpaceNode[]
596
+ }
597
+ export const TemplateSpaceNode: z.ZodType<TemplateSpaceNode, TemplateSpaceNode> = z.lazy(() =>
598
+ z.object({
599
+ title: z.string().max(300),
600
+ icon: z.string().max(64).nullable(),
601
+ /** the page's body, with the same `{{variables}}` in it as any other */
602
+ doc: RichDoc,
603
+ children: z.array(TemplateSpaceNode),
604
+ }),
605
+ )
606
+
607
+ /**
608
+ * The body of a `space` template: the tree, under one key.
609
+ *
610
+ * An object rather than a bare array so that `Template.doc` is a JSON object for both kinds — the
611
+ * column defaults to `'{}'`, and a default that is the wrong *type* for half the rows is a default
612
+ * that lies. It also means the two bodies are told apart by their keys rather than by `typeof`.
613
+ */
614
+ export const TemplateSpaceBody = z.object({ pages: z.array(TemplateSpaceNode) })
615
+ export type TemplateSpaceBody = z.infer<typeof TemplateSpaceBody>
616
+
617
+ /**
618
+ * A page, or a whole space, saved so it can be made again.
619
+ *
620
+ * `doc` is left opaque here for the reason `RichDoc` is: this file is not what knows the shape of a
621
+ * document. It is a page doc when `kind` is `page` and a `TemplateSpaceBody` when `kind` is `space`,
622
+ * and the server parses the second with the schema above.
623
+ *
624
+ * A shipped starter is not one of these — it has no id, no workspace and no timestamps, because it
625
+ * is a constant. What the picker returns is the starters and these together, with a row carrying a
626
+ * starter's `key` standing in for that starter.
627
+ */
628
+ export const Template = z.object({
629
+ id: Id,
630
+ workspaceId: WorkspaceId,
631
+ /** null is workspace-wide; a space id scopes it to one space. Always null when `kind` is `space`. */
632
+ spaceId: Id.nullable(),
633
+ kind: TemplateKind,
634
+ /**
635
+ * The starter this row replaces, or null for somebody's own template.
636
+ *
637
+ * `z.string()` and **not** `TemplateStarterKey`, deliberately. This is stored data: a release that
638
+ * stopped shipping a starter would turn every override of it into a parse failure, which is a
639
+ * picker that throws rather than a picker missing one entry. A key naming a starter that no longer
640
+ * exists is an ordinary custom template, and the read side treats it as one.
641
+ */
642
+ key: z.string().min(1).max(64).nullable(),
643
+ /** true exactly when `key` is set — the database holds the pair to that with a check constraint */
644
+ builtIn: z.boolean(),
645
+ name: z.string().min(1).max(120),
646
+ description: z.string().max(2000),
647
+ /** a Lucide icon name, or an emoji */
648
+ icon: z.string().max(64).nullable(),
649
+ doc: RichDoc,
650
+ variables: z.array(TemplateVariable),
651
+ createdBy: UserId.nullable(),
652
+ createdAt: Timestamp,
653
+ updatedAt: Timestamp,
654
+ })
655
+ export type Template = z.infer<typeof Template>
656
+
657
+ /**
658
+ * A template without its body.
659
+ *
660
+ * This exists so that opening a picker does not download every template's prose to draw a list of
661
+ * names — thirty page docs to render thirty rows, of which one is ever used. The body is fetched
662
+ * when a template is chosen.
663
+ */
664
+ export const TemplateSummary = Template.omit({ doc: true })
665
+ export type TemplateSummary = z.infer<typeof TemplateSummary>
666
+
476
667
  export const Ok = z.object({ ok: z.literal(true) })