@kernhq/module-quire 0.11.0 → 0.12.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 (48) hide show
  1. package/dist/contract/models.d.ts +46 -0
  2. package/dist/contract/models.d.ts.map +1 -1
  3. package/dist/contract/models.js +73 -0
  4. package/dist/contract/models.js.map +1 -1
  5. package/dist/contract/permissions.d.ts +17 -1
  6. package/dist/contract/permissions.d.ts.map +1 -1
  7. package/dist/contract/permissions.js +34 -0
  8. package/dist/contract/permissions.js.map +1 -1
  9. package/dist/contract/router.d.ts +670 -0
  10. package/dist/contract/router.d.ts.map +1 -1
  11. package/dist/contract/router.js +273 -2
  12. package/dist/contract/router.js.map +1 -1
  13. package/dist/server/_impl.d.ts +770 -0
  14. package/dist/server/_impl.d.ts.map +1 -1
  15. package/dist/server/_impl.js +264 -2
  16. package/dist/server/_impl.js.map +1 -1
  17. package/dist/server/schema.d.ts +318 -1
  18. package/dist/server/schema.d.ts.map +1 -1
  19. package/dist/server/schema.js +86 -0
  20. package/dist/server/schema.js.map +1 -1
  21. package/dist/server/services/access.d.ts +1 -0
  22. package/dist/server/services/access.d.ts.map +1 -1
  23. package/dist/server/services/index.d.ts +3 -0
  24. package/dist/server/services/index.d.ts.map +1 -1
  25. package/dist/server/services/index.js +5 -1
  26. package/dist/server/services/index.js.map +1 -1
  27. package/dist/server/services/pages.d.ts.map +1 -1
  28. package/dist/server/services/pages.js +6 -0
  29. package/dist/server/services/pages.js.map +1 -1
  30. package/dist/server/services/publications.d.ts +177 -0
  31. package/dist/server/services/publications.d.ts.map +1 -0
  32. package/dist/server/services/publications.js +553 -0
  33. package/dist/server/services/publications.js.map +1 -0
  34. package/dist/server/services/versions.d.ts +4 -0
  35. package/dist/server/services/versions.d.ts.map +1 -1
  36. package/migrations/0008_publications.sql +140 -0
  37. package/migrations/meta/_journal.json +7 -0
  38. package/package.json +1 -1
  39. package/src/client/components/PublishDialog.svelte +857 -0
  40. package/src/client/i18n.ts +434 -0
  41. package/src/client/index.ts +21 -0
  42. package/src/client/mock.ts +426 -2
  43. package/src/client/pages/PageView.svelte +196 -5
  44. package/src/client/public-url.ts +64 -0
  45. package/src/client/query.ts +16 -0
  46. package/src/contract/models.ts +77 -0
  47. package/src/contract/permissions.ts +53 -1
  48. package/src/contract/router.ts +302 -1
