@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,86 @@
1
+ import { asc, eq, getTableColumns } from 'drizzle-orm'
2
+ import { list } from '../../../core/server/utils/crud'
3
+ import { captureRead } from '../../../core/server/utils/read-capture'
4
+ import type { BuiltCollection } from '../../../core/server/utils/collection-types'
5
+ import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
6
+
7
+ export interface PageAlternate { locale: string; path: string }
8
+ export interface ResolvedPage { collection: string; page: Record<string, unknown>; alternates: PageAlternate[] }
9
+
10
+ /**
11
+ * The page's published, INDEXABLE translation siblings (self included) as locale→path pairs — the hreflang
12
+ * set the public head emits. Mirrors the sitemap's rules exactly so the two never disagree: published-only
13
+ * (a draft URL must never be advertised to crawlers), `seo.noindex` variants EXCLUDED (advertising an
14
+ * hreflang to a noindexed page is a conflicting signal), null-path rows skipped, and a single-member group
15
+ * returns [] (hreflang is meaningless for a lone page). EVERY sibling in the group is `captureRead`-tagged,
16
+ * filtered-out ones included, so an incremental publish re-renders every group member when a sibling is
17
+ * renamed/published/unpublished — otherwise a baked page would keep a stale/dead hreflang href.
18
+ */
19
+ function publishedAlternates(db: BetterSQLite3Database, c: BuiltCollection, page: Record<string, unknown>): PageAlternate[] {
20
+ if (c.def.mode !== 'multi' || !c.def.translatable) return []
21
+ const group = page.translationGroup
22
+ if (typeof group !== 'string' || !group) return []
23
+ const cols = getTableColumns(c.table) as Record<string, never>
24
+ // A status-less pageLike collection has no draft state — every sibling is "published".
25
+ const hasStatus = Object.hasOwn(cols, 'status')
26
+ const proj: Record<string, unknown> = { id: cols.id, locale: cols.locale, path: cols.path }
27
+ if (hasStatus) proj.status = cols.status
28
+ if (c.def.seo) proj.seo = cols.seo
29
+ // Read the WHOLE group and filter in JS: a row excluded by SQL is never captured, so a draft sibling
30
+ // would carry no dependency edge for the publish tag `<coll>:<id>` to match, and this page would keep a
31
+ // baked hreflang set missing the newly-published sibling until the next full generate.
32
+ const rows = db.select(proj as never)
33
+ .from(c.table)
34
+ .where(eq(cols.translationGroup, group))
35
+ .orderBy(asc(cols.locale))
36
+ .all() as Array<{ id: number; locale: string; path: unknown; status?: string; seo?: { noindex?: boolean } | null }>
37
+ const alternates: PageAlternate[] = []
38
+ let selfAdvertisable = false
39
+ for (const r of rows) {
40
+ // Capture the sibling as a publish dependency even if it is filtered out below — a draft/noindexed/
41
+ // null-path sibling that later becomes advertisable must still re-render this page's hreflang set.
42
+ captureRead(c.def.name, r.id)
43
+ if (hasStatus && r.status !== 'published') continue // a draft URL must never be advertised
44
+ if (typeof r.path !== 'string' || !r.path) continue // null/empty path — no advertisable URL
45
+ if (r.seo?.noindex) continue // noindexed — never advertise it via hreflang (matches the sitemap)
46
+ if (r.id === page.id) selfAdvertisable = true
47
+ alternates.push({ locale: r.locale, path: r.path })
48
+ }
49
+ // A hreflang set with no self-reference is a signal search engines ignore wholesale — if the page
50
+ // being rendered didn't itself survive the filters above, advertise nothing rather than an orphan set.
51
+ if (!selfAdvertisable) return []
52
+ return alternates.length >= 2 ? alternates : []
53
+ }
54
+
55
+ /**
56
+ * The first page-like record (across all collections, in registration order) whose `path` matches,
57
+ * populated at depth 1 — or null. Reuses the access-scoped `list()` so media/links populate exactly as
58
+ * a direct collection read would. `publishedOnly` defaults true: a static render (prerender / runtime
59
+ * publisher) and an anonymous live request must never see drafts. The authenticated-admin live preview
60
+ * passes false to surface a draft at its real URL. Registration order is the precedence rule when two
61
+ * page-like collections happen to share a path.
62
+ */
63
+ export function resolvePage(db: BetterSQLite3Database, collections: BuiltCollection[], path: string, locale?: string, publishedOnly = true): ResolvedPage | null {
64
+ for (const c of collections) {
65
+ if (!c.def.pageLike) continue
66
+ // withTotal:false — read only the first row, skip count(). capture:false — don't tag the whole
67
+ // collection here (the self-lookup is by path); instead tag the found record below, so this page
68
+ // depends on its OWN record, not on every sibling in the collection (precise invalidation).
69
+ let result: ReturnType<typeof list>
70
+ try {
71
+ result = list(db, c, { filter: [{ field: 'path', op: 'eq', value: path }], locale, depth: 1, perPage: 1, withTotal: false, capture: false }, publishedOnly)
72
+ } catch (error) {
73
+ // Table not migrated yet (e.g. a bare prerender DB) — isolate this collection's drift from the rest,
74
+ // but never silently: an unread collection is indistinguishable from one with no matching page, and
75
+ // the publisher would write a 404 over every one of its routes.
76
+ console.error(`[kestrel] resolvePage: skipped collection ${c.def.name}:`, (error as Error)?.message ?? error)
77
+ continue
78
+ }
79
+ const { data } = result
80
+ if (data.length) {
81
+ captureRead(c.def.name, (data[0] as { id?: number }).id ?? null)
82
+ return { collection: c.def.name, page: data[0]!, alternates: publishedAlternates(db, c, data[0]!) }
83
+ }
84
+ }
85
+ return null
86
+ }
@@ -0,0 +1,66 @@
1
+ import type { FieldPopulator } from '../../../core/server/utils/populate'
2
+ import { memoDuringPrerender } from '../../../core/server/utils/prerender-memo'
3
+ import { memoResolver } from '../../../core/server/utils/resolve-scope'
4
+ import { captureRead } from '../../../core/server/utils/read-capture'
5
+ import { collectRichtextRefs, resolveRichtextLinks } from '../../../fields/app/utils/richtext-links'
6
+
7
+ // The localized-href-for-a-page-row rule lives once in core (`pageRowHref`); the link populator/resolver
8
+ // use it as the target-row href. The resolver (link-resolve) STATUS-GATES: a missing OR draft target
9
+ // renders `'#'` (no draft-slug leak); only a published target gets its real path.
10
+
11
+ // Resolves an internal link `{collection, id}` to the target's localized path, or null when it has no
12
+ // public path (target missing, not page-like, or no path). The locale is the TARGET row's own, so the
13
+ // resolver takes no locale argument — mirroring how the media populator injects its by-id resolver.
14
+ export type ResolveHref = (collection: string, id: number) => string | null
15
+
16
+ interface InternalLink { type: 'internal'; collection: string; id: number; href?: string }
17
+
18
+ function isInternal(value: unknown): value is InternalLink {
19
+ if (!value || typeof value !== 'object') return false
20
+ const v = value as Record<string, unknown>
21
+ return v.type === 'internal' && typeof v.collection === 'string' && typeof v.id === 'number'
22
+ }
23
+
24
+ // Replace an internal link value in place with a cloned `{...value, href}` (KestrelLink reads value.href).
25
+ // external / email / tel and unresolvable internals pass through untouched (the latter render '#').
26
+ function resolveLink(value: unknown, resolveHref: ResolveHref): unknown {
27
+ if (!isInternal(value)) return value
28
+ const href = resolveHref(value.collection, value.id)
29
+ return href == null ? value : { ...value, href }
30
+ }
31
+
32
+ /**
33
+ * The `link` + `richtext` field populators. `link` replaces an internal link value with `{...value, href}`;
34
+ * `richtext` rewrites `kestrel:` internal-link markers in the HTML to resolved localized paths. Both are
35
+ * bare-keyed in every key-mode (link/richtext are not single-ref columns), so `keyMode` is irrelevant.
36
+ * Registered per-type via `registerFieldPopulator`; the shared field-tree walker drives them over
37
+ * top-level fields, block props, and (now) repeater entries. Both share one prerender memo so a target is
38
+ * resolved once per generate across links and richtext alike.
39
+ */
40
+ export function buildLinkFieldPopulators(resolveHref: ResolveHref): { link: FieldPopulator; richtext: FieldPopulator } {
41
+ // Same composition as the relation/media populators: build-wide memo during generate, request-/publish-
42
+ // run-wide via the resolve scope (budgeted per live request, read-tags replayed on hits).
43
+ // memoResolver OUTERMOST so a per-scope budget-skip null is never cached build-wide by the prerender memo.
44
+ const key = (collection: string, id: number) => `link:${collection}:${id}`
45
+ const resolve = memoResolver(memoDuringPrerender(resolveHref, key), key)
46
+ return {
47
+ // captureRead runs even on a memo hit (each embedding page must record the dep), so it sits here, not
48
+ // inside `resolve`: it durably ties this render's route to `<collection>:<id>`, so renaming the target
49
+ // re-renders the referrer instead of leaving a baked href pointing at the pruned old path. A DRAFT
50
+ // target is tagged for the same reason: resolution is status-gated, so the referrer baked '#', and this
51
+ // edge is what lets the availability tag `<collection>:<id>` (planInvalidation's publish/unpublish/
52
+ // delete branches) re-render it into the real path. A dangling target's tag is simply inert — no write
53
+ // ever emits it.
54
+ link: (bag, key) => {
55
+ const value = bag[key]
56
+ if (isInternal(value)) captureRead(value.collection, value.id)
57
+ bag[key] = resolveLink(value, resolve)
58
+ },
59
+ richtext: (bag, key) => {
60
+ const html = bag[key]
61
+ if (typeof html !== 'string') return
62
+ for (const ref of collectRichtextRefs(html)) captureRead(ref.collection, ref.id)
63
+ bag[key] = resolveRichtextLinks(html, resolve)
64
+ },
65
+ }
66
+ }
@@ -0,0 +1,22 @@
1
+ import type { Invalidation } from './invalidation'
2
+
3
+ /**
4
+ * Merge a burst of invalidations collected during a debounce window into one: any `full` wins (a
5
+ * full rebuild subsumes everything); otherwise union the `tags`/`render`/`prune` sets (dedup, stable
6
+ * insertion order); all-`noop` (or empty) collapses to `noop`.
7
+ */
8
+ export function coalesce(items: Invalidation[]): Invalidation {
9
+ if (items.some((i) => i.type === 'full')) return { type: 'full' }
10
+ const tags = new Set<string>()
11
+ const render = new Set<string>()
12
+ const prune = new Set<string>()
13
+ let any = false
14
+ for (const i of items) {
15
+ if (i.type !== 'tags') continue
16
+ any = true
17
+ for (const t of i.tags) tags.add(t)
18
+ for (const r of i.render) render.add(r)
19
+ for (const p of i.prune) prune.add(p)
20
+ }
21
+ return any ? { type: 'tags', tags: [...tags], render: [...render], prune: [...prune] } : { type: 'noop' }
22
+ }
@@ -0,0 +1,43 @@
1
+ import { eq } from 'drizzle-orm'
2
+ import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
3
+ import { publishDeps } from '../../database/publish-deps'
4
+ import type { DepsPersistence } from './deps'
5
+
6
+ /** No-op persistence: a `DepsStore` backed by it runs purely in-memory. */
7
+ const inMemoryOnly: DepsPersistence = { load: () => [], save: () => {}, remove: () => {}, clearAll: () => {} }
8
+
9
+ /**
10
+ * SQLite-backed durable persistence for the publish deps index. Takes the drizzle db as an argument (the
11
+ * caller passes `useDb()`) so this stays a pure function of an injected db — fully unit-testable against an
12
+ * in-memory database, no auto-import in the module. Tags are stored JSON-encoded (a small bounded set per
13
+ * route).
14
+ *
15
+ * Probes the `publish_deps` table once on creation: if it is absent (a not-yet-migrated deploy — prod never
16
+ * auto-DDLs, so an operator must run `db:migrate`) it warns and returns an in-memory-only no-op rather than
17
+ * crashing the boot publish + every write. Full durability (cross-restart pruning) returns once the table
18
+ * exists. Probing once (not per-op) keeps ALL of load/save/remove/clearAll consistent — the boot publish
19
+ * calls `save()` per route, so a resilient `load()` alone was not enough.
20
+ */
21
+ export function createSqlitePersistence(db: BetterSQLite3Database): DepsPersistence {
22
+ try {
23
+ db.select().from(publishDeps).limit(1).all()
24
+ } catch {
25
+ console.warn('[kestrel] publish_deps table is missing — run the `db:migrate` task. Falling back to in-memory deps (no cross-restart prune until migrated).')
26
+ return inMemoryOnly
27
+ }
28
+ return {
29
+ load() {
30
+ return db.select().from(publishDeps).all().map((r) => [r.route, JSON.parse(r.tags) as string[]] as const)
31
+ },
32
+ save(route, tags) {
33
+ const json = JSON.stringify([...tags])
34
+ db.insert(publishDeps).values({ route, tags: json }).onConflictDoUpdate({ target: publishDeps.route, set: { tags: json } }).run()
35
+ },
36
+ remove(route) {
37
+ db.delete(publishDeps).where(eq(publishDeps.route, route)).run()
38
+ },
39
+ clearAll() {
40
+ db.delete(publishDeps).run()
41
+ },
42
+ }
43
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Routes whose captured dependency tags intersect `changed`. The publisher captures, per published
3
+ * route, the set of data tags it read while rendering (`<coll>` for a `list`, `<coll>:<id>` for a
4
+ * `getOne`, `<coll>` for a singleton). On a write, the changed tags `{coll, coll:id}` map back to
5
+ * exactly the routes that embed that data — so editing one speaker re-renders its detail page + the
6
+ * overview that lists speakers, and nothing else. Pure.
7
+ */
8
+ export function routesForTags(changed: Iterable<string>, index: Map<string, Set<string>>): string[] {
9
+ const want = new Set(changed)
10
+ const out: string[] = []
11
+ for (const [route, tags] of index) {
12
+ for (const t of tags) {
13
+ if (want.has(t)) { out.push(route); break }
14
+ }
15
+ }
16
+ return out
17
+ }
18
+
19
+ /**
20
+ * Routes the publisher previously wrote (tracked in the deps index) that are no longer in the published
21
+ * set — a page that was unpublished, deleted, or whose slug changed. Their static files must be removed
22
+ * on the next full publish. Pure; the diff drives a targeted prune (it only ever deletes files this
23
+ * publisher created — output ≡ DB, no opt-in toggle).
24
+ */
25
+ export function staleRoutes(tracked: Iterable<string>, publishedNow: Iterable<string>): string[] {
26
+ const live = new Set(publishedNow)
27
+ return [...tracked].filter((route) => !live.has(route))
28
+ }
29
+
30
+ /**
31
+ * Durable backing for the deps index: a write-through `route → tags` store that survives a restart.
32
+ * The in-memory index is the (synchronous) query layer; this port persists every mutation and is read
33
+ * back once on construction to rehydrate. Kept abstract so `DepsStore` stays pure-unit-testable (a fake
34
+ * port in node tests) while production injects the SQLite-backed adapter (`createSqlitePersistence`).
35
+ */
36
+ export interface DepsPersistence {
37
+ /** Every persisted `route → tags` pair (read once on DepsStore construction to rehydrate). */
38
+ load(): Iterable<readonly [string, Iterable<string>]>
39
+ /** Persist (upsert) a route's tag set. */
40
+ save(route: string, tags: Iterable<string>): void
41
+ /** Remove a route. */
42
+ remove(route: string): void
43
+ /** Drop every route. */
44
+ clearAll(): void
45
+ }
46
+
47
+ /**
48
+ * In-memory `route → tags` index, populated as routes are published and queried on each write — the fast
49
+ * synchronous query layer. When constructed with a `DepsPersistence`, the index is rehydrated from it on
50
+ * boot and every mutation is written through, so the store survives a restart: a boot full-publish's
51
+ * `staleRoutes` can then prune pages that were unpublished/deleted while the server was down.
52
+ */
53
+ export class DepsStore {
54
+ private index = new Map<string, Set<string>>()
55
+
56
+ constructor(private persistence?: DepsPersistence) {
57
+ if (persistence) for (const [route, tags] of persistence.load()) this.index.set(route, new Set(tags))
58
+ }
59
+
60
+ /** Replace the tag set captured for `route`. */
61
+ record(route: string, tags: Iterable<string>): void {
62
+ const set = new Set(tags)
63
+ this.index.set(route, set)
64
+ this.persistence?.save(route, set)
65
+ }
66
+
67
+ /** Routes whose tags intersect `changed`. */
68
+ routesForTags(changed: Iterable<string>): string[] {
69
+ return routesForTags(changed, this.index)
70
+ }
71
+
72
+ /** Every route with a captured tag set. */
73
+ routes(): string[] {
74
+ return [...this.index.keys()]
75
+ }
76
+
77
+ /** Drop `route` from the index (on prune/unpublish). */
78
+ forget(route: string): void {
79
+ this.index.delete(route)
80
+ this.persistence?.remove(route)
81
+ }
82
+
83
+ clear(): void {
84
+ this.index.clear()
85
+ this.persistence?.clearAll()
86
+ }
87
+ }
@@ -0,0 +1,141 @@
1
+ import { pageRowHref } from '../../../../core/server/utils/page-route'
2
+
3
+ /** The shape of the collection a write targets (the subset invalidation needs). */
4
+ export interface WriteCollection {
5
+ name: string
6
+ pageLike?: boolean
7
+ /** Whether the collection has a draft/published `status` column. */
8
+ status?: boolean
9
+ }
10
+
11
+ type Row = Record<string, unknown> | null
12
+
13
+ /** A content write reduced to what invalidation needs (computed by `classifyWrite`). */
14
+ export interface WriteClassification {
15
+ collection: string
16
+ pageLike: boolean
17
+ status: 'created' | 'updated' | 'deleted'
18
+ id: number | null
19
+ /** pageLike `path` changed (the old static file must be pruned, the new one written). */
20
+ pathChanged: boolean
21
+ /** published-ness changed (publish or unpublish — adds/removes the page + sitemap entry). */
22
+ statusChanged: boolean
23
+ isPublished: boolean
24
+ wasPublished: boolean
25
+ /** The page's own public route from the NEW row (pageLike + has a path), else null. */
26
+ selfRoute: string | null
27
+ /** The page's own public route from the OLD row (pageLike + had a path), else null — the route whose
28
+ * static file must be pruned on a slug change / unpublish / delete (symmetric to `selfRoute`). */
29
+ oldRoute: string | null
30
+ }
31
+
32
+ /** What to republish for a write. Routes are resolved from `tags` against the captured deps index. */
33
+ export type Invalidation =
34
+ | { type: 'full' }
35
+ | { type: 'tags'; tags: string[]; render: string[]; prune: string[] }
36
+ | { type: 'noop' }
37
+
38
+ /**
39
+ * The routes to actually delete from a coalesced invalidation: `prune` minus anything we just wrote live.
40
+ * A render and a prune for the SAME route can coalesce into one window (e.g. a new page created at /x while
41
+ * another page vacates /x) — pruning a freshly-rendered route would delete the live page, so render wins.
42
+ * Filtered against the ACTUALLY-rendered set (not the render candidates), so a route whose record is now a
43
+ * draft — skipped by the renderer — is still pruned.
44
+ */
45
+ export function routesToPrune(prune: string[], rendered: string[]): string[] {
46
+ const live = new Set(rendered)
47
+ return prune.filter((r) => !live.has(r))
48
+ }
49
+
50
+ /** A collection with no `status` column is always public; otherwise public ⇔ status === 'published'. */
51
+ function isPublic(def: WriteCollection, row: Row): boolean {
52
+ if (!def.status) return true
53
+ return !!row && row.status === 'published'
54
+ }
55
+
56
+ /** Reduce a before/after row pair (either may be null) to a `WriteClassification`. Pure (routes via the shared `pageRowHref`). */
57
+ export function classifyWrite(def: WriteCollection, before: Row, after: Row, primaryLocale: string, prefixPrimary = false): WriteClassification {
58
+ const status: WriteClassification['status'] = before === null ? 'created' : after === null ? 'deleted' : 'updated'
59
+ const row = after ?? before
60
+ const id = typeof row?.id === 'number' ? row.id : null
61
+ const pageLike = !!def.pageLike
62
+
63
+ const isPublished = isPublic(def, after)
64
+ const wasPublished = isPublic(def, before)
65
+ const statusChanged = status === 'updated' && !!def.status && isPublished !== wasPublished
66
+
67
+ const routeFrom = (r: Row): string | null => (pageLike ? pageRowHref(r, primaryLocale, prefixPrimary) : null)
68
+ const selfRoute = routeFrom(after)
69
+ const oldRoute = routeFrom(before)
70
+ // Compare the RESOLVED routes, not raw `path`: the route is localePath(path, locale), so a locale-only
71
+ // change (path unchanged) still moves the route — the old-locale file must be pruned.
72
+ const pathChanged = pageLike && status === 'updated' && oldRoute !== selfRoute
73
+
74
+ return { collection: def.name, pageLike, status, id, pathChanged, statusChanged, isPublished, wasPublished, selfRoute, oldRoute }
75
+ }
76
+
77
+ /**
78
+ * Decide what a write invalidates, per the maintainer-agreed model. Two notions of "dependent":
79
+ * - LISTINGS — pages that QUERY the collection (overviews) → captured as the `<coll>` tag.
80
+ * - EXPLICIT REFERRERS — pages that LINK/EMBED/relate-to a specific record → captured as `<coll>:<id>`.
81
+ *
82
+ * Two principles drive the split:
83
+ * 1. FRESHENING (content or path changed) re-renders BOTH listings and explicit referrers (`[coll, coll:id]`)
84
+ * so their output stays fresh.
85
+ * 2. AVAILABILITY (publish / unpublish / delete) re-renders listings (membership changed) AND the explicit
86
+ * referrers (`[coll, coll:id]`), because a referrer's BAKED output encodes the target's availability:
87
+ * link resolution is status-gated (a draft target bakes `href="#"`) and a page's hreflang set lists only
88
+ * its published translation siblings — both go dead/stale the moment availability flips, and only a
89
+ * re-render can fix them. A record's OWN artifact always matches its DB state (render on publish, prune
90
+ * on unpublish/delete). A draft that stays a draft is a no-op.
91
+ *
92
+ * `full` is reserved for the boot publish + the reconciler (enqueued directly), never produced here.
93
+ */
94
+ export function planInvalidation(ev: WriteClassification): Invalidation {
95
+ const coll = ev.collection
96
+ const recordTag = ev.id != null ? `${coll}:${ev.id}` : null
97
+ const tags = recordTag ? [coll, recordTag] : [coll]
98
+ const selfRender = ev.pageLike && ev.selfRoute ? [ev.selfRoute] : []
99
+
100
+ // DELETE — leaves the collection. Listings re-render, referrers too (their baked link/hreflang now points
101
+ // at a route that is about to disappear); the own (pageLike) route is pruned.
102
+ if (ev.status === 'deleted') {
103
+ const prune = ev.pageLike && ev.oldRoute ? [ev.oldRoute] : []
104
+ return { type: 'tags', tags, render: [], prune }
105
+ }
106
+
107
+ // CREATE — joins the collection. A created draft produces no public output (noop); a created published
108
+ // record re-renders listings + its own route. No referrer can point at a brand-new id, so no `coll:id`.
109
+ if (ev.status === 'created') {
110
+ if (!ev.isPublished) return { type: 'noop' }
111
+ return { type: 'tags', tags: [coll], render: selfRender, prune: [] }
112
+ }
113
+
114
+ // UNPUBLISH — leaves the published set. Listings re-render; referrers re-render so their link falls back to
115
+ // '#' and their hreflang drops this sibling; the own route is pruned (the live file sits at the OLD path if
116
+ // this save also renamed).
117
+ if (ev.statusChanged && !ev.isPublished) {
118
+ const route = ev.oldRoute ?? ev.selfRoute
119
+ const prune = ev.pageLike && route ? [route] : []
120
+ return { type: 'tags', tags, render: [], prune }
121
+ }
122
+
123
+ // PUBLISH — joins the published set. Listings re-render; referrers re-render because a link to a draft
124
+ // resolves to '#' — their baked href only becomes the real path on a re-render; the own route renders.
125
+ if (ev.statusChanged && ev.isPublished) {
126
+ return { type: 'tags', tags, render: selfRender, prune: [] }
127
+ }
128
+
129
+ // Below: a plain update, no availability change. A draft that stays a draft touches no public output.
130
+ if (!ev.isPublished) return { type: 'noop' }
131
+
132
+ // SLUG / PATH CHANGE — path freshening: listings + referrers re-render (their link path updates); the new
133
+ // route renders, the old route is pruned.
134
+ if (ev.pathChanged) {
135
+ const prune = ev.oldRoute ? [ev.oldRoute] : []
136
+ return { type: 'tags', tags, render: selfRender, prune }
137
+ }
138
+
139
+ // CONTENT EDIT — content freshening: listings + explicit referrers re-render; the own route re-renders.
140
+ return { type: 'tags', tags, render: selfRender, prune: [] }
141
+ }
@@ -0,0 +1,54 @@
1
+ import { eq } from 'drizzle-orm'
2
+ import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
3
+ import { publishStatus } from '../../database/publish-status'
4
+
5
+ export interface PublishOutcome {
6
+ status: 'success' | 'error'
7
+ /** The failure message (render / write / S3) on an 'error' outcome; omitted/null on success. */
8
+ error?: string | null
9
+ /** Which output the attempt wrote to — `outputConfig().driver` ('local' | 's3'). */
10
+ target: string
11
+ }
12
+
13
+ /**
14
+ * Upsert the LATEST publish outcome for a route — one row per route (latest-state, not a history). Called
15
+ * by the runtime publisher after each render+put (success) or when one throws (error). Resilient by design:
16
+ * a missing `publish_status` table (a not-yet-migrated deploy — prod never auto-DDLs) degrades to a warn
17
+ * rather than breaking the publish loop or masking the real render/write error in the caller's catch. The
18
+ * timestamp is an explicit `new Date()` so the conflict-update branch (which does not fire `$defaultFn`)
19
+ * also refreshes it; drizzle maps it to UNIX seconds (mode:'timestamp'), never raw milliseconds.
20
+ */
21
+ export function recordPublishStatus(db: BetterSQLite3Database, route: string, outcome: PublishOutcome): void {
22
+ try {
23
+ const set = { status: outcome.status, error: outcome.error ?? null, target: outcome.target, updatedAt: new Date() }
24
+ db.insert(publishStatus).values({ route, ...set }).onConflictDoUpdate({ target: publishStatus.route, set }).run()
25
+ } catch (error) {
26
+ console.warn('[kestrel] could not record publish status (is `publish_status` migrated? run `db:migrate`):', (error as Error).message)
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Classify a route's render result into a publish outcome:
32
+ * - `success` — a 200 with a rendered body (write it + record success).
33
+ * - `error` — a 5xx: the page itself rendered to a server error (a bad block / relation / template).
34
+ * Crucially this surfaces as a non-200 RESPONSE, not a thrown exception, so without this
35
+ * branch it would be a silent skip leaving a stale `success` row — the failure mode the
36
+ * editor ampel most needs to show.
37
+ * - `skip` — any other non-200 (a draft / unpublish race resolving to a 404 / redirect): not an error,
38
+ * leave the route's status untouched (the prune path handles a real unpublish).
39
+ */
40
+ export function renderOutcome(status: number, hasBody: boolean): 'success' | 'error' | 'skip' {
41
+ if (status === 200 && hasBody) return 'success'
42
+ if (status >= 500) return 'error'
43
+ return 'skip'
44
+ }
45
+
46
+ /** Clear a route's status row — its static file was pruned (unpublish / delete / slug change), so it is no
47
+ * longer live. Idempotent; same missing-table resilience as `recordPublishStatus`. */
48
+ export function clearPublishStatus(db: BetterSQLite3Database, route: string): void {
49
+ try {
50
+ db.delete(publishStatus).where(eq(publishStatus.route, route)).run()
51
+ } catch (error) {
52
+ console.warn('[kestrel] could not clear publish status:', (error as Error).message)
53
+ }
54
+ }