@michaelthielemann/kestrel 3.0.1 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -110
- package/kestrel.config.ts +1 -1
- package/layers/access/server/middleware/00.ip-allowlist.ts +3 -18
- package/layers/access/server/middleware/access-guard.ts +25 -23
- package/layers/access/server/types.d.ts +1 -2
- package/layers/admin/app/components/AdminAccount.vue +1 -1
- package/layers/admin/app/components/BlockFields.vue +1 -2
- package/layers/admin/app/components/BlockPreview.vue +17 -5
- package/layers/admin/app/components/BlocksBody.vue +1 -1
- package/layers/admin/app/components/CollectionEditor.vue +35 -16
- package/layers/admin/app/components/CollectionList.vue +34 -3
- package/layers/admin/app/components/CollectionListBulkBar.vue +17 -3
- package/layers/admin/app/components/CollectionListFilterPanel.vue +1 -1
- package/layers/admin/app/components/CollectionListPager.vue +1 -1
- package/layers/admin/app/components/CollectionListTable.vue +41 -2
- package/layers/admin/app/components/EditorStatus.vue +8 -7
- package/layers/admin/app/components/LocaleBar.vue +3 -1
- package/layers/admin/app/components/PageFields.vue +2 -4
- package/layers/admin/app/components/SeoFields.vue +2 -4
- package/layers/admin/app/components/SingletonEditor.vue +3 -2
- package/layers/admin/app/composables/useAuth.ts +3 -3
- package/layers/admin/app/composables/useBlockTree.ts +1 -1
- package/layers/admin/app/composables/useBlocks.ts +1 -1
- package/layers/admin/app/composables/useCollectionOps.ts +45 -8
- package/layers/admin/app/composables/useCollections.ts +1 -2
- package/layers/admin/app/composables/useEditForm.ts +35 -17
- package/layers/admin/app/composables/useLinkResolver.ts +1 -1
- package/layers/admin/app/composables/useListBatchActions.ts +20 -3
- package/layers/admin/app/composables/useListColumns.ts +1 -1
- package/layers/admin/app/composables/useListFilterDraft.ts +3 -2
- package/layers/admin/app/composables/useListRows.ts +7 -2
- package/layers/admin/app/composables/useListUrlState.ts +1 -1
- package/layers/admin/app/composables/usePublishStatus.ts +2 -2
- package/layers/admin/app/layouts/admin.vue +0 -1
- package/layers/admin/app/pages/admin/[collection]/[id].vue +5 -4
- package/layers/admin/app/pages/admin/index.vue +1 -1
- package/layers/admin/app/pages/admin/references.vue +2 -2
- package/layers/admin/app/utils/block-tree.ts +1 -1
- package/layers/admin/app/utils/collection-ops.ts +28 -3
- package/layers/admin/app/utils/edit-form.ts +34 -2
- package/layers/admin/app/utils/editor-expose.ts +2 -0
- package/layers/admin/app/utils/editor-form-context.ts +1 -2
- package/layers/admin/app/utils/field-keys.ts +1 -1
- package/layers/admin/app/utils/list-columns.ts +3 -2
- package/layers/admin/app/utils/list-query.ts +2 -2
- package/layers/admin/app/utils/populate-blocks.ts +2 -2
- package/layers/admin/app/utils/reauth.ts +5 -6
- package/layers/auth/server/plugins/01.register-auth-pipelines.ts +7 -0
- package/layers/collections/server/plugins/01.register.ts +9 -12
- package/layers/collections/server/plugins/02.register-relation-populate.ts +5 -7
- package/layers/{fields → core}/app/utils/field-factories.ts +1 -1
- package/layers/core/modules/auto-discovery/extract-block.ts +3 -3
- package/layers/core/modules/auto-discovery/index.ts +68 -11
- package/layers/core/modules/auto-discovery/package-registry.ts +19 -0
- package/layers/core/modules/auto-discovery/scan.ts +77 -0
- package/layers/core/modules/auto-discovery/virtual.d.ts +5 -0
- package/layers/core/modules/component-namespace/index.ts +24 -0
- package/layers/core/modules/kestrel/index.ts +6 -1
- package/layers/core/modules/plugin-order/index.ts +69 -0
- package/layers/core/modules/plugin-order/plugin-order.ts +273 -0
- package/layers/core/nuxt.config.ts +1 -0
- package/layers/core/server/api/[...path].ts +98 -0
- package/layers/core/server/plugins/00.config.ts +12 -0
- package/layers/core/server/plugins/00.migrate.ts +1 -0
- package/layers/core/server/plugins/01.register-introspection-pipeline.ts +5 -0
- package/layers/core/server/plugins/01.register-openapi-pipeline.ts +5 -0
- package/layers/core/server/plugins/01.register-tooling-pipelines.ts +5 -0
- package/layers/core/server/plugins/02.schema-sync.ts +2 -7
- package/layers/core/server/plugins/04.outbox-worker.ts +31 -0
- package/layers/core/server/plugins/05.reindex-refs.ts +5 -0
- package/layers/core/server/routes/__kestrel/dashboard.get.ts +29 -0
- package/layers/core/server/tasks/db/migrate-module.ts +83 -0
- package/layers/core/server/tasks/db/migrate-revisions.ts +20 -0
- package/layers/core/server/tasks/db/migrate.ts +2 -6
- package/layers/core/server/utils/server-config.ts +66 -3
- package/layers/fields/server/plugins/01.register-blocks-pipeline.ts +7 -0
- package/layers/fields/server/plugins/01.register-field-populate.ts +2 -2
- package/layers/media/app/components/KestrelImg.vue +3 -3
- package/layers/media/app/components/MediaLibrary.vue +2 -2
- package/layers/media/app/components/field/Media.vue +1 -1
- package/layers/media/app/composables/useMediaResolver.ts +1 -1
- package/layers/media/app/composables/useMediaUpload.ts +1 -1
- package/layers/media/app/composables/useMediaVariant.ts +2 -2
- package/layers/media/app/utils/ai-disclosure.ts +1 -1
- package/layers/media/app/utils/library.ts +1 -1
- package/layers/media/app/utils/ops.ts +1 -3
- package/layers/media/app/utils/picture-model.ts +2 -2
- package/layers/media/app/utils/variant-name.ts +1 -1
- package/layers/media/server/middleware/ondemand-variants.ts +6 -5
- package/layers/media/server/plugins/01.register-media-pipelines.ts +7 -0
- package/layers/media/server/plugins/02.register-media.ts +10 -7
- package/layers/media/server/plugins/04.variant-capture.ts +12 -8
- package/layers/media/server/plugins/05.media-cleanup.ts +5 -0
- package/layers/media/server/tasks/media/backfill.ts +3 -4
- package/layers/public/app/components/KestrelLink.vue +1 -1
- package/layers/public/app/pages/[...slug].vue +1 -1
- package/layers/public/app/pages/__kestrel/preview.vue +2 -2
- package/layers/public/app/utils/json-ld.ts +1 -1
- package/layers/public/app/utils/link-href.ts +1 -1
- package/layers/public/app/utils/page-head.ts +1 -1
- package/layers/public/modules/deploy-output/index.ts +4 -6
- package/layers/public/modules/prerender-routes/index.ts +4 -4
- package/layers/public/modules/prerender-routes/routes.ts +1 -1
- package/layers/public/modules/prune-media/index.ts +2 -2
- package/layers/public/server/delivery-live/serve.ts +111 -0
- package/layers/public/server/middleware/delivery-live-catchall.ts +7 -0
- package/layers/public/server/pipelines/index.ts +23 -0
- package/layers/public/server/plugins/00.ensure-snapshot-triggers.ts +14 -0
- package/layers/public/server/plugins/01.register-public-pipelines.ts +7 -0
- package/layers/public/server/plugins/02.register-links.ts +2 -3
- package/layers/public/server/plugins/03.redirects.ts +41 -23
- package/layers/public/server/plugins/05.plan-publish.ts +5 -0
- package/layers/public/server/plugins/zz.publish.ts +79 -24
- package/layers/public/server/routes/llms-full.txt.get.ts +5 -5
- package/layers/public/server/routes/llms.txt.get.ts +4 -2
- package/layers/public/server/routes/redirects.json.get.ts +2 -2
- package/layers/public/server/routes/robots.txt.get.ts +2 -0
- package/layers/public/server/routes/sitemap.xml.get.ts +16 -6
- package/layers/public/server/tasks/publish/run.ts +9 -4
- package/layers/public/server/utils/publish/render-live.ts +26 -0
- package/layers/ui/app/components/field/Choice.vue +1 -1
- package/layers/ui/app/components/field/Datetime.vue +1 -1
- package/layers/ui/app/components/field/Json.vue +1 -2
- package/layers/ui/app/components/field/Layout.vue +2 -4
- package/layers/ui/app/components/field/Link.vue +1 -1
- package/layers/ui/app/components/field/LinkInternalPicker.vue +3 -3
- package/layers/ui/app/components/field/Number.vue +2 -2
- package/layers/ui/app/components/field/Relation.vue +1 -1
- package/layers/ui/app/components/field/Repeater.vue +1 -3
- package/layers/ui/app/components/field/Slug.vue +2 -3
- package/layers/ui/app/components/field/Text.vue +1 -1
- package/layers/ui/app/components/ui/Richtext.vue +1 -1
- package/layers/ui/app/components/ui/RichtextToolbar.vue +1 -3
- package/layers/ui/app/composables/useLinkField.ts +1 -1
- package/layers/ui/app/composables/useRepeater.ts +1 -1
- package/layers/ui/app/i18n/de.ts +7 -7
- package/layers/ui/app/i18n/en.ts +7 -39
- package/layers/ui/app/utils/field-component.ts +1 -1
- package/layers/ui/app/utils/field-registry.ts +1 -1
- package/layers/ui/app/utils/icons.ts +2 -6
- package/layers/ui/app/utils/localized.ts +1 -1
- package/nuxt.config.ts +1 -1
- package/package.json +53 -19
- package/scripts/consumer-template-ci.mjs +157 -0
- package/scripts/coverage-all.mjs +163 -0
- package/scripts/dashboard.mjs +89 -0
- package/scripts/hash-password.mjs +1 -1
- package/scripts/lib/coverage-model.mjs +51 -0
- package/scripts/port-graph.mjs +350 -0
- package/scripts/typecheck.mjs +11 -0
- package/templates/starter/app/blocks/Prose.vue +1 -1
- package/layers/access/server/utils/csrf.ts +0 -39
- package/layers/access/server/utils/grant-registry.ts +0 -36
- package/layers/access/server/utils/guard.ts +0 -44
- package/layers/access/server/utils/ip-allowlist.ts +0 -79
- package/layers/access/server/utils/policy.ts +0 -98
- package/layers/access/server/utils/public-resources.ts +0 -11
- package/layers/access/server/utils/render-context.ts +0 -41
- package/layers/access/server/utils/require-admin.ts +0 -15
- package/layers/admin/app/pages/admin/[collection]/[id].nuxt.test.ts +0 -200
- package/layers/admin/app/pages/admin/[collection]/publish-preview.nuxt.test.ts +0 -142
- package/layers/auth/server/api/auth/login.post.ts +0 -38
- package/layers/auth/server/api/auth/logout.post.ts +0 -9
- package/layers/auth/server/api/auth/session.get.ts +0 -1
- package/layers/auth/server/utils/admin-credential.ts +0 -42
- package/layers/auth/server/utils/client-ip.ts +0 -88
- package/layers/auth/server/utils/login-throttle.ts +0 -126
- package/layers/auth/server/utils/password.ts +0 -52
- package/layers/auth/server/utils/session-cookie.ts +0 -44
- package/layers/auth/server/utils/session-epoch.ts +0 -96
- package/layers/auth/server/utils/session.ts +0 -88
- package/layers/collections/server/collections/pages.ts +0 -19
- package/layers/collections/server/utils/populate-relations.ts +0 -79
- package/layers/core/app/utils/bulk-actions.ts +0 -9
- package/layers/core/app/utils/filter-ops.ts +0 -98
- package/layers/core/app/utils/list-limits.ts +0 -27
- package/layers/core/app/utils/locale-path.ts +0 -12
- package/layers/core/app/utils/slugify.ts +0 -16
- package/layers/core/server/api/[collection]/[id]/dead-refs.get.ts +0 -8
- package/layers/core/server/api/[collection]/[id]/translations.get.ts +0 -4
- package/layers/core/server/api/[collection]/[id].delete.ts +0 -5
- package/layers/core/server/api/[collection]/[id].get.ts +0 -11
- package/layers/core/server/api/[collection]/[id].patch.ts +0 -8
- package/layers/core/server/api/[collection]/bulk.post.ts +0 -37
- package/layers/core/server/api/[collection]/index.get.ts +0 -24
- package/layers/core/server/api/[collection]/index.post.ts +0 -8
- package/layers/core/server/api/[collection]/index.put.test.ts +0 -76
- package/layers/core/server/api/[collection]/index.put.ts +0 -24
- package/layers/core/server/api/[collection]/options.get.test.ts +0 -60
- package/layers/core/server/api/[collection]/options.get.ts +0 -18
- package/layers/core/server/api/[collection]/populate-scope.test.ts +0 -119
- package/layers/core/server/api/[collection]/translations.get.test.ts +0 -90
- package/layers/core/server/api/[collection]/translations.get.ts +0 -25
- package/layers/core/server/api/collections/[name].get.ts +0 -4
- package/layers/core/server/api/collections/index.get.ts +0 -3
- package/layers/core/server/api/references/broken.get.ts +0 -9
- package/layers/core/server/api/references/referrers.get.ts +0 -24
- package/layers/core/server/database/record-refs.ts +0 -20
- package/layers/core/server/plugins/03.record-refs.ts +0 -12
- package/layers/core/server/schema/bootstrap.ts +0 -41
- package/layers/core/server/schema/desired.ts +0 -99
- package/layers/core/server/schema/dev-sync.ts +0 -23
- package/layers/core/server/schema/dialect.ts +0 -61
- package/layers/core/server/schema/diff.ts +0 -110
- package/layers/core/server/schema/introspect.ts +0 -68
- package/layers/core/server/schema/model.ts +0 -61
- package/layers/core/server/schema/render-sqlite.ts +0 -89
- package/layers/core/server/schema/sync.ts +0 -201
- package/layers/core/server/utils/blocks.ts +0 -6
- package/layers/core/server/utils/collection-types.ts +0 -28
- package/layers/core/server/utils/crud.ts +0 -578
- package/layers/core/server/utils/db.ts +0 -26
- package/layers/core/server/utils/defineCollection.ts +0 -213
- package/layers/core/server/utils/duplicate.ts +0 -162
- package/layers/core/server/utils/field-layout.ts +0 -125
- package/layers/core/server/utils/filter-predicate.ts +0 -101
- package/layers/core/server/utils/http.ts +0 -50
- package/layers/core/server/utils/if-unmodified.ts +0 -13
- package/layers/core/server/utils/kestrel-config.ts +0 -451
- package/layers/core/server/utils/key-lock.ts +0 -33
- package/layers/core/server/utils/locale.ts +0 -53
- package/layers/core/server/utils/page-route.ts +0 -58
- package/layers/core/server/utils/page-slug.ts +0 -127
- package/layers/core/server/utils/picker.ts +0 -82
- package/layers/core/server/utils/populate.ts +0 -50
- package/layers/core/server/utils/prerender-memo.ts +0 -23
- package/layers/core/server/utils/read-capture.ts +0 -37
- package/layers/core/server/utils/record-ref-index.ts +0 -200
- package/layers/core/server/utils/registry.ts +0 -24
- package/layers/core/server/utils/resolve-scope.ts +0 -79
- package/layers/core/server/utils/seo.ts +0 -42
- package/layers/core/server/utils/serialize-collection.ts +0 -147
- package/layers/core/server/utils/sql.ts +0 -2
- package/layers/core/server/utils/storage.local.ts +0 -117
- package/layers/core/server/utils/storage.s3.ts +0 -197
- package/layers/core/server/utils/storage.ts +0 -90
- package/layers/core/server/utils/write-effects.ts +0 -40
- package/layers/core/server/utils/write-events.ts +0 -31
- package/layers/fields/app/utils/condition.ts +0 -71
- package/layers/fields/app/utils/define-block.ts +0 -13
- package/layers/fields/app/utils/field-constraints.ts +0 -17
- package/layers/fields/app/utils/field-meta.ts +0 -33
- package/layers/fields/app/utils/field-validate.ts +0 -75
- package/layers/fields/app/utils/field-value.ts +0 -7
- package/layers/fields/app/utils/richtext-links.ts +0 -48
- package/layers/fields/server/api/blocks/index.get.ts +0 -9
- package/layers/fields/server/field-registry/index.ts +0 -249
- package/layers/fields/server/field-registry/naming.ts +0 -10
- package/layers/fields/server/field-registry/sanitize.ts +0 -43
- package/layers/fields/server/field-registry/types.ts +0 -19
- package/layers/fields/server/utils/block-ids.ts +0 -43
- package/layers/fields/server/utils/block-populate.ts +0 -41
- package/layers/fields/server/utils/buildCollection.ts +0 -90
- package/layers/fields/server/utils/buildFieldSchema.ts +0 -22
- package/layers/fields/server/utils/buildTable.ts +0 -113
- package/layers/fields/server/utils/conditional-required.ts +0 -25
- package/layers/fields/server/utils/defineBlock.ts +0 -86
- package/layers/fields/server/utils/defineFieldType.ts +0 -33
- package/layers/fields/server/utils/extract-refs.ts +0 -152
- package/layers/fields/server/utils/field-populate.ts +0 -68
- package/layers/media/server/api/media/[id]/usages.get.ts +0 -8
- package/layers/media/server/api/media/[id].delete.ts +0 -18
- package/layers/media/server/api/media/[id].get.ts +0 -10
- package/layers/media/server/api/media/[id].patch.test.ts +0 -66
- package/layers/media/server/api/media/[id].patch.ts +0 -65
- package/layers/media/server/api/media/backfill.post.ts +0 -18
- package/layers/media/server/api/media/copy.post.ts +0 -7
- package/layers/media/server/api/media/delete.post.ts +0 -13
- package/layers/media/server/api/media/folders.post.ts +0 -18
- package/layers/media/server/api/media/index.get.ts +0 -15
- package/layers/media/server/api/media/index.post.ts +0 -173
- package/layers/media/server/api/media/library.get.ts +0 -17
- package/layers/media/server/api/media/move.post.ts +0 -7
- package/layers/media/server/api/media/rename.post.ts +0 -7
- package/layers/media/server/api/media/resolve.get.ts +0 -13
- package/layers/media/server/collections/media-settings.ts +0 -25
- package/layers/media/server/collections/media.ts +0 -43
- package/layers/media/server/database/folders.ts +0 -10
- package/layers/media/server/plugins/03.media-cleanup.ts +0 -20
- package/layers/media/server/schema-tables/folders.ts +0 -4
- package/layers/media/server/utils/ai-disclosure-enabled.ts +0 -16
- package/layers/media/server/utils/ai-signal-detect.ts +0 -155
- package/layers/media/server/utils/backfill.ts +0 -144
- package/layers/media/server/utils/derive.ts +0 -59
- package/layers/media/server/utils/folder-paths.ts +0 -42
- package/layers/media/server/utils/folders.ts +0 -12
- package/layers/media/server/utils/library.ts +0 -91
- package/layers/media/server/utils/media-cleanup.ts +0 -27
- package/layers/media/server/utils/media-enabled.ts +0 -23
- package/layers/media/server/utils/media-ops.ts +0 -118
- package/layers/media/server/utils/media-write.ts +0 -13
- package/layers/media/server/utils/naming.ts +0 -52
- package/layers/media/server/utils/ondemand.ts +0 -69
- package/layers/media/server/utils/persist-upload.ts +0 -62
- package/layers/media/server/utils/populate.ts +0 -47
- package/layers/media/server/utils/record.ts +0 -71
- package/layers/media/server/utils/relocate-ops.ts +0 -341
- package/layers/media/server/utils/relocate-plan.ts +0 -63
- package/layers/media/server/utils/resolve.ts +0 -98
- package/layers/media/server/utils/sanitize-svg.ts +0 -12
- package/layers/media/server/utils/sniff.ts +0 -68
- package/layers/media/server/utils/storage-relocate.ts +0 -139
- package/layers/media/server/utils/translations.ts +0 -19
- package/layers/media/server/utils/usages.ts +0 -66
- package/layers/media/server/utils/variant-capture.ts +0 -70
- package/layers/media/server/utils/variants.ts +0 -123
- package/layers/public/modules/deploy-output/deploy-output.ts +0 -384
- package/layers/public/server/api/links/resolve.get.ts +0 -19
- package/layers/public/server/api/preview.get.ts +0 -28
- package/layers/public/server/api/preview.post.ts +0 -93
- package/layers/public/server/api/publish-status.get.ts +0 -58
- package/layers/public/server/api/publish.post.ts +0 -84
- package/layers/public/server/api/route.get.ts +0 -54
- package/layers/public/server/collections/redirects.ts +0 -75
- package/layers/public/server/collections/site.ts +0 -44
- package/layers/public/server/database/publish-deps.ts +0 -12
- package/layers/public/server/database/publish-status.ts +0 -19
- package/layers/public/server/schema-tables/publish-deps.ts +0 -4
- package/layers/public/server/schema-tables/publish-status.ts +0 -4
- package/layers/public/server/utils/link-resolve.ts +0 -38
- package/layers/public/server/utils/llms-full.ts +0 -125
- package/layers/public/server/utils/llms.ts +0 -52
- package/layers/public/server/utils/page-resolve.ts +0 -208
- package/layers/public/server/utils/populate-links.ts +0 -66
- package/layers/public/server/utils/preview-token.ts +0 -109
- package/layers/public/server/utils/publish/coalesce.ts +0 -22
- package/layers/public/server/utils/publish/deps-persistence.ts +0 -43
- package/layers/public/server/utils/publish/deps.ts +0 -87
- package/layers/public/server/utils/publish/invalidation.ts +0 -225
- package/layers/public/server/utils/publish/pending.ts +0 -74
- package/layers/public/server/utils/publish/publish-runtime.ts +0 -28
- package/layers/public/server/utils/publish/publish-status.ts +0 -72
- package/layers/public/server/utils/publish/publisher.ts +0 -362
- package/layers/public/server/utils/publish/queue.ts +0 -67
- package/layers/public/server/utils/publish/redirect-rules.ts +0 -221
- package/layers/public/server/utils/publish/redirects-artifact.ts +0 -20
- package/layers/public/server/utils/publish/route-for-record.ts +0 -16
- package/layers/public/server/utils/publish/route-keys.ts +0 -13
- package/layers/public/server/utils/richtext-markdown.ts +0 -260
- package/layers/public/server/utils/site-url.ts +0 -40
- package/layers/public/server/utils/sitemap.ts +0 -99
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
import { inArray, getTableColumns } from 'drizzle-orm'
|
|
2
|
-
import type { AnySQLiteTable } from 'drizzle-orm/sqlite-core'
|
|
3
|
-
import { MAX_BULK_IDS } from '../../../core/app/utils/list-limits'
|
|
4
|
-
import { classifyWrite, planInvalidation } from '../utils/publish/invalidation'
|
|
5
|
-
import { staleRoutes } from '../utils/publish/deps'
|
|
6
|
-
import { usePublishRuntime } from '../utils/publish/publish-runtime'
|
|
7
|
-
import { allPublishedRoutes } from '../utils/publish/publisher'
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Publish records: write their static files (and everything whose baked output embeds them) to the
|
|
11
|
-
* configured output — the deliberate second half of the split ADR-0008 introduced. Saving persists to the
|
|
12
|
-
* DB and leaves the live site alone; THIS is what changes what visitors see.
|
|
13
|
-
*
|
|
14
|
-
* body: { collection: string, ids?: number[], id?: number }
|
|
15
|
-
* 200: { queued, generates, routes, pruned, drafts }
|
|
16
|
-
*
|
|
17
|
-
* The record's publish INTENT (`status`) is not touched here — that is a field the editor saves like any
|
|
18
|
-
* other, so a page goes live by being published while published. A draft is therefore reported back
|
|
19
|
-
* (`drafts`) rather than silently promoted: it has no public output to write.
|
|
20
|
-
*
|
|
21
|
-
* All-or-nothing on lookup (an unknown id 404s before anything is enqueued), like the bulk write actions.
|
|
22
|
-
*/
|
|
23
|
-
export default defineEventHandler(async (event) => {
|
|
24
|
-
requireAdmin(event) // write-authorization backstop (defense-in-depth; see require-admin.ts)
|
|
25
|
-
const body = await readBody(event)
|
|
26
|
-
const name = typeof (body as { collection?: unknown })?.collection === 'string' ? (body as { collection: string }).collection : ''
|
|
27
|
-
const c = getCollection(name)
|
|
28
|
-
if (!c) throw createError({ statusCode: 404, statusMessage: `Unknown collection: ${name}` })
|
|
29
|
-
|
|
30
|
-
const raw = (body as { ids?: unknown; id?: unknown })?.ids ?? [(body as { id?: unknown })?.id]
|
|
31
|
-
const ids = parseIdList(raw, MAX_BULK_IDS)
|
|
32
|
-
|
|
33
|
-
const db = useDb()
|
|
34
|
-
const table = c.table as AnySQLiteTable
|
|
35
|
-
const cols = getTableColumns(table) as Record<string, never>
|
|
36
|
-
const rows = db.select().from(table).where(inArray(cols.id, ids)).all() as Record<string, unknown>[]
|
|
37
|
-
const found = new Set(rows.map((r) => r.id as number))
|
|
38
|
-
const missing = ids.filter((id) => !found.has(id))
|
|
39
|
-
if (missing.length) throw createError({ statusCode: 404, statusMessage: `${c.def.name} not found: ${missing.join(', ')}` })
|
|
40
|
-
|
|
41
|
-
// Where the next publish would go, and whether one happens here at all: in dev (or with `output.auto`
|
|
42
|
-
// off) there is no runtime publisher, and saying so is more useful than a queued run that never runs.
|
|
43
|
-
const output = (useRuntimeConfig().kestrel as { output?: { auto?: boolean } }).output
|
|
44
|
-
const runtime = usePublishRuntime()
|
|
45
|
-
const generates = !import.meta.dev && !!output?.auto && !!runtime
|
|
46
|
-
|
|
47
|
-
// The live route set answers "is this tracked route still somebody's page?" — the question a rename
|
|
48
|
-
// leaves open. `failed` means the enumeration was incomplete, and an incomplete read must never drive a
|
|
49
|
-
// delete (the standing rule from the 2026-07-25 audit), so the prune is skipped wholesale.
|
|
50
|
-
const liveNow = runtime ? allPublishedRoutes() : { routes: [], failed: ['*'] }
|
|
51
|
-
const prunable = liveNow.failed.length === 0
|
|
52
|
-
|
|
53
|
-
const primary = primaryLocale()
|
|
54
|
-
const prefixPrimary = prefixPrimaryLocale()
|
|
55
|
-
const routes: string[] = []
|
|
56
|
-
const pruned: string[] = []
|
|
57
|
-
const drafts: number[] = []
|
|
58
|
-
let queued = false
|
|
59
|
-
|
|
60
|
-
for (const row of rows) {
|
|
61
|
-
// before === after: nothing about the record is changing, this is a re-render of its current state.
|
|
62
|
-
// A draft classifies as not-published, so `planInvalidation` returns a noop for it — reported below.
|
|
63
|
-
const ev = classifyWrite(c.def, row, row, primary, prefixPrimary)
|
|
64
|
-
const inv = planInvalidation(ev)
|
|
65
|
-
if (inv.type !== 'tags') {
|
|
66
|
-
drafts.push(row.id as number)
|
|
67
|
-
continue
|
|
68
|
-
}
|
|
69
|
-
// Abandoned URLs: routes the publisher baked FROM this record (tagged with its id) that no live page
|
|
70
|
-
// claims any more — in practice the old file a published rename left behind. Same rule a full publish
|
|
71
|
-
// applies globally, scoped to this record's tag; a referrer or listing carrying the tag is a live route
|
|
72
|
-
// and therefore never in this set.
|
|
73
|
-
const tagged = ev.id != null ? (runtime?.deps.routesForTags([`${c.def.name}:${ev.id}`]) ?? []) : []
|
|
74
|
-
const stale = prunable ? staleRoutes(tagged, liveNow.routes) : []
|
|
75
|
-
routes.push(...inv.render)
|
|
76
|
-
pruned.push(...stale)
|
|
77
|
-
if (runtime) {
|
|
78
|
-
runtime.queue.enqueue({ ...inv, prune: [...inv.prune, ...stale] })
|
|
79
|
-
queued = true
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
return { queued, generates, routes, pruned, drafts }
|
|
84
|
-
})
|
|
@@ -1,54 +0,0 @@
|
|
|
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 db = useDb()
|
|
17
|
-
const { page: resolved, failed } = resolvePage(db, allCollections(), path, locale, publishedOnly)
|
|
18
|
-
// The site-wide head tier rides along on the fetch the page already awaits, so it reaches SSR and the
|
|
19
|
-
// prerender on a path that is known to work. Looked up through the registry, not imported, so an
|
|
20
|
-
// installation whose registry never received the built-in simply has the tier off (`null`) instead of
|
|
21
|
-
// querying a table the schema never created. `depth: 1` resolves the sharing image into `$media`;
|
|
22
|
-
// `getSingleton` captures the read, so an edit re-publishes every route that embedded it.
|
|
23
|
-
const siteCollection = getCollection('site')
|
|
24
|
-
let site: ReturnType<typeof getSingleton> = null
|
|
25
|
-
let siteUnreadable = false
|
|
26
|
-
if (siteCollection) {
|
|
27
|
-
try { site = getSingleton(db, siteCollection, locale, false, 1) }
|
|
28
|
-
catch (error) {
|
|
29
|
-
// Registered but unreadable (its migration hasn't been run) — indistinguishable in the response from
|
|
30
|
-
// the off state above, so it joins the incomplete-read channel rather than degrading silently.
|
|
31
|
-
siteUnreadable = true
|
|
32
|
-
console.error('[kestrel] route: the site singleton could not be read:', (error as Error)?.message ?? error)
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
// One rule for every incomplete read: never answer 200. The publisher classifies a 200-with-body as a
|
|
36
|
-
// successful render, writes it over the live file and records success — so an unreadable page collection
|
|
37
|
-
// would bake the catch-all's empty document over a real page (the record may well live in the collection
|
|
38
|
-
// that failed, which is why this is not a 404), and an unreadable head tier would strip the composed
|
|
39
|
-
// title, default description and sharing image from every route it touches. Both are unrecoverable
|
|
40
|
-
// without a full re-publish and neither leaves a mark. A 5xx keeps the existing artifact and turns the
|
|
41
|
-
// editor's status red. The head tier is site-wide, so it fails the request even when a page did resolve.
|
|
42
|
-
if (siteUnreadable || (failed.length && !resolved)) {
|
|
43
|
-
throw createError({ statusCode: 503, statusMessage: 'Route lookup incomplete' })
|
|
44
|
-
}
|
|
45
|
-
return {
|
|
46
|
-
collection: resolved?.collection ?? null,
|
|
47
|
-
page: resolved?.page ?? null,
|
|
48
|
-
alternates: resolved?.alternates ?? [],
|
|
49
|
-
// The breadcrumb trail rides the same fetch as the hreflang set, for the same reason: it is resolved
|
|
50
|
-
// from the DB, and the publish-dep capture only works on a path the renderer actually awaits.
|
|
51
|
-
ancestors: resolved?.ancestors ?? [],
|
|
52
|
-
site,
|
|
53
|
-
}
|
|
54
|
-
})
|
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
import { buildCollection } from '../../../fields/server/utils/buildCollection'
|
|
2
|
-
import { defineCollection } from '../../../core/server/utils/defineCollection'
|
|
3
|
-
import { compileRedirects } from '../utils/publish/redirect-rules'
|
|
4
|
-
|
|
5
|
-
// Editorial SEO redirects, kept in the DB rather than in config because they outlive a deployment and are
|
|
6
|
-
// the editors' to change. Saving compiles them into the `redirects.json` artifact the edge serves — Kestrel
|
|
7
|
-
// itself never redirects at runtime (there is no live public SSR to redirect in).
|
|
8
|
-
const built = buildCollection(defineCollection({
|
|
9
|
-
name: 'redirects',
|
|
10
|
-
mode: 'single',
|
|
11
|
-
builtin: true,
|
|
12
|
-
label: { singular: { en: 'Redirects', de: 'Weiterleitungen' }, plural: { en: 'Redirects', de: 'Weiterleitungen' } },
|
|
13
|
-
icon: 'external-link',
|
|
14
|
-
fields: {
|
|
15
|
-
rules: {
|
|
16
|
-
type: 'repeater',
|
|
17
|
-
// Row order is priority and there is no per-field help text in the editor, so the rule and the
|
|
18
|
-
// wildcard syntax have to ride along in the labels — this is the only place an editor sees them.
|
|
19
|
-
label: {
|
|
20
|
-
en: 'Rules — the first matching rule wins, so put the most specific one first',
|
|
21
|
-
de: 'Regeln — die erste passende Regel gewinnt, spezifische zuerst',
|
|
22
|
-
},
|
|
23
|
-
options: {
|
|
24
|
-
fields: {
|
|
25
|
-
from: {
|
|
26
|
-
type: 'text',
|
|
27
|
-
required: true,
|
|
28
|
-
label: {
|
|
29
|
-
en: 'From — old path, * = one segment, ** = several',
|
|
30
|
-
de: 'Von — alter Pfad, * = ein Segment, ** = mehrere',
|
|
31
|
-
},
|
|
32
|
-
},
|
|
33
|
-
to: {
|
|
34
|
-
type: 'text',
|
|
35
|
-
required: true,
|
|
36
|
-
label: {
|
|
37
|
-
en: 'To — new path or full URL, $1/$2 = the wildcards',
|
|
38
|
-
de: 'Nach — neuer Pfad oder vollständige URL, $1/$2 = die Platzhalter',
|
|
39
|
-
},
|
|
40
|
-
},
|
|
41
|
-
status: {
|
|
42
|
-
type: 'choice',
|
|
43
|
-
label: { en: 'Status', de: 'Status' },
|
|
44
|
-
options: {
|
|
45
|
-
choices: [
|
|
46
|
-
{ label: { en: '301 — permanent', de: '301 — dauerhaft' }, value: '301' },
|
|
47
|
-
{ label: { en: '302 — temporary', de: '302 — vorübergehend' }, value: '302' },
|
|
48
|
-
{ label: { en: '307 — temporary, keeps the method', de: '307 — vorübergehend, Methode bleibt' }, value: '307' },
|
|
49
|
-
{ label: { en: '308 — permanent, keeps the method', de: '308 — dauerhaft, Methode bleibt' }, value: '308' },
|
|
50
|
-
],
|
|
51
|
-
display: 'select',
|
|
52
|
-
},
|
|
53
|
-
default: '301',
|
|
54
|
-
},
|
|
55
|
-
},
|
|
56
|
-
fieldLayout: [['from|2', 'to|2', 'status|1']],
|
|
57
|
-
},
|
|
58
|
-
},
|
|
59
|
-
},
|
|
60
|
-
// A row that cannot compile must never reach the DB: it would leave the artifact stale on every
|
|
61
|
-
// subsequent save and fail the prerender of `/redirects.json`. Zod validates one field at a time and
|
|
62
|
-
// cannot see that `to: '/x/$2'` needs a second wildcard in `from`, so the check lands here — before the
|
|
63
|
-
// write, as a field-scoped 400 the editor renders on the repeater.
|
|
64
|
-
validate: (record) => {
|
|
65
|
-
try {
|
|
66
|
-
compileRedirects(record.rules)
|
|
67
|
-
return []
|
|
68
|
-
} catch (error) {
|
|
69
|
-
return [{ path: ['rules'], message: (error as Error).message }]
|
|
70
|
-
}
|
|
71
|
-
},
|
|
72
|
-
}))
|
|
73
|
-
|
|
74
|
-
export const redirects = built.table
|
|
75
|
-
export default built
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import { buildCollection } from '../../../fields/server/utils/buildCollection'
|
|
2
|
-
import { defineCollection } from '../../../core/server/utils/defineCollection'
|
|
3
|
-
|
|
4
|
-
// `siteUrl`/`siteName` stay in `kestrel.config` on purpose: the build needs them for canonical URLs, the
|
|
5
|
-
// sitemap and robots.txt, so they cannot live in the DB. What is here is editorial instead — and a write
|
|
6
|
-
// re-publishes the routes that embed it, which a config value frozen at module setup can never do.
|
|
7
|
-
const built = buildCollection(defineCollection({
|
|
8
|
-
name: 'site',
|
|
9
|
-
mode: 'single',
|
|
10
|
-
translatable: true,
|
|
11
|
-
builtin: true,
|
|
12
|
-
label: { singular: { en: 'Site', de: 'Website' }, plural: { en: 'Site', de: 'Website' } },
|
|
13
|
-
icon: 'globe',
|
|
14
|
-
fields: {
|
|
15
|
-
baseTitle: { type: 'text', label: { en: 'Base title', de: 'Basis-Titel' } },
|
|
16
|
-
titleSeparator: { type: 'text', label: { en: 'Title separator', de: 'Titel-Trenner' }, default: '|' },
|
|
17
|
-
titlePosition: {
|
|
18
|
-
type: 'choice',
|
|
19
|
-
label: { en: 'Base title position', de: 'Position des Basis-Titels' },
|
|
20
|
-
options: {
|
|
21
|
-
choices: [
|
|
22
|
-
{ label: { en: 'After the page title', de: 'Nach dem Seitentitel' }, value: 'after' },
|
|
23
|
-
{ label: { en: 'Before the page title', de: 'Vor dem Seitentitel' }, value: 'before' },
|
|
24
|
-
],
|
|
25
|
-
display: 'buttons',
|
|
26
|
-
},
|
|
27
|
-
default: 'after',
|
|
28
|
-
},
|
|
29
|
-
description: {
|
|
30
|
-
type: 'text',
|
|
31
|
-
label: { en: 'Default meta description', de: 'Standard-Meta-Beschreibung' },
|
|
32
|
-
options: { multiline: true },
|
|
33
|
-
},
|
|
34
|
-
image: {
|
|
35
|
-
type: 'media',
|
|
36
|
-
label: { en: 'Default sharing image', de: 'Standard-Sharing-Bild' },
|
|
37
|
-
options: { accept: 'image' },
|
|
38
|
-
},
|
|
39
|
-
},
|
|
40
|
-
fieldLayout: [['baseTitle|2', 'titleSeparator|1'], 'titlePosition', 'description', 'image'],
|
|
41
|
-
}))
|
|
42
|
-
|
|
43
|
-
export const site = built.table
|
|
44
|
-
export default built
|
|
@@ -1,12 +0,0 @@
|
|
|
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
|
-
})
|
|
@@ -1,19 +0,0 @@
|
|
|
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
|
-
})
|
|
@@ -1,38 +0,0 @@
|
|
|
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 (error) {
|
|
31
|
-
// Still null (a throw would 500 every page holding an internal link on a bare prerender DB), but the
|
|
32
|
-
// memo caches that null run-wide as "not linkable", so the dead link needs a trace to be diagnosable.
|
|
33
|
-
console.error(`[kestrel] resolveInternalHref: ${collection}:${id} unreadable:`, (error as Error)?.message ?? error)
|
|
34
|
-
return null
|
|
35
|
-
}
|
|
36
|
-
if (!isPubliclyLinkable(row, Object.hasOwn(cols, 'status'))) return null
|
|
37
|
-
return pageRowHref(row, primaryLocale(), prefixPrimaryLocale())
|
|
38
|
-
}
|
|
@@ -1,125 +0,0 @@
|
|
|
1
|
-
import type { CollectionDef, FieldDef } from '../../../core/server/utils/defineCollection'
|
|
2
|
-
import { fieldIs } from '../../../core/server/utils/defineCollection'
|
|
3
|
-
import { getBlock } from '../../../fields/server/utils/defineBlock'
|
|
4
|
-
import { resolveRichtextLinks } from '../../../fields/app/utils/richtext-links'
|
|
5
|
-
import { escapeMarkdownBlock, richtextToMarkdown } from './richtext-markdown'
|
|
6
|
-
|
|
7
|
-
/** One published page rendered in full: what `llms.txt` links to, plus the body it links to. */
|
|
8
|
-
export interface LlmsFullPage {
|
|
9
|
-
title: string
|
|
10
|
-
url: string
|
|
11
|
-
description?: string
|
|
12
|
-
/** Markdown; already heading-shifted to nest under the page's own `###`. */
|
|
13
|
-
body: string
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/** A titled group of pages — one `## heading` block, mirroring `llms.txt`'s sections. */
|
|
17
|
-
export interface LlmsFullSection {
|
|
18
|
-
heading: string
|
|
19
|
-
pages: LlmsFullPage[]
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/** Pages sit at `###`, so a body's own `<h1>` has to start at `####` to keep the outline valid. */
|
|
23
|
-
export const LLMS_FULL_HEADING_OFFSET = 3
|
|
24
|
-
|
|
25
|
-
// Same defence as `llms.txt`: a title, heading or description is editor-authored text, and a newline in
|
|
26
|
-
// it would forge a second document line — here, a heading that invents a page.
|
|
27
|
-
const oneLine = (s: string): string => s.replace(/\s+/g, ' ').trim()
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Render an `llms-full.txt` (the long form of https://llmstxt.org): the site header, then every
|
|
31
|
-
* published, indexable page's full Markdown body under its own `###` heading, grouped by collection.
|
|
32
|
-
* Where `llms.txt` is a map, this is the territory — one document an answer engine can retrieve without
|
|
33
|
-
* crawling. Pure + deterministic; the route feeds it published, indexable pages only.
|
|
34
|
-
*/
|
|
35
|
-
export function buildLlmsFullTxt(opts: { siteName: string; siteDescription?: string; sections: LlmsFullSection[] }): string {
|
|
36
|
-
const blocks: string[] = [`# ${oneLine(opts.siteName)}`]
|
|
37
|
-
// A description is a whole block of its own, so flattening its newlines is not enough — a leading `##`
|
|
38
|
-
// would still open a section beside the ones this generator writes.
|
|
39
|
-
if (opts.siteDescription) blocks.push(`> ${escapeMarkdownBlock(oneLine(opts.siteDescription))}`)
|
|
40
|
-
for (const section of opts.sections) {
|
|
41
|
-
if (!section.pages.length) continue
|
|
42
|
-
blocks.push(`## ${oneLine(section.heading)}`)
|
|
43
|
-
for (const page of section.pages) {
|
|
44
|
-
blocks.push(`### ${oneLine(page.title)}`)
|
|
45
|
-
blocks.push(`Source: ${page.url}`)
|
|
46
|
-
if (page.description) blocks.push(escapeMarkdownBlock(oneLine(page.description)))
|
|
47
|
-
if (page.body) blocks.push(page.body)
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
return `${blocks.join('\n\n')}\n`
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export interface RecordMarkdownOptions {
|
|
54
|
-
/** Push every body heading down by this many levels — see `LLMS_FULL_HEADING_OFFSET`. */
|
|
55
|
-
headingOffset?: number
|
|
56
|
-
/** Top-level field keys the caller already rendered (the title it used as the heading). */
|
|
57
|
-
skipFields?: string[]
|
|
58
|
-
/** `kestrel:<collection>:<id>` → absolute URL, or null for a target that must not be linked (a draft,
|
|
59
|
-
* a noindexed page, a non-routable record). Unresolved links keep their text and lose the anchor. */
|
|
60
|
-
resolveLink?: (collection: string, id: number) => string | null
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/** The prose in one flat value bag (record columns | block props | a repeater entry), in field order.
|
|
64
|
-
* Only `text` and `richtext` carry prose; `repeater` recurses. Everything else is data, not content —
|
|
65
|
-
* a consumer's own field type is skipped rather than guessed at. */
|
|
66
|
-
function bagMarkdown(fields: Record<string, FieldDef>, bag: Record<string, unknown>, opts: RecordMarkdownOptions, skip?: Set<string>): string[] {
|
|
67
|
-
const out: string[] = []
|
|
68
|
-
for (const [key, field] of Object.entries(fields)) {
|
|
69
|
-
if (skip?.has(key)) continue
|
|
70
|
-
const value = bag[key]
|
|
71
|
-
if (field.type === 'text') {
|
|
72
|
-
// `fieldIs` rather than a switch: the open consumer-type arm makes `type` a non-discriminant, so a
|
|
73
|
-
// switch would not narrow (the same reason `extract-refs` walks this way).
|
|
74
|
-
// A text field is raw editor input with no markup to constrain it, so it is the one value that
|
|
75
|
-
// reaches the document verbatim — it needs the same block-marker escaping converted prose gets.
|
|
76
|
-
const text = typeof value === 'string' ? value.trim() : ''
|
|
77
|
-
if (text) out.push(escapeMarkdownBlock(text))
|
|
78
|
-
} else if (field.type === 'richtext') {
|
|
79
|
-
// Always run the marker rewrite, resolver or not: an unrewritten `kestrel:<coll>:<id>` href would
|
|
80
|
-
// ship a private storage token as a link target. With no resolver every marker declines to `#`,
|
|
81
|
-
// which the converter renders as plain text.
|
|
82
|
-
const html = typeof value === 'string' ? resolveRichtextLinks(value, opts.resolveLink ?? (() => null)) : ''
|
|
83
|
-
const md = richtextToMarkdown(html, { headingOffset: opts.headingOffset })
|
|
84
|
-
if (md) out.push(md)
|
|
85
|
-
} else if (fieldIs(field, 'repeater')) {
|
|
86
|
-
if (Array.isArray(value)) {
|
|
87
|
-
for (const entry of value) {
|
|
88
|
-
if (entry && typeof entry === 'object') out.push(...bagMarkdown(field.options.fields, entry as Record<string, unknown>, opts))
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
return out
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/** The prose in a block `content` array, recursing slots — the same walk `extract-refs` does for refs. */
|
|
97
|
-
function blockMarkdown(content: unknown, opts: RecordMarkdownOptions): string[] {
|
|
98
|
-
const out: string[] = []
|
|
99
|
-
const walk = (nodes: unknown): void => {
|
|
100
|
-
if (!Array.isArray(nodes)) return
|
|
101
|
-
for (const node of nodes) {
|
|
102
|
-
if (!node || typeof node !== 'object') continue
|
|
103
|
-
const n = node as { type?: string; props?: Record<string, unknown>; slots?: Record<string, unknown> }
|
|
104
|
-
// An unregistered block type has no field defs, so there is no way to tell its prose from its
|
|
105
|
-
// configuration — emitting every string prop would put layout tokens into the document.
|
|
106
|
-
const def = n.type ? getBlock(n.type) : undefined
|
|
107
|
-
if (def && n.props && typeof n.props === 'object') out.push(...bagMarkdown(def.fields, n.props, opts))
|
|
108
|
-
if (n.slots && typeof n.slots === 'object') for (const slotNodes of Object.values(n.slots)) walk(slotNodes)
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
walk(content)
|
|
112
|
-
return out
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* A record's readable content as Markdown: its own text/richtext fields (in declaration order), then its
|
|
117
|
-
* block tree if the collection enables blocks. Pure apart from the block registry — the same dependency
|
|
118
|
-
* `extractRecordRefs` has, and for the same reason: a block's prose is only identifiable through its
|
|
119
|
-
* registered field defs.
|
|
120
|
-
*/
|
|
121
|
-
export function recordMarkdown(def: CollectionDef, row: Record<string, unknown>, opts: RecordMarkdownOptions = {}): string {
|
|
122
|
-
const blocks = bagMarkdown(def.fields, row, opts, opts.skipFields?.length ? new Set(opts.skipFields) : undefined)
|
|
123
|
-
if (def.blocks?.enabled) blocks.push(...blockMarkdown(row.content, opts))
|
|
124
|
-
return blocks.join('\n\n')
|
|
125
|
-
}
|
|
@@ -1,52 +0,0 @@
|
|
|
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
|
-
/** The `## heading` for a collection's section: its plural label in the primary locale, else any locale's,
|
|
28
|
-
* else its capitalised name. Shared by `llms.txt` and `llms-full.txt` so the two never disagree. */
|
|
29
|
-
export function collectionHeading(def: { name: string; label?: { plural?: unknown } }, primaryLocale: string): string {
|
|
30
|
-
const cap = (s: string) => (s ? s[0]!.toUpperCase() + s.slice(1) : s)
|
|
31
|
-
const plural = def.label?.plural
|
|
32
|
-
if (typeof plural === 'string') return plural
|
|
33
|
-
if (plural && typeof plural === 'object') {
|
|
34
|
-
const byLocale = plural as Record<string, string>
|
|
35
|
-
return byLocale[primaryLocale] ?? Object.values(byLocale)[0] ?? cap(def.name)
|
|
36
|
-
}
|
|
37
|
-
return cap(def.name)
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export function buildLlmsTxt(opts: { siteName: string; siteDescription?: string; sections: LlmsSection[] }): string {
|
|
41
|
-
const lines: string[] = [`# ${oneLine(opts.siteName)}`]
|
|
42
|
-
if (opts.siteDescription) lines.push('', `> ${oneLine(opts.siteDescription)}`)
|
|
43
|
-
for (const section of opts.sections) {
|
|
44
|
-
if (!section.entries.length) continue
|
|
45
|
-
lines.push('', `## ${oneLine(section.heading)}`, '')
|
|
46
|
-
for (const e of section.entries) {
|
|
47
|
-
const desc = e.description ? `: ${oneLine(e.description)}` : ''
|
|
48
|
-
lines.push(`- [${linkText(e.title)}](${e.url})${desc}`)
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
return `${lines.join('\n')}\n`
|
|
52
|
-
}
|