@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.
- package/dist/contract/models.d.ts +46 -0
- package/dist/contract/models.d.ts.map +1 -1
- package/dist/contract/models.js +73 -0
- package/dist/contract/models.js.map +1 -1
- package/dist/contract/permissions.d.ts +17 -1
- package/dist/contract/permissions.d.ts.map +1 -1
- package/dist/contract/permissions.js +34 -0
- package/dist/contract/permissions.js.map +1 -1
- package/dist/contract/router.d.ts +670 -0
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/contract/router.js +273 -2
- package/dist/contract/router.js.map +1 -1
- package/dist/server/_impl.d.ts +770 -0
- package/dist/server/_impl.d.ts.map +1 -1
- package/dist/server/_impl.js +264 -2
- package/dist/server/_impl.js.map +1 -1
- package/dist/server/schema.d.ts +318 -1
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +86 -0
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/access.d.ts +1 -0
- package/dist/server/services/access.d.ts.map +1 -1
- package/dist/server/services/index.d.ts +3 -0
- package/dist/server/services/index.d.ts.map +1 -1
- package/dist/server/services/index.js +5 -1
- package/dist/server/services/index.js.map +1 -1
- package/dist/server/services/pages.d.ts.map +1 -1
- package/dist/server/services/pages.js +6 -0
- package/dist/server/services/pages.js.map +1 -1
- package/dist/server/services/publications.d.ts +177 -0
- package/dist/server/services/publications.d.ts.map +1 -0
- package/dist/server/services/publications.js +553 -0
- package/dist/server/services/publications.js.map +1 -0
- package/dist/server/services/versions.d.ts +4 -0
- package/dist/server/services/versions.d.ts.map +1 -1
- package/migrations/0008_publications.sql +140 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +1 -1
- package/src/client/components/PublishDialog.svelte +857 -0
- package/src/client/i18n.ts +434 -0
- package/src/client/index.ts +21 -0
- package/src/client/mock.ts +426 -2
- package/src/client/pages/PageView.svelte +196 -5
- package/src/client/public-url.ts +64 -0
- package/src/client/query.ts +16 -0
- package/src/contract/models.ts +77 -0
- package/src/contract/permissions.ts +53 -1
- package/src/contract/router.ts +302 -1
package/src/client/mock.ts
CHANGED
|
@@ -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
|
-
|
|
157
|
-
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
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
|