@michaelthielemann/kestrel 3.0.0 → 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 +6 -8
- 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/Button.vue +2 -2
- package/layers/ui/app/components/ui/ButtonGroup.vue +2 -2
- package/layers/ui/app/components/ui/Checkbox.vue +2 -2
- package/layers/ui/app/components/ui/Combobox.vue +5 -5
- package/layers/ui/app/components/ui/DatePicker.vue +2 -2
- package/layers/ui/app/components/ui/DateRangePicker.vue +2 -2
- package/layers/ui/app/components/ui/NumberInput.vue +4 -4
- package/layers/ui/app/components/ui/Richtext.vue +1 -1
- package/layers/ui/app/components/ui/RichtextToolbar.vue +1 -3
- package/layers/ui/app/components/ui/Select.vue +2 -2
- package/layers/ui/app/components/ui/TextInput.vue +4 -4
- package/layers/ui/app/components/ui/Textarea.vue +2 -2
- package/layers/ui/app/components/ui/TimeInput.vue +2 -2
- package/layers/ui/app/components/ui/Toasts.vue +3 -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,4 +1,4 @@
|
|
|
1
|
-
import { localePath } from '
|
|
1
|
+
import { localePath } from '@michaelthielemann/kestrel-core/client'
|
|
2
2
|
|
|
3
3
|
export interface PageHeadImage { src: string; width?: number | null; height?: number | null }
|
|
4
4
|
export interface PageHeadAlternate { locale: string; path: string }
|
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
import { defineNuxtModule } from '@nuxt/kit'
|
|
2
|
-
import { createS3Driver } from '
|
|
3
|
-
import { resolveOutputCreds, deployStaticOutput, isEnvTrue, planS3Deploy, readRouteDiscovery } from '
|
|
4
|
-
import { resolveKestrel } from '
|
|
5
|
-
import type { KestrelConfig } from '
|
|
6
|
-
|
|
7
|
-
|
|
2
|
+
import { createS3Driver } from '@michaelthielemann/kestrel-core'
|
|
3
|
+
import { resolveOutputCreds, deployStaticOutput, isEnvTrue, planS3Deploy, readRouteDiscovery } from '@michaelthielemann/kestrel-delivery-static'
|
|
4
|
+
import { resolveKestrel } from '@michaelthielemann/kestrel-core'
|
|
5
|
+
import type { KestrelConfig } from '@michaelthielemann/kestrel-core'
|
|
8
6
|
/**
|
|
9
7
|
* Ships `nuxt generate`'s static output (`.output/public`) to an S3 bucket when `kestrel.output.driver`
|
|
10
8
|
* is `s3` — the deploy half of FEATURES "Output target: local directory or S3 bucket". Runs on Nitro's
|
|
@@ -3,10 +3,10 @@ import { existsSync } from 'node:fs'
|
|
|
3
3
|
import { defineNuxtModule } from '@nuxt/kit'
|
|
4
4
|
import Database from 'better-sqlite3'
|
|
5
5
|
import { collectPageRoutes, pageLikeTables } from './discover'
|
|
6
|
-
import { localePath } from '
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
|
|
6
|
+
import { localePath, resolveKestrel } from '@michaelthielemann/kestrel-core'
|
|
7
|
+
import type { KestrelConfig } from '@michaelthielemann/kestrel-core'
|
|
8
|
+
import { META_KEYS } from '@michaelthielemann/kestrel-core'
|
|
9
|
+
import { recordRouteDiscovery, type RouteDiscovery } from '@michaelthielemann/kestrel-delivery-static'
|
|
10
10
|
// Read published paths from EVERY page-like collection straight from the DB at build time so
|
|
11
11
|
// `nuxt generate` prerenders one HTML file per page (see `collectPageRoutes` for how pageLike tables
|
|
12
12
|
// are identified). The server's migrate-on-boot hasn't run yet at this point, so a DB that isn't there
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { defineNuxtModule } from '@nuxt/kit'
|
|
2
2
|
import Database from 'better-sqlite3'
|
|
3
|
-
import { resolveKestrel, type KestrelConfig } from '
|
|
4
|
-
import { isEnvTrue } from '
|
|
3
|
+
import { resolveKestrel, type KestrelConfig } from '@michaelthielemann/kestrel-core'
|
|
4
|
+
import { isEnvTrue } from '@michaelthielemann/kestrel-delivery-static'
|
|
5
5
|
import { pruneUnreferencedMedia, mediaOwnedKeys } from './prune-media'
|
|
6
6
|
|
|
7
7
|
// The set of storage keys the media library owns (originals + derivatives), read straight from the DB at
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { getMethod, getRequestHeader, sendRedirect, send, setHeader, setResponseStatus, type H3Event } from 'h3'
|
|
2
|
+
import { isRendererContext } from '@michaelthielemann/kestrel-access'
|
|
3
|
+
import { cacheControlFor, isDeliveryExemptPath, isDeliveryLive } from '@michaelthielemann/kestrel-core'
|
|
4
|
+
import { usePublishingDb, currentSnapshot, htmlKeyForRoute } from '@michaelthielemann/kestrel-publishing'
|
|
5
|
+
import { liveRedirectFor } from '@michaelthielemann/kestrel-delivery-live'
|
|
6
|
+
|
|
7
|
+
// Requests this adapter must never answer, whatever `currentSnapshot` says: the admin app, the pipeline
|
|
8
|
+
// API, Nitro's own internals (tasks — e.g. the manual `publish:run` trigger), built assets, and the
|
|
9
|
+
// editor's preview ticket flow. `META_ROUTES` mirrors `@michaelthielemann/kestrel-core`'s own `META_KEYS`
|
|
10
|
+
// (sitemap/robots/llms/redirects) — those are rendered by their own routes, not snapshots. Every prefix
|
|
11
|
+
// is slash-terminated (`normalizeExemptPrefix`'s own rule): a bare `/admin` would also match `/admin-guide`
|
|
12
|
+
// — a DIFFERENT, publishable route this adapter must still serve from its own snapshot.
|
|
13
|
+
const EXEMPT_PREFIXES = ['/api/', '/admin/', '/_nuxt/', '/_nitro/', '/__kestrel/']
|
|
14
|
+
const META_ROUTES = new Set(['/sitemap.xml', '/robots.txt', '/llms.txt', '/llms-full.txt', '/redirects.json'])
|
|
15
|
+
|
|
16
|
+
// EXEMPT_PREFIXES is slash-terminated the same way (see the const's own comment), so the bare form gets
|
|
17
|
+
// the same exact-path treatment `isDeliveryExemptPath` applies to the configured entries — matching only
|
|
18
|
+
// `startsWith(prefix)` left it unmatched, contrary to `deliveryExempt`'s own documented examples
|
|
19
|
+
// (`/health`, `/feed.xml`).
|
|
20
|
+
function isExempt(path: string): boolean {
|
|
21
|
+
if (META_ROUTES.has(path)) return true
|
|
22
|
+
if (path === '/admin') return true // the bare admin root has no trailing segment to anchor a '/admin/' prefix match
|
|
23
|
+
if (EXEMPT_PREFIXES.some((prefix) => path === prefix.slice(0, -1) || path.startsWith(prefix))) return true
|
|
24
|
+
return isDeliveryExemptPath(path)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// A route's own trailing slash is not a distinct route: '/route/' must resolve the same snapshot as
|
|
28
|
+
// '/route'. Only the FINAL slash is stripped (one normalization pass), so a prefix match like '/admin/'
|
|
29
|
+
// against '/admin/foo/' still sees '/admin/foo' and still starts with '/admin/'. The root path is its
|
|
30
|
+
// own fixed point and is never touched.
|
|
31
|
+
function normalizeTrailingSlash(path: string): string {
|
|
32
|
+
return path !== '/' && path.endsWith('/') ? path.slice(0, -1) : path
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// RFC 7232 §3.2: If-None-Match may carry a comma-separated list of ETags, and a bare `*` matches any
|
|
36
|
+
// current representation (always a match, as long as the resource exists — checked by the caller).
|
|
37
|
+
// This route's ETag is always strong (`"<fingerprint>"`), so a weak validator (`W/"..."`) never matches
|
|
38
|
+
// under strong comparison, per the same section.
|
|
39
|
+
function ifNoneMatchHits(header: string | undefined, etag: string): boolean {
|
|
40
|
+
if (!header) return false
|
|
41
|
+
const trimmed = header.trim()
|
|
42
|
+
if (trimmed === '*') return true
|
|
43
|
+
return trimmed.split(',').some((candidate) => candidate.trim() === etag)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The delivery-live catch-all: under `delivery: 'live'`, answers public content traffic exclusively from
|
|
48
|
+
* `published_snapshots` — a configured redirect, 200 with the snapshot's own html, or 404. Wired as a
|
|
49
|
+
* Nitro MIDDLEWARE (see `middleware/delivery-live-catchall.ts`) rather than a route, since a middleware
|
|
50
|
+
* runs ahead of Nuxt's page router; middleware return values are ignored by h3, so this writes the
|
|
51
|
+
* response itself and returns whether it did.
|
|
52
|
+
*
|
|
53
|
+
* Returns `false` (unhandled — the caller falls through to ordinary page rendering) for everything the
|
|
54
|
+
* adapter must not touch: `delivery: 'static'` (the default), a response already sent, a non-GET/HEAD
|
|
55
|
+
* request, a non-content path (admin/api/assets/meta/consumer-exempted routes), or the producer's OWN
|
|
56
|
+
* internal render fetch. That last one is `isRendererContext()`/`import.meta.prerender` — the SAME
|
|
57
|
+
* unforgeable AsyncLocalStorage marker `pipelines/route.ts` and `00.ip-allowlist.ts` already trust for
|
|
58
|
+
* exactly this "is this our own in-process render, not an external request" question (`render-live.ts`'s
|
|
59
|
+
* `renderRouteLive` runs its `localFetch` inside `runAsRenderer`, which sets it) — NOT a request header,
|
|
60
|
+
* which any external client could forge. Without this bypass, a publish's own render would loop back
|
|
61
|
+
* into this catch-all and get served its route's CURRENT (about-to-be-superseded) snapshot instead of
|
|
62
|
+
* ever reaching a real page render, so a
|
|
63
|
+
* publish under `'live'` could never record a fresh one.
|
|
64
|
+
*/
|
|
65
|
+
export async function serveLiveRoute(event: H3Event): Promise<boolean> {
|
|
66
|
+
if (event.node?.res?.writableEnded) return false
|
|
67
|
+
if (!isDeliveryLive()) return false
|
|
68
|
+
if (import.meta.prerender === true || isRendererContext()) return false
|
|
69
|
+
const method = getMethod(event)
|
|
70
|
+
if (method !== 'GET' && method !== 'HEAD') return false
|
|
71
|
+
const path = normalizeTrailingSlash(event.path.split('?')[0] || '/')
|
|
72
|
+
if (isExempt(path)) return false
|
|
73
|
+
|
|
74
|
+
// Live mode is the app's own edge — there is no separate njs/edge script in front of it the way the
|
|
75
|
+
// static topology has, so a configured redirect has to be honoured here too, before the 404.
|
|
76
|
+
const redirect = liveRedirectFor(path)
|
|
77
|
+
if (redirect) {
|
|
78
|
+
await sendRedirect(event, redirect.target, redirect.status)
|
|
79
|
+
return true
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const snapshot = currentSnapshot(usePublishingDb().db, path)
|
|
83
|
+
if (!snapshot) {
|
|
84
|
+
setResponseStatus(event, 404)
|
|
85
|
+
await send(event, 'Not Found', 'text/plain')
|
|
86
|
+
return true
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// The fingerprint is a sha256 of the exact rendered bytes (publisher.ts), so a STRONG ETag is exact:
|
|
90
|
+
// any two responses sharing it are byte-identical, never merely semantically equivalent. Cache-Control
|
|
91
|
+
// mirrors delivery-static's own policy for an html key (`cacheControlFor`/`REVALIDATE_CACHE`) so the
|
|
92
|
+
// two adapters agree on cacheability for the same snapshot. NOTE: this ETag/304 state lives only in
|
|
93
|
+
// `published_snapshots`, read fresh per request — there is no separate in-process cache to invalidate,
|
|
94
|
+
// so this is safe across multiple server processes sharing the DB.
|
|
95
|
+
const etag = `"${snapshot.fingerprint}"`
|
|
96
|
+
const cacheControl = cacheControlFor(htmlKeyForRoute(path))
|
|
97
|
+
setHeader(event, 'etag', etag)
|
|
98
|
+
if (cacheControl) setHeader(event, 'cache-control', cacheControl)
|
|
99
|
+
|
|
100
|
+
if (ifNoneMatchHits(getRequestHeader(event, 'if-none-match'), etag)) {
|
|
101
|
+
setResponseStatus(event, 304)
|
|
102
|
+
await send(event, undefined)
|
|
103
|
+
return true
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const body = Buffer.from(snapshot.html, 'utf8')
|
|
107
|
+
setHeader(event, 'content-length', body.byteLength)
|
|
108
|
+
setResponseStatus(event, 200)
|
|
109
|
+
await send(event, method === 'HEAD' ? undefined : snapshot.html, 'text/html; charset=utf-8')
|
|
110
|
+
return true
|
|
111
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { serveLiveRoute } from '../delivery-live/serve'
|
|
2
|
+
|
|
3
|
+
// Runs on every request; only intercepts when serveLiveRoute's own gate says so (delivery-live/ stays
|
|
4
|
+
// the single owner of when/how this adapter answers — see its TSDoc).
|
|
5
|
+
export default defineEventHandler(async (event) => {
|
|
6
|
+
await serveLiveRoute(event)
|
|
7
|
+
})
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { PipelineDef } from '@michaelthielemann/kestrel-core'
|
|
2
|
+
import {
|
|
3
|
+
buildPublishPipelines,
|
|
4
|
+
buildPublishRunsPipelines,
|
|
5
|
+
buildLinkPipelines,
|
|
6
|
+
buildPreviewPipelines,
|
|
7
|
+
buildRoutePipelines,
|
|
8
|
+
} from '@michaelthielemann/kestrel-publishing'
|
|
9
|
+
import { buildDeliveryLivePipelines } from '@michaelthielemann/kestrel-delivery-live'
|
|
10
|
+
|
|
11
|
+
/** The public layer's own API surface: the render entry, the preview ticket pair, the publish action and
|
|
12
|
+
* its status read, the orchestrator's admin progress read, the editor's link resolver, and the
|
|
13
|
+
* delivery-live read API. All collection-less, so each lives at `/api/<name>`. */
|
|
14
|
+
export function buildPublicPipelines(): PipelineDef[] {
|
|
15
|
+
return [
|
|
16
|
+
...buildRoutePipelines(),
|
|
17
|
+
...buildPreviewPipelines(),
|
|
18
|
+
...buildPublishPipelines(),
|
|
19
|
+
...buildPublishRunsPipelines(),
|
|
20
|
+
...buildLinkPipelines(),
|
|
21
|
+
...buildDeliveryLivePipelines(),
|
|
22
|
+
]
|
|
23
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type Database from 'better-sqlite3'
|
|
2
|
+
import { useDb } from '@michaelthielemann/kestrel-core'
|
|
3
|
+
import { ensureSnapshotTriggers } from '@michaelthielemann/kestrel-publishing'
|
|
4
|
+
|
|
5
|
+
// Layer order puts every core-layer plugin (00.migrate, 02.schema-sync) before this layer's, so
|
|
6
|
+
// `published_snapshots` already exists here for either provisioning path (a committed migration inside
|
|
7
|
+
// Kestrel's own repo, or the schema engine for a consumer layer) — except a boot before EITHER has run,
|
|
8
|
+
// which `ensureSnapshotTriggers` itself tolerates (warns, does not crash). Skipped during prerender, same
|
|
9
|
+
// as `02.schema-sync`: no DDL runs in that phase.
|
|
10
|
+
export default defineNitroPlugin(() => {
|
|
11
|
+
if (import.meta.prerender) return
|
|
12
|
+
const client = (useDb() as unknown as { $client: Database.Database }).$client
|
|
13
|
+
ensureSnapshotTriggers(client)
|
|
14
|
+
})
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { registerPipeline } from '@michaelthielemann/kestrel-core'
|
|
2
|
+
import { buildPublicPipelines } from '../pipelines'
|
|
3
|
+
|
|
4
|
+
// Registration only — nothing resolves a pipeline here; the registry is read on the first request.
|
|
5
|
+
export default defineNitroPlugin(() => {
|
|
6
|
+
for (const def of buildPublicPipelines()) registerPipeline(def)
|
|
7
|
+
})
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import { registerFieldPopulator } from '
|
|
2
|
-
import { buildLinkFieldPopulators } from '
|
|
3
|
-
import { resolveInternalHref } from '../utils/link-resolve'
|
|
1
|
+
import { registerFieldPopulator } from '@michaelthielemann/kestrel-core'
|
|
2
|
+
import { buildLinkFieldPopulators, resolveInternalHref } from '@michaelthielemann/kestrel-publishing'
|
|
4
3
|
|
|
5
4
|
// Resolve internal links to the target record's localized public path at read time (page-like targets;
|
|
6
5
|
// external/email/tel pass through). The resolver IS status-gated: a link to a DRAFT resolves to nothing and
|
|
@@ -1,37 +1,55 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { Effect } from 'effect'
|
|
2
|
+
import { collectionOf, registerAfterStep } from '@michaelthielemann/kestrel-core'
|
|
3
|
+
import type { StepDef } from '@michaelthielemann/kestrel-core'
|
|
4
|
+
import { outputDriver, REDIRECTS_COLLECTION, REDIRECTS_FIELD, writeRedirectsArtifact } from '@michaelthielemann/kestrel-publishing'
|
|
5
|
+
import { invalidateLiveRedirects } from '@michaelthielemann/kestrel-delivery-live'
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
8
|
* Publish `redirects.json` on every save of the redirects singleton — deliberately decoupled from the
|
|
7
9
|
* publish cycle, so a redirect goes live without a full republish (and without an editor pressing
|
|
8
10
|
* Publish, which after ADR-0008 they otherwise would have to).
|
|
9
11
|
*
|
|
10
|
-
* A
|
|
11
|
-
* the
|
|
12
|
-
* every environment, unlike the publish plugin — that one is dev-gated because a dev publish
|
|
13
|
-
* Vite-dev HTML with no hashed `_nuxt`, a CONTENT problem this artifact does not have (it is
|
|
12
|
+
* A CRITICAL after-step, not a non-critical one: a save that reports success while the edge still serves
|
|
13
|
+
* the old rules is exactly the failure this feature cannot have — its failure becomes the save's response.
|
|
14
|
+
* Registered in every environment, unlike the publish plugin — that one is dev-gated because a dev publish
|
|
15
|
+
* would write Vite-dev HTML with no hashed `_nuxt`, a CONTENT problem this artifact does not have (it is
|
|
14
16
|
* `JSON.stringify` of DB rows, byte-identical either way).
|
|
15
17
|
*
|
|
16
18
|
* Where it lands is `output.dir` / the S3 prefix — the same target the publisher uses. With the classic
|
|
17
19
|
* `output.auto: false` + `driver: 'local'` build model that is NOT the deployed tree, so there a
|
|
18
20
|
* redirect goes live with the next `nuxt generate` instead; documented under Redirects in
|
|
19
|
-
* `docs/
|
|
21
|
+
* `docs/guide/redirects.md`.
|
|
20
22
|
*/
|
|
21
23
|
export default defineNitroPlugin(() => {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
})
|
|
35
|
-
|
|
36
|
-
|
|
24
|
+
const writeRedirectsStep: StepDef = {
|
|
25
|
+
name: 'writeRedirects',
|
|
26
|
+
when: (ctx) => collectionOf(ctx).def.name === REDIRECTS_COLLECTION,
|
|
27
|
+
whenLabel: `collection is "${REDIRECTS_COLLECTION}"`,
|
|
28
|
+
fn: (ctx) => Effect.gen(function* () {
|
|
29
|
+
const row = ctx.output as Record<string, unknown>
|
|
30
|
+
// The row is already committed regardless of what the static artifact write below does (see its
|
|
31
|
+
// own comment) — the live catch-all reads straight from the row, so its cache drops unconditionally,
|
|
32
|
+
// in lockstep with every save.
|
|
33
|
+
invalidateLiveRedirects()
|
|
34
|
+
// A JS try/catch does NOT observe an Effect failure crossing a `yield*` — only Effect's own
|
|
35
|
+
// combinators do — so the survivor rewrap has to be Effect.catchAll, not a wrapping try/catch.
|
|
36
|
+
yield* Effect.tryPromise({ try: () => writeRedirectsArtifact(row[REDIRECTS_FIELD], outputDriver()), catch: (error) => error }).pipe(
|
|
37
|
+
Effect.catchAll((error) => Effect.sync(() => {
|
|
38
|
+
// The row is already committed (CRUD holds no transaction), so the only honest message is
|
|
39
|
+
// "saved, but not live". Short and ASCII on purpose: h3 truncates nothing but the reason phrase is
|
|
40
|
+
// the wire's, and the cause goes in `data` where nothing can strip it. `savedUpdatedAt` lets the
|
|
41
|
+
// editor rebaseline on this failure the same way it would on success — it only rebaselines then.
|
|
42
|
+
throw createError({
|
|
43
|
+
statusCode: 500,
|
|
44
|
+
statusMessage: 'Redirects saved, but publishing redirects.json failed. Save again to retry.',
|
|
45
|
+
data: {
|
|
46
|
+
cause: (error as Error)?.message ?? String(error),
|
|
47
|
+
savedUpdatedAt: new Date(row.updatedAt as string | number).getTime(),
|
|
48
|
+
},
|
|
49
|
+
})
|
|
50
|
+
})),
|
|
51
|
+
)
|
|
52
|
+
}),
|
|
53
|
+
}
|
|
54
|
+
registerAfterStep({ step: writeRedirectsStep, critical: true, ops: ['updateOne'] })
|
|
37
55
|
})
|
|
@@ -1,28 +1,57 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
1
|
+
import { renderRouteLive } from '../utils/publish/render-live'
|
|
2
|
+
import {
|
|
3
|
+
createPublishQueue,
|
|
4
|
+
setPublishRuntime,
|
|
5
|
+
DepsStore,
|
|
6
|
+
createSqlitePersistence,
|
|
7
|
+
usePublishingDb,
|
|
8
|
+
outputDriver,
|
|
9
|
+
publishInvalidation,
|
|
10
|
+
resumePublishRuns,
|
|
11
|
+
startPublishRun,
|
|
12
|
+
setRenderRouteLive,
|
|
13
|
+
type PublishDelivery,
|
|
14
|
+
} from '@michaelthielemann/kestrel-publishing'
|
|
8
15
|
|
|
9
16
|
/**
|
|
10
|
-
* The in-server publish runtime (the production operation model). Wires
|
|
11
|
-
* `output.auto
|
|
17
|
+
* The in-server publish runtime (the production operation model). Wires the queue + deps index and gates
|
|
18
|
+
* whether they exist at all, on `output.auto`, skipped in dev (a dev publish would write Vite-dev HTML
|
|
19
|
+
* without hashed `_nuxt`):
|
|
12
20
|
*
|
|
13
|
-
* 1.
|
|
14
|
-
*
|
|
21
|
+
* 1. THE RUNTIME ITSELF — `setPublishRuntime` publishes the queue + deps index via `usePublishRuntime()`.
|
|
22
|
+
* The `planPublish` outbox handler (`handlers/plan-publish.ts`, registered independently by
|
|
23
|
+
* `05.plan-publish.ts`) is the actual AUTO-TRIGGER: it classifies every content/media write into an
|
|
24
|
+
* invalidation and enqueues it, but only when this plugin has actually set a runtime — a no-op
|
|
25
|
+
* otherwise (dev, or `output.auto` off), so the gate lives here even though the trigger does not.
|
|
15
26
|
* 2. BOOT PUBLISH — a full publish on startup resyncs this build's `_nuxt` + records every route's deps.
|
|
16
27
|
* DETACHED (`runNitroPlugins` is synchronous + unawaited; `localFetch` is already wired before plugins
|
|
17
|
-
* run + before the server listens), so it never blocks boot.
|
|
28
|
+
* run + before the server listens), so it never blocks boot. A FULL run (boot's own, and the
|
|
29
|
+
* reconciler's below) goes through `startPublishRun`, so its command -> snapshot -> delivery ->
|
|
30
|
+
* done sequence is persisted to `publish_runs` and survives a crash — an incremental (tag) run a
|
|
31
|
+
* content/media write triggers stays a direct `publishInvalidation` call, since it is not part of
|
|
32
|
+
* the owned sequence.
|
|
18
33
|
* 3. RECONCILER — an optional periodic full publish (`output.reconcileMinutes`) self-heals any missed
|
|
19
|
-
* invalidation.
|
|
34
|
+
* invalidation, also tracked as an owned run. The self-heal is real, not just "re-render everything
|
|
35
|
+
* and hope": every full/reconcile publish live-renders each published route and compares its
|
|
36
|
+
* fingerprint against the currently recorded snapshot (`publisher.ts`'s `PublishMode: 'reconcile'`,
|
|
37
|
+
* ADR-0013's amendment) — a mismatch (a missed invalidation, a template/component deploy) records +
|
|
38
|
+
* delivers the fresh render; a match costs nothing beyond the render itself.
|
|
39
|
+
*
|
|
40
|
+
* Crash-resume (ADR-0025): `resumePublishRuns` marks any run a crash left `running` as `failed` — it does
|
|
41
|
+
* NOT redeliver — and runs BEFORE the boot enqueue below, which fires unconditionally regardless of what
|
|
42
|
+
* resume found. The queue remains the ONLY caller that ever invokes a delivery; resume never bypasses its
|
|
43
|
+
* single-flight guard.
|
|
20
44
|
*/
|
|
21
45
|
export default defineNitroPlugin(() => {
|
|
22
46
|
if (import.meta.dev) return
|
|
23
47
|
const output = (useRuntimeConfig().kestrel as { output?: { auto?: boolean; publishOnSave?: boolean; reconcileMinutes?: number; verbose?: boolean } }).output
|
|
24
48
|
if (!output?.auto) return
|
|
25
49
|
|
|
50
|
+
// Wired explicitly here (not a module-load side effect) — see tasks/publish/run.ts's own comment for
|
|
51
|
+
// why: it removes any dependency on import-order/instance-identity between this plugin's own
|
|
52
|
+
// `@michaelthielemann/kestrel-publishing` import and the one `publisher.ts`'s internals resolve. Idempotent.
|
|
53
|
+
setRenderRouteLive(renderRouteLive)
|
|
54
|
+
|
|
26
55
|
// `output.verbose`: on top of the one-line summary, itemise each incremental republish with a
|
|
27
56
|
// timestamped per-route line (rendered / pruned) for traceability. Off by default. (A queued FULL run
|
|
28
57
|
// — boot / reconciler — is a bulk resync and stays a summary; publishInvalidation returns no per-route
|
|
@@ -33,9 +62,35 @@ export default defineNitroPlugin(() => {
|
|
|
33
62
|
const driver = outputDriver()
|
|
34
63
|
// Durable deps: rehydrate `route → tags` from SQLite so the boot full-publish below can prune routes
|
|
35
64
|
// that were unpublished/deleted while the server was down (staleRoutes over a non-empty tracked set).
|
|
36
|
-
const deps = new DepsStore(createSqlitePersistence(
|
|
65
|
+
const deps = new DepsStore(createSqlitePersistence(usePublishingDb().db))
|
|
66
|
+
|
|
67
|
+
// The delivery port for a FULL run: the real renderer, driven the same way a queued full invalidation
|
|
68
|
+
// always was. `startPublishRun` persists the command -> snapshot -> delivery -> done sequence around
|
|
69
|
+
// this call; this closure is only the "delivery" step itself.
|
|
70
|
+
const fullDelivery: PublishDelivery = {
|
|
71
|
+
deliver: async () => {
|
|
72
|
+
const { rendered, pruned, counts } = await publishInvalidation({ type: 'full' }, driver, deps)
|
|
73
|
+
if (counts.rendered === 0 && counts.pruned === 0) return
|
|
74
|
+
console.info(`[kestrel] published ${counts.rendered} route(s) (pruned ${counts.pruned})`)
|
|
75
|
+
if (verbose) {
|
|
76
|
+
for (const route of rendered) console.info(`[kestrel] ${stamp()} rendered ${route}`)
|
|
77
|
+
for (const route of pruned) console.info(`[kestrel] ${stamp()} pruned ${route}`)
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
|
|
37
82
|
const queue = createPublishQueue({
|
|
38
83
|
run: async (inv) => {
|
|
84
|
+
if (inv.type === 'full') {
|
|
85
|
+
const result = await startPublishRun(fullDelivery)
|
|
86
|
+
// Rethrow on a recorded failure so the queue's own retry-on-failure fires (its `.catch()` calls
|
|
87
|
+
// `onError` below and re-queues the batch) — `startPublishRun` itself never throws for a delivery
|
|
88
|
+
// failure (it resolves with the outcome as data), so restoring that retry has to happen here.
|
|
89
|
+
// `id: 0` is the synthetic placeholder `startPublishRun`'s missing-table fallback returns (no real
|
|
90
|
+
// row backs it) — label it distinctly rather than a misleading "#0".
|
|
91
|
+
if (result.status === 'failed') throw new Error(`publish run #${result.id === 0 ? 'untracked' : result.id} failed: ${result.error}`)
|
|
92
|
+
return
|
|
93
|
+
}
|
|
39
94
|
const { rendered, pruned, counts } = await publishInvalidation(inv, driver, deps)
|
|
40
95
|
if (counts.rendered === 0 && counts.pruned === 0) return
|
|
41
96
|
// Incremental runs name the routes; a queued full run (the reconciler) prints counts only.
|
|
@@ -53,19 +108,19 @@ export default defineNitroPlugin(() => {
|
|
|
53
108
|
// a write-driven prune are serialized by one single-flight run rather than racing each other.
|
|
54
109
|
setPublishRuntime({ queue, deps })
|
|
55
110
|
|
|
56
|
-
// A save writes the DB, not the site: by default only what a save must still REMOVE from the output (an
|
|
57
|
-
// unpublished or deleted record's page) passes through, and everything renderable waits for an explicit
|
|
58
|
-
// publish — see ADR-0008. `output.publishOnSave` restores the pre-2.0 model, where every write republished.
|
|
59
|
-
const publishOnSave = output.publishOnSave ?? false
|
|
60
|
-
registerWriteListener(({ def, before, after }) => {
|
|
61
|
-
queue.enqueue(planWrite(classifyWrite(def, before, after, primaryLocale(), prefixPrimaryLocale()), publishOnSave))
|
|
62
|
-
})
|
|
63
|
-
|
|
64
111
|
// Boot publish goes THROUGH the queue (not a direct publishFull) so it shares the single-flight guard:
|
|
65
112
|
// otherwise a boot full-publish could overlap a write-triggered incremental or the reconciler, and both
|
|
66
113
|
// runs share the module-level variant accumulator (corrupting the registry) and can interleave
|
|
67
|
-
// render/prune (leaving an unpublished page live). The queue serializes them
|
|
68
|
-
|
|
114
|
+
// render/prune (leaving an unpublished page live). The queue serializes them, and it is the ONLY
|
|
115
|
+
// AUTOMATIC caller that ever invokes a delivery — `resumePublishRuns` below just updates rows, so there
|
|
116
|
+
// is nothing left to race it. (The `publish:run` Nitro task — `tasks/publish/run.ts` — is a documented
|
|
117
|
+
// exception: an operator-triggered manual run that calls `publishFull` directly, outside this queue.)
|
|
118
|
+
//
|
|
119
|
+
// Resume runs first, awaited: it is a plain DB update (never invokes delivery, so it cannot itself
|
|
120
|
+
// crash the process the way a redelivered render could), which is what guarantees the boot enqueue below
|
|
121
|
+
// always fires — a resume that instead redelivered and crashed again the same way would leave the row
|
|
122
|
+
// `running` forever and never reach this line, an infinite boot loop that never makes progress.
|
|
123
|
+
void resumePublishRuns().finally(() => queue.enqueue({ type: 'full' }))
|
|
69
124
|
|
|
70
125
|
const mins = output.reconcileMinutes ?? 0
|
|
71
126
|
if (mins > 0) setInterval(() => queue.enqueue({ type: 'full' }), mins * 60_000).unref?.()
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { getTableColumns } from 'drizzle-orm'
|
|
2
|
-
import { localePath } from '
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
5
|
-
import
|
|
6
|
-
|
|
2
|
+
import { localePath, useDb, primaryLocale, prefixPrimaryLocale, allCollections } from '@michaelthielemann/kestrel-core'
|
|
3
|
+
import type { BuiltCollection } from '@michaelthielemann/kestrel-core'
|
|
4
|
+
import { isPubliclyReadable, publicReadableResources } from '@michaelthielemann/kestrel-access'
|
|
5
|
+
import { LLMS_FULL_HEADING_OFFSET, buildLlmsFullTxt, recordMarkdown, collectionHeading, siteBaseUrl, siteName, siteDescription, llmsFullEnabled } from '@michaelthielemann/kestrel-publishing'
|
|
6
|
+
import type { LlmsFullSection, LlmsFullPage } from '@michaelthielemann/kestrel-publishing'
|
|
7
7
|
// The long form of llms.txt: every published, indexable page's full Markdown body in one document, so an
|
|
8
8
|
// answer engine can ground on the site without crawling it. Same registry-driven public set and the same
|
|
9
9
|
// status/noindex filters as `llms.txt` and the sitemap (single source: the auth policy).
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { getTableColumns } from 'drizzle-orm'
|
|
2
|
-
import { localePath } from '
|
|
3
|
-
import
|
|
2
|
+
import { localePath, useDb, primaryLocale, prefixPrimaryLocale, allCollections } from '@michaelthielemann/kestrel-core'
|
|
3
|
+
import { isPubliclyReadable, publicReadableResources } from '@michaelthielemann/kestrel-access'
|
|
4
|
+
import { collectionHeading, buildLlmsTxt, siteBaseUrl, siteName, siteDescription } from '@michaelthielemann/kestrel-publishing'
|
|
5
|
+
import type { LlmsSection, LlmsEntry } from '@michaelthielemann/kestrel-publishing'
|
|
4
6
|
|
|
5
7
|
// Generates an `llms.txt` (llmstxt.org): the site name + description, then one section per pageLike
|
|
6
8
|
// collection listing its published, indexable records — the SAME registry-driven public set the sitemap
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { eq } from 'drizzle-orm'
|
|
2
|
-
import {
|
|
3
|
-
import { REDIRECTS_COLLECTION, REDIRECTS_FIELD } from '
|
|
2
|
+
import { getCollection, useDb } from '@michaelthielemann/kestrel-core'
|
|
3
|
+
import { compilePublishableRedirects, serializeRedirects, REDIRECTS_COLLECTION, REDIRECTS_FIELD } from '@michaelthielemann/kestrel-publishing'
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* The redirect artifact the edge polls. A save publishes it directly (see `plugins/03.redirects.ts`);
|
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import { getTableColumns } from 'drizzle-orm'
|
|
2
|
-
import { localePath } from '
|
|
2
|
+
import { localePath, useDb, primaryLocale, prefixPrimaryLocale, allCollections, isDeliveryLive } from '@michaelthielemann/kestrel-core'
|
|
3
|
+
import { isPubliclyReadable, publicReadableResources } from '@michaelthielemann/kestrel-access'
|
|
4
|
+
import { currentRoutes, usePublishingDb, buildSitemap, withHreflang, siteBaseUrl } from '@michaelthielemann/kestrel-publishing'
|
|
5
|
+
import type { SitemapCandidate } from '@michaelthielemann/kestrel-publishing'
|
|
3
6
|
|
|
4
7
|
// Lists every published, indexable page-like record (across all pageLike collections) as a
|
|
5
|
-
// sitemap. Filters status/noindex itself, so it is safe to serve publicly and to prerender.
|
|
8
|
+
// sitemap. Filters status/noindex itself, so it is safe to serve publicly and to prerender. Under
|
|
9
|
+
// `delivery: 'live'`, a route must ALSO have a current, non-retracted snapshot — the row's own
|
|
10
|
+
// status/noindex fields and the snapshot store are two independent "is this reachable" facts (a direct
|
|
11
|
+
// snapshot retraction leaves the row untouched, see `@michaelthielemann/kestrel-publishing`'s `server/db/snapshots.ts`), and this route is what a crawler
|
|
12
|
+
// actually finds, so both must agree before a route is listed.
|
|
6
13
|
export default defineEventHandler((event) => {
|
|
7
14
|
const db = useDb()
|
|
8
15
|
const base = siteBaseUrl()
|
|
@@ -17,10 +24,11 @@ export default defineEventHandler((event) => {
|
|
|
17
24
|
const primary = primaryLocale()
|
|
18
25
|
const prefixPrimary = prefixPrimaryLocale()
|
|
19
26
|
const candidates: SitemapCandidate[] = []
|
|
27
|
+
// One set lookup for the whole candidate list instead of one currentSnapshot query per candidate.
|
|
28
|
+
const liveRoutes = isDeliveryLive() ? new Set(currentRoutes(usePublishingDb().db)) : null
|
|
20
29
|
|
|
21
|
-
// A sitemap must only advertise publicly-reachable URLs
|
|
22
|
-
//
|
|
23
|
-
// SAME registry-driven public set (single source: the auth policy) rather than diverging from it.
|
|
30
|
+
// A sitemap must only advertise publicly-reachable URLs, so it tracks the SAME registry-driven
|
|
31
|
+
// public set the public render path uses (single source: the auth policy).
|
|
24
32
|
const pub = publicReadableResources()
|
|
25
33
|
for (const c of allCollections()) {
|
|
26
34
|
if (!c.def.pageLike) continue
|
|
@@ -52,8 +60,10 @@ export default defineEventHandler((event) => {
|
|
|
52
60
|
const seo = (row.seo ?? {}) as { noindex?: boolean }
|
|
53
61
|
if (seo.noindex) continue
|
|
54
62
|
const locale = (row.locale as string | undefined) ?? primary
|
|
63
|
+
const route = localePath(path, locale, primary, prefixPrimary)
|
|
64
|
+
if (liveRoutes && !liveRoutes.has(route)) continue
|
|
55
65
|
candidates.push({
|
|
56
|
-
loc: base +
|
|
66
|
+
loc: base + route,
|
|
57
67
|
lastmod: row.updatedAt instanceof Date ? row.updatedAt.toISOString() : undefined,
|
|
58
68
|
locale,
|
|
59
69
|
group: (row.translationGroup as string | undefined) ?? null,
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { DepsStore } from '
|
|
3
|
-
import { createSqlitePersistence } from '../../utils/publish/deps-persistence'
|
|
1
|
+
import { renderRouteLive } from '../../utils/publish/render-live'
|
|
2
|
+
import { publishFull, outputDriver, DepsStore, createSqlitePersistence, usePublishingDb, setRenderRouteLive } from '@michaelthielemann/kestrel-publishing'
|
|
4
3
|
|
|
5
4
|
/**
|
|
6
5
|
* The shared publish engine, exposed as a Nitro task. Renders every published page (+ sitemap/robots)
|
|
@@ -17,9 +16,15 @@ import { createSqlitePersistence } from '../../utils/publish/deps-persistence'
|
|
|
17
16
|
export default defineTask({
|
|
18
17
|
meta: { name: 'publish:run', description: 'Render all published pages to the configured static output (HTML + _nuxt + assets)' },
|
|
19
18
|
async run() {
|
|
19
|
+
// Wired explicitly here (not a module-load side effect): Nitro dev re-evaluates a task's module graph
|
|
20
|
+
// per invocation for hot-reload, which can give this call and `publisher.ts`'s a differently-scoped
|
|
21
|
+
// `@michaelthielemann/kestrel-publishing` instance if the wiring only ran once, earlier, at a boot-time import — calling
|
|
22
|
+
// the setter fresh, synchronously, right before the only call that needs it removes that dependency on
|
|
23
|
+
// import-order/instance-identity entirely. Idempotent: safe to call before every run.
|
|
24
|
+
setRenderRouteLive(renderRouteLive)
|
|
20
25
|
// The same durable store the boot-publish plugin uses, so a manually-triggered run also prunes
|
|
21
26
|
// routes that left the published set and records deps for later incremental publishes.
|
|
22
|
-
const deps = new DepsStore(createSqlitePersistence(
|
|
27
|
+
const deps = new DepsStore(createSqlitePersistence(usePublishingDb().db))
|
|
23
28
|
const result = await publishFull(outputDriver(), deps)
|
|
24
29
|
console.info(`[kestrel] publish:run rendered ${result.rendered} route(s), pruned ${result.pruned}`)
|
|
25
30
|
return { result }
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { runAsRenderer } from '@michaelthielemann/kestrel-access'
|
|
2
|
+
|
|
3
|
+
/** Render a public route via the in-process server, returning its HTML buffer (200) or a null body with the
|
|
4
|
+
* HTTP status, so the caller can tell a broken page (5xx — a server-error RESPONSE, not a throw) from an
|
|
5
|
+
* expected non-200 (a draft / unpublish race → 404 / redirect). The PRODUCER's own render primitive — the
|
|
6
|
+
* delivery-static side never calls this; see `@michaelthielemann/kestrel-delivery-static`'s `render-route.ts`. `@michaelthielemann/kestrel-publishing`
|
|
7
|
+
* never imports this directly (a package cannot reach `useNitroApp`) — each real
|
|
8
|
+
* entry point (`zz.publish.ts`, `tasks/publish/run.ts`) instead calls `setRenderRouteLive(renderRouteLive)`
|
|
9
|
+
* itself, right before the call that needs it (see either file's own comment for why NOT a module-load
|
|
10
|
+
* side effect: Nitro dev can re-evaluate a task's module graph per invocation). */
|
|
11
|
+
export async function renderRouteLive(route: string): Promise<{ body: Buffer | null; status: number }> {
|
|
12
|
+
// Run as the renderer principal so the nested $fetch('/api/route') passes the access guard (it is not a
|
|
13
|
+
// public endpoint). The ALS context propagates through localFetch → the page render → the nested fetch.
|
|
14
|
+
// useNitroApp is imported LAZILY (not via the Nitro auto-import) on purpose: a static import gives this
|
|
15
|
+
// module an edge to nitropack's app.mjs, which both defines useNitroApp AND loads the plugin registry
|
|
16
|
+
// that reaches this file (via zz.publish) — a build-time circular dependency. A dynamic import is a chunk
|
|
17
|
+
// boundary, so that cycle disappears from the build graph; it's the same nitroApp singleton either way
|
|
18
|
+
// (Rollup bundles the literal specifier; useNitroApp is only CALLED here at publish time, long after boot).
|
|
19
|
+
const { useNitroApp } = await import('nitropack/runtime')
|
|
20
|
+
// Every consumer that needs to know "is this OUR OWN in-process render" (delivery-live's catch-all,
|
|
21
|
+
// media's variant-capture scan) reads `isRendererContext()`/`import.meta.prerender` instead of a header —
|
|
22
|
+
// both are set by `runAsRenderer` below and an external request can't forge either.
|
|
23
|
+
const res = await runAsRenderer(() => useNitroApp().localFetch(route, { method: 'GET' }))
|
|
24
|
+
if (res.status !== 200) return { body: null, status: res.status }
|
|
25
|
+
return { body: Buffer.from(await res.arrayBuffer()), status: res.status }
|
|
26
|
+
}
|