@michaelthielemann/kestrel 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (395) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +12 -0
  3. package/README.md +234 -0
  4. package/kestrel.config.ts +28 -0
  5. package/layers/access/nuxt.config.ts +5 -0
  6. package/layers/access/server/middleware/00.ip-allowlist.ts +67 -0
  7. package/layers/access/server/middleware/access-guard.ts +30 -0
  8. package/layers/access/server/types.d.ts +8 -0
  9. package/layers/access/server/utils/csrf.ts +39 -0
  10. package/layers/access/server/utils/grant-registry.ts +36 -0
  11. package/layers/access/server/utils/guard.ts +44 -0
  12. package/layers/access/server/utils/ip-allowlist.ts +76 -0
  13. package/layers/access/server/utils/policy.ts +98 -0
  14. package/layers/access/server/utils/public-resources.ts +11 -0
  15. package/layers/access/server/utils/render-context.ts +41 -0
  16. package/layers/access/server/utils/require-admin.ts +15 -0
  17. package/layers/admin/app/components/AdminAccount.vue +212 -0
  18. package/layers/admin/app/components/AdminNav.vue +44 -0
  19. package/layers/admin/app/components/BlockFields.vue +68 -0
  20. package/layers/admin/app/components/BlockPreview.vue +401 -0
  21. package/layers/admin/app/components/BlockTree.vue +433 -0
  22. package/layers/admin/app/components/BlocksBody.vue +162 -0
  23. package/layers/admin/app/components/CollectionDeleteDialog.vue +38 -0
  24. package/layers/admin/app/components/CollectionEditor.vue +203 -0
  25. package/layers/admin/app/components/CollectionList.vue +1091 -0
  26. package/layers/admin/app/components/EditorStatus.vue +199 -0
  27. package/layers/admin/app/components/EditorUnsupported.vue +28 -0
  28. package/layers/admin/app/components/FieldsBody.vue +23 -0
  29. package/layers/admin/app/components/LocaleBar.vue +105 -0
  30. package/layers/admin/app/components/PageFields.vue +101 -0
  31. package/layers/admin/app/components/PageFieldsPane.vue +39 -0
  32. package/layers/admin/app/components/SeoFields.vue +169 -0
  33. package/layers/admin/app/components/SingletonEditor.vue +92 -0
  34. package/layers/admin/app/composables/useAuth.ts +68 -0
  35. package/layers/admin/app/composables/useBlockTree.ts +95 -0
  36. package/layers/admin/app/composables/useBlocks.ts +7 -0
  37. package/layers/admin/app/composables/useCachedResource.ts +35 -0
  38. package/layers/admin/app/composables/useCollectionOps.ts +53 -0
  39. package/layers/admin/app/composables/useCollections.ts +7 -0
  40. package/layers/admin/app/composables/useContentLocales.ts +23 -0
  41. package/layers/admin/app/composables/useEditForm.ts +370 -0
  42. package/layers/admin/app/composables/useLinkResolver.ts +40 -0
  43. package/layers/admin/app/composables/useListColumns.ts +53 -0
  44. package/layers/admin/app/composables/useListUrlState.ts +53 -0
  45. package/layers/admin/app/composables/usePendingPublishPoll.ts +15 -0
  46. package/layers/admin/app/composables/usePreviewViewport.ts +30 -0
  47. package/layers/admin/app/composables/usePublishStatus.ts +98 -0
  48. package/layers/admin/app/composables/useRailCollapsed.ts +14 -0
  49. package/layers/admin/app/composables/useTheme.ts +36 -0
  50. package/layers/admin/app/composables/useUnsavedGuard.ts +22 -0
  51. package/layers/admin/app/layouts/admin.vue +267 -0
  52. package/layers/admin/app/middleware/admin-auth.ts +4 -0
  53. package/layers/admin/app/pages/admin/[collection]/[id].vue +202 -0
  54. package/layers/admin/app/pages/admin/[collection]/index.vue +62 -0
  55. package/layers/admin/app/pages/admin/index.vue +118 -0
  56. package/layers/admin/app/pages/admin/login.vue +68 -0
  57. package/layers/admin/app/pages/admin/media.vue +28 -0
  58. package/layers/admin/app/pages/admin/references.vue +146 -0
  59. package/layers/admin/app/plugins/admin-style-guard.client.ts +76 -0
  60. package/layers/admin/app/plugins/reauth.client.ts +20 -0
  61. package/layers/admin/app/utils/admin-style-guard.ts +20 -0
  62. package/layers/admin/app/utils/block-tree.ts +214 -0
  63. package/layers/admin/app/utils/collection-ops.ts +44 -0
  64. package/layers/admin/app/utils/dead-refs.ts +30 -0
  65. package/layers/admin/app/utils/edit-form.ts +223 -0
  66. package/layers/admin/app/utils/editor-expose.ts +26 -0
  67. package/layers/admin/app/utils/editor-form-context.ts +83 -0
  68. package/layers/admin/app/utils/editor-registry.ts +20 -0
  69. package/layers/admin/app/utils/field-keys.ts +11 -0
  70. package/layers/admin/app/utils/list-columns.ts +141 -0
  71. package/layers/admin/app/utils/list-query.ts +96 -0
  72. package/layers/admin/app/utils/nav-active.ts +9 -0
  73. package/layers/admin/app/utils/populate-blocks.ts +288 -0
  74. package/layers/admin/app/utils/preview-channel.ts +62 -0
  75. package/layers/admin/app/utils/preview-viewport.ts +80 -0
  76. package/layers/admin/app/utils/reauth.ts +68 -0
  77. package/layers/admin/app/utils/record-title.ts +15 -0
  78. package/layers/admin/app/utils/register-builtin-editors.ts +9 -0
  79. package/layers/admin/app/utils/safe-redirect.ts +12 -0
  80. package/layers/admin/nuxt.config.ts +17 -0
  81. package/layers/auth/nuxt.config.ts +1 -0
  82. package/layers/auth/server/api/auth/login.post.ts +38 -0
  83. package/layers/auth/server/api/auth/logout.post.ts +9 -0
  84. package/layers/auth/server/api/auth/session.get.ts +1 -0
  85. package/layers/auth/server/utils/admin-credential.ts +42 -0
  86. package/layers/auth/server/utils/client-ip.ts +88 -0
  87. package/layers/auth/server/utils/login-throttle.ts +126 -0
  88. package/layers/auth/server/utils/password.ts +52 -0
  89. package/layers/auth/server/utils/session-cookie.ts +44 -0
  90. package/layers/auth/server/utils/session-epoch.ts +96 -0
  91. package/layers/auth/server/utils/session.ts +85 -0
  92. package/layers/collections/nuxt.config.ts +1 -0
  93. package/layers/collections/server/collections/pages.ts +19 -0
  94. package/layers/collections/server/plugins/01.register.ts +32 -0
  95. package/layers/collections/server/plugins/02.register-relation-populate.ts +17 -0
  96. package/layers/collections/server/utils/populate-relations.ts +66 -0
  97. package/layers/core/app/utils/bulk-actions.ts +9 -0
  98. package/layers/core/app/utils/filter-ops.ts +98 -0
  99. package/layers/core/app/utils/list-limits.ts +27 -0
  100. package/layers/core/app/utils/locale-path.ts +12 -0
  101. package/layers/core/app/utils/slugify.ts +16 -0
  102. package/layers/core/modules/auto-discovery/extract-block.ts +176 -0
  103. package/layers/core/modules/auto-discovery/index.ts +44 -0
  104. package/layers/core/modules/auto-discovery/scan.ts +78 -0
  105. package/layers/core/modules/auto-discovery/virtual.d.ts +12 -0
  106. package/layers/core/modules/kestrel/index.ts +86 -0
  107. package/layers/core/nuxt.config.ts +8 -0
  108. package/layers/core/server/api/[collection]/[id]/dead-refs.get.ts +8 -0
  109. package/layers/core/server/api/[collection]/[id]/translations.get.ts +4 -0
  110. package/layers/core/server/api/[collection]/[id].delete.ts +5 -0
  111. package/layers/core/server/api/[collection]/[id].get.ts +8 -0
  112. package/layers/core/server/api/[collection]/[id].patch.ts +8 -0
  113. package/layers/core/server/api/[collection]/bulk.post.ts +37 -0
  114. package/layers/core/server/api/[collection]/index.get.ts +19 -0
  115. package/layers/core/server/api/[collection]/index.post.ts +8 -0
  116. package/layers/core/server/api/[collection]/index.put.ts +7 -0
  117. package/layers/core/server/api/[collection]/options.get.ts +18 -0
  118. package/layers/core/server/api/[collection]/translations.get.ts +25 -0
  119. package/layers/core/server/api/collections/[name].get.ts +4 -0
  120. package/layers/core/server/api/collections/index.get.ts +3 -0
  121. package/layers/core/server/api/references/broken.get.ts +3 -0
  122. package/layers/core/server/api/references/referrers.get.ts +24 -0
  123. package/layers/core/server/database/record-refs.ts +20 -0
  124. package/layers/core/server/plugins/00.migrate.ts +11 -0
  125. package/layers/core/server/plugins/02.schema-sync.ts +63 -0
  126. package/layers/core/server/plugins/03.record-refs.ts +12 -0
  127. package/layers/core/server/schema/bootstrap.ts +41 -0
  128. package/layers/core/server/schema/desired.ts +99 -0
  129. package/layers/core/server/schema/dev-sync.ts +23 -0
  130. package/layers/core/server/schema/dialect.ts +59 -0
  131. package/layers/core/server/schema/diff.ts +110 -0
  132. package/layers/core/server/schema/introspect.ts +67 -0
  133. package/layers/core/server/schema/model.ts +61 -0
  134. package/layers/core/server/schema/render-sqlite.ts +89 -0
  135. package/layers/core/server/schema/sync.ts +201 -0
  136. package/layers/core/server/tasks/db/migrate.ts +54 -0
  137. package/layers/core/server/utils/blocks.ts +6 -0
  138. package/layers/core/server/utils/collection-types.ts +27 -0
  139. package/layers/core/server/utils/crud.ts +577 -0
  140. package/layers/core/server/utils/db.ts +26 -0
  141. package/layers/core/server/utils/defineCollection.ts +207 -0
  142. package/layers/core/server/utils/duplicate.ts +162 -0
  143. package/layers/core/server/utils/field-layout.ts +125 -0
  144. package/layers/core/server/utils/filter-predicate.ts +101 -0
  145. package/layers/core/server/utils/http.ts +50 -0
  146. package/layers/core/server/utils/if-unmodified.ts +13 -0
  147. package/layers/core/server/utils/kestrel-config.ts +410 -0
  148. package/layers/core/server/utils/key-lock.ts +33 -0
  149. package/layers/core/server/utils/locale.ts +53 -0
  150. package/layers/core/server/utils/page-route.ts +58 -0
  151. package/layers/core/server/utils/page-slug.ts +127 -0
  152. package/layers/core/server/utils/picker.ts +82 -0
  153. package/layers/core/server/utils/populate.ts +48 -0
  154. package/layers/core/server/utils/prerender-memo.ts +23 -0
  155. package/layers/core/server/utils/read-capture.ts +37 -0
  156. package/layers/core/server/utils/record-ref-index.ts +200 -0
  157. package/layers/core/server/utils/registry.ts +24 -0
  158. package/layers/core/server/utils/resolve-scope.ts +79 -0
  159. package/layers/core/server/utils/seo.ts +24 -0
  160. package/layers/core/server/utils/serialize-collection.ts +149 -0
  161. package/layers/core/server/utils/server-config.ts +18 -0
  162. package/layers/core/server/utils/sql.ts +2 -0
  163. package/layers/core/server/utils/storage.local.ts +117 -0
  164. package/layers/core/server/utils/storage.s3.ts +197 -0
  165. package/layers/core/server/utils/storage.ts +90 -0
  166. package/layers/core/server/utils/write-events.ts +31 -0
  167. package/layers/fields/app/utils/condition.ts +71 -0
  168. package/layers/fields/app/utils/define-block.ts +13 -0
  169. package/layers/fields/app/utils/field-constraints.ts +17 -0
  170. package/layers/fields/app/utils/field-factories.ts +129 -0
  171. package/layers/fields/app/utils/field-meta.ts +33 -0
  172. package/layers/fields/app/utils/field-validate.ts +75 -0
  173. package/layers/fields/app/utils/field-value.ts +7 -0
  174. package/layers/fields/app/utils/richtext-links.ts +48 -0
  175. package/layers/fields/nuxt.config.ts +1 -0
  176. package/layers/fields/server/api/blocks/index.get.ts +9 -0
  177. package/layers/fields/server/field-registry/index.ts +248 -0
  178. package/layers/fields/server/field-registry/naming.ts +10 -0
  179. package/layers/fields/server/field-registry/sanitize.ts +40 -0
  180. package/layers/fields/server/field-registry/types.ts +19 -0
  181. package/layers/fields/server/plugins/01.register-field-populate.ts +11 -0
  182. package/layers/fields/server/utils/block-ids.ts +43 -0
  183. package/layers/fields/server/utils/block-populate.ts +41 -0
  184. package/layers/fields/server/utils/buildCollection.ts +88 -0
  185. package/layers/fields/server/utils/buildFieldSchema.ts +22 -0
  186. package/layers/fields/server/utils/buildTable.ts +107 -0
  187. package/layers/fields/server/utils/conditional-required.ts +25 -0
  188. package/layers/fields/server/utils/defineBlock.ts +86 -0
  189. package/layers/fields/server/utils/defineFieldType.ts +33 -0
  190. package/layers/fields/server/utils/extract-refs.ts +152 -0
  191. package/layers/fields/server/utils/field-populate.ts +68 -0
  192. package/layers/media/app/components/KestrelImg.vue +65 -0
  193. package/layers/media/app/components/MediaConflictDialog.vue +35 -0
  194. package/layers/media/app/components/MediaDeleteDialog.vue +52 -0
  195. package/layers/media/app/components/MediaGrid.vue +113 -0
  196. package/layers/media/app/components/MediaImage.vue +70 -0
  197. package/layers/media/app/components/MediaLibrary.vue +451 -0
  198. package/layers/media/app/components/MediaNewFolderDialog.vue +32 -0
  199. package/layers/media/app/components/MediaPathBar.vue +163 -0
  200. package/layers/media/app/components/MediaPicker.vue +19 -0
  201. package/layers/media/app/components/MediaRenameDialog.vue +32 -0
  202. package/layers/media/app/components/MediaTable.vue +75 -0
  203. package/layers/media/app/components/MediaThumb.vue +25 -0
  204. package/layers/media/app/components/MediaToolbar.vue +90 -0
  205. package/layers/media/app/components/MediaUploadDialog.vue +87 -0
  206. package/layers/media/app/components/MediaViewer.vue +88 -0
  207. package/layers/media/app/components/field/Media.vue +261 -0
  208. package/layers/media/app/composables/useBlurUp.ts +55 -0
  209. package/layers/media/app/composables/useMediaClipboard.ts +14 -0
  210. package/layers/media/app/composables/useMediaContextMenu.ts +56 -0
  211. package/layers/media/app/composables/useMediaDnd.ts +56 -0
  212. package/layers/media/app/composables/useMediaLibrary.ts +151 -0
  213. package/layers/media/app/composables/useMediaOps.ts +50 -0
  214. package/layers/media/app/composables/useMediaRelocate.ts +39 -0
  215. package/layers/media/app/composables/useMediaResolver.ts +50 -0
  216. package/layers/media/app/composables/useMediaUpload.ts +93 -0
  217. package/layers/media/app/composables/useMediaVariant.ts +34 -0
  218. package/layers/media/app/plugins/field-media.client.ts +6 -0
  219. package/layers/media/app/utils/dnd.ts +69 -0
  220. package/layers/media/app/utils/library.ts +79 -0
  221. package/layers/media/app/utils/ops.ts +62 -0
  222. package/layers/media/app/utils/picture-model.ts +125 -0
  223. package/layers/media/app/utils/thumbhash.ts +19 -0
  224. package/layers/media/app/utils/variant-name.ts +20 -0
  225. package/layers/media/nuxt.config.ts +3 -0
  226. package/layers/media/server/api/media/[id]/usages.get.ts +8 -0
  227. package/layers/media/server/api/media/[id].delete.ts +18 -0
  228. package/layers/media/server/api/media/[id].get.ts +10 -0
  229. package/layers/media/server/api/media/[id].patch.ts +41 -0
  230. package/layers/media/server/api/media/backfill.post.ts +18 -0
  231. package/layers/media/server/api/media/copy.post.ts +7 -0
  232. package/layers/media/server/api/media/delete.post.ts +13 -0
  233. package/layers/media/server/api/media/folders.post.ts +18 -0
  234. package/layers/media/server/api/media/index.get.ts +15 -0
  235. package/layers/media/server/api/media/index.post.ts +157 -0
  236. package/layers/media/server/api/media/library.get.ts +17 -0
  237. package/layers/media/server/api/media/move.post.ts +7 -0
  238. package/layers/media/server/api/media/rename.post.ts +7 -0
  239. package/layers/media/server/api/media/resolve.get.ts +13 -0
  240. package/layers/media/server/collections/media-settings.ts +25 -0
  241. package/layers/media/server/collections/media.ts +29 -0
  242. package/layers/media/server/database/folders.ts +10 -0
  243. package/layers/media/server/middleware/ondemand-variants.ts +37 -0
  244. package/layers/media/server/plugins/02.register-media.ts +19 -0
  245. package/layers/media/server/plugins/03.media-cleanup.ts +20 -0
  246. package/layers/media/server/plugins/04.variant-capture.ts +23 -0
  247. package/layers/media/server/schema-tables/folders.ts +4 -0
  248. package/layers/media/server/tasks/media/backfill.ts +18 -0
  249. package/layers/media/server/utils/backfill.ts +132 -0
  250. package/layers/media/server/utils/derive.ts +59 -0
  251. package/layers/media/server/utils/folder-paths.ts +42 -0
  252. package/layers/media/server/utils/folders.ts +12 -0
  253. package/layers/media/server/utils/library.ts +90 -0
  254. package/layers/media/server/utils/media-cleanup.ts +27 -0
  255. package/layers/media/server/utils/media-enabled.ts +23 -0
  256. package/layers/media/server/utils/media-ops.ts +118 -0
  257. package/layers/media/server/utils/media-write.ts +13 -0
  258. package/layers/media/server/utils/naming.ts +52 -0
  259. package/layers/media/server/utils/ondemand.ts +69 -0
  260. package/layers/media/server/utils/persist-upload.ts +62 -0
  261. package/layers/media/server/utils/populate.ts +47 -0
  262. package/layers/media/server/utils/record.ts +63 -0
  263. package/layers/media/server/utils/relocate-ops.ts +341 -0
  264. package/layers/media/server/utils/relocate-plan.ts +63 -0
  265. package/layers/media/server/utils/resolve.ts +86 -0
  266. package/layers/media/server/utils/sanitize-svg.ts +12 -0
  267. package/layers/media/server/utils/sniff.ts +68 -0
  268. package/layers/media/server/utils/storage-relocate.ts +139 -0
  269. package/layers/media/server/utils/translations.ts +19 -0
  270. package/layers/media/server/utils/usages.ts +66 -0
  271. package/layers/media/server/utils/variant-capture.ts +70 -0
  272. package/layers/media/server/utils/variants.ts +108 -0
  273. package/layers/public/app/app.vue +5 -0
  274. package/layers/public/app/components/BlockRenderer.vue +56 -0
  275. package/layers/public/app/components/KestrelLink.vue +26 -0
  276. package/layers/public/app/components/KestrelPreviewBridge.vue +133 -0
  277. package/layers/public/app/composables/public-page.ts +24 -0
  278. package/layers/public/app/error.vue +12 -0
  279. package/layers/public/app/layouts/default.vue +5 -0
  280. package/layers/public/app/pages/[...slug].vue +157 -0
  281. package/layers/public/app/pages/__kestrel/preview.vue +34 -0
  282. package/layers/public/app/utils/block-edit-context.ts +16 -0
  283. package/layers/public/app/utils/link-href.ts +46 -0
  284. package/layers/public/app/utils/page-head.ts +84 -0
  285. package/layers/public/app/utils/preview-protocol.ts +81 -0
  286. package/layers/public/app/utils/public-route.ts +46 -0
  287. package/layers/public/modules/deploy-output/deploy-output.ts +370 -0
  288. package/layers/public/modules/deploy-output/index.ts +82 -0
  289. package/layers/public/modules/prerender-routes/discover.ts +37 -0
  290. package/layers/public/modules/prerender-routes/index.ts +85 -0
  291. package/layers/public/modules/prerender-routes/routes.ts +16 -0
  292. package/layers/public/modules/prune-media/index.ts +48 -0
  293. package/layers/public/modules/prune-media/prune-media.ts +81 -0
  294. package/layers/public/nuxt.config.ts +11 -0
  295. package/layers/public/server/api/links/resolve.get.ts +19 -0
  296. package/layers/public/server/api/publish-status.get.ts +44 -0
  297. package/layers/public/server/api/route.get.ts +18 -0
  298. package/layers/public/server/database/publish-deps.ts +12 -0
  299. package/layers/public/server/database/publish-status.ts +19 -0
  300. package/layers/public/server/plugins/02.register-links.ts +17 -0
  301. package/layers/public/server/plugins/zz.publish.ts +63 -0
  302. package/layers/public/server/routes/llms.txt.get.ts +72 -0
  303. package/layers/public/server/routes/robots.txt.get.ts +8 -0
  304. package/layers/public/server/routes/sitemap.xml.get.ts +66 -0
  305. package/layers/public/server/schema-tables/publish-deps.ts +4 -0
  306. package/layers/public/server/schema-tables/publish-status.ts +4 -0
  307. package/layers/public/server/tasks/publish/run.ts +26 -0
  308. package/layers/public/server/utils/link-resolve.ts +35 -0
  309. package/layers/public/server/utils/llms.ts +39 -0
  310. package/layers/public/server/utils/page-resolve.ts +86 -0
  311. package/layers/public/server/utils/populate-links.ts +66 -0
  312. package/layers/public/server/utils/publish/coalesce.ts +22 -0
  313. package/layers/public/server/utils/publish/deps-persistence.ts +43 -0
  314. package/layers/public/server/utils/publish/deps.ts +87 -0
  315. package/layers/public/server/utils/publish/invalidation.ts +141 -0
  316. package/layers/public/server/utils/publish/publish-status.ts +54 -0
  317. package/layers/public/server/utils/publish/publisher.ts +291 -0
  318. package/layers/public/server/utils/publish/queue.ts +67 -0
  319. package/layers/public/server/utils/publish/route-for-record.ts +16 -0
  320. package/layers/public/server/utils/publish/route-keys.ts +13 -0
  321. package/layers/public/server/utils/site-url.ts +32 -0
  322. package/layers/public/server/utils/sitemap.ts +97 -0
  323. package/layers/ui/app/assets/scss/_base.scss +16 -0
  324. package/layers/ui/app/assets/scss/_datepicker.scss +82 -0
  325. package/layers/ui/app/assets/scss/_mixins.scss +50 -0
  326. package/layers/ui/app/assets/scss/_reset.scss +65 -0
  327. package/layers/ui/app/assets/scss/_tokens.scss +183 -0
  328. package/layers/ui/app/assets/scss/main.scss +4 -0
  329. package/layers/ui/app/components/field/Boolean.vue +30 -0
  330. package/layers/ui/app/components/field/Choice.vue +68 -0
  331. package/layers/ui/app/components/field/Datetime.vue +96 -0
  332. package/layers/ui/app/components/field/DeadRefNote.vue +28 -0
  333. package/layers/ui/app/components/field/Json.vue +63 -0
  334. package/layers/ui/app/components/field/Layout.vue +140 -0
  335. package/layers/ui/app/components/field/Link.vue +112 -0
  336. package/layers/ui/app/components/field/LinkInternalPicker.vue +95 -0
  337. package/layers/ui/app/components/field/Number.vue +30 -0
  338. package/layers/ui/app/components/field/Relation.vue +49 -0
  339. package/layers/ui/app/components/field/Renderer.vue +30 -0
  340. package/layers/ui/app/components/field/Repeater.vue +460 -0
  341. package/layers/ui/app/components/field/Richtext.vue +26 -0
  342. package/layers/ui/app/components/field/Slug.vue +66 -0
  343. package/layers/ui/app/components/field/Text.vue +35 -0
  344. package/layers/ui/app/components/field/Unsupported.vue +34 -0
  345. package/layers/ui/app/components/ui/Alert.vue +50 -0
  346. package/layers/ui/app/components/ui/Brand.vue +22 -0
  347. package/layers/ui/app/components/ui/Button.vue +116 -0
  348. package/layers/ui/app/components/ui/ButtonGroup.vue +96 -0
  349. package/layers/ui/app/components/ui/Checkbox.vue +52 -0
  350. package/layers/ui/app/components/ui/CheckboxGroup.vue +50 -0
  351. package/layers/ui/app/components/ui/Combobox.vue +343 -0
  352. package/layers/ui/app/components/ui/DatePicker.vue +92 -0
  353. package/layers/ui/app/components/ui/DateRangePicker.vue +100 -0
  354. package/layers/ui/app/components/ui/Dialog.vue +107 -0
  355. package/layers/ui/app/components/ui/EmptyState.vue +46 -0
  356. package/layers/ui/app/components/ui/Field.vue +57 -0
  357. package/layers/ui/app/components/ui/Fieldset.vue +64 -0
  358. package/layers/ui/app/components/ui/Icon.vue +44 -0
  359. package/layers/ui/app/components/ui/Menu.vue +54 -0
  360. package/layers/ui/app/components/ui/NumberInput.vue +173 -0
  361. package/layers/ui/app/components/ui/Richtext.vue +119 -0
  362. package/layers/ui/app/components/ui/RichtextToolbar.vue +253 -0
  363. package/layers/ui/app/components/ui/Select.vue +51 -0
  364. package/layers/ui/app/components/ui/TextInput.vue +110 -0
  365. package/layers/ui/app/components/ui/Textarea.vue +38 -0
  366. package/layers/ui/app/components/ui/TimeInput.vue +17 -0
  367. package/layers/ui/app/components/ui/Toasts.vue +103 -0
  368. package/layers/ui/app/components/ui/Tooltip.vue +45 -0
  369. package/layers/ui/app/composables/useAdminLang.ts +8 -0
  370. package/layers/ui/app/composables/useDragReorder.ts +41 -0
  371. package/layers/ui/app/composables/useEchoGuard.ts +28 -0
  372. package/layers/ui/app/composables/useLinkField.ts +70 -0
  373. package/layers/ui/app/composables/useRecordOptions.ts +84 -0
  374. package/layers/ui/app/composables/useRepeater.ts +99 -0
  375. package/layers/ui/app/composables/useT.ts +32 -0
  376. package/layers/ui/app/composables/useToast.ts +46 -0
  377. package/layers/ui/app/i18n/de.ts +406 -0
  378. package/layers/ui/app/i18n/en.ts +440 -0
  379. package/layers/ui/app/utils/boolean-field.ts +10 -0
  380. package/layers/ui/app/utils/field-component.ts +22 -0
  381. package/layers/ui/app/utils/field-empty.ts +21 -0
  382. package/layers/ui/app/utils/field-registry.ts +36 -0
  383. package/layers/ui/app/utils/humanize.ts +14 -0
  384. package/layers/ui/app/utils/icons.ts +147 -0
  385. package/layers/ui/app/utils/iso-date.ts +18 -0
  386. package/layers/ui/app/utils/localized.ts +13 -0
  387. package/layers/ui/app/utils/record-context.ts +15 -0
  388. package/layers/ui/app/utils/reorder.ts +8 -0
  389. package/layers/ui/app/utils/richtext-preserve-class.ts +31 -0
  390. package/layers/ui/app/utils/useFieldA11y.ts +40 -0
  391. package/layers/ui/nuxt.config.ts +5 -0
  392. package/nuxt.config.ts +105 -0
  393. package/package.json +122 -0
  394. package/scripts/hash-password.mjs +26 -0
  395. package/scripts/typecheck.mjs +51 -0
