@michaelthielemann/kestrel 4.1.3 → 5.0.2

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 (342) hide show
  1. package/README.md +26 -201
  2. package/dist/boot.d.ts +45 -0
  3. package/dist/boot.js +284 -0
  4. package/dist/cast.d.ts +3 -0
  5. package/dist/cast.js +6 -0
  6. package/dist/catalogue.d.ts +11 -0
  7. package/dist/catalogue.js +1 -0
  8. package/dist/cli.d.ts +2 -0
  9. package/dist/cli.js +55 -0
  10. package/dist/context.d.ts +62 -0
  11. package/dist/context.js +61 -0
  12. package/dist/dataflow.d.ts +3 -0
  13. package/dist/dataflow.js +65 -0
  14. package/dist/defineConfig.d.ts +162 -0
  15. package/dist/defineConfig.js +42 -0
  16. package/dist/defineContract.d.ts +12 -0
  17. package/dist/defineContract.js +16 -0
  18. package/dist/defineModule.d.ts +75 -0
  19. package/dist/defineModule.js +9 -0
  20. package/dist/definePipeline.d.ts +7 -0
  21. package/dist/definePipeline.js +11 -0
  22. package/dist/errors.d.ts +26 -0
  23. package/dist/errors.js +48 -0
  24. package/dist/index.d.ts +19 -0
  25. package/dist/index.js +14 -0
  26. package/dist/load.d.ts +7 -0
  27. package/dist/load.js +79 -0
  28. package/dist/logger.d.ts +18 -0
  29. package/dist/logger.js +36 -0
  30. package/dist/registry.d.ts +14 -0
  31. package/dist/registry.js +87 -0
  32. package/dist/result.d.ts +19 -0
  33. package/dist/result.js +19 -0
  34. package/dist/runner.d.ts +29 -0
  35. package/dist/runner.js +133 -0
  36. package/dist/schema.d.ts +7 -0
  37. package/dist/schema.js +142 -0
  38. package/dist/sort.d.ts +2 -0
  39. package/dist/sort.js +50 -0
  40. package/dist/testing/runPipeline.d.ts +9 -0
  41. package/dist/testing/runPipeline.js +22 -0
  42. package/dist/triggers/allowlist.d.ts +9 -0
  43. package/dist/triggers/allowlist.js +49 -0
  44. package/dist/triggers/cron.d.ts +10 -0
  45. package/dist/triggers/cron.js +44 -0
  46. package/dist/triggers/cronMatch.d.ts +10 -0
  47. package/dist/triggers/cronMatch.js +68 -0
  48. package/dist/triggers/http.d.ts +59 -0
  49. package/dist/triggers/http.js +300 -0
  50. package/package.json +25 -151
  51. package/NOTICE +0 -12
  52. package/kestrel.config.ts +0 -28
  53. package/layers/access/nuxt.config.ts +0 -5
  54. package/layers/access/server/middleware/00.ip-allowlist.ts +0 -52
  55. package/layers/access/server/middleware/access-guard.ts +0 -32
  56. package/layers/access/server/types.d.ts +0 -7
  57. package/layers/admin/app/components/AdminAccount.vue +0 -212
  58. package/layers/admin/app/components/AdminNav.vue +0 -44
  59. package/layers/admin/app/components/BlockFields.vue +0 -67
  60. package/layers/admin/app/components/BlockPreview.vue +0 -413
  61. package/layers/admin/app/components/BlockTree.vue +0 -433
  62. package/layers/admin/app/components/BlocksBody.vue +0 -163
  63. package/layers/admin/app/components/CollectionDeleteDialog.vue +0 -38
  64. package/layers/admin/app/components/CollectionEditor.vue +0 -324
  65. package/layers/admin/app/components/CollectionList.vue +0 -429
  66. package/layers/admin/app/components/CollectionListBulkBar.vue +0 -62
  67. package/layers/admin/app/components/CollectionListColumnsPanel.vue +0 -37
  68. package/layers/admin/app/components/CollectionListFilterPanel.vue +0 -145
  69. package/layers/admin/app/components/CollectionListPager.vue +0 -70
  70. package/layers/admin/app/components/CollectionListTable.vue +0 -347
  71. package/layers/admin/app/components/EditorStatus.vue +0 -211
  72. package/layers/admin/app/components/EditorUnsupported.vue +0 -28
  73. package/layers/admin/app/components/FieldsBody.vue +0 -23
  74. package/layers/admin/app/components/LocaleBar.vue +0 -107
  75. package/layers/admin/app/components/PageFields.vue +0 -129
  76. package/layers/admin/app/components/PageFieldsPane.vue +0 -39
  77. package/layers/admin/app/components/SeoFields.vue +0 -210
  78. package/layers/admin/app/components/SingletonEditor.vue +0 -93
  79. package/layers/admin/app/composables/useAuth.ts +0 -68
  80. package/layers/admin/app/composables/useBlockTree.ts +0 -95
  81. package/layers/admin/app/composables/useBlocks.ts +0 -7
  82. package/layers/admin/app/composables/useCachedResource.ts +0 -35
  83. package/layers/admin/app/composables/useCollectionOps.ts +0 -102
  84. package/layers/admin/app/composables/useCollections.ts +0 -6
  85. package/layers/admin/app/composables/useContentLocales.ts +0 -23
  86. package/layers/admin/app/composables/useEditForm.ts +0 -402
  87. package/layers/admin/app/composables/useLinkResolver.ts +0 -40
  88. package/layers/admin/app/composables/useListBatchActions.ts +0 -78
  89. package/layers/admin/app/composables/useListColumns.ts +0 -53
  90. package/layers/admin/app/composables/useListFilterDraft.ts +0 -129
  91. package/layers/admin/app/composables/useListRows.ts +0 -75
  92. package/layers/admin/app/composables/useListSelection.ts +0 -28
  93. package/layers/admin/app/composables/useListUrlState.ts +0 -53
  94. package/layers/admin/app/composables/usePendingPublishPoll.ts +0 -15
  95. package/layers/admin/app/composables/usePreviewViewport.ts +0 -30
  96. package/layers/admin/app/composables/usePublishStatus.ts +0 -107
  97. package/layers/admin/app/composables/useRailCollapsed.ts +0 -14
  98. package/layers/admin/app/composables/useTheme.ts +0 -36
  99. package/layers/admin/app/composables/useToolbarPanel.ts +0 -38
  100. package/layers/admin/app/composables/useUnsavedGuard.ts +0 -22
  101. package/layers/admin/app/layouts/admin.vue +0 -266
  102. package/layers/admin/app/middleware/admin-auth.ts +0 -4
  103. package/layers/admin/app/pages/admin/[collection]/[id].vue +0 -203
  104. package/layers/admin/app/pages/admin/[collection]/index.vue +0 -62
  105. package/layers/admin/app/pages/admin/index.vue +0 -118
  106. package/layers/admin/app/pages/admin/login.vue +0 -68
  107. package/layers/admin/app/pages/admin/media.vue +0 -28
  108. package/layers/admin/app/pages/admin/references.vue +0 -146
  109. package/layers/admin/app/plugins/admin-style-guard.client.ts +0 -76
  110. package/layers/admin/app/plugins/reauth.client.ts +0 -20
  111. package/layers/admin/app/utils/admin-style-guard.ts +0 -20
  112. package/layers/admin/app/utils/block-tree.ts +0 -214
  113. package/layers/admin/app/utils/collection-ops.ts +0 -69
  114. package/layers/admin/app/utils/dead-refs.ts +0 -30
  115. package/layers/admin/app/utils/edit-form.ts +0 -267
  116. package/layers/admin/app/utils/editor-expose.ts +0 -36
  117. package/layers/admin/app/utils/editor-form-context.ts +0 -82
  118. package/layers/admin/app/utils/editor-registry.ts +0 -20
  119. package/layers/admin/app/utils/field-keys.ts +0 -11
  120. package/layers/admin/app/utils/list-cell.ts +0 -46
  121. package/layers/admin/app/utils/list-columns.ts +0 -142
  122. package/layers/admin/app/utils/list-query.ts +0 -96
  123. package/layers/admin/app/utils/nav-active.ts +0 -9
  124. package/layers/admin/app/utils/populate-blocks.ts +0 -288
  125. package/layers/admin/app/utils/preview-channel.ts +0 -62
  126. package/layers/admin/app/utils/preview-viewport.ts +0 -80
  127. package/layers/admin/app/utils/reauth.ts +0 -67
  128. package/layers/admin/app/utils/record-title.ts +0 -15
  129. package/layers/admin/app/utils/register-builtin-editors.ts +0 -9
  130. package/layers/admin/app/utils/safe-redirect.ts +0 -12
  131. package/layers/admin/nuxt.config.ts +0 -20
  132. package/layers/auth/nuxt.config.ts +0 -1
  133. package/layers/auth/server/plugins/01.register-auth-pipelines.ts +0 -7
  134. package/layers/collections/nuxt.config.ts +0 -1
  135. package/layers/collections/server/plugins/01.register.ts +0 -29
  136. package/layers/collections/server/plugins/02.register-relation-populate.ts +0 -18
  137. package/layers/core/app/composables/layouts.ts +0 -5
  138. package/layers/core/app/utils/field-factories.ts +0 -129
  139. package/layers/core/app/utils/layouts.ts +0 -38
  140. package/layers/core/modules/auto-discovery/extract-block.ts +0 -179
  141. package/layers/core/modules/auto-discovery/index.ts +0 -112
  142. package/layers/core/modules/auto-discovery/package-registry.ts +0 -19
  143. package/layers/core/modules/auto-discovery/scan.ts +0 -170
  144. package/layers/core/modules/auto-discovery/virtual.d.ts +0 -20
  145. package/layers/core/modules/component-namespace/index.ts +0 -44
  146. package/layers/core/modules/component-namespace/shared.ts +0 -24
  147. package/layers/core/modules/kestrel/app-shell.ts +0 -55
  148. package/layers/core/modules/kestrel/auto-imports.ts +0 -44
  149. package/layers/core/modules/kestrel/index.ts +0 -144
  150. package/layers/core/modules/plugin-order/index.ts +0 -69
  151. package/layers/core/modules/plugin-order/plugin-order.ts +0 -273
  152. package/layers/core/nuxt.config.ts +0 -10
  153. package/layers/core/server/api/[...path].ts +0 -98
  154. package/layers/core/server/plugins/00.config.ts +0 -12
  155. package/layers/core/server/plugins/00.migrate.ts +0 -12
  156. package/layers/core/server/plugins/01.register-introspection-pipeline.ts +0 -5
  157. package/layers/core/server/plugins/01.register-openapi-pipeline.ts +0 -5
  158. package/layers/core/server/plugins/01.register-tooling-pipelines.ts +0 -5
  159. package/layers/core/server/plugins/02.schema-sync.ts +0 -58
  160. package/layers/core/server/plugins/04.outbox-worker.ts +0 -31
  161. package/layers/core/server/plugins/05.reindex-refs.ts +0 -5
  162. package/layers/core/server/routes/__kestrel/dashboard.get.ts +0 -29
  163. package/layers/core/server/tasks/db/migrate-module.ts +0 -83
  164. package/layers/core/server/tasks/db/migrate-revisions.ts +0 -20
  165. package/layers/core/server/tasks/db/migrate.ts +0 -50
  166. package/layers/core/server/utils/server-config.ts +0 -81
  167. package/layers/fields/nuxt.config.ts +0 -1
  168. package/layers/fields/server/plugins/01.register-blocks-pipeline.ts +0 -7
  169. package/layers/fields/server/plugins/01.register-field-populate.ts +0 -11
  170. package/layers/media/app/components/KestrelImg.vue +0 -94
  171. package/layers/media/app/components/MediaConflictDialog.vue +0 -35
  172. package/layers/media/app/components/MediaDeleteDialog.vue +0 -52
  173. package/layers/media/app/components/MediaGrid.vue +0 -113
  174. package/layers/media/app/components/MediaImage.vue +0 -70
  175. package/layers/media/app/components/MediaLibrary.vue +0 -456
  176. package/layers/media/app/components/MediaNewFolderDialog.vue +0 -32
  177. package/layers/media/app/components/MediaPathBar.vue +0 -163
  178. package/layers/media/app/components/MediaPicker.vue +0 -19
  179. package/layers/media/app/components/MediaRenameDialog.vue +0 -32
  180. package/layers/media/app/components/MediaTable.vue +0 -75
  181. package/layers/media/app/components/MediaThumb.vue +0 -25
  182. package/layers/media/app/components/MediaToolbar.vue +0 -90
  183. package/layers/media/app/components/MediaUploadDialog.vue +0 -87
  184. package/layers/media/app/components/MediaViewer.vue +0 -137
  185. package/layers/media/app/components/field/Media.vue +0 -263
  186. package/layers/media/app/composables/useBlurUp.ts +0 -55
  187. package/layers/media/app/composables/useMediaClipboard.ts +0 -14
  188. package/layers/media/app/composables/useMediaContextMenu.ts +0 -56
  189. package/layers/media/app/composables/useMediaDnd.ts +0 -56
  190. package/layers/media/app/composables/useMediaLibrary.ts +0 -152
  191. package/layers/media/app/composables/useMediaOps.ts +0 -50
  192. package/layers/media/app/composables/useMediaRelocate.ts +0 -39
  193. package/layers/media/app/composables/useMediaResolver.ts +0 -50
  194. package/layers/media/app/composables/useMediaUpload.ts +0 -93
  195. package/layers/media/app/composables/useMediaVariant.ts +0 -34
  196. package/layers/media/app/plugins/field-media.client.ts +0 -6
  197. package/layers/media/app/utils/ai-disclosure.ts +0 -19
  198. package/layers/media/app/utils/dnd.ts +0 -69
  199. package/layers/media/app/utils/library.ts +0 -81
  200. package/layers/media/app/utils/ops.ts +0 -60
  201. package/layers/media/app/utils/picture-model.ts +0 -125
  202. package/layers/media/app/utils/thumbhash.ts +0 -19
  203. package/layers/media/app/utils/variant-name.ts +0 -20
  204. package/layers/media/nuxt.config.ts +0 -7
  205. package/layers/media/server/middleware/ondemand-variants.ts +0 -38
  206. package/layers/media/server/plugins/01.register-media-pipelines.ts +0 -7
  207. package/layers/media/server/plugins/02.register-media.ts +0 -22
  208. package/layers/media/server/plugins/04.variant-capture.ts +0 -27
  209. package/layers/media/server/plugins/05.media-cleanup.ts +0 -5
  210. package/layers/media/server/tasks/media/backfill.ts +0 -17
  211. package/layers/public/app/app.vue +0 -5
  212. package/layers/public/app/components/BlockRenderer.vue +0 -56
  213. package/layers/public/app/components/KestrelLink.vue +0 -26
  214. package/layers/public/app/components/KestrelPreviewBridge.vue +0 -133
  215. package/layers/public/app/composables/public-page.ts +0 -24
  216. package/layers/public/app/error.vue +0 -12
  217. package/layers/public/app/layouts/default.vue +0 -5
  218. package/layers/public/app/pages/[...slug].vue +0 -239
  219. package/layers/public/app/pages/__kestrel/preview.vue +0 -46
  220. package/layers/public/app/utils/block-edit-context.ts +0 -16
  221. package/layers/public/app/utils/json-ld.ts +0 -139
  222. package/layers/public/app/utils/link-href.ts +0 -46
  223. package/layers/public/app/utils/page-head.ts +0 -84
  224. package/layers/public/app/utils/page-layout.ts +0 -18
  225. package/layers/public/app/utils/preview-protocol.ts +0 -117
  226. package/layers/public/app/utils/public-route.ts +0 -46
  227. package/layers/public/app/utils/site-head.ts +0 -43
  228. package/layers/public/modules/deploy-output/index.ts +0 -80
  229. package/layers/public/modules/prerender-routes/discover.ts +0 -37
  230. package/layers/public/modules/prerender-routes/index.ts +0 -88
  231. package/layers/public/modules/prerender-routes/routes.ts +0 -16
  232. package/layers/public/modules/prune-media/index.ts +0 -48
  233. package/layers/public/modules/prune-media/prune-media.ts +0 -81
  234. package/layers/public/nuxt.config.ts +0 -14
  235. package/layers/public/server/delivery-live/serve.ts +0 -111
  236. package/layers/public/server/middleware/delivery-live-catchall.ts +0 -7
  237. package/layers/public/server/pipelines/index.ts +0 -23
  238. package/layers/public/server/plugins/00.ensure-snapshot-triggers.ts +0 -14
  239. package/layers/public/server/plugins/01.register-public-pipelines.ts +0 -7
  240. package/layers/public/server/plugins/02.register-links.ts +0 -16
  241. package/layers/public/server/plugins/03.redirects.ts +0 -55
  242. package/layers/public/server/plugins/05.plan-publish.ts +0 -5
  243. package/layers/public/server/plugins/zz.publish.ts +0 -127
  244. package/layers/public/server/routes/llms-full.txt.get.ts +0 -99
  245. package/layers/public/server/routes/llms.txt.get.ts +0 -66
  246. package/layers/public/server/routes/redirects.json.get.ts +0 -58
  247. package/layers/public/server/routes/robots.txt.get.ts +0 -11
  248. package/layers/public/server/routes/sitemap.xml.get.ts +0 -79
  249. package/layers/public/server/tasks/publish/run.ts +0 -32
  250. package/layers/public/server/utils/publish/render-live.ts +0 -26
  251. package/layers/ui/app/assets/scss/_base.scss +0 -16
  252. package/layers/ui/app/assets/scss/_datepicker.scss +0 -82
  253. package/layers/ui/app/assets/scss/_mixins.scss +0 -50
  254. package/layers/ui/app/assets/scss/_reset.scss +0 -65
  255. package/layers/ui/app/assets/scss/_tokens.scss +0 -183
  256. package/layers/ui/app/assets/scss/main.scss +0 -4
  257. package/layers/ui/app/components/field/Boolean.vue +0 -30
  258. package/layers/ui/app/components/field/Choice.vue +0 -73
  259. package/layers/ui/app/components/field/Datetime.vue +0 -98
  260. package/layers/ui/app/components/field/DeadRefNote.vue +0 -28
  261. package/layers/ui/app/components/field/Json.vue +0 -62
  262. package/layers/ui/app/components/field/Layout.vue +0 -138
  263. package/layers/ui/app/components/field/Link.vue +0 -112
  264. package/layers/ui/app/components/field/LinkInternalPicker.vue +0 -95
  265. package/layers/ui/app/components/field/Number.vue +0 -30
  266. package/layers/ui/app/components/field/Relation.vue +0 -49
  267. package/layers/ui/app/components/field/Renderer.vue +0 -30
  268. package/layers/ui/app/components/field/Repeater.vue +0 -460
  269. package/layers/ui/app/components/field/Richtext.vue +0 -26
  270. package/layers/ui/app/components/field/Slug.vue +0 -65
  271. package/layers/ui/app/components/field/Text.vue +0 -35
  272. package/layers/ui/app/components/field/Unsupported.vue +0 -34
  273. package/layers/ui/app/components/ui/Alert.vue +0 -50
  274. package/layers/ui/app/components/ui/Brand.vue +0 -22
  275. package/layers/ui/app/components/ui/Button.vue +0 -116
  276. package/layers/ui/app/components/ui/ButtonGroup.vue +0 -96
  277. package/layers/ui/app/components/ui/Checkbox.vue +0 -53
  278. package/layers/ui/app/components/ui/CheckboxGroup.vue +0 -51
  279. package/layers/ui/app/components/ui/Combobox.vue +0 -345
  280. package/layers/ui/app/components/ui/DatePicker.vue +0 -92
  281. package/layers/ui/app/components/ui/DateRangePicker.vue +0 -100
  282. package/layers/ui/app/components/ui/Dialog.vue +0 -107
  283. package/layers/ui/app/components/ui/EmptyState.vue +0 -46
  284. package/layers/ui/app/components/ui/Field.vue +0 -58
  285. package/layers/ui/app/components/ui/Fieldset.vue +0 -65
  286. package/layers/ui/app/components/ui/Icon.vue +0 -44
  287. package/layers/ui/app/components/ui/Menu.vue +0 -54
  288. package/layers/ui/app/components/ui/NumberInput.vue +0 -175
  289. package/layers/ui/app/components/ui/Richtext.vue +0 -141
  290. package/layers/ui/app/components/ui/RichtextToolbar.vue +0 -251
  291. package/layers/ui/app/components/ui/Select.vue +0 -52
  292. package/layers/ui/app/components/ui/TextInput.vue +0 -111
  293. package/layers/ui/app/components/ui/Textarea.vue +0 -39
  294. package/layers/ui/app/components/ui/TimeInput.vue +0 -18
  295. package/layers/ui/app/components/ui/Toasts.vue +0 -103
  296. package/layers/ui/app/components/ui/Tooltip.vue +0 -45
  297. package/layers/ui/app/composables/useAdminLang.ts +0 -8
  298. package/layers/ui/app/composables/useDragReorder.ts +0 -41
  299. package/layers/ui/app/composables/useEchoGuard.ts +0 -28
  300. package/layers/ui/app/composables/useLinkField.ts +0 -70
  301. package/layers/ui/app/composables/useRecordOptions.ts +0 -84
  302. package/layers/ui/app/composables/useRepeater.ts +0 -99
  303. package/layers/ui/app/composables/useT.ts +0 -32
  304. package/layers/ui/app/composables/useToast.ts +0 -46
  305. package/layers/ui/app/i18n/de.ts +0 -432
  306. package/layers/ui/app/i18n/en.ts +0 -434
  307. package/layers/ui/app/utils/boolean-field.ts +0 -10
  308. package/layers/ui/app/utils/field-component.ts +0 -22
  309. package/layers/ui/app/utils/field-empty.ts +0 -21
  310. package/layers/ui/app/utils/field-registry.ts +0 -36
  311. package/layers/ui/app/utils/humanize.ts +0 -14
  312. package/layers/ui/app/utils/icons.ts +0 -143
  313. package/layers/ui/app/utils/iso-date.ts +0 -18
  314. package/layers/ui/app/utils/localized.ts +0 -13
  315. package/layers/ui/app/utils/record-context.ts +0 -15
  316. package/layers/ui/app/utils/reorder.ts +0 -8
  317. package/layers/ui/app/utils/richtext-preserve-class.ts +0 -31
  318. package/layers/ui/app/utils/useFieldA11y.ts +0 -40
  319. package/layers/ui/nuxt.config.ts +0 -9
  320. package/nuxt.config.ts +0 -108
  321. package/scripts/consumer-template-ci.mjs +0 -160
  322. package/scripts/copy-create-payload.mjs +0 -49
  323. package/scripts/coverage-all.mjs +0 -163
  324. package/scripts/dashboard.mjs +0 -89
  325. package/scripts/hash-password.mjs +0 -29
  326. package/scripts/kestrel.mjs +0 -240
  327. package/scripts/lib/cli.mjs +0 -129
  328. package/scripts/lib/coverage-model.mjs +0 -51
  329. package/scripts/lib/password.mjs +0 -19
  330. package/scripts/lib/scaffold.mjs +0 -205
  331. package/scripts/port-graph.mjs +0 -350
  332. package/scripts/typecheck.mjs +0 -62
  333. package/templates/starter/README.md +0 -65
  334. package/templates/starter/_env.example +0 -17
  335. package/templates/starter/_gitignore +0 -26
  336. package/templates/starter/_package.json +0 -22
  337. package/templates/starter/app/app.vue +0 -7
  338. package/templates/starter/app/blocks/Prose.vue +0 -13
  339. package/templates/starter/app/layouts/default.vue +0 -6
  340. package/templates/starter/nuxt.config.ts +0 -23
  341. package/templates/starter/pnpm-workspace.yaml +0 -8
  342. package/templates/starter/tsconfig.json +0 -3