@@ -0,0 +1,857 @@
1
+ <script lang="ts">
2
+ import { Button, Dialog, Field, Icon, Input, relativeTime, Select, Switch, Textarea, toast } from '@kernhq/ui'
3
+ import { createQuery, useQueryClient } from '@tanstack/svelte-query'
4
+ import { untrack } from 'svelte'
5
+ import { getQuireApi } from '../api-instance.js'
6
+ import { t } from '../i18n.js'
7
+ import type { Page, PageNode, Publication } from '../index.js'
8
+ import { publicSiteUrl } from '../public-url.js'
9
+ import { quireKeys } from '../query.js'
10
+
11
+ /**
12
+ * Putting a page on the internet, and taking it off again.
13
+ *
14
+ * **This is the one screen in Quire where a misunderstanding is a data leak**, and three things
15
+ * follow from that rather than from taste:
16
+ *
17
+ * 1. **The consequence is stated above the control that causes it**, in a block nobody has to open,
18
+ * in the words somebody would use afterwards: anyone with the link, without signing in, this
19
+ * page *and everything under it*. A checkbox called "Public" with a tooltip is the shape of this
20
+ * screen that leaks a handbook.
21
+ * 2. **What a stranger sees is measured, not claimed.** After publishing, the dialog calls the
22
+ * signed-out `public.site` — the same procedure the internet calls — and reports the number of
23
+ * pages that came back. That is the only line here that cannot be wrong: a root page nobody ever
24
+ * published produces a live URL and an empty site, which looks exactly like success from the
25
+ * inside and is a broken link to everybody else.
26
+ * 3. **The per-page list says why a page is not on the site, not only whether it was opted out.**
27
+ * A switch on its own answers "did somebody exclude this", and the interesting question is "is
28
+ * this readable by strangers" — which a never-published page, an archived one and a child of an
29
+ * opted-out parent all answer differently and for different reasons.
30
+ *
31
+ * What this dialog deliberately does *not* do is decide who may publish. The menu entry that opens
32
+ * it is gated on `quire.page.publish` for the rail's benefit, and every procedure it calls asks the
33
+ * same permission about this page on the server. A person who reaches it another way gets refused
34
+ * there, which is the only refusal that counts.
35
+ */
36
+ interface Props {
37
+ open?: boolean
38
+ workspaceId: string
39
+ workspaceSlug: string
40
+ spaceId: string
41
+ /** the page the site would be rooted at — its own published version is the front page */
42
+ page: Pick<Page, 'id' | 'title' | 'publishedVersionId'>
43
+ }
44
+ let { open = $bindable(false), workspaceId, workspaceSlug, spaceId, page }: Props = $props()
45
+
46
+ const api = getQuireApi()
47
+ const client = useQueryClient()
48
+
49
+ // ------------------------------------------------------------------------------------------------
50
+ // What exists
51
+ // ------------------------------------------------------------------------------------------------
52
+
53
+ /**
54
+ * Space-scoped, because that is the only listing the server offers — a publication has no
55
+ * "for this page" read, and asking for one page's would be a second procedure saying the same
56
+ * thing. The list is small (one row per published site in the space) and it is already the key a
57
+ * `publication` announcement invalidates, so another tab publishing something redraws this one.
58
+ */
59
+ const publicationsQuery = createQuery(() => ({
60
+ queryKey: quireKeys.publications(workspaceId, spaceId),
61
+ enabled: open && Boolean(workspaceId && spaceId),
62
+ queryFn: () => api.publications.list({ workspaceId, spaceId }),
63
+ }))
64
+ const publication = $derived((publicationsQuery.data ?? []).find((row) => row.rootPageId === page.id) ?? null)
65
+ /**
66
+ * Nothing is drawn until the list has arrived.
67
+ *
68
+ * "No publication yet" and "not asked yet" are the same value here, and rendering the first while
69
+ * the second is true opens a published page on the *unpublished* screen — a "Publish to the web"
70
+ * button for a site that already exists, which invites somebody to make a second one. It is a
71
+ * flicker on a fast connection and a wrong screen on a slow one.
72
+ */
73
+ const loading = $derived(publicationsQuery.isPending)
74
+
75
+ /**
76
+ * The same key the sidebar holds, on purpose.
77
+ *
78
+ * Opening this dialog costs no request in the common case — the tree for the space somebody is
79
+ * reading is already in the cache — and, more usefully, toggling a page here refreshes the sidebar
80
+ * for free. `includeArchived: false` has to match the sidebar's call exactly or the two queries are
81
+ * different queries wearing the same key.
82
+ */
83
+ const treeQuery = createQuery(() => ({
84
+ queryKey: quireKeys.tree(workspaceId, spaceId),
85
+ enabled: open && Boolean(workspaceId && spaceId),
86
+ queryFn: () => api.pages.tree({ workspaceId, spaceId, includeArchived: false }),
87
+ }))
88
+
89
+ /**
90
+ * The signed-out read of the site, made from inside the app.
91
+ *
92
+ * `public.site` replaces the principal before it does anything, so this call is answered exactly as
93
+ * it would be for a stranger — which is what makes the sentence it feeds honest rather than
94
+ * reassuring. `retry: false` because the failure *is* the answer: an address that 404s should say
95
+ * so at once, not after three attempts.
96
+ */
97
+ const siteQuery = createQuery(() => ({
98
+ queryKey: quireKeys.site(workspaceId, publication?.slug ?? ''),
99
+ enabled: open && Boolean(workspaceId && publication),
100
+ retry: false,
101
+ queryFn: () => api.public.site({ workspaceId, slug: publication?.slug ?? '', token: null }),
102
+ }))
103
+
104
+ // ------------------------------------------------------------------------------------------------
105
+ // The form
106
+ // ------------------------------------------------------------------------------------------------
107
+
108
+ const SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/
109
+
110
+ const slugify = (value: string) =>
111
+ value
112
+ .toLowerCase()
113
+ .replace(/[^a-z0-9]+/g, '-')
114
+ .replace(/^-+|-+$/g, '')
115
+ .slice(0, 64)
116
+ .replace(/-+$/g, '')
117
+
118
+ let slug = $state('')
119
+ let includeDescendants = $state(true)
120
+ let indexable = $state(true)
121
+ let theme = $state<Publication['theme']>('auto')
122
+ let seoTitle = $state('')
123
+ let seoDescription = $state('')
124
+ let ogImageUrl = $state('')
125
+ let expiresOn = $state('')
126
+ /** empty means "leave the password alone" — never "there is no password". See `passwordPatch`. */
127
+ let password = $state('')
128
+ let dropPassword = $state(false)
129
+ let showMore = $state(false)
130
+ let error = $state<string | null>(null)
131
+ let confirmingUnpublish = $state(false)
132
+
133
+ /**
134
+ * Which row the form was filled from, so it is filled once rather than on every render.
135
+ *
136
+ * An `$effect` that reads the fields it also writes is its own trigger, so `formFor` is read
137
+ * through `untrack` — the effect depends on `open` and on the publication, and on nothing it sets.
138
+ * Resetting it when the dialog closes is what makes reopening show what is stored rather than a
139
+ * half-finished edit somebody abandoned.
140
+ */
141
+ let formFor = $state<string | null>(null)
142
+
143
+ $effect(() => {
144
+ const row = open ? publication : null
145
+ const key = open ? (row?.id ?? 'new') : null
146
+ if (untrack(() => formFor) === key) return
147
+ formFor = key
148
+ error = null
149
+ password = ''
150
+ dropPassword = false
151
+ confirmingUnpublish = false
152
+ if (key === null) return
153
+ slug = row ? row.slug : (slugify(page.title) || 'page').slice(0, 64)
154
+ includeDescendants = row ? row.includeDescendants : true
155
+ indexable = row ? row.indexable : true
156
+ theme = row ? row.theme : 'auto'
157
+ seoTitle = row?.seoTitle ?? ''
158
+ seoDescription = row?.seoDescription ?? ''
159
+ ogImageUrl = row?.ogImageUrl ?? ''
160
+ expiresOn = row?.expiresAt ? row.expiresAt.slice(0, 10) : ''
161
+ showMore = false
162
+ })
163
+
164
+ const slugValid = $derived(slug.length >= 2 && slug.length <= 64 && SLUG_PATTERN.test(slug))
165
+
166
+ /**
167
+ * The address that is **actually serving**, not the one in the box.
168
+ *
169
+ * Once a site exists the two can differ for as long as somebody is halfway through renaming it, and
170
+ * the copy button is right there: a link box that followed the field would hand out a dead URL to
171
+ * anybody who typed a character and then copied. Before there is a publication there is nothing to
172
+ * be wrong about, so it follows the field — that is the whole point of showing it then.
173
+ */
174
+ const url = $derived(
175
+ publicSiteUrl({ workspaceSlug, slug: publication?.slug ?? (slugValid ? slug : 'your-page') }),
176
+ )
177
+ /** The address it becomes on save — shown as a sentence, so there are never two links on screen. */
178
+ const pendingUrl = $derived(
179
+ publication && slugValid && slug !== publication.slug ? publicSiteUrl({ workspaceSlug, slug }) : null,
180
+ )
181
+
182
+ /**
183
+ * `password` is three-valued at the contract and has to stay three-valued here.
184
+ *
185
+ * A string sets one, `null` removes one, and leaving the key out changes nothing. Collapsing that
186
+ * into "whatever is in the box" is how a rename quietly takes the door off a handbook: the field is
187
+ * empty on every visit, because a password is never read back.
188
+ */
189
+ const passwordPatch = $derived<{ password?: string | null }>(
190
+ dropPassword ? { password: null } : password.length > 0 ? { password } : {},
191
+ )
192
+
193
+ const settings = $derived({
194
+ slug,
195
+ includeDescendants,
196
+ indexable,
197
+ theme,
198
+ seoTitle: seoTitle.trim(),
199
+ seoDescription: seoDescription.trim(),
200
+ ogImageUrl: ogImageUrl.trim() || null,
201
+ expiresAt: expiresOn ? new Date(`${expiresOn}T23:59:59`).toISOString() : null,
202
+ })
203
+
204
+ const dirty = $derived.by(() => {
205
+ const row = publication
206
+ if (!row) return false
207
+ if (dropPassword || password.length > 0) return true
208
+ return (
209
+ settings.slug !== row.slug ||
210
+ settings.includeDescendants !== row.includeDescendants ||
211
+ settings.indexable !== row.indexable ||
212
+ settings.theme !== row.theme ||
213
+ settings.seoTitle !== row.seoTitle ||
214
+ settings.seoDescription !== row.seoDescription ||
215
+ settings.ogImageUrl !== row.ogImageUrl ||
216
+ (settings.expiresAt ?? '').slice(0, 10) !== (row.expiresAt ?? '').slice(0, 10)
217
+ )
218
+ })
219
+
220
+ // ------------------------------------------------------------------------------------------------
221
+ // The pages underneath
222
+ // ------------------------------------------------------------------------------------------------
223
+
224
+ interface Descendant {
225
+ node: PageNode
226
+ depth: number
227
+ /** an ancestor between this page and the root is opted out, so nothing here is reachable */
228
+ blocked: boolean
229
+ }
230
+
231
+ const descendants = $derived.by((): Descendant[] => {
232
+ const childrenOf = new Map<string, PageNode[]>()
233
+ for (const node of treeQuery.data ?? []) {
234
+ if (!node.parentId) continue
235
+ childrenOf.set(node.parentId, [...(childrenOf.get(node.parentId) ?? []), node])
236
+ }
237
+ const out: Descendant[] = []
238
+ const walk = (parentId: string, depth: number, blocked: boolean) => {
239
+ for (const node of childrenOf.get(parentId) ?? []) {
240
+ out.push({ node, depth, blocked })
241
+ walk(node.id, depth + 1, blocked || node.excludedFromPublic)
242
+ }
243
+ }
244
+ walk(page.id, 0, false)
245
+ return out
246
+ })
247
+
248
+ /**
249
+ * Why this page is not on the site — the first reason that applies, in the order somebody can act
250
+ * on them. `null` means it is public, which is the only state that needs no explanation.
251
+ */
252
+ function reasonFor(row: Descendant): string | null {
253
+ if (row.node.excludedFromPublic) return t('share_child_private')
254
+ if (row.node.archivedAt) return t('share_child_archived')
255
+ if (!row.node.hasPublishedVersion) return t('share_child_draft')
256
+ if (row.blocked) return t('share_child_blocked')
257
+ return null
258
+ }
259
+
260
+ // ------------------------------------------------------------------------------------------------
261
+ // Doing it
262
+ // ------------------------------------------------------------------------------------------------
263
+
264
+ /* Set in the same tick as the click. `isPending` arrives a render late, so two quick clicks on
265
+ "Publish" are one render apart and both get through — which here means two sites. */
266
+ let busy = false
267
+ let rowsBusy = $state<string[]>([])
268
+
269
+ const message = (err: unknown) => (err instanceof Error ? err.message : String(err))
270
+
271
+ async function refresh() {
272
+ await client.invalidateQueries({ queryKey: quireKeys.publications(workspaceId, spaceId) })
273
+ await client.invalidateQueries({ queryKey: quireKeys.tree(workspaceId, spaceId) })
274
+ }
275
+
276
+ async function publish() {
277
+ if (busy || !slugValid) return
278
+ busy = true
279
+ error = null
280
+ try {
281
+ await api.publications.create({
282
+ workspaceId,
283
+ rootPageId: page.id,
284
+ ...settings,
285
+ ...passwordPatch,
286
+ })
287
+ password = ''
288
+ dropPassword = false
289
+ await refresh()
290
+ toast.success(t('share_published_toast'))
291
+ } catch (err) {
292
+ error = message(err)
293
+ } finally {
294
+ busy = false
295
+ }
296
+ }
297
+
298
+ async function save() {
299
+ const row = publication
300
+ if (busy || !row || !slugValid) return
301
+ busy = true
302
+ error = null
303
+ try {
304
+ await api.publications.update({
305
+ workspaceId,
306
+ publicationId: row.id,
307
+ ...settings,
308
+ ...passwordPatch,
309
+ })
310
+ password = ''
311
+ dropPassword = false
312
+ await refresh()
313
+ toast.success(t('share_saved'))
314
+ } catch (err) {
315
+ error = message(err)
316
+ } finally {
317
+ busy = false
318
+ }
319
+ }
320
+
321
+ async function unpublish() {
322
+ const row = publication
323
+ if (busy || !row) return
324
+ busy = true
325
+ error = null
326
+ try {
327
+ await api.publications.remove({ workspaceId, publicationId: row.id })
328
+ confirmingUnpublish = false
329
+ await refresh()
330
+ toast.success(t('share_unpublish_done'))
331
+ } catch (err) {
332
+ error = message(err)
333
+ } finally {
334
+ busy = false
335
+ }
336
+ }
337
+
338
+ /**
339
+ * Guarded rather than disabled: disabling the switch somebody just pressed blurs it, and the
340
+ * browser hands that focus to `<body>` — so a keyboard user toggling three pages in a row loses
341
+ * their place after the first one.
342
+ */
343
+ async function setExcluded(node: PageNode, excluded: boolean) {
344
+ if (rowsBusy.includes(node.id)) return
345
+ rowsBusy = [...rowsBusy, node.id]
346
+ try {
347
+ await api.publications.optOut({ workspaceId, pageId: node.id, excluded })
348
+ await client.invalidateQueries({ queryKey: quireKeys.tree(workspaceId, spaceId) })
349
+ await client.invalidateQueries({ queryKey: quireKeys.site(workspaceId, publication?.slug ?? '') })
350
+ } catch (err) {
351
+ error = message(err)
352
+ } finally {
353
+ rowsBusy = rowsBusy.filter((id) => id !== node.id)
354
+ }
355
+ }
356
+
357
+ async function copyLink() {
358
+ try {
359
+ await navigator.clipboard.writeText(url)
360
+ toast.success(t('share_copied'))
361
+ } catch {
362
+ // A denied clipboard is not worth an error toast: the link is on screen to select.
363
+ toast.info(t('share_copy_manually'))
364
+ }
365
+ }
366
+ </script>
367
+
368
+ <Dialog bind:open title={t('share_title')} size="lg">
369
+ <div class="sheet">
370
+ {#if loading}
371
+ <p class="note">{t('loading')}</p>
372
+ {:else if publicationsQuery.isError}
373
+ <p class="error" role="alert">{t('page_error_desc')}</p>
374
+ {:else}
375
+ <!--
376
+ The consequence, above the control that causes it, whether or not it has already happened.
377
+ It is not a warning that appears when something is wrong — it is a description of what the
378
+ button does, and it stays true after the button has been pressed.
379
+ -->
380
+ <section class="tell" class:live={publication !== null}>
381
+ <Icon name={publication ? 'globe' : 'triangle-alert'} size={17} />
382
+ <div class="tell-body">
383
+ <p class="tell-title">{publication ? t('share_published') : t('share_warn_title')}</p>
384
+ <p>{t('share_warn_body')}</p>
385
+ <p>{t('share_warn_version')}</p>
386
+ <p>{t('share_warn_unpublished')}</p>
387
+ {#if indexable}<p>{t('share_warn_search')}</p>{/if}
388
+ </div>
389
+ </section>
390
+
391
+ {#if !publication && page.publishedVersionId === null}
392
+ <p class="caution" role="status">{t('share_needs_publish')}</p>
393
+ {/if}
394
+
395
+ <!-- The address, and the link it makes, side by side so one is visibly the other. -->
396
+ <Field
397
+ label={t('share_address')}
398
+ hint={t('share_address_hint')}
399
+ error={slugValid ? null : t('share_address_invalid')}
400
+ >
401
+ {#snippet children(id: string)}
402
+ <Input
403
+ {id}
404
+ mono
405
+ value={slug}
406
+ oninput={(e: Event) => (slug = slugify((e.currentTarget as HTMLInputElement).value))}
407
+ />
408
+ {/snippet}
409
+ </Field>
410
+
411
+ <!--
412
+ A heading rather than nothing. Read linearly — which is how a screen reader meets it — an
413
+ unlabelled `<code>` between a form field and two buttons is a string of punctuation with no
414
+ say in what it is. `aria-label` would have been the wrong fix: on an element with text content
415
+ it *replaces* the text, so the one thing worth hearing would be the one thing hidden.
416
+ -->
417
+ <p class="link-label">{t('share_link')}</p>
418
+ <div class="link">
419
+ <code class="link-url">{url}</code>
420
+ <Button size="sm" variant="secondary" icon="copy" onclick={() => void copyLink()}>
421
+ {t('share_copy')}
422
+ </Button>
423
+ {#if publication}
424
+ <Button
425
+ size="sm"
426
+ variant="ghost"
427
+ icon="external-link"
428
+ href={url}
429
+ target="_blank"
430
+ rel="noreferrer noopener"
431
+ >
432
+ {t('share_open')}
433
+ </Button>
434
+ {/if}
435
+ </div>
436
+
437
+ {#if pendingUrl}
438
+ <p class="note" role="status">
439
+ {t('share_address_pending')}
440
+ <code class="inline-url">{pendingUrl}</code>
441
+ </p>
442
+ {/if}
443
+
444
+ <!--
445
+ Measured, not claimed. Everything else on this screen is what we intend to be true; this line
446
+ is the only one that went and asked.
447
+ -->
448
+ {#if publication}
449
+ <p class="check" role="status">
450
+ {#if siteQuery.isPending}
451
+ {t('share_check_running')}
452
+ {:else if siteQuery.isError}
453
+ <Icon name="triangle-alert" size={13} />{t('share_check_failed')}
454
+ {:else if siteQuery.data?.locked}
455
+ <Icon name="lock" size={13} />{t('share_check_locked')}
456
+ {:else}
457
+ <Icon name="check" size={13} />{t('share_check_ok', {
458
+ count: siteQuery.data?.site?.nav.length ?? 0,
459
+ })}
460
+ {/if}
461
+ </p>
462
+ <p class="when">{t('share_published_when', { when: relativeTime(publication.createdAt) })}</p>
463
+ {/if}
464
+
465
+ <div class="switches">
466
+ <Switch
467
+ checked={includeDescendants}
468
+ label={t('share_include')}
469
+ description={t('share_include_desc')}
470
+ onCheckedChange={(next: boolean) => (includeDescendants = next)}
471
+ />
472
+ {#if includeDescendants && descendants.length > 0}
473
+ <p class="note">{t('share_count', { count: descendants.length })}</p>
474
+ {/if}
475
+ </div>
476
+
477
+ <button type="button" class="more" onclick={() => (showMore = !showMore)}>
478
+ <Icon name={showMore ? 'chevron-up' : 'chevron-down'} size={14} />
479
+ {showMore ? t('share_less') : t('share_more')}
480
+ </button>
481
+
482
+ {#if showMore}
483
+ <div class="form">
484
+ <Switch
485
+ checked={indexable}
486
+ label={t('share_indexable')}
487
+ description={t('share_indexable_desc')}
488
+ onCheckedChange={(next: boolean) => (indexable = next)}
489
+ />
490
+
491
+ <Field
492
+ label={t('share_password')}
493
+ hint={publication?.hasPassword ? t('share_password_keep') : t('share_password_hint')}
494
+ >
495
+ {#snippet children(id: string)}
496
+ <Input
497
+ {id}
498
+ type="password"
499
+ autocomplete="new-password"
500
+ bind:value={password}
501
+ placeholder={publication?.hasPassword ? t('share_password_on') : ''}
502
+ />
503
+ {/snippet}
504
+ </Field>
505
+ {#if publication?.hasPassword}
506
+ <Switch
507
+ checked={dropPassword}
508
+ label={t('share_password_remove')}
509
+ onCheckedChange={(next: boolean) => (dropPassword = next)}
510
+ />
511
+ {/if}
512
+
513
+ <Field label={t('share_expires')} hint={t('share_expires_hint')}>
514
+ {#snippet children(id: string)}
515
+ <Input {id} type="date" bind:value={expiresOn} />
516
+ {/snippet}
517
+ </Field>
518
+
519
+ <Field label={t('share_theme')}>
520
+ {#snippet children(id: string)}
521
+ <Select
522
+ {id}
523
+ ariaLabel={t('share_theme')}
524
+ value={theme}
525
+ options={[
526
+ { value: 'auto', label: t('share_theme_auto') },
527
+ { value: 'light', label: t('share_theme_light') },
528
+ { value: 'dark', label: t('share_theme_dark') },
529
+ ]}
530
+ onValueChange={(v: string) => (theme = v as Publication['theme'])}
531
+ />
532
+ {/snippet}
533
+ </Field>
534
+
535
+ <Field label={t('share_seo_title')} hint={t('share_seo_title_hint')}>
536
+ {#snippet children(id: string)}
537
+ <Input {id} bind:value={seoTitle} />
538
+ {/snippet}
539
+ </Field>
540
+
541
+ <Field label={t('share_seo_description')}>
542
+ {#snippet children(id: string)}
543
+ <Textarea {id} bind:value={seoDescription} rows={2} />
544
+ {/snippet}
545
+ </Field>
546
+
547
+ <Field label={t('share_og_image')} hint={t('share_og_image_hint')}>
548
+ {#snippet children(id: string)}
549
+ <Input {id} bind:value={ogImageUrl} placeholder="https://" />
550
+ {/snippet}
551
+ </Field>
552
+ </div>
553
+ {/if}
554
+
555
+ <!--
556
+ Only once the site exists. Before that the switches would write a flag against pages nobody is
557
+ publishing, which is a real setting with no visible effect — and this list is the one place
558
+ somebody looks to find out what is on the internet.
559
+ -->
560
+ {#if publication}
561
+ <section class="children">
562
+ <h3>{t('share_children')}</h3>
563
+ {#if !includeDescendants}
564
+ <p class="note">{t('share_children_off')}</p>
565
+ {:else if treeQuery.isError}
566
+ <!-- Never "nothing is nested under this page" because the tree failed to load: on this
567
+ screen an empty list reads as a promise that nothing else is public. -->
568
+ <p class="error" role="alert">{t('tree_error')}</p>
569
+ {:else if treeQuery.isPending}
570
+ <p class="note">{t('loading')}</p>
571
+ {:else if descendants.length === 0}
572
+ <p class="note">{t('share_children_none')}</p>
573
+ {:else}
574
+ <p class="note">{t('share_children_desc')}</p>
575
+ <ul>
576
+ {#each descendants as row (row.node.id)}
577
+ {@const reason = reasonFor(row)}
578
+ <!--
579
+ The reason is a sibling of the switch, not its `description`.
580
+
581
+ `Switch` puts a description inside its own `<label>` and names the control from the
582
+ title alone, so a description is drawn on screen and reachable by nobody using a
583
+ screen reader — and the reason is the whole difference between a switch that is on
584
+ because the page is public and one that is on while the page is archived. As a
585
+ paragraph in the list item it is read like any other content. No `ariaLabel` either:
586
+ the visible title *is* the accessible name, which is the right pattern, and the prop
587
+ would have been silently ignored beside a `label`.
588
+ -->
589
+ <li style={`--depth:${Math.min(row.depth, 6)}`}>
590
+ <Switch
591
+ class="row-switch"
592
+ checked={!row.node.excludedFromPublic}
593
+ label={row.node.title.trim() || t('untitled')}
594
+ onCheckedChange={(next: boolean) => void setExcluded(row.node, !next)}
595
+ />
596
+ {#if reason}<p class="reason">{reason}</p>{/if}
597
+ </li>
598
+ {/each}
599
+ </ul>
600
+ {/if}
601
+ </section>
602
+ {/if}
603
+
604
+ {#if error}<p class="error" role="alert">{error}</p>{/if}
605
+ {/if}
606
+ </div>
607
+
608
+ {#snippet footer()}
609
+ <div class="foot">
610
+ {#if loading || publicationsQuery.isError}
611
+ <span class="spacer"></span>
612
+ <Button variant="secondary" onclick={() => (open = false)}>{t('cancel')}</Button>
613
+ {:else if publication}
614
+ {#if confirmingUnpublish}
615
+ <p class="ask">{t('share_unpublish_ask')}</p>
616
+ <Button variant="secondary" size="sm" onclick={() => (confirmingUnpublish = false)}>
617
+ {t('cancel')}
618
+ </Button>
619
+ <Button variant="danger" size="sm" onclick={() => void unpublish()}>
620
+ {t('share_unpublish')}
621
+ </Button>
622
+ {:else}
623
+ <Button variant="danger" size="sm" onclick={() => (confirmingUnpublish = true)}>
624
+ {t('share_unpublish')}
625
+ </Button>
626
+ <span class="spacer"></span>
627
+ {#if dirty}
628
+ <Button disabled={!slugValid} onclick={() => void save()}>{t('share_save')}</Button>
629
+ {:else}
630
+ <Button variant="secondary" onclick={() => (open = false)}>{t('share_done')}</Button>
631
+ {/if}
632
+ {/if}
633
+ {:else}
634
+ <span class="spacer"></span>
635
+ <Button variant="secondary" onclick={() => (open = false)}>{t('cancel')}</Button>
636
+ <!--
637
+ `disabled` for an address that cannot be a URL, and a plain flag inside `publish()` for the
638
+ second click. They are different problems: the first is a statement about the form that the
639
+ field beside it already explains, and the second is a race a disabled attribute loses,
640
+ because it reaches the button one render after the click that should have been stopped.
641
+ -->
642
+ <Button disabled={!slugValid} onclick={() => void publish()}>{t('share_publish')}</Button>
643
+ {/if}
644
+ </div>
645
+ {/snippet}
646
+ </Dialog>
647
+
648
+ <style>
649
+ .sheet {
650
+ display: flex;
651
+ flex-direction: column;
652
+ gap: 16px;
653
+ }
654
+
655
+ /*
656
+ * The sentence that matters. Warning-tinted before it is true and accent-tinted after, because
657
+ * afterwards it is not a warning — it is the state of the page, and a permanent amber banner is a
658
+ * thing people stop seeing.
659
+ */
660
+ .tell {
661
+ display: flex;
662
+ gap: 10px;
663
+ padding: 12px 13px;
664
+ border-radius: var(--kern-r-lg);
665
+ background: var(--kern-warning-tint);
666
+ color: var(--kern-ink-700);
667
+ }
668
+ .tell.live {
669
+ background: var(--kern-accent-tint);
670
+ }
671
+ .tell-body {
672
+ display: flex;
673
+ flex-direction: column;
674
+ gap: 6px;
675
+ min-width: 0;
676
+ }
677
+ .tell p {
678
+ margin: 0;
679
+ font-size: 13px;
680
+ line-height: 1.55;
681
+ text-wrap: pretty;
682
+ }
683
+ .tell-title {
684
+ font-weight: 600;
685
+ font-size: 13.5px;
686
+ color: var(--kern-ink-900);
687
+ }
688
+ .caution {
689
+ margin: 0;
690
+ padding: 10px 12px;
691
+ border-radius: var(--kern-r-lg);
692
+ background: var(--kern-info-tint);
693
+ color: var(--kern-ink-700);
694
+ font-size: 13px;
695
+ line-height: 1.55;
696
+ }
697
+
698
+ .link-label {
699
+ margin: 0 0 -10px;
700
+ font-size: 12.5px;
701
+ font-weight: 500;
702
+ color: var(--kern-ink-600);
703
+ }
704
+ .link {
705
+ display: flex;
706
+ align-items: center;
707
+ gap: 8px;
708
+ flex-wrap: wrap;
709
+ padding: 9px 11px;
710
+ border: 1px solid var(--kern-border);
711
+ border-radius: var(--kern-r-lg);
712
+ background: var(--kern-surface-input);
713
+ }
714
+ .link-url {
715
+ flex: 1;
716
+ min-width: 0;
717
+ font-family: var(--kern-font-mono);
718
+ font-size: 12.5px;
719
+ color: var(--kern-ink-800);
720
+ overflow-x: auto;
721
+ white-space: nowrap;
722
+ /* the address is Latin whichever way the page runs, and it is read left to right */
723
+ direction: ltr;
724
+ unicode-bidi: isolate;
725
+ text-align: start;
726
+ }
727
+
728
+ .check,
729
+ .when {
730
+ display: flex;
731
+ align-items: center;
732
+ gap: 6px;
733
+ margin: -8px 0 0;
734
+ font-size: 12.5px;
735
+ line-height: 1.5;
736
+ /* muted with a colour: `opacity` fades this against the dialog until nobody can read it */
737
+ color: var(--kern-ink-450);
738
+ }
739
+ .when {
740
+ margin-block-start: -12px;
741
+ }
742
+
743
+ .switches {
744
+ display: flex;
745
+ flex-direction: column;
746
+ gap: 6px;
747
+ }
748
+ .note {
749
+ margin: 0;
750
+ font-size: 12.5px;
751
+ line-height: 1.5;
752
+ color: var(--kern-ink-450);
753
+ text-wrap: pretty;
754
+ }
755
+ /*
756
+ * A URL is Latin and reads left to right whichever way the sentence around it runs. Without its own
757
+ * direction the bidi algorithm lays it out against the paragraph's, and a Persian note about
758
+ * `https://…/p/ws/handbook` comes apart at the slashes.
759
+ */
760
+ .inline-url {
761
+ display: inline-block;
762
+ direction: ltr;
763
+ unicode-bidi: isolate;
764
+ font-family: var(--kern-font-mono);
765
+ font-size: 12px;
766
+ color: var(--kern-ink-700);
767
+ overflow-wrap: anywhere;
768
+ }
769
+
770
+ .more {
771
+ align-self: flex-start;
772
+ display: inline-flex;
773
+ align-items: center;
774
+ gap: 6px;
775
+ min-height: 32px;
776
+ padding: 0 8px;
777
+ margin-inline-start: -8px;
778
+ border: 0;
779
+ border-radius: var(--kern-r-md);
780
+ background: none;
781
+ color: var(--kern-ink-600);
782
+ font: inherit;
783
+ font-size: 13px;
784
+ cursor: pointer;
785
+ }
786
+ .more:hover {
787
+ background: var(--kern-surface-hover);
788
+ color: var(--kern-ink-900);
789
+ }
790
+
791
+ .form {
792
+ display: flex;
793
+ flex-direction: column;
794
+ gap: 14px;
795
+ padding: 14px;
796
+ border: 1px solid var(--kern-border);
797
+ border-radius: var(--kern-r-lg);
798
+ background: var(--kern-surface-raised);
799
+ }
800
+
801
+ .children h3 {
802
+ margin: 0 0 4px;
803
+ font-size: 13.5px;
804
+ font-weight: 600;
805
+ color: var(--kern-ink-900);
806
+ }
807
+ .children ul {
808
+ list-style: none;
809
+ margin: 10px 0 0;
810
+ padding: 0;
811
+ display: flex;
812
+ flex-direction: column;
813
+ }
814
+ .children li {
815
+ /* logical, so a Persian tree indents from the right like the sidebar does */
816
+ padding-inline-start: calc(var(--depth) * 16px);
817
+ padding-block: 5px;
818
+ border-block-end: 1px solid var(--kern-border-hairline);
819
+ }
820
+ .children li:last-child {
821
+ border-block-end: 0;
822
+ }
823
+ .children :global(.row-switch) {
824
+ width: 100%;
825
+ }
826
+ .reason {
827
+ margin: 0 0 2px;
828
+ font-size: 12px;
829
+ line-height: 1.45;
830
+ color: var(--kern-ink-450);
831
+ }
832
+
833
+ .error {
834
+ margin: 0;
835
+ font-size: 13px;
836
+ color: var(--kern-danger);
837
+ }
838
+
839
+ .foot {
840
+ display: flex;
841
+ align-items: center;
842
+ gap: 8px;
843
+ flex-wrap: wrap;
844
+ width: 100%;
845
+ }
846
+ .spacer {
847
+ flex: 1;
848
+ }
849
+ .ask {
850
+ flex: 1 1 220px;
851
+ margin: 0;
852
+ font-size: 12.5px;
853
+ line-height: 1.5;
854
+ color: var(--kern-ink-700);
855
+ text-wrap: pretty;
856
+ }
857
+ </style>