@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.
- package/dist/server/formula.d.ts +13 -3
- package/dist/server/formula.d.ts.map +1 -1
- package/dist/server/formula.js +4 -4
- package/dist/server/formula.js.map +1 -1
- package/package.json +15 -3
- package/src/client/api-instance.ts +29 -0
- package/src/client/components/CommentsPanel.svelte +305 -0
- package/src/client/components/NewSpaceDialog.svelte +141 -0
- package/src/client/components/PageEditor.svelte +83 -0
- package/src/client/components/PageInline.svelte +44 -0
- package/src/client/components/PageTreeRow.svelte +147 -0
- package/src/client/components/SidebarSpaces.svelte +248 -0
- package/src/client/components/VersionHistory.svelte +159 -0
- package/src/client/i18n.ts +443 -0
- package/src/client/index.ts +4 -10
- package/src/client/mock.ts +287 -0
- package/src/client/module.ts +98 -0
- package/src/client/pages/PageView.svelte +406 -0
- package/src/client/pages/SpacePage.svelte +79 -0
- package/src/client/pages/SpacesPage.svelte +141 -0
- package/src/client/permissions.ts +39 -0
- package/src/client/query.ts +15 -0
|
@@ -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
|