@michaelthielemann/kestrel 4.1.3 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (340) hide show
  1. package/README.md +26 -201
  2. package/dist/boot.d.ts +45 -0
  3. package/dist/boot.js +274 -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 +157 -0
  15. package/dist/defineConfig.js +41 -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/cron.d.ts +10 -0
  43. package/dist/triggers/cron.js +44 -0
  44. package/dist/triggers/cronMatch.d.ts +10 -0
  45. package/dist/triggers/cronMatch.js +68 -0
  46. package/dist/triggers/http.d.ts +58 -0
  47. package/dist/triggers/http.js +292 -0
  48. package/package.json +25 -151
  49. package/NOTICE +0 -12
  50. package/kestrel.config.ts +0 -28
  51. package/layers/access/nuxt.config.ts +0 -5
  52. package/layers/access/server/middleware/00.ip-allowlist.ts +0 -52
  53. package/layers/access/server/middleware/access-guard.ts +0 -32
  54. package/layers/access/server/types.d.ts +0 -7
  55. package/layers/admin/app/components/AdminAccount.vue +0 -212
  56. package/layers/admin/app/components/AdminNav.vue +0 -44
  57. package/layers/admin/app/components/BlockFields.vue +0 -67
  58. package/layers/admin/app/components/BlockPreview.vue +0 -413
  59. package/layers/admin/app/components/BlockTree.vue +0 -433
  60. package/layers/admin/app/components/BlocksBody.vue +0 -163
  61. package/layers/admin/app/components/CollectionDeleteDialog.vue +0 -38
  62. package/layers/admin/app/components/CollectionEditor.vue +0 -324
  63. package/layers/admin/app/components/CollectionList.vue +0 -429
  64. package/layers/admin/app/components/CollectionListBulkBar.vue +0 -62
  65. package/layers/admin/app/components/CollectionListColumnsPanel.vue +0 -37
  66. package/layers/admin/app/components/CollectionListFilterPanel.vue +0 -145
  67. package/layers/admin/app/components/CollectionListPager.vue +0 -70
  68. package/layers/admin/app/components/CollectionListTable.vue +0 -347
  69. package/layers/admin/app/components/EditorStatus.vue +0 -211
  70. package/layers/admin/app/components/EditorUnsupported.vue +0 -28
  71. package/layers/admin/app/components/FieldsBody.vue +0 -23
  72. package/layers/admin/app/components/LocaleBar.vue +0 -107
  73. package/layers/admin/app/components/PageFields.vue +0 -129
  74. package/layers/admin/app/components/PageFieldsPane.vue +0 -39
  75. package/layers/admin/app/components/SeoFields.vue +0 -210
  76. package/layers/admin/app/components/SingletonEditor.vue +0 -93
  77. package/layers/admin/app/composables/useAuth.ts +0 -68
  78. package/layers/admin/app/composables/useBlockTree.ts +0 -95
  79. package/layers/admin/app/composables/useBlocks.ts +0 -7
  80. package/layers/admin/app/composables/useCachedResource.ts +0 -35
  81. package/layers/admin/app/composables/useCollectionOps.ts +0 -102
  82. package/layers/admin/app/composables/useCollections.ts +0 -6
  83. package/layers/admin/app/composables/useContentLocales.ts +0 -23
  84. package/layers/admin/app/composables/useEditForm.ts +0 -402
  85. package/layers/admin/app/composables/useLinkResolver.ts +0 -40
  86. package/layers/admin/app/composables/useListBatchActions.ts +0 -78
  87. package/layers/admin/app/composables/useListColumns.ts +0 -53
  88. package/layers/admin/app/composables/useListFilterDraft.ts +0 -129
  89. package/layers/admin/app/composables/useListRows.ts +0 -75
  90. package/layers/admin/app/composables/useListSelection.ts +0 -28
  91. package/layers/admin/app/composables/useListUrlState.ts +0 -53
  92. package/layers/admin/app/composables/usePendingPublishPoll.ts +0 -15
  93. package/layers/admin/app/composables/usePreviewViewport.ts +0 -30
  94. package/layers/admin/app/composables/usePublishStatus.ts +0 -107
  95. package/layers/admin/app/composables/useRailCollapsed.ts +0 -14
  96. package/layers/admin/app/composables/useTheme.ts +0 -36
  97. package/layers/admin/app/composables/useToolbarPanel.ts +0 -38
  98. package/layers/admin/app/composables/useUnsavedGuard.ts +0 -22
  99. package/layers/admin/app/layouts/admin.vue +0 -266
  100. package/layers/admin/app/middleware/admin-auth.ts +0 -4
  101. package/layers/admin/app/pages/admin/[collection]/[id].vue +0 -203
  102. package/layers/admin/app/pages/admin/[collection]/index.vue +0 -62
  103. package/layers/admin/app/pages/admin/index.vue +0 -118
  104. package/layers/admin/app/pages/admin/login.vue +0 -68
  105. package/layers/admin/app/pages/admin/media.vue +0 -28
  106. package/layers/admin/app/pages/admin/references.vue +0 -146
  107. package/layers/admin/app/plugins/admin-style-guard.client.ts +0 -76
  108. package/layers/admin/app/plugins/reauth.client.ts +0 -20
  109. package/layers/admin/app/utils/admin-style-guard.ts +0 -20
  110. package/layers/admin/app/utils/block-tree.ts +0 -214
  111. package/layers/admin/app/utils/collection-ops.ts +0 -69
  112. package/layers/admin/app/utils/dead-refs.ts +0 -30
  113. package/layers/admin/app/utils/edit-form.ts +0 -267
  114. package/layers/admin/app/utils/editor-expose.ts +0 -36
  115. package/layers/admin/app/utils/editor-form-context.ts +0 -82
  116. package/layers/admin/app/utils/editor-registry.ts +0 -20
  117. package/layers/admin/app/utils/field-keys.ts +0 -11
  118. package/layers/admin/app/utils/list-cell.ts +0 -46
  119. package/layers/admin/app/utils/list-columns.ts +0 -142
  120. package/layers/admin/app/utils/list-query.ts +0 -96
  121. package/layers/admin/app/utils/nav-active.ts +0 -9
  122. package/layers/admin/app/utils/populate-blocks.ts +0 -288
  123. package/layers/admin/app/utils/preview-channel.ts +0 -62
  124. package/layers/admin/app/utils/preview-viewport.ts +0 -80
  125. package/layers/admin/app/utils/reauth.ts +0 -67
  126. package/layers/admin/app/utils/record-title.ts +0 -15
  127. package/layers/admin/app/utils/register-builtin-editors.ts +0 -9
  128. package/layers/admin/app/utils/safe-redirect.ts +0 -12
  129. package/layers/admin/nuxt.config.ts +0 -20
  130. package/layers/auth/nuxt.config.ts +0 -1
  131. package/layers/auth/server/plugins/01.register-auth-pipelines.ts +0 -7
  132. package/layers/collections/nuxt.config.ts +0 -1
  133. package/layers/collections/server/plugins/01.register.ts +0 -29
  134. package/layers/collections/server/plugins/02.register-relation-populate.ts +0 -18
  135. package/layers/core/app/composables/layouts.ts +0 -5
  136. package/layers/core/app/utils/field-factories.ts +0 -129
  137. package/layers/core/app/utils/layouts.ts +0 -38
  138. package/layers/core/modules/auto-discovery/extract-block.ts +0 -179
  139. package/layers/core/modules/auto-discovery/index.ts +0 -112
  140. package/layers/core/modules/auto-discovery/package-registry.ts +0 -19
  141. package/layers/core/modules/auto-discovery/scan.ts +0 -170
  142. package/layers/core/modules/auto-discovery/virtual.d.ts +0 -20
  143. package/layers/core/modules/component-namespace/index.ts +0 -44
  144. package/layers/core/modules/component-namespace/shared.ts +0 -24
  145. package/layers/core/modules/kestrel/app-shell.ts +0 -55
  146. package/layers/core/modules/kestrel/auto-imports.ts +0 -44
  147. package/layers/core/modules/kestrel/index.ts +0 -144
  148. package/layers/core/modules/plugin-order/index.ts +0 -69
  149. package/layers/core/modules/plugin-order/plugin-order.ts +0 -273
  150. package/layers/core/nuxt.config.ts +0 -10
  151. package/layers/core/server/api/[...path].ts +0 -98
  152. package/layers/core/server/plugins/00.config.ts +0 -12
  153. package/layers/core/server/plugins/00.migrate.ts +0 -12
  154. package/layers/core/server/plugins/01.register-introspection-pipeline.ts +0 -5
  155. package/layers/core/server/plugins/01.register-openapi-pipeline.ts +0 -5
  156. package/layers/core/server/plugins/01.register-tooling-pipelines.ts +0 -5
  157. package/layers/core/server/plugins/02.schema-sync.ts +0 -58
  158. package/layers/core/server/plugins/04.outbox-worker.ts +0 -31
  159. package/layers/core/server/plugins/05.reindex-refs.ts +0 -5
  160. package/layers/core/server/routes/__kestrel/dashboard.get.ts +0 -29
  161. package/layers/core/server/tasks/db/migrate-module.ts +0 -83
  162. package/layers/core/server/tasks/db/migrate-revisions.ts +0 -20
  163. package/layers/core/server/tasks/db/migrate.ts +0 -50
  164. package/layers/core/server/utils/server-config.ts +0 -81
  165. package/layers/fields/nuxt.config.ts +0 -1
  166. package/layers/fields/server/plugins/01.register-blocks-pipeline.ts +0 -7
  167. package/layers/fields/server/plugins/01.register-field-populate.ts +0 -11
  168. package/layers/media/app/components/KestrelImg.vue +0 -94
  169. package/layers/media/app/components/MediaConflictDialog.vue +0 -35
  170. package/layers/media/app/components/MediaDeleteDialog.vue +0 -52
  171. package/layers/media/app/components/MediaGrid.vue +0 -113
  172. package/layers/media/app/components/MediaImage.vue +0 -70
  173. package/layers/media/app/components/MediaLibrary.vue +0 -456
  174. package/layers/media/app/components/MediaNewFolderDialog.vue +0 -32
  175. package/layers/media/app/components/MediaPathBar.vue +0 -163
  176. package/layers/media/app/components/MediaPicker.vue +0 -19
  177. package/layers/media/app/components/MediaRenameDialog.vue +0 -32
  178. package/layers/media/app/components/MediaTable.vue +0 -75
  179. package/layers/media/app/components/MediaThumb.vue +0 -25
  180. package/layers/media/app/components/MediaToolbar.vue +0 -90
  181. package/layers/media/app/components/MediaUploadDialog.vue +0 -87
  182. package/layers/media/app/components/MediaViewer.vue +0 -137
  183. package/layers/media/app/components/field/Media.vue +0 -263
  184. package/layers/media/app/composables/useBlurUp.ts +0 -55
  185. package/layers/media/app/composables/useMediaClipboard.ts +0 -14
  186. package/layers/media/app/composables/useMediaContextMenu.ts +0 -56
  187. package/layers/media/app/composables/useMediaDnd.ts +0 -56
  188. package/layers/media/app/composables/useMediaLibrary.ts +0 -152
  189. package/layers/media/app/composables/useMediaOps.ts +0 -50
  190. package/layers/media/app/composables/useMediaRelocate.ts +0 -39
  191. package/layers/media/app/composables/useMediaResolver.ts +0 -50
  192. package/layers/media/app/composables/useMediaUpload.ts +0 -93
  193. package/layers/media/app/composables/useMediaVariant.ts +0 -34
  194. package/layers/media/app/plugins/field-media.client.ts +0 -6
  195. package/layers/media/app/utils/ai-disclosure.ts +0 -19
  196. package/layers/media/app/utils/dnd.ts +0 -69
  197. package/layers/media/app/utils/library.ts +0 -81
  198. package/layers/media/app/utils/ops.ts +0 -60
  199. package/layers/media/app/utils/picture-model.ts +0 -125
  200. package/layers/media/app/utils/thumbhash.ts +0 -19
  201. package/layers/media/app/utils/variant-name.ts +0 -20
  202. package/layers/media/nuxt.config.ts +0 -7
  203. package/layers/media/server/middleware/ondemand-variants.ts +0 -38
  204. package/layers/media/server/plugins/01.register-media-pipelines.ts +0 -7
  205. package/layers/media/server/plugins/02.register-media.ts +0 -22
  206. package/layers/media/server/plugins/04.variant-capture.ts +0 -27
  207. package/layers/media/server/plugins/05.media-cleanup.ts +0 -5
  208. package/layers/media/server/tasks/media/backfill.ts +0 -17
  209. package/layers/public/app/app.vue +0 -5
  210. package/layers/public/app/components/BlockRenderer.vue +0 -56
  211. package/layers/public/app/components/KestrelLink.vue +0 -26
  212. package/layers/public/app/components/KestrelPreviewBridge.vue +0 -133
  213. package/layers/public/app/composables/public-page.ts +0 -24
  214. package/layers/public/app/error.vue +0 -12
  215. package/layers/public/app/layouts/default.vue +0 -5
  216. package/layers/public/app/pages/[...slug].vue +0 -239
  217. package/layers/public/app/pages/__kestrel/preview.vue +0 -46
  218. package/layers/public/app/utils/block-edit-context.ts +0 -16
  219. package/layers/public/app/utils/json-ld.ts +0 -139
  220. package/layers/public/app/utils/link-href.ts +0 -46
  221. package/layers/public/app/utils/page-head.ts +0 -84
  222. package/layers/public/app/utils/page-layout.ts +0 -18
  223. package/layers/public/app/utils/preview-protocol.ts +0 -117
  224. package/layers/public/app/utils/public-route.ts +0 -46
  225. package/layers/public/app/utils/site-head.ts +0 -43
  226. package/layers/public/modules/deploy-output/index.ts +0 -80
  227. package/layers/public/modules/prerender-routes/discover.ts +0 -37
  228. package/layers/public/modules/prerender-routes/index.ts +0 -88
  229. package/layers/public/modules/prerender-routes/routes.ts +0 -16
  230. package/layers/public/modules/prune-media/index.ts +0 -48
  231. package/layers/public/modules/prune-media/prune-media.ts +0 -81
  232. package/layers/public/nuxt.config.ts +0 -14
  233. package/layers/public/server/delivery-live/serve.ts +0 -111
  234. package/layers/public/server/middleware/delivery-live-catchall.ts +0 -7
  235. package/layers/public/server/pipelines/index.ts +0 -23
  236. package/layers/public/server/plugins/00.ensure-snapshot-triggers.ts +0 -14
  237. package/layers/public/server/plugins/01.register-public-pipelines.ts +0 -7
  238. package/layers/public/server/plugins/02.register-links.ts +0 -16
  239. package/layers/public/server/plugins/03.redirects.ts +0 -55
  240. package/layers/public/server/plugins/05.plan-publish.ts +0 -5
  241. package/layers/public/server/plugins/zz.publish.ts +0 -127
  242. package/layers/public/server/routes/llms-full.txt.get.ts +0 -99
  243. package/layers/public/server/routes/llms.txt.get.ts +0 -66
  244. package/layers/public/server/routes/redirects.json.get.ts +0 -58
  245. package/layers/public/server/routes/robots.txt.get.ts +0 -11
  246. package/layers/public/server/routes/sitemap.xml.get.ts +0 -79
  247. package/layers/public/server/tasks/publish/run.ts +0 -32
  248. package/layers/public/server/utils/publish/render-live.ts +0 -26
  249. package/layers/ui/app/assets/scss/_base.scss +0 -16
  250. package/layers/ui/app/assets/scss/_datepicker.scss +0 -82
  251. package/layers/ui/app/assets/scss/_mixins.scss +0 -50
  252. package/layers/ui/app/assets/scss/_reset.scss +0 -65
  253. package/layers/ui/app/assets/scss/_tokens.scss +0 -183
  254. package/layers/ui/app/assets/scss/main.scss +0 -4
  255. package/layers/ui/app/components/field/Boolean.vue +0 -30
  256. package/layers/ui/app/components/field/Choice.vue +0 -73
  257. package/layers/ui/app/components/field/Datetime.vue +0 -98
  258. package/layers/ui/app/components/field/DeadRefNote.vue +0 -28
  259. package/layers/ui/app/components/field/Json.vue +0 -62
  260. package/layers/ui/app/components/field/Layout.vue +0 -138
  261. package/layers/ui/app/components/field/Link.vue +0 -112
  262. package/layers/ui/app/components/field/LinkInternalPicker.vue +0 -95
  263. package/layers/ui/app/components/field/Number.vue +0 -30
  264. package/layers/ui/app/components/field/Relation.vue +0 -49
  265. package/layers/ui/app/components/field/Renderer.vue +0 -30
  266. package/layers/ui/app/components/field/Repeater.vue +0 -460
  267. package/layers/ui/app/components/field/Richtext.vue +0 -26
  268. package/layers/ui/app/components/field/Slug.vue +0 -65
  269. package/layers/ui/app/components/field/Text.vue +0 -35
  270. package/layers/ui/app/components/field/Unsupported.vue +0 -34
  271. package/layers/ui/app/components/ui/Alert.vue +0 -50
  272. package/layers/ui/app/components/ui/Brand.vue +0 -22
  273. package/layers/ui/app/components/ui/Button.vue +0 -116
  274. package/layers/ui/app/components/ui/ButtonGroup.vue +0 -96
  275. package/layers/ui/app/components/ui/Checkbox.vue +0 -53
  276. package/layers/ui/app/components/ui/CheckboxGroup.vue +0 -51
  277. package/layers/ui/app/components/ui/Combobox.vue +0 -345
  278. package/layers/ui/app/components/ui/DatePicker.vue +0 -92
  279. package/layers/ui/app/components/ui/DateRangePicker.vue +0 -100
  280. package/layers/ui/app/components/ui/Dialog.vue +0 -107
  281. package/layers/ui/app/components/ui/EmptyState.vue +0 -46
  282. package/layers/ui/app/components/ui/Field.vue +0 -58
  283. package/layers/ui/app/components/ui/Fieldset.vue +0 -65
  284. package/layers/ui/app/components/ui/Icon.vue +0 -44
  285. package/layers/ui/app/components/ui/Menu.vue +0 -54
  286. package/layers/ui/app/components/ui/NumberInput.vue +0 -175
  287. package/layers/ui/app/components/ui/Richtext.vue +0 -141
  288. package/layers/ui/app/components/ui/RichtextToolbar.vue +0 -251
  289. package/layers/ui/app/components/ui/Select.vue +0 -52
  290. package/layers/ui/app/components/ui/TextInput.vue +0 -111
  291. package/layers/ui/app/components/ui/Textarea.vue +0 -39
  292. package/layers/ui/app/components/ui/TimeInput.vue +0 -18
  293. package/layers/ui/app/components/ui/Toasts.vue +0 -103
  294. package/layers/ui/app/components/ui/Tooltip.vue +0 -45
  295. package/layers/ui/app/composables/useAdminLang.ts +0 -8
  296. package/layers/ui/app/composables/useDragReorder.ts +0 -41
  297. package/layers/ui/app/composables/useEchoGuard.ts +0 -28
  298. package/layers/ui/app/composables/useLinkField.ts +0 -70
  299. package/layers/ui/app/composables/useRecordOptions.ts +0 -84
  300. package/layers/ui/app/composables/useRepeater.ts +0 -99
  301. package/layers/ui/app/composables/useT.ts +0 -32
  302. package/layers/ui/app/composables/useToast.ts +0 -46
  303. package/layers/ui/app/i18n/de.ts +0 -432
  304. package/layers/ui/app/i18n/en.ts +0 -434
  305. package/layers/ui/app/utils/boolean-field.ts +0 -10
  306. package/layers/ui/app/utils/field-component.ts +0 -22
  307. package/layers/ui/app/utils/field-empty.ts +0 -21
  308. package/layers/ui/app/utils/field-registry.ts +0 -36
  309. package/layers/ui/app/utils/humanize.ts +0 -14
  310. package/layers/ui/app/utils/icons.ts +0 -143
  311. package/layers/ui/app/utils/iso-date.ts +0 -18
  312. package/layers/ui/app/utils/localized.ts +0 -13
  313. package/layers/ui/app/utils/record-context.ts +0 -15
  314. package/layers/ui/app/utils/reorder.ts +0 -8
  315. package/layers/ui/app/utils/richtext-preserve-class.ts +0 -31
  316. package/layers/ui/app/utils/useFieldA11y.ts +0 -40
  317. package/layers/ui/nuxt.config.ts +0 -9
  318. package/nuxt.config.ts +0 -108
  319. package/scripts/consumer-template-ci.mjs +0 -160
  320. package/scripts/copy-create-payload.mjs +0 -49
  321. package/scripts/coverage-all.mjs +0 -163
  322. package/scripts/dashboard.mjs +0 -89
  323. package/scripts/hash-password.mjs +0 -29
  324. package/scripts/kestrel.mjs +0 -240
  325. package/scripts/lib/cli.mjs +0 -129
  326. package/scripts/lib/coverage-model.mjs +0 -51
  327. package/scripts/lib/password.mjs +0 -19
  328. package/scripts/lib/scaffold.mjs +0 -205
  329. package/scripts/port-graph.mjs +0 -350
  330. package/scripts/typecheck.mjs +0 -62
  331. package/templates/starter/README.md +0 -65
  332. package/templates/starter/_env.example +0 -17
  333. package/templates/starter/_gitignore +0 -26
  334. package/templates/starter/_package.json +0 -22
  335. package/templates/starter/app/app.vue +0 -7
  336. package/templates/starter/app/blocks/Prose.vue +0 -13
  337. package/templates/starter/app/layouts/default.vue +0 -6
  338. package/templates/starter/nuxt.config.ts +0 -23
  339. package/templates/starter/pnpm-workspace.yaml +0 -8
  340. 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({})