@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,370 @@
|
|
|
1
|
+
import { readdirSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join, relative, sep, extname } from 'node:path'
|
|
3
|
+
import { setTimeout as sleepFor } from 'node:timers/promises'
|
|
4
|
+
import { resolveS3Settings } from '../../../core/server/utils/kestrel-config'
|
|
5
|
+
import type { StorageDriver } from '../../../core/server/utils/storage'
|
|
6
|
+
|
|
7
|
+
const MIME: Record<string, string> = {
|
|
8
|
+
// markup / scripts / data
|
|
9
|
+
'.html': 'text/html; charset=utf-8',
|
|
10
|
+
'.css': 'text/css; charset=utf-8',
|
|
11
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
12
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
13
|
+
'.json': 'application/json; charset=utf-8',
|
|
14
|
+
'.map': 'application/json; charset=utf-8',
|
|
15
|
+
'.xml': 'application/xml; charset=utf-8',
|
|
16
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
17
|
+
'.csv': 'text/csv; charset=utf-8',
|
|
18
|
+
'.md': 'text/markdown; charset=utf-8',
|
|
19
|
+
'.wasm': 'application/wasm',
|
|
20
|
+
'.pdf': 'application/pdf',
|
|
21
|
+
'.webmanifest': 'application/manifest+json',
|
|
22
|
+
// images
|
|
23
|
+
'.svg': 'image/svg+xml',
|
|
24
|
+
'.ico': 'image/x-icon',
|
|
25
|
+
'.png': 'image/png',
|
|
26
|
+
'.jpg': 'image/jpeg',
|
|
27
|
+
'.jpeg': 'image/jpeg',
|
|
28
|
+
'.gif': 'image/gif',
|
|
29
|
+
'.webp': 'image/webp',
|
|
30
|
+
'.avif': 'image/avif',
|
|
31
|
+
'.apng': 'image/apng',
|
|
32
|
+
'.bmp': 'image/bmp',
|
|
33
|
+
// fonts
|
|
34
|
+
'.woff': 'font/woff',
|
|
35
|
+
'.woff2': 'font/woff2',
|
|
36
|
+
'.ttf': 'font/ttf',
|
|
37
|
+
'.otf': 'font/otf',
|
|
38
|
+
'.eot': 'application/vnd.ms-fontobject',
|
|
39
|
+
// audio / video
|
|
40
|
+
'.mp3': 'audio/mpeg',
|
|
41
|
+
'.ogg': 'audio/ogg',
|
|
42
|
+
'.wav': 'audio/wav',
|
|
43
|
+
'.m4a': 'audio/mp4',
|
|
44
|
+
'.mp4': 'video/mp4',
|
|
45
|
+
'.webm': 'video/webm',
|
|
46
|
+
'.mov': 'video/quicktime',
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** MIME type for a static output file, inferred from its extension; octet-stream when unknown. A
|
|
50
|
+
* pre-compressed sibling (`app.js.br`) carries the encoding in `Content-Encoding`, so its type is the
|
|
51
|
+
* underlying asset's — strip a trailing `.br`/`.gz` before the lookup. Only strip it when a
|
|
52
|
+
* `Content-Encoding` will actually be sent (`hasEncoding`, default true for existing callers): a
|
|
53
|
+
* standalone archive with no such header must keep its own extension, or it ships mislabelled as the
|
|
54
|
+
* underlying (uncompressed) type — e.g. a browser trying to parse raw gzip bytes as JSON. */
|
|
55
|
+
export function contentTypeFor(filename: string, hasEncoding = true): string {
|
|
56
|
+
const base = hasEncoding ? filename.replace(/\.(?:br|gz)$/i, '') : filename
|
|
57
|
+
return MIME[extname(base).toLowerCase()] ?? 'application/octet-stream'
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The `Content-Encoding` for a precompressed sidecar (`app.js.br` beside `app.js`), or undefined for a
|
|
61
|
+
* standalone archive with no uncompressed base — tagging that would make a browser transparently decode
|
|
62
|
+
* genuine content and corrupt it. `siblingNames` are the file names in the same directory. */
|
|
63
|
+
export function precompressedEncoding(filename: string, siblingNames: string[]): 'br' | 'gzip' | undefined {
|
|
64
|
+
const m = /\.(br|gz)$/.exec(filename)
|
|
65
|
+
if (!m || !siblingNames.includes(filename.slice(0, m.index))) return undefined
|
|
66
|
+
return m[1] === 'br' ? 'br' : 'gzip'
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const IMMUTABLE_CACHE = 'public, max-age=31536000, immutable'
|
|
70
|
+
const REVALIDATE_CACHE = 'public, max-age=0, must-revalidate'
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* `Cache-Control` for a static-output key, or `undefined` for no explicit policy. Content-hashed
|
|
74
|
+
* `_nuxt/` assets get a year + `immutable` (the hash is the cache key — new content ⇒ new URL). HTML,
|
|
75
|
+
* the sitemap and `robots.txt` live at *stable* URLs whose content changes on any deploy, so they get
|
|
76
|
+
* `max-age=0, must-revalidate` (cacheable but always revalidated). Everything else (favicons, fonts,
|
|
77
|
+
* un-hashed media) is left to the host default.
|
|
78
|
+
*/
|
|
79
|
+
export function cacheControlFor(key: string): string | undefined {
|
|
80
|
+
// Nuxt's app manifest lives at a STABLE _nuxt URL but its content (the buildId) changes every build, so it
|
|
81
|
+
// must revalidate — else the immutable rule below pins it and clients never detect a new deploy.
|
|
82
|
+
if (key === '_nuxt/builds/latest.json') return REVALIDATE_CACHE
|
|
83
|
+
if (key === '_nuxt' || key.startsWith('_nuxt/')) return IMMUTABLE_CACHE
|
|
84
|
+
const base = key.split('/').pop() ?? key
|
|
85
|
+
if (base.endsWith('.html') || base === 'sitemap.xml' || base === 'robots.txt' || base === 'llms.txt') return REVALIDATE_CACHE
|
|
86
|
+
return undefined
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A boolean env flag is on for any of the common truthy spellings (case-insensitive, trimmed). */
|
|
90
|
+
export function isEnvTrue(value: string | undefined): boolean {
|
|
91
|
+
const t = value?.trim().toLowerCase()
|
|
92
|
+
return t === 'true' || t === '1' || t === 'yes' || t === 'on'
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Decide what the deploy hook should do, kept pure so the trigger guard is unit-testable. Returns
|
|
97
|
+
* `'skip'` when this isn't a static `nuxt generate` — a plain `nuxt build` (or dev) must never deploy,
|
|
98
|
+
* even with `driver: s3` configured — and when `output.auto` hands publishing to the running server
|
|
99
|
+
* (see `autoPublish`). For a real (non-dry-run) static generate it throws when the
|
|
100
|
+
* bucket or `KESTREL_S3_*` credentials are missing, so a misconfigured deploy fails the generate
|
|
101
|
+
* loudly instead of warning and exiting 0 with a stale live bucket. A dry-run needs neither.
|
|
102
|
+
*/
|
|
103
|
+
/** Strip leading/trailing slashes so prefixes compare on bare path segments. */
|
|
104
|
+
const normPrefix = (p: string): string => p.replace(/^\/+|\/+$/g, '')
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Do two S3 key prefixes share keyspace on a path-segment boundary — i.e. is one equal to, or nested
|
|
108
|
+
* under, the other? Catches both nesting directions: a prune of `a` reaches everything under `a/b`, and
|
|
109
|
+
* a prune of `a/b` reaches the `a/b/…` slice of media stored under `a`. A pure-string collision like
|
|
110
|
+
* `site` vs `siteother` is *not* an overlap. Empty prefixes never overlap (handled by their own guards).
|
|
111
|
+
*/
|
|
112
|
+
function prefixesOverlap(a: string, b: string): boolean {
|
|
113
|
+
const x = normPrefix(a)
|
|
114
|
+
const y = normPrefix(b)
|
|
115
|
+
if (!x || !y) return false
|
|
116
|
+
return x === y || x.startsWith(`${y}/`) || y.startsWith(`${x}/`)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function planS3Deploy(opts: {
|
|
120
|
+
isStaticGenerate: boolean
|
|
121
|
+
dryRun: boolean
|
|
122
|
+
bucket: string
|
|
123
|
+
accessKeyId: string
|
|
124
|
+
secretAccessKey: string
|
|
125
|
+
prefix?: string
|
|
126
|
+
/**
|
|
127
|
+
* `output.auto` — the running server publishes to this bucket/prefix itself. Build-time route seeding is
|
|
128
|
+
* then disabled (a prerendered file would shadow the live route), so `.output/public` holds only what
|
|
129
|
+
* Nitro's crawler reached: shipping it would reconcile the server's live pages, sitemap and robots away.
|
|
130
|
+
*/
|
|
131
|
+
autoPublish?: boolean
|
|
132
|
+
/** Bucket the S3 media driver writes live uploads to (empty when media isn't on S3 — then irrelevant). */
|
|
133
|
+
mediaBucket?: string
|
|
134
|
+
/** Key prefix of those live media objects, to keep the reconcile from deleting them. */
|
|
135
|
+
mediaPrefix?: string
|
|
136
|
+
}): 'skip' | 'deploy' {
|
|
137
|
+
if (!opts.isStaticGenerate) return 'skip'
|
|
138
|
+
if (opts.autoPublish) return 'skip'
|
|
139
|
+
if (!opts.dryRun) {
|
|
140
|
+
const missing: string[] = []
|
|
141
|
+
if (!opts.bucket) missing.push('bucket (KESTREL_OUTPUT_S3_BUCKET)')
|
|
142
|
+
if (!opts.accessKeyId) missing.push('KESTREL_OUTPUT_S3_ACCESS_KEY_ID / KESTREL_S3_ACCESS_KEY_ID')
|
|
143
|
+
if (!opts.secretAccessKey) missing.push('KESTREL_OUTPUT_S3_SECRET_ACCESS_KEY / KESTREL_S3_SECRET_ACCESS_KEY')
|
|
144
|
+
if (missing.length) {
|
|
145
|
+
throw new Error(
|
|
146
|
+
`[kestrel] output.driver=s3 but ${missing.join(', ')} ${missing.length > 1 ? 'are' : 'is'} missing`
|
|
147
|
+
+ ' — refusing to finish `nuxt generate` with a broken S3 deploy.',
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
// The deploy always reconciles (deletes every remote object under the prefix not in this generate —
|
|
151
|
+
// output ≡ DB). With an empty prefix that's the whole bucket root, which may hold the media bucket's
|
|
152
|
+
// objects too. Refuse rather than risk wiping it: an S3 deploy must target a dedicated prefix.
|
|
153
|
+
if (!opts.prefix) {
|
|
154
|
+
throw new Error(
|
|
155
|
+
'[kestrel] output.s3.prefix is empty — refusing to deploy (the always-on reconcile would delete the'
|
|
156
|
+
+ ' whole bucket root, and a shared/media bucket could be wiped). Set KESTREL_OUTPUT_S3_PREFIX to a'
|
|
157
|
+
+ ' prefix dedicated to the generated site.',
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
// Same bucket as the S3 media driver AND overlapping prefixes → the reconcile would delete live uploads.
|
|
161
|
+
if (opts.mediaBucket && opts.mediaBucket === opts.bucket
|
|
162
|
+
&& opts.mediaPrefix && prefixesOverlap(opts.prefix, opts.mediaPrefix)) {
|
|
163
|
+
throw new Error(
|
|
164
|
+
`[kestrel] the output reconcile would delete live media: output prefix "${opts.prefix}" overlaps the`
|
|
165
|
+
+ ` S3 media prefix "${opts.mediaPrefix}" in the same bucket "${opts.bucket}". Give the generated`
|
|
166
|
+
+ ' site its own bucket or a non-overlapping KESTREL_OUTPUT_S3_PREFIX.',
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return 'deploy'
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export interface OutputTarget {
|
|
174
|
+
driver: 'local' | 's3'
|
|
175
|
+
/** Non-secret S3 settings; credentials are resolved separately by `resolveOutputCreds`. */
|
|
176
|
+
s3: { bucket: string; region: string; endpoint: string; prefix: string }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
type OutputConfig = { driver?: string; s3?: { bucket?: string; region?: string; endpoint?: string; prefix?: string } }
|
|
180
|
+
const clean = (v: unknown): string | undefined => (typeof v === 'string' && v.trim() ? v.trim() : undefined)
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Resolve where `nuxt generate`'s static output is shipped: a local directory (default) or an S3
|
|
184
|
+
* bucket. Precedence per setting: `KESTREL_OUTPUT_*` env → config → default (the documented Kestrel
|
|
185
|
+
* order, same as the runtime publisher). Pure, so the precedence is unit-testable; secrets are resolved
|
|
186
|
+
* by `resolveOutputCreds`, never here.
|
|
187
|
+
*/
|
|
188
|
+
export function resolveOutputTarget(out: OutputConfig = {}, env: Record<string, string | undefined> = {}): OutputTarget {
|
|
189
|
+
const driver = (clean(env.KESTREL_OUTPUT_DRIVER) ?? clean(out.driver))?.toLowerCase() === 's3' ? 's3' : 'local'
|
|
190
|
+
// Same non-secret resolver media uses, under the KESTREL_OUTPUT_S3_* prefix (own bucket, own creds).
|
|
191
|
+
return { driver, s3: resolveS3Settings(out.s3, env, 'KESTREL_OUTPUT_S3') }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* S3 credentials for the output deploy: the output-specific `KESTREL_OUTPUT_S3_*`, falling back to the
|
|
196
|
+
* shared media `KESTREL_S3_*` so a single S3 account "just works" — identical to the runtime publisher's
|
|
197
|
+
* resolution. Required keys default to '' (not undefined) so `planS3Deploy` detects a missing credential.
|
|
198
|
+
*/
|
|
199
|
+
export function resolveOutputCreds(env: Record<string, string | undefined> = {}): { accessKeyId: string; secretAccessKey: string; sessionToken?: string } {
|
|
200
|
+
return {
|
|
201
|
+
accessKeyId: clean(env.KESTREL_OUTPUT_S3_ACCESS_KEY_ID) ?? clean(env.KESTREL_S3_ACCESS_KEY_ID) ?? '',
|
|
202
|
+
secretAccessKey: clean(env.KESTREL_OUTPUT_S3_SECRET_ACCESS_KEY) ?? clean(env.KESTREL_S3_SECRET_ACCESS_KEY) ?? '',
|
|
203
|
+
sessionToken: clean(env.KESTREL_OUTPUT_S3_SESSION_TOKEN) ?? clean(env.KESTREL_S3_SESSION_TOKEN),
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Every file under `dir` as `{ abs, key }`, the key being its POSIX-relative path (stable order). */
|
|
208
|
+
function listFiles(dir: string): Array<{ abs: string; key: string }> {
|
|
209
|
+
return readdirSync(dir, { recursive: true, withFileTypes: true })
|
|
210
|
+
.filter((e) => e.isFile())
|
|
211
|
+
.map((e) => {
|
|
212
|
+
const abs = join(e.parentPath, e.name)
|
|
213
|
+
return { abs, key: relative(dir, abs).split(sep).join('/') }
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* A `*.gz`/`*.br` file is a pre-compressed sidecar (from `nitro.compressPublicAssets`) when its
|
|
219
|
+
* uncompressed base is also in the output. On a plain static S3 host these are dead weight — the host
|
|
220
|
+
* does no `Accept-Encoding` negotiation, so nobody fetches `index.html.gz`, and storing it *as*
|
|
221
|
+
* `index.html` with `Content-Encoding` would collide with the real file — so the deploy skips them.
|
|
222
|
+
* A standalone `*.gz` archive (no base file) is genuine content and is kept.
|
|
223
|
+
*/
|
|
224
|
+
export function isCompressedSidecar(key: string, all: Set<string>): boolean {
|
|
225
|
+
const m = /\.(?:gz|br)$/.exec(key)
|
|
226
|
+
return m ? all.has(key.slice(0, m.index)) : false
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export interface DeployResult { pruned: number; keys: string[] }
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* What build-time route discovery enumerated for this generate, plus WHY that enumeration is known to be
|
|
233
|
+
* short of the site's pages (`incomplete` unset ⇒ it is complete). The runtime publisher's
|
|
234
|
+
* `{ routes, failed }` is the same contract: an enumeration that came up short must never be the
|
|
235
|
+
* authority for what gets deleted.
|
|
236
|
+
*/
|
|
237
|
+
export interface RouteDiscovery { routes: string[]; incomplete?: string }
|
|
238
|
+
|
|
239
|
+
interface DiscoveryHost { _kestrelRouteDiscovery?: RouteDiscovery }
|
|
240
|
+
|
|
241
|
+
/** The prerender and deploy modules are loaded separately and share no module scope — but they are handed
|
|
242
|
+
* the same Nuxt instance, so that object is what carries the signal from the one to the other. */
|
|
243
|
+
export function recordRouteDiscovery(nuxt: object, discovery: RouteDiscovery): void {
|
|
244
|
+
(nuxt as DiscoveryHost)._kestrelRouteDiscovery = discovery
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** `undefined` ⇒ discovery never reported anything, which is not itself evidence of a degraded build. */
|
|
248
|
+
export function readRouteDiscovery(nuxt: object): RouteDiscovery | undefined {
|
|
249
|
+
return (nuxt as DiscoveryHost)._kestrelRouteDiscovery
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** The deploy needs `put` always, and `list`+`delete` for the reconcile; both are optional on the driver. */
|
|
253
|
+
type DeployDriver = Pick<StorageDriver, 'put'> & Partial<Pick<StorageDriver, 'list' | 'delete'>>
|
|
254
|
+
|
|
255
|
+
export interface DeployOptions {
|
|
256
|
+
dryRun?: boolean
|
|
257
|
+
log?: (msg: string) => void
|
|
258
|
+
/** Max operations in flight at once (default 8). Bounded so a large tree never opens thousands of sockets. */
|
|
259
|
+
concurrency?: number
|
|
260
|
+
/** Total attempts per operation before giving up (default 3); retries ride out transient S3 errors. */
|
|
261
|
+
retries?: number
|
|
262
|
+
/** Sleep between retries (exponential backoff); injectable so tests run without real delay. */
|
|
263
|
+
sleep?: (ms: number) => Promise<void>
|
|
264
|
+
/**
|
|
265
|
+
* Why a build step REPORTED that this output tree is short of pages (routes that failed to prerender,
|
|
266
|
+
* route discovery that could not read the DB). Set ⇒ upload but never reconcile: pages missing from the
|
|
267
|
+
* input are indistinguishable from pages removed from the CMS, so the prune would delete live ones.
|
|
268
|
+
* Only a step's own failure report belongs here: the SHAPE of the output cannot tell a degraded build
|
|
269
|
+
* from a small site, so inferring it would strand the pages an editor deleted.
|
|
270
|
+
*/
|
|
271
|
+
incomplete?: string
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Run `task` over `items` with at most `concurrency` in flight at once. */
|
|
275
|
+
async function runPool<T>(items: T[], concurrency: number, task: (item: T) => Promise<void>): Promise<void> {
|
|
276
|
+
let next = 0
|
|
277
|
+
const worker = async (): Promise<void> => {
|
|
278
|
+
while (next < items.length) {
|
|
279
|
+
const i = next++
|
|
280
|
+
await task(items[i])
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker))
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** HTTP statuses the S3 driver surfaces in its error message (e.g. `S3 put failed (403) for x`). */
|
|
287
|
+
function statusFromError(err: unknown): number | undefined {
|
|
288
|
+
const m = /\((\d{3})\)/.exec((err as Error)?.message ?? '')
|
|
289
|
+
return m ? Number(m[1]) : undefined
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* A permanent client error — bad/forbidden credentials, missing bucket, denied access (400/401/403/404).
|
|
294
|
+
* These never become successful on a later attempt, so retrying only delays the inevitable loud failure.
|
|
295
|
+
*/
|
|
296
|
+
export function isPermanentError(err: unknown): boolean {
|
|
297
|
+
const s = statusFromError(err)
|
|
298
|
+
return s === 400 || s === 401 || s === 403 || s === 404
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Retry `fn` with exponential backoff up to `retries` total attempts, surfacing the last error. */
|
|
302
|
+
async function withRetry(fn: () => Promise<void>, label: string, retries: number, sleep: (ms: number) => Promise<void>, log?: (msg: string) => void): Promise<void> {
|
|
303
|
+
for (let attempt = 1; ; attempt++) {
|
|
304
|
+
try {
|
|
305
|
+
await fn()
|
|
306
|
+
return
|
|
307
|
+
} catch (err) {
|
|
308
|
+
// A permanent 4xx can't be ridden out — re-throw at once so the deploy fails loudly, not slowly.
|
|
309
|
+
if (isPermanentError(err) || attempt >= retries) throw err
|
|
310
|
+
log?.(`retry ${attempt}/${retries - 1} for ${label}: ${(err as Error).message}`)
|
|
311
|
+
await sleep(100 * 2 ** (attempt - 1))
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Upload every file under `dir` to `driver`, keyed by its POSIX-relative path, with a content type
|
|
318
|
+
* inferred from the extension — the S3 equivalent of `aws s3 sync` (overwrites). Then reconciles: deletes
|
|
319
|
+
* remote objects under the prefix that this generate didn't produce (`--delete`), so pages removed from
|
|
320
|
+
* the CMS stop being served (output ≡ DB, no toggle). The reconcile is skipped when the driver can't
|
|
321
|
+
* list+delete and when the caller flagged the tree `incomplete` — a build step reported it is short of
|
|
322
|
+
* pages, so their absence says nothing about the CMS. Uploads still run then: shipping the pages that did
|
|
323
|
+
* render is safe, deleting on their absence is not.
|
|
324
|
+
* Uploads and deletes run with bounded concurrency and each is retried with
|
|
325
|
+
* backoff, so a transient 5xx/throttle/dropped-connection no longer aborts the deploy mid-way and
|
|
326
|
+
* half-updates the bucket; only an operation that fails every attempt rejects (true rollback isn't
|
|
327
|
+
* possible on S3). With `dryRun`, walks/lists and reports without writing.
|
|
328
|
+
*/
|
|
329
|
+
export async function deployStaticOutput(
|
|
330
|
+
dir: string,
|
|
331
|
+
driver: DeployDriver,
|
|
332
|
+
opts: DeployOptions = {},
|
|
333
|
+
): Promise<DeployResult> {
|
|
334
|
+
const concurrency = Math.max(1, opts.concurrency ?? 8)
|
|
335
|
+
const retries = Math.max(1, opts.retries ?? 3)
|
|
336
|
+
const sleep = opts.sleep ?? ((ms: number) => sleepFor(ms))
|
|
337
|
+
const all = listFiles(dir)
|
|
338
|
+
const allKeys = new Set(all.map((f) => f.key))
|
|
339
|
+
const files = all.filter((f) => !isCompressedSidecar(f.key, allKeys))
|
|
340
|
+
const skipped = all.length - files.length
|
|
341
|
+
if (skipped) opts.log?.(`skipping ${skipped} pre-compressed sidecar(s) (.gz/.br)`)
|
|
342
|
+
const keys = files.map((f) => f.key)
|
|
343
|
+
|
|
344
|
+
if (!opts.dryRun) {
|
|
345
|
+
// `files` already excludes pre-compressed sidecars (above), so anything ending `.gz`/`.br` here is a
|
|
346
|
+
// standalone archive shipped with no `Content-Encoding` — keep its own extension in the type lookup.
|
|
347
|
+
await runPool(files, concurrency, (f) =>
|
|
348
|
+
withRetry(() => driver.put(f.key, readFileSync(f.abs), contentTypeFor(f.key, false), { cacheControl: cacheControlFor(f.key) }), f.key, retries, sleep, opts.log))
|
|
349
|
+
}
|
|
350
|
+
opts.log?.(`${opts.dryRun ? 'Would deploy' : 'Deployed'} ${keys.length} file(s)`)
|
|
351
|
+
|
|
352
|
+
// Reconcile (output ≡ DB) — skipped when the driver lacks list/delete (e.g. a put-only target) and when
|
|
353
|
+
// a build step reported that this generate is short of pages.
|
|
354
|
+
let pruned = 0
|
|
355
|
+
if (!driver.list || !driver.delete) {
|
|
356
|
+
opts.log?.('driver cannot list/delete — skipping reconcile')
|
|
357
|
+
} else if (opts.incomplete) {
|
|
358
|
+
opts.log?.(`skipping reconcile — ${opts.incomplete}; the missing pages would read as stale and be deleted`)
|
|
359
|
+
} else {
|
|
360
|
+
const keep = new Set(keys)
|
|
361
|
+
const stale = (await driver.list()).filter((k) => !keep.has(k))
|
|
362
|
+
if (!opts.dryRun) {
|
|
363
|
+
const del = driver.delete
|
|
364
|
+
await runPool(stale, concurrency, (k) => withRetry(() => del(k), `delete ${k}`, retries, sleep, opts.log))
|
|
365
|
+
pruned = stale.length
|
|
366
|
+
}
|
|
367
|
+
opts.log?.(`${opts.dryRun ? 'Would prune' : 'Pruned'} ${stale.length} stale object(s)`)
|
|
368
|
+
}
|
|
369
|
+
return { pruned, keys }
|
|
370
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { defineNuxtModule } from '@nuxt/kit'
|
|
2
|
+
import { createS3Driver } from '../../../core/server/utils/storage.s3'
|
|
3
|
+
import { resolveOutputCreds, deployStaticOutput, isEnvTrue, planS3Deploy, readRouteDiscovery } from './deploy-output'
|
|
4
|
+
import { resolveKestrel } from '../../../core/server/utils/kestrel-config'
|
|
5
|
+
import type { KestrelConfig } from '../../../core/server/utils/kestrel-config'
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Ships `nuxt generate`'s static output (`.output/public`) to an S3 bucket when `kestrel.output.driver`
|
|
10
|
+
* is `s3` — the deploy half of FEATURES "Output target: local directory or S3 bucket". Runs on Nitro's
|
|
11
|
+
* `compiled` hook (which fires once the full output is on disk — prerendered HTML, the `_nuxt` client
|
|
12
|
+
* bundle + static `public/` assets copied by `copyPublicAssets`, and any compressed variants — i.e. after
|
|
13
|
+
* prerendering, which does NOT abort on a route error unless `prerender.failOnError` is set: hence the
|
|
14
|
+
* `incomplete` signal below), and only when `nitro.options.static` is set, i.e. a real `nuxt generate`.
|
|
15
|
+
* A plain `nuxt build`/`dev` never deploys.
|
|
16
|
+
* Nor does it with `output.auto` on — then the running server owns publishing (see the `auto` note below).
|
|
17
|
+
* With `driver: local` (the default) it does nothing — the local `.output/public` tree is the artifact.
|
|
18
|
+
* S3 credentials are env-only (`KESTREL_OUTPUT_S3_*`, falling back to the shared media `KESTREL_S3_*`).
|
|
19
|
+
*/
|
|
20
|
+
export default defineNuxtModule({
|
|
21
|
+
meta: { name: 'kestrel-deploy-output' },
|
|
22
|
+
setup(_options, nuxt) {
|
|
23
|
+
// Build-time: resolve from the consumer's config through the one shared resolver, so driver, prefixes
|
|
24
|
+
// and `auto` here are byte-identical to what the runtime publisher and the media driver see — an
|
|
25
|
+
// ad-hoc env probe that disagrees (env-first vs config-first) would silently disarm the guards below.
|
|
26
|
+
const c = resolveKestrel(nuxt.options.kestrel as KestrelConfig, process.env, nuxt.options.rootDir)
|
|
27
|
+
if (c.output.driver !== 's3') return
|
|
28
|
+
const target = c.output.s3
|
|
29
|
+
|
|
30
|
+
// Where the S3 media driver keeps live uploads — used to stop the reconcile from wiping them when the
|
|
31
|
+
// generated site shares its bucket. Undefined (media on the local driver) makes the guard a no-op.
|
|
32
|
+
const mediaS3 = c.media.driver === 's3' ? c.media.s3 : undefined
|
|
33
|
+
|
|
34
|
+
nuxt.hook('nitro:init', (nitro) => {
|
|
35
|
+
// Nitro prerenders BEFORE the build that fires `compiled`, and `prerender.failOnError` is off by
|
|
36
|
+
// default — a route that 5xx'd leaves no file and does not abort the generate. Remember that here:
|
|
37
|
+
// its live page is still there and must not be mistaken for content removed from the CMS.
|
|
38
|
+
let failedRoutes = 0
|
|
39
|
+
nitro.hooks.hook('prerender:done', ({ failedRoutes: failed }) => { failedRoutes = failed?.length ?? 0 })
|
|
40
|
+
|
|
41
|
+
nitro.hooks.hook('compiled', async () => {
|
|
42
|
+
const env = process.env
|
|
43
|
+
const creds = resolveOutputCreds(env)
|
|
44
|
+
const dryRun = isEnvTrue(env.KESTREL_OUTPUT_DRY_RUN)
|
|
45
|
+
// Skip on a plain build (no static generate) or when the runtime publisher owns the bucket;
|
|
46
|
+
// throw on a misconfigured real deploy or unsafe reconcile.
|
|
47
|
+
if (planS3Deploy({
|
|
48
|
+
isStaticGenerate: nitro.options.static === true,
|
|
49
|
+
autoPublish: c.output.auto,
|
|
50
|
+
dryRun,
|
|
51
|
+
bucket: target.bucket,
|
|
52
|
+
accessKeyId: creds.accessKeyId,
|
|
53
|
+
secretAccessKey: creds.secretAccessKey,
|
|
54
|
+
prefix: target.prefix,
|
|
55
|
+
mediaBucket: mediaS3?.bucket,
|
|
56
|
+
mediaPrefix: mediaS3?.prefix,
|
|
57
|
+
}) === 'skip') {
|
|
58
|
+
// A plain build skips silently; someone who actually ran `nuxt generate` deserves to hear why
|
|
59
|
+
// nothing shipped.
|
|
60
|
+
if (nitro.options.static === true && c.output.auto) {
|
|
61
|
+
console.log('[kestrel] output.auto is on — the running server publishes to this bucket; not shipping'
|
|
62
|
+
+ ' .output/public. Set output.auto=false (or KESTREL_OUTPUT_AUTO=false) for the build-time model.')
|
|
63
|
+
}
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const driver = createS3Driver({ ...target, publicBaseUrl: '', ...creds })
|
|
68
|
+
console.log(`[kestrel] ${dryRun ? 'dry-run: ' : ''}shipping static output → s3://${target.bucket || '<bucket>'}/${target.prefix}`)
|
|
69
|
+
// Both signals are REPORTED failures of a step that knows: route discovery could not enumerate the
|
|
70
|
+
// pages, or Nitro could not render some of them. Neither is inferred from how the tree looks —
|
|
71
|
+
// no shape of output distinguishes a degraded build from a small site.
|
|
72
|
+
const discovery = readRouteDiscovery(nuxt)
|
|
73
|
+
const res = await deployStaticOutput(nitro.options.output.publicDir, driver, {
|
|
74
|
+
dryRun,
|
|
75
|
+
incomplete: discovery?.incomplete ?? (failedRoutes ? `${failedRoutes} route(s) failed to prerender` : undefined),
|
|
76
|
+
log: (m) => console.log(`[kestrel] ${m}`),
|
|
77
|
+
})
|
|
78
|
+
console.log(`[kestrel] static output ${dryRun ? 'walk' : 'deploy'} complete — ${res.keys.length} file(s), ${res.pruned} pruned.`)
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
},
|
|
82
|
+
})
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type Database from 'better-sqlite3'
|
|
2
|
+
import { pagesToRoutes, type PageRouteRow } from './routes'
|
|
3
|
+
|
|
4
|
+
/** Tables the schema engine marked routable, by their partial `path` index (see `collectPageRoutes`).
|
|
5
|
+
* Empty means this file holds no page-like collection at all — a structural fact about the schema, not a
|
|
6
|
+
* statement about how many pages exist: a CMS with one page still has its table and that index. */
|
|
7
|
+
export function pageLikeTables(db: Database.Database): string[] {
|
|
8
|
+
return (db
|
|
9
|
+
.prepare("SELECT DISTINCT tbl_name AS name FROM sqlite_master WHERE type = 'index' AND sql LIKE '%path is not null%'")
|
|
10
|
+
.all() as { name: string }[]).map((t) => t.name)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Page routes to seed for `nuxt generate`, discovered straight from the build-time DB (no registry is
|
|
15
|
+
* available at build). Every page-like collection carries a **partial unique index on `path`**
|
|
16
|
+
* (`… WHERE path is not null`) — the schema engine's marker for a routable table — so we enumerate those.
|
|
17
|
+
* That is reliable where "has a `path` column" is NOT: the media `folders` registry has a `path` column +
|
|
18
|
+
* a plain unique index, but no such partial clause, so it is correctly excluded. Per pageLike table:
|
|
19
|
+
* published rows (only where a `status` column exists) with a non-null path, keyed by their own `locale`
|
|
20
|
+
* (or the primary locale for a non-translatable collection). Returns the deduped, sorted route list
|
|
21
|
+
* (always including the root).
|
|
22
|
+
*/
|
|
23
|
+
export function collectPageRoutes(db: Database.Database, primaryLocale: string, prefixPrimary = false): string[] {
|
|
24
|
+
const rows: PageRouteRow[] = []
|
|
25
|
+
for (const name of pageLikeTables(db)) {
|
|
26
|
+
const q = `"${name.replace(/"/g, '""')}"`
|
|
27
|
+
const cols = (db.prepare(`PRAGMA table_info(${q})`).all() as { name: string }[]).map((c) => c.name)
|
|
28
|
+
const statusClause = cols.includes('status') ? "status = 'published' AND " : ''
|
|
29
|
+
if (cols.includes('locale')) {
|
|
30
|
+
rows.push(...(db.prepare(`SELECT path, locale FROM ${q} WHERE ${statusClause}path IS NOT NULL`).all() as PageRouteRow[]))
|
|
31
|
+
} else {
|
|
32
|
+
const bare = db.prepare(`SELECT path FROM ${q} WHERE ${statusClause}path IS NOT NULL`).all() as { path: string | null }[]
|
|
33
|
+
for (const r of bare) rows.push({ path: r.path, locale: primaryLocale })
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return pagesToRoutes(rows, primaryLocale, prefixPrimary)
|
|
37
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { cpus } from 'node:os'
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
3
|
+
import { defineNuxtModule } from '@nuxt/kit'
|
|
4
|
+
import Database from 'better-sqlite3'
|
|
5
|
+
import { collectPageRoutes, pageLikeTables } from './discover'
|
|
6
|
+
import { localePath } from '../../../core/app/utils/locale-path'
|
|
7
|
+
import { recordRouteDiscovery, type RouteDiscovery } from '../deploy-output/deploy-output'
|
|
8
|
+
import { resolveKestrel, type KestrelConfig } from '../../../core/server/utils/kestrel-config'
|
|
9
|
+
|
|
10
|
+
// Read published paths from EVERY page-like collection straight from the DB at build time so
|
|
11
|
+
// `nuxt generate` prerenders one HTML file per page (see `collectPageRoutes` for how pageLike tables
|
|
12
|
+
// are identified). The server's migrate-on-boot hasn't run yet at this point, so a DB that isn't there
|
|
13
|
+
// yet degrades to the root alone — reported as `incomplete`, because a deploy that reconciles must be
|
|
14
|
+
// able to tell that degrade apart from a CMS that genuinely holds one page.
|
|
15
|
+
// `reconciles` (an S3 deploy follows this build) makes an unreadable-but-present DB fail the build
|
|
16
|
+
// instead: there the degraded tree would also be uploaded over a real live site. Without that step there
|
|
17
|
+
// is nothing destructive downstream, so a degrade stays a warning.
|
|
18
|
+
export function discoverRoutes(path: string, primary: string, prefixPrimary: boolean, reconciles = false): RouteDiscovery {
|
|
19
|
+
const root = localePath('/', primary, primary, prefixPrimary) // `/` or `/<primary>` when the primary is prefixed
|
|
20
|
+
if (path === ':memory:') return { routes: [root], incomplete: 'the build ran against an in-memory database — only the site root was prerendered' }
|
|
21
|
+
if (!existsSync(path)) {
|
|
22
|
+
console.warn(`[kestrel] prerender: no database at ${path} — prerendering the site root only`)
|
|
23
|
+
return { routes: [root], incomplete: `no database at ${path} — only the site root was prerendered` }
|
|
24
|
+
}
|
|
25
|
+
let db: Database.Database | undefined
|
|
26
|
+
try {
|
|
27
|
+
db = new Database(path, { readonly: true, fileMustExist: true })
|
|
28
|
+
// Opening the file is not enumerating it: an unmigrated, zero-byte or simply wrong `KESTREL_DB` file
|
|
29
|
+
// opens as a valid EMPTY database, and the root-only route list it yields is byte-identical to a
|
|
30
|
+
// complete enumeration. Report the missing schema so a reconcile downstream can tell the two apart.
|
|
31
|
+
if (!pageLikeTables(db).length) {
|
|
32
|
+
const why = `the database at ${path} holds no page-like table — only the site root was prerendered`
|
|
33
|
+
console.warn(`[kestrel] prerender: ${why}`)
|
|
34
|
+
return { routes: [root], incomplete: why }
|
|
35
|
+
}
|
|
36
|
+
return { routes: collectPageRoutes(db, primary, prefixPrimary) }
|
|
37
|
+
} catch (error) {
|
|
38
|
+
const why = `cannot read the database at ${path}: ${(error as Error)?.message ?? error}`
|
|
39
|
+
if (reconciles) throw new Error(`[kestrel] prerender: ${why} — refusing to ship a site with no pages to an S3 target`)
|
|
40
|
+
console.warn(`[kestrel] prerender: ${why} — prerendering the site root only`)
|
|
41
|
+
return { routes: [root], incomplete: why }
|
|
42
|
+
} finally {
|
|
43
|
+
db?.close()
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export default defineNuxtModule({
|
|
48
|
+
meta: { name: 'kestrel-prerender-routes' },
|
|
49
|
+
setup(_options, nuxt) {
|
|
50
|
+
// Build-time: resolve directly from the consumer's config (runtimeConfig/useRuntimeConfig aren't
|
|
51
|
+
// available here), rather than the request-time locale.ts globals.
|
|
52
|
+
const c = resolveKestrel(nuxt.options.kestrel as KestrelConfig, process.env, nuxt.options.rootDir)
|
|
53
|
+
// The editor-preview fallback page must NEVER be prerendered: the classic `nuxt generate` (static
|
|
54
|
+
// preset) auto-seeds every static page path, and this page 404s without an admin session — a
|
|
55
|
+
// guaranteed prerender error on every SSG build. Registered unconditionally (before the output.auto
|
|
56
|
+
// early-return) so both publishing models exclude it.
|
|
57
|
+
nuxt.hook('nitro:config', (nitro) => {
|
|
58
|
+
nitro.prerender ||= {}
|
|
59
|
+
nitro.prerender.ignore = [...(nitro.prerender.ignore ?? []), '/__kestrel/preview']
|
|
60
|
+
})
|
|
61
|
+
// The runtime publisher (output.auto) and build-time prerendering are mutually exclusive: a prerendered
|
|
62
|
+
// `.output/public/<route>/index.html` SHADOWS the live SSR route, so the publisher's localFetch would
|
|
63
|
+
// read STALE build-time HTML instead of rendering fresh. With auto on, the publisher (boot + on-write)
|
|
64
|
+
// owns publishing — so skip prerender seeding entirely. (auto off = the classic `nuxt generate` SSG model.)
|
|
65
|
+
if (c.output.auto) return
|
|
66
|
+
nuxt.hook('nitro:config', (nitro) => {
|
|
67
|
+
// Only an S3 target reconciles (deletes what this build didn't produce); a local build leaves the
|
|
68
|
+
// tree on disk, so an incomplete enumeration destroys nothing there. `nitro.static` is the same flag
|
|
69
|
+
// the deploy itself gates on: this hook also runs for `nuxt dev` and a plain `nuxt build`, and
|
|
70
|
+
// neither ships anything, so there a degrade must stay a warning instead of failing the command.
|
|
71
|
+
const reconciles = c.output.driver === 's3' && nitro.static === true
|
|
72
|
+
const discovery = discoverRoutes(c.dbPath, c.primaryLocale, c.prefixPrimary, reconciles)
|
|
73
|
+
// Hand the deploy module the completeness of THIS enumeration — the only step that knows it.
|
|
74
|
+
recordRouteDiscovery(nuxt, discovery)
|
|
75
|
+
nitro.prerender ||= {}
|
|
76
|
+
nitro.prerender.routes = [...new Set([...(nitro.prerender.routes ?? []), ...discovery.routes, '/sitemap.xml', '/robots.txt', '/llms.txt'])]
|
|
77
|
+
// Render pages in parallel — the dominant lever on `nuxt generate` wall-clock as page count grows.
|
|
78
|
+
// Safe: page reads are synchronous better-sqlite3 (WAL + busy_timeout, can't interleave mid-statement);
|
|
79
|
+
// the one WRITE on this path — the variant-registry capture — is an IMMEDIATE transaction, so
|
|
80
|
+
// concurrent prerender writers (and a co-running server publisher) serialize instead of losing updates.
|
|
81
|
+
// Capped to avoid memory over-subscription; overridable.
|
|
82
|
+
nitro.prerender.concurrency ??= Math.min(Math.max(cpus().length, 2), 8)
|
|
83
|
+
})
|
|
84
|
+
},
|
|
85
|
+
})
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { localePath } from '../../../core/app/utils/locale-path'
|
|
2
|
+
|
|
3
|
+
export interface PageRouteRow {
|
|
4
|
+
path: string | null
|
|
5
|
+
locale: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function pagesToRoutes(rows: PageRouteRow[], primaryLocale: string, prefixPrimary = false): string[] {
|
|
9
|
+
// The site root is the primary-locale home — `/` normally, `/<primary>` when the primary is prefixed.
|
|
10
|
+
const routes = new Set<string>([localePath('/', primaryLocale, primaryLocale, prefixPrimary)])
|
|
11
|
+
for (const { path, locale } of rows) {
|
|
12
|
+
if (!path) continue
|
|
13
|
+
routes.add(localePath(path, locale, primaryLocale, prefixPrimary))
|
|
14
|
+
}
|
|
15
|
+
return [...routes].sort()
|
|
16
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { defineNuxtModule } from '@nuxt/kit'
|
|
2
|
+
import Database from 'better-sqlite3'
|
|
3
|
+
import { resolveKestrel, type KestrelConfig } from '../../../core/server/utils/kestrel-config'
|
|
4
|
+
import { isEnvTrue } from '../deploy-output/deploy-output'
|
|
5
|
+
import { pruneUnreferencedMedia, mediaOwnedKeys } from './prune-media'
|
|
6
|
+
|
|
7
|
+
// The set of storage keys the media library owns (originals + derivatives), read straight from the DB at
|
|
8
|
+
// build time. Returns null when the registry can't be read (no DB, no media table) — the caller then SKIPS
|
|
9
|
+
// the prune entirely rather than risk deleting extension/consumer blobs (e.g. gallery ciphertext).
|
|
10
|
+
function readOwnedKeys(dbPath: string): Set<string> | null {
|
|
11
|
+
if (dbPath === ':memory:') return null
|
|
12
|
+
try {
|
|
13
|
+
const db = new Database(dbPath, { readonly: true, fileMustExist: true })
|
|
14
|
+
try {
|
|
15
|
+
const rows = db.prepare('SELECT storage_key AS storageKey, derivatives FROM media').all() as { storageKey: string; derivatives: string | null }[]
|
|
16
|
+
return mediaOwnedKeys(rows.map((r) => ({ storageKey: r.storageKey, derivatives: r.derivatives ? JSON.parse(r.derivatives) : null })))
|
|
17
|
+
} finally {
|
|
18
|
+
db.close()
|
|
19
|
+
}
|
|
20
|
+
} catch {
|
|
21
|
+
return null
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// `nuxt generate` bakes the WHOLE upload dir into .output/public/<baseUrl>; without this, images of
|
|
26
|
+
// deleted/unpublished pages ship to prod. At `compiled` (full output on disk, post prerender-abort) delete
|
|
27
|
+
// every baked media file no generated page references — prunes the BAKE only, so a wrong prune costs a
|
|
28
|
+
// re-generate, not data. Local-driver only (only it registers the /uploads publicAssets that gets baked).
|
|
29
|
+
// If deploy-output is ever added to modules[], it must come AFTER this so the prune runs before the ship.
|
|
30
|
+
export default defineNuxtModule({
|
|
31
|
+
meta: { name: 'kestrel-prune-media' },
|
|
32
|
+
setup(_options, nuxt) {
|
|
33
|
+
const c = resolveKestrel(nuxt.options.kestrel as KestrelConfig, process.env, nuxt.options.rootDir)
|
|
34
|
+
if (c.media.driver !== 'local') return
|
|
35
|
+
const dryRun = isEnvTrue(process.env.KESTREL_OUTPUT_DRY_RUN)
|
|
36
|
+
nuxt.hook('nitro:init', (nitro) => {
|
|
37
|
+
nitro.hooks.hook('compiled', () => {
|
|
38
|
+
if (nitro.options.static !== true) return
|
|
39
|
+
const ownedKeys = readOwnedKeys(c.dbPath)
|
|
40
|
+
if (!ownedKeys) {
|
|
41
|
+
console.log('[kestrel] media prune skipped: media registry unavailable (would risk deleting extension/consumer blobs)')
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
pruneUnreferencedMedia(nitro.options.output.publicDir, c.media.baseUrl, { dryRun, ownedKeys, log: (m) => console.log(`[kestrel] ${m}`) })
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
},
|
|
48
|
+
})
|