@@ -0,0 +1,81 @@
1
+ import { existsSync, readFileSync, readdirSync, rmSync } from 'node:fs'
2
+ import { join, relative, sep } from 'node:path'
3
+
4
+ function escapeRe(s: string): string {
5
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
6
+ }
7
+
8
+ // Prefix-relative keys referenced in `text`. The charset stops at ?/#/quote/space, so query strings,
9
+ // fragments and srcset descriptors (320w, 2x) fall outside the key; the leading `/` keeps `/uploads` from
10
+ // matching `/myuploads`.
11
+ export function referencedKeys(text: string, prefix: string): Set<string> {
12
+ const out = new Set<string>()
13
+ const re = new RegExp(`${escapeRe(prefix)}/([A-Za-z0-9._/-]+)`, 'g')
14
+ for (const m of text.matchAll(re)) out.add(m[1])
15
+ return out
16
+ }
17
+
18
+ // The prunable candidates: unreferenced files that the media library OWNS. When `ownedKeys` is given, a
19
+ // file not in it (gallery ciphertext / index, or any consumer blob written via useStorageDriver) is NEVER
20
+ // a candidate — those are baked deliberately and their URLs are built at runtime, so they never appear as
21
+ // literal keys in the output. Without `ownedKeys` the legacy "prune any unreferenced key" behaviour holds.
22
+ export function planMediaPrune(files: string[], referenced: Set<string>, ownedKeys?: Set<string>): string[] {
23
+ return files.filter((f) => !referenced.has(f) && (!ownedKeys || ownedKeys.has(f)))
24
+ }
25
+
26
+ // The storage keys the media library owns: every original + every derivative object it tracks. Used to
27
+ // scope the bake prune so it can only ever delete media-library artifacts, never extension/consumer blobs.
28
+ export function mediaOwnedKeys(rows: { storageKey?: string | null; derivatives?: Record<string, { key?: string }> | null }[]): Set<string> {
29
+ const out = new Set<string>()
30
+ for (const r of rows) {
31
+ if (r.storageKey) out.add(r.storageKey)
32
+ for (const e of Object.values(r.derivatives ?? {})) if (e?.key) out.add(e.key)
33
+ }
34
+ return out
35
+ }
36
+
37
+ function walkFiles(dir: string): string[] {
38
+ return readdirSync(dir, { recursive: true, withFileTypes: true })
39
+ .filter((e) => e.isFile())
40
+ .map((e) => join(e.parentPath, e.name))
41
+ }
42
+
43
+ export interface PruneResult {
44
+ kept: number
45
+ pruned: number
46
+ }
47
+
48
+ export interface PruneOptions {
49
+ /** Report what would be deleted without touching the filesystem. */
50
+ dryRun?: boolean
51
+ /** Restrict deletion to these media-library-owned keys; anything else baked under the root is kept. */
52
+ ownedKeys?: Set<string>
53
+ log?: (msg: string) => void
54
+ }
55
+
56
+ // Delete every baked upload under `publicDir/<baseUrl>` that no generated .html/.css/.json references.
57
+ // Over-scanning those files is keep-on-doubt (a key seen anywhere is kept). No-op when the dir is absent
58
+ // (S3 media / nothing baked). Touches only the bake — the library upload dir is elsewhere.
59
+ export function pruneUnreferencedMedia(publicDir: string, baseUrl: string, opts: PruneOptions = {}): PruneResult {
60
+ const prefix = baseUrl.replace(/\/+$/, '')
61
+ if (!prefix) return { kept: 0, pruned: 0 } // an empty baseUrl would target the whole output — refuse
62
+ const uploadsDir = join(publicDir, prefix)
63
+ if (!existsSync(uploadsDir)) return { kept: 0, pruned: 0 }
64
+
65
+ const referenced = new Set<string>()
66
+ for (const abs of walkFiles(publicDir)) {
67
+ // Also scan JS chunks: a media URL can be emitted only from client JS (a background image, a config
68
+ // object), never as literal HTML/CSS — pruning it would 404 the live reference.
69
+ if (!/\.(?:html|css|json|m?js)$/i.test(abs)) continue
70
+ for (const k of referencedKeys(readFileSync(abs, 'utf8'), prefix)) referenced.add(k)
71
+ }
72
+
73
+ const files = walkFiles(uploadsDir).map((abs) => ({ abs, key: relative(uploadsDir, abs).split(sep).join('/') }))
74
+ const prune = new Set(planMediaPrune(files.map((f) => f.key), referenced, opts.ownedKeys))
75
+ const toDelete = files.filter((f) => prune.has(f.key))
76
+ if (!opts.dryRun) for (const f of toDelete) rmSync(f.abs, { force: true })
77
+
78
+ const kept = files.length - toDelete.length
79
+ opts.log?.(`media prune${opts.dryRun ? ' (dry-run)' : ''}: kept ${kept}, ${opts.dryRun ? 'would prune' : 'pruned'} ${toDelete.length} unreferenced file(s)`)
80
+ return { kept, pruned: toDelete.length }
81
+ }
@@ -0,0 +1,11 @@
1
+ import { fileURLToPath } from 'node:url'
2
+
3
+ export default defineNuxtConfig({
4
+ modules: [
5
+ fileURLToPath(new URL('./modules/prerender-routes/index.ts', import.meta.url)),
6
+ fileURLToPath(new URL('./modules/prune-media/index.ts', import.meta.url)),
7
+ // AFTER prune-media so the bake is pruned before it ships. Self-gates to output.driver:'s3' + a real
8
+ // `nuxt generate`; a no-op otherwise. Without it, output.driver:'s3' + output.auto:false shipped nothing.
9
+ fileURLToPath(new URL('./modules/deploy-output/index.ts', import.meta.url)),
10
+ ],
11
+ })
@@ -0,0 +1,19 @@
1
+ import { resolveInternalHref } from '../../utils/link-resolve'
2
+
3
+ // Batch-resolve internal link refs (`?refs=collection:id,collection:id`) to public hrefs for the admin
4
+ // block preview. Mirrors the read-path populator, status gate included; only resolved refs are returned,
5
+ // so the client renders '#' for a target that is missing or still a draft — same as the generated site.
6
+ export default defineEventHandler((event) => {
7
+ const refs = String(getQuery(event).refs ?? '').split(',').map((s) => s.trim()).filter(Boolean)
8
+ const data: { collection: string; id: number; href: string }[] = []
9
+ for (const ref of refs) {
10
+ const sep = ref.lastIndexOf(':')
11
+ if (sep <= 0) continue
12
+ const collection = ref.slice(0, sep)
13
+ const id = Number(ref.slice(sep + 1))
14
+ if (!Number.isInteger(id) || id <= 0) continue
15
+ const href = resolveInternalHref(collection, id)
16
+ if (href != null) data.push({ collection, id, href })
17
+ }
18
+ return { data }
19
+ })
@@ -0,0 +1,44 @@
1
+ import { eq, getTableColumns } from 'drizzle-orm'
2
+ import type { AnySQLiteTable } from 'drizzle-orm/sqlite-core'
3
+ import { publishStatus } from '../database/publish-status'
4
+ import { routeForRecord } from '../utils/publish/route-for-record'
5
+
6
+ /**
7
+ * Admin-only read of the LIVE publish state of a record's static page (`?collection=&id=`). Admin-only by
8
+ * construction: `/api/*` is default-deny for anonymous and `publish-status` is not a public pageLike
9
+ * resource, so the access guard rejects an unauthenticated request before this runs. Resolves the record's
10
+ * route, then looks up its `publish_status` row. Returns `{ route, status }` (+ error/updatedAt on a row),
11
+ * or `{ route: null, status: null }` when the record has no route (non-pageLike / blank slug / missing),
12
+ * or `{ route, status: null }` when there is no status row yet (never published — dev, a draft, or a
13
+ * republish still in flight). The editor's live-ampel reads this and refetches after each save.
14
+ */
15
+ export default defineEventHandler((event) => {
16
+ // Environment-level facts the right lamp needs beyond the per-route row: where the NEXT publish would go
17
+ // (`driver`) and whether the runtime publisher actually produces files HERE (prod + `output.auto`). In dev
18
+ // (or with auto off) nothing is ever generated, so the lamp shows a calm "Not built" instead of a stuck
19
+ // "Generating". Read straight from runtimeConfig — no need to pull in the whole publisher module.
20
+ const output = (useRuntimeConfig().kestrel as { output?: { driver?: 'local' | 's3'; auto?: boolean } }).output
21
+ const env = { driver: (output?.driver ?? 'local') as 'local' | 's3', generates: !import.meta.dev && !!output?.auto }
22
+
23
+ const q = getQuery(event)
24
+ const name = typeof q.collection === 'string' ? q.collection : ''
25
+ const id = Number(typeof q.id === 'string' ? q.id : NaN)
26
+ const c = getCollection(name)
27
+ if (!c || !c.def.pageLike || !Number.isInteger(id) || id <= 0) return { route: null, status: null, ...env }
28
+
29
+ const db = useDb()
30
+ const table = c.table as AnySQLiteTable
31
+ const cols = getTableColumns(table) as Record<string, never>
32
+ const row = db.select().from(table).where(eq(cols.id, id)).get() as { path?: unknown; locale?: unknown } | undefined
33
+ const route = routeForRecord(row, true, primaryLocale(), prefixPrimaryLocale())
34
+ if (!route) return { route: null, status: null, ...env }
35
+
36
+ try {
37
+ const st = db.select().from(publishStatus).where(eq(publishStatus.route, route)).get()
38
+ if (!st) return { route, status: null, ...env }
39
+ return { route, status: st.status, error: st.error, updatedAt: st.updatedAt, target: st.target, ...env }
40
+ } catch {
41
+ // publish_status not migrated yet → treat as "no status" rather than a 500.
42
+ return { route, status: null, ...env }
43
+ }
44
+ })
@@ -0,0 +1,18 @@
1
+ import { resolvePage } from '../utils/page-resolve'
2
+
3
+ // Resolves a public path to the first matching page-like record across ALL pageLike collections (the
4
+ // generic render entry point). Published-only EXCEPT for an authenticated admin's live preview:
5
+ // - a static render (build prerender OR the runtime publisher) is always published-only, so a draft
6
+ // never reaches the static site (the renderer principal is itself published-only at the policy
7
+ // level; this explicit `isStaticRender` clause is belt-and-suspenders defense-in-depth);
8
+ // - an anonymous live request carries readScope 'published';
9
+ // - an authenticated admin carries readScope 'all', so a draft renders at its real URL (the preview).
10
+ export default defineEventHandler((event) => {
11
+ const q = getQuery(event)
12
+ const path = typeof q.path === 'string' && q.path ? q.path : '/'
13
+ const locale = typeof q.locale === 'string' ? q.locale : undefined
14
+ const isStaticRender = import.meta.prerender === true || isRendererContext()
15
+ const publishedOnly = isStaticRender || event.context.readScope !== 'all'
16
+ const resolved = resolvePage(useDb(), allCollections(), path, locale, publishedOnly)
17
+ return { collection: resolved?.collection ?? null, page: resolved?.page ?? null, alternates: resolved?.alternates ?? [] }
18
+ })
@@ -0,0 +1,12 @@
1
+ import { sqliteTable, text } from 'drizzle-orm/sqlite-core'
2
+
3
+ // Durable `route -> data tags` index backing the runtime publisher's DepsStore. Persists what each
4
+ // published route read while rendering (its `<coll>` / `<coll>:<id>` tags) so a write maps back to exactly
5
+ // the routes that embed that data, AND so a boot full-publish can prune routes that left the published set
6
+ // while the server was down. `route` is the natural primary key; `tags` is the JSON-encoded tag set.
7
+ // Hand-authored system table (not a collection) — registered in the desired schema via bootstrap.ts +
8
+ // re-exported from collections/schema.ts, mirroring `folders`.
9
+ export const publishDeps = sqliteTable('publish_deps', {
10
+ route: text('route').primaryKey(),
11
+ tags: text('tags').notNull(),
12
+ })
@@ -0,0 +1,19 @@
1
+ import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'
2
+
3
+ // Durable per-route record of the LAST static-publish outcome: did this route's file get written to the
4
+ // output target (local dir or S3) successfully, or did the last attempt error? `route` is the natural
5
+ // primary key (one latest-state row per route — not a history). The runtime publisher upserts a `success`
6
+ // row after each render+put and an `error` row (with the failure message, incl. S3) when one throws; a
7
+ // prune (unpublish / delete / slug change) clears the row so the route reads as "not live". The admin
8
+ // editor's live-ampel reads it through `/api/publish-status` to show whether THIS record's page is live.
9
+ // `updatedAt` via `$defaultFn` (no SQL default → it won't perpetually diff as a rebuild; UNIX seconds via
10
+ // `mode:'timestamp'`, matching every other table). Hand-authored system table (not a collection) —
11
+ // registered in the desired schema via bootstrap.ts + re-exported from collections/schema.ts, mirroring
12
+ // `publish_deps` / `record_refs` / `folders`.
13
+ export const publishStatus = sqliteTable('publish_status', {
14
+ route: text('route').primaryKey(),
15
+ status: text('status').notNull(), // 'success' | 'error'
16
+ error: text('error'), // nullable — the failure message (render / write / S3) on an 'error' row
17
+ target: text('target').notNull(), // 'local' | 's3' — which output the attempt wrote to
18
+ updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
19
+ })
@@ -0,0 +1,17 @@
1
+ import { registerFieldPopulator } from '../../../core/server/utils/populate'
2
+ import { buildLinkFieldPopulators } from '../utils/populate-links'
3
+ import { resolveInternalHref } from '../utils/link-resolve'
4
+
5
+ // Resolve internal links to the target record's localized public path at read time (page-like targets;
6
+ // external/email/tel pass through). NOTE: the resolver does NOT status-gate the target — a link to a DRAFT
7
+ // emits the draft's real (not-yet-generated) path, disclosing its slug and shipping a 404 until publish. This
8
+ // is deliberate: it keeps links stable without re-rendering every referrer when a target's status flips, and
9
+ // the editor warns about draft/dead links instead. (See link-resolve.ts for the rationale.)
10
+ //
11
+ // Registered as `link` + `richtext` per-type populators; the shared field-tree walker dispatches them over
12
+ // top-level fields, block props, slots, and repeater entries.
13
+ export default defineNitroPlugin(() => {
14
+ const { link, richtext } = buildLinkFieldPopulators(resolveInternalHref)
15
+ registerFieldPopulator('link', link)
16
+ registerFieldPopulator('richtext', richtext)
17
+ })
@@ -0,0 +1,63 @@
1
+ import { createPublishQueue } from '../utils/publish/queue'
2
+ import { DepsStore } from '../utils/publish/deps'
3
+ import { createSqlitePersistence } from '../utils/publish/deps-persistence'
4
+ import { outputDriver, publishInvalidation } from '../utils/publish/publisher'
5
+ import { classifyWrite, planInvalidation } from '../utils/publish/invalidation'
6
+ import { registerWriteListener } from '../../../core/server/utils/write-events'
7
+
8
+ /**
9
+ * The in-server publish runtime (the production operation model). Wires three things, gated on
10
+ * `output.auto` and skipped in dev (a dev publish would write Vite-dev HTML without hashed `_nuxt`):
11
+ *
12
+ * 1. AUTO-TRIGGER — every content write classifies into an invalidation and enqueues an incremental
13
+ * republish (a debounced/coalesced/single-flight queue; precise routes via the captured deps index).
14
+ * 2. BOOT PUBLISH — a full publish on startup resyncs this build's `_nuxt` + records every route's deps.
15
+ * DETACHED (`runNitroPlugins` is synchronous + unawaited; `localFetch` is already wired before plugins
16
+ * run + before the server listens), so it never blocks boot.
17
+ * 3. RECONCILER — an optional periodic full publish (`output.reconcileMinutes`) self-heals any missed
18
+ * invalidation and picks up time-based `publishDate` publishing that no write event would trigger.
19
+ */
20
+ export default defineNitroPlugin(() => {
21
+ if (import.meta.dev) return
22
+ const output = (useRuntimeConfig().kestrel as { output?: { auto?: boolean; reconcileMinutes?: number; verbose?: boolean } }).output
23
+ if (!output?.auto) return
24
+
25
+ // `output.verbose`: on top of the one-line summary, itemise each incremental republish with a
26
+ // timestamped per-route line (rendered / pruned) for traceability. Off by default. (A queued FULL run
27
+ // — boot / reconciler — is a bulk resync and stays a summary; publishInvalidation returns no per-route
28
+ // list for it.)
29
+ const verbose = output.verbose ?? false
30
+ const stamp = () => new Date().toISOString()
31
+
32
+ const driver = outputDriver()
33
+ // Durable deps: rehydrate `route → tags` from SQLite so the boot full-publish below can prune routes
34
+ // that were unpublished/deleted while the server was down (staleRoutes over a non-empty tracked set).
35
+ const deps = new DepsStore(createSqlitePersistence(useDb()))
36
+ const queue = createPublishQueue({
37
+ run: async (inv) => {
38
+ const { rendered, pruned, counts } = await publishInvalidation(inv, driver, deps)
39
+ if (counts.rendered === 0 && counts.pruned === 0) return
40
+ // Incremental runs name the routes; a queued full run (the reconciler) prints counts only.
41
+ const list = rendered.length ? `: ${rendered.join(', ')}` : ''
42
+ console.info(`[kestrel] published ${counts.rendered} route(s)${list} (pruned ${counts.pruned})`)
43
+ if (verbose) {
44
+ for (const route of rendered) console.info(`[kestrel] ${stamp()} rendered ${route}`)
45
+ for (const route of pruned) console.info(`[kestrel] ${stamp()} pruned ${route}`)
46
+ }
47
+ },
48
+ onError: (error) => console.error('[kestrel] publish run failed:', error),
49
+ })
50
+
51
+ registerWriteListener(({ def, before, after }) => {
52
+ queue.enqueue(planInvalidation(classifyWrite(def, before, after, primaryLocale(), prefixPrimaryLocale())))
53
+ })
54
+
55
+ // Boot publish goes THROUGH the queue (not a direct publishFull) so it shares the single-flight guard:
56
+ // otherwise a boot full-publish could overlap a write-triggered incremental or the reconciler, and both
57
+ // runs share the module-level variant accumulator (corrupting the registry) and can interleave
58
+ // render/prune (leaving an unpublished page live). The queue serializes them.
59
+ queue.enqueue({ type: 'full' })
60
+
61
+ const mins = output.reconcileMinutes ?? 0
62
+ if (mins > 0) setInterval(() => queue.enqueue({ type: 'full' }), mins * 60_000).unref?.()
63
+ })
@@ -0,0 +1,72 @@
1
+ import { getTableColumns } from 'drizzle-orm'
2
+ import { localePath } from '../../../core/app/utils/locale-path'
3
+ import type { LlmsSection, LlmsEntry } from '../utils/llms'
4
+
5
+ // Generates an `llms.txt` (llmstxt.org): the site name + description, then one section per pageLike
6
+ // collection listing its published, indexable records — the SAME registry-driven public set the sitemap
7
+ // advertises (single source: the auth policy). Safe to serve publicly and to prerender.
8
+ export default defineEventHandler((event) => {
9
+ const db = useDb()
10
+ const base = siteBaseUrl()
11
+ const primary = primaryLocale()
12
+ const prefixPrimary = prefixPrimaryLocale()
13
+
14
+ const cap = (s: string) => (s ? s[0].toUpperCase() + s.slice(1) : s)
15
+ const headingFor = (def: { name: string; label?: { plural?: unknown } }): string => {
16
+ const pl = def.label?.plural
17
+ if (typeof pl === 'string') return pl
18
+ if (pl && typeof pl === 'object') {
19
+ const m = pl as Record<string, string>
20
+ return m[primary] ?? Object.values(m)[0] ?? cap(def.name)
21
+ }
22
+ return cap(def.name)
23
+ }
24
+
25
+ const pub = publicReadableResources()
26
+ const sections: LlmsSection[] = []
27
+ // Without an absolute origin, every resource URL would be a relative path — omit the page sections (keep
28
+ // the site name + description) rather than emit a link list that resolves against the wrong host.
29
+ if (!base) console.warn('[kestrel] llms.txt: siteUrl is unset — omitting page links (they would be relative)')
30
+ for (const c of base ? allCollections() : []) {
31
+ if (!c.def.pageLike) continue
32
+ if (!isPubliclyReadable(c.def.name, pub)) continue
33
+ // Project ONLY the columns this listing reads — never SELECT *, which would pull every row's
34
+ // block-content JSON into memory just to emit a link; the runtime publisher re-renders this route on
35
+ // every incremental publish. Optional columns exist only when the def declares them (buildTable adds no
36
+ // locale/status/seo column otherwise), and a `cols.<missing>` would be undefined and make the select
37
+ // throw — gate on the same flags, as the sitemap does. `title` is a plain field, so probe the table.
38
+ const cols = getTableColumns(c.table) as Record<string, never>
39
+ const proj: Record<string, unknown> = { path: cols.path }
40
+ if (c.def.translatable) proj.locale = cols.locale
41
+ if (c.def.status) proj.status = cols.status
42
+ if (c.def.seo) proj.seo = cols.seo
43
+ if (Object.hasOwn(cols, 'title')) proj.title = cols.title
44
+ let rows: Record<string, unknown>[]
45
+ try {
46
+ rows = db.select(proj as never).from(c.table).all() as Record<string, unknown>[]
47
+ } catch {
48
+ continue // table not migrated yet (e.g. a bare prerender DB)
49
+ }
50
+ const entries: LlmsEntry[] = []
51
+ for (const row of rows) {
52
+ if (c.def.status && row.status !== 'published') continue
53
+ const path = row.path as string | null
54
+ if (!path) continue
55
+ const seo = (row.seo ?? {}) as { title?: string; description?: string; noindex?: boolean }
56
+ if (seo.noindex) continue
57
+ const locale = (row.locale as string | undefined) ?? primary
58
+ entries.push({
59
+ title: seo.title || (row.title as string | undefined) || path,
60
+ url: base + localePath(path, locale, primary, prefixPrimary),
61
+ description: seo.description || undefined,
62
+ })
63
+ }
64
+ if (entries.length) {
65
+ entries.sort((a, b) => a.url.localeCompare(b.url))
66
+ sections.push({ heading: headingFor(c.def), entries })
67
+ }
68
+ }
69
+
70
+ setHeader(event, 'content-type', 'text/plain; charset=utf-8')
71
+ return buildLlmsTxt({ siteName: siteName(), siteDescription: siteDescription() || undefined, sections })
72
+ })
@@ -0,0 +1,8 @@
1
+ export default defineEventHandler((event) => {
2
+ const base = siteBaseUrl()
3
+ setHeader(event, 'content-type', 'text/plain; charset=utf-8')
4
+ return buildRobots({
5
+ sitemapUrl: base ? `${base}/sitemap.xml` : undefined,
6
+ llmsUrl: base ? `${base}/llms.txt` : undefined,
7
+ })
8
+ })
@@ -0,0 +1,66 @@
1
+ import { getTableColumns } from 'drizzle-orm'
2
+ import { localePath } from '../../../core/app/utils/locale-path'
3
+
4
+ // Lists every published, indexable page-like record (across all pageLike collections) as a
5
+ // sitemap. Filters status/noindex itself, so it is safe to serve publicly and to prerender.
6
+ export default defineEventHandler((event) => {
7
+ const db = useDb()
8
+ const base = siteBaseUrl()
9
+ // Without an absolute origin (kestrel.siteUrl / KESTREL_SITE_URL) every <loc> would be a RELATIVE path,
10
+ // which the sitemaps.org schema rejects outright — a worse result than an empty sitemap. Emit a valid
11
+ // empty one + warn, mirroring robots.txt omitting its `Sitemap:` directive when the base is unset.
12
+ if (!base) {
13
+ console.warn('[kestrel] sitemap.xml: siteUrl is unset — emitting an empty sitemap (relative <loc> URLs would be invalid)')
14
+ setHeader(event, 'content-type', 'application/xml; charset=utf-8')
15
+ return buildSitemap([])
16
+ }
17
+ const primary = primaryLocale()
18
+ const prefixPrimary = prefixPrimaryLocale()
19
+ const candidates: SitemapCandidate[] = []
20
+
21
+ // A sitemap must only advertise publicly-reachable URLs. The public render path (anonymous access
22
+ // policy, prerender seed, catch-all) now renders every pageLike collection, so the sitemap tracks the
23
+ // SAME registry-driven public set (single source: the auth policy) rather than diverging from it.
24
+ const pub = publicReadableResources()
25
+ for (const c of allCollections()) {
26
+ if (!c.def.pageLike) continue
27
+ if (!isPubliclyReadable(c.def.name, pub)) continue
28
+ // Project ONLY the columns the sitemap reads — never SELECT *, which would pull every row's
29
+ // block-content JSON (and every other field) into memory just to emit a URL; the runtime publisher
30
+ // re-renders this route on every incremental publish. Optional columns exist only when the def
31
+ // declares them (buildTable adds no locale/status/seo column otherwise), and a `cols.<missing>` would
32
+ // be undefined and make the select throw — gate on the same flags, as allPublishedRoutes does.
33
+ const cols = getTableColumns(c.table) as Record<string, never>
34
+ const proj: Record<string, unknown> = { path: cols.path, updatedAt: cols.updatedAt }
35
+ if (c.def.translatable) proj.locale = cols.locale
36
+ if (c.def.translatable && c.def.mode !== 'single') proj.translationGroup = cols.translationGroup
37
+ if (c.def.status) proj.status = cols.status
38
+ if (c.def.seo) proj.seo = cols.seo
39
+ let rows: Record<string, unknown>[]
40
+ try {
41
+ rows = db.select(proj as never).from(c.table).all() as Record<string, unknown>[]
42
+ } catch {
43
+ continue // table not migrated yet (e.g. a bare prerender DB)
44
+ }
45
+ for (const row of rows) {
46
+ if (c.def.status && row.status !== 'published') continue
47
+ const path = row.path as string | null
48
+ if (!path) continue
49
+ const seo = (row.seo ?? {}) as { noindex?: boolean }
50
+ if (seo.noindex) continue
51
+ const locale = (row.locale as string | undefined) ?? primary
52
+ candidates.push({
53
+ loc: base + localePath(path, locale, primary, prefixPrimary),
54
+ lastmod: row.updatedAt instanceof Date ? row.updatedAt.toISOString() : undefined,
55
+ locale,
56
+ group: (row.translationGroup as string | undefined) ?? null,
57
+ })
58
+ }
59
+ }
60
+
61
+ // Link each page's published, indexable locale variants via hreflang alternates before serializing.
62
+ const entries = withHreflang(candidates, primary)
63
+ entries.sort((a, b) => a.loc.localeCompare(b.loc))
64
+ setHeader(event, 'content-type', 'application/xml; charset=utf-8')
65
+ return buildSitemap(entries)
66
+ })
@@ -0,0 +1,4 @@
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
@@ -0,0 +1,4 @@
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
@@ -0,0 +1,26 @@
1
+ import { publishFull, outputDriver } from '../../utils/publish/publisher'
2
+ import { DepsStore } from '../../utils/publish/deps'
3
+ import { createSqlitePersistence } from '../../utils/publish/deps-persistence'
4
+
5
+ /**
6
+ * The shared publish engine, exposed as a Nitro task. Renders every published page (+ sitemap/robots)
7
+ * via the live server, mirrors `_nuxt`/assets, and always prunes the routes that left the published set
8
+ * (output ≡ DB).
9
+ *
10
+ * Triggering (Nuxt 4.4 / Nitro 2.13 — there is NO `nuxi task run`):
11
+ * - dev: GET http://localhost:3000/_nitro/tasks/publish:run (the dev-only task route; plumbing smoke test)
12
+ * - prod: the boot-publish plugin (`zz.publish.ts`) runs this on startup; for an on-demand prod trigger,
13
+ * expose your own authenticated route that calls `runTask('publish:run')` (the built node-server
14
+ * has no task endpoint/CLI — only `runTask()` from inside the process + cron `scheduledTasks`).
15
+ */
16
+ export default defineTask({
17
+ meta: { name: 'publish:run', description: 'Render all published pages to the configured static output (HTML + _nuxt + assets)' },
18
+ async run() {
19
+ // The same durable store the boot-publish plugin uses, so a manually-triggered run also prunes
20
+ // routes that left the published set and records deps for later incremental publishes.
21
+ const deps = new DepsStore(createSqlitePersistence(useDb()))
22
+ const result = await publishFull(outputDriver(), deps)
23
+ console.info(`[kestrel] publish:run rendered ${result.rendered} route(s), pruned ${result.pruned}`)
24
+ return { result }
25
+ },
26
+ })
@@ -0,0 +1,35 @@
1
+ import { eq, getTableColumns } from 'drizzle-orm'
2
+ import { getCollection } from '../../../core/server/utils/registry'
3
+ import { primaryLocale, prefixPrimaryLocale } from '../../../core/server/utils/locale'
4
+ import { pageRowHref } from '../../../core/server/utils/page-route'
5
+
6
+ /** Whether a fetched target row may be linked from PUBLIC output: it must exist and — if its collection has a
7
+ * status column — be published. A draft target is NOT linkable, so its slug never leaks into published HTML
8
+ * (the caller maps the unresolved link to `'#'`). Pure (no DB) → unit-tested. */
9
+ export function isPubliclyLinkable(row: Record<string, unknown> | undefined, hasStatus: boolean): boolean {
10
+ if (!row) return false
11
+ return !hasStatus || row.status === 'published'
12
+ }
13
+
14
+ /**
15
+ * Resolve an internal link `{collection, id}` to the target record's localized public path via the DB.
16
+ * STATUS-GATED: a draft/unpublished target resolves to null → the link renders `'#'`, so its slug never
17
+ * reaches published HTML (the editor is separately warned about draft/dead links via record-ref-index). The
18
+ * baked href therefore encodes availability, which is why publishing/unpublishing a target emits the
19
+ * `<coll>:<id>` tag and re-renders its referrers. Returns null for a non-page-like, missing, or draft target. Shared by the
20
+ * populate plugin (read path, during `nuxt generate`) and the preview `/api/links/resolve` endpoint, so both
21
+ * resolve identically. Server-only (`useDb` by default; injectable for tests); tolerates an unmigrated DB → null.
22
+ */
23
+ export function resolveInternalHref(collection: string, id: number, db = useDb()): string | null {
24
+ const c = getCollection(collection)
25
+ if (!c?.def.pageLike) return null
26
+ const cols = getTableColumns(c.table) as Record<string, never>
27
+ let row: Record<string, unknown> | undefined
28
+ try {
29
+ row = db.select().from(c.table).where(eq(cols.id, id)).get() as Record<string, unknown> | undefined
30
+ } catch {
31
+ return null // table not migrated yet (e.g. a bare prerender DB)
32
+ }
33
+ if (!isPubliclyLinkable(row, Object.hasOwn(cols, 'status'))) return null
34
+ return pageRowHref(row, primaryLocale(), prefixPrimaryLocale())
35
+ }
@@ -0,0 +1,39 @@
1
+ /** One linked resource in an llms.txt section. */
2
+ export interface LlmsEntry {
3
+ title: string
4
+ url: string
5
+ /** Optional one-line summary (rendered after the link as `: description`). */
6
+ description?: string
7
+ }
8
+
9
+ /** A titled group of resources — one `## heading` block. */
10
+ export interface LlmsSection {
11
+ heading: string
12
+ entries: LlmsEntry[]
13
+ }
14
+
15
+ /**
16
+ * Render an `llms.txt` (https://llmstxt.org): an H1 site name, an optional `>` blockquote summary, then
17
+ * one `## heading` per section listing `- [title](url): description` resources. The goal is a compact,
18
+ * machine-readable map of the site so an AI agent grasps what it is and where the key pages live. Pure +
19
+ * deterministic (the route feeds it published, indexable pages only); empty sections are omitted.
20
+ */
21
+ // Collapse any whitespace (incl. newlines) to single spaces so a user-authored title/description/heading —
22
+ // which flows in from an SEO field / textarea — can't inject a spurious line into the one-per-line list.
23
+ const oneLine = (s: string): string => s.replace(/\s+/g, ' ').trim()
24
+ // Additionally escape the brackets that would otherwise break the `[title](url)` markdown link.
25
+ const linkText = (s: string): string => oneLine(s).replace(/[[\]]/g, '\\$&')
26
+
27
+ export function buildLlmsTxt(opts: { siteName: string; siteDescription?: string; sections: LlmsSection[] }): string {
28
+ const lines: string[] = [`# ${oneLine(opts.siteName)}`]
29
+ if (opts.siteDescription) lines.push('', `> ${oneLine(opts.siteDescription)}`)
30
+ for (const section of opts.sections) {
31
+ if (!section.entries.length) continue
32
+ lines.push('', `## ${oneLine(section.heading)}`, '')
33
+ for (const e of section.entries) {
34
+ const desc = e.description ? `: ${oneLine(e.description)}` : ''
35
+ lines.push(`- [${linkText(e.title)}](${e.url})${desc}`)
36
+ }
37
+ }
38
+ return `${lines.join('\n')}\n`
39
+ }