@@ -1,96 +0,0 @@
1
- import { FILTER_RE, clampPerPage, isFilterOp, opAllowed } from '@michaelthielemann/kestrel-core/client'
2
- import type { FilterOp } from '@michaelthielemann/kestrel-core/client'
3
- import type { ListColumn } from './list-columns'
4
-
5
- // The page-size bounds live in ONE shared module (core/app/utils/list-limits) so the client list, the
6
- // per-page picker and the server crud cap can never drift; `toQuery` clamps through it below.
7
-
8
- /** One active filter clause in the admin UI: the chosen operator + its (string) value. Kept per-column
9
- * (`Record<field, FilterCell>`) — the per-column panel shows one clause per column. */
10
- export interface FilterCell {
11
- op: FilterOp
12
- value: string
13
- }
14
-
15
- export interface ListState {
16
- sort: string | null
17
- page: number
18
- perPage: number
19
- filter: Record<string, FilterCell>
20
- }
21
-
22
- /** UI state -> the `$fetch` query object the list endpoint expects. An `eq` clause stays the bare
23
- * `filter[field]` key (so existing URLs round-trip); any other operator serializes as `filter[field][op]`.
24
- * Empty-valued clauses are dropped. `perPage` is clamped. */
25
- export function toQuery(state: ListState): Record<string, string | number> {
26
- const q: Record<string, string | number> = {
27
- page: Math.max(1, Math.floor(state.page)),
28
- perPage: clampPerPage(state.perPage),
29
- }
30
- if (state.sort) q.sort = state.sort
31
- for (const [field, cell] of Object.entries(state.filter)) {
32
- if (cell.value === '' || cell.value == null) continue
33
- q[cell.op === 'eq' ? `filter[${field}]` : `filter[${field}][${cell.op}]`] = cell.value
34
- }
35
- return q
36
- }
37
-
38
- /** Pick the first usable scalar from a URL query value. Real `route.query` values are strings (or arrays of
39
- * strings for a repeated key — we take the first, so `filter[x]=a&filter[x]=b` keeps the FIRST clause); a
40
- * round-trip through `toQuery` may hand back a number (page/perPage), so coerce that too. Anything else
41
- * (null / object) yields `undefined`. */
42
- function firstStr(v: unknown): string | undefined {
43
- const s = Array.isArray(v) ? v[0] : v
44
- if (typeof s === 'string') return s
45
- if (typeof s === 'number') return String(s)
46
- return undefined
47
- }
48
-
49
- /** The pure inverse of `toQuery`: read committed list state back OUT of a URL query. It NEVER throws — every
50
- * invalid or hostile key is DROPPED so the caller's defaults apply (a junk `page`/`perPage`, an unknown or
51
- * non-sortable `sort` field, an unknown/unfilterable filter column, or an operator not allowed for that
52
- * column's kind). A repeated `filter[field]` keeps the FIRST clause (the admin model holds one clause per
53
- * column). Absent/invalid keys are OMITTED, so `parseListQuery(toQuery(state), columns)` deep-equals the
54
- * normalized `state`. */
55
- export function parseListQuery(query: Record<string, unknown>, columns: ListColumn[]): Partial<ListState> {
56
- const out: Partial<ListState> = {}
57
-
58
- const p = firstStr(query.page)
59
- if (p != null) { const n = Number(p); if (Number.isFinite(n) && n >= 1) out.page = Math.floor(n) }
60
-
61
- const pp = firstStr(query.perPage)
62
- if (pp) { const n = Number(pp); if (Number.isFinite(n)) out.perPage = clampPerPage(n) } // garbage → omit → cookie
63
-
64
- const s = firstStr(query.sort)
65
- if (s) { const base = s.startsWith('-') ? s.slice(1) : s; if (columns.some((c) => c.key === base && c.sortable)) out.sort = s } // unknown/non-sortable → omit → default
66
-
67
- const filter: Record<string, FilterCell> = {}
68
- for (const [k, raw] of Object.entries(query)) {
69
- const m = FILTER_RE.exec(k)
70
- if (!m) continue
71
- const field = m[1]!
72
- if (field in filter) continue // one clause per column: the first key for this field wins
73
- const op = m[2] ?? 'eq'
74
- const value = firstStr(raw)
75
- if (value == null || value === '') continue // empty value → drop
76
- if (!isFilterOp(op)) continue // unknown operator token → drop
77
- const col = columns.find((c) => c.key === field && c.filterable)
78
- if (!col) continue // unknown / unfilterable column → drop
79
- if (!opAllowed(col.filterKind ?? 'text', op)) continue // op disallowed for the column kind → drop
80
- filter[field] = { op, value }
81
- }
82
- if (Object.keys(filter).length) out.filter = filter
83
-
84
- return out
85
- }
86
-
87
- /** Toggle a column between ascending (`field`) and descending (`-field`); a new field starts ascending. */
88
- export function toggleSort(current: string | null, field: string): string {
89
- return current === field ? `-${field}` : field
90
- }
91
-
92
- export function sortDirection(current: string | null, field: string): 'asc' | 'desc' | null {
93
- if (current === field) return 'asc'
94
- if (current === `-${field}`) return 'desc'
95
- return null
96
- }
@@ -1,9 +0,0 @@
1
- /**
2
- * Whether a rail nav item linking to `base` (e.g. "/admin/posts") should read as active for the
3
- * current route `path`. Inclusive of descendant routes so a collection stays lit while editing one
4
- * of its records (/admin/posts/1) — Vue Router's record-based active match does not, because the
5
- * list and record pages are flat sibling routes that share no matched record.
6
- */
7
- export function isNavItemActive(path: string, base: string): boolean {
8
- return path === base || path.startsWith(`${base}/`)
9
- }
@@ -1,288 +0,0 @@
1
- import type { SerializedBlock, SerializedField } from '@michaelthielemann/kestrel-core'
2
- import type { ResolvedMedia } from '../../../media/app/composables/useMediaResolver'
3
- import { collectRichtextRefs, resolveRichtextLinks } from '@michaelthielemann/kestrel-core/client'
4
-
5
- // Single source of truth for the resolved-media shape lives in the media layer (admin → media).
6
- export type { ResolvedMedia }
7
-
8
- interface BlockNode {
9
- id?: string
10
- type: string
11
- props?: Record<string, unknown>
12
- [k: string]: unknown
13
- }
14
-
15
- /** The sub-field schema of a `repeater` SerializedField, or null. Serialized as `options.fields` (a
16
- * name→SerializedField map, recursively) by `serializeField`. */
17
- function repeaterFields(field: SerializedField): Record<string, SerializedField> | null {
18
- if (field.type !== 'repeater') return null
19
- const sub = (field.options as { fields?: Record<string, SerializedField> } | undefined)?.fields
20
- return sub && typeof sub === 'object' ? sub : null
21
- }
22
-
23
- /** A block's `slots` bag from the data (a name→child-array map), or null if it has none. */
24
- function slotsOf(node: BlockNode): Record<string, unknown> | null {
25
- const s = (node as { slots?: unknown }).slots
26
- return s && typeof s === 'object' && !Array.isArray(s) ? (s as Record<string, unknown>) : null
27
- }
28
-
29
- /**
30
- * Rebuild a block's `slots` bag, recursing each child array through `recurse`. Identity-preserving: a
31
- * slot whose populated children are each `===` the originals keeps the original array ref, and when NO
32
- * slot changed the whole bag is reported unchanged (`null`) — so the live preview never churns block
33
- * identities on an unaffected subtree. The `b === arr[i]` short-circuit is load-bearing for reactivity.
34
- */
35
- function mapSlots(
36
- slots: Record<string, unknown> | null,
37
- recurse: (arr: unknown[]) => BlockNode[],
38
- ): Record<string, unknown> | null {
39
- if (!slots) return null
40
- const rebuilt: Record<string, unknown> = {}
41
- let changed = false
42
- for (const [name, arr] of Object.entries(slots)) {
43
- if (!Array.isArray(arr)) {
44
- rebuilt[name] = arr
45
- continue
46
- }
47
- const populated = recurse(arr)
48
- const same = populated.length === arr.length && populated.every((b, i) => b === arr[i])
49
- rebuilt[name] = same ? arr : populated
50
- if (!same) changed = true
51
- }
52
- return changed ? rebuilt : null
53
- }
54
-
55
- export interface LinkRef { collection: string; id: number }
56
-
57
- /** The `{collection, id}` of an internal LinkValue, or null for external/email/tel/empty. */
58
- function internalRefOf(value: unknown): LinkRef | null {
59
- if (!value || typeof value !== 'object') return null
60
- const v = value as Record<string, unknown>
61
- return v.type === 'internal' && typeof v.collection === 'string' && typeof v.id === 'number'
62
- ? { collection: v.collection, id: v.id }
63
- : null
64
- }
65
-
66
- // ─────────────────────────────────────────────────────────────────────────────────────────────────
67
- // Field-bag recursion — the client mirror of the server field-tree walker
68
- // (`layers/fields/server/utils/field-populate.ts`). A "bag" is a block's props OR a repeater entry;
69
- // both key media/link/richtext values BARE (props key-mode). Every collector/populator descends
70
- // `repeater` sub-fields so a repeater-nested media/link resolves in the preview exactly as it does in
71
- // the generated page — the two must stay in lock-step.
72
- // ─────────────────────────────────────────────────────────────────────────────────────────────────
73
-
74
- /** Media ids referenced by a value-bag (a block's props or a repeater entry), recursing repeater entries. */
75
- function collectMediaFromBag(fields: Record<string, SerializedField>, bag: Record<string, unknown>, ids: Set<number>): void {
76
- for (const [key, field] of Object.entries(fields)) {
77
- if (field.type === 'media') {
78
- const v = bag[key]
79
- if (field.options?.multiple) {
80
- if (Array.isArray(v)) for (const x of v) if (typeof x === 'number') ids.add(x)
81
- } else if (typeof v === 'number') {
82
- ids.add(v)
83
- }
84
- } else {
85
- const sub = repeaterFields(field)
86
- const entries = sub ? bag[key] : null
87
- if (sub && Array.isArray(entries)) {
88
- for (const e of entries) if (e && typeof e === 'object') collectMediaFromBag(sub, e as Record<string, unknown>, ids)
89
- }
90
- }
91
- }
92
- }
93
-
94
- /** Every media id referenced by block props (incl. repeater entries), deduped and walked into slot
95
- * children — for one batched resolve call. */
96
- export function collectMediaIds(blocks: unknown, byType: Record<string, SerializedBlock>): number[] {
97
- const ids = new Set<number>()
98
- const walk = (nodes: unknown): void => {
99
- if (!Array.isArray(nodes)) return
100
- for (const node of nodes as BlockNode[]) {
101
- const def = node?.type ? byType[node.type] : undefined
102
- if (def && node.props) collectMediaFromBag(def.fields, node.props, ids)
103
- const slots = node ? slotsOf(node) : null
104
- if (slots) for (const arr of Object.values(slots)) walk(arr)
105
- }
106
- }
107
- walk(blocks)
108
- return [...ids]
109
- }
110
-
111
- /**
112
- * Populate media in a value-bag: attach a `$media` sibling for its direct media fields and recurse each
113
- * repeater entry. Identity-preserving — returns the SAME bag ref when nothing resolved and reuses each
114
- * unchanged repeater array/entry — so the preview doesn't churn identities on every recompute.
115
- */
116
- function populateMediaInBag(
117
- fields: Record<string, SerializedField>,
118
- bag: Record<string, unknown>,
119
- resolve: (id: number) => ResolvedMedia | null,
120
- ): Record<string, unknown> {
121
- let media: Record<string, unknown> | null = null
122
- let repeaters: Record<string, unknown> | null = null
123
- for (const [key, field] of Object.entries(fields)) {
124
- if (field.type === 'media') {
125
- const v = bag[key]
126
- if (field.options?.multiple) {
127
- if (Array.isArray(v)) {
128
- const resolved = v.filter((x): x is number => typeof x === 'number').map(resolve).filter((m): m is ResolvedMedia => !!m)
129
- if (resolved.length) (media ??= {})[key] = resolved
130
- }
131
- } else if (typeof v === 'number') {
132
- const m = resolve(v)
133
- if (m) (media ??= {})[key] = m
134
- }
135
- } else {
136
- const sub = repeaterFields(field)
137
- const entries = sub ? bag[key] : null
138
- if (sub && Array.isArray(entries)) {
139
- const populated = entries.map((e) => (e && typeof e === 'object' ? populateMediaInBag(sub, e as Record<string, unknown>, resolve) : e))
140
- if (!populated.every((e, i) => e === entries[i])) (repeaters ??= {})[key] = populated
141
- }
142
- }
143
- }
144
- if (!media && !repeaters) return bag
145
- const out = { ...bag }
146
- if (repeaters) Object.assign(out, repeaters)
147
- if (media) out.$media = media
148
- return out
149
- }
150
-
151
- /**
152
- * Client mirror of the server media populator: attach a `$media` bag to each block's props (and each
153
- * repeater entry) so the public `BlockRenderer` and `Blocks*` display components render images in the
154
- * live preview. Pure; `resolve` is the (reactive) id→media lookup. Recurses slot children AND repeater
155
- * entries. A block whose whole subtree needs no media (media-free, unknown type, empty/unchanged slots,
156
- * or not-yet-resolved ids) is returned **by reference** so unchanged branches keep their refs all the
157
- * way up.
158
- */
159
- export function populateBlocksMedia(
160
- blocks: unknown,
161
- byType: Record<string, SerializedBlock>,
162
- resolve: (id: number) => ResolvedMedia | null,
163
- ): BlockNode[] {
164
- if (!Array.isArray(blocks)) return []
165
- return (blocks as BlockNode[]).map((node) => {
166
- const def = node?.type ? byType[node.type] : undefined
167
- const nextProps = def && node.props ? populateMediaInBag(def.fields, node.props, resolve) : node.props
168
-
169
- // Rebuild slots only where a child subtree actually changed; otherwise reuse the original array
170
- // ref so an empty or media-free slot doesn't force a new parent object.
171
- const nextSlots = mapSlots(node ? slotsOf(node) : null, (arr) => populateBlocksMedia(arr, byType, resolve))
172
-
173
- if (nextProps === node.props && !nextSlots) return node
174
- const out: BlockNode = { ...node }
175
- if (nextProps !== node.props) out.props = nextProps
176
- if (nextSlots) out.slots = nextSlots
177
- return out
178
- })
179
- }
180
-
181
- /** Internal-link + richtext-marker refs in a value-bag (a block's props or a repeater entry), recursing
182
- * repeater entries. */
183
- function collectLinkRefsFromBag(fields: Record<string, SerializedField>, bag: Record<string, unknown>, add: (ref: LinkRef) => void): void {
184
- for (const [key, field] of Object.entries(fields)) {
185
- if (field.type === 'link') {
186
- const ref = internalRefOf(bag[key])
187
- if (ref) add(ref)
188
- } else if (field.type === 'richtext') {
189
- for (const ref of collectRichtextRefs(bag[key] as string | undefined)) add(ref)
190
- } else {
191
- const sub = repeaterFields(field)
192
- const entries = sub ? bag[key] : null
193
- if (sub && Array.isArray(entries)) {
194
- for (const e of entries) if (e && typeof e === 'object') collectLinkRefsFromBag(sub, e as Record<string, unknown>, add)
195
- }
196
- }
197
- }
198
- }
199
-
200
- /** Every internal-link `{collection,id}` referenced by block props (incl. repeater entries + richtext
201
- * markers), deduped, walked into slots — for one batched resolve call (mirror of `collectMediaIds`). */
202
- export function collectLinkRefs(blocks: unknown, byType: Record<string, SerializedBlock>): LinkRef[] {
203
- const seen = new Set<string>()
204
- const refs: LinkRef[] = []
205
- const add = (ref: LinkRef): void => {
206
- const k = `${ref.collection}:${ref.id}`
207
- if (!seen.has(k)) {
208
- seen.add(k)
209
- refs.push(ref)
210
- }
211
- }
212
- const walk = (nodes: unknown): void => {
213
- if (!Array.isArray(nodes)) return
214
- for (const node of nodes as BlockNode[]) {
215
- const def = node?.type ? byType[node.type] : undefined
216
- if (def && node.props) collectLinkRefsFromBag(def.fields, node.props, add)
217
- const slots = node ? slotsOf(node) : null
218
- if (slots) for (const arr of Object.values(slots)) walk(arr)
219
- }
220
- }
221
- walk(blocks)
222
- return refs
223
- }
224
-
225
- /**
226
- * Resolve internal links + richtext markers in a value-bag, recursing repeater entries. Identity-
227
- * preserving — a link/richtext value that needs no change keeps its ref, an unchanged repeater array/
228
- * entry keeps its ref, and a bag with nothing to change is returned as-is (lazily cloned only on the
229
- * first actual change).
230
- */
231
- function populateLinksInBag(
232
- fields: Record<string, SerializedField>,
233
- bag: Record<string, unknown>,
234
- resolveHref: (collection: string, id: number) => string | null,
235
- ): Record<string, unknown> {
236
- let next: Record<string, unknown> | null = null
237
- for (const [key, field] of Object.entries(fields)) {
238
- if (field.type === 'link') {
239
- const ref = internalRefOf(bag[key])
240
- if (!ref) continue
241
- const href = resolveHref(ref.collection, ref.id)
242
- const current = bag[key] as Record<string, unknown>
243
- if (href == null || current.href === href) continue
244
- ;(next ??= { ...bag })[key] = { ...current, href }
245
- } else if (field.type === 'richtext') {
246
- const current = bag[key]
247
- if (typeof current !== 'string') continue
248
- const resolved = resolveRichtextLinks(current, resolveHref)
249
- if (resolved === current) continue // no markers (or already identical) → keep ref
250
- ;(next ??= { ...bag })[key] = resolved
251
- } else {
252
- const sub = repeaterFields(field)
253
- const entries = sub ? bag[key] : null
254
- if (sub && Array.isArray(entries)) {
255
- const populated = entries.map((e) => (e && typeof e === 'object' ? populateLinksInBag(sub, e as Record<string, unknown>, resolveHref) : e))
256
- if (!populated.every((e, i) => e === entries[i])) (next ??= { ...bag })[key] = populated
257
- }
258
- }
259
- }
260
- return next ?? bag
261
- }
262
-
263
- /**
264
- * Client mirror of the server link populator: replace each block's internal-link field value with a
265
- * cloned `{...value, href}` (and rewrite richtext markers) so the live preview's `KestrelLink` renders
266
- * the real path instead of `'#'`. external/email/tel and unresolved internals are left untouched.
267
- * Recurses slot children AND repeater entries. Identity-preserving like `populateBlocksMedia` — a block
268
- * (and its slot/repeater subtree) whose links need no change is returned by reference.
269
- */
270
- export function populateBlocksLinks(
271
- blocks: unknown,
272
- byType: Record<string, SerializedBlock>,
273
- resolveHref: (collection: string, id: number) => string | null,
274
- ): BlockNode[] {
275
- if (!Array.isArray(blocks)) return []
276
- return (blocks as BlockNode[]).map((node) => {
277
- const def = node?.type ? byType[node.type] : undefined
278
- const nextProps = def && node.props ? populateLinksInBag(def.fields, node.props, resolveHref) : node.props
279
-
280
- const nextSlots = mapSlots(node ? slotsOf(node) : null, (arr) => populateBlocksLinks(arr, byType, resolveHref))
281
-
282
- if (nextProps === node.props && !nextSlots) return node
283
- const out: BlockNode = { ...node }
284
- if (nextProps !== node.props) out.props = nextProps
285
- if (nextSlots) out.slots = nextSlots
286
- return out
287
- })
288
- }
@@ -1,62 +0,0 @@
1
- import { contentMessage, selectedMessage, type PreviewBlockNode } from '../../../public/app/utils/preview-protocol'
2
-
3
- /**
4
- * Editor→iframe send half of the live-preview channel (the receive half is the public
5
- * `KestrelPreviewBridge`). Content updates are coalesced to one post per animation frame — a typing
6
- * burst produces a single message carrying the LATEST tree (read at fire time), so the preview stays
7
- * keystroke-live (≤ one frame behind) without flooding the channel. Selection changes are tiny and
8
- * latency-sensitive → posted immediately.
9
- *
10
- * `target` is a getter because the iframe's contentWindow appears on load and is replaced on every
11
- * navigation/reload; posts silently no-op while it is gone. `origin` is passed as postMessage's
12
- * targetOrigin so a frame that navigated cross-origin can never receive editor content.
13
- */
14
- export interface PreviewSender {
15
- sendContent: (blocks: PreviewBlockNode[]) => void
16
- sendSelected: (selectedId: string | null) => void
17
- dispose: () => void
18
- }
19
-
20
- export function createPreviewSender(
21
- target: () => Window | null | undefined,
22
- origin: string,
23
- // Read at FIRE time (like the blocks), never captured at schedule time: an immediate selection post
24
- // can overtake a pending content frame, and a stale captured id in that frame would then revert the
25
- // bridge's newer selection (content messages carry the selection authoritatively).
26
- getSelected: () => string | null,
27
- ): PreviewSender {
28
- let rafId: number | null = null
29
- let latest: PreviewBlockNode[] | null = null
30
-
31
- const post = (msg: unknown) => target()?.postMessage(msg, origin)
32
-
33
- const fire = () => {
34
- rafId = null
35
- if (!latest) return
36
- // JSON round-trip: detaches Vue reactive proxies (structured clone chokes on some exotic wrappers)
37
- // and guarantees the payload is the same JSON-safe shape the DB round-trips. Block trees are small;
38
- // at one clone per frame this is well under a millisecond.
39
- post(contentMessage(JSON.parse(JSON.stringify(latest)), getSelected()))
40
- latest = null
41
- }
42
-
43
- return {
44
- sendContent(blocks) {
45
- latest = blocks
46
- rafId ??= requestAnimationFrame(fire)
47
- },
48
- sendSelected(selectedId) {
49
- post(selectedMessage(selectedId))
50
- },
51
- dispose() {
52
- if (rafId !== null) cancelAnimationFrame(rafId)
53
- rafId = null
54
- latest = null
55
- },
56
- }
57
- }
58
-
59
- /** Parent-side trust guard: accept a message only from OUR origin AND our preview iframe's window. */
60
- export function acceptsFrameEvent(event: MessageEvent, frameWindow: Window | null, origin: string): boolean {
61
- return frameWindow !== null && event.origin === origin && event.source === frameWindow
62
- }
@@ -1,80 +0,0 @@
1
- import type { IconName } from '../../../ui/app/utils/icons'
2
-
3
- /**
4
- * Pure geometry for the page-builder preview toolbar (device presets + automatic scale-to-fit + custom
5
- * W×H). Kept framework-free so it's node-testable — the Vue component (`BlockPreview.vue`) only wires refs
6
- * to these.
7
- */
8
-
9
- /** A viewport axis: a fixed CSS px, or `'auto'` (fills the available pane at the current scale). */
10
- export type Dim = number | 'auto'
11
-
12
- export interface ViewportPreset {
13
- key: 'desktop' | 'tablet' | 'mobile'
14
- /** Toolbar glyph (registry key). */
15
- icon: IconName
16
- /** i18n label key (the resolution is shown alongside it in the tooltip). */
17
- label: string
18
- /** Reference viewport width/height — the iframe's REAL size (scaled only visually); `'auto'` fills. */
19
- w: Dim
20
- h: Dim
21
- }
22
-
23
- export type PresetKey = ViewportPreset['key'] | 'custom'
24
-
25
- /** Bounds shared by the two numeric W×H inputs. */
26
- export const DIM_MIN = 320
27
- export const WIDTH_MAX = 3840
28
- export const HEIGHT_MAX = 4320
29
-
30
- /**
31
- * The three quick-select presets. Desktop keeps the config-driven breakpoint WIDTH (`kestrel.config.ts` →
32
- * `preview.desktopWidth`, default 1440) but an `'auto'` HEIGHT so the frame fills the pane vertically and
33
- * only the iframe's own document scrolls. Tablet/mobile are canonical fixed device viewports (iPad
34
- * portrait / iPhone) so `100vh`/sticky/lazy behave like the real device, scaled down to fit both axes.
35
- */
36
- export function PRESETS(desktopWidth: number): ViewportPreset[] {
37
- return [
38
- { key: 'desktop', icon: 'monitor', label: 'preview.device.desktop', w: desktopWidth, h: 'auto' },
39
- { key: 'tablet', icon: 'tablet', label: 'preview.device.tablet', w: 768, h: 1024 },
40
- { key: 'mobile', icon: 'smartphone', label: 'preview.device.mobile', w: 390, h: 844 },
41
- ]
42
- }
43
-
44
- /** The preset a (w,h) exactly matches, else `'custom'` — drives which quick-select button is highlighted. */
45
- export function matchPreset(w: Dim, h: Dim, presets: ViewportPreset[]): PresetKey {
46
- return presets.find((p) => p.w === w && p.h === h)?.key ?? 'custom'
47
- }
48
-
49
- /**
50
- * Uniform (no-distortion) scale: fit each FIXED axis into its available space, take the min, and **cap at
51
- * 1×** (never upscale). An `'auto'` axis doesn't constrain — it fills instead (see `resolveDim`). So
52
- * desktop (width fixed, height auto) is width-limited; tablet/mobile (both fixed) fit whichever axis is
53
- * tighter and letterbox the other. Returns 1 before the stage is measured (`avail ≤ 0`) so nothing jumps.
54
- */
55
- export function fitScale(availW: number, availH: number, w: Dim, h: Dim): number {
56
- const ratios = [1]
57
- if (typeof w === 'number' && w > 0 && availW > 0) ratios.push(availW / w)
58
- if (typeof h === 'number' && h > 0 && availH > 0) ratios.push(availH / h)
59
- return Math.min(...ratios)
60
- }
61
-
62
- /**
63
- * Resolve a `Dim` to real px. A fixed value passes straight through; an `'auto'` axis fills the available
64
- * space at the current scale (`avail / scale`, so the SCALED px exactly equals `avail`). Falls back to
65
- * `fallback` when unmeasured (SSR/tests: `avail ≤ 0` or `scale ≤ 0`) so the frame still has a size.
66
- */
67
- export function resolveDim(avail: number, dim: Dim, scale: number, fallback: number): number {
68
- if (dim !== 'auto') return dim
69
- return avail > 0 && scale > 0 ? Math.round(avail / scale) : fallback
70
- }
71
-
72
- /**
73
- * Commit a numeric-input value: clamp into `[min,max]` and floor to an integer px. Returns `null` for
74
- * empty/non-finite input so the caller keeps the previous value (the field can hold un-parsable editing
75
- * text whose model is momentarily null — we must not reset the viewport to a default then).
76
- */
77
- export function clampDim(n: number | null | undefined, min: number, max: number): number | null {
78
- if (n == null || !Number.isFinite(n)) return null
79
- return Math.min(max, Math.max(min, Math.floor(n)))
80
- }
@@ -1,67 +0,0 @@
1
- import { safeRedirect } from './safe-redirect'
2
-
3
- // The auth endpoints (login/session/logout). A 401 from these is not a stale session — a wrong-password
4
- // login 401s — so it must not trigger a re-auth redirect. Matched exactly, so a guarded endpoint under a
5
- // consumer collection named `auth` (e.g. `/api/auth/readOne/5`) still re-authenticates.
6
- const BOOTSTRAP_PATHS = new Set(['/api/login', '/api/session', '/api/logout'])
7
-
8
- /**
9
- * Decide whether a failed `$fetch` should bounce the user to the login screen, and to which URL.
10
- *
11
- * Returns the login redirect target for a 401 raised by a guarded `/api/*` call made from inside the
12
- * admin SPA — but stays null for:
13
- * - non-401 statuses and non-`/api` requests (nothing to re-authenticate);
14
- * - the auth endpoints: a wrong-password login 401s and must stay put;
15
- * - any location that isn't a safe admin redirect target — which also rules out the login page itself
16
- * (no redirect loop) and public/SSG pages (the static site never logs in).
17
- */
18
- export function reauthTarget(input: { status: number; url: string; currentPath: string }): string | null {
19
- if (input.status !== 401) return null
20
- const path = apiPath(input.url)
21
- if (!path.startsWith('/api/') || BOOTSTRAP_PATHS.has(path)) return null
22
- // The current location must itself be a valid place to return to. `safeRedirect` rejects the login
23
- // page and any non-admin path, so this single check guards both the loop and the public site.
24
- const back = safeRedirect(input.currentPath)
25
- if (!back) return null
26
- return `/admin/login?redirect=${encodeURIComponent(back)}`
27
- }
28
-
29
- function apiPath(url: string): string {
30
- // Tolerate absolute URLs (SSR/base-prefixed) and relative ones alike; fall back to the raw string.
31
- try { return new URL(url, 'http://localhost').pathname } catch { return url }
32
- }
33
-
34
- /** Minimal shape of the ofetch `onResponseError` context this interceptor reads. */
35
- export interface ReauthContext {
36
- request?: string | { url?: string } | URL
37
- response?: { status?: number }
38
- }
39
-
40
- /**
41
- * Build an `onResponseError` handler that re-authenticates on a stale-session 401. Pure dependency
42
- * injection (current path / reset / navigate) keeps it unit-testable away from the Nuxt runtime; the
43
- * client plugin wires it to the real router, `useAuth().reset` and `navigateTo`.
44
- */
45
- export function makeReauthInterceptor(deps: {
46
- currentPath: () => string
47
- reset: () => void
48
- navigate: (to: string) => void
49
- }) {
50
- return (ctx: ReauthContext): void => {
51
- const target = reauthTarget({
52
- status: ctx.response?.status ?? 0,
53
- url: requestUrl(ctx.request),
54
- currentPath: deps.currentPath(),
55
- })
56
- if (!target) return
57
- deps.reset()
58
- deps.navigate(target)
59
- }
60
- }
61
-
62
- function requestUrl(request: ReauthContext['request']): string {
63
- if (typeof request === 'string') return request
64
- if (request instanceof URL) return request.href
65
- if (request && typeof request === 'object' && typeof request.url === 'string') return request.url
66
- return ''
67
- }
@@ -1,15 +0,0 @@
1
- /**
2
- * The text the editor header shows for a saved record: the `title` text field if present, else the first
3
- * text field — the SAME pick `slugSourceValue` (core/page-slug) derives the auto-slug from, so the heading
4
- * and the URL never disagree about which field is "the title".
5
- *
6
- * Returns `''` when there is no such field or its value is blank/non-string; the header then falls back to
7
- * the generic "Edit {collection} #{id}" (an id is all a brand-new record has).
8
- */
9
- export function recordTitle(fields: Record<string, { type: string }>, values: Record<string, unknown>): string {
10
- const entries = Object.entries(fields)
11
- const pick = entries.find(([k, f]) => k === 'title' && f.type === 'text') ?? entries.find(([, f]) => f.type === 'text')
12
- if (!pick) return ''
13
- const v = values[pick[0]]
14
- return typeof v === 'string' ? v.trim() : ''
15
- }
@@ -1,9 +0,0 @@
1
- import { registerCollectionEditor } from './editor-registry'
2
- import FieldsBody from '../components/FieldsBody.vue'
3
- import BlocksBody from '../components/BlocksBody.vue'
4
-
5
- // Built-in editor bodies. Registered as an import side-effect (CollectionEditor imports this module), so
6
- // they are present before any editor renders — the same guarantee the field registry's static map gives.
7
- // Extensions add their own types via a `*.client.ts` plugin (auto-discovered through `extends`).
8
- registerCollectionEditor('fields', FieldsBody)
9
- registerCollectionEditor('blocks', BlocksBody)
@@ -1,12 +0,0 @@
1
- /**
2
- * Validate a post-login redirect target. Only local `/admin` paths are allowed —
3
- * never external/protocol-relative URLs, and never `/admin/login` (no redirect loop).
4
- */
5
- export function safeRedirect(target: unknown): string | null {
6
- if (typeof target !== 'string') return null
7
- if (target.startsWith('//')) return null
8
- const local = target === '/admin' || target.startsWith('/admin/') || target.startsWith('/admin?')
9
- if (!local) return null
10
- if (target === '/admin/login' || target.startsWith('/admin/login?') || target.startsWith('/admin/login/')) return null
11
- return target
12
- }
@@ -1,20 +0,0 @@
1
- import { kestrelComponents } from '../core/modules/component-namespace/shared'
2
-
3
- export default defineNuxtConfig({
4
- components: [kestrelComponents(import.meta.url)],
5
- routeRules: {
6
- // The admin is a client-rendered SPA.
7
- '/admin/**': { ssr: false },
8
- },
9
- modules: [
10
- // The field-widget gallery (/admin/gallery) is a dev-only showcase — strip it from the
11
- // production admin SPA so it never ships to end users.
12
- (_options, nuxt) => {
13
- nuxt.hook('pages:extend', (pages) => {
14
- if (nuxt.options.dev) return
15
- const i = pages.findIndex((p) => p.file?.endsWith('/admin/gallery.vue'))
16
- if (i !== -1) pages.splice(i, 1)
17
- })
18
- },
19
- ],
20
- })
@@ -1 +0,0 @@
1
- export default defineNuxtConfig({})