@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
@@ -14,6 +14,7 @@ import type {
14
14
  Property,
15
15
  PropertyConfig,
16
16
  PropertyType,
17
+ Publication,
17
18
  RecentEntry,
18
19
  RowRef,
19
20
  Space,
@@ -153,8 +154,16 @@ export function createMockQuireApi() {
153
154
  publishedVersionId: uid(153),
154
155
  hasUnpublishedChanges: true,
155
156
  }),
156
- page(103, uid(1), 'Your first week', 'ba', 102),
157
- page(104, uid(1), 'Time off', 'bb', 102),
157
+ /*
158
+ * Both published, because the share dialog is judged on the difference between them.
159
+ *
160
+ * "Your first week" is in the published site; "Time off" is published and *opted out*, so the
161
+ * demo shows a page that could be public and is deliberately not — which is the one row in that
162
+ * list nobody would otherwise see. A child with no published version at all would have looked
163
+ * the same on screen for an entirely different reason, and the dialog says which is which.
164
+ */
165
+ page(103, uid(1), 'Your first week', 'ba', 102, { publishedVersionId: uid(155) }),
166
+ page(104, uid(1), 'Time off', 'bb', 102, { publishedVersionId: uid(156) }),
158
167
  page(105, uid(1), 'Expenses', 'c', null, { kind: 'live' }),
159
168
  /*
160
169
  * A subtree in the trash, because that is the case the trash screen exists for.
@@ -349,6 +358,16 @@ export function createMockQuireApi() {
349
358
  36e5,
350
359
  COLLEAGUE,
351
360
  ),
361
+ version(
362
+ 155,
363
+ uid(103),
364
+ 'publish',
365
+ null,
366
+ 'Everything worth doing in your first five days, in the order it is worth doing it.',
367
+ 864e5,
368
+ ME,
369
+ ),
370
+ version(156, uid(104), 'publish', null, 'How much you get, and how to book it.', 1728e5, COLLEAGUE),
352
371
  ]
353
372
 
354
373
  const richDoc = (text: string): Record<string, unknown> => ({
@@ -471,6 +490,55 @@ export function createMockQuireApi() {
471
490
  { pageId: uid(201), viewedAt: iso(9e6) },
472
491
  ]
473
492
 
493
+ /**
494
+ * What has been handed to the internet, and what is held back from it.
495
+ *
496
+ * One publication, rooted at "Working here", so `dev:mock` opens on the published state rather
497
+ * than on the empty one — the empty one is a single button and the populated one is the whole
498
+ * screen. "Time off" is opted out, which is what makes the per-page list in the dialog say
499
+ * something rather than being four switches all pointing the same way.
500
+ *
501
+ * `excludedFromPublic` is a set here rather than a column on the row for the same reason the
502
+ * watchers and the labels are maps: `strip()` only removes `_order`, so anything hung on a `Row`
503
+ * ends up in the `Page` a screen is handed, and this one is a flag about publishing that no
504
+ * screen should read off a page.
505
+ */
506
+ const excludedFromPublic = new Set<string>([uid(104)])
507
+
508
+ /**
509
+ * The password is kept in the clear here, and only here.
510
+ *
511
+ * The server keeps a scrypt hash and never gives one back, which is why `Publication` carries
512
+ * `hasPassword` and no hash at all — see the note on the model. The mock has to compare something
513
+ * to answer `public.unlock`, so it keeps the password beside the row and strips it on the way out
514
+ * through `publicationOut`, exactly where the server's own boundary is.
515
+ */
516
+ type PublicationRow = Omit<Publication, 'hasPassword'> & { password: string | null }
517
+ const publicationOut = ({ password, ...rest }: PublicationRow): Publication => ({
518
+ ...rest,
519
+ hasPassword: password !== null,
520
+ })
521
+
522
+ const publications: PublicationRow[] = [
523
+ {
524
+ id: uid(400),
525
+ workspaceId: '' as Publication['workspaceId'],
526
+ rootPageId: uid(102),
527
+ includeDescendants: true,
528
+ slug: 'working-here',
529
+ password: null,
530
+ expiresAt: null,
531
+ seoTitle: 'How this team works',
532
+ seoDescription: 'The handbook we point every new person at on their first morning.',
533
+ ogImageUrl: null,
534
+ indexable: true,
535
+ theme: 'auto',
536
+ createdBy: ME as Publication['createdBy'],
537
+ createdAt: iso(432e5),
538
+ updatedAt: iso(432e5),
539
+ },
540
+ ]
541
+
474
542
  let seq = 900
