@michaelthielemann/kestrel 3.0.1 → 4.0.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/README.md +35 -110
- package/kestrel.config.ts +1 -1
- package/layers/access/server/middleware/00.ip-allowlist.ts +3 -18
- package/layers/access/server/middleware/access-guard.ts +25 -23
- package/layers/access/server/types.d.ts +1 -2
- package/layers/admin/app/components/AdminAccount.vue +1 -1
- package/layers/admin/app/components/BlockFields.vue +1 -2
- package/layers/admin/app/components/BlockPreview.vue +17 -5
- package/layers/admin/app/components/BlocksBody.vue +1 -1
- package/layers/admin/app/components/CollectionEditor.vue +35 -16
- package/layers/admin/app/components/CollectionList.vue +34 -3
- package/layers/admin/app/components/CollectionListBulkBar.vue +17 -3
- package/layers/admin/app/components/CollectionListFilterPanel.vue +1 -1
- package/layers/admin/app/components/CollectionListPager.vue +1 -1
- package/layers/admin/app/components/CollectionListTable.vue +41 -2
- package/layers/admin/app/components/EditorStatus.vue +8 -7
- package/layers/admin/app/components/LocaleBar.vue +3 -1
- package/layers/admin/app/components/PageFields.vue +2 -4
- package/layers/admin/app/components/SeoFields.vue +2 -4
- package/layers/admin/app/components/SingletonEditor.vue +3 -2
- package/layers/admin/app/composables/useAuth.ts +3 -3
- package/layers/admin/app/composables/useBlockTree.ts +1 -1
- package/layers/admin/app/composables/useBlocks.ts +1 -1
- package/layers/admin/app/composables/useCollectionOps.ts +45 -8
- package/layers/admin/app/composables/useCollections.ts +1 -2
- package/layers/admin/app/composables/useEditForm.ts +35 -17
- package/layers/admin/app/composables/useLinkResolver.ts +1 -1
- package/layers/admin/app/composables/useListBatchActions.ts +20 -3
- package/layers/admin/app/composables/useListColumns.ts +1 -1
- package/layers/admin/app/composables/useListFilterDraft.ts +3 -2
- package/layers/admin/app/composables/useListRows.ts +7 -2
- package/layers/admin/app/composables/useListUrlState.ts +1 -1
- package/layers/admin/app/composables/usePublishStatus.ts +2 -2
- package/layers/admin/app/layouts/admin.vue +0 -1
- package/layers/admin/app/pages/admin/[collection]/[id].vue +5 -4
- package/layers/admin/app/pages/admin/index.vue +1 -1
- package/layers/admin/app/pages/admin/references.vue +2 -2
- package/layers/admin/app/utils/block-tree.ts +1 -1
- package/layers/admin/app/utils/collection-ops.ts +28 -3
- package/layers/admin/app/utils/edit-form.ts +34 -2
- package/layers/admin/app/utils/editor-expose.ts +2 -0
- package/layers/admin/app/utils/editor-form-context.ts +1 -2
- package/layers/admin/app/utils/field-keys.ts +1 -1
- package/layers/admin/app/utils/list-columns.ts +3 -2
- package/layers/admin/app/utils/list-query.ts +2 -2
- package/layers/admin/app/utils/populate-blocks.ts +2 -2
- package/layers/admin/app/utils/reauth.ts +5 -6
- package/layers/auth/server/plugins/01.register-auth-pipelines.ts +7 -0
- package/layers/collections/server/plugins/01.register.ts +9 -12
- package/layers/collections/server/plugins/02.register-relation-populate.ts +5 -7
- package/layers/{fields → core}/app/utils/field-factories.ts +1 -1
- package/layers/core/modules/auto-discovery/extract-block.ts +3 -3
- package/layers/core/modules/auto-discovery/index.ts +68 -11
- package/layers/core/modules/auto-discovery/package-registry.ts +19 -0
- package/layers/core/modules/auto-discovery/scan.ts +77 -0
- package/layers/core/modules/auto-discovery/virtual.d.ts +5 -0
- package/layers/core/modules/component-namespace/index.ts +24 -0
- package/layers/core/modules/kestrel/index.ts +6 -1
- package/layers/core/modules/plugin-order/index.ts +69 -0
- package/layers/core/modules/plugin-order/plugin-order.ts +273 -0
- package/layers/core/nuxt.config.ts +1 -0
- package/layers/core/server/api/[...path].ts +98 -0
- package/layers/core/server/plugins/00.config.ts +12 -0
- package/layers/core/server/plugins/00.migrate.ts +1 -0
- package/layers/core/server/plugins/01.register-introspection-pipeline.ts +5 -0
- package/layers/core/server/plugins/01.register-openapi-pipeline.ts +5 -0
- package/layers/core/server/plugins/01.register-tooling-pipelines.ts +5 -0
- package/layers/core/server/plugins/02.schema-sync.ts +2 -7
- package/layers/core/server/plugins/04.outbox-worker.ts +31 -0
- package/layers/core/server/plugins/05.reindex-refs.ts +5 -0
- package/layers/core/server/routes/__kestrel/dashboard.get.ts +29 -0
- package/layers/core/server/tasks/db/migrate-module.ts +83 -0
- package/layers/core/server/tasks/db/migrate-revisions.ts +20 -0
- package/layers/core/server/tasks/db/migrate.ts +2 -6
- package/layers/core/server/utils/server-config.ts +66 -3
- package/layers/fields/server/plugins/01.register-blocks-pipeline.ts +7 -0
- package/layers/fields/server/plugins/01.register-field-populate.ts +2 -2
- package/layers/media/app/components/KestrelImg.vue +3 -3
- package/layers/media/app/components/MediaLibrary.vue +2 -2
- package/layers/media/app/components/field/Media.vue +1 -1
- package/layers/media/app/composables/useMediaResolver.ts +1 -1
- package/layers/media/app/composables/useMediaUpload.ts +1 -1
- package/layers/media/app/composables/useMediaVariant.ts +2 -2
- package/layers/media/app/utils/ai-disclosure.ts +1 -1
- package/layers/media/app/utils/library.ts +1 -1
- package/layers/media/app/utils/ops.ts +1 -3
- package/layers/media/app/utils/picture-model.ts +2 -2
- package/layers/media/app/utils/variant-name.ts +1 -1
- package/layers/media/server/middleware/ondemand-variants.ts +6 -5
- package/layers/media/server/plugins/01.register-media-pipelines.ts +7 -0
- package/layers/media/server/plugins/02.register-media.ts +10 -7
- package/layers/media/server/plugins/04.variant-capture.ts +12 -8
- package/layers/media/server/plugins/05.media-cleanup.ts +5 -0
- package/layers/media/server/tasks/media/backfill.ts +3 -4
- package/layers/public/app/components/KestrelLink.vue +1 -1
- package/layers/public/app/pages/[...slug].vue +1 -1
- package/layers/public/app/pages/__kestrel/preview.vue +2 -2
- package/layers/public/app/utils/json-ld.ts +1 -1
- package/layers/public/app/utils/link-href.ts +1 -1
- package/layers/public/app/utils/page-head.ts +1 -1
- package/layers/public/modules/deploy-output/index.ts +4 -6
- package/layers/public/modules/prerender-routes/index.ts +4 -4
- package/layers/public/modules/prerender-routes/routes.ts +1 -1
- package/layers/public/modules/prune-media/index.ts +2 -2
- package/layers/public/server/delivery-live/serve.ts +111 -0
- package/layers/public/server/middleware/delivery-live-catchall.ts +7 -0
- package/layers/public/server/pipelines/index.ts +23 -0
- package/layers/public/server/plugins/00.ensure-snapshot-triggers.ts +14 -0
- package/layers/public/server/plugins/01.register-public-pipelines.ts +7 -0
- package/layers/public/server/plugins/02.register-links.ts +2 -3
- package/layers/public/server/plugins/03.redirects.ts +41 -23
- package/layers/public/server/plugins/05.plan-publish.ts +5 -0
- package/layers/public/server/plugins/zz.publish.ts +79 -24
- package/layers/public/server/routes/llms-full.txt.get.ts +5 -5
- package/layers/public/server/routes/llms.txt.get.ts +4 -2
- package/layers/public/server/routes/redirects.json.get.ts +2 -2
- package/layers/public/server/routes/robots.txt.get.ts +2 -0
- package/layers/public/server/routes/sitemap.xml.get.ts +16 -6
- package/layers/public/server/tasks/publish/run.ts +9 -4
- package/layers/public/server/utils/publish/render-live.ts +26 -0
- package/layers/ui/app/components/field/Choice.vue +1 -1
- package/layers/ui/app/components/field/Datetime.vue +1 -1
- package/layers/ui/app/components/field/Json.vue +1 -2
- package/layers/ui/app/components/field/Layout.vue +2 -4
- package/layers/ui/app/components/field/Link.vue +1 -1
- package/layers/ui/app/components/field/LinkInternalPicker.vue +3 -3
- package/layers/ui/app/components/field/Number.vue +2 -2
- package/layers/ui/app/components/field/Relation.vue +1 -1
- package/layers/ui/app/components/field/Repeater.vue +1 -3
- package/layers/ui/app/components/field/Slug.vue +2 -3
- package/layers/ui/app/components/field/Text.vue +1 -1
- package/layers/ui/app/components/ui/Richtext.vue +1 -1
- package/layers/ui/app/components/ui/RichtextToolbar.vue +1 -3
- package/layers/ui/app/composables/useLinkField.ts +1 -1
- package/layers/ui/app/composables/useRepeater.ts +1 -1
- package/layers/ui/app/i18n/de.ts +7 -7
- package/layers/ui/app/i18n/en.ts +7 -39
- package/layers/ui/app/utils/field-component.ts +1 -1
- package/layers/ui/app/utils/field-registry.ts +1 -1
- package/layers/ui/app/utils/icons.ts +2 -6
- package/layers/ui/app/utils/localized.ts +1 -1
- package/nuxt.config.ts +1 -1
- package/package.json +53 -19
- package/scripts/consumer-template-ci.mjs +157 -0
- package/scripts/coverage-all.mjs +163 -0
- package/scripts/dashboard.mjs +89 -0
- package/scripts/hash-password.mjs +1 -1
- package/scripts/lib/coverage-model.mjs +51 -0
- package/scripts/port-graph.mjs +350 -0
- package/scripts/typecheck.mjs +11 -0
- package/templates/starter/app/blocks/Prose.vue +1 -1
- package/layers/access/server/utils/csrf.ts +0 -39
- package/layers/access/server/utils/grant-registry.ts +0 -36
- package/layers/access/server/utils/guard.ts +0 -44
- package/layers/access/server/utils/ip-allowlist.ts +0 -79
- package/layers/access/server/utils/policy.ts +0 -98
- package/layers/access/server/utils/public-resources.ts +0 -11
- package/layers/access/server/utils/render-context.ts +0 -41
- package/layers/access/server/utils/require-admin.ts +0 -15
- package/layers/admin/app/pages/admin/[collection]/[id].nuxt.test.ts +0 -200
- package/layers/admin/app/pages/admin/[collection]/publish-preview.nuxt.test.ts +0 -142
- package/layers/auth/server/api/auth/login.post.ts +0 -38
- package/layers/auth/server/api/auth/logout.post.ts +0 -9
- package/layers/auth/server/api/auth/session.get.ts +0 -1
- package/layers/auth/server/utils/admin-credential.ts +0 -42
- package/layers/auth/server/utils/client-ip.ts +0 -88
- package/layers/auth/server/utils/login-throttle.ts +0 -126
- package/layers/auth/server/utils/password.ts +0 -52
- package/layers/auth/server/utils/session-cookie.ts +0 -44
- package/layers/auth/server/utils/session-epoch.ts +0 -96
- package/layers/auth/server/utils/session.ts +0 -88
- package/layers/collections/server/collections/pages.ts +0 -19
- package/layers/collections/server/utils/populate-relations.ts +0 -79
- package/layers/core/app/utils/bulk-actions.ts +0 -9
- package/layers/core/app/utils/filter-ops.ts +0 -98
- package/layers/core/app/utils/list-limits.ts +0 -27
- package/layers/core/app/utils/locale-path.ts +0 -12
- package/layers/core/app/utils/slugify.ts +0 -16
- package/layers/core/server/api/[collection]/[id]/dead-refs.get.ts +0 -8
- package/layers/core/server/api/[collection]/[id]/translations.get.ts +0 -4
- package/layers/core/server/api/[collection]/[id].delete.ts +0 -5
- package/layers/core/server/api/[collection]/[id].get.ts +0 -11
- package/layers/core/server/api/[collection]/[id].patch.ts +0 -8
- package/layers/core/server/api/[collection]/bulk.post.ts +0 -37
- package/layers/core/server/api/[collection]/index.get.ts +0 -24
- package/layers/core/server/api/[collection]/index.post.ts +0 -8
- package/layers/core/server/api/[collection]/index.put.test.ts +0 -76
- package/layers/core/server/api/[collection]/index.put.ts +0 -24
- package/layers/core/server/api/[collection]/options.get.test.ts +0 -60
- package/layers/core/server/api/[collection]/options.get.ts +0 -18
- package/layers/core/server/api/[collection]/populate-scope.test.ts +0 -119
- package/layers/core/server/api/[collection]/translations.get.test.ts +0 -90
- package/layers/core/server/api/[collection]/translations.get.ts +0 -25
- package/layers/core/server/api/collections/[name].get.ts +0 -4
- package/layers/core/server/api/collections/index.get.ts +0 -3
- package/layers/core/server/api/references/broken.get.ts +0 -9
- package/layers/core/server/api/references/referrers.get.ts +0 -24
- package/layers/core/server/database/record-refs.ts +0 -20
- package/layers/core/server/plugins/03.record-refs.ts +0 -12
- package/layers/core/server/schema/bootstrap.ts +0 -41
- package/layers/core/server/schema/desired.ts +0 -99
- package/layers/core/server/schema/dev-sync.ts +0 -23
- package/layers/core/server/schema/dialect.ts +0 -61
- package/layers/core/server/schema/diff.ts +0 -110
- package/layers/core/server/schema/introspect.ts +0 -68
- package/layers/core/server/schema/model.ts +0 -61
- package/layers/core/server/schema/render-sqlite.ts +0 -89
- package/layers/core/server/schema/sync.ts +0 -201
- package/layers/core/server/utils/blocks.ts +0 -6
- package/layers/core/server/utils/collection-types.ts +0 -28
- package/layers/core/server/utils/crud.ts +0 -578
- package/layers/core/server/utils/db.ts +0 -26
- package/layers/core/server/utils/defineCollection.ts +0 -213
- package/layers/core/server/utils/duplicate.ts +0 -162
- package/layers/core/server/utils/field-layout.ts +0 -125
- package/layers/core/server/utils/filter-predicate.ts +0 -101
- package/layers/core/server/utils/http.ts +0 -50
- package/layers/core/server/utils/if-unmodified.ts +0 -13
- package/layers/core/server/utils/kestrel-config.ts +0 -451
- package/layers/core/server/utils/key-lock.ts +0 -33
- package/layers/core/server/utils/locale.ts +0 -53
- package/layers/core/server/utils/page-route.ts +0 -58
- package/layers/core/server/utils/page-slug.ts +0 -127
- package/layers/core/server/utils/picker.ts +0 -82
- package/layers/core/server/utils/populate.ts +0 -50
- package/layers/core/server/utils/prerender-memo.ts +0 -23
- package/layers/core/server/utils/read-capture.ts +0 -37
- package/layers/core/server/utils/record-ref-index.ts +0 -200
- package/layers/core/server/utils/registry.ts +0 -24
- package/layers/core/server/utils/resolve-scope.ts +0 -79
- package/layers/core/server/utils/seo.ts +0 -42
- package/layers/core/server/utils/serialize-collection.ts +0 -147
- package/layers/core/server/utils/sql.ts +0 -2
- package/layers/core/server/utils/storage.local.ts +0 -117
- package/layers/core/server/utils/storage.s3.ts +0 -197
- package/layers/core/server/utils/storage.ts +0 -90
- package/layers/core/server/utils/write-effects.ts +0 -40
- package/layers/core/server/utils/write-events.ts +0 -31
- package/layers/fields/app/utils/condition.ts +0 -71
- package/layers/fields/app/utils/define-block.ts +0 -13
- package/layers/fields/app/utils/field-constraints.ts +0 -17
- package/layers/fields/app/utils/field-meta.ts +0 -33
- package/layers/fields/app/utils/field-validate.ts +0 -75
- package/layers/fields/app/utils/field-value.ts +0 -7
- package/layers/fields/app/utils/richtext-links.ts +0 -48
- package/layers/fields/server/api/blocks/index.get.ts +0 -9
- package/layers/fields/server/field-registry/index.ts +0 -249
- package/layers/fields/server/field-registry/naming.ts +0 -10
- package/layers/fields/server/field-registry/sanitize.ts +0 -43
- package/layers/fields/server/field-registry/types.ts +0 -19
- package/layers/fields/server/utils/block-ids.ts +0 -43
- package/layers/fields/server/utils/block-populate.ts +0 -41
- package/layers/fields/server/utils/buildCollection.ts +0 -90
- package/layers/fields/server/utils/buildFieldSchema.ts +0 -22
- package/layers/fields/server/utils/buildTable.ts +0 -113
- package/layers/fields/server/utils/conditional-required.ts +0 -25
- package/layers/fields/server/utils/defineBlock.ts +0 -86
- package/layers/fields/server/utils/defineFieldType.ts +0 -33
- package/layers/fields/server/utils/extract-refs.ts +0 -152
- package/layers/fields/server/utils/field-populate.ts +0 -68
- package/layers/media/server/api/media/[id]/usages.get.ts +0 -8
- package/layers/media/server/api/media/[id].delete.ts +0 -18
- package/layers/media/server/api/media/[id].get.ts +0 -10
- package/layers/media/server/api/media/[id].patch.test.ts +0 -66
- package/layers/media/server/api/media/[id].patch.ts +0 -65
- package/layers/media/server/api/media/backfill.post.ts +0 -18
- package/layers/media/server/api/media/copy.post.ts +0 -7
- package/layers/media/server/api/media/delete.post.ts +0 -13
- package/layers/media/server/api/media/folders.post.ts +0 -18
- package/layers/media/server/api/media/index.get.ts +0 -15
- package/layers/media/server/api/media/index.post.ts +0 -173
- package/layers/media/server/api/media/library.get.ts +0 -17
- package/layers/media/server/api/media/move.post.ts +0 -7
- package/layers/media/server/api/media/rename.post.ts +0 -7
- package/layers/media/server/api/media/resolve.get.ts +0 -13
- package/layers/media/server/collections/media-settings.ts +0 -25
- package/layers/media/server/collections/media.ts +0 -43
- package/layers/media/server/database/folders.ts +0 -10
- package/layers/media/server/plugins/03.media-cleanup.ts +0 -20
- package/layers/media/server/schema-tables/folders.ts +0 -4
- package/layers/media/server/utils/ai-disclosure-enabled.ts +0 -16
- package/layers/media/server/utils/ai-signal-detect.ts +0 -155
- package/layers/media/server/utils/backfill.ts +0 -144
- package/layers/media/server/utils/derive.ts +0 -59
- package/layers/media/server/utils/folder-paths.ts +0 -42
- package/layers/media/server/utils/folders.ts +0 -12
- package/layers/media/server/utils/library.ts +0 -91
- package/layers/media/server/utils/media-cleanup.ts +0 -27
- package/layers/media/server/utils/media-enabled.ts +0 -23
- package/layers/media/server/utils/media-ops.ts +0 -118
- package/layers/media/server/utils/media-write.ts +0 -13
- package/layers/media/server/utils/naming.ts +0 -52
- package/layers/media/server/utils/ondemand.ts +0 -69
- package/layers/media/server/utils/persist-upload.ts +0 -62
- package/layers/media/server/utils/populate.ts +0 -47
- package/layers/media/server/utils/record.ts +0 -71
- package/layers/media/server/utils/relocate-ops.ts +0 -341
- package/layers/media/server/utils/relocate-plan.ts +0 -63
- package/layers/media/server/utils/resolve.ts +0 -98
- package/layers/media/server/utils/sanitize-svg.ts +0 -12
- package/layers/media/server/utils/sniff.ts +0 -68
- package/layers/media/server/utils/storage-relocate.ts +0 -139
- package/layers/media/server/utils/translations.ts +0 -19
- package/layers/media/server/utils/usages.ts +0 -66
- package/layers/media/server/utils/variant-capture.ts +0 -70
- package/layers/media/server/utils/variants.ts +0 -123
- package/layers/public/modules/deploy-output/deploy-output.ts +0 -384
- package/layers/public/server/api/links/resolve.get.ts +0 -19
- package/layers/public/server/api/preview.get.ts +0 -28
- package/layers/public/server/api/preview.post.ts +0 -93
- package/layers/public/server/api/publish-status.get.ts +0 -58
- package/layers/public/server/api/publish.post.ts +0 -84
- package/layers/public/server/api/route.get.ts +0 -54
- package/layers/public/server/collections/redirects.ts +0 -75
- package/layers/public/server/collections/site.ts +0 -44
- package/layers/public/server/database/publish-deps.ts +0 -12
- package/layers/public/server/database/publish-status.ts +0 -19
- package/layers/public/server/schema-tables/publish-deps.ts +0 -4
- package/layers/public/server/schema-tables/publish-status.ts +0 -4
- package/layers/public/server/utils/link-resolve.ts +0 -38
- package/layers/public/server/utils/llms-full.ts +0 -125
- package/layers/public/server/utils/llms.ts +0 -52
- package/layers/public/server/utils/page-resolve.ts +0 -208
- package/layers/public/server/utils/populate-links.ts +0 -66
- package/layers/public/server/utils/preview-token.ts +0 -109
- package/layers/public/server/utils/publish/coalesce.ts +0 -22
- package/layers/public/server/utils/publish/deps-persistence.ts +0 -43
- package/layers/public/server/utils/publish/deps.ts +0 -87
- package/layers/public/server/utils/publish/invalidation.ts +0 -225
- package/layers/public/server/utils/publish/pending.ts +0 -74
- package/layers/public/server/utils/publish/publish-runtime.ts +0 -28
- package/layers/public/server/utils/publish/publish-status.ts +0 -72
- package/layers/public/server/utils/publish/publisher.ts +0 -362
- package/layers/public/server/utils/publish/queue.ts +0 -67
- package/layers/public/server/utils/publish/redirect-rules.ts +0 -221
- package/layers/public/server/utils/publish/redirects-artifact.ts +0 -20
- package/layers/public/server/utils/publish/route-for-record.ts +0 -16
- package/layers/public/server/utils/publish/route-keys.ts +0 -13
- package/layers/public/server/utils/richtext-markdown.ts +0 -260
- package/layers/public/server/utils/site-url.ts +0 -40
- package/layers/public/server/utils/sitemap.ts +0 -99
|
@@ -1,208 +0,0 @@
|
|
|
1
|
-
import { and, asc, eq, getTableColumns } from 'drizzle-orm'
|
|
2
|
-
import { list } from '../../../core/server/utils/crud'
|
|
3
|
-
import { captureRead } from '../../../core/server/utils/read-capture'
|
|
4
|
-
import { pagePathTag, translationGroupTag } from './publish/invalidation'
|
|
5
|
-
import type { BuiltCollection } from '../../../core/server/utils/collection-types'
|
|
6
|
-
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
|
|
7
|
-
|
|
8
|
-
export interface PageAlternate { locale: string; path: string }
|
|
9
|
-
/** One published page above this one in the path hierarchy — a real, linkable breadcrumb step. */
|
|
10
|
-
export interface PageAncestor {
|
|
11
|
-
path: string
|
|
12
|
-
title?: string
|
|
13
|
-
/** The ancestor's OWN locale, so the crumb builds the URL that was actually published. Absent for a
|
|
14
|
-
* non-translatable collection, whose rows have a single unprefixed (primary-locale) URL — prefixing
|
|
15
|
-
* one of those with the reader's locale would link a page `nuxt generate` never wrote. */
|
|
16
|
-
locale?: string
|
|
17
|
-
}
|
|
18
|
-
export interface ResolvedPage {
|
|
19
|
-
collection: string
|
|
20
|
-
page: Record<string, unknown>
|
|
21
|
-
alternates: PageAlternate[]
|
|
22
|
-
ancestors: PageAncestor[]
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/** The matched page (or null) plus the collections whose lookup threw. `failed` is non-empty ⇒ the scan
|
|
26
|
-
* was INCOMPLETE, so `page: null` must never be treated as an authoritative "no such page". */
|
|
27
|
-
export interface PageResolution {
|
|
28
|
-
page: ResolvedPage | null
|
|
29
|
-
failed: string[]
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* The page's published, INDEXABLE translation siblings (self included) as locale→path pairs — the hreflang
|
|
34
|
-
* set the public head emits. Mirrors the sitemap's rules exactly so the two never disagree: published-only
|
|
35
|
-
* (a draft URL must never be advertised to crawlers), `seo.noindex` variants EXCLUDED (advertising an
|
|
36
|
-
* hreflang to a noindexed page is a conflicting signal), null-path rows skipped, and a single-member group
|
|
37
|
-
* returns [] (hreflang is meaningless for a lone page). EVERY sibling in the group is `captureRead`-tagged,
|
|
38
|
-
* filtered-out ones included, so an incremental publish re-renders every group member when a sibling is
|
|
39
|
-
* renamed/published/unpublished — otherwise a baked page would keep a stale/dead hreflang href. The GROUP
|
|
40
|
-
* itself is tagged too: a sibling that does not exist yet has no id to have been captured, so the group tag
|
|
41
|
-
* is the only edge a later CREATE can match.
|
|
42
|
-
*/
|
|
43
|
-
function publishedAlternates(db: BetterSQLite3Database, c: BuiltCollection, page: Record<string, unknown>): PageAlternate[] {
|
|
44
|
-
if (c.def.mode !== 'multi' || !c.def.translatable) return []
|
|
45
|
-
const group = page.translationGroup
|
|
46
|
-
if (typeof group !== 'string' || !group) return []
|
|
47
|
-
captureRead(translationGroupTag(c.def.name, group))
|
|
48
|
-
const cols = getTableColumns(c.table) as Record<string, never>
|
|
49
|
-
// A status-less pageLike collection has no draft state — every sibling is "published".
|
|
50
|
-
const hasStatus = Object.hasOwn(cols, 'status')
|
|
51
|
-
const proj: Record<string, unknown> = { id: cols.id, locale: cols.locale, path: cols.path }
|
|
52
|
-
if (hasStatus) proj.status = cols.status
|
|
53
|
-
if (c.def.seo) proj.seo = cols.seo
|
|
54
|
-
// Read the WHOLE group and filter in JS: a row excluded by SQL is never captured, so a draft sibling
|
|
55
|
-
// would carry no dependency edge for the publish tag `<coll>:<id>` to match, and this page would keep a
|
|
56
|
-
// baked hreflang set missing the newly-published sibling until the next full generate.
|
|
57
|
-
const rows = db.select(proj as never)
|
|
58
|
-
.from(c.table)
|
|
59
|
-
.where(eq(cols.translationGroup, group))
|
|
60
|
-
.orderBy(asc(cols.locale))
|
|
61
|
-
.all() as Array<{ id: number; locale: string; path: unknown; status?: string; seo?: { noindex?: boolean } | null }>
|
|
62
|
-
const alternates: PageAlternate[] = []
|
|
63
|
-
let selfAdvertisable = false
|
|
64
|
-
for (const r of rows) {
|
|
65
|
-
// Capture the sibling as a publish dependency even if it is filtered out below — a draft/noindexed/
|
|
66
|
-
// null-path sibling that later becomes advertisable must still re-render this page's hreflang set.
|
|
67
|
-
captureRead(c.def.name, r.id)
|
|
68
|
-
if (hasStatus && r.status !== 'published') continue // a draft URL must never be advertised
|
|
69
|
-
if (typeof r.path !== 'string' || !r.path) continue // null/empty path — no advertisable URL
|
|
70
|
-
if (r.seo?.noindex) continue // noindexed — never advertise it via hreflang (matches the sitemap)
|
|
71
|
-
if (r.id === page.id) selfAdvertisable = true
|
|
72
|
-
alternates.push({ locale: r.locale, path: r.path })
|
|
73
|
-
}
|
|
74
|
-
// A hreflang set with no self-reference is a signal search engines ignore wholesale — if the page
|
|
75
|
-
// being rendered didn't itself survive the filters above, advertise nothing rather than an orphan set.
|
|
76
|
-
if (!selfAdvertisable) return []
|
|
77
|
-
return alternates.length >= 2 ? alternates : []
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/** Every strict path prefix of a page's path, outermost first: `/blog/hello` → `['/', '/blog']`. The
|
|
81
|
-
* site root is an ancestor of everything except itself. */
|
|
82
|
-
function ancestorPaths(path: string): string[] {
|
|
83
|
-
if (path === '/' || !path.startsWith('/')) return []
|
|
84
|
-
const segments = path.split('/').filter(Boolean)
|
|
85
|
-
const paths = ['/']
|
|
86
|
-
for (let i = 1; i < segments.length; i += 1) paths.push(`/${segments.slice(0, i).join('/')}`)
|
|
87
|
-
return paths
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* The page's breadcrumb trail: the published, INDEXABLE page at each ancestor path, in the page's own
|
|
92
|
-
* locale. A path segment with no page behind it is SKIPPED rather than synthesised — schema.org
|
|
93
|
-
* breadcrumb items are links, and a trail that points at a 404 is a worse signal than a shorter trail.
|
|
94
|
-
* The filters mirror the sitemap's, so a breadcrumb never advertises what the sitemap withholds.
|
|
95
|
-
*
|
|
96
|
-
* TWO dependency edges are captured per ancestor, because neither covers the other:
|
|
97
|
-
* - the PATH (`pagePathTag`), for every path looked in — including those with no page at all and those
|
|
98
|
-
* whose lookup threw. Kestrel has no parent/child relation between pages, so an ancestor is a
|
|
99
|
-
* path-prefix match, and a page CREATED at `/blog` after `/blog/hello` was published has no id anything
|
|
100
|
-
* could have captured beforehand. A path is knowable before its page exists.
|
|
101
|
-
* - the RECORD (`<coll>:<id>`) of whatever row sits there, captured before the filters below so a
|
|
102
|
-
* currently-invisible one counts too — the edge that carries a rename, a `noindex` or an unpublish,
|
|
103
|
-
* none of which `crumbTags` can see (see the comment at that capture).
|
|
104
|
-
* `publishedAlternates` pairs a group tag with a record tag for exactly the same reason.
|
|
105
|
-
*/
|
|
106
|
-
function publishedAncestors(
|
|
107
|
-
db: BetterSQLite3Database,
|
|
108
|
-
collections: BuiltCollection[],
|
|
109
|
-
path: string,
|
|
110
|
-
locale: string | undefined,
|
|
111
|
-
publishedOnly: boolean,
|
|
112
|
-
): PageAncestor[] {
|
|
113
|
-
const out: PageAncestor[] = []
|
|
114
|
-
for (const ancestorPath of ancestorPaths(path)) {
|
|
115
|
-
// Before the scan, so the edge exists whatever the scan finds — no page, a draft, a noindexed one, or
|
|
116
|
-
// a collection whose table could not be read. A write at this path emits the same tag (`crumbTags` in
|
|
117
|
-
// classifyWrite), which is what repairs this page's trail.
|
|
118
|
-
captureRead(pagePathTag(ancestorPath))
|
|
119
|
-
for (const c of collections) {
|
|
120
|
-
if (!c.def.pageLike) continue
|
|
121
|
-
// Project only what a crumb needs — never the row: this runs once per path segment per render, and
|
|
122
|
-
// pulling every page's block JSON to read a title would multiply the cost of a full publish.
|
|
123
|
-
const cols = getTableColumns(c.table) as Record<string, never>
|
|
124
|
-
const hasStatus = Object.hasOwn(cols, 'status')
|
|
125
|
-
const proj: Record<string, unknown> = { id: cols.id, path: cols.path }
|
|
126
|
-
if (hasStatus) proj.status = cols.status
|
|
127
|
-
if (c.def.seo) proj.seo = cols.seo
|
|
128
|
-
if (c.def.translatable) proj.locale = cols.locale
|
|
129
|
-
if (Object.hasOwn(cols, 'title')) proj.title = cols.title
|
|
130
|
-
const scoped = c.def.translatable && locale ? and(eq(cols.path, ancestorPath), eq(cols.locale, locale)) : eq(cols.path, ancestorPath)
|
|
131
|
-
let row: { id: number; status?: string; seo?: { title?: string; noindex?: boolean } | null; title?: unknown; locale?: string } | undefined
|
|
132
|
-
try {
|
|
133
|
-
// Order by locale so an unscoped lookup (no locale requested) still answers deterministically
|
|
134
|
-
// rather than with whatever row the table happens to yield first.
|
|
135
|
-
const q = db.select(proj as never).from(c.table).where(scoped)
|
|
136
|
-
row = (c.def.translatable ? q.orderBy(asc(cols.locale)) : q).limit(1).get() as typeof row
|
|
137
|
-
} catch (error) {
|
|
138
|
-
// Same isolation rule as the main scan: a drifted collection loses its crumbs, loudly, instead of
|
|
139
|
-
// failing the whole render.
|
|
140
|
-
console.error(`[kestrel] resolvePage: skipped ancestor lookup in ${c.def.name}:`, (error as Error)?.message ?? error)
|
|
141
|
-
continue
|
|
142
|
-
}
|
|
143
|
-
if (!row) continue
|
|
144
|
-
// BOTH edges are needed, and neither subsumes the other — the same pairing `publishedAlternates`
|
|
145
|
-
// uses. The record tag covers every change to the row sitting here, INCLUDING the ones that make it
|
|
146
|
-
// stop being a crumb: the explicit publish action classifies its write as `before === after` (the
|
|
147
|
-
// record's current state), so a rename, a `noindex` or an unpublish is invisible in `crumbTags`,
|
|
148
|
-
// which only ever names where the record is NOW. `<coll>:<id>` is in that write's tag list whatever
|
|
149
|
-
// the row looks like, so it is what repairs the trail. Captured BEFORE the filters below, so a
|
|
150
|
-
// draft/noindexed/shadowing row — one that is currently NOT the crumb — still carries the edge that
|
|
151
|
-
// fires when it goes away.
|
|
152
|
-
captureRead(c.def.name, row.id)
|
|
153
|
-
if (publishedOnly && hasStatus && row.status !== 'published') break
|
|
154
|
-
if (row.seo?.noindex) break
|
|
155
|
-
const ancestor: PageAncestor = { path: ancestorPath }
|
|
156
|
-
const title = row.seo?.title || (typeof row.title === 'string' ? row.title : undefined)
|
|
157
|
-
if (title) ancestor.title = title
|
|
158
|
-
if (c.def.translatable && row.locale) ancestor.locale = row.locale
|
|
159
|
-
out.push(ancestor)
|
|
160
|
-
break
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
return out
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
/**
|
|
167
|
-
* The first page-like record (across all collections, in registration order) whose `path` matches,
|
|
168
|
-
* populated at depth 1 — or null, alongside the collections that could not be read at all. Reuses the
|
|
169
|
-
* access-scoped `list()` so media/links populate exactly as a direct collection read would.
|
|
170
|
-
* `publishedOnly` defaults true: a static render (prerender / runtime publisher) and an anonymous live
|
|
171
|
-
* request must never see drafts. The authenticated-admin live preview passes false to surface a draft at
|
|
172
|
-
* its real URL. Registration order is the precedence rule when two page-like collections happen to share
|
|
173
|
-
* a path.
|
|
174
|
-
*/
|
|
175
|
-
export function resolvePage(db: BetterSQLite3Database, collections: BuiltCollection[], path: string, locale?: string, publishedOnly = true): PageResolution {
|
|
176
|
-
const failed: string[] = []
|
|
177
|
-
for (const c of collections) {
|
|
178
|
-
if (!c.def.pageLike) continue
|
|
179
|
-
// withTotal:false — read only the first row, skip count(). capture:false — don't tag the whole
|
|
180
|
-
// collection here (the self-lookup is by path); instead tag the found record below, so this page
|
|
181
|
-
// depends on its OWN record, not on every sibling in the collection (precise invalidation).
|
|
182
|
-
let result: ReturnType<typeof list>
|
|
183
|
-
try {
|
|
184
|
-
result = list(db, c, { filter: [{ field: 'path', op: 'eq', value: path }], locale, depth: 1, perPage: 1, withTotal: false, capture: false }, publishedOnly)
|
|
185
|
-
} catch (error) {
|
|
186
|
-
// Table not migrated yet (e.g. a bare prerender DB) — isolate this collection's drift from the rest,
|
|
187
|
-
// but never silently: an unread collection is indistinguishable from one with no matching page, and
|
|
188
|
-
// the publisher would write a 404 over every one of its routes.
|
|
189
|
-
failed.push(c.def.name)
|
|
190
|
-
console.error(`[kestrel] resolvePage: skipped collection ${c.def.name}:`, (error as Error)?.message ?? error)
|
|
191
|
-
continue
|
|
192
|
-
}
|
|
193
|
-
const { data } = result
|
|
194
|
-
if (data.length) {
|
|
195
|
-
captureRead(c.def.name, (data[0] as { id?: number }).id ?? null)
|
|
196
|
-
return {
|
|
197
|
-
page: {
|
|
198
|
-
collection: c.def.name,
|
|
199
|
-
page: data[0]!,
|
|
200
|
-
alternates: publishedAlternates(db, c, data[0]!),
|
|
201
|
-
ancestors: publishedAncestors(db, collections, path, locale, publishedOnly),
|
|
202
|
-
},
|
|
203
|
-
failed,
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
return { page: null, failed }
|
|
208
|
-
}
|
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
import type { FieldPopulator } from '../../../core/server/utils/populate'
|
|
2
|
-
import { memoDuringPrerender } from '../../../core/server/utils/prerender-memo'
|
|
3
|
-
import { memoResolver } from '../../../core/server/utils/resolve-scope'
|
|
4
|
-
import { captureRead } from '../../../core/server/utils/read-capture'
|
|
5
|
-
import { collectRichtextRefs, resolveRichtextLinks } from '../../../fields/app/utils/richtext-links'
|
|
6
|
-
|
|
7
|
-
// The localized-href-for-a-page-row rule lives once in core (`pageRowHref`); the link populator/resolver
|
|
8
|
-
// use it as the target-row href. The resolver (link-resolve) STATUS-GATES: a missing OR draft target
|
|
9
|
-
// renders `'#'` (no draft-slug leak); only a published target gets its real path.
|
|
10
|
-
|
|
11
|
-
// Resolves an internal link `{collection, id}` to the target's localized path, or null when it has no
|
|
12
|
-
// public path (target missing, not page-like, or no path). The locale is the TARGET row's own, so the
|
|
13
|
-
// resolver takes no locale argument — mirroring how the media populator injects its by-id resolver.
|
|
14
|
-
export type ResolveHref = (collection: string, id: number) => string | null
|
|
15
|
-
|
|
16
|
-
interface InternalLink { type: 'internal'; collection: string; id: number; href?: string }
|
|
17
|
-
|
|
18
|
-
function isInternal(value: unknown): value is InternalLink {
|
|
19
|
-
if (!value || typeof value !== 'object') return false
|
|
20
|
-
const v = value as Record<string, unknown>
|
|
21
|
-
return v.type === 'internal' && typeof v.collection === 'string' && typeof v.id === 'number'
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
// Replace an internal link value in place with a cloned `{...value, href}` (KestrelLink reads value.href).
|
|
25
|
-
// external / email / tel and unresolvable internals pass through untouched (the latter render '#').
|
|
26
|
-
function resolveLink(value: unknown, resolveHref: ResolveHref): unknown {
|
|
27
|
-
if (!isInternal(value)) return value
|
|
28
|
-
const href = resolveHref(value.collection, value.id)
|
|
29
|
-
return href == null ? value : { ...value, href }
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* The `link` + `richtext` field populators. `link` replaces an internal link value with `{...value, href}`;
|
|
34
|
-
* `richtext` rewrites `kestrel:` internal-link markers in the HTML to resolved localized paths. Both are
|
|
35
|
-
* bare-keyed in every key-mode (link/richtext are not single-ref columns), so `keyMode` is irrelevant.
|
|
36
|
-
* Registered per-type via `registerFieldPopulator`; the shared field-tree walker drives them over
|
|
37
|
-
* top-level fields, block props, and (now) repeater entries. Both share one prerender memo so a target is
|
|
38
|
-
* resolved once per generate across links and richtext alike.
|
|
39
|
-
*/
|
|
40
|
-
export function buildLinkFieldPopulators(resolveHref: ResolveHref): { link: FieldPopulator; richtext: FieldPopulator } {
|
|
41
|
-
// Same composition as the relation/media populators: build-wide memo during generate, request-/publish-
|
|
42
|
-
// run-wide via the resolve scope (budgeted per live request, read-tags replayed on hits).
|
|
43
|
-
// memoResolver OUTERMOST so a per-scope budget-skip null is never cached build-wide by the prerender memo.
|
|
44
|
-
const key = (collection: string, id: number) => `link:${collection}:${id}`
|
|
45
|
-
const resolve = memoResolver(memoDuringPrerender(resolveHref, key), key)
|
|
46
|
-
return {
|
|
47
|
-
// captureRead runs even on a memo hit (each embedding page must record the dep), so it sits here, not
|
|
48
|
-
// inside `resolve`: it durably ties this render's route to `<collection>:<id>`, so renaming the target
|
|
49
|
-
// re-renders the referrer instead of leaving a baked href pointing at the pruned old path. A DRAFT
|
|
50
|
-
// target is tagged for the same reason: resolution is status-gated, so the referrer baked '#', and this
|
|
51
|
-
// edge is what lets the availability tag `<collection>:<id>` (planInvalidation's publish/unpublish/
|
|
52
|
-
// delete branches) re-render it into the real path. A dangling target's tag is simply inert — no write
|
|
53
|
-
// ever emits it.
|
|
54
|
-
link: (bag, key) => {
|
|
55
|
-
const value = bag[key]
|
|
56
|
-
if (isInternal(value)) captureRead(value.collection, value.id)
|
|
57
|
-
bag[key] = resolveLink(value, resolve)
|
|
58
|
-
},
|
|
59
|
-
richtext: (bag, key) => {
|
|
60
|
-
const html = bag[key]
|
|
61
|
-
if (typeof html !== 'string') return
|
|
62
|
-
for (const ref of collectRichtextRefs(html)) captureRead(ref.collection, ref.id)
|
|
63
|
-
bag[key] = resolveRichtextLinks(html, resolve)
|
|
64
|
-
},
|
|
65
|
-
}
|
|
66
|
-
}
|
|
@@ -1,109 +0,0 @@
|
|
|
1
|
-
import { randomBytes } from 'node:crypto'
|
|
2
|
-
import type { H3Event } from 'h3'
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Short-lived tickets carrying the editor's UNSAVED form state to a real page render. The editor's own
|
|
6
|
-
* iframe gets unsaved content over postMessage, but an external tab has no parent window to talk to — so
|
|
7
|
-
* instead of saving (which would publish intent the user never expressed) it mints a ticket and opens
|
|
8
|
-
* `<url>?kestrel-preview-token=…`. Nothing is written to the DB; the ticket lives in this process only.
|
|
9
|
-
*
|
|
10
|
-
* The admin session is the actual gate (both endpoints are admin-only under the default-deny API guard).
|
|
11
|
-
* The owner binding on top is NOT per-session isolation today: Kestrel has one shared admin credential and
|
|
12
|
-
* `derivePrincipal` (access layer) never mints more than one admin identity, so every caller that reaches
|
|
13
|
-
* this store has already been narrowed by `requireAdmin` to the same principal, and `previewOwner()`
|
|
14
|
-
* resolves to the literal string `'admin'` every time — `t.owner === owner` cannot currently be false for
|
|
15
|
-
* an admin caller. The binding is kept because it is the seam that makes the check meaningful the moment
|
|
16
|
-
* (if ever) a per-user identity is added upstream; until then it costs nothing and documents the intent.
|
|
17
|
-
* Tickets stay readable until they expire — a preview tab may be reloaded — and the store bounds itself in
|
|
18
|
-
* both directions: a sweep on every mint, and a hard cap that evicts the oldest ticket.
|
|
19
|
-
*
|
|
20
|
-
* In-memory by design: previewing is a per-editor, per-minute affair, and a second server instance would
|
|
21
|
-
* simply re-mint. Nothing durable depends on it.
|
|
22
|
-
*/
|
|
23
|
-
export interface PreviewPayload {
|
|
24
|
-
collection: string
|
|
25
|
-
/** The record being previewed, or null for one that has never been saved. */
|
|
26
|
-
id: number | null
|
|
27
|
-
locale?: string
|
|
28
|
-
/** The editor's populated values — the same tree the live-preview bridge posts into the iframe. */
|
|
29
|
-
values: Record<string, unknown>
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export interface PreviewTicket {
|
|
33
|
-
token: string
|
|
34
|
-
expiresAt: number
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export interface PreviewStore {
|
|
38
|
-
mint: (owner: string, payload: PreviewPayload) => PreviewTicket
|
|
39
|
-
read: (token: string, owner: string) => PreviewPayload | null
|
|
40
|
-
size: () => number
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export interface PreviewStoreOptions {
|
|
44
|
-
ttlMs?: number
|
|
45
|
-
max?: number
|
|
46
|
-
now?: () => number
|
|
47
|
-
randomToken?: () => string
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
const TTL_MS = 10 * 60 * 1000
|
|
51
|
-
const MAX_TICKETS = 32
|
|
52
|
-
|
|
53
|
-
export function createPreviewStore(opts: PreviewStoreOptions = {}): PreviewStore {
|
|
54
|
-
const ttlMs = opts.ttlMs ?? TTL_MS
|
|
55
|
-
const max = opts.max ?? MAX_TICKETS
|
|
56
|
-
const now = opts.now ?? Date.now
|
|
57
|
-
const randomToken = opts.randomToken ?? (() => randomBytes(24).toString('base64url'))
|
|
58
|
-
// Insertion-ordered, which is what makes "evict the oldest" a single `keys().next()`.
|
|
59
|
-
const tickets = new Map<string, { owner: string; payload: PreviewPayload; expiresAt: number }>()
|
|
60
|
-
|
|
61
|
-
function sweep(at: number): void {
|
|
62
|
-
for (const [token, t] of tickets) if (t.expiresAt <= at) tickets.delete(token)
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
return {
|
|
66
|
-
mint(owner, payload) {
|
|
67
|
-
const at = now()
|
|
68
|
-
sweep(at)
|
|
69
|
-
while (tickets.size >= max) tickets.delete(tickets.keys().next().value as string)
|
|
70
|
-
const token = randomToken()
|
|
71
|
-
const expiresAt = at + ttlMs
|
|
72
|
-
tickets.set(token, { owner, payload, expiresAt })
|
|
73
|
-
return { token, expiresAt }
|
|
74
|
-
},
|
|
75
|
-
read(token, owner) {
|
|
76
|
-
const t = tickets.get(token)
|
|
77
|
-
if (!t) return null
|
|
78
|
-
if (t.expiresAt <= now()) {
|
|
79
|
-
tickets.delete(token)
|
|
80
|
-
return null
|
|
81
|
-
}
|
|
82
|
-
// Bound to the minting owner rather than trusted on token possession alone — inert while every admin
|
|
83
|
-
// caller resolves to the same owner (see the module docstring), but the check a future per-user
|
|
84
|
-
// identity would need is already the one being run, not one that would need to be added later.
|
|
85
|
-
return t.owner === owner ? t.payload : null
|
|
86
|
-
},
|
|
87
|
-
size: () => tickets.size,
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Who a ticket belongs to. In production this only ever runs after `requireAdmin(event)` has already
|
|
93
|
-
* thrown for anyone but the admin principal, and `derivePrincipal` (access layer) always gives that
|
|
94
|
-
* principal a fixed `userId: 'admin'` — so the first branch always wins and this always returns the
|
|
95
|
-
* literal `'admin'`. The `role` / `'anonymous'` fallbacks are unreached by any principal shape
|
|
96
|
-
* `derivePrincipal` produces today; kept as a defensive default rather than a non-null assertion, since
|
|
97
|
-
* this function has no way to enforce that invariant itself.
|
|
98
|
-
*/
|
|
99
|
-
export function previewOwner(event: H3Event): string {
|
|
100
|
-
const principal = event.context.principal as { userId?: string | null; role?: string } | undefined
|
|
101
|
-
return principal?.userId ?? principal?.role ?? 'anonymous'
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/** The process-wide store the two `/api/preview` handlers share. */
|
|
105
|
-
let shared: PreviewStore | null = null
|
|
106
|
-
export function usePreviewStore(): PreviewStore {
|
|
107
|
-
shared ??= createPreviewStore()
|
|
108
|
-
return shared
|
|
109
|
-
}
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
import type { Invalidation } from './invalidation'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Merge a burst of invalidations collected during a debounce window into one: any `full` wins (a
|
|
5
|
-
* full rebuild subsumes everything); otherwise union the `tags`/`render`/`prune` sets (dedup, stable
|
|
6
|
-
* insertion order); all-`noop` (or empty) collapses to `noop`.
|
|
7
|
-
*/
|
|
8
|
-
export function coalesce(items: Invalidation[]): Invalidation {
|
|
9
|
-
if (items.some((i) => i.type === 'full')) return { type: 'full' }
|
|
10
|
-
const tags = new Set<string>()
|
|
11
|
-
const render = new Set<string>()
|
|
12
|
-
const prune = new Set<string>()
|
|
13
|
-
let any = false
|
|
14
|
-
for (const i of items) {
|
|
15
|
-
if (i.type !== 'tags') continue
|
|
16
|
-
any = true
|
|
17
|
-
for (const t of i.tags) tags.add(t)
|
|
18
|
-
for (const r of i.render) render.add(r)
|
|
19
|
-
for (const p of i.prune) prune.add(p)
|
|
20
|
-
}
|
|
21
|
-
return any ? { type: 'tags', tags: [...tags], render: [...render], prune: [...prune] } : { type: 'noop' }
|
|
22
|
-
}
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
import { eq } from 'drizzle-orm'
|
|
2
|
-
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
|
|
3
|
-
import { publishDeps } from '../../database/publish-deps'
|
|
4
|
-
import type { DepsPersistence } from './deps'
|
|
5
|
-
|
|
6
|
-
/** No-op persistence: a `DepsStore` backed by it runs purely in-memory. */
|
|
7
|
-
const inMemoryOnly: DepsPersistence = { load: () => [], save: () => {}, remove: () => {}, clearAll: () => {} }
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* SQLite-backed durable persistence for the publish deps index. Takes the drizzle db as an argument (the
|
|
11
|
-
* caller passes `useDb()`) so this stays a pure function of an injected db — fully unit-testable against an
|
|
12
|
-
* in-memory database, no auto-import in the module. Tags are stored JSON-encoded (a small bounded set per
|
|
13
|
-
* route).
|
|
14
|
-
*
|
|
15
|
-
* Probes the `publish_deps` table once on creation: if it is absent (a not-yet-migrated deploy — prod never
|
|
16
|
-
* auto-DDLs, so an operator must run `db:migrate`) it warns and returns an in-memory-only no-op rather than
|
|
17
|
-
* crashing the boot publish + every write. Full durability (cross-restart pruning) returns once the table
|
|
18
|
-
* exists. Probing once (not per-op) keeps ALL of load/save/remove/clearAll consistent — the boot publish
|
|
19
|
-
* calls `save()` per route, so a resilient `load()` alone was not enough.
|
|
20
|
-
*/
|
|
21
|
-
export function createSqlitePersistence(db: BetterSQLite3Database): DepsPersistence {
|
|
22
|
-
try {
|
|
23
|
-
db.select().from(publishDeps).limit(1).all()
|
|
24
|
-
} catch {
|
|
25
|
-
console.warn('[kestrel] publish_deps table is missing — run the `db:migrate` task. Falling back to in-memory deps (no cross-restart prune until migrated).')
|
|
26
|
-
return inMemoryOnly
|
|
27
|
-
}
|
|
28
|
-
return {
|
|
29
|
-
load() {
|
|
30
|
-
return db.select().from(publishDeps).all().map((r) => [r.route, JSON.parse(r.tags) as string[]] as const)
|
|
31
|
-
},
|
|
32
|
-
save(route, tags) {
|
|
33
|
-
const json = JSON.stringify([...tags])
|
|
34
|
-
db.insert(publishDeps).values({ route, tags: json }).onConflictDoUpdate({ target: publishDeps.route, set: { tags: json } }).run()
|
|
35
|
-
},
|
|
36
|
-
remove(route) {
|
|
37
|
-
db.delete(publishDeps).where(eq(publishDeps.route, route)).run()
|
|
38
|
-
},
|
|
39
|
-
clearAll() {
|
|
40
|
-
db.delete(publishDeps).run()
|
|
41
|
-
},
|
|
42
|
-
}
|
|
43
|
-
}
|
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Routes whose captured dependency tags intersect `changed`. The publisher captures, per published
|
|
3
|
-
* route, the set of data tags it read while rendering (`<coll>` for a `list`, `<coll>:<id>` for a
|
|
4
|
-
* `getOne`, `<coll>` for a singleton). On a write, the changed tags `{coll, coll:id}` map back to
|
|
5
|
-
* exactly the routes that embed that data — so editing one speaker re-renders its detail page + the
|
|
6
|
-
* overview that lists speakers, and nothing else. Pure.
|
|
7
|
-
*/
|
|
8
|
-
export function routesForTags(changed: Iterable<string>, index: Map<string, Set<string>>): string[] {
|
|
9
|
-
const want = new Set(changed)
|
|
10
|
-
const out: string[] = []
|
|
11
|
-
for (const [route, tags] of index) {
|
|
12
|
-
for (const t of tags) {
|
|
13
|
-
if (want.has(t)) { out.push(route); break }
|
|
14
|
-
}
|
|
15
|
-
}
|
|
16
|
-
return out
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Routes the publisher previously wrote (tracked in the deps index) that are no longer in the published
|
|
21
|
-
* set — a page that was unpublished, deleted, or whose slug changed. Their static files must be removed
|
|
22
|
-
* on the next full publish. Pure; the diff drives a targeted prune (it only ever deletes files this
|
|
23
|
-
* publisher created — output ≡ DB, no opt-in toggle).
|
|
24
|
-
*/
|
|
25
|
-
export function staleRoutes(tracked: Iterable<string>, publishedNow: Iterable<string>): string[] {
|
|
26
|
-
const live = new Set(publishedNow)
|
|
27
|
-
return [...tracked].filter((route) => !live.has(route))
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Durable backing for the deps index: a write-through `route → tags` store that survives a restart.
|
|
32
|
-
* The in-memory index is the (synchronous) query layer; this port persists every mutation and is read
|
|
33
|
-
* back once on construction to rehydrate. Kept abstract so `DepsStore` stays pure-unit-testable (a fake
|
|
34
|
-
* port in node tests) while production injects the SQLite-backed adapter (`createSqlitePersistence`).
|
|
35
|
-
*/
|
|
36
|
-
export interface DepsPersistence {
|
|
37
|
-
/** Every persisted `route → tags` pair (read once on DepsStore construction to rehydrate). */
|
|
38
|
-
load(): Iterable<readonly [string, Iterable<string>]>
|
|
39
|
-
/** Persist (upsert) a route's tag set. */
|
|
40
|
-
save(route: string, tags: Iterable<string>): void
|
|
41
|
-
/** Remove a route. */
|
|
42
|
-
remove(route: string): void
|
|
43
|
-
/** Drop every route. */
|
|
44
|
-
clearAll(): void
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* In-memory `route → tags` index, populated as routes are published and queried on each write — the fast
|
|
49
|
-
* synchronous query layer. When constructed with a `DepsPersistence`, the index is rehydrated from it on
|
|
50
|
-
* boot and every mutation is written through, so the store survives a restart: a boot full-publish's
|
|
51
|
-
* `staleRoutes` can then prune pages that were unpublished/deleted while the server was down.
|
|
52
|
-
*/
|
|
53
|
-
export class DepsStore {
|
|
54
|
-
private index = new Map<string, Set<string>>()
|
|
55
|
-
|
|
56
|
-
constructor(private persistence?: DepsPersistence) {
|
|
57
|
-
if (persistence) for (const [route, tags] of persistence.load()) this.index.set(route, new Set(tags))
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/** Replace the tag set captured for `route`. */
|
|
61
|
-
record(route: string, tags: Iterable<string>): void {
|
|
62
|
-
const set = new Set(tags)
|
|
63
|
-
this.index.set(route, set)
|
|
64
|
-
this.persistence?.save(route, set)
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/** Routes whose tags intersect `changed`. */
|
|
68
|
-
routesForTags(changed: Iterable<string>): string[] {
|
|
69
|
-
return routesForTags(changed, this.index)
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** Every route with a captured tag set. */
|
|
73
|
-
routes(): string[] {
|
|
74
|
-
return [...this.index.keys()]
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/** Drop `route` from the index (on prune/unpublish). */
|
|
78
|
-
forget(route: string): void {
|
|
79
|
-
this.index.delete(route)
|
|
80
|
-
this.persistence?.remove(route)
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
clear(): void {
|
|
84
|
-
this.index.clear()
|
|
85
|
-
this.persistence?.clearAll()
|
|
86
|
-
}
|
|
87
|
-
}
|