@michaelthielemann/kestrel 3.0.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (353) hide show
  1. package/README.md +35 -110
  2. package/kestrel.config.ts +1 -1
  3. package/layers/access/server/middleware/00.ip-allowlist.ts +3 -18
  4. package/layers/access/server/middleware/access-guard.ts +25 -23
  5. package/layers/access/server/types.d.ts +1 -2
  6. package/layers/admin/app/components/AdminAccount.vue +1 -1
  7. package/layers/admin/app/components/BlockFields.vue +1 -2
  8. package/layers/admin/app/components/BlockPreview.vue +17 -5
  9. package/layers/admin/app/components/BlocksBody.vue +1 -1
  10. package/layers/admin/app/components/CollectionEditor.vue +35 -16
  11. package/layers/admin/app/components/CollectionList.vue +34 -3
  12. package/layers/admin/app/components/CollectionListBulkBar.vue +17 -3
  13. package/layers/admin/app/components/CollectionListFilterPanel.vue +1 -1
  14. package/layers/admin/app/components/CollectionListPager.vue +1 -1
  15. package/layers/admin/app/components/CollectionListTable.vue +41 -2
  16. package/layers/admin/app/components/EditorStatus.vue +8 -7
  17. package/layers/admin/app/components/LocaleBar.vue +3 -1
  18. package/layers/admin/app/components/PageFields.vue +2 -4
  19. package/layers/admin/app/components/SeoFields.vue +2 -4
  20. package/layers/admin/app/components/SingletonEditor.vue +3 -2
  21. package/layers/admin/app/composables/useAuth.ts +3 -3
  22. package/layers/admin/app/composables/useBlockTree.ts +1 -1
  23. package/layers/admin/app/composables/useBlocks.ts +1 -1
  24. package/layers/admin/app/composables/useCollectionOps.ts +45 -8
  25. package/layers/admin/app/composables/useCollections.ts +1 -2
  26. package/layers/admin/app/composables/useEditForm.ts +35 -17
  27. package/layers/admin/app/composables/useLinkResolver.ts +1 -1
  28. package/layers/admin/app/composables/useListBatchActions.ts +20 -3
  29. package/layers/admin/app/composables/useListColumns.ts +1 -1
  30. package/layers/admin/app/composables/useListFilterDraft.ts +3 -2
  31. package/layers/admin/app/composables/useListRows.ts +7 -2
  32. package/layers/admin/app/composables/useListUrlState.ts +1 -1
  33. package/layers/admin/app/composables/usePublishStatus.ts +2 -2
  34. package/layers/admin/app/layouts/admin.vue +0 -1
  35. package/layers/admin/app/pages/admin/[collection]/[id].vue +5 -4
  36. package/layers/admin/app/pages/admin/index.vue +1 -1
  37. package/layers/admin/app/pages/admin/references.vue +2 -2
  38. package/layers/admin/app/utils/block-tree.ts +1 -1
  39. package/layers/admin/app/utils/collection-ops.ts +28 -3
  40. package/layers/admin/app/utils/edit-form.ts +34 -2
  41. package/layers/admin/app/utils/editor-expose.ts +2 -0
  42. package/layers/admin/app/utils/editor-form-context.ts +1 -2
  43. package/layers/admin/app/utils/field-keys.ts +1 -1
  44. package/layers/admin/app/utils/list-columns.ts +3 -2
  45. package/layers/admin/app/utils/list-query.ts +2 -2
  46. package/layers/admin/app/utils/populate-blocks.ts +2 -2
  47. package/layers/admin/app/utils/reauth.ts +5 -6
  48. package/layers/auth/server/plugins/01.register-auth-pipelines.ts +7 -0
  49. package/layers/collections/server/plugins/01.register.ts +9 -12
  50. package/layers/collections/server/plugins/02.register-relation-populate.ts +5 -7
  51. package/layers/{fields → core}/app/utils/field-factories.ts +1 -1
  52. package/layers/core/modules/auto-discovery/extract-block.ts +3 -3
  53. package/layers/core/modules/auto-discovery/index.ts +68 -11
  54. package/layers/core/modules/auto-discovery/package-registry.ts +19 -0
  55. package/layers/core/modules/auto-discovery/scan.ts +77 -0
  56. package/layers/core/modules/auto-discovery/virtual.d.ts +5 -0
  57. package/layers/core/modules/component-namespace/index.ts +24 -0
  58. package/layers/core/modules/kestrel/index.ts +6 -1
  59. package/layers/core/modules/plugin-order/index.ts +69 -0
  60. package/layers/core/modules/plugin-order/plugin-order.ts +273 -0
  61. package/layers/core/nuxt.config.ts +1 -0
  62. package/layers/core/server/api/[...path].ts +98 -0
  63. package/layers/core/server/plugins/00.config.ts +12 -0
  64. package/layers/core/server/plugins/00.migrate.ts +1 -0
  65. package/layers/core/server/plugins/01.register-introspection-pipeline.ts +5 -0
  66. package/layers/core/server/plugins/01.register-openapi-pipeline.ts +5 -0
  67. package/layers/core/server/plugins/01.register-tooling-pipelines.ts +5 -0
  68. package/layers/core/server/plugins/02.schema-sync.ts +2 -7
  69. package/layers/core/server/plugins/04.outbox-worker.ts +31 -0
  70. package/layers/core/server/plugins/05.reindex-refs.ts +5 -0
  71. package/layers/core/server/routes/__kestrel/dashboard.get.ts +29 -0
  72. package/layers/core/server/tasks/db/migrate-module.ts +83 -0
  73. package/layers/core/server/tasks/db/migrate-revisions.ts +20 -0
  74. package/layers/core/server/tasks/db/migrate.ts +2 -6
  75. package/layers/core/server/utils/server-config.ts +66 -3
  76. package/layers/fields/server/plugins/01.register-blocks-pipeline.ts +7 -0
  77. package/layers/fields/server/plugins/01.register-field-populate.ts +2 -2
  78. package/layers/media/app/components/KestrelImg.vue +3 -3
  79. package/layers/media/app/components/MediaLibrary.vue +2 -2
  80. package/layers/media/app/components/field/Media.vue +1 -1
  81. package/layers/media/app/composables/useMediaResolver.ts +1 -1
  82. package/layers/media/app/composables/useMediaUpload.ts +1 -1
  83. package/layers/media/app/composables/useMediaVariant.ts +2 -2
  84. package/layers/media/app/utils/ai-disclosure.ts +1 -1
  85. package/layers/media/app/utils/library.ts +1 -1
  86. package/layers/media/app/utils/ops.ts +1 -3
  87. package/layers/media/app/utils/picture-model.ts +2 -2
  88. package/layers/media/app/utils/variant-name.ts +1 -1
  89. package/layers/media/server/middleware/ondemand-variants.ts +6 -5
  90. package/layers/media/server/plugins/01.register-media-pipelines.ts +7 -0
  91. package/layers/media/server/plugins/02.register-media.ts +10 -7
  92. package/layers/media/server/plugins/04.variant-capture.ts +12 -8
  93. package/layers/media/server/plugins/05.media-cleanup.ts +5 -0
  94. package/layers/media/server/tasks/media/backfill.ts +3 -4
  95. package/layers/public/app/components/KestrelLink.vue +1 -1
  96. package/layers/public/app/pages/[...slug].vue +1 -1
  97. package/layers/public/app/pages/__kestrel/preview.vue +2 -2
  98. package/layers/public/app/utils/json-ld.ts +1 -1
  99. package/layers/public/app/utils/link-href.ts +1 -1
  100. package/layers/public/app/utils/page-head.ts +1 -1
  101. package/layers/public/modules/deploy-output/index.ts +4 -6
  102. package/layers/public/modules/prerender-routes/index.ts +4 -4
  103. package/layers/public/modules/prerender-routes/routes.ts +1 -1
  104. package/layers/public/modules/prune-media/index.ts +2 -2
  105. package/layers/public/server/delivery-live/serve.ts +111 -0
  106. package/layers/public/server/middleware/delivery-live-catchall.ts +7 -0
  107. package/layers/public/server/pipelines/index.ts +23 -0
  108. package/layers/public/server/plugins/00.ensure-snapshot-triggers.ts +14 -0
  109. package/layers/public/server/plugins/01.register-public-pipelines.ts +7 -0
  110. package/layers/public/server/plugins/02.register-links.ts +2 -3
  111. package/layers/public/server/plugins/03.redirects.ts +41 -23
  112. package/layers/public/server/plugins/05.plan-publish.ts +5 -0
  113. package/layers/public/server/plugins/zz.publish.ts +79 -24
  114. package/layers/public/server/routes/llms-full.txt.get.ts +5 -5
  115. package/layers/public/server/routes/llms.txt.get.ts +4 -2
  116. package/layers/public/server/routes/redirects.json.get.ts +2 -2
  117. package/layers/public/server/routes/robots.txt.get.ts +2 -0
  118. package/layers/public/server/routes/sitemap.xml.get.ts +16 -6
  119. package/layers/public/server/tasks/publish/run.ts +9 -4
  120. package/layers/public/server/utils/publish/render-live.ts +26 -0
  121. package/layers/ui/app/components/field/Choice.vue +1 -1
  122. package/layers/ui/app/components/field/Datetime.vue +1 -1
  123. package/layers/ui/app/components/field/Json.vue +1 -2
  124. package/layers/ui/app/components/field/Layout.vue +2 -4
  125. package/layers/ui/app/components/field/Link.vue +1 -1
  126. package/layers/ui/app/components/field/LinkInternalPicker.vue +3 -3
  127. package/layers/ui/app/components/field/Number.vue +2 -2
  128. package/layers/ui/app/components/field/Relation.vue +1 -1
  129. package/layers/ui/app/components/field/Repeater.vue +6 -8
  130. package/layers/ui/app/components/field/Slug.vue +2 -3
  131. package/layers/ui/app/components/field/Text.vue +1 -1
  132. package/layers/ui/app/components/ui/Button.vue +2 -2
  133. package/layers/ui/app/components/ui/ButtonGroup.vue +2 -2
  134. package/layers/ui/app/components/ui/Checkbox.vue +2 -2
  135. package/layers/ui/app/components/ui/Combobox.vue +5 -5
  136. package/layers/ui/app/components/ui/DatePicker.vue +2 -2
  137. package/layers/ui/app/components/ui/DateRangePicker.vue +2 -2
  138. package/layers/ui/app/components/ui/NumberInput.vue +4 -4
  139. package/layers/ui/app/components/ui/Richtext.vue +1 -1
  140. package/layers/ui/app/components/ui/RichtextToolbar.vue +1 -3
  141. package/layers/ui/app/components/ui/Select.vue +2 -2
  142. package/layers/ui/app/components/ui/TextInput.vue +4 -4
  143. package/layers/ui/app/components/ui/Textarea.vue +2 -2
  144. package/layers/ui/app/components/ui/TimeInput.vue +2 -2
  145. package/layers/ui/app/components/ui/Toasts.vue +3 -3
  146. package/layers/ui/app/composables/useLinkField.ts +1 -1
  147. package/layers/ui/app/composables/useRepeater.ts +1 -1
  148. package/layers/ui/app/i18n/de.ts +7 -7
  149. package/layers/ui/app/i18n/en.ts +7 -39
  150. package/layers/ui/app/utils/field-component.ts +1 -1
  151. package/layers/ui/app/utils/field-registry.ts +1 -1
  152. package/layers/ui/app/utils/icons.ts +2 -6
  153. package/layers/ui/app/utils/localized.ts +1 -1
  154. package/nuxt.config.ts +1 -1
  155. package/package.json +53 -19
  156. package/scripts/consumer-template-ci.mjs +157 -0
  157. package/scripts/coverage-all.mjs +163 -0
  158. package/scripts/dashboard.mjs +89 -0
  159. package/scripts/hash-password.mjs +1 -1
  160. package/scripts/lib/coverage-model.mjs +51 -0
  161. package/scripts/port-graph.mjs +350 -0
  162. package/scripts/typecheck.mjs +11 -0
  163. package/templates/starter/app/blocks/Prose.vue +1 -1
  164. package/layers/access/server/utils/csrf.ts +0 -39
  165. package/layers/access/server/utils/grant-registry.ts +0 -36
  166. package/layers/access/server/utils/guard.ts +0 -44
  167. package/layers/access/server/utils/ip-allowlist.ts +0 -79
  168. package/layers/access/server/utils/policy.ts +0 -98
  169. package/layers/access/server/utils/public-resources.ts +0 -11
  170. package/layers/access/server/utils/render-context.ts +0 -41
  171. package/layers/access/server/utils/require-admin.ts +0 -15
  172. package/layers/admin/app/pages/admin/[collection]/[id].nuxt.test.ts +0 -200
  173. package/layers/admin/app/pages/admin/[collection]/publish-preview.nuxt.test.ts +0 -142
  174. package/layers/auth/server/api/auth/login.post.ts +0 -38
  175. package/layers/auth/server/api/auth/logout.post.ts +0 -9
  176. package/layers/auth/server/api/auth/session.get.ts +0 -1
  177. package/layers/auth/server/utils/admin-credential.ts +0 -42
  178. package/layers/auth/server/utils/client-ip.ts +0 -88
  179. package/layers/auth/server/utils/login-throttle.ts +0 -126
  180. package/layers/auth/server/utils/password.ts +0 -52
  181. package/layers/auth/server/utils/session-cookie.ts +0 -44
  182. package/layers/auth/server/utils/session-epoch.ts +0 -96
  183. package/layers/auth/server/utils/session.ts +0 -88
  184. package/layers/collections/server/collections/pages.ts +0 -19
  185. package/layers/collections/server/utils/populate-relations.ts +0 -79
  186. package/layers/core/app/utils/bulk-actions.ts +0 -9
  187. package/layers/core/app/utils/filter-ops.ts +0 -98
  188. package/layers/core/app/utils/list-limits.ts +0 -27
  189. package/layers/core/app/utils/locale-path.ts +0 -12
  190. package/layers/core/app/utils/slugify.ts +0 -16
  191. package/layers/core/server/api/[collection]/[id]/dead-refs.get.ts +0 -8
  192. package/layers/core/server/api/[collection]/[id]/translations.get.ts +0 -4
  193. package/layers/core/server/api/[collection]/[id].delete.ts +0 -5
  194. package/layers/core/server/api/[collection]/[id].get.ts +0 -11
  195. package/layers/core/server/api/[collection]/[id].patch.ts +0 -8
  196. package/layers/core/server/api/[collection]/bulk.post.ts +0 -37
  197. package/layers/core/server/api/[collection]/index.get.ts +0 -24
  198. package/layers/core/server/api/[collection]/index.post.ts +0 -8
  199. package/layers/core/server/api/[collection]/index.put.test.ts +0 -76
  200. package/layers/core/server/api/[collection]/index.put.ts +0 -24
  201. package/layers/core/server/api/[collection]/options.get.test.ts +0 -60
  202. package/layers/core/server/api/[collection]/options.get.ts +0 -18
  203. package/layers/core/server/api/[collection]/populate-scope.test.ts +0 -119
  204. package/layers/core/server/api/[collection]/translations.get.test.ts +0 -90
  205. package/layers/core/server/api/[collection]/translations.get.ts +0 -25
  206. package/layers/core/server/api/collections/[name].get.ts +0 -4
  207. package/layers/core/server/api/collections/index.get.ts +0 -3
  208. package/layers/core/server/api/references/broken.get.ts +0 -9
  209. package/layers/core/server/api/references/referrers.get.ts +0 -24
  210. package/layers/core/server/database/record-refs.ts +0 -20
  211. package/layers/core/server/plugins/03.record-refs.ts +0 -12
  212. package/layers/core/server/schema/bootstrap.ts +0 -41
  213. package/layers/core/server/schema/desired.ts +0 -99
  214. package/layers/core/server/schema/dev-sync.ts +0 -23
  215. package/layers/core/server/schema/dialect.ts +0 -61
  216. package/layers/core/server/schema/diff.ts +0 -110
  217. package/layers/core/server/schema/introspect.ts +0 -68
  218. package/layers/core/server/schema/model.ts +0 -61
  219. package/layers/core/server/schema/render-sqlite.ts +0 -89
  220. package/layers/core/server/schema/sync.ts +0 -201
  221. package/layers/core/server/utils/blocks.ts +0 -6
  222. package/layers/core/server/utils/collection-types.ts +0 -28
  223. package/layers/core/server/utils/crud.ts +0 -578
  224. package/layers/core/server/utils/db.ts +0 -26
  225. package/layers/core/server/utils/defineCollection.ts +0 -213
  226. package/layers/core/server/utils/duplicate.ts +0 -162
  227. package/layers/core/server/utils/field-layout.ts +0 -125
  228. package/layers/core/server/utils/filter-predicate.ts +0 -101
  229. package/layers/core/server/utils/http.ts +0 -50
  230. package/layers/core/server/utils/if-unmodified.ts +0 -13
  231. package/layers/core/server/utils/kestrel-config.ts +0 -451
  232. package/layers/core/server/utils/key-lock.ts +0 -33
  233. package/layers/core/server/utils/locale.ts +0 -53
  234. package/layers/core/server/utils/page-route.ts +0 -58
  235. package/layers/core/server/utils/page-slug.ts +0 -127
  236. package/layers/core/server/utils/picker.ts +0 -82
  237. package/layers/core/server/utils/populate.ts +0 -50
  238. package/layers/core/server/utils/prerender-memo.ts +0 -23
  239. package/layers/core/server/utils/read-capture.ts +0 -37
  240. package/layers/core/server/utils/record-ref-index.ts +0 -200
  241. package/layers/core/server/utils/registry.ts +0 -24
  242. package/layers/core/server/utils/resolve-scope.ts +0 -79
  243. package/layers/core/server/utils/seo.ts +0 -42
  244. package/layers/core/server/utils/serialize-collection.ts +0 -147
  245. package/layers/core/server/utils/sql.ts +0 -2
  246. package/layers/core/server/utils/storage.local.ts +0 -117
  247. package/layers/core/server/utils/storage.s3.ts +0 -197
  248. package/layers/core/server/utils/storage.ts +0 -90
  249. package/layers/core/server/utils/write-effects.ts +0 -40
  250. package/layers/core/server/utils/write-events.ts +0 -31
  251. package/layers/fields/app/utils/condition.ts +0 -71
  252. package/layers/fields/app/utils/define-block.ts +0 -13
  253. package/layers/fields/app/utils/field-constraints.ts +0 -17
  254. package/layers/fields/app/utils/field-meta.ts +0 -33
  255. package/layers/fields/app/utils/field-validate.ts +0 -75
  256. package/layers/fields/app/utils/field-value.ts +0 -7
  257. package/layers/fields/app/utils/richtext-links.ts +0 -48
  258. package/layers/fields/server/api/blocks/index.get.ts +0 -9
  259. package/layers/fields/server/field-registry/index.ts +0 -249
  260. package/layers/fields/server/field-registry/naming.ts +0 -10
  261. package/layers/fields/server/field-registry/sanitize.ts +0 -43
  262. package/layers/fields/server/field-registry/types.ts +0 -19
  263. package/layers/fields/server/utils/block-ids.ts +0 -43
  264. package/layers/fields/server/utils/block-populate.ts +0 -41
  265. package/layers/fields/server/utils/buildCollection.ts +0 -90
  266. package/layers/fields/server/utils/buildFieldSchema.ts +0 -22
  267. package/layers/fields/server/utils/buildTable.ts +0 -113
  268. package/layers/fields/server/utils/conditional-required.ts +0 -25
  269. package/layers/fields/server/utils/defineBlock.ts +0 -86
  270. package/layers/fields/server/utils/defineFieldType.ts +0 -33
  271. package/layers/fields/server/utils/extract-refs.ts +0 -152
  272. package/layers/fields/server/utils/field-populate.ts +0 -68
  273. package/layers/media/server/api/media/[id]/usages.get.ts +0 -8
  274. package/layers/media/server/api/media/[id].delete.ts +0 -18
  275. package/layers/media/server/api/media/[id].get.ts +0 -10
  276. package/layers/media/server/api/media/[id].patch.test.ts +0 -66
  277. package/layers/media/server/api/media/[id].patch.ts +0 -65
  278. package/layers/media/server/api/media/backfill.post.ts +0 -18
  279. package/layers/media/server/api/media/copy.post.ts +0 -7
  280. package/layers/media/server/api/media/delete.post.ts +0 -13
  281. package/layers/media/server/api/media/folders.post.ts +0 -18
  282. package/layers/media/server/api/media/index.get.ts +0 -15
  283. package/layers/media/server/api/media/index.post.ts +0 -173
  284. package/layers/media/server/api/media/library.get.ts +0 -17
  285. package/layers/media/server/api/media/move.post.ts +0 -7
  286. package/layers/media/server/api/media/rename.post.ts +0 -7
  287. package/layers/media/server/api/media/resolve.get.ts +0 -13
  288. package/layers/media/server/collections/media-settings.ts +0 -25
  289. package/layers/media/server/collections/media.ts +0 -43
  290. package/layers/media/server/database/folders.ts +0 -10
  291. package/layers/media/server/plugins/03.media-cleanup.ts +0 -20
  292. package/layers/media/server/schema-tables/folders.ts +0 -4
  293. package/layers/media/server/utils/ai-disclosure-enabled.ts +0 -16
  294. package/layers/media/server/utils/ai-signal-detect.ts +0 -155
  295. package/layers/media/server/utils/backfill.ts +0 -144
  296. package/layers/media/server/utils/derive.ts +0 -59
  297. package/layers/media/server/utils/folder-paths.ts +0 -42
  298. package/layers/media/server/utils/folders.ts +0 -12
  299. package/layers/media/server/utils/library.ts +0 -91
  300. package/layers/media/server/utils/media-cleanup.ts +0 -27
  301. package/layers/media/server/utils/media-enabled.ts +0 -23
  302. package/layers/media/server/utils/media-ops.ts +0 -118
  303. package/layers/media/server/utils/media-write.ts +0 -13
  304. package/layers/media/server/utils/naming.ts +0 -52
  305. package/layers/media/server/utils/ondemand.ts +0 -69
  306. package/layers/media/server/utils/persist-upload.ts +0 -62
  307. package/layers/media/server/utils/populate.ts +0 -47
  308. package/layers/media/server/utils/record.ts +0 -71
  309. package/layers/media/server/utils/relocate-ops.ts +0 -341
  310. package/layers/media/server/utils/relocate-plan.ts +0 -63
  311. package/layers/media/server/utils/resolve.ts +0 -98
  312. package/layers/media/server/utils/sanitize-svg.ts +0 -12
  313. package/layers/media/server/utils/sniff.ts +0 -68
  314. package/layers/media/server/utils/storage-relocate.ts +0 -139
  315. package/layers/media/server/utils/translations.ts +0 -19
  316. package/layers/media/server/utils/usages.ts +0 -66
  317. package/layers/media/server/utils/variant-capture.ts +0 -70
  318. package/layers/media/server/utils/variants.ts +0 -123
  319. package/layers/public/modules/deploy-output/deploy-output.ts +0 -384
  320. package/layers/public/server/api/links/resolve.get.ts +0 -19
  321. package/layers/public/server/api/preview.get.ts +0 -28
  322. package/layers/public/server/api/preview.post.ts +0 -93
  323. package/layers/public/server/api/publish-status.get.ts +0 -58
  324. package/layers/public/server/api/publish.post.ts +0 -84
  325. package/layers/public/server/api/route.get.ts +0 -54
  326. package/layers/public/server/collections/redirects.ts +0 -75
  327. package/layers/public/server/collections/site.ts +0 -44
  328. package/layers/public/server/database/publish-deps.ts +0 -12
  329. package/layers/public/server/database/publish-status.ts +0 -19
  330. package/layers/public/server/schema-tables/publish-deps.ts +0 -4
  331. package/layers/public/server/schema-tables/publish-status.ts +0 -4
  332. package/layers/public/server/utils/link-resolve.ts +0 -38
  333. package/layers/public/server/utils/llms-full.ts +0 -125
  334. package/layers/public/server/utils/llms.ts +0 -52
  335. package/layers/public/server/utils/page-resolve.ts +0 -208
  336. package/layers/public/server/utils/populate-links.ts +0 -66
  337. package/layers/public/server/utils/preview-token.ts +0 -109
  338. package/layers/public/server/utils/publish/coalesce.ts +0 -22
  339. package/layers/public/server/utils/publish/deps-persistence.ts +0 -43
  340. package/layers/public/server/utils/publish/deps.ts +0 -87
  341. package/layers/public/server/utils/publish/invalidation.ts +0 -225
  342. package/layers/public/server/utils/publish/pending.ts +0 -74
  343. package/layers/public/server/utils/publish/publish-runtime.ts +0 -28
  344. package/layers/public/server/utils/publish/publish-status.ts +0 -72
  345. package/layers/public/server/utils/publish/publisher.ts +0 -362
  346. package/layers/public/server/utils/publish/queue.ts +0 -67
  347. package/layers/public/server/utils/publish/redirect-rules.ts +0 -221
  348. package/layers/public/server/utils/publish/redirects-artifact.ts +0 -20
  349. package/layers/public/server/utils/publish/route-for-record.ts +0 -16
  350. package/layers/public/server/utils/publish/route-keys.ts +0 -13
  351. package/layers/public/server/utils/richtext-markdown.ts +0 -260
  352. package/layers/public/server/utils/site-url.ts +0 -40
  353. 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,4 +0,0 @@
1
- import { publishDeps } from '../database/publish-deps'
2
-
3
- // Registers the standalone publish_deps table with the schema engine (the `#kestrel/schema-tables` virtual).
4
- export default publishDeps
@@ -1,4 +0,0 @@
1
- import { publishStatus } from '../database/publish-status'
2
-
3
- // Registers the standalone publish_status table with the schema engine (the `#kestrel/schema-tables` virtual).
4
- export default publishStatus
@@ -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
- }