475
543
  const nextId = () => uid(++seq)
476
544
  const strip = ({ _order, ...p }: Row): Page => p
@@ -619,6 +687,127 @@ export function createMockQuireApi() {
619
687
  return found ? [found] : []
620
688
  })
621
689
 
690
+ // ---------------------------------------------------------------------------------------------
691
+ // What a signed-out stranger sees
692
+ // ---------------------------------------------------------------------------------------------
693
+
694
+ const thePublication = (id: string): PublicationRow => {
695
+ const found = publications.find((p) => p.id === id)
696
+ if (!found) throw notFound('Publication')
697
+ return found
698
+ }
699
+
700
+ /**
701
+ * The five things that keep a page out of a published site, reproduced rather than approximated.
702
+ *
703
+ * A demo whose public walk is looser than the server's is worse than no demo: it shows a page in
704
+ * the site that the real thing would refuse, and the one screen this mock exists to exercise is
705
+ * the one where that difference is a leak. So the rules are the server's, in the server's order —
706
+ * only a `page` (never a live doc, a database or a row), not archived, not trashed, not opted
707
+ * out, and **actually rendered once**, because a page with no published version has nothing to
708
+ * serve however the tree is shaped.
709
+ */
710
+ const isPublicPage = (row: Row): boolean =>
711
+ row.kind === 'page' &&
712
+ !row._databaseId &&
713
+ !row.deletedAt &&
714
+ !row.archivedAt &&
715
+ !excludedFromPublic.has(row.id) &&
716
+ row.publishedVersionId !== null
717
+
718
+ /** The server's own slug rule: Unicode letters and digits, so a Persian title keeps a Persian slug. */
719
+ const slugifyTitle = (title: string): string =>
720
+ title
721
+ .normalize('NFC')
722
+ .toLowerCase()
723
+ .replace(/[^\p{L}\p{N}]+/gu, '-')
724
+ .replace(/^-+|-+$/g, '')
725
+ .slice(0, 60)
726
+ .replace(/-+$/g, '') || 'untitled'
727
+
728
+ interface PublicNode {
729
+ row: Row
730
+ path: string
731
+ parentPath: string | null
732
+ }
733
+
734
+ /**
735
+ * Walk the publication, pruning rather than filtering.
736
+ *
737
+ * The distinction is the whole security model and it is worth reproducing here: a page whose
738
+ * *parent* did not survive is unreachable, so it never enters the walk — a child of an opted-out
739
+ * page is not public even though nobody opted the child out. Filtering a flat list would have
740
+ * kept it.
741
+ */
742
+ const publicWalk = (pub: PublicationRow): PublicNode[] => {
743
+ const root = pages.find((p) => p.id === pub.rootPageId)
744
+ if (!root || !isPublicPage(root)) return []
745
+ const out: PublicNode[] = [{ row: root, path: '', parentPath: null }]
746
+ if (!pub.includeDescendants) return out
747
+ const queue: PublicNode[] = [...out]
748
+ while (queue.length > 0) {
749
+ const parent = queue.shift() as PublicNode
750
+ const taken = new Set<string>()
751
+ const children = pages
752
+ .filter((p) => p.parentId === parent.row.id)
753
+ .sort((a, b) => (a._order < b._order ? -1 : a._order > b._order ? 1 : 0))
754
+ for (const child of children) {
755
+ if (!isPublicPage(child)) continue
756
+ const base = slugifyTitle(child.title)
757
+ let slug = base
758
+ for (let n = 2; taken.has(slug); n++) slug = `${base}-${n}`
759
+ taken.add(slug)
760
+ const node: PublicNode = {
761
+ row: child,
762
+ path: parent.path === '' ? slug : `${parent.path}/${slug}`,
763
+ parentPath: parent.path,
764
+ }
765
+ out.push(node)
766
+ queue.push(node)
767
+ }
768
+ }
769
+ return out
770
+ }
771
+
772
+ const publishedAtOf = (row: Row): string =>
773
+ (row.publishedVersionId ? versions.find((v) => v.id === row.publishedVersionId) : null)?.createdAt ??
774
+ row.updatedAt
775
+
776
+ const normalisePath = (path: string) =>
777
+ path
778
+ .normalize('NFC')
779
+ .replace(/^\/+|\/+$/g, '')
780
+ .toLowerCase()
781
+
782
+ /** Expired is gone, checked on the request rather than by a sweep — as the server does it. */
783
+ const publicationBySlug = (slug: string): PublicationRow => {
784
+ const pub = publications.find((p) => p.slug === slug)
785
+ if (!pub) throw notFound('Publication')
786
+ if (pub.expiresAt && new Date(pub.expiresAt).getTime() <= Date.now()) throw notFound('Publication')
787
+ return pub
788
+ }
789
+
790
+ /** A capability token, carrying no identity — the shape the server mints, without the sealing. */
791
+ const tokenFor = (pub: PublicationRow) => `mock-unlock:${pub.id}`
792
+ const unlocked = (pub: PublicationRow, token: string | null) =>
793
+ pub.password === null || token === tokenFor(pub)
794
+
795
+ const escapeHtml = (text: string) =>
796
+ text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
797
+
798
+ /**
799
+ * There is no renderer here, so the published version's preview stands in for its prose.
800
+ *
801
+ * Escaped rather than interpolated: the demo is where somebody types `<script>` into a title to
802
+ * see what happens, and a mock that answers with it unescaped teaches the wrong lesson about a
803
+ * surface whose whole job is to be safe.
804
+ */
805
+ const publicHtmlOf = (row: Row): string => {
806
+ const preview =
807
+ (row.publishedVersionId ? versions.find((v) => v.id === row.publishedVersionId) : null)?.preview ?? ''
808
+ return preview ? `<p>${escapeHtml(preview)}</p>` : ''
809
+ }
810
+
622
811
  /**
623
812
  * A copy on the way out, because a real API answers with fresh JSON every time.
624
813
  *
@@ -804,6 +993,8 @@ export function createMockQuireApi() {
804
993
  icon: r.icon,
805
994
  hasChildren: parents.has(r.id),
806
995
  archivedAt: r.archivedAt,
996
+ excludedFromPublic: excludedFromPublic.has(r.id),
997
+ hasPublishedVersion: r.publishedVersionId !== null,
807
998
  }))
808
999
  },
809
1000
  get: async ({ pageId }: { pageId: string }) => strip(found(pageId)),
@@ -1219,6 +1410,239 @@ export function createMockQuireApi() {
1219
1410
  },
1220
1411
  },
1221
1412
 
1413
+ publications: {
1414
+ list: async ({ spaceId }: { spaceId: string }) =>
1415
+ publications
1416
+ .filter((p) => pages.find((row) => row.id === p.rootPageId)?.spaceId === spaceId)
1417
+ .map(publicationOut),
1418
+
1419
+ get: async ({ publicationId }: { publicationId: string }) =>
1420
+ publicationOut(thePublication(publicationId)),
1421
+
1422
+ create: async (input: {
1423
+ rootPageId: string
1424
+ slug: string
1425
+ includeDescendants?: boolean
1426
+ password?: string | null
1427
+ expiresAt?: string | null
1428
+ seoTitle?: string
1429
+ seoDescription?: string
1430
+ ogImageUrl?: string | null
1431
+ indexable?: boolean
1432
+ theme?: Publication['theme']
1433
+ }) => {
1434
+ found(input.rootPageId)
1435
+ if (publications.some((p) => p.slug === input.slug))
1436
+ throw Object.assign(new Error(`Another site already uses the address “${input.slug}”`), {
1437
+ code: 'CONFLICT',
1438
+ })
1439
+ const row: PublicationRow = {
1440
+ id: nextId(),
1441
+ workspaceId: '' as Publication['workspaceId'],
1442
+ rootPageId: input.rootPageId,
1443
+ includeDescendants: input.includeDescendants ?? true,
1444
+ slug: input.slug,
1445
+ password: input.password ?? null,
1446
+ expiresAt: input.expiresAt ?? null,
1447
+ seoTitle: input.seoTitle ?? '',
1448
+ seoDescription: input.seoDescription ?? '',
1449
+ ogImageUrl: input.ogImageUrl ?? null,
1450
+ indexable: input.indexable ?? true,
1451
+ theme: input.theme ?? 'auto',
1452
+ createdBy: ME as Publication['createdBy'],
1453
+ createdAt: new Date().toISOString(),
1454
+ updatedAt: new Date().toISOString(),
1455
+ }
1456
+ publications.push(row)
1457
+ return publicationOut(row)
1458
+ },
1459
+
1460
+ /**
1461
+ * `password` is three-valued here too, and the mock is where that is easiest to get wrong: a
1462
+ * key left out changes nothing, `null` takes the door off, a string sets a new one. Spreading
1463
+ * the patch would turn every "rename the site" into an "unlock the site".
1464
+ */
1465
+ update: async ({
1466
+ publicationId,
1467
+ ...patch
1468
+ }: {
1469
+ publicationId: string
1470
+ slug?: string
1471
+ includeDescendants?: boolean
1472
+ password?: string | null
1473
+ expiresAt?: string | null
1474
+ seoTitle?: string
1475
+ seoDescription?: string
1476
+ ogImageUrl?: string | null
1477
+ indexable?: boolean
1478
+ theme?: Publication['theme']
1479
+ }) => {
1480
+ const row = thePublication(publicationId)
1481
+ if (patch.slug !== undefined && publications.some((p) => p.slug === patch.slug && p.id !== row.id))
1482
+ throw Object.assign(new Error(`Another site already uses the address “${patch.slug}”`), {
1483
+ code: 'CONFLICT',
1484
+ })
1485
+ for (const [key, value] of Object.entries(patch))
1486
+ if (value !== undefined) Object.assign(row, { [key]: value })
1487
+ row.updatedAt = new Date().toISOString()
1488
+ return publicationOut(row)
1489
+ },
1490
+
1491
+ remove: async ({ publicationId }: { publicationId: string }) => {
1492
+ const row = thePublication(publicationId)
1493
+ publications.splice(publications.indexOf(row), 1)
1494
+ return { ok: true as const }
1495
+ },
1496
+
1497
+ optOut: async ({ pageId, excluded = true }: { pageId: string; excluded?: boolean }) => {
1498
+ found(pageId)
1499
+ if (excluded) excludedFromPublic.add(pageId)
1500
+ else excludedFromPublic.delete(pageId)
1501
+ return { pageId, excluded }
1502
+ },
1503
+ },
1504
+
1505
+ /**
1506
+ * The signed-out surface, reproduced so the share dialog's "what does a stranger see" check has
1507
+ * something honest to answer it in `dev:mock`.
1508
+ *
1509
+ * There is no principal to ignore here, which is exactly the point: none of these reads the
1510
+ * signed-in member the rest of this file assumes. Everything they answer comes out of
1511
+ * `publicWalk`, so a page the walk pruned cannot be reached by asking for it by path either.
1512
+ */
1513
+ public: {
1514
+ site: async ({ slug, token = null }: { slug: string; token?: string | null }) => {
1515
+ const pub = publicationBySlug(slug)
1516
+ if (!unlocked(pub, token)) return { slug: pub.slug, theme: pub.theme, locked: true, site: null }
1517
+ const nodes = publicWalk(pub)
1518
+ const root = nodes[0]
1519
+ if (!root) throw notFound('Publication')
1520
+ return {
1521
+ slug: pub.slug,
1522
+ theme: pub.theme,
1523
+ locked: false,
1524
+ site: {
1525
+ title: pub.seoTitle || root.row.title || 'Untitled',
1526
+ description: pub.seoDescription,
1527
+ ogImageUrl: pub.ogImageUrl,
1528
+ indexable: pub.indexable,
1529
+ updatedAt: nodes
1530
+ .map((n) => publishedAtOf(n.row))
1531
+ .sort()
1532
+ .at(-1) as string,
1533
+ nav: nodes.map((n) => ({
1534
+ path: n.path,
1535
+ parentPath: n.parentPath,
1536
+ title: n.row.title || 'Untitled',
1537
+ icon: n.row.icon,
1538
+ })),
1539
+ },
1540
+ }
1541
+ },
1542
+
1543
+ /**
1544
+ * `basePath` is accepted and unused, deliberately.
1545
+ *
1546
+ * The real handler rewrites every inter-page link in the rendered HTML against it, and there
1547
+ * is no rendered HTML here — but a mock whose *signature* differs from the contract is a
1548
+ * screen that works in the demo and throws in production, which is the one thing this file
1549
+ * exists to prevent.
1550
+ */
1551
+ page: async ({
1552
+ slug,
1553
+ path = '',
1554
+ token = null,
1555
+ }: {
1556
+ slug: string
1557
+ path?: string
1558
+ basePath?: string
1559
+ token?: string | null
1560
+ }) => {
1561
+ const pub = publicationBySlug(slug)
1562
+ if (!unlocked(pub, token)) throw notFound('Page')
1563
+ const nodes = publicWalk(pub)
1564
+ const wanted = normalisePath(path)
1565
+ const node = nodes.find((n) => normalisePath(n.path) === wanted)
1566
+ if (!node) throw notFound('Page')
1567
+ const trail: { path: string; title: string }[] = []
1568
+ for (let at: PublicNode | undefined = node; at; ) {
1569
+ trail.unshift({ path: at.path, title: at.row.title || 'Untitled' })
1570
+ const parentPath: string | null = at.parentPath
1571
+ at = parentPath === null ? undefined : nodes.find((n) => n.path === parentPath)
1572
+ }
1573
+ return {
1574
+ path: node.path,
1575
+ title: node.row.title || 'Untitled',
1576
+ icon: node.row.icon,
1577
+ coverUrl: node.row.coverUrl,
1578
+ html: publicHtmlOf(node.row),
1579
+ publishedAt: publishedAtOf(node.row),
1580
+ // A hash of the version rather than the version id, because the id addresses a procedure
1581
+ // that asks a permission. There is nothing to hash with here, so it is prefixed instead —
1582
+ // what matters is that the value a browser caches on is not an identifier of anything.
1583
+ etag: `mock-${node.row.publishedVersionId ?? 'none'}`,
1584
+ breadcrumbs: trail,
1585
+ }
1586
+ },
1587
+
1588
+ /**
1589
+ * Reads the published version's text, never the draft.
1590
+ *
1591
+ * The mock has one string per version and it is the published one, so this is true here by
1592
+ * construction rather than by care — which is the reason to write the search against
1593
+ * `versions` rather than against `pages`, even though both would look right in a demo.
1594
+ */
1595
+ search: async ({ slug, q, limit = 20 }: { slug: string; q: string; limit?: number }) => {
1596
+ const pub = publicationBySlug(slug)
1597
+ const needle = q.trim().toLowerCase()
1598
+ const items = publicWalk(pub)
1599
+ .flatMap((node) => {
1600
+ const preview =
1601
+ (node.row.publishedVersionId
1602
+ ? versions.find((v) => v.id === node.row.publishedVersionId)
1603
+ : null
1604
+ )?.preview ?? ''
1605
+ const haystack = `${node.row.title} ${preview}`.toLowerCase()
1606
+ if (!haystack.includes(needle)) return []
1607
+ return [{ path: node.path, title: node.row.title || 'Untitled', snippet: preview.slice(0, 200) }]
1608
+ })
1609
+ .slice(0, limit)
1610
+ return { items }
1611
+ },
1612
+
1613
+ // A site behind a password, or one asked to stay out of search, has an empty sitemap rather
1614
+ // than a private one — the file exists to be fetched by robots.
1615
+ sitemap: async ({ slug }: { slug: string }) => {
1616
+ const pub = publicationBySlug(slug)
1617
+ if (pub.password !== null || !pub.indexable) return { entries: [] }
1618
+ return {
1619
+ entries: publicWalk(pub).map((n) => ({
1620
+ path: n.path,
1621
+ lastModified: publishedAtOf(n.row),
1622
+ })),
1623
+ }
1624
+ },
1625
+
1626
+ // The one call that never distinguishes a missing slug from an expired or locked one.
1627
+ robots: async ({ slug }: { slug: string }) => {
1628
+ const pub = publications.find((p) => p.slug === slug)
1629
+ const gone = !pub || (pub.expiresAt !== null && new Date(pub.expiresAt).getTime() <= Date.now())
1630
+ if (gone || pub.password !== null || !pub.indexable) return { indexable: false, sitemapPath: null }
1631
+ return { indexable: true, sitemapPath: 'sitemap.xml' }
1632
+ },
1633
+
1634
+ unlock: async ({ slug, password }: { slug: string; password: string }) => {
1635
+ const pub = publicationBySlug(slug)
1636
+ // A site with no password has no door, and saying so would confirm the slug exists.
1637
+ if (pub.password === null) throw notFound('Publication')
1638
+ if (pub.password !== password)
1639
+ throw Object.assign(new Error('That password does not open this site'), {
1640
+ code: 'UNAUTHORIZED',
1641
+ })
1642
+ return { token: tokenFor(pub), expiresAt: new Date(Date.now() + 12 * 36e5).toISOString() }
1643
+ },
1644
+ },
1645
+
1222
1646
  databases: {
1223
1647
  list: async ({ spaceId }: { spaceId: string }): Promise<DatabaseRef[]> =>
1224
1648
  databases