@kernhq/module-quire 0.6.0 → 0.7.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.
@@ -0,0 +1,287 @@
1
+ import type { Page, PageNode, Space } from './index.js'
2
+
3
+ /**
4
+ * The in-memory quire API.
5
+ *
6
+ * A module missing from the mock has a working page and no way to reach it in exactly the
7
+ * environment used for demos and end-to-end tests. Keep it in step with the contract.
8
+ *
9
+ * Ordering keys here are plain strings that happen to sort — the real ones are base-62 fractions
10
+ * minted by `rankBetween`. Nothing in the mock inserts between two siblings often enough to need it,
11
+ * and a second implementation of that algorithm is a second place for it to be wrong.
12
+ */
13
+ const now = Date.now()
14
+ const iso = (msAgo = 0) => new Date(now - msAgo).toISOString()
15
+
16
+ const uid = (n: number) => `01920000-0000-7000-8000-0000000${String(n).padStart(5, '0')}`
17
+
18
+ interface Row extends Page {
19
+ /** the mock keeps trashed rows in the same list, as the server does */
20
+ _order: string
21
+ }
22
+
23
+ export function createMockQuireApi() {
24
+ const spaces: Space[] = [
25
+ {
26
+ id: uid(1),
27
+ workspaceId: '' as Space['workspaceId'],
28
+ key: 'handbook',
29
+ name: 'Handbook',
30
+ description: 'How this team works',
31
+ icon: 'scroll-text',
32
+ visibility: 'open',
33
+ homepageId: uid(101),
34
+ createdBy: null,
35
+ createdAt: iso(9e7),
36
+ updatedAt: iso(36e5),
37
+ archivedAt: null,
38
+ },
39
+ {
40
+ id: uid(2),
41
+ workspaceId: '' as Space['workspaceId'],
42
+ key: 'engineering',
43
+ name: 'Engineering',
44
+ description: 'Architecture notes, runbooks and decisions',
45
+ icon: 'git-branch',
46
+ visibility: 'restricted',
47
+ homepageId: null,
48
+ createdBy: null,
49
+ createdAt: iso(8e7),
50
+ updatedAt: iso(72e5),
51
+ archivedAt: null,
52
+ },
53
+ ]
54
+
55
+ const page = (
56
+ id: number,
57
+ spaceId: string,
58
+ title: string,
59
+ order: string,
60
+ parent: number | null = null,
61
+ over: Partial<Page> = {},
62
+ ): Row => ({
63
+ id: uid(id),
64
+ workspaceId: '' as Page['workspaceId'],
65
+ spaceId,
66
+ parentId: parent === null ? null : uid(parent),
67
+ position: order,
68
+ kind: 'page',
69
+ title,
70
+ icon: null,
71
+ coverUrl: null,
72
+ publishedVersionId: null,
73
+ hasUnpublishedChanges: false,
74
+ createdBy: null,
75
+ updatedBy: null,
76
+ createdAt: iso(9e7),
77
+ updatedAt: iso(36e5),
78
+ archivedAt: null,
79
+ deletedAt: null,
80
+ _order: order,
81
+ ...over,
82
+ })
83
+
84
+ const pages: Row[] = [
85
+ page(101, uid(1), 'Welcome', 'a'),
86
+ page(102, uid(1), 'Working here', 'b'),
87
+ page(103, uid(1), 'Your first week', 'ba', 102),
88
+ page(104, uid(1), 'Time off', 'bb', 102),
89
+ page(105, uid(1), 'Expenses', 'c', null, { kind: 'live' }),
90
+ page(201, uid(2), 'Architecture', 'a'),
91
+ page(202, uid(2), 'Runbooks', 'b'),
92
+ page(203, uid(2), 'Deploying', 'ba', 202),
93
+ page(204, uid(2), 'An old note', 'c', null, { deletedAt: iso(864e5) }),
94
+ ]
95
+
96
+ let seq = 900
97
+ const nextId = () => uid(++seq)
98
+ const strip = ({ _order, ...p }: Row): Page => p
99
+ const found = (id: string) => {
100
+ const row = pages.find((p) => p.id === id)
101
+ if (!row) throw Object.assign(new Error('Page not found'), { code: 'NOT_FOUND' })
102
+ return row
103
+ }
104
+ /** Every descendant of `id`, including it — the same subtree the server acts on. */
105
+ const subtree = (id: string): Row[] => {
106
+ const out: Row[] = []
107
+ const walk = (parent: string) => {
108
+ out.push(...pages.filter((p) => p.id === parent))
109
+ for (const child of pages.filter((p) => p.parentId === parent)) walk(child.id)
110
+ }
111
+ walk(id)
112
+ return out
113
+ }
114
+ const touch = (row: Row) => {
115
+ row.updatedAt = new Date().toISOString()
116
+ }
117
+
118
+ return {
119
+ spaces: {
120
+ list: async ({ includeArchived = false }: { includeArchived?: boolean } = {}) =>
121
+ spaces.filter((s) => includeArchived || !s.archivedAt),
122
+ get: async ({ spaceId }: { spaceId: string }) => {
123
+ const s = spaces.find((x) => x.id === spaceId)
124
+ if (!s) throw Object.assign(new Error('Space not found'), { code: 'NOT_FOUND' })
125
+ return s
126
+ },
127
+ create: async (input: {
128
+ key: string
129
+ name: string
130
+ description?: string
131
+ icon?: string | null
132
+ visibility?: Space['visibility']
133
+ }) => {
134
+ if (spaces.some((s) => s.key === input.key))
135
+ throw Object.assign(new Error(`A space with the key "${input.key}" already exists`), {
136
+ code: 'CONFLICT',
137
+ })
138
+ const s: Space = {
139
+ id: nextId(),
140
+ workspaceId: '' as Space['workspaceId'],
141
+ key: input.key,
142
+ name: input.name,
143
+ description: input.description ?? '',
144
+ icon: input.icon ?? null,
145
+ visibility: input.visibility ?? 'open',
146
+ homepageId: null,
147
+ createdBy: null,
148
+ createdAt: new Date().toISOString(),
149
+ updatedAt: new Date().toISOString(),
150
+ archivedAt: null,
151
+ }
152
+ spaces.push(s)
153
+ return s
154
+ },
155
+ update: async ({ spaceId, ...patch }: { spaceId: string } & Partial<Space>) => {
156
+ const s = spaces.find((x) => x.id === spaceId)
157
+ if (!s) throw Object.assign(new Error('Space not found'), { code: 'NOT_FOUND' })
158
+ Object.assign(s, patch, { updatedAt: new Date().toISOString() })
159
+ return s
160
+ },
161
+ archive: async ({ spaceId, archived = true }: { spaceId: string; archived?: boolean }) => {
162
+ const s = spaces.find((x) => x.id === spaceId)
163
+ if (!s) throw Object.assign(new Error('Space not found'), { code: 'NOT_FOUND' })
164
+ s.archivedAt = archived ? new Date().toISOString() : null
165
+ return s
166
+ },
167
+ },
168
+
169
+ pages: {
170
+ tree: async ({
171
+ spaceId,
172
+ includeArchived = false,
173
+ }: {
174
+ spaceId: string
175
+ includeArchived?: boolean
176
+ }): Promise<PageNode[]> => {
177
+ const rows = pages
178
+ .filter((p) => p.spaceId === spaceId && !p.deletedAt && (includeArchived || !p.archivedAt))
179
+ .sort((a, b) => (a._order < b._order ? -1 : a._order > b._order ? 1 : 0))
180
+ const parents = new Set(rows.map((r) => r.parentId).filter((x): x is string => x !== null))
181
+ return rows.map((r) => ({
182
+ id: r.id,
183
+ parentId: r.parentId,
184
+ position: r.position,
185
+ kind: r.kind,
186
+ title: r.title,
187
+ icon: r.icon,
188
+ hasChildren: parents.has(r.id),
189
+ archivedAt: r.archivedAt,
190
+ }))
191
+ },
192
+ get: async ({ pageId }: { pageId: string }) => strip(found(pageId)),
193
+ trash: async ({ spaceId, limit = 50 }: { spaceId: string; limit?: number }) => ({
194
+ items: pages
195
+ .filter((p) => p.spaceId === spaceId && p.deletedAt)
196
+ .slice(0, limit)
197
+ .map(strip),
198
+ nextCursor: null,
199
+ }),
200
+ create: async (input: {
201
+ spaceId: string
202
+ parentId?: string | null
203
+ title?: string
204
+ kind?: Page['kind']
205
+ icon?: string | null
206
+ afterId?: string | null
207
+ }) => {
208
+ const siblings = pages
209
+ .filter(
210
+ (p) => p.spaceId === input.spaceId && p.parentId === (input.parentId ?? null) && !p.deletedAt,
211
+ )
212
+ .sort((a, b) => (a._order < b._order ? -1 : 1))
213
+ const last = siblings.at(-1)?._order ?? 'a'
214
+ const row = page(++seq, input.spaceId, input.title ?? '', `${last}m`, null, {
215
+ kind: input.kind ?? 'page',
216
+ icon: input.icon ?? null,
217
+ })
218
+ row.id = uid(seq)
219
+ row.parentId = input.parentId ?? null
220
+ row.createdAt = new Date().toISOString()
221
+ row.updatedAt = row.createdAt
222
+ pages.push(row)
223
+ return strip(row)
224
+ },
225
+ update: async ({ pageId, ...patch }: { pageId: string } & Partial<Page>) => {
226
+ const row = found(pageId)
227
+ Object.assign(row, patch)
228
+ touch(row)
229
+ return strip(row)
230
+ },
231
+ move: async ({
232
+ pageId,
233
+ parentId,
234
+ afterId = null,
235
+ }: {
236
+ pageId: string
237
+ parentId: string | null
238
+ afterId?: string | null
239
+ }) => {
240
+ const row = found(pageId)
241
+ if (parentId === pageId)
242
+ throw Object.assign(new Error('A page cannot be its own parent'), { code: 'BAD_REQUEST' })
243
+ if (parentId && subtree(pageId).some((p) => p.id === parentId))
244
+ throw Object.assign(new Error('A page cannot move inside one of its own descendants'), {
245
+ code: 'BAD_REQUEST',
246
+ })
247
+ row.parentId = parentId
248
+ const siblings = pages
249
+ .filter(
250
+ (p) => p.spaceId === row.spaceId && p.parentId === parentId && p.id !== pageId && !p.deletedAt,
251
+ )
252
+ .sort((a, b) => (a._order < b._order ? -1 : 1))
253
+ const at = afterId ? siblings.findIndex((s) => s.id === afterId) : -1
254
+ const before = at >= 0 ? siblings[at]?._order : undefined
255
+ row._order = before ? `${before}m` : `${siblings[0]?._order ?? 'a'.repeat(1)}0`
256
+ row.position = row._order
257
+ touch(row)
258
+ return strip(row)
259
+ },
260
+ archive: async ({ pageId, archived = true }: { pageId: string; archived?: boolean }) => {
261
+ const row = found(pageId)
262
+ row.archivedAt = archived ? new Date().toISOString() : null
263
+ touch(row)
264
+ return strip(row)
265
+ },
266
+ trashPage: async ({ pageId }: { pageId: string }) => {
267
+ const rows = subtree(pageId)
268
+ const at = new Date().toISOString()
269
+ for (const r of rows) r.deletedAt = at
270
+ return { ok: true as const, count: rows.length }
271
+ },
272
+ restore: async ({ pageId }: { pageId: string }) => {
273
+ const row = found(pageId)
274
+ // Restoring under a parent that is still in the trash would hide it for ever.
275
+ if (row.parentId && pages.find((p) => p.id === row.parentId)?.deletedAt) row.parentId = null
276
+ for (const r of subtree(pageId)) r.deletedAt = null
277
+ touch(row)
278
+ return strip(row)
279
+ },
280
+ purge: async ({ pageId }: { pageId: string }) => {
281
+ const ids = new Set(subtree(pageId).map((r) => r.id))
282
+ for (let i = pages.length - 1; i >= 0; i--) if (ids.has(pages[i]!.id)) pages.splice(i, 1)
283
+ return { ok: true as const, count: ids.size }
284
+ },
285
+ },
286
+ }
287
+ }
@@ -0,0 +1,98 @@
1
+ import { defineClientModule } from '@kernhq/ui'
2
+ import { quireMessageBundles, t } from './i18n.js'
3
+ import { QUIRE_PERMISSIONS } from './permissions.js'
4
+
5
+ /**
6
+ * Quire as the shell sees it.
7
+ *
8
+ * The sidebar is a search box and a page tree rather than a "New page" button, because the sidebar
9
+ * belongs to the module you are in and a wiki's sidebar is its table of contents (DESIGN.md §2.3).
10
+ * Creating a page happens where you are standing — at the space, or under the page you are reading.
11
+ *
12
+ * The three routes are declarations now, not files in the app. `:space` and `:page` are matched by
13
+ * the shell and handed to the component as `params`, so a wiki page's URL is this module's business
14
+ * rather than something the app has to mirror in its route tree.
15
+ *
16
+ * Labels are getters because a module is defined once at import time while the interface language
17
+ * can change afterwards; reading them on render keeps the rail in the language actually chosen.
18
+ */
19
+ export const quireClientModule = defineClientModule({
20
+ id: 'quire',
21
+ name: 'Quire',
22
+ icon: 'scroll-text',
23
+ messages: quireMessageBundles,
24
+
25
+ nav: [
26
+ {
27
+ id: 'quire',
28
+ get label() {
29
+ return t('nav')
30
+ },
31
+ icon: 'scroll-text',
32
+ href: '/quire',
33
+ order: 40,
34
+ permission: QUIRE_PERMISSIONS.spaceView,
35
+ },
36
+ ],
37
+
38
+ routes: [
39
+ {
40
+ path: '/quire',
41
+ component: () => import('./pages/SpacesPage.svelte'),
42
+ get title() {
43
+ return t('title')
44
+ },
45
+ permission: QUIRE_PERMISSIONS.spaceView,
46
+ },
47
+ {
48
+ path: '/quire/:space',
49
+ component: () => import('./pages/SpacePage.svelte'),
50
+ permission: QUIRE_PERMISSIONS.spaceView,
51
+ },
52
+ {
53
+ path: '/quire/:space/:page',
54
+ component: () => import('./pages/PageView.svelte'),
55
+ permission: QUIRE_PERMISSIONS.pageView,
56
+ },
57
+ ],
58
+
59
+ commands: [
60
+ {
61
+ id: 'quire.open',
62
+ get label() {
63
+ return t('cmd_open')
64
+ },
65
+ icon: 'scroll-text',
66
+ permission: QUIRE_PERMISSIONS.spaceView,
67
+ run: (ctx) => ctx.navigate('/quire'),
68
+ },
69
+ {
70
+ id: 'quire.new-space',
71
+ get label() {
72
+ return t('cmd_new_space')
73
+ },
74
+ icon: 'plus',
75
+ permission: QUIRE_PERMISSIONS.spaceManage,
76
+ run: (ctx) => ctx.navigate('/quire?new=1'),
77
+ },
78
+ ],
79
+
80
+ sidebar: [
81
+ {
82
+ id: 'quire',
83
+ match: ['quire'],
84
+ permission: QUIRE_PERMISSIONS.spaceView,
85
+ component: () => import('./components/SidebarSpaces.svelte'),
86
+ },
87
+ ],
88
+
89
+ presenters: [
90
+ {
91
+ type: 'page',
92
+ inline: () => import('./components/PageInline.svelte'),
93
+ page: (id, workspaceSlug) => `/${workspaceSlug}/quire/p/${encodeURIComponent(id)}`,
94
+ },
95
+ ],
96
+ })
97
+
98
+ export default quireClientModule