@michaelthielemann/kestrel 1.2.1
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/LICENSE +201 -0
- package/NOTICE +12 -0
- package/README.md +234 -0
- package/kestrel.config.ts +28 -0
- package/layers/access/nuxt.config.ts +5 -0
- package/layers/access/server/middleware/00.ip-allowlist.ts +67 -0
- package/layers/access/server/middleware/access-guard.ts +30 -0
- package/layers/access/server/types.d.ts +8 -0
- package/layers/access/server/utils/csrf.ts +39 -0
- package/layers/access/server/utils/grant-registry.ts +36 -0
- package/layers/access/server/utils/guard.ts +44 -0
- package/layers/access/server/utils/ip-allowlist.ts +76 -0
- package/layers/access/server/utils/policy.ts +98 -0
- package/layers/access/server/utils/public-resources.ts +11 -0
- package/layers/access/server/utils/render-context.ts +41 -0
- package/layers/access/server/utils/require-admin.ts +15 -0
- package/layers/admin/app/components/AdminAccount.vue +212 -0
- package/layers/admin/app/components/AdminNav.vue +44 -0
- package/layers/admin/app/components/BlockFields.vue +68 -0
- package/layers/admin/app/components/BlockPreview.vue +401 -0
- package/layers/admin/app/components/BlockTree.vue +433 -0
- package/layers/admin/app/components/BlocksBody.vue +162 -0
- package/layers/admin/app/components/CollectionDeleteDialog.vue +38 -0
- package/layers/admin/app/components/CollectionEditor.vue +203 -0
- package/layers/admin/app/components/CollectionList.vue +1091 -0
- package/layers/admin/app/components/EditorStatus.vue +199 -0
- package/layers/admin/app/components/EditorUnsupported.vue +28 -0
- package/layers/admin/app/components/FieldsBody.vue +23 -0
- package/layers/admin/app/components/LocaleBar.vue +105 -0
- package/layers/admin/app/components/PageFields.vue +101 -0
- package/layers/admin/app/components/PageFieldsPane.vue +39 -0
- package/layers/admin/app/components/SeoFields.vue +169 -0
- package/layers/admin/app/components/SingletonEditor.vue +92 -0
- package/layers/admin/app/composables/useAuth.ts +68 -0
- package/layers/admin/app/composables/useBlockTree.ts +95 -0
- package/layers/admin/app/composables/useBlocks.ts +7 -0
- package/layers/admin/app/composables/useCachedResource.ts +35 -0
- package/layers/admin/app/composables/useCollectionOps.ts +53 -0
- package/layers/admin/app/composables/useCollections.ts +7 -0
- package/layers/admin/app/composables/useContentLocales.ts +23 -0
- package/layers/admin/app/composables/useEditForm.ts +370 -0
- package/layers/admin/app/composables/useLinkResolver.ts +40 -0
- package/layers/admin/app/composables/useListColumns.ts +53 -0
- package/layers/admin/app/composables/useListUrlState.ts +53 -0
- package/layers/admin/app/composables/usePendingPublishPoll.ts +15 -0
- package/layers/admin/app/composables/usePreviewViewport.ts +30 -0
- package/layers/admin/app/composables/usePublishStatus.ts +98 -0
- package/layers/admin/app/composables/useRailCollapsed.ts +14 -0
- package/layers/admin/app/composables/useTheme.ts +36 -0
- package/layers/admin/app/composables/useUnsavedGuard.ts +22 -0
- package/layers/admin/app/layouts/admin.vue +267 -0
- package/layers/admin/app/middleware/admin-auth.ts +4 -0
- package/layers/admin/app/pages/admin/[collection]/[id].vue +202 -0
- package/layers/admin/app/pages/admin/[collection]/index.vue +62 -0
- package/layers/admin/app/pages/admin/index.vue +118 -0
- package/layers/admin/app/pages/admin/login.vue +68 -0
- package/layers/admin/app/pages/admin/media.vue +28 -0
- package/layers/admin/app/pages/admin/references.vue +146 -0
- package/layers/admin/app/plugins/admin-style-guard.client.ts +76 -0
- package/layers/admin/app/plugins/reauth.client.ts +20 -0
- package/layers/admin/app/utils/admin-style-guard.ts +20 -0
- package/layers/admin/app/utils/block-tree.ts +214 -0
- package/layers/admin/app/utils/collection-ops.ts +44 -0
- package/layers/admin/app/utils/dead-refs.ts +30 -0
- package/layers/admin/app/utils/edit-form.ts +223 -0
- package/layers/admin/app/utils/editor-expose.ts +26 -0
- package/layers/admin/app/utils/editor-form-context.ts +83 -0
- package/layers/admin/app/utils/editor-registry.ts +20 -0
- package/layers/admin/app/utils/field-keys.ts +11 -0
- package/layers/admin/app/utils/list-columns.ts +141 -0
- package/layers/admin/app/utils/list-query.ts +96 -0
- package/layers/admin/app/utils/nav-active.ts +9 -0
- package/layers/admin/app/utils/populate-blocks.ts +288 -0
- package/layers/admin/app/utils/preview-channel.ts +62 -0
- package/layers/admin/app/utils/preview-viewport.ts +80 -0
- package/layers/admin/app/utils/reauth.ts +68 -0
- package/layers/admin/app/utils/record-title.ts +15 -0
- package/layers/admin/app/utils/register-builtin-editors.ts +9 -0
- package/layers/admin/app/utils/safe-redirect.ts +12 -0
- package/layers/admin/nuxt.config.ts +17 -0
- package/layers/auth/nuxt.config.ts +1 -0
- package/layers/auth/server/api/auth/login.post.ts +38 -0
- package/layers/auth/server/api/auth/logout.post.ts +9 -0
- package/layers/auth/server/api/auth/session.get.ts +1 -0
- package/layers/auth/server/utils/admin-credential.ts +42 -0
- package/layers/auth/server/utils/client-ip.ts +88 -0
- package/layers/auth/server/utils/login-throttle.ts +126 -0
- package/layers/auth/server/utils/password.ts +52 -0
- package/layers/auth/server/utils/session-cookie.ts +44 -0
- package/layers/auth/server/utils/session-epoch.ts +96 -0
- package/layers/auth/server/utils/session.ts +85 -0
- package/layers/collections/nuxt.config.ts +1 -0
- package/layers/collections/server/collections/pages.ts +19 -0
- package/layers/collections/server/plugins/01.register.ts +32 -0
- package/layers/collections/server/plugins/02.register-relation-populate.ts +17 -0
- package/layers/collections/server/utils/populate-relations.ts +66 -0
- package/layers/core/app/utils/bulk-actions.ts +9 -0
- package/layers/core/app/utils/filter-ops.ts +98 -0
- package/layers/core/app/utils/list-limits.ts +27 -0
- package/layers/core/app/utils/locale-path.ts +12 -0
- package/layers/core/app/utils/slugify.ts +16 -0
- package/layers/core/modules/auto-discovery/extract-block.ts +176 -0
- package/layers/core/modules/auto-discovery/index.ts +44 -0
- package/layers/core/modules/auto-discovery/scan.ts +78 -0
- package/layers/core/modules/auto-discovery/virtual.d.ts +12 -0
- package/layers/core/modules/kestrel/index.ts +86 -0
- package/layers/core/nuxt.config.ts +8 -0
- package/layers/core/server/api/[collection]/[id]/dead-refs.get.ts +8 -0
- package/layers/core/server/api/[collection]/[id]/translations.get.ts +4 -0
- package/layers/core/server/api/[collection]/[id].delete.ts +5 -0
- package/layers/core/server/api/[collection]/[id].get.ts +8 -0
- package/layers/core/server/api/[collection]/[id].patch.ts +8 -0
- package/layers/core/server/api/[collection]/bulk.post.ts +37 -0
- package/layers/core/server/api/[collection]/index.get.ts +19 -0
- package/layers/core/server/api/[collection]/index.post.ts +8 -0
- package/layers/core/server/api/[collection]/index.put.ts +7 -0
- package/layers/core/server/api/[collection]/options.get.ts +18 -0
- package/layers/core/server/api/[collection]/translations.get.ts +25 -0
- package/layers/core/server/api/collections/[name].get.ts +4 -0
- package/layers/core/server/api/collections/index.get.ts +3 -0
- package/layers/core/server/api/references/broken.get.ts +3 -0
- package/layers/core/server/api/references/referrers.get.ts +24 -0
- package/layers/core/server/database/record-refs.ts +20 -0
- package/layers/core/server/plugins/00.migrate.ts +11 -0
- package/layers/core/server/plugins/02.schema-sync.ts +63 -0
- package/layers/core/server/plugins/03.record-refs.ts +12 -0
- package/layers/core/server/schema/bootstrap.ts +41 -0
- package/layers/core/server/schema/desired.ts +99 -0
- package/layers/core/server/schema/dev-sync.ts +23 -0
- package/layers/core/server/schema/dialect.ts +59 -0
- package/layers/core/server/schema/diff.ts +110 -0
- package/layers/core/server/schema/introspect.ts +67 -0
- package/layers/core/server/schema/model.ts +61 -0
- package/layers/core/server/schema/render-sqlite.ts +89 -0
- package/layers/core/server/schema/sync.ts +201 -0
- package/layers/core/server/tasks/db/migrate.ts +54 -0
- package/layers/core/server/utils/blocks.ts +6 -0
- package/layers/core/server/utils/collection-types.ts +27 -0
- package/layers/core/server/utils/crud.ts +577 -0
- package/layers/core/server/utils/db.ts +26 -0
- package/layers/core/server/utils/defineCollection.ts +207 -0
- package/layers/core/server/utils/duplicate.ts +162 -0
- package/layers/core/server/utils/field-layout.ts +125 -0
- package/layers/core/server/utils/filter-predicate.ts +101 -0
- package/layers/core/server/utils/http.ts +50 -0
- package/layers/core/server/utils/if-unmodified.ts +13 -0
- package/layers/core/server/utils/kestrel-config.ts +410 -0
- package/layers/core/server/utils/key-lock.ts +33 -0
- package/layers/core/server/utils/locale.ts +53 -0
- package/layers/core/server/utils/page-route.ts +58 -0
- package/layers/core/server/utils/page-slug.ts +127 -0
- package/layers/core/server/utils/picker.ts +82 -0
- package/layers/core/server/utils/populate.ts +48 -0
- package/layers/core/server/utils/prerender-memo.ts +23 -0
- package/layers/core/server/utils/read-capture.ts +37 -0
- package/layers/core/server/utils/record-ref-index.ts +200 -0
- package/layers/core/server/utils/registry.ts +24 -0
- package/layers/core/server/utils/resolve-scope.ts +79 -0
- package/layers/core/server/utils/seo.ts +24 -0
- package/layers/core/server/utils/serialize-collection.ts +149 -0
- package/layers/core/server/utils/server-config.ts +18 -0
- package/layers/core/server/utils/sql.ts +2 -0
- package/layers/core/server/utils/storage.local.ts +117 -0
- package/layers/core/server/utils/storage.s3.ts +197 -0
- package/layers/core/server/utils/storage.ts +90 -0
- package/layers/core/server/utils/write-events.ts +31 -0
- package/layers/fields/app/utils/condition.ts +71 -0
- package/layers/fields/app/utils/define-block.ts +13 -0
- package/layers/fields/app/utils/field-constraints.ts +17 -0
- package/layers/fields/app/utils/field-factories.ts +129 -0
- package/layers/fields/app/utils/field-meta.ts +33 -0
- package/layers/fields/app/utils/field-validate.ts +75 -0
- package/layers/fields/app/utils/field-value.ts +7 -0
- package/layers/fields/app/utils/richtext-links.ts +48 -0
- package/layers/fields/nuxt.config.ts +1 -0
- package/layers/fields/server/api/blocks/index.get.ts +9 -0
- package/layers/fields/server/field-registry/index.ts +248 -0
- package/layers/fields/server/field-registry/naming.ts +10 -0
- package/layers/fields/server/field-registry/sanitize.ts +40 -0
- package/layers/fields/server/field-registry/types.ts +19 -0
- package/layers/fields/server/plugins/01.register-field-populate.ts +11 -0
- package/layers/fields/server/utils/block-ids.ts +43 -0
- package/layers/fields/server/utils/block-populate.ts +41 -0
- package/layers/fields/server/utils/buildCollection.ts +88 -0
- package/layers/fields/server/utils/buildFieldSchema.ts +22 -0
- package/layers/fields/server/utils/buildTable.ts +107 -0
- package/layers/fields/server/utils/conditional-required.ts +25 -0
- package/layers/fields/server/utils/defineBlock.ts +86 -0
- package/layers/fields/server/utils/defineFieldType.ts +33 -0
- package/layers/fields/server/utils/extract-refs.ts +152 -0
- package/layers/fields/server/utils/field-populate.ts +68 -0
- package/layers/media/app/components/KestrelImg.vue +65 -0
- package/layers/media/app/components/MediaConflictDialog.vue +35 -0
- package/layers/media/app/components/MediaDeleteDialog.vue +52 -0
- package/layers/media/app/components/MediaGrid.vue +113 -0
- package/layers/media/app/components/MediaImage.vue +70 -0
- package/layers/media/app/components/MediaLibrary.vue +451 -0
- package/layers/media/app/components/MediaNewFolderDialog.vue +32 -0
- package/layers/media/app/components/MediaPathBar.vue +163 -0
- package/layers/media/app/components/MediaPicker.vue +19 -0
- package/layers/media/app/components/MediaRenameDialog.vue +32 -0
- package/layers/media/app/components/MediaTable.vue +75 -0
- package/layers/media/app/components/MediaThumb.vue +25 -0
- package/layers/media/app/components/MediaToolbar.vue +90 -0
- package/layers/media/app/components/MediaUploadDialog.vue +87 -0
- package/layers/media/app/components/MediaViewer.vue +88 -0
- package/layers/media/app/components/field/Media.vue +261 -0
- package/layers/media/app/composables/useBlurUp.ts +55 -0
- package/layers/media/app/composables/useMediaClipboard.ts +14 -0
- package/layers/media/app/composables/useMediaContextMenu.ts +56 -0
- package/layers/media/app/composables/useMediaDnd.ts +56 -0
- package/layers/media/app/composables/useMediaLibrary.ts +151 -0
- package/layers/media/app/composables/useMediaOps.ts +50 -0
- package/layers/media/app/composables/useMediaRelocate.ts +39 -0
- package/layers/media/app/composables/useMediaResolver.ts +50 -0
- package/layers/media/app/composables/useMediaUpload.ts +93 -0
- package/layers/media/app/composables/useMediaVariant.ts +34 -0
- package/layers/media/app/plugins/field-media.client.ts +6 -0
- package/layers/media/app/utils/dnd.ts +69 -0
- package/layers/media/app/utils/library.ts +79 -0
- package/layers/media/app/utils/ops.ts +62 -0
- package/layers/media/app/utils/picture-model.ts +125 -0
- package/layers/media/app/utils/thumbhash.ts +19 -0
- package/layers/media/app/utils/variant-name.ts +20 -0
- package/layers/media/nuxt.config.ts +3 -0
- package/layers/media/server/api/media/[id]/usages.get.ts +8 -0
- package/layers/media/server/api/media/[id].delete.ts +18 -0
- package/layers/media/server/api/media/[id].get.ts +10 -0
- package/layers/media/server/api/media/[id].patch.ts +41 -0
- package/layers/media/server/api/media/backfill.post.ts +18 -0
- package/layers/media/server/api/media/copy.post.ts +7 -0
- package/layers/media/server/api/media/delete.post.ts +13 -0
- package/layers/media/server/api/media/folders.post.ts +18 -0
- package/layers/media/server/api/media/index.get.ts +15 -0
- package/layers/media/server/api/media/index.post.ts +157 -0
- package/layers/media/server/api/media/library.get.ts +17 -0
- package/layers/media/server/api/media/move.post.ts +7 -0
- package/layers/media/server/api/media/rename.post.ts +7 -0
- package/layers/media/server/api/media/resolve.get.ts +13 -0
- package/layers/media/server/collections/media-settings.ts +25 -0
- package/layers/media/server/collections/media.ts +29 -0
- package/layers/media/server/database/folders.ts +10 -0
- package/layers/media/server/middleware/ondemand-variants.ts +37 -0
- package/layers/media/server/plugins/02.register-media.ts +19 -0
- package/layers/media/server/plugins/03.media-cleanup.ts +20 -0
- package/layers/media/server/plugins/04.variant-capture.ts +23 -0
- package/layers/media/server/schema-tables/folders.ts +4 -0
- package/layers/media/server/tasks/media/backfill.ts +18 -0
- package/layers/media/server/utils/backfill.ts +132 -0
- package/layers/media/server/utils/derive.ts +59 -0
- package/layers/media/server/utils/folder-paths.ts +42 -0
- package/layers/media/server/utils/folders.ts +12 -0
- package/layers/media/server/utils/library.ts +90 -0
- package/layers/media/server/utils/media-cleanup.ts +27 -0
- package/layers/media/server/utils/media-enabled.ts +23 -0
- package/layers/media/server/utils/media-ops.ts +118 -0
- package/layers/media/server/utils/media-write.ts +13 -0
- package/layers/media/server/utils/naming.ts +52 -0
- package/layers/media/server/utils/ondemand.ts +69 -0
- package/layers/media/server/utils/persist-upload.ts +62 -0
- package/layers/media/server/utils/populate.ts +47 -0
- package/layers/media/server/utils/record.ts +63 -0
- package/layers/media/server/utils/relocate-ops.ts +341 -0
- package/layers/media/server/utils/relocate-plan.ts +63 -0
- package/layers/media/server/utils/resolve.ts +86 -0
- package/layers/media/server/utils/sanitize-svg.ts +12 -0
- package/layers/media/server/utils/sniff.ts +68 -0
- package/layers/media/server/utils/storage-relocate.ts +139 -0
- package/layers/media/server/utils/translations.ts +19 -0
- package/layers/media/server/utils/usages.ts +66 -0
- package/layers/media/server/utils/variant-capture.ts +70 -0
- package/layers/media/server/utils/variants.ts +108 -0
- package/layers/public/app/app.vue +5 -0
- package/layers/public/app/components/BlockRenderer.vue +56 -0
- package/layers/public/app/components/KestrelLink.vue +26 -0
- package/layers/public/app/components/KestrelPreviewBridge.vue +133 -0
- package/layers/public/app/composables/public-page.ts +24 -0
- package/layers/public/app/error.vue +12 -0
- package/layers/public/app/layouts/default.vue +5 -0
- package/layers/public/app/pages/[...slug].vue +157 -0
- package/layers/public/app/pages/__kestrel/preview.vue +34 -0
- package/layers/public/app/utils/block-edit-context.ts +16 -0
- package/layers/public/app/utils/link-href.ts +46 -0
- package/layers/public/app/utils/page-head.ts +84 -0
- package/layers/public/app/utils/preview-protocol.ts +81 -0
- package/layers/public/app/utils/public-route.ts +46 -0
- package/layers/public/modules/deploy-output/deploy-output.ts +370 -0
- package/layers/public/modules/deploy-output/index.ts +82 -0
- package/layers/public/modules/prerender-routes/discover.ts +37 -0
- package/layers/public/modules/prerender-routes/index.ts +85 -0
- package/layers/public/modules/prerender-routes/routes.ts +16 -0
- package/layers/public/modules/prune-media/index.ts +48 -0
- package/layers/public/modules/prune-media/prune-media.ts +81 -0
- package/layers/public/nuxt.config.ts +11 -0
- package/layers/public/server/api/links/resolve.get.ts +19 -0
- package/layers/public/server/api/publish-status.get.ts +44 -0
- package/layers/public/server/api/route.get.ts +18 -0
- package/layers/public/server/database/publish-deps.ts +12 -0
- package/layers/public/server/database/publish-status.ts +19 -0
- package/layers/public/server/plugins/02.register-links.ts +17 -0
- package/layers/public/server/plugins/zz.publish.ts +63 -0
- package/layers/public/server/routes/llms.txt.get.ts +72 -0
- package/layers/public/server/routes/robots.txt.get.ts +8 -0
- package/layers/public/server/routes/sitemap.xml.get.ts +66 -0
- package/layers/public/server/schema-tables/publish-deps.ts +4 -0
- package/layers/public/server/schema-tables/publish-status.ts +4 -0
- package/layers/public/server/tasks/publish/run.ts +26 -0
- package/layers/public/server/utils/link-resolve.ts +35 -0
- package/layers/public/server/utils/llms.ts +39 -0
- package/layers/public/server/utils/page-resolve.ts +86 -0
- package/layers/public/server/utils/populate-links.ts +66 -0
- package/layers/public/server/utils/publish/coalesce.ts +22 -0
- package/layers/public/server/utils/publish/deps-persistence.ts +43 -0
- package/layers/public/server/utils/publish/deps.ts +87 -0
- package/layers/public/server/utils/publish/invalidation.ts +141 -0
- package/layers/public/server/utils/publish/publish-status.ts +54 -0
- package/layers/public/server/utils/publish/publisher.ts +291 -0
- package/layers/public/server/utils/publish/queue.ts +67 -0
- package/layers/public/server/utils/publish/route-for-record.ts +16 -0
- package/layers/public/server/utils/publish/route-keys.ts +13 -0
- package/layers/public/server/utils/site-url.ts +32 -0
- package/layers/public/server/utils/sitemap.ts +97 -0
- package/layers/ui/app/assets/scss/_base.scss +16 -0
- package/layers/ui/app/assets/scss/_datepicker.scss +82 -0
- package/layers/ui/app/assets/scss/_mixins.scss +50 -0
- package/layers/ui/app/assets/scss/_reset.scss +65 -0
- package/layers/ui/app/assets/scss/_tokens.scss +183 -0
- package/layers/ui/app/assets/scss/main.scss +4 -0
- package/layers/ui/app/components/field/Boolean.vue +30 -0
- package/layers/ui/app/components/field/Choice.vue +68 -0
- package/layers/ui/app/components/field/Datetime.vue +96 -0
- package/layers/ui/app/components/field/DeadRefNote.vue +28 -0
- package/layers/ui/app/components/field/Json.vue +63 -0
- package/layers/ui/app/components/field/Layout.vue +140 -0
- package/layers/ui/app/components/field/Link.vue +112 -0
- package/layers/ui/app/components/field/LinkInternalPicker.vue +95 -0
- package/layers/ui/app/components/field/Number.vue +30 -0
- package/layers/ui/app/components/field/Relation.vue +49 -0
- package/layers/ui/app/components/field/Renderer.vue +30 -0
- package/layers/ui/app/components/field/Repeater.vue +460 -0
- package/layers/ui/app/components/field/Richtext.vue +26 -0
- package/layers/ui/app/components/field/Slug.vue +66 -0
- package/layers/ui/app/components/field/Text.vue +35 -0
- package/layers/ui/app/components/field/Unsupported.vue +34 -0
- package/layers/ui/app/components/ui/Alert.vue +50 -0
- package/layers/ui/app/components/ui/Brand.vue +22 -0
- package/layers/ui/app/components/ui/Button.vue +116 -0
- package/layers/ui/app/components/ui/ButtonGroup.vue +96 -0
- package/layers/ui/app/components/ui/Checkbox.vue +52 -0
- package/layers/ui/app/components/ui/CheckboxGroup.vue +50 -0
- package/layers/ui/app/components/ui/Combobox.vue +343 -0
- package/layers/ui/app/components/ui/DatePicker.vue +92 -0
- package/layers/ui/app/components/ui/DateRangePicker.vue +100 -0
- package/layers/ui/app/components/ui/Dialog.vue +107 -0
- package/layers/ui/app/components/ui/EmptyState.vue +46 -0
- package/layers/ui/app/components/ui/Field.vue +57 -0
- package/layers/ui/app/components/ui/Fieldset.vue +64 -0
- package/layers/ui/app/components/ui/Icon.vue +44 -0
- package/layers/ui/app/components/ui/Menu.vue +54 -0
- package/layers/ui/app/components/ui/NumberInput.vue +173 -0
- package/layers/ui/app/components/ui/Richtext.vue +119 -0
- package/layers/ui/app/components/ui/RichtextToolbar.vue +253 -0
- package/layers/ui/app/components/ui/Select.vue +51 -0
- package/layers/ui/app/components/ui/TextInput.vue +110 -0
- package/layers/ui/app/components/ui/Textarea.vue +38 -0
- package/layers/ui/app/components/ui/TimeInput.vue +17 -0
- package/layers/ui/app/components/ui/Toasts.vue +103 -0
- package/layers/ui/app/components/ui/Tooltip.vue +45 -0
- package/layers/ui/app/composables/useAdminLang.ts +8 -0
- package/layers/ui/app/composables/useDragReorder.ts +41 -0
- package/layers/ui/app/composables/useEchoGuard.ts +28 -0
- package/layers/ui/app/composables/useLinkField.ts +70 -0
- package/layers/ui/app/composables/useRecordOptions.ts +84 -0
- package/layers/ui/app/composables/useRepeater.ts +99 -0
- package/layers/ui/app/composables/useT.ts +32 -0
- package/layers/ui/app/composables/useToast.ts +46 -0
- package/layers/ui/app/i18n/de.ts +406 -0
- package/layers/ui/app/i18n/en.ts +440 -0
- package/layers/ui/app/utils/boolean-field.ts +10 -0
- package/layers/ui/app/utils/field-component.ts +22 -0
- package/layers/ui/app/utils/field-empty.ts +21 -0
- package/layers/ui/app/utils/field-registry.ts +36 -0
- package/layers/ui/app/utils/humanize.ts +14 -0
- package/layers/ui/app/utils/icons.ts +147 -0
- package/layers/ui/app/utils/iso-date.ts +18 -0
- package/layers/ui/app/utils/localized.ts +13 -0
- package/layers/ui/app/utils/record-context.ts +15 -0
- package/layers/ui/app/utils/reorder.ts +8 -0
- package/layers/ui/app/utils/richtext-preserve-class.ts +31 -0
- package/layers/ui/app/utils/useFieldA11y.ts +40 -0
- package/layers/ui/nuxt.config.ts +5 -0
- package/nuxt.config.ts +105 -0
- package/package.json +122 -0
- package/scripts/hash-password.mjs +26 -0
- package/scripts/typecheck.mjs +51 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { 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 type { BuiltCollection } from '../../../core/server/utils/collection-types'
|
|
5
|
+
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
|
|
6
|
+
|
|
7
|
+
export interface PageAlternate { locale: string; path: string }
|
|
8
|
+
export interface ResolvedPage { collection: string; page: Record<string, unknown>; alternates: PageAlternate[] }
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The page's published, INDEXABLE translation siblings (self included) as locale→path pairs — the hreflang
|
|
12
|
+
* set the public head emits. Mirrors the sitemap's rules exactly so the two never disagree: published-only
|
|
13
|
+
* (a draft URL must never be advertised to crawlers), `seo.noindex` variants EXCLUDED (advertising an
|
|
14
|
+
* hreflang to a noindexed page is a conflicting signal), null-path rows skipped, and a single-member group
|
|
15
|
+
* returns [] (hreflang is meaningless for a lone page). EVERY sibling in the group is `captureRead`-tagged,
|
|
16
|
+
* filtered-out ones included, so an incremental publish re-renders every group member when a sibling is
|
|
17
|
+
* renamed/published/unpublished — otherwise a baked page would keep a stale/dead hreflang href.
|
|
18
|
+
*/
|
|
19
|
+
function publishedAlternates(db: BetterSQLite3Database, c: BuiltCollection, page: Record<string, unknown>): PageAlternate[] {
|
|
20
|
+
if (c.def.mode !== 'multi' || !c.def.translatable) return []
|
|
21
|
+
const group = page.translationGroup
|
|
22
|
+
if (typeof group !== 'string' || !group) return []
|
|
23
|
+
const cols = getTableColumns(c.table) as Record<string, never>
|
|
24
|
+
// A status-less pageLike collection has no draft state — every sibling is "published".
|
|
25
|
+
const hasStatus = Object.hasOwn(cols, 'status')
|
|
26
|
+
const proj: Record<string, unknown> = { id: cols.id, locale: cols.locale, path: cols.path }
|
|
27
|
+
if (hasStatus) proj.status = cols.status
|
|
28
|
+
if (c.def.seo) proj.seo = cols.seo
|
|
29
|
+
// Read the WHOLE group and filter in JS: a row excluded by SQL is never captured, so a draft sibling
|
|
30
|
+
// would carry no dependency edge for the publish tag `<coll>:<id>` to match, and this page would keep a
|
|
31
|
+
// baked hreflang set missing the newly-published sibling until the next full generate.
|
|
32
|
+
const rows = db.select(proj as never)
|
|
33
|
+
.from(c.table)
|
|
34
|
+
.where(eq(cols.translationGroup, group))
|
|
35
|
+
.orderBy(asc(cols.locale))
|
|
36
|
+
.all() as Array<{ id: number; locale: string; path: unknown; status?: string; seo?: { noindex?: boolean } | null }>
|
|
37
|
+
const alternates: PageAlternate[] = []
|
|
38
|
+
let selfAdvertisable = false
|
|
39
|
+
for (const r of rows) {
|
|
40
|
+
// Capture the sibling as a publish dependency even if it is filtered out below — a draft/noindexed/
|
|
41
|
+
// null-path sibling that later becomes advertisable must still re-render this page's hreflang set.
|
|
42
|
+
captureRead(c.def.name, r.id)
|
|
43
|
+
if (hasStatus && r.status !== 'published') continue // a draft URL must never be advertised
|
|
44
|
+
if (typeof r.path !== 'string' || !r.path) continue // null/empty path — no advertisable URL
|
|
45
|
+
if (r.seo?.noindex) continue // noindexed — never advertise it via hreflang (matches the sitemap)
|
|
46
|
+
if (r.id === page.id) selfAdvertisable = true
|
|
47
|
+
alternates.push({ locale: r.locale, path: r.path })
|
|
48
|
+
}
|
|
49
|
+
// A hreflang set with no self-reference is a signal search engines ignore wholesale — if the page
|
|
50
|
+
// being rendered didn't itself survive the filters above, advertise nothing rather than an orphan set.
|
|
51
|
+
if (!selfAdvertisable) return []
|
|
52
|
+
return alternates.length >= 2 ? alternates : []
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The first page-like record (across all collections, in registration order) whose `path` matches,
|
|
57
|
+
* populated at depth 1 — or null. Reuses the access-scoped `list()` so media/links populate exactly as
|
|
58
|
+
* a direct collection read would. `publishedOnly` defaults true: a static render (prerender / runtime
|
|
59
|
+
* publisher) and an anonymous live request must never see drafts. The authenticated-admin live preview
|
|
60
|
+
* passes false to surface a draft at its real URL. Registration order is the precedence rule when two
|
|
61
|
+
* page-like collections happen to share a path.
|
|
62
|
+
*/
|
|
63
|
+
export function resolvePage(db: BetterSQLite3Database, collections: BuiltCollection[], path: string, locale?: string, publishedOnly = true): ResolvedPage | null {
|
|
64
|
+
for (const c of collections) {
|
|
65
|
+
if (!c.def.pageLike) continue
|
|
66
|
+
// withTotal:false — read only the first row, skip count(). capture:false — don't tag the whole
|
|
67
|
+
// collection here (the self-lookup is by path); instead tag the found record below, so this page
|
|
68
|
+
// depends on its OWN record, not on every sibling in the collection (precise invalidation).
|
|
69
|
+
let result: ReturnType<typeof list>
|
|
70
|
+
try {
|
|
71
|
+
result = list(db, c, { filter: [{ field: 'path', op: 'eq', value: path }], locale, depth: 1, perPage: 1, withTotal: false, capture: false }, publishedOnly)
|
|
72
|
+
} catch (error) {
|
|
73
|
+
// Table not migrated yet (e.g. a bare prerender DB) — isolate this collection's drift from the rest,
|
|
74
|
+
// but never silently: an unread collection is indistinguishable from one with no matching page, and
|
|
75
|
+
// the publisher would write a 404 over every one of its routes.
|
|
76
|
+
console.error(`[kestrel] resolvePage: skipped collection ${c.def.name}:`, (error as Error)?.message ?? error)
|
|
77
|
+
continue
|
|
78
|
+
}
|
|
79
|
+
const { data } = result
|
|
80
|
+
if (data.length) {
|
|
81
|
+
captureRead(c.def.name, (data[0] as { id?: number }).id ?? null)
|
|
82
|
+
return { collection: c.def.name, page: data[0]!, alternates: publishedAlternates(db, c, data[0]!) }
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return null
|
|
86
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { pageRowHref } from '../../../../core/server/utils/page-route'
|
|
2
|
+
|
|
3
|
+
/** The shape of the collection a write targets (the subset invalidation needs). */
|
|
4
|
+
export interface WriteCollection {
|
|
5
|
+
name: string
|
|
6
|
+
pageLike?: boolean
|
|
7
|
+
/** Whether the collection has a draft/published `status` column. */
|
|
8
|
+
status?: boolean
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
type Row = Record<string, unknown> | null
|
|
12
|
+
|
|
13
|
+
/** A content write reduced to what invalidation needs (computed by `classifyWrite`). */
|
|
14
|
+
export interface WriteClassification {
|
|
15
|
+
collection: string
|
|
16
|
+
pageLike: boolean
|
|
17
|
+
status: 'created' | 'updated' | 'deleted'
|
|
18
|
+
id: number | null
|
|
19
|
+
/** pageLike `path` changed (the old static file must be pruned, the new one written). */
|
|
20
|
+
pathChanged: boolean
|
|
21
|
+
/** published-ness changed (publish or unpublish — adds/removes the page + sitemap entry). */
|
|
22
|
+
statusChanged: boolean
|
|
23
|
+
isPublished: boolean
|
|
24
|
+
wasPublished: boolean
|
|
25
|
+
/** The page's own public route from the NEW row (pageLike + has a path), else null. */
|
|
26
|
+
selfRoute: string | null
|
|
27
|
+
/** The page's own public route from the OLD row (pageLike + had a path), else null — the route whose
|
|
28
|
+
* static file must be pruned on a slug change / unpublish / delete (symmetric to `selfRoute`). */
|
|
29
|
+
oldRoute: string | null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** What to republish for a write. Routes are resolved from `tags` against the captured deps index. */
|
|
33
|
+
export type Invalidation =
|
|
34
|
+
| { type: 'full' }
|
|
35
|
+
| { type: 'tags'; tags: string[]; render: string[]; prune: string[] }
|
|
36
|
+
| { type: 'noop' }
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The routes to actually delete from a coalesced invalidation: `prune` minus anything we just wrote live.
|
|
40
|
+
* A render and a prune for the SAME route can coalesce into one window (e.g. a new page created at /x while
|
|
41
|
+
* another page vacates /x) — pruning a freshly-rendered route would delete the live page, so render wins.
|
|
42
|
+
* Filtered against the ACTUALLY-rendered set (not the render candidates), so a route whose record is now a
|
|
43
|
+
* draft — skipped by the renderer — is still pruned.
|
|
44
|
+
*/
|
|
45
|
+
export function routesToPrune(prune: string[], rendered: string[]): string[] {
|
|
46
|
+
const live = new Set(rendered)
|
|
47
|
+
return prune.filter((r) => !live.has(r))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A collection with no `status` column is always public; otherwise public ⇔ status === 'published'. */
|
|
51
|
+
function isPublic(def: WriteCollection, row: Row): boolean {
|
|
52
|
+
if (!def.status) return true
|
|
53
|
+
return !!row && row.status === 'published'
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Reduce a before/after row pair (either may be null) to a `WriteClassification`. Pure (routes via the shared `pageRowHref`). */
|
|
57
|
+
export function classifyWrite(def: WriteCollection, before: Row, after: Row, primaryLocale: string, prefixPrimary = false): WriteClassification {
|
|
58
|
+
const status: WriteClassification['status'] = before === null ? 'created' : after === null ? 'deleted' : 'updated'
|
|
59
|
+
const row = after ?? before
|
|
60
|
+
const id = typeof row?.id === 'number' ? row.id : null
|
|
61
|
+
const pageLike = !!def.pageLike
|
|
62
|
+
|
|
63
|
+
const isPublished = isPublic(def, after)
|
|
64
|
+
const wasPublished = isPublic(def, before)
|
|
65
|
+
const statusChanged = status === 'updated' && !!def.status && isPublished !== wasPublished
|
|
66
|
+
|
|
67
|
+
const routeFrom = (r: Row): string | null => (pageLike ? pageRowHref(r, primaryLocale, prefixPrimary) : null)
|
|
68
|
+
const selfRoute = routeFrom(after)
|
|
69
|
+
const oldRoute = routeFrom(before)
|
|
70
|
+
// Compare the RESOLVED routes, not raw `path`: the route is localePath(path, locale), so a locale-only
|
|
71
|
+
// change (path unchanged) still moves the route — the old-locale file must be pruned.
|
|
72
|
+
const pathChanged = pageLike && status === 'updated' && oldRoute !== selfRoute
|
|
73
|
+
|
|
74
|
+
return { collection: def.name, pageLike, status, id, pathChanged, statusChanged, isPublished, wasPublished, selfRoute, oldRoute }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Decide what a write invalidates, per the maintainer-agreed model. Two notions of "dependent":
|
|
79
|
+
* - LISTINGS — pages that QUERY the collection (overviews) → captured as the `<coll>` tag.
|
|
80
|
+
* - EXPLICIT REFERRERS — pages that LINK/EMBED/relate-to a specific record → captured as `<coll>:<id>`.
|
|
81
|
+
*
|
|
82
|
+
* Two principles drive the split:
|
|
83
|
+
* 1. FRESHENING (content or path changed) re-renders BOTH listings and explicit referrers (`[coll, coll:id]`)
|
|
84
|
+
* so their output stays fresh.
|
|
85
|
+
* 2. AVAILABILITY (publish / unpublish / delete) re-renders listings (membership changed) AND the explicit
|
|
86
|
+
* referrers (`[coll, coll:id]`), because a referrer's BAKED output encodes the target's availability:
|
|
87
|
+
* link resolution is status-gated (a draft target bakes `href="#"`) and a page's hreflang set lists only
|
|
88
|
+
* its published translation siblings — both go dead/stale the moment availability flips, and only a
|
|
89
|
+
* re-render can fix them. A record's OWN artifact always matches its DB state (render on publish, prune
|
|
90
|
+
* on unpublish/delete). A draft that stays a draft is a no-op.
|
|
91
|
+
*
|
|
92
|
+
* `full` is reserved for the boot publish + the reconciler (enqueued directly), never produced here.
|
|
93
|
+
*/
|
|
94
|
+
export function planInvalidation(ev: WriteClassification): Invalidation {
|
|
95
|
+
const coll = ev.collection
|
|
96
|
+
const recordTag = ev.id != null ? `${coll}:${ev.id}` : null
|
|
97
|
+
const tags = recordTag ? [coll, recordTag] : [coll]
|
|
98
|
+
const selfRender = ev.pageLike && ev.selfRoute ? [ev.selfRoute] : []
|
|
99
|
+
|
|
100
|
+
// DELETE — leaves the collection. Listings re-render, referrers too (their baked link/hreflang now points
|
|
101
|
+
// at a route that is about to disappear); the own (pageLike) route is pruned.
|
|
102
|
+
if (ev.status === 'deleted') {
|
|
103
|
+
const prune = ev.pageLike && ev.oldRoute ? [ev.oldRoute] : []
|
|
104
|
+
return { type: 'tags', tags, render: [], prune }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// CREATE — joins the collection. A created draft produces no public output (noop); a created published
|
|
108
|
+
// record re-renders listings + its own route. No referrer can point at a brand-new id, so no `coll:id`.
|
|
109
|
+
if (ev.status === 'created') {
|
|
110
|
+
if (!ev.isPublished) return { type: 'noop' }
|
|
111
|
+
return { type: 'tags', tags: [coll], render: selfRender, prune: [] }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// UNPUBLISH — leaves the published set. Listings re-render; referrers re-render so their link falls back to
|
|
115
|
+
// '#' and their hreflang drops this sibling; the own route is pruned (the live file sits at the OLD path if
|
|
116
|
+
// this save also renamed).
|
|
117
|
+
if (ev.statusChanged && !ev.isPublished) {
|
|
118
|
+
const route = ev.oldRoute ?? ev.selfRoute
|
|
119
|
+
const prune = ev.pageLike && route ? [route] : []
|
|
120
|
+
return { type: 'tags', tags, render: [], prune }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// PUBLISH — joins the published set. Listings re-render; referrers re-render because a link to a draft
|
|
124
|
+
// resolves to '#' — their baked href only becomes the real path on a re-render; the own route renders.
|
|
125
|
+
if (ev.statusChanged && ev.isPublished) {
|
|
126
|
+
return { type: 'tags', tags, render: selfRender, prune: [] }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Below: a plain update, no availability change. A draft that stays a draft touches no public output.
|
|
130
|
+
if (!ev.isPublished) return { type: 'noop' }
|
|
131
|
+
|
|
132
|
+
// SLUG / PATH CHANGE — path freshening: listings + referrers re-render (their link path updates); the new
|
|
133
|
+
// route renders, the old route is pruned.
|
|
134
|
+
if (ev.pathChanged) {
|
|
135
|
+
const prune = ev.oldRoute ? [ev.oldRoute] : []
|
|
136
|
+
return { type: 'tags', tags, render: selfRender, prune }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// CONTENT EDIT — content freshening: listings + explicit referrers re-render; the own route re-renders.
|
|
140
|
+
return { type: 'tags', tags, render: selfRender, prune: [] }
|
|
141
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { eq } from 'drizzle-orm'
|
|
2
|
+
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
|
|
3
|
+
import { publishStatus } from '../../database/publish-status'
|
|
4
|
+
|
|
5
|
+
export interface PublishOutcome {
|
|
6
|
+
status: 'success' | 'error'
|
|
7
|
+
/** The failure message (render / write / S3) on an 'error' outcome; omitted/null on success. */
|
|
8
|
+
error?: string | null
|
|
9
|
+
/** Which output the attempt wrote to — `outputConfig().driver` ('local' | 's3'). */
|
|
10
|
+
target: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Upsert the LATEST publish outcome for a route — one row per route (latest-state, not a history). Called
|
|
15
|
+
* by the runtime publisher after each render+put (success) or when one throws (error). Resilient by design:
|
|
16
|
+
* a missing `publish_status` table (a not-yet-migrated deploy — prod never auto-DDLs) degrades to a warn
|
|
17
|
+
* rather than breaking the publish loop or masking the real render/write error in the caller's catch. The
|
|
18
|
+
* timestamp is an explicit `new Date()` so the conflict-update branch (which does not fire `$defaultFn`)
|
|
19
|
+
* also refreshes it; drizzle maps it to UNIX seconds (mode:'timestamp'), never raw milliseconds.
|
|
20
|
+
*/
|
|
21
|
+
export function recordPublishStatus(db: BetterSQLite3Database, route: string, outcome: PublishOutcome): void {
|
|
22
|
+
try {
|
|
23
|
+
const set = { status: outcome.status, error: outcome.error ?? null, target: outcome.target, updatedAt: new Date() }
|
|
24
|
+
db.insert(publishStatus).values({ route, ...set }).onConflictDoUpdate({ target: publishStatus.route, set }).run()
|
|
25
|
+
} catch (error) {
|
|
26
|
+
console.warn('[kestrel] could not record publish status (is `publish_status` migrated? run `db:migrate`):', (error as Error).message)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Classify a route's render result into a publish outcome:
|
|
32
|
+
* - `success` — a 200 with a rendered body (write it + record success).
|
|
33
|
+
* - `error` — a 5xx: the page itself rendered to a server error (a bad block / relation / template).
|
|
34
|
+
* Crucially this surfaces as a non-200 RESPONSE, not a thrown exception, so without this
|
|
35
|
+
* branch it would be a silent skip leaving a stale `success` row — the failure mode the
|
|
36
|
+
* editor ampel most needs to show.
|
|
37
|
+
* - `skip` — any other non-200 (a draft / unpublish race resolving to a 404 / redirect): not an error,
|
|
38
|
+
* leave the route's status untouched (the prune path handles a real unpublish).
|
|
39
|
+
*/
|
|
40
|
+
export function renderOutcome(status: number, hasBody: boolean): 'success' | 'error' | 'skip' {
|
|
41
|
+
if (status === 200 && hasBody) return 'success'
|
|
42
|
+
if (status >= 500) return 'error'
|
|
43
|
+
return 'skip'
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Clear a route's status row — its static file was pruned (unpublish / delete / slug change), so it is no
|
|
47
|
+
* longer live. Idempotent; same missing-table resilience as `recordPublishStatus`. */
|
|
48
|
+
export function clearPublishStatus(db: BetterSQLite3Database, route: string): void {
|
|
49
|
+
try {
|
|
50
|
+
db.delete(publishStatus).where(eq(publishStatus.route, route)).run()
|
|
51
|
+
} catch (error) {
|
|
52
|
+
console.warn('[kestrel] could not clear publish status:', (error as Error).message)
|
|
53
|
+
}
|
|
54
|
+
}
|