@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,291 @@
1
+ import { readdir, readFile } from 'node:fs/promises'
2
+ import { resolve } from 'node:path'
3
+ import { getTableColumns } from 'drizzle-orm'
4
+ import type { StorageDriver } from '../../../../core/server/utils/storage'
5
+ import { createLocalDriver } from '../../../../core/server/utils/storage.local'
6
+ import { createS3Driver } from '../../../../core/server/utils/storage.s3'
7
+ import { contentTypeFor, cacheControlFor, precompressedEncoding } from '../../../modules/deploy-output/deploy-output'
8
+ import { localePath } from '../../../../core/app/utils/locale-path'
9
+ import { pageRowHref } from '../../../../core/server/utils/page-route'
10
+ import { withReadCapture } from '../../../../core/server/utils/read-capture'
11
+ import { withResolveScope } from '../../../../core/server/utils/resolve-scope'
12
+ import { runAsRenderer } from '../../../../access/server/utils/render-context'
13
+ import { htmlKeyForRoute } from './route-keys'
14
+ import { staleRoutes, type DepsStore } from './deps'
15
+ import { recordPublishStatus, clearPublishStatus, renderOutcome } from './publish-status'
16
+ import { routesToPrune, type Invalidation } from './invalidation'
17
+
18
+ /**
19
+ * The runtime static publisher: renders public routes from the LIVE server (`localFetch`, the same
20
+ * handler `nuxt generate` uses) and writes them — plus the built `_nuxt` bundle + assets — through the
21
+ * configured output StorageDriver (local dir or S3). This is the engine the manual `publish:run` task
22
+ * and (later) the auto-trigger queue both drive. NOT unit-tested (needs a running build); the pure
23
+ * pieces it composes (route-keys, invalidation, deps, queue) are.
24
+ */
25
+
26
+ interface OutputRc {
27
+ driver: 'local' | 's3'
28
+ dir: string
29
+ publicDir: string
30
+ auto: boolean
31
+ reconcileMinutes: number
32
+ verbose: boolean
33
+ s3: { bucket: string; region: string; endpoint: string; prefix: string; accessKeyId: string; secretAccessKey: string; sessionToken: string }
34
+ }
35
+
36
+ export function outputConfig(): OutputRc {
37
+ return (useRuntimeConfig().kestrel as { output: OutputRc }).output
38
+ }
39
+
40
+ /** Build the output driver from runtimeConfig (local dir or S3 bucket) — separate from the media driver. */
41
+ export function outputDriver(cfg: OutputRc = outputConfig()): StorageDriver {
42
+ if (cfg.driver === 's3') {
43
+ return createS3Driver({
44
+ bucket: cfg.s3.bucket, region: cfg.s3.region, endpoint: cfg.s3.endpoint || undefined, prefix: cfg.s3.prefix,
45
+ publicBaseUrl: '', accessKeyId: cfg.s3.accessKeyId, secretAccessKey: cfg.s3.secretAccessKey, sessionToken: cfg.s3.sessionToken || undefined,
46
+ })
47
+ }
48
+ return createLocalDriver({ dir: cfg.dir, baseUrl: '/' })
49
+ }
50
+
51
+ /** Render a public route via the in-process server, returning its HTML buffer (200) or a null body with the
52
+ * HTTP status, so the caller can tell a broken page (5xx — a server-error RESPONSE, not a throw) from an
53
+ * expected non-200 (a draft / unpublish race → 404 / redirect). */
54
+ export async function renderRoute(route: string): Promise<{ body: Buffer | null; status: number }> {
55
+ // Run as the renderer principal so the nested $fetch('/api/route') passes the access guard (it is not a
56
+ // public endpoint). The ALS context propagates through localFetch → the page render → the nested fetch.
57
+ // useNitroApp is imported LAZILY (not via the Nitro auto-import) on purpose: a static import gives this
58
+ // module an edge to nitropack's app.mjs, which both defines useNitroApp AND loads the plugin registry
59
+ // that reaches this file (via zz.publish) — a build-time circular dependency. A dynamic import is a chunk
60
+ // boundary, so that cycle disappears from the build graph; it's the same nitroApp singleton either way
61
+ // (Rollup bundles the literal specifier; useNitroApp is only CALLED here at publish time, long after boot).
62
+ const { useNitroApp } = await import('nitropack/runtime')
63
+ const res = await runAsRenderer(() => useNitroApp().localFetch(route, { method: 'GET', headers: { 'x-kestrel-publish': '1' } }))
64
+ if (res.status !== 200) return { body: null, status: res.status }
65
+ return { body: Buffer.from(await res.arrayBuffer()), status: res.status }
66
+ }
67
+
68
+ /** The enumerated published routes plus the collections whose query failed. `failed` is non-empty ⇒ the
69
+ * route set is INCOMPLETE, so it must never be used as the authority for what to delete. */
70
+ export interface PublishedRoutes {
71
+ routes: string[]
72
+ /** Names of collections whose route query threw (drifted schema, locked DB) — routes are missing. */
73
+ failed: string[]
74
+ }
75
+
76
+ /** Every published, publicly-readable page-like route (across collections) + the site root — the
77
+ * runtime analogue of the prerender/sitemap route enumeration. noindex pages ARE published (real
78
+ * reachable pages); only the sitemap omits them. */
79
+ export function allPublishedRoutes(): PublishedRoutes {
80
+ const db = useDb()
81
+ const primary = primaryLocale()
82
+ const prefixPrimary = prefixPrimaryLocale()
83
+ const pub = publicReadableResources()
84
+ const routes = new Set<string>([localePath('/', primary, primary, prefixPrimary)]) // `/` or `/<primary>`
85
+ const failed: string[] = []
86
+ for (const c of allCollections()) {
87
+ if (!c.def.pageLike || !isPubliclyReadable(c.def.name, pub)) continue
88
+ // Project ONLY the columns the route rule + status gate need — never SELECT *, which would pull every
89
+ // row's block-content JSON (and every other field) into memory on each full publish just to read `path`.
90
+ // `locale` exists ONLY on a translatable collection (buildTable adds no locale column otherwise); a bare
91
+ // `cols.locale` would be undefined and make the select throw, dropping every one of this NON-translatable
92
+ // collection's live pages (then pruning them). Guard it exactly like findRouteConflict does.
93
+ const cols = getTableColumns(c.table) as Record<string, never>
94
+ const proj: Record<string, unknown> = { path: cols.path }
95
+ if (c.def.translatable) proj.locale = cols.locale
96
+ if (c.def.status) proj.status = cols.status
97
+ let rows: Record<string, unknown>[]
98
+ try { rows = db.select(proj as never).from(c.table).all() as Record<string, unknown>[] }
99
+ catch (error) {
100
+ // Report the gap instead of contributing zero routes: an unreadable collection is NOT an empty one,
101
+ // and a caller that can't tell them apart would treat every live page of it as stale.
102
+ failed.push(c.def.name)
103
+ console.error(`[kestrel] allPublishedRoutes: skipped collection ${c.def.name}:`, (error as Error)?.message ?? error)
104
+ continue
105
+ }
106
+ for (const row of rows) {
107
+ if (c.def.status && row.status !== 'published') continue
108
+ const route = pageRowHref(row, primary, prefixPrimary) // the shared (path, locale) → route rule
109
+ if (route) routes.add(route)
110
+ }
111
+ }
112
+ return { routes: [...routes], failed }
113
+ }
114
+
115
+ /** Render + write the given routes (skips non-200). When `deps` is given, each render is wrapped in a
116
+ * read-capture and its data tags recorded, so a later write maps back to the routes it affects. */
117
+ export async function publishRoutes(routes: string[], driver: StorageDriver, deps?: DepsStore): Promise<string[]> {
118
+ // One UNBUDGETED resolve scope for the whole run: a media/link/relation shared across pages resolves
119
+ // once per publish instead of once per embedding page (the runtime analogue of the generate build's
120
+ // memoDuringPrerender). Memo hits replay their read-tags, so each page's dep capture stays complete.
121
+ return withResolveScope(() => publishRoutesInScope(routes, driver, deps))
122
+ }
123
+
124
+ async function publishRoutesInScope(routes: string[], driver: StorageDriver, deps?: DepsStore): Promise<string[]> {
125
+ const written: string[] = []
126
+ const db = useDb()
127
+ const target = outputConfig().driver
128
+ for (const route of routes) {
129
+ try {
130
+ const { result, tags } = await withReadCapture(() => renderRoute(route))
131
+ const outcome = renderOutcome(result.status, !!result.body)
132
+ if (outcome === 'success') {
133
+ deps?.record(route, tags)
134
+ const key = htmlKeyForRoute(route)
135
+ await driver.put(key, result.body!, contentTypeFor(key), { cacheControl: cacheControlFor(key) })
136
+ written.push(route)
137
+ recordPublishStatus(db, route, { status: 'success', target })
138
+ } else if (outcome === 'error') {
139
+ // The page itself rendered to a server error (5xx) — a non-200 RESPONSE, not a thrown exception, so
140
+ // it never reaches the catch below. Record it so the editor ampel shows the failure; the
141
+ // previously-written file (if any) stays in place.
142
+ const message = `Render failed with HTTP ${result.status}`
143
+ recordPublishStatus(db, route, { status: 'error', error: message, target })
144
+ console.error(`[kestrel] publish failed for ${route}: ${message}`)
145
+ }
146
+ // outcome === 'skip': an expected non-200 (a draft / unpublish race) — leave the status untouched.
147
+ } catch (error) {
148
+ // Render or write (incl. S3 put) THREW for THIS route: record the outcome and carry on so one bad
149
+ // route doesn't abort the whole publish. The failure stays visible in the logs and the editor ampel.
150
+ const message = (error as Error)?.message ?? String(error)
151
+ recordPublishStatus(db, route, { status: 'error', error: message, target })
152
+ console.error(`[kestrel] publish failed for ${route}:`, message)
153
+ }
154
+ }
155
+ return written
156
+ }
157
+
158
+ /** Delete the given routes' static files (idempotent) — unpublish / delete / old-path prune. */
159
+ export async function prunePages(routes: string[], driver: StorageDriver): Promise<void> {
160
+ // pruneEmptyDirs: a page is `<path>/index.html`, so removing it must also clear the now-empty `<path>/`.
161
+ const db = useDb()
162
+ for (const route of routes) {
163
+ await driver.delete(htmlKeyForRoute(route), { pruneEmptyDirs: true })
164
+ clearPublishStatus(db, route) // the file is gone → no live status (after the delete, so a failed delete keeps the row)
165
+ }
166
+ }
167
+
168
+ /** Render + write sitemap.xml, robots.txt and llms.txt (served at literal keys, not `<path>/index.html`). */
169
+ async function publishMeta(driver: StorageDriver): Promise<void> {
170
+ for (const key of ['sitemap.xml', 'robots.txt', 'llms.txt']) {
171
+ const { body } = await renderRoute(`/${key}`)
172
+ if (body) await driver.put(key, body, contentTypeFor(key), { cacheControl: cacheControlFor(key) })
173
+ }
174
+ }
175
+
176
+ /** Mirror the built client bundle + static assets (`_nuxt/**`, favicon, fonts) into the output, but
177
+ * NOT the build's stale `*.html`/sitemap/robots — those are rendered fresh from the live DB. */
178
+ async function syncStaticAssets(driver: StorageDriver, publicDir: string): Promise<void> {
179
+ const walk = async (dir: string, prefix: string): Promise<void> => {
180
+ let entries
181
+ try { entries = await readdir(dir, { withFileTypes: true }) }
182
+ catch (error) {
183
+ // The root directory failing is a misconfig (missing/unreadable output.publicDir), not an empty
184
+ // tree — silently returning here means zero assets get synced while the render below still
185
+ // uploads fresh HTML referencing hashed `_nuxt/*` chunks that were never uploaded. A nested
186
+ // directory disappearing mid-walk is comparatively benign and stays silent.
187
+ if (!prefix) {
188
+ console.error(`[kestrel] output.publicDir "${dir}" is unreadable — publishing HTML without syncing _nuxt/assets: ${(error as Error)?.message ?? error}`)
189
+ }
190
+ return
191
+ }
192
+ const names = entries.map((s) => s.name)
193
+ for (const e of entries) {
194
+ const rel = prefix ? `${prefix}/${e.name}` : e.name
195
+ if (e.isDirectory()) { await walk(resolve(dir, e.name), rel); continue }
196
+ // Strip a precompressed `.br`/`.gz` suffix before the skip test: `index.html.br` is still the
197
+ // build's stale HTML (only content-negotiable, not literally named `*.html`) and must be excluded
198
+ // exactly like its uncompressed sibling, or it ships as a live sidecar for the fresh HTML rendered
199
+ // below — served to any Accept-Encoding-negotiating client instead of the page just published.
200
+ const base = rel.replace(/\.(?:br|gz)$/i, '')
201
+ if (!e.isFile() || base.endsWith('.html') || base === 'sitemap.xml' || base === 'robots.txt' || base === 'llms.txt') continue
202
+ const bytes = await readFile(resolve(dir, e.name))
203
+ // Tag a precompressed sibling (`.br`/`.gz` beside its base) with Content-Encoding so a proxy over S3
204
+ // serves it directly; a standalone archive stays unencoded so browsers don't decode + corrupt it.
205
+ const contentEncoding = precompressedEncoding(e.name, names)
206
+ await driver.put(rel, bytes, contentTypeFor(rel, !!contentEncoding), { cacheControl: cacheControlFor(rel), contentEncoding })
207
+ }
208
+ }
209
+ await walk(publicDir, '')
210
+ }
211
+
212
+ /** Full publish: sync `_nuxt`/assets first (so HTML never references not-yet-uploaded chunks), render
213
+ * every published route + sitemap/robots, and prune routes that left the published set (unpublish /
214
+ * delete / slug change) — tracked via the now-durable deps index, so a page unpublished/deleted while the
215
+ * server was down is pruned on the next boot too. Targeted (only ever deletes routes this publisher wrote),
216
+ * and skipped entirely when the published set could not be enumerated in full.
217
+ *
218
+ * Deliberately NOT single-flighted: each caller brings its own driver and DepsStore, and folding a later
219
+ * caller into a run already in progress would hand it that run's older route snapshot — an edit made
220
+ * after the snapshot would never render and its deps would never be recorded. Serializing overlapping
221
+ * triggers is the publish queue's job. */
222
+ export async function publishFull(driver: StorageDriver = outputDriver(), deps?: DepsStore): Promise<{ rendered: number; pruned: number }> {
223
+ const cfg = outputConfig()
224
+ // Keep the registry read (`allPublishedRoutes` → `allCollections()`) AFTER this first `await`: on a
225
+ // boot publish it lets the synchronous Nitro plugin loop — including collections/01.register, which
226
+ // runs late (layer-then-filename order) — finish populating the registry first. Moving the read before
227
+ // an await would silently render an empty registry. See docs/architecture.md → "Server plugins".
228
+ await syncStaticAssets(driver, cfg.publicDir)
229
+ const { routes, failed } = allPublishedRoutes()
230
+ if (failed.length) {
231
+ // Keep on doubt: with a collection missing from the enumeration, every one of its live pages looks
232
+ // stale, so a prune would wipe it from the output. Rendering still proceeds — a stale extra file is
233
+ // recoverable, a deleted site is not.
234
+ console.error(`[kestrel] publish: prune skipped — routes of ${failed.join(', ')} could not be enumerated; existing files kept`)
235
+ }
236
+
237
+ // Targeted prune: a route we previously published that is no longer in the published set — a page
238
+ // unpublished, deleted, or whose slug changed — must lose its static file. Safe because it only deletes
239
+ // files this publisher wrote (tracked in deps, durable across restarts). Output ≡ DB; no opt-in toggle.
240
+ let pruned = 0
241
+ if (deps && !failed.length) {
242
+ const stale = staleRoutes(deps.routes(), routes)
243
+ if (stale.length) {
244
+ await prunePages(stale, driver)
245
+ for (const route of stale) deps.forget(route)
246
+ pruned = stale.length
247
+ }
248
+ }
249
+
250
+ // Reset the discovery accumulator so this full run reconciles ONLY what it actually renders — an earlier
251
+ // incremental (tag) publish also feeds the accumulator, and a variant it recorded whose usage was later
252
+ // removed would otherwise survive and be re-registered here (defeating usage-driven narrowing).
253
+ clearVariants()
254
+ const written = await publishRoutes(routes, driver, deps)
255
+ const rendered = written.length
256
+ await publishMeta(driver)
257
+ // Auto-discovery: a FULL render just visited every published route, so the capture accumulator now holds
258
+ // the complete set of variants actually used — reconcile it into the media_settings registry (scan entries
259
+ // replaced, manual/pinned kept). A partial (tag) publish must NOT do this: it would drop variants used only
260
+ // by the un-rendered routes. ONLY narrow when EVERY route rendered: a partial failure leaves the accumulator
261
+ // incomplete, so reconciling would deregister variants still referenced by the stale (kept) published HTML,
262
+ // which a later backfill would then delete out from under the live page. An un-enumerated collection is
263
+ // the same partial-coverage case: its pages were never visited, so their variants are missing too.
264
+ if (!failed.length && rendered === routes.length) saveDiscoveredVariants(useDb())
265
+ return { rendered, pruned }
266
+ }
267
+
268
+ /** Dispatch a coalesced invalidation from the queue: `full` → full publish; `tags` → re-render exactly
269
+ * the affected routes (deps-matched ∪ explicit `render`) + prune the `prune` routes + regen sitemap/robots
270
+ * (their `<lastmod>` may have changed). */
271
+ export interface PublishResult { rendered: string[]; pruned: string[]; counts: { rendered: number; pruned: number } }
272
+
273
+ export async function publishInvalidation(inv: Invalidation, driver: StorageDriver = outputDriver(), deps?: DepsStore): Promise<PublishResult> {
274
+ if (inv.type === 'noop') return { rendered: [], pruned: [], counts: { rendered: 0, pruned: 0 } }
275
+ if (inv.type === 'full') {
276
+ const r = await publishFull(driver, deps) // full: counts only (don't list every route)
277
+ return { rendered: [], pruned: [], counts: { rendered: r.rendered, pruned: r.pruned } }
278
+ }
279
+ const routes = [...new Set([...(deps?.routesForTags(inv.tags) ?? []), ...inv.render])]
280
+ const rendered = await publishRoutes(routes, driver, deps)
281
+ let pruned: string[] = []
282
+ // Never prune a route we just wrote live — render wins a coalesced render+prune collision (see routesToPrune).
283
+ const toPrune = routesToPrune(inv.prune, rendered)
284
+ if (toPrune.length) {
285
+ await prunePages(toPrune, driver)
286
+ for (const route of toPrune) deps?.forget(route)
287
+ pruned = toPrune
288
+ }
289
+ await publishMeta(driver)
290
+ return { rendered, pruned, counts: { rendered: rendered.length, pruned: pruned.length } }
291
+ }
@@ -0,0 +1,67 @@
1
+ import type { Invalidation } from './invalidation'
2
+ import { coalesce } from './coalesce'
3
+
4
+ export interface PublishQueueOptions {
5
+ /** Dispatch a coalesced invalidation (full or tags). Rejections are caught + re-queued. */
6
+ run: (inv: Invalidation) => Promise<void>
7
+ /** Quiet window before a pending batch fires (default 3000ms). */
8
+ debounceMs?: number
9
+ /** Cap on how long a steady stream of writes can defer the flush (default 60000ms). */
10
+ maxWaitMs?: number
11
+ onError?: (error: unknown) => void
12
+ }
13
+
14
+ export interface PublishQueue {
15
+ /** Record an invalidation; a coalesced run fires after the quiet window. noop is ignored. */
16
+ enqueue: (inv: Invalidation) => void
17
+ }
18
+
19
+ /**
20
+ * Debounce + coalesce + single-flight queue for publish invalidations. A burst of content writes
21
+ * collapses to ONE run after a quiet window (capped by maxWait); only one run is in flight at a time,
22
+ * and anything enqueued mid-run defers to the next pass — so a publish always reflects the CURRENT DB
23
+ * state and converges (idempotent). A failed run re-queues its batch (never silently drops writes).
24
+ */
25
+ export function createPublishQueue(opts: PublishQueueOptions): PublishQueue {
26
+ const debounceMs = opts.debounceMs ?? 3000
27
+ const maxWaitMs = opts.maxWaitMs ?? 60000
28
+ let pending: Invalidation[] = []
29
+ let timer: ReturnType<typeof setTimeout> | null = null
30
+ let firstAt = 0
31
+ let running = false
32
+
33
+ function arm() {
34
+ if (timer) clearTimeout(timer)
35
+ const now = Date.now()
36
+ if (!firstAt) firstAt = now
37
+ const wait = Math.max(0, Math.min(debounceMs, firstAt + maxWaitMs - now))
38
+ timer = setTimeout(flush, wait)
39
+ }
40
+
41
+ function flush() {
42
+ timer = null
43
+ firstAt = 0
44
+ if (running || pending.length === 0) return // a run is in flight; its finally() re-arms
45
+ const inv = coalesce(pending)
46
+ pending = []
47
+ if (inv.type === 'noop') return
48
+ running = true
49
+ void opts.run(inv)
50
+ .catch((error) => {
51
+ opts.onError?.(error)
52
+ pending.unshift(inv) // never drop a batch on failure — retry on the next pass
53
+ })
54
+ .finally(() => {
55
+ running = false
56
+ if (pending.length) arm()
57
+ })
58
+ }
59
+
60
+ function enqueue(inv: Invalidation) {
61
+ if (inv.type === 'noop') return
62
+ pending.push(inv)
63
+ if (!running) arm() // mid-run enqueues are collected; finally() re-arms once the run settles
64
+ }
65
+
66
+ return { enqueue }
67
+ }
@@ -0,0 +1,16 @@
1
+ import { pageRowHref } from '../../../../core/server/utils/page-route'
2
+
3
+ /**
4
+ * The static route a page-like record publishes to, or null when it has none — a non-pageLike collection,
5
+ * or a row with no `path` (a blank slug, or no row at all). Adds the `pageLike` gate on top of the shared
6
+ * `pageRowHref` rule (the publish-status endpoint's null cases); the locale falls back to the primary for
7
+ * a row without one (non-translatable).
8
+ */
9
+ export function routeForRecord(
10
+ row: { path?: unknown; locale?: unknown } | null | undefined,
11
+ pageLike: boolean,
12
+ primaryLocale: string,
13
+ prefixPrimary: boolean,
14
+ ): string | null {
15
+ return pageLike ? pageRowHref(row, primaryLocale, prefixPrimary) : null
16
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Map a public route to its static HTML file key, mirroring Nitro's prerender layout:
3
+ * `/` → `index.html`, `/about` → `about/index.html`, `/de/x` → `de/x/index.html`.
4
+ * Throws on a route that can't be a safe relative key (traversal `..`, query `?`, fragment `#`,
5
+ * or a backslash) so a bad path can never escape the output dir / S3 prefix.
6
+ */
7
+ export function htmlKeyForRoute(route: string): string {
8
+ if (/[?#\\]/.test(route) || route.split('/').some((seg) => seg === '..')) {
9
+ throw new Error(`unsafe route for a static key: ${JSON.stringify(route)}`)
10
+ }
11
+ const trimmed = route.replace(/^\/+/, '').replace(/\/+$/, '')
12
+ return trimmed === '' ? 'index.html' : `${trimmed}/index.html`
13
+ }
@@ -0,0 +1,32 @@
1
+ import { resolveServerKestrel, serverRuntimeConfig } from '../../../core/server/utils/server-config'
2
+ import { normalizeBase } from './sitemap'
3
+
4
+ /**
5
+ * Absolute site origin for emitted artifacts (sitemap `<loc>`, robots `Sitemap:`).
6
+ * Configured via `kestrel.siteUrl` (or `KESTREL_SITE_URL`); empty when unset, in which case callers
7
+ * fall back to relative paths and omit the robots `Sitemap:` directive.
8
+ */
9
+ export function siteBaseUrl(): string {
10
+ // Prefer the siteUrl the kestrel module resolved from the consumer's `kestrel: {}` (via runtimeConfig);
11
+ // fall back to Kestrel's own config file + env for non-Nitro callers. Mirrors `useDb`'s db-path handling.
12
+ const fromRc = serverRuntimeConfig()?.kestrel as { siteUrl?: string } | undefined
13
+ return normalizeBase(fromRc?.siteUrl ?? resolveServerKestrel().siteUrl)
14
+ }
15
+
16
+ /** Human site name for `llms.txt` — config/env, else the siteUrl host, else 'Website'. */
17
+ export function siteName(): string {
18
+ const fromRc = serverRuntimeConfig()?.kestrel as { siteName?: string } | undefined
19
+ const name = (fromRc?.siteName ?? resolveServerKestrel().siteName)?.trim()
20
+ if (name) return name
21
+ const base = siteBaseUrl()
22
+ if (base) {
23
+ try { return new URL(base).host } catch { /* not a parseable URL — fall through */ }
24
+ }
25
+ return 'Website'
26
+ }
27
+
28
+ /** One-line site description for `llms.txt` (empty when unset). */
29
+ export function siteDescription(): string {
30
+ const fromRc = serverRuntimeConfig()?.kestrel as { siteDescription?: string } | undefined
31
+ return (fromRc?.siteDescription ?? resolveServerKestrel().siteDescription ?? '').trim()
32
+ }
@@ -0,0 +1,97 @@
1
+ export interface SitemapAlternate {
2
+ hreflang: string
3
+ href: string
4
+ }
5
+
6
+ export interface SitemapEntry {
7
+ loc: string
8
+ lastmod?: string
9
+ alternates?: SitemapAlternate[]
10
+ }
11
+
12
+ /** A page-like row reduced to what the sitemap needs, before grouping translations into hreflang sets. */
13
+ export interface SitemapCandidate {
14
+ loc: string
15
+ lastmod?: string
16
+ locale: string
17
+ /** The translation-group key, or null/undefined for rows that have no localized siblings. */
18
+ group?: string | null
19
+ }
20
+
21
+ /** Drop any trailing slashes from a site base URL so `base + path` never doubles up. */
22
+ export function normalizeBase(base: string): string {
23
+ return base.replace(/\/+$/, '')
24
+ }
25
+
26
+ function xmlEscape(value: string): string {
27
+ return value
28
+ .replace(/&/g, '&amp;')
29
+ .replace(/</g, '&lt;')
30
+ .replace(/>/g, '&gt;')
31
+ .replace(/"/g, '&quot;')
32
+ .replace(/'/g, '&apos;')
33
+ }
34
+
35
+ /**
36
+ * Group page-like candidates into `<url>` entries, attaching the hreflang alternate set to every
37
+ * member of a multi-locale translation group. Pure: the route feeds it published+indexable rows only,
38
+ * so alternates never advertise a draft/noindex variant. Order is preserved (the route sorts after).
39
+ * A group of one (or a row with no group) gets no alternates — hreflang is meaningless for a lone page.
40
+ * `x-default` points at the primary-locale variant when that variant is part of the group.
41
+ */
42
+ export function withHreflang(candidates: SitemapCandidate[], primaryLocale: string): SitemapEntry[] {
43
+ const groups = new Map<string, SitemapCandidate[]>()
44
+ for (const c of candidates) {
45
+ if (c.group == null) continue
46
+ const arr = groups.get(c.group)
47
+ if (arr) arr.push(c)
48
+ else groups.set(c.group, [c])
49
+ }
50
+
51
+ return candidates.map((c) => {
52
+ const entry: SitemapEntry = { loc: c.loc }
53
+ if (c.lastmod) entry.lastmod = c.lastmod
54
+ const variants = c.group != null ? groups.get(c.group)! : []
55
+ if (variants.length >= 2) entry.alternates = buildAlternates(variants, primaryLocale)
56
+ return entry
57
+ })
58
+ }
59
+
60
+ /** Sorted-by-locale alternate links for a group, plus an `x-default` to the primary variant if present. */
61
+ function buildAlternates(variants: SitemapCandidate[], primaryLocale: string): SitemapAlternate[] {
62
+ const sorted = [...variants].sort((a, b) => a.locale.localeCompare(b.locale))
63
+ const seen = new Set<string>()
64
+ const alternates: SitemapAlternate[] = []
65
+ for (const v of sorted) {
66
+ if (seen.has(v.locale)) continue
67
+ seen.add(v.locale)
68
+ alternates.push({ hreflang: v.locale, href: v.loc })
69
+ }
70
+ const primary = sorted.find((v) => v.locale === primaryLocale)
71
+ if (primary) alternates.push({ hreflang: 'x-default', href: primary.loc })
72
+ return alternates
73
+ }
74
+
75
+ export function buildSitemap(entries: SitemapEntry[]): string {
76
+ const hasAlternates = entries.some((e) => e.alternates && e.alternates.length > 0)
77
+ const xhtmlNs = hasAlternates ? ' xmlns:xhtml="http://www.w3.org/1999/xhtml"' : ''
78
+ const urls = entries
79
+ .map((e) => {
80
+ const lastmod = e.lastmod ? `<lastmod>${xmlEscape(e.lastmod)}</lastmod>` : ''
81
+ const links = (e.alternates ?? [])
82
+ .map((a) => `<xhtml:link rel="alternate" hreflang="${xmlEscape(a.hreflang)}" href="${xmlEscape(a.href)}"/>`)
83
+ .join('')
84
+ return `<url><loc>${xmlEscape(e.loc)}</loc>${lastmod}${links}</url>`
85
+ })
86
+ .join('')
87
+ return `<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"${xhtmlNs}>${urls}</urlset>`
88
+ }
89
+
90
+ export function buildRobots(opts: { sitemapUrl?: string; llmsUrl?: string } = {}): string {
91
+ const lines = ['User-agent: *', 'Allow: /']
92
+ // A comment (robots.txt has no standard llms directive) so crawlers that read robots also find the
93
+ // agent-oriented site map at /llms.txt. Kept above the blank-line-separated Sitemap directive.
94
+ if (opts.llmsUrl) lines.push(`# llms.txt: ${opts.llmsUrl}`)
95
+ if (opts.sitemapUrl) lines.push('', `Sitemap: ${opts.sitemapUrl}`)
96
+ return `${lines.join('\n')}\n`
97
+ }
@@ -0,0 +1,16 @@
1
+ body {
2
+ font-family: var(--font-sans);
3
+ font-size: var(--text-base);
4
+ color: var(--color-text);
5
+ background: var(--color-bg);
6
+ }
7
+
8
+ a {
9
+ color: var(--color-primary);
10
+ }
11
+
12
+ /* Opt-in reading measure for form-heavy pages inside the now full-bleed admin shell. */
13
+ .u-measure {
14
+ max-width: var(--content-measure);
15
+ margin-inline: auto;
16
+ }
@@ -0,0 +1,82 @@
1
+ @use 'mixins' as *;
2
+
3
+ // Shared chrome for the date pickers. The calendar popover is teleported to <body> by Reka, so these
4
+ // must stay GLOBAL (non-scoped) — a `scoped` block would silently drop them. Both DatePicker and
5
+ // DateRangePicker @include this so neither silently borrows the other's styles (`__sep` is only used
6
+ // by the range variant, the rest by both).
7
+ @mixin ui-datepicker {
8
+ .ui-datepicker {
9
+ &__field {
10
+ display: inline-flex;
11
+ align-items: center;
12
+ gap: 1px;
13
+ padding: var(--space-2) var(--space-3);
14
+ border: 1px solid var(--color-control-border, var(--color-border));
15
+ border-radius: var(--radius-md);
16
+ background: var(--color-surface);
17
+
18
+ &[aria-invalid='true'] { border-color: var(--color-danger); }
19
+ }
20
+ &__segment {
21
+ @include focus-ring;
22
+ padding: 0 1px;
23
+ border-radius: var(--radius-sm);
24
+
25
+ &[data-placeholder] { color: var(--color-text-muted); }
26
+ // The fill alone can't tell a keyboard user which segment the arrow keys will change (reka hides
27
+ // the caret), so the ring has to survive it — only pointer focus drops the outline.
28
+ &:focus { background: var(--color-active); color: var(--color-text); }
29
+ &:focus:not(:focus-visible) { outline: none; }
30
+ }
31
+ &__sep { margin: 0 var(--space-2); color: var(--color-text-muted); }
32
+ &__trigger {
33
+ @include focus-ring;
34
+ margin-inline-start: var(--space-2);
35
+ border: 0;
36
+ background: transparent;
37
+ cursor: pointer;
38
+ }
39
+ &__content {
40
+ background: var(--color-surface);
41
+ border: 1px solid var(--color-border);
42
+ border-radius: var(--radius-md);
43
+ box-shadow: var(--shadow-md);
44
+ padding: var(--space-3);
45
+ z-index: var(--z-dropdown);
46
+ }
47
+ &__header {
48
+ display: flex;
49
+ align-items: center;
50
+ justify-content: space-between;
51
+ margin-bottom: var(--space-2);
52
+ }
53
+ &__nav {
54
+ @include focus-ring;
55
+ border: 0;
56
+ background: transparent;
57
+ cursor: pointer;
58
+ font-size: var(--text-lg);
59
+ padding: 0 var(--space-2);
60
+ }
61
+ &__heading { font-weight: var(--weight-medium); font-size: var(--text-sm); }
62
+ &__weekday { font-size: var(--text-sm); color: var(--color-text-muted); padding: var(--space-1); font-weight: var(--weight-normal); }
63
+ &__cell {
64
+ @include focus-ring;
65
+ display: inline-flex;
66
+ align-items: center;
67
+ justify-content: center;
68
+ width: 2rem;
69
+ height: 2rem;
70
+ border: 0;
71
+ border-radius: var(--radius-sm);
72
+ background: transparent;
73
+ color: var(--color-text);
74
+ cursor: pointer;
75
+
76
+ &[data-today] { font-weight: var(--weight-bold); }
77
+ &[data-selected] { background: var(--color-primary); color: var(--color-on-primary); }
78
+ &[data-outside-view] { color: var(--color-text-muted); }
79
+ &[data-disabled] { opacity: 0.4; cursor: not-allowed; }
80
+ }
81
+ }
82
+ }