@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,81 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, rmSync } from 'node:fs'
|
|
2
|
+
import { join, relative, sep } from 'node:path'
|
|
3
|
+
|
|
4
|
+
function escapeRe(s: string): string {
|
|
5
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// Prefix-relative keys referenced in `text`. The charset stops at ?/#/quote/space, so query strings,
|
|
9
|
+
// fragments and srcset descriptors (320w, 2x) fall outside the key; the leading `/` keeps `/uploads` from
|
|
10
|
+
// matching `/myuploads`.
|
|
11
|
+
export function referencedKeys(text: string, prefix: string): Set<string> {
|
|
12
|
+
const out = new Set<string>()
|
|
13
|
+
const re = new RegExp(`${escapeRe(prefix)}/([A-Za-z0-9._/-]+)`, 'g')
|
|
14
|
+
for (const m of text.matchAll(re)) out.add(m[1])
|
|
15
|
+
return out
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// The prunable candidates: unreferenced files that the media library OWNS. When `ownedKeys` is given, a
|
|
19
|
+
// file not in it (gallery ciphertext / index, or any consumer blob written via useStorageDriver) is NEVER
|
|
20
|
+
// a candidate — those are baked deliberately and their URLs are built at runtime, so they never appear as
|
|
21
|
+
// literal keys in the output. Without `ownedKeys` the legacy "prune any unreferenced key" behaviour holds.
|
|
22
|
+
export function planMediaPrune(files: string[], referenced: Set<string>, ownedKeys?: Set<string>): string[] {
|
|
23
|
+
return files.filter((f) => !referenced.has(f) && (!ownedKeys || ownedKeys.has(f)))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// The storage keys the media library owns: every original + every derivative object it tracks. Used to
|
|
27
|
+
// scope the bake prune so it can only ever delete media-library artifacts, never extension/consumer blobs.
|
|
28
|
+
export function mediaOwnedKeys(rows: { storageKey?: string | null; derivatives?: Record<string, { key?: string }> | null }[]): Set<string> {
|
|
29
|
+
const out = new Set<string>()
|
|
30
|
+
for (const r of rows) {
|
|
31
|
+
if (r.storageKey) out.add(r.storageKey)
|
|
32
|
+
for (const e of Object.values(r.derivatives ?? {})) if (e?.key) out.add(e.key)
|
|
33
|
+
}
|
|
34
|
+
return out
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function walkFiles(dir: string): string[] {
|
|
38
|
+
return readdirSync(dir, { recursive: true, withFileTypes: true })
|
|
39
|
+
.filter((e) => e.isFile())
|
|
40
|
+
.map((e) => join(e.parentPath, e.name))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface PruneResult {
|
|
44
|
+
kept: number
|
|
45
|
+
pruned: number
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface PruneOptions {
|
|
49
|
+
/** Report what would be deleted without touching the filesystem. */
|
|
50
|
+
dryRun?: boolean
|
|
51
|
+
/** Restrict deletion to these media-library-owned keys; anything else baked under the root is kept. */
|
|
52
|
+
ownedKeys?: Set<string>
|
|
53
|
+
log?: (msg: string) => void
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Delete every baked upload under `publicDir/<baseUrl>` that no generated .html/.css/.json references.
|
|
57
|
+
// Over-scanning those files is keep-on-doubt (a key seen anywhere is kept). No-op when the dir is absent
|
|
58
|
+
// (S3 media / nothing baked). Touches only the bake — the library upload dir is elsewhere.
|
|
59
|
+
export function pruneUnreferencedMedia(publicDir: string, baseUrl: string, opts: PruneOptions = {}): PruneResult {
|
|
60
|
+
const prefix = baseUrl.replace(/\/+$/, '')
|
|
61
|
+
if (!prefix) return { kept: 0, pruned: 0 } // an empty baseUrl would target the whole output — refuse
|
|
62
|
+
const uploadsDir = join(publicDir, prefix)
|
|
63
|
+
if (!existsSync(uploadsDir)) return { kept: 0, pruned: 0 }
|
|
64
|
+
|
|
65
|
+
const referenced = new Set<string>()
|
|
66
|
+
for (const abs of walkFiles(publicDir)) {
|
|
67
|
+
// Also scan JS chunks: a media URL can be emitted only from client JS (a background image, a config
|
|
68
|
+
// object), never as literal HTML/CSS — pruning it would 404 the live reference.
|
|
69
|
+
if (!/\.(?:html|css|json|m?js)$/i.test(abs)) continue
|
|
70
|
+
for (const k of referencedKeys(readFileSync(abs, 'utf8'), prefix)) referenced.add(k)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const files = walkFiles(uploadsDir).map((abs) => ({ abs, key: relative(uploadsDir, abs).split(sep).join('/') }))
|
|
74
|
+
const prune = new Set(planMediaPrune(files.map((f) => f.key), referenced, opts.ownedKeys))
|
|
75
|
+
const toDelete = files.filter((f) => prune.has(f.key))
|
|
76
|
+
if (!opts.dryRun) for (const f of toDelete) rmSync(f.abs, { force: true })
|
|
77
|
+
|
|
78
|
+
const kept = files.length - toDelete.length
|
|
79
|
+
opts.log?.(`media prune${opts.dryRun ? ' (dry-run)' : ''}: kept ${kept}, ${opts.dryRun ? 'would prune' : 'pruned'} ${toDelete.length} unreferenced file(s)`)
|
|
80
|
+
return { kept, pruned: toDelete.length }
|
|
81
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { fileURLToPath } from 'node:url'
|
|
2
|
+
|
|
3
|
+
export default defineNuxtConfig({
|
|
4
|
+
modules: [
|
|
5
|
+
fileURLToPath(new URL('./modules/prerender-routes/index.ts', import.meta.url)),
|
|
6
|
+
fileURLToPath(new URL('./modules/prune-media/index.ts', import.meta.url)),
|
|
7
|
+
// AFTER prune-media so the bake is pruned before it ships. Self-gates to output.driver:'s3' + a real
|
|
8
|
+
// `nuxt generate`; a no-op otherwise. Without it, output.driver:'s3' + output.auto:false shipped nothing.
|
|
9
|
+
fileURLToPath(new URL('./modules/deploy-output/index.ts', import.meta.url)),
|
|
10
|
+
],
|
|
11
|
+
})
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { resolveInternalHref } from '../../utils/link-resolve'
|
|
2
|
+
|
|
3
|
+
// Batch-resolve internal link refs (`?refs=collection:id,collection:id`) to public hrefs for the admin
|
|
4
|
+
// block preview. Mirrors the read-path populator, status gate included; only resolved refs are returned,
|
|
5
|
+
// so the client renders '#' for a target that is missing or still a draft — same as the generated site.
|
|
6
|
+
export default defineEventHandler((event) => {
|
|
7
|
+
const refs = String(getQuery(event).refs ?? '').split(',').map((s) => s.trim()).filter(Boolean)
|
|
8
|
+
const data: { collection: string; id: number; href: string }[] = []
|
|
9
|
+
for (const ref of refs) {
|
|
10
|
+
const sep = ref.lastIndexOf(':')
|
|
11
|
+
if (sep <= 0) continue
|
|
12
|
+
const collection = ref.slice(0, sep)
|
|
13
|
+
const id = Number(ref.slice(sep + 1))
|
|
14
|
+
if (!Number.isInteger(id) || id <= 0) continue
|
|
15
|
+
const href = resolveInternalHref(collection, id)
|
|
16
|
+
if (href != null) data.push({ collection, id, href })
|
|
17
|
+
}
|
|
18
|
+
return { data }
|
|
19
|
+
})
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { eq, getTableColumns } from 'drizzle-orm'
|
|
2
|
+
import type { AnySQLiteTable } from 'drizzle-orm/sqlite-core'
|
|
3
|
+
import { publishStatus } from '../database/publish-status'
|
|
4
|
+
import { routeForRecord } from '../utils/publish/route-for-record'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Admin-only read of the LIVE publish state of a record's static page (`?collection=&id=`). Admin-only by
|
|
8
|
+
* construction: `/api/*` is default-deny for anonymous and `publish-status` is not a public pageLike
|
|
9
|
+
* resource, so the access guard rejects an unauthenticated request before this runs. Resolves the record's
|
|
10
|
+
* route, then looks up its `publish_status` row. Returns `{ route, status }` (+ error/updatedAt on a row),
|
|
11
|
+
* or `{ route: null, status: null }` when the record has no route (non-pageLike / blank slug / missing),
|
|
12
|
+
* or `{ route, status: null }` when there is no status row yet (never published — dev, a draft, or a
|
|
13
|
+
* republish still in flight). The editor's live-ampel reads this and refetches after each save.
|
|
14
|
+
*/
|
|
15
|
+
export default defineEventHandler((event) => {
|
|
16
|
+
// Environment-level facts the right lamp needs beyond the per-route row: where the NEXT publish would go
|
|
17
|
+
// (`driver`) and whether the runtime publisher actually produces files HERE (prod + `output.auto`). In dev
|
|
18
|
+
// (or with auto off) nothing is ever generated, so the lamp shows a calm "Not built" instead of a stuck
|
|
19
|
+
// "Generating". Read straight from runtimeConfig — no need to pull in the whole publisher module.
|
|
20
|
+
const output = (useRuntimeConfig().kestrel as { output?: { driver?: 'local' | 's3'; auto?: boolean } }).output
|
|
21
|
+
const env = { driver: (output?.driver ?? 'local') as 'local' | 's3', generates: !import.meta.dev && !!output?.auto }
|
|
22
|
+
|
|
23
|
+
const q = getQuery(event)
|
|
24
|
+
const name = typeof q.collection === 'string' ? q.collection : ''
|
|
25
|
+
const id = Number(typeof q.id === 'string' ? q.id : NaN)
|
|
26
|
+
const c = getCollection(name)
|
|
27
|
+
if (!c || !c.def.pageLike || !Number.isInteger(id) || id <= 0) return { route: null, status: null, ...env }
|
|
28
|
+
|
|
29
|
+
const db = useDb()
|
|
30
|
+
const table = c.table as AnySQLiteTable
|
|
31
|
+
const cols = getTableColumns(table) as Record<string, never>
|
|
32
|
+
const row = db.select().from(table).where(eq(cols.id, id)).get() as { path?: unknown; locale?: unknown } | undefined
|
|
33
|
+
const route = routeForRecord(row, true, primaryLocale(), prefixPrimaryLocale())
|
|
34
|
+
if (!route) return { route: null, status: null, ...env }
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const st = db.select().from(publishStatus).where(eq(publishStatus.route, route)).get()
|
|
38
|
+
if (!st) return { route, status: null, ...env }
|
|
39
|
+
return { route, status: st.status, error: st.error, updatedAt: st.updatedAt, target: st.target, ...env }
|
|
40
|
+
} catch {
|
|
41
|
+
// publish_status not migrated yet → treat as "no status" rather than a 500.
|
|
42
|
+
return { route, status: null, ...env }
|
|
43
|
+
}
|
|
44
|
+
})
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { resolvePage } from '../utils/page-resolve'
|
|
2
|
+
|
|
3
|
+
// Resolves a public path to the first matching page-like record across ALL pageLike collections (the
|
|
4
|
+
// generic render entry point). Published-only EXCEPT for an authenticated admin's live preview:
|
|
5
|
+
// - a static render (build prerender OR the runtime publisher) is always published-only, so a draft
|
|
6
|
+
// never reaches the static site (the renderer principal is itself published-only at the policy
|
|
7
|
+
// level; this explicit `isStaticRender` clause is belt-and-suspenders defense-in-depth);
|
|
8
|
+
// - an anonymous live request carries readScope 'published';
|
|
9
|
+
// - an authenticated admin carries readScope 'all', so a draft renders at its real URL (the preview).
|
|
10
|
+
export default defineEventHandler((event) => {
|
|
11
|
+
const q = getQuery(event)
|
|
12
|
+
const path = typeof q.path === 'string' && q.path ? q.path : '/'
|
|
13
|
+
const locale = typeof q.locale === 'string' ? q.locale : undefined
|
|
14
|
+
const isStaticRender = import.meta.prerender === true || isRendererContext()
|
|
15
|
+
const publishedOnly = isStaticRender || event.context.readScope !== 'all'
|
|
16
|
+
const resolved = resolvePage(useDb(), allCollections(), path, locale, publishedOnly)
|
|
17
|
+
return { collection: resolved?.collection ?? null, page: resolved?.page ?? null, alternates: resolved?.alternates ?? [] }
|
|
18
|
+
})
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { sqliteTable, text } from 'drizzle-orm/sqlite-core'
|
|
2
|
+
|
|
3
|
+
// Durable `route -> data tags` index backing the runtime publisher's DepsStore. Persists what each
|
|
4
|
+
// published route read while rendering (its `<coll>` / `<coll>:<id>` tags) so a write maps back to exactly
|
|
5
|
+
// the routes that embed that data, AND so a boot full-publish can prune routes that left the published set
|
|
6
|
+
// while the server was down. `route` is the natural primary key; `tags` is the JSON-encoded tag set.
|
|
7
|
+
// Hand-authored system table (not a collection) — registered in the desired schema via bootstrap.ts +
|
|
8
|
+
// re-exported from collections/schema.ts, mirroring `folders`.
|
|
9
|
+
export const publishDeps = sqliteTable('publish_deps', {
|
|
10
|
+
route: text('route').primaryKey(),
|
|
11
|
+
tags: text('tags').notNull(),
|
|
12
|
+
})
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'
|
|
2
|
+
|
|
3
|
+
// Durable per-route record of the LAST static-publish outcome: did this route's file get written to the
|
|
4
|
+
// output target (local dir or S3) successfully, or did the last attempt error? `route` is the natural
|
|
5
|
+
// primary key (one latest-state row per route — not a history). The runtime publisher upserts a `success`
|
|
6
|
+
// row after each render+put and an `error` row (with the failure message, incl. S3) when one throws; a
|
|
7
|
+
// prune (unpublish / delete / slug change) clears the row so the route reads as "not live". The admin
|
|
8
|
+
// editor's live-ampel reads it through `/api/publish-status` to show whether THIS record's page is live.
|
|
9
|
+
// `updatedAt` via `$defaultFn` (no SQL default → it won't perpetually diff as a rebuild; UNIX seconds via
|
|
10
|
+
// `mode:'timestamp'`, matching every other table). Hand-authored system table (not a collection) —
|
|
11
|
+
// registered in the desired schema via bootstrap.ts + re-exported from collections/schema.ts, mirroring
|
|
12
|
+
// `publish_deps` / `record_refs` / `folders`.
|
|
13
|
+
export const publishStatus = sqliteTable('publish_status', {
|
|
14
|
+
route: text('route').primaryKey(),
|
|
15
|
+
status: text('status').notNull(), // 'success' | 'error'
|
|
16
|
+
error: text('error'), // nullable — the failure message (render / write / S3) on an 'error' row
|
|
17
|
+
target: text('target').notNull(), // 'local' | 's3' — which output the attempt wrote to
|
|
18
|
+
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
|
19
|
+
})
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { registerFieldPopulator } from '../../../core/server/utils/populate'
|
|
2
|
+
import { buildLinkFieldPopulators } from '../utils/populate-links'
|
|
3
|
+
import { resolveInternalHref } from '../utils/link-resolve'
|
|
4
|
+
|
|
5
|
+
// Resolve internal links to the target record's localized public path at read time (page-like targets;
|
|
6
|
+
// external/email/tel pass through). NOTE: the resolver does NOT status-gate the target — a link to a DRAFT
|
|
7
|
+
// emits the draft's real (not-yet-generated) path, disclosing its slug and shipping a 404 until publish. This
|
|
8
|
+
// is deliberate: it keeps links stable without re-rendering every referrer when a target's status flips, and
|
|
9
|
+
// the editor warns about draft/dead links instead. (See link-resolve.ts for the rationale.)
|
|
10
|
+
//
|
|
11
|
+
// Registered as `link` + `richtext` per-type populators; the shared field-tree walker dispatches them over
|
|
12
|
+
// top-level fields, block props, slots, and repeater entries.
|
|
13
|
+
export default defineNitroPlugin(() => {
|
|
14
|
+
const { link, richtext } = buildLinkFieldPopulators(resolveInternalHref)
|
|
15
|
+
registerFieldPopulator('link', link)
|
|
16
|
+
registerFieldPopulator('richtext', richtext)
|
|
17
|
+
})
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { createPublishQueue } from '../utils/publish/queue'
|
|
2
|
+
import { DepsStore } from '../utils/publish/deps'
|
|
3
|
+
import { createSqlitePersistence } from '../utils/publish/deps-persistence'
|
|
4
|
+
import { outputDriver, publishInvalidation } from '../utils/publish/publisher'
|
|
5
|
+
import { classifyWrite, planInvalidation } from '../utils/publish/invalidation'
|
|
6
|
+
import { registerWriteListener } from '../../../core/server/utils/write-events'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The in-server publish runtime (the production operation model). Wires three things, gated on
|
|
10
|
+
* `output.auto` and skipped in dev (a dev publish would write Vite-dev HTML without hashed `_nuxt`):
|
|
11
|
+
*
|
|
12
|
+
* 1. AUTO-TRIGGER — every content write classifies into an invalidation and enqueues an incremental
|
|
13
|
+
* republish (a debounced/coalesced/single-flight queue; precise routes via the captured deps index).
|
|
14
|
+
* 2. BOOT PUBLISH — a full publish on startup resyncs this build's `_nuxt` + records every route's deps.
|
|
15
|
+
* DETACHED (`runNitroPlugins` is synchronous + unawaited; `localFetch` is already wired before plugins
|
|
16
|
+
* run + before the server listens), so it never blocks boot.
|
|
17
|
+
* 3. RECONCILER — an optional periodic full publish (`output.reconcileMinutes`) self-heals any missed
|
|
18
|
+
* invalidation and picks up time-based `publishDate` publishing that no write event would trigger.
|
|
19
|
+
*/
|
|
20
|
+
export default defineNitroPlugin(() => {
|
|
21
|
+
if (import.meta.dev) return
|
|
22
|
+
const output = (useRuntimeConfig().kestrel as { output?: { auto?: boolean; reconcileMinutes?: number; verbose?: boolean } }).output
|
|
23
|
+
if (!output?.auto) return
|
|
24
|
+
|
|
25
|
+
// `output.verbose`: on top of the one-line summary, itemise each incremental republish with a
|
|
26
|
+
// timestamped per-route line (rendered / pruned) for traceability. Off by default. (A queued FULL run
|
|
27
|
+
// — boot / reconciler — is a bulk resync and stays a summary; publishInvalidation returns no per-route
|
|
28
|
+
// list for it.)
|
|
29
|
+
const verbose = output.verbose ?? false
|
|
30
|
+
const stamp = () => new Date().toISOString()
|
|
31
|
+
|
|
32
|
+
const driver = outputDriver()
|
|
33
|
+
// Durable deps: rehydrate `route → tags` from SQLite so the boot full-publish below can prune routes
|
|
34
|
+
// that were unpublished/deleted while the server was down (staleRoutes over a non-empty tracked set).
|
|
35
|
+
const deps = new DepsStore(createSqlitePersistence(useDb()))
|
|
36
|
+
const queue = createPublishQueue({
|
|
37
|
+
run: async (inv) => {
|
|
38
|
+
const { rendered, pruned, counts } = await publishInvalidation(inv, driver, deps)
|
|
39
|
+
if (counts.rendered === 0 && counts.pruned === 0) return
|
|
40
|
+
// Incremental runs name the routes; a queued full run (the reconciler) prints counts only.
|
|
41
|
+
const list = rendered.length ? `: ${rendered.join(', ')}` : ''
|
|
42
|
+
console.info(`[kestrel] published ${counts.rendered} route(s)${list} (pruned ${counts.pruned})`)
|
|
43
|
+
if (verbose) {
|
|
44
|
+
for (const route of rendered) console.info(`[kestrel] ${stamp()} rendered ${route}`)
|
|
45
|
+
for (const route of pruned) console.info(`[kestrel] ${stamp()} pruned ${route}`)
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
onError: (error) => console.error('[kestrel] publish run failed:', error),
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
registerWriteListener(({ def, before, after }) => {
|
|
52
|
+
queue.enqueue(planInvalidation(classifyWrite(def, before, after, primaryLocale(), prefixPrimaryLocale())))
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
// Boot publish goes THROUGH the queue (not a direct publishFull) so it shares the single-flight guard:
|
|
56
|
+
// otherwise a boot full-publish could overlap a write-triggered incremental or the reconciler, and both
|
|
57
|
+
// runs share the module-level variant accumulator (corrupting the registry) and can interleave
|
|
58
|
+
// render/prune (leaving an unpublished page live). The queue serializes them.
|
|
59
|
+
queue.enqueue({ type: 'full' })
|
|
60
|
+
|
|
61
|
+
const mins = output.reconcileMinutes ?? 0
|
|
62
|
+
if (mins > 0) setInterval(() => queue.enqueue({ type: 'full' }), mins * 60_000).unref?.()
|
|
63
|
+
})
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { getTableColumns } from 'drizzle-orm'
|
|
2
|
+
import { localePath } from '../../../core/app/utils/locale-path'
|
|
3
|
+
import type { LlmsSection, LlmsEntry } from '../utils/llms'
|
|
4
|
+
|
|
5
|
+
// Generates an `llms.txt` (llmstxt.org): the site name + description, then one section per pageLike
|
|
6
|
+
// collection listing its published, indexable records — the SAME registry-driven public set the sitemap
|
|
7
|
+
// advertises (single source: the auth policy). Safe to serve publicly and to prerender.
|
|
8
|
+
export default defineEventHandler((event) => {
|
|
9
|
+
const db = useDb()
|
|
10
|
+
const base = siteBaseUrl()
|
|
11
|
+
const primary = primaryLocale()
|
|
12
|
+
const prefixPrimary = prefixPrimaryLocale()
|
|
13
|
+
|
|
14
|
+
const cap = (s: string) => (s ? s[0].toUpperCase() + s.slice(1) : s)
|
|
15
|
+
const headingFor = (def: { name: string; label?: { plural?: unknown } }): string => {
|
|
16
|
+
const pl = def.label?.plural
|
|
17
|
+
if (typeof pl === 'string') return pl
|
|
18
|
+
if (pl && typeof pl === 'object') {
|
|
19
|
+
const m = pl as Record<string, string>
|
|
20
|
+
return m[primary] ?? Object.values(m)[0] ?? cap(def.name)
|
|
21
|
+
}
|
|
22
|
+
return cap(def.name)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const pub = publicReadableResources()
|
|
26
|
+
const sections: LlmsSection[] = []
|
|
27
|
+
// Without an absolute origin, every resource URL would be a relative path — omit the page sections (keep
|
|
28
|
+
// the site name + description) rather than emit a link list that resolves against the wrong host.
|
|
29
|
+
if (!base) console.warn('[kestrel] llms.txt: siteUrl is unset — omitting page links (they would be relative)')
|
|
30
|
+
for (const c of base ? allCollections() : []) {
|
|
31
|
+
if (!c.def.pageLike) continue
|
|
32
|
+
if (!isPubliclyReadable(c.def.name, pub)) continue
|
|
33
|
+
// Project ONLY the columns this listing reads — never SELECT *, which would pull every row's
|
|
34
|
+
// block-content JSON into memory just to emit a link; the runtime publisher re-renders this route on
|
|
35
|
+
// every incremental publish. Optional columns exist only when the def declares them (buildTable adds no
|
|
36
|
+
// locale/status/seo column otherwise), and a `cols.<missing>` would be undefined and make the select
|
|
37
|
+
// throw — gate on the same flags, as the sitemap does. `title` is a plain field, so probe the table.
|
|
38
|
+
const cols = getTableColumns(c.table) as Record<string, never>
|
|
39
|
+
const proj: Record<string, unknown> = { path: cols.path }
|
|
40
|
+
if (c.def.translatable) proj.locale = cols.locale
|
|
41
|
+
if (c.def.status) proj.status = cols.status
|
|
42
|
+
if (c.def.seo) proj.seo = cols.seo
|
|
43
|
+
if (Object.hasOwn(cols, 'title')) proj.title = cols.title
|
|
44
|
+
let rows: Record<string, unknown>[]
|
|
45
|
+
try {
|
|
46
|
+
rows = db.select(proj as never).from(c.table).all() as Record<string, unknown>[]
|
|
47
|
+
} catch {
|
|
48
|
+
continue // table not migrated yet (e.g. a bare prerender DB)
|
|
49
|
+
}
|
|
50
|
+
const entries: LlmsEntry[] = []
|
|
51
|
+
for (const row of rows) {
|
|
52
|
+
if (c.def.status && row.status !== 'published') continue
|
|
53
|
+
const path = row.path as string | null
|
|
54
|
+
if (!path) continue
|
|
55
|
+
const seo = (row.seo ?? {}) as { title?: string; description?: string; noindex?: boolean }
|
|
56
|
+
if (seo.noindex) continue
|
|
57
|
+
const locale = (row.locale as string | undefined) ?? primary
|
|
58
|
+
entries.push({
|
|
59
|
+
title: seo.title || (row.title as string | undefined) || path,
|
|
60
|
+
url: base + localePath(path, locale, primary, prefixPrimary),
|
|
61
|
+
description: seo.description || undefined,
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
if (entries.length) {
|
|
65
|
+
entries.sort((a, b) => a.url.localeCompare(b.url))
|
|
66
|
+
sections.push({ heading: headingFor(c.def), entries })
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
setHeader(event, 'content-type', 'text/plain; charset=utf-8')
|
|
71
|
+
return buildLlmsTxt({ siteName: siteName(), siteDescription: siteDescription() || undefined, sections })
|
|
72
|
+
})
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export default defineEventHandler((event) => {
|
|
2
|
+
const base = siteBaseUrl()
|
|
3
|
+
setHeader(event, 'content-type', 'text/plain; charset=utf-8')
|
|
4
|
+
return buildRobots({
|
|
5
|
+
sitemapUrl: base ? `${base}/sitemap.xml` : undefined,
|
|
6
|
+
llmsUrl: base ? `${base}/llms.txt` : undefined,
|
|
7
|
+
})
|
|
8
|
+
})
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { getTableColumns } from 'drizzle-orm'
|
|
2
|
+
import { localePath } from '../../../core/app/utils/locale-path'
|
|
3
|
+
|
|
4
|
+
// 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.
|
|
6
|
+
export default defineEventHandler((event) => {
|
|
7
|
+
const db = useDb()
|
|
8
|
+
const base = siteBaseUrl()
|
|
9
|
+
// Without an absolute origin (kestrel.siteUrl / KESTREL_SITE_URL) every <loc> would be a RELATIVE path,
|
|
10
|
+
// which the sitemaps.org schema rejects outright — a worse result than an empty sitemap. Emit a valid
|
|
11
|
+
// empty one + warn, mirroring robots.txt omitting its `Sitemap:` directive when the base is unset.
|
|
12
|
+
if (!base) {
|
|
13
|
+
console.warn('[kestrel] sitemap.xml: siteUrl is unset — emitting an empty sitemap (relative <loc> URLs would be invalid)')
|
|
14
|
+
setHeader(event, 'content-type', 'application/xml; charset=utf-8')
|
|
15
|
+
return buildSitemap([])
|
|
16
|
+
}
|
|
17
|
+
const primary = primaryLocale()
|
|
18
|
+
const prefixPrimary = prefixPrimaryLocale()
|
|
19
|
+
const candidates: SitemapCandidate[] = []
|
|
20
|
+
|
|
21
|
+
// A sitemap must only advertise publicly-reachable URLs. The public render path (anonymous access
|
|
22
|
+
// policy, prerender seed, catch-all) now renders every pageLike collection, so the sitemap tracks the
|
|
23
|
+
// SAME registry-driven public set (single source: the auth policy) rather than diverging from it.
|
|
24
|
+
const pub = publicReadableResources()
|
|
25
|
+
for (const c of allCollections()) {
|
|
26
|
+
if (!c.def.pageLike) continue
|
|
27
|
+
if (!isPubliclyReadable(c.def.name, pub)) continue
|
|
28
|
+
// Project ONLY the columns the sitemap reads — never SELECT *, which would pull every row's
|
|
29
|
+
// block-content JSON (and every other field) into memory just to emit a URL; the runtime publisher
|
|
30
|
+
// re-renders this route on every incremental publish. Optional columns exist only when the def
|
|
31
|
+
// declares them (buildTable adds no locale/status/seo column otherwise), and a `cols.<missing>` would
|
|
32
|
+
// be undefined and make the select throw — gate on the same flags, as allPublishedRoutes does.
|
|
33
|
+
const cols = getTableColumns(c.table) as Record<string, never>
|
|
34
|
+
const proj: Record<string, unknown> = { path: cols.path, updatedAt: cols.updatedAt }
|
|
35
|
+
if (c.def.translatable) proj.locale = cols.locale
|
|
36
|
+
if (c.def.translatable && c.def.mode !== 'single') proj.translationGroup = cols.translationGroup
|
|
37
|
+
if (c.def.status) proj.status = cols.status
|
|
38
|
+
if (c.def.seo) proj.seo = cols.seo
|
|
39
|
+
let rows: Record<string, unknown>[]
|
|
40
|
+
try {
|
|
41
|
+
rows = db.select(proj as never).from(c.table).all() as Record<string, unknown>[]
|
|
42
|
+
} catch {
|
|
43
|
+
continue // table not migrated yet (e.g. a bare prerender DB)
|
|
44
|
+
}
|
|
45
|
+
for (const row of rows) {
|
|
46
|
+
if (c.def.status && row.status !== 'published') continue
|
|
47
|
+
const path = row.path as string | null
|
|
48
|
+
if (!path) continue
|
|
49
|
+
const seo = (row.seo ?? {}) as { noindex?: boolean }
|
|
50
|
+
if (seo.noindex) continue
|
|
51
|
+
const locale = (row.locale as string | undefined) ?? primary
|
|
52
|
+
candidates.push({
|
|
53
|
+
loc: base + localePath(path, locale, primary, prefixPrimary),
|
|
54
|
+
lastmod: row.updatedAt instanceof Date ? row.updatedAt.toISOString() : undefined,
|
|
55
|
+
locale,
|
|
56
|
+
group: (row.translationGroup as string | undefined) ?? null,
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Link each page's published, indexable locale variants via hreflang alternates before serializing.
|
|
62
|
+
const entries = withHreflang(candidates, primary)
|
|
63
|
+
entries.sort((a, b) => a.loc.localeCompare(b.loc))
|
|
64
|
+
setHeader(event, 'content-type', 'application/xml; charset=utf-8')
|
|
65
|
+
return buildSitemap(entries)
|
|
66
|
+
})
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { publishFull, outputDriver } from '../../utils/publish/publisher'
|
|
2
|
+
import { DepsStore } from '../../utils/publish/deps'
|
|
3
|
+
import { createSqlitePersistence } from '../../utils/publish/deps-persistence'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The shared publish engine, exposed as a Nitro task. Renders every published page (+ sitemap/robots)
|
|
7
|
+
* via the live server, mirrors `_nuxt`/assets, and always prunes the routes that left the published set
|
|
8
|
+
* (output ≡ DB).
|
|
9
|
+
*
|
|
10
|
+
* Triggering (Nuxt 4.4 / Nitro 2.13 — there is NO `nuxi task run`):
|
|
11
|
+
* - dev: GET http://localhost:3000/_nitro/tasks/publish:run (the dev-only task route; plumbing smoke test)
|
|
12
|
+
* - prod: the boot-publish plugin (`zz.publish.ts`) runs this on startup; for an on-demand prod trigger,
|
|
13
|
+
* expose your own authenticated route that calls `runTask('publish:run')` (the built node-server
|
|
14
|
+
* has no task endpoint/CLI — only `runTask()` from inside the process + cron `scheduledTasks`).
|
|
15
|
+
*/
|
|
16
|
+
export default defineTask({
|
|
17
|
+
meta: { name: 'publish:run', description: 'Render all published pages to the configured static output (HTML + _nuxt + assets)' },
|
|
18
|
+
async run() {
|
|
19
|
+
// The same durable store the boot-publish plugin uses, so a manually-triggered run also prunes
|
|
20
|
+
// routes that left the published set and records deps for later incremental publishes.
|
|
21
|
+
const deps = new DepsStore(createSqlitePersistence(useDb()))
|
|
22
|
+
const result = await publishFull(outputDriver(), deps)
|
|
23
|
+
console.info(`[kestrel] publish:run rendered ${result.rendered} route(s), pruned ${result.pruned}`)
|
|
24
|
+
return { result }
|
|
25
|
+
},
|
|
26
|
+
})
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { eq, getTableColumns } from 'drizzle-orm'
|
|
2
|
+
import { getCollection } from '../../../core/server/utils/registry'
|
|
3
|
+
import { primaryLocale, prefixPrimaryLocale } from '../../../core/server/utils/locale'
|
|
4
|
+
import { pageRowHref } from '../../../core/server/utils/page-route'
|
|
5
|
+
|
|
6
|
+
/** Whether a fetched target row may be linked from PUBLIC output: it must exist and — if its collection has a
|
|
7
|
+
* status column — be published. A draft target is NOT linkable, so its slug never leaks into published HTML
|
|
8
|
+
* (the caller maps the unresolved link to `'#'`). Pure (no DB) → unit-tested. */
|
|
9
|
+
export function isPubliclyLinkable(row: Record<string, unknown> | undefined, hasStatus: boolean): boolean {
|
|
10
|
+
if (!row) return false
|
|
11
|
+
return !hasStatus || row.status === 'published'
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Resolve an internal link `{collection, id}` to the target record's localized public path via the DB.
|
|
16
|
+
* STATUS-GATED: a draft/unpublished target resolves to null → the link renders `'#'`, so its slug never
|
|
17
|
+
* reaches published HTML (the editor is separately warned about draft/dead links via record-ref-index). The
|
|
18
|
+
* baked href therefore encodes availability, which is why publishing/unpublishing a target emits the
|
|
19
|
+
* `<coll>:<id>` tag and re-renders its referrers. Returns null for a non-page-like, missing, or draft target. Shared by the
|
|
20
|
+
* populate plugin (read path, during `nuxt generate`) and the preview `/api/links/resolve` endpoint, so both
|
|
21
|
+
* resolve identically. Server-only (`useDb` by default; injectable for tests); tolerates an unmigrated DB → null.
|
|
22
|
+
*/
|
|
23
|
+
export function resolveInternalHref(collection: string, id: number, db = useDb()): string | null {
|
|
24
|
+
const c = getCollection(collection)
|
|
25
|
+
if (!c?.def.pageLike) return null
|
|
26
|
+
const cols = getTableColumns(c.table) as Record<string, never>
|
|
27
|
+
let row: Record<string, unknown> | undefined
|
|
28
|
+
try {
|
|
29
|
+
row = db.select().from(c.table).where(eq(cols.id, id)).get() as Record<string, unknown> | undefined
|
|
30
|
+
} catch {
|
|
31
|
+
return null // table not migrated yet (e.g. a bare prerender DB)
|
|
32
|
+
}
|
|
33
|
+
if (!isPubliclyLinkable(row, Object.hasOwn(cols, 'status'))) return null
|
|
34
|
+
return pageRowHref(row, primaryLocale(), prefixPrimaryLocale())
|
|
35
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** One linked resource in an llms.txt section. */
|
|
2
|
+
export interface LlmsEntry {
|
|
3
|
+
title: string
|
|
4
|
+
url: string
|
|
5
|
+
/** Optional one-line summary (rendered after the link as `: description`). */
|
|
6
|
+
description?: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** A titled group of resources — one `## heading` block. */
|
|
10
|
+
export interface LlmsSection {
|
|
11
|
+
heading: string
|
|
12
|
+
entries: LlmsEntry[]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Render an `llms.txt` (https://llmstxt.org): an H1 site name, an optional `>` blockquote summary, then
|
|
17
|
+
* one `## heading` per section listing `- [title](url): description` resources. The goal is a compact,
|
|
18
|
+
* machine-readable map of the site so an AI agent grasps what it is and where the key pages live. Pure +
|
|
19
|
+
* deterministic (the route feeds it published, indexable pages only); empty sections are omitted.
|
|
20
|
+
*/
|
|
21
|
+
// Collapse any whitespace (incl. newlines) to single spaces so a user-authored title/description/heading —
|
|
22
|
+
// which flows in from an SEO field / textarea — can't inject a spurious line into the one-per-line list.
|
|
23
|
+
const oneLine = (s: string): string => s.replace(/\s+/g, ' ').trim()
|
|
24
|
+
// Additionally escape the brackets that would otherwise break the `[title](url)` markdown link.
|
|
25
|
+
const linkText = (s: string): string => oneLine(s).replace(/[[\]]/g, '\\$&')
|
|
26
|
+
|
|
27
|
+
export function buildLlmsTxt(opts: { siteName: string; siteDescription?: string; sections: LlmsSection[] }): string {
|
|
28
|
+
const lines: string[] = [`# ${oneLine(opts.siteName)}`]
|
|
29
|
+
if (opts.siteDescription) lines.push('', `> ${oneLine(opts.siteDescription)}`)
|
|
30
|
+
for (const section of opts.sections) {
|
|
31
|
+
if (!section.entries.length) continue
|
|
32
|
+
lines.push('', `## ${oneLine(section.heading)}`, '')
|
|
33
|
+
for (const e of section.entries) {
|
|
34
|
+
const desc = e.description ? `: ${oneLine(e.description)}` : ''
|
|
35
|
+
lines.push(`- [${linkText(e.title)}](${e.url})${desc}`)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return `${lines.join('\n')}\n`
|
|
39
|
+
}
|