@michaelthielemann/kestrel 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (395) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +12 -0
  3. package/README.md +234 -0
  4. package/kestrel.config.ts +28 -0
  5. package/layers/access/nuxt.config.ts +5 -0
  6. package/layers/access/server/middleware/00.ip-allowlist.ts +67 -0
  7. package/layers/access/server/middleware/access-guard.ts +30 -0
  8. package/layers/access/server/types.d.ts +8 -0
  9. package/layers/access/server/utils/csrf.ts +39 -0
  10. package/layers/access/server/utils/grant-registry.ts +36 -0
  11. package/layers/access/server/utils/guard.ts +44 -0
  12. package/layers/access/server/utils/ip-allowlist.ts +76 -0
  13. package/layers/access/server/utils/policy.ts +98 -0
  14. package/layers/access/server/utils/public-resources.ts +11 -0
  15. package/layers/access/server/utils/render-context.ts +41 -0
  16. package/layers/access/server/utils/require-admin.ts +15 -0
  17. package/layers/admin/app/components/AdminAccount.vue +212 -0
  18. package/layers/admin/app/components/AdminNav.vue +44 -0
  19. package/layers/admin/app/components/BlockFields.vue +68 -0
  20. package/layers/admin/app/components/BlockPreview.vue +401 -0
  21. package/layers/admin/app/components/BlockTree.vue +433 -0
  22. package/layers/admin/app/components/BlocksBody.vue +162 -0
  23. package/layers/admin/app/components/CollectionDeleteDialog.vue +38 -0
  24. package/layers/admin/app/components/CollectionEditor.vue +203 -0
  25. package/layers/admin/app/components/CollectionList.vue +1091 -0
  26. package/layers/admin/app/components/EditorStatus.vue +199 -0
  27. package/layers/admin/app/components/EditorUnsupported.vue +28 -0
  28. package/layers/admin/app/components/FieldsBody.vue +23 -0
  29. package/layers/admin/app/components/LocaleBar.vue +105 -0
  30. package/layers/admin/app/components/PageFields.vue +101 -0
  31. package/layers/admin/app/components/PageFieldsPane.vue +39 -0
  32. package/layers/admin/app/components/SeoFields.vue +169 -0
  33. package/layers/admin/app/components/SingletonEditor.vue +92 -0
  34. package/layers/admin/app/composables/useAuth.ts +68 -0
  35. package/layers/admin/app/composables/useBlockTree.ts +95 -0
  36. package/layers/admin/app/composables/useBlocks.ts +7 -0
  37. package/layers/admin/app/composables/useCachedResource.ts +35 -0
  38. package/layers/admin/app/composables/useCollectionOps.ts +53 -0
  39. package/layers/admin/app/composables/useCollections.ts +7 -0
  40. package/layers/admin/app/composables/useContentLocales.ts +23 -0
  41. package/layers/admin/app/composables/useEditForm.ts +370 -0
  42. package/layers/admin/app/composables/useLinkResolver.ts +40 -0
  43. package/layers/admin/app/composables/useListColumns.ts +53 -0
  44. package/layers/admin/app/composables/useListUrlState.ts +53 -0
  45. package/layers/admin/app/composables/usePendingPublishPoll.ts +15 -0
  46. package/layers/admin/app/composables/usePreviewViewport.ts +30 -0
  47. package/layers/admin/app/composables/usePublishStatus.ts +98 -0
  48. package/layers/admin/app/composables/useRailCollapsed.ts +14 -0
  49. package/layers/admin/app/composables/useTheme.ts +36 -0
  50. package/layers/admin/app/composables/useUnsavedGuard.ts +22 -0
  51. package/layers/admin/app/layouts/admin.vue +267 -0
  52. package/layers/admin/app/middleware/admin-auth.ts +4 -0
  53. package/layers/admin/app/pages/admin/[collection]/[id].vue +202 -0
  54. package/layers/admin/app/pages/admin/[collection]/index.vue +62 -0
  55. package/layers/admin/app/pages/admin/index.vue +118 -0
  56. package/layers/admin/app/pages/admin/login.vue +68 -0
  57. package/layers/admin/app/pages/admin/media.vue +28 -0
  58. package/layers/admin/app/pages/admin/references.vue +146 -0
  59. package/layers/admin/app/plugins/admin-style-guard.client.ts +76 -0
  60. package/layers/admin/app/plugins/reauth.client.ts +20 -0
  61. package/layers/admin/app/utils/admin-style-guard.ts +20 -0
  62. package/layers/admin/app/utils/block-tree.ts +214 -0
  63. package/layers/admin/app/utils/collection-ops.ts +44 -0
  64. package/layers/admin/app/utils/dead-refs.ts +30 -0
  65. package/layers/admin/app/utils/edit-form.ts +223 -0
  66. package/layers/admin/app/utils/editor-expose.ts +26 -0
  67. package/layers/admin/app/utils/editor-form-context.ts +83 -0
  68. package/layers/admin/app/utils/editor-registry.ts +20 -0
  69. package/layers/admin/app/utils/field-keys.ts +11 -0
  70. package/layers/admin/app/utils/list-columns.ts +141 -0
  71. package/layers/admin/app/utils/list-query.ts +96 -0
  72. package/layers/admin/app/utils/nav-active.ts +9 -0
  73. package/layers/admin/app/utils/populate-blocks.ts +288 -0
  74. package/layers/admin/app/utils/preview-channel.ts +62 -0
  75. package/layers/admin/app/utils/preview-viewport.ts +80 -0
  76. package/layers/admin/app/utils/reauth.ts +68 -0
  77. package/layers/admin/app/utils/record-title.ts +15 -0
  78. package/layers/admin/app/utils/register-builtin-editors.ts +9 -0
  79. package/layers/admin/app/utils/safe-redirect.ts +12 -0
  80. package/layers/admin/nuxt.config.ts +17 -0
  81. package/layers/auth/nuxt.config.ts +1 -0
  82. package/layers/auth/server/api/auth/login.post.ts +38 -0
  83. package/layers/auth/server/api/auth/logout.post.ts +9 -0
  84. package/layers/auth/server/api/auth/session.get.ts +1 -0
  85. package/layers/auth/server/utils/admin-credential.ts +42 -0
  86. package/layers/auth/server/utils/client-ip.ts +88 -0
  87. package/layers/auth/server/utils/login-throttle.ts +126 -0
  88. package/layers/auth/server/utils/password.ts +52 -0
  89. package/layers/auth/server/utils/session-cookie.ts +44 -0
  90. package/layers/auth/server/utils/session-epoch.ts +96 -0
  91. package/layers/auth/server/utils/session.ts +85 -0
  92. package/layers/collections/nuxt.config.ts +1 -0
  93. package/layers/collections/server/collections/pages.ts +19 -0
  94. package/layers/collections/server/plugins/01.register.ts +32 -0
  95. package/layers/collections/server/plugins/02.register-relation-populate.ts +17 -0
  96. package/layers/collections/server/utils/populate-relations.ts +66 -0
  97. package/layers/core/app/utils/bulk-actions.ts +9 -0
  98. package/layers/core/app/utils/filter-ops.ts +98 -0
  99. package/layers/core/app/utils/list-limits.ts +27 -0
  100. package/layers/core/app/utils/locale-path.ts +12 -0
  101. package/layers/core/app/utils/slugify.ts +16 -0
  102. package/layers/core/modules/auto-discovery/extract-block.ts +176 -0
  103. package/layers/core/modules/auto-discovery/index.ts +44 -0
  104. package/layers/core/modules/auto-discovery/scan.ts +78 -0
  105. package/layers/core/modules/auto-discovery/virtual.d.ts +12 -0
  106. package/layers/core/modules/kestrel/index.ts +86 -0
  107. package/layers/core/nuxt.config.ts +8 -0
  108. package/layers/core/server/api/[collection]/[id]/dead-refs.get.ts +8 -0
  109. package/layers/core/server/api/[collection]/[id]/translations.get.ts +4 -0
  110. package/layers/core/server/api/[collection]/[id].delete.ts +5 -0
  111. package/layers/core/server/api/[collection]/[id].get.ts +8 -0
  112. package/layers/core/server/api/[collection]/[id].patch.ts +8 -0
  113. package/layers/core/server/api/[collection]/bulk.post.ts +37 -0
  114. package/layers/core/server/api/[collection]/index.get.ts +19 -0
  115. package/layers/core/server/api/[collection]/index.post.ts +8 -0
  116. package/layers/core/server/api/[collection]/index.put.ts +7 -0
  117. package/layers/core/server/api/[collection]/options.get.ts +18 -0
  118. package/layers/core/server/api/[collection]/translations.get.ts +25 -0
  119. package/layers/core/server/api/collections/[name].get.ts +4 -0
  120. package/layers/core/server/api/collections/index.get.ts +3 -0
  121. package/layers/core/server/api/references/broken.get.ts +3 -0
  122. package/layers/core/server/api/references/referrers.get.ts +24 -0
  123. package/layers/core/server/database/record-refs.ts +20 -0
  124. package/layers/core/server/plugins/00.migrate.ts +11 -0
  125. package/layers/core/server/plugins/02.schema-sync.ts +63 -0
  126. package/layers/core/server/plugins/03.record-refs.ts +12 -0
  127. package/layers/core/server/schema/bootstrap.ts +41 -0
  128. package/layers/core/server/schema/desired.ts +99 -0
  129. package/layers/core/server/schema/dev-sync.ts +23 -0
  130. package/layers/core/server/schema/dialect.ts +59 -0
  131. package/layers/core/server/schema/diff.ts +110 -0
  132. package/layers/core/server/schema/introspect.ts +67 -0
  133. package/layers/core/server/schema/model.ts +61 -0
  134. package/layers/core/server/schema/render-sqlite.ts +89 -0
  135. package/layers/core/server/schema/sync.ts +201 -0
  136. package/layers/core/server/tasks/db/migrate.ts +54 -0
  137. package/layers/core/server/utils/blocks.ts +6 -0
  138. package/layers/core/server/utils/collection-types.ts +27 -0
  139. package/layers/core/server/utils/crud.ts +577 -0
  140. package/layers/core/server/utils/db.ts +26 -0
  141. package/layers/core/server/utils/defineCollection.ts +207 -0
  142. package/layers/core/server/utils/duplicate.ts +162 -0
  143. package/layers/core/server/utils/field-layout.ts +125 -0
  144. package/layers/core/server/utils/filter-predicate.ts +101 -0
  145. package/layers/core/server/utils/http.ts +50 -0
  146. package/layers/core/server/utils/if-unmodified.ts +13 -0
  147. package/layers/core/server/utils/kestrel-config.ts +410 -0
  148. package/layers/core/server/utils/key-lock.ts +33 -0
  149. package/layers/core/server/utils/locale.ts +53 -0
  150. package/layers/core/server/utils/page-route.ts +58 -0
  151. package/layers/core/server/utils/page-slug.ts +127 -0
  152. package/layers/core/server/utils/picker.ts +82 -0
  153. package/layers/core/server/utils/populate.ts +48 -0
  154. package/layers/core/server/utils/prerender-memo.ts +23 -0
  155. package/layers/core/server/utils/read-capture.ts +37 -0
  156. package/layers/core/server/utils/record-ref-index.ts +200 -0
  157. package/layers/core/server/utils/registry.ts +24 -0
  158. package/layers/core/server/utils/resolve-scope.ts +79 -0
  159. package/layers/core/server/utils/seo.ts +24 -0
  160. package/layers/core/server/utils/serialize-collection.ts +149 -0
  161. package/layers/core/server/utils/server-config.ts +18 -0
  162. package/layers/core/server/utils/sql.ts +2 -0
  163. package/layers/core/server/utils/storage.local.ts +117 -0
  164. package/layers/core/server/utils/storage.s3.ts +197 -0
  165. package/layers/core/server/utils/storage.ts +90 -0
  166. package/layers/core/server/utils/write-events.ts +31 -0
  167. package/layers/fields/app/utils/condition.ts +71 -0
  168. package/layers/fields/app/utils/define-block.ts +13 -0
  169. package/layers/fields/app/utils/field-constraints.ts +17 -0
  170. package/layers/fields/app/utils/field-factories.ts +129 -0
  171. package/layers/fields/app/utils/field-meta.ts +33 -0
  172. package/layers/fields/app/utils/field-validate.ts +75 -0
  173. package/layers/fields/app/utils/field-value.ts +7 -0
  174. package/layers/fields/app/utils/richtext-links.ts +48 -0
  175. package/layers/fields/nuxt.config.ts +1 -0
  176. package/layers/fields/server/api/blocks/index.get.ts +9 -0
  177. package/layers/fields/server/field-registry/index.ts +248 -0
  178. package/layers/fields/server/field-registry/naming.ts +10 -0
  179. package/layers/fields/server/field-registry/sanitize.ts +40 -0
  180. package/layers/fields/server/field-registry/types.ts +19 -0
  181. package/layers/fields/server/plugins/01.register-field-populate.ts +11 -0
  182. package/layers/fields/server/utils/block-ids.ts +43 -0
  183. package/layers/fields/server/utils/block-populate.ts +41 -0
  184. package/layers/fields/server/utils/buildCollection.ts +88 -0
  185. package/layers/fields/server/utils/buildFieldSchema.ts +22 -0
  186. package/layers/fields/server/utils/buildTable.ts +107 -0
  187. package/layers/fields/server/utils/conditional-required.ts +25 -0
  188. package/layers/fields/server/utils/defineBlock.ts +86 -0
  189. package/layers/fields/server/utils/defineFieldType.ts +33 -0
  190. package/layers/fields/server/utils/extract-refs.ts +152 -0
  191. package/layers/fields/server/utils/field-populate.ts +68 -0
  192. package/layers/media/app/components/KestrelImg.vue +65 -0
  193. package/layers/media/app/components/MediaConflictDialog.vue +35 -0
  194. package/layers/media/app/components/MediaDeleteDialog.vue +52 -0
  195. package/layers/media/app/components/MediaGrid.vue +113 -0
  196. package/layers/media/app/components/MediaImage.vue +70 -0
  197. package/layers/media/app/components/MediaLibrary.vue +451 -0
  198. package/layers/media/app/components/MediaNewFolderDialog.vue +32 -0
  199. package/layers/media/app/components/MediaPathBar.vue +163 -0
  200. package/layers/media/app/components/MediaPicker.vue +19 -0
  201. package/layers/media/app/components/MediaRenameDialog.vue +32 -0
  202. package/layers/media/app/components/MediaTable.vue +75 -0
  203. package/layers/media/app/components/MediaThumb.vue +25 -0
  204. package/layers/media/app/components/MediaToolbar.vue +90 -0
  205. package/layers/media/app/components/MediaUploadDialog.vue +87 -0
  206. package/layers/media/app/components/MediaViewer.vue +88 -0
  207. package/layers/media/app/components/field/Media.vue +261 -0
  208. package/layers/media/app/composables/useBlurUp.ts +55 -0
  209. package/layers/media/app/composables/useMediaClipboard.ts +14 -0
  210. package/layers/media/app/composables/useMediaContextMenu.ts +56 -0
  211. package/layers/media/app/composables/useMediaDnd.ts +56 -0
  212. package/layers/media/app/composables/useMediaLibrary.ts +151 -0
  213. package/layers/media/app/composables/useMediaOps.ts +50 -0
  214. package/layers/media/app/composables/useMediaRelocate.ts +39 -0
  215. package/layers/media/app/composables/useMediaResolver.ts +50 -0
  216. package/layers/media/app/composables/useMediaUpload.ts +93 -0
  217. package/layers/media/app/composables/useMediaVariant.ts +34 -0
  218. package/layers/media/app/plugins/field-media.client.ts +6 -0
  219. package/layers/media/app/utils/dnd.ts +69 -0
  220. package/layers/media/app/utils/library.ts +79 -0
  221. package/layers/media/app/utils/ops.ts +62 -0
  222. package/layers/media/app/utils/picture-model.ts +125 -0
  223. package/layers/media/app/utils/thumbhash.ts +19 -0
  224. package/layers/media/app/utils/variant-name.ts +20 -0
  225. package/layers/media/nuxt.config.ts +3 -0
  226. package/layers/media/server/api/media/[id]/usages.get.ts +8 -0
  227. package/layers/media/server/api/media/[id].delete.ts +18 -0
  228. package/layers/media/server/api/media/[id].get.ts +10 -0
  229. package/layers/media/server/api/media/[id].patch.ts +41 -0
  230. package/layers/media/server/api/media/backfill.post.ts +18 -0
  231. package/layers/media/server/api/media/copy.post.ts +7 -0
  232. package/layers/media/server/api/media/delete.post.ts +13 -0
  233. package/layers/media/server/api/media/folders.post.ts +18 -0
  234. package/layers/media/server/api/media/index.get.ts +15 -0
  235. package/layers/media/server/api/media/index.post.ts +157 -0
  236. package/layers/media/server/api/media/library.get.ts +17 -0
  237. package/layers/media/server/api/media/move.post.ts +7 -0
  238. package/layers/media/server/api/media/rename.post.ts +7 -0
  239. package/layers/media/server/api/media/resolve.get.ts +13 -0
  240. package/layers/media/server/collections/media-settings.ts +25 -0
  241. package/layers/media/server/collections/media.ts +29 -0
  242. package/layers/media/server/database/folders.ts +10 -0
  243. package/layers/media/server/middleware/ondemand-variants.ts +37 -0
  244. package/layers/media/server/plugins/02.register-media.ts +19 -0
  245. package/layers/media/server/plugins/03.media-cleanup.ts +20 -0
  246. package/layers/media/server/plugins/04.variant-capture.ts +23 -0
  247. package/layers/media/server/schema-tables/folders.ts +4 -0
  248. package/layers/media/server/tasks/media/backfill.ts +18 -0
  249. package/layers/media/server/utils/backfill.ts +132 -0
  250. package/layers/media/server/utils/derive.ts +59 -0
  251. package/layers/media/server/utils/folder-paths.ts +42 -0
  252. package/layers/media/server/utils/folders.ts +12 -0
  253. package/layers/media/server/utils/library.ts +90 -0
  254. package/layers/media/server/utils/media-cleanup.ts +27 -0
  255. package/layers/media/server/utils/media-enabled.ts +23 -0
  256. package/layers/media/server/utils/media-ops.ts +118 -0
  257. package/layers/media/server/utils/media-write.ts +13 -0
  258. package/layers/media/server/utils/naming.ts +52 -0
  259. package/layers/media/server/utils/ondemand.ts +69 -0
  260. package/layers/media/server/utils/persist-upload.ts +62 -0
  261. package/layers/media/server/utils/populate.ts +47 -0
  262. package/layers/media/server/utils/record.ts +63 -0
  263. package/layers/media/server/utils/relocate-ops.ts +341 -0
  264. package/layers/media/server/utils/relocate-plan.ts +63 -0
  265. package/layers/media/server/utils/resolve.ts +86 -0
  266. package/layers/media/server/utils/sanitize-svg.ts +12 -0
  267. package/layers/media/server/utils/sniff.ts +68 -0
  268. package/layers/media/server/utils/storage-relocate.ts +139 -0
  269. package/layers/media/server/utils/translations.ts +19 -0
  270. package/layers/media/server/utils/usages.ts +66 -0
  271. package/layers/media/server/utils/variant-capture.ts +70 -0
  272. package/layers/media/server/utils/variants.ts +108 -0
  273. package/layers/public/app/app.vue +5 -0
  274. package/layers/public/app/components/BlockRenderer.vue +56 -0
  275. package/layers/public/app/components/KestrelLink.vue +26 -0
  276. package/layers/public/app/components/KestrelPreviewBridge.vue +133 -0
  277. package/layers/public/app/composables/public-page.ts +24 -0
  278. package/layers/public/app/error.vue +12 -0
  279. package/layers/public/app/layouts/default.vue +5 -0
  280. package/layers/public/app/pages/[...slug].vue +157 -0
  281. package/layers/public/app/pages/__kestrel/preview.vue +34 -0
  282. package/layers/public/app/utils/block-edit-context.ts +16 -0
  283. package/layers/public/app/utils/link-href.ts +46 -0
  284. package/layers/public/app/utils/page-head.ts +84 -0
  285. package/layers/public/app/utils/preview-protocol.ts +81 -0
  286. package/layers/public/app/utils/public-route.ts +46 -0
  287. package/layers/public/modules/deploy-output/deploy-output.ts +370 -0
  288. package/layers/public/modules/deploy-output/index.ts +82 -0
  289. package/layers/public/modules/prerender-routes/discover.ts +37 -0
  290. package/layers/public/modules/prerender-routes/index.ts +85 -0
  291. package/layers/public/modules/prerender-routes/routes.ts +16 -0
  292. package/layers/public/modules/prune-media/index.ts +48 -0
  293. package/layers/public/modules/prune-media/prune-media.ts +81 -0
  294. package/layers/public/nuxt.config.ts +11 -0
  295. package/layers/public/server/api/links/resolve.get.ts +19 -0
  296. package/layers/public/server/api/publish-status.get.ts +44 -0
  297. package/layers/public/server/api/route.get.ts +18 -0
  298. package/layers/public/server/database/publish-deps.ts +12 -0
  299. package/layers/public/server/database/publish-status.ts +19 -0
  300. package/layers/public/server/plugins/02.register-links.ts +17 -0
  301. package/layers/public/server/plugins/zz.publish.ts +63 -0
  302. package/layers/public/server/routes/llms.txt.get.ts +72 -0
  303. package/layers/public/server/routes/robots.txt.get.ts +8 -0
  304. package/layers/public/server/routes/sitemap.xml.get.ts +66 -0
  305. package/layers/public/server/schema-tables/publish-deps.ts +4 -0
  306. package/layers/public/server/schema-tables/publish-status.ts +4 -0
  307. package/layers/public/server/tasks/publish/run.ts +26 -0
  308. package/layers/public/server/utils/link-resolve.ts +35 -0
  309. package/layers/public/server/utils/llms.ts +39 -0
  310. package/layers/public/server/utils/page-resolve.ts +86 -0
  311. package/layers/public/server/utils/populate-links.ts +66 -0
  312. package/layers/public/server/utils/publish/coalesce.ts +22 -0
  313. package/layers/public/server/utils/publish/deps-persistence.ts +43 -0
  314. package/layers/public/server/utils/publish/deps.ts +87 -0
  315. package/layers/public/server/utils/publish/invalidation.ts +141 -0
  316. package/layers/public/server/utils/publish/publish-status.ts +54 -0
  317. package/layers/public/server/utils/publish/publisher.ts +291 -0
  318. package/layers/public/server/utils/publish/queue.ts +67 -0
  319. package/layers/public/server/utils/publish/route-for-record.ts +16 -0
  320. package/layers/public/server/utils/publish/route-keys.ts +13 -0
  321. package/layers/public/server/utils/site-url.ts +32 -0
  322. package/layers/public/server/utils/sitemap.ts +97 -0
  323. package/layers/ui/app/assets/scss/_base.scss +16 -0
  324. package/layers/ui/app/assets/scss/_datepicker.scss +82 -0
  325. package/layers/ui/app/assets/scss/_mixins.scss +50 -0
  326. package/layers/ui/app/assets/scss/_reset.scss +65 -0
  327. package/layers/ui/app/assets/scss/_tokens.scss +183 -0
  328. package/layers/ui/app/assets/scss/main.scss +4 -0
  329. package/layers/ui/app/components/field/Boolean.vue +30 -0
  330. package/layers/ui/app/components/field/Choice.vue +68 -0
  331. package/layers/ui/app/components/field/Datetime.vue +96 -0
  332. package/layers/ui/app/components/field/DeadRefNote.vue +28 -0
  333. package/layers/ui/app/components/field/Json.vue +63 -0
  334. package/layers/ui/app/components/field/Layout.vue +140 -0
  335. package/layers/ui/app/components/field/Link.vue +112 -0
  336. package/layers/ui/app/components/field/LinkInternalPicker.vue +95 -0
  337. package/layers/ui/app/components/field/Number.vue +30 -0
  338. package/layers/ui/app/components/field/Relation.vue +49 -0
  339. package/layers/ui/app/components/field/Renderer.vue +30 -0
  340. package/layers/ui/app/components/field/Repeater.vue +460 -0
  341. package/layers/ui/app/components/field/Richtext.vue +26 -0
  342. package/layers/ui/app/components/field/Slug.vue +66 -0
  343. package/layers/ui/app/components/field/Text.vue +35 -0
  344. package/layers/ui/app/components/field/Unsupported.vue +34 -0
  345. package/layers/ui/app/components/ui/Alert.vue +50 -0
  346. package/layers/ui/app/components/ui/Brand.vue +22 -0
  347. package/layers/ui/app/components/ui/Button.vue +116 -0
  348. package/layers/ui/app/components/ui/ButtonGroup.vue +96 -0
  349. package/layers/ui/app/components/ui/Checkbox.vue +52 -0
  350. package/layers/ui/app/components/ui/CheckboxGroup.vue +50 -0
  351. package/layers/ui/app/components/ui/Combobox.vue +343 -0
  352. package/layers/ui/app/components/ui/DatePicker.vue +92 -0
  353. package/layers/ui/app/components/ui/DateRangePicker.vue +100 -0
  354. package/layers/ui/app/components/ui/Dialog.vue +107 -0
  355. package/layers/ui/app/components/ui/EmptyState.vue +46 -0
  356. package/layers/ui/app/components/ui/Field.vue +57 -0
  357. package/layers/ui/app/components/ui/Fieldset.vue +64 -0
  358. package/layers/ui/app/components/ui/Icon.vue +44 -0
  359. package/layers/ui/app/components/ui/Menu.vue +54 -0
  360. package/layers/ui/app/components/ui/NumberInput.vue +173 -0
  361. package/layers/ui/app/components/ui/Richtext.vue +119 -0
  362. package/layers/ui/app/components/ui/RichtextToolbar.vue +253 -0
  363. package/layers/ui/app/components/ui/Select.vue +51 -0
  364. package/layers/ui/app/components/ui/TextInput.vue +110 -0
  365. package/layers/ui/app/components/ui/Textarea.vue +38 -0
  366. package/layers/ui/app/components/ui/TimeInput.vue +17 -0
  367. package/layers/ui/app/components/ui/Toasts.vue +103 -0
  368. package/layers/ui/app/components/ui/Tooltip.vue +45 -0
  369. package/layers/ui/app/composables/useAdminLang.ts +8 -0
  370. package/layers/ui/app/composables/useDragReorder.ts +41 -0
  371. package/layers/ui/app/composables/useEchoGuard.ts +28 -0
  372. package/layers/ui/app/composables/useLinkField.ts +70 -0
  373. package/layers/ui/app/composables/useRecordOptions.ts +84 -0
  374. package/layers/ui/app/composables/useRepeater.ts +99 -0
  375. package/layers/ui/app/composables/useT.ts +32 -0
  376. package/layers/ui/app/composables/useToast.ts +46 -0
  377. package/layers/ui/app/i18n/de.ts +406 -0
  378. package/layers/ui/app/i18n/en.ts +440 -0
  379. package/layers/ui/app/utils/boolean-field.ts +10 -0
  380. package/layers/ui/app/utils/field-component.ts +22 -0
  381. package/layers/ui/app/utils/field-empty.ts +21 -0
  382. package/layers/ui/app/utils/field-registry.ts +36 -0
  383. package/layers/ui/app/utils/humanize.ts +14 -0
  384. package/layers/ui/app/utils/icons.ts +147 -0
  385. package/layers/ui/app/utils/iso-date.ts +18 -0
  386. package/layers/ui/app/utils/localized.ts +13 -0
  387. package/layers/ui/app/utils/record-context.ts +15 -0
  388. package/layers/ui/app/utils/reorder.ts +8 -0
  389. package/layers/ui/app/utils/richtext-preserve-class.ts +31 -0
  390. package/layers/ui/app/utils/useFieldA11y.ts +40 -0
  391. package/layers/ui/nuxt.config.ts +5 -0
  392. package/nuxt.config.ts +105 -0
  393. package/package.json +122 -0
  394. package/scripts/hash-password.mjs +26 -0
  395. package/scripts/typecheck.mjs +51 -0
@@ -0,0 +1,147 @@
1
+ export type IconName =
2
+ | 'grip' | 'chevron-up' | 'chevron-down' | 'chevron-left' | 'chevron-right' | 'arrow-left'
3
+ | 'copy' | 'trash' | 'x' | 'plus' | 'pencil' | 'check'
4
+ | 'home' | 'file-text' | 'folder' | 'folder-plus' | 'panel-left-close' | 'panel-left-open' | 'log-out'
5
+ | 'sun' | 'moon' | 'calendar' | 'upload' | 'layout-grid' | 'table'
6
+ // collection icons + list toolbar
7
+ | 'settings' | 'image' | 'newspaper' | 'columns' | 'filter' | 'search' | 'sliders'
8
+ // richtext toolbar
9
+ | 'bold' | 'italic' | 'underline' | 'strikethrough' | 'code' | 'square-code' | 'highlighter'
10
+ | 'subscript' | 'superscript' | 'list' | 'list-ordered' | 'quote' | 'minus' | 'message-square'
11
+ | 'align-left' | 'align-center' | 'align-right' | 'align-justify'
12
+ | 'eraser' | 'undo' | 'redo' | 'link' | 'link-2'
13
+ | 'external-link' | 'triangle-alert'
14
+ // password reveal
15
+ | 'eye' | 'eye-off'
16
+ // preview device widths + reload
17
+ | 'monitor' | 'tablet' | 'smartphone' | 'rotate-cw'
18
+ // block-type tiles
19
+ | 'clock' | 'user'
20
+
21
+ export const icons: Record<IconName, string> = {
22
+ 'monitor': '<rect width="20" height="14" x="2" y="3" rx="2"/><line x1="8" x2="16" y1="21" y2="21"/><line x1="12" x2="12" y1="17" y2="21"/>',
23
+ 'tablet': '<rect width="16" height="20" x="4" y="2" rx="2" ry="2"/><line x1="12" x2="12.01" y1="18" y2="18"/>',
24
+ 'smartphone': '<rect width="14" height="20" x="5" y="2" rx="2" ry="2"/><path d="M12 18h.01"/>',
25
+ 'rotate-cw': '<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/>',
26
+ 'clock': '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
27
+ 'user': '<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
28
+ 'chevron-up': '<path d="m18 15-6-6-6 6"/>',
29
+ 'chevron-down': '<path d="m6 9 6 6 6-6"/>',
30
+ 'chevron-left': '<path d="m15 18-6-6 6-6"/>',
31
+ 'chevron-right': '<path d="m9 18 6-6-6-6"/>',
32
+ 'arrow-left': '<path d="m12 19-7-7 7-7"/><path d="M19 12H5"/>',
33
+ 'x': '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
34
+ 'plus': '<path d="M5 12h14"/><path d="M12 5v14"/>',
35
+ 'check': '<path d="M20 6 9 17l-5-5"/>',
36
+ 'trash': '<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/>',
37
+ 'copy': '<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>',
38
+ 'grip': '<circle cx="9" cy="6" r="1"/><circle cx="9" cy="12" r="1"/><circle cx="9" cy="18" r="1"/><circle cx="15" cy="6" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="15" cy="18" r="1"/>',
39
+ 'pencil': '<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/>',
40
+ 'home': '<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><path d="M9 22V12h6v10"/>',
41
+ 'file-text': '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/>',
42
+ 'folder': '<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>',
43
+ 'panel-left-close': '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M9 3v18"/><path d="m16 15-3-3 3-3"/>',
44
+ 'panel-left-open': '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M9 3v18"/><path d="m14 9 3 3-3 3"/>',
45
+ 'log-out': '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="m16 17 5-5-5-5"/><path d="M21 12H9"/>',
46
+ 'sun': '<circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/>',
47
+ 'moon': '<path d="M12 3a6.36 6.36 0 0 0 9 9 9 9 0 1 1-9-9Z"/>',
48
+ 'calendar': '<path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/>',
49
+ 'folder-plus': '<path d="M12 10v6"/><path d="M9 13h6"/><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>',
50
+ 'upload': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" x2="12" y1="3" y2="15"/>',
51
+ 'layout-grid': '<rect width="7" height="7" x="3" y="3" rx="1"/><rect width="7" height="7" x="14" y="3" rx="1"/><rect width="7" height="7" x="14" y="14" rx="1"/><rect width="7" height="7" x="3" y="14" rx="1"/>',
52
+ 'table': '<path d="M12 3v18"/><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 9h18"/><path d="M3 15h18"/>',
53
+ 'bold': '<path d="M14 12a4 4 0 0 0 0-8H6v8"/><path d="M15 20a4 4 0 0 0 0-8H6v8Z"/>',
54
+ 'italic': '<line x1="19" x2="10" y1="4" y2="4"/><line x1="14" x2="5" y1="20" y2="20"/><line x1="15" x2="9" y1="4" y2="20"/>',
55
+ 'underline': '<path d="M6 4v6a6 6 0 0 0 12 0V4"/><line x1="4" x2="20" y1="21" y2="21"/>',
56
+ 'strikethrough': '<path d="M16 4H9a3 3 0 0 0-2.83 4"/><path d="M14 12a4 4 0 0 1 0 8H6"/><line x1="4" x2="20" y1="12" y2="12"/>',
57
+ 'code': '<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>',
58
+ 'square-code': '<path d="m10 9-3 3 3 3"/><path d="m14 15 3-3-3-3"/><rect width="18" height="18" x="3" y="3" rx="2"/>',
59
+ 'highlighter': '<path d="m9 11-6 6v3h9l3-3"/><path d="m22 12-4.6 4.6a2 2 0 0 1-2.8 0l-5.2-5.2a2 2 0 0 1 0-2.8L14 4"/>',
60
+ 'subscript': '<path d="m4 5 8 8"/><path d="m12 5-8 8"/><path d="M20 19h-4c0-1.5.44-2 1.5-2.5S20 15.33 20 14c0-.47-.17-.93-.48-1.29a2.11 2.11 0 0 0-2.62-.44c-.42.24-.74.62-.9 1.07"/>',
61
+ 'superscript': '<path d="m4 19 8-8"/><path d="m12 19-8-8"/><path d="M20 12h-4c0-1.5.44-2 1.5-2.5S20 8.33 20 7c0-.47-.17-.93-.48-1.29a2.11 2.11 0 0 0-2.62-.44c-.42.24-.74.62-.9 1.07"/>',
62
+ 'list': '<line x1="8" x2="21" y1="6" y2="6"/><line x1="8" x2="21" y1="12" y2="12"/><line x1="8" x2="21" y1="18" y2="18"/><line x1="3" x2="3.01" y1="6" y2="6"/><line x1="3" x2="3.01" y1="12" y2="12"/><line x1="3" x2="3.01" y1="18" y2="18"/>',
63
+ 'list-ordered': '<line x1="10" x2="21" y1="6" y2="6"/><line x1="10" x2="21" y1="12" y2="12"/><line x1="10" x2="21" y1="18" y2="18"/><path d="M4 6h1v4"/><path d="M4 10h2"/><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"/>',
64
+ 'quote': '<path d="M17 6H3"/><path d="M21 12H8"/><path d="M21 18H8"/><path d="M3 12v6"/>',
65
+ 'message-square': '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
66
+ 'minus': '<path d="M5 12h14"/>',
67
+ 'align-left': '<line x1="21" x2="3" y1="6" y2="6"/><line x1="15" x2="3" y1="12" y2="12"/><line x1="17" x2="3" y1="18" y2="18"/>',
68
+ 'align-center': '<line x1="21" x2="3" y1="6" y2="6"/><line x1="17" x2="7" y1="12" y2="12"/><line x1="19" x2="5" y1="18" y2="18"/>',
69
+ 'align-right': '<line x1="21" x2="3" y1="6" y2="6"/><line x1="21" x2="9" y1="12" y2="12"/><line x1="21" x2="7" y1="18" y2="18"/>',
70
+ 'align-justify': '<line x1="3" x2="21" y1="6" y2="6"/><line x1="3" x2="21" y1="12" y2="12"/><line x1="3" x2="21" y1="18" y2="18"/>',
71
+ 'eraser': '<path d="m7 21-4.3-4.3c-1-1-1-2.5 0-3.4l9.6-9.6c1-1 2.5-1 3.4 0l5.6 5.6c1 1 1 2.5 0 3.4L13 21"/><path d="M22 21H7"/><path d="m5 11 9 9"/>',
72
+ 'undo': '<path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13"/>',
73
+ 'redo': '<path d="M21 7v6h-6"/><path d="M3 17a9 9 0 0 1 9-9 9 9 0 0 1 6 2.3l3 2.7"/>',
74
+ 'link': '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
75
+ 'link-2': '<path d="M9 17H7A5 5 0 0 1 7 7h2"/><path d="M15 7h2a5 5 0 1 1 0 10h-2"/><line x1="8" x2="16" y1="12" y2="12"/>',
76
+ 'external-link': '<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h6"/>',
77
+ 'triangle-alert': '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
78
+ 'settings': '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
79
+ 'image': '<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>',
80
+ 'newspaper': '<path d="M15 18h-5"/><path d="M18 14h-8"/><path d="M15 22H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v18a2 2 0 0 0 4 0v-9a2 2 0 0 0-2-2h-2"/><rect width="8" height="4" x="6" y="6" rx="1"/>',
81
+ 'columns': '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M9 3v18"/><path d="M15 3v18"/>',
82
+ 'filter': '<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/>',
83
+ 'search': '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
84
+ 'sliders': '<line x1="21" x2="14" y1="4" y2="4"/><line x1="10" x2="3" y1="4" y2="4"/><line x1="21" x2="12" y1="12" y2="12"/><line x1="8" x2="3" y1="12" y2="12"/><line x1="21" x2="16" y1="20" y2="20"/><line x1="12" x2="3" y1="20" y2="20"/><line x1="14" x2="14" y1="2" y2="6"/><line x1="8" x2="8" y1="10" y2="14"/><line x1="16" x2="16" y1="18" y2="22"/>',
85
+ 'eye': '<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/>',
86
+ 'eye-off': '<path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49"/><path d="M14.084 14.158a3 3 0 0 1-4.242-4.242"/><path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143"/><path d="m2 2 20 20"/>',
87
+ }
88
+
89
+ /** A registry key, or arbitrary (developer-defined) inline SVG. The named-icon overloads keep editor
90
+ * autocomplete while allowing a collection to supply its own icon markup. */
91
+ export type IconSpec = IconName | (string & {})
92
+
93
+ export function isIconName(name: string): name is IconName {
94
+ return Object.hasOwn(icons, name)
95
+ }
96
+
97
+ // Known-safe SVG elements/attributes. Names are lowercased for comparison; the original casing is
98
+ // preserved on output (`viewBox`, `linearGradient`, … are case-sensitive in SVG).
99
+ const ALLOWED_SVG_TAGS = new Set([
100
+ 'svg', 'path', 'circle', 'rect', 'line', 'polyline', 'polygon', 'ellipse',
101
+ 'g', 'defs', 'lineargradient', 'radialgradient', 'stop', 'clippath', 'mask',
102
+ 'title', 'desc',
103
+ ])
104
+ const ALLOWED_SVG_ATTRS = new Set([
105
+ 'd', 'cx', 'cy', 'r', 'rx', 'ry', 'x', 'y', 'x1', 'y1', 'x2', 'y2', 'fx', 'fy',
106
+ 'width', 'height', 'points', 'transform', 'gradienttransform', 'viewbox', 'preserveaspectratio',
107
+ 'fill', 'fill-rule', 'fill-opacity', 'stroke', 'stroke-width', 'stroke-linecap',
108
+ 'stroke-linejoin', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-opacity', 'stroke-miterlimit',
109
+ 'opacity', 'offset', 'stop-color', 'stop-opacity', 'gradientunits', 'spreadmethod',
110
+ 'clip-path', 'clip-rule', 'id', 'class',
111
+ ])
112
+
113
+ /**
114
+ * Allowlist-sanitise developer-supplied icon SVG before it reaches `v-html` (Icon.vue). We keep ONLY
115
+ * known-safe SVG elements and attributes and drop everything else, rather than blocklisting bad patterns
116
+ * (a denylist is defeated by `/`-separated handlers, entity-encoded `javascript:`, etc.). So `<script>`,
117
+ * `<image>`, `<animate>`, `<a>`, `<use>`, `<foreignObject>`, every `on*` handler and every URL-bearing
118
+ * attribute are removed regardless of how they are written; no allowed element/attribute can load a URL
119
+ * or run script. Collection icons are developer-authored today, but this stays safe if collections become
120
+ * runtime-defined (the schema-engine roadmap), where `icon` would cross a user-controlled boundary.
121
+ */
122
+ export function sanitizeIconSvg(svg: string): string {
123
+ return svg.replace(/<(\/?)([a-zA-Z][\w:-]*)((?:"[^"]*"|'[^']*'|[^>])*?)(\/?)>/g, (_m, close, tag, attrs, selfClose) => {
124
+ if (!ALLOWED_SVG_TAGS.has(tag.toLowerCase())) return ''
125
+ if (close) return `</${tag}>`
126
+ const kept: string[] = []
127
+ const attrRe = /([a-zA-Z_:][\w:.-]*)\s*=\s*("[^"]*"|'[^']*'|[^\s"'>]+)/g
128
+ let a: RegExpExecArray | null
129
+ while ((a = attrRe.exec(attrs))) {
130
+ if (ALLOWED_SVG_ATTRS.has(a[1].toLowerCase())) kept.push(`${a[1]}=${a[2]}`)
131
+ }
132
+ return `<${tag}${kept.length ? ' ' + kept.join(' ') : ''}${selfClose ? '/' : ''}>`
133
+ })
134
+ }
135
+
136
+ /**
137
+ * Resolve an `IconSpec` to the inner SVG markup rendered inside the standard `<svg>` wrapper. A registry
138
+ * name yields its stored body; anything containing `<` is treated as raw inner-SVG markup (a full
139
+ * `<svg>…</svg>` is unwrapped to its children so it inherits the wrapper's size/`currentColor`), then
140
+ * sanitized. Unknown bare strings render nothing.
141
+ */
142
+ export function resolveIconBody(name: string): string {
143
+ if (isIconName(name)) return icons[name]
144
+ if (!name.includes('<')) return ''
145
+ const inner = name.replace(/^\s*<svg[^>]*>([\s\S]*)<\/svg\s*>\s*$/i, '$1')
146
+ return sanitizeIconSvg(inner)
147
+ }
@@ -0,0 +1,18 @@
1
+ import { parseDate, parseDateTime, type DateValue } from '@internationalized/date'
2
+
3
+ export type IsoPrecision = 'date' | 'datetime'
4
+
5
+ /** ISO string → a `@internationalized/date` value for Reka; invalid/empty → undefined. */
6
+ export function isoToDate(iso: string | null | undefined, precision: IsoPrecision): DateValue | undefined {
7
+ if (!iso) return undefined
8
+ try {
9
+ return precision === 'date' ? parseDate(iso) : parseDateTime(iso)
10
+ } catch {
11
+ return undefined
12
+ }
13
+ }
14
+
15
+ /** A `@internationalized/date` value → ISO string; null/undefined → null. */
16
+ export function dateToIso(value: DateValue | null | undefined): string | null {
17
+ return value ? value.toString() : null
18
+ }
@@ -0,0 +1,13 @@
1
+ import type { Localized } from '../../../core/server/utils/defineCollection'
2
+
3
+ /**
4
+ * Resolve a (possibly localized) label for the active admin language. A plain string is returned as-is
5
+ * (language-agnostic); a `{ <lang>: string }` map resolves to the requested language, then `en`, then any
6
+ * present value. Returns `undefined` when there is nothing to show, so callers fall back (e.g. to the
7
+ * collection/block name). One source of label resolution for the nav, list, editor and block panes.
8
+ */
9
+ export function resolveLocalized(value: Localized | undefined, lang: string): string | undefined {
10
+ if (value == null) return undefined
11
+ if (typeof value === 'string') return value
12
+ return value[lang] ?? value.en ?? Object.values(value)[0]
13
+ }
@@ -0,0 +1,15 @@
1
+ import type { InjectionKey, Ref } from 'vue'
2
+
3
+ /** Editor → field-widget context: lets a widget know the record's id (`'new'` until first save) and whether
4
+ * it has been saved. A field widget that manages out-of-band storage (e.g. the secure gallery's namespace)
5
+ * uses this to clean up after an abandoned, never-saved draft. Provided by `CollectionEditor`; mirrors the
6
+ * `blockEditKey` provide/inject precedent. Auto-imported (no path import needed in an extension). */
7
+ export interface RecordEditContext {
8
+ id: Ref<string>
9
+ saved: Ref<boolean>
10
+ /** The editor's reactive record values, so a widget can read SIBLING fields (e.g. the gallery `slug`,
11
+ * which the proofing overlay uses as the submission key). Reactive object — read `values.<field>`. */
12
+ values: Record<string, unknown>
13
+ }
14
+
15
+ export const recordEditContextKey = Symbol('kestrel.recordEdit') as InjectionKey<RecordEditContext>
@@ -0,0 +1,8 @@
1
+ export function reorder<T>(arr: readonly T[], from: number, to: number): T[] {
2
+ if (from === to || from < 0 || to < 0 || from >= arr.length || to >= arr.length) {
3
+ return [...arr]
4
+ }
5
+ const result = [...arr]
6
+ result.splice(to, 0, result.splice(from, 1)[0] as T)
7
+ return result
8
+ }
@@ -0,0 +1,31 @@
1
+ import { Mark, Extension } from '@tiptap/vue-3' // re-exports @tiptap/core; keeps deps unchanged
2
+
3
+ // StarterKit has no span/TextStyle mark and no `class` attribute on block nodes, so parsing stored
4
+ // markup discards every consumer presentational class (e.g. the hero title's `text--color-white`).
5
+ // These two extensions teach the schema to keep `class` on span wrappers and on block nodes, matching
6
+ // what sanitize.ts already allows (`*: [class]`) so the class survives an edit/focus round-trip.
7
+ const classAttr = {
8
+ class: {
9
+ default: null as string | null,
10
+ parseHTML: (el: HTMLElement) => el.getAttribute('class'),
11
+ renderHTML: (a: Record<string, unknown>) => (a.class ? { class: a.class } : {}),
12
+ },
13
+ }
14
+
15
+ // Inline `<span class="…">` wrapper. Only spans that HAVE a class become this mark, so ordinary spans
16
+ // aren't captured.
17
+ export const RichtextSpanClass = Mark.create({
18
+ name: 'spanClass',
19
+ parseHTML: () => [{ tag: 'span[class]' }],
20
+ renderHTML: ({ HTMLAttributes }) => ['span', HTMLAttributes, 0],
21
+ addAttributes: () => classAttr,
22
+ })
23
+
24
+ // Block-level class (e.g. `<p class="text--section-label">`).
25
+ export const RichtextBlockClass = Extension.create({
26
+ name: 'blockClass',
27
+ addGlobalAttributes: () => [{
28
+ types: ['heading', 'paragraph', 'blockquote', 'bulletList', 'orderedList', 'listItem', 'codeBlock'],
29
+ attributes: classAttr,
30
+ }],
31
+ })
@@ -0,0 +1,40 @@
1
+ import { computed, useId, type ComputedRef } from 'vue'
2
+
3
+ interface FieldA11yProps {
4
+ hint?: string
5
+ error?: string | null
6
+ required?: boolean
7
+ id?: string
8
+ }
9
+
10
+ interface FieldA11y {
11
+ fieldId: ComputedRef<string>
12
+ hintId: ComputedRef<string>
13
+ errId: ComputedRef<string>
14
+ describedby: ComputedRef<string | undefined>
15
+ ariaInvalid: ComputedRef<'true' | undefined>
16
+ ariaRequired: ComputedRef<'true' | undefined>
17
+ }
18
+
19
+ /**
20
+ * Shared accessibility wiring for labelled controls (UiField) and grouped controls (UiFieldset):
21
+ * a stable id, hint/error ids, the aria-describedby join, and aria-invalid. One source so the
22
+ * describedby ordering and aria contract cannot drift between the two.
23
+ */
24
+ export function useFieldA11y(props: FieldA11yProps): FieldA11y {
25
+ const auto = useId()
26
+ const fieldId = computed(() => props.id ?? auto)
27
+ const hintId = computed(() => `${fieldId.value}-hint`)
28
+ const errId = computed(() => `${fieldId.value}-error`)
29
+ const describedby = computed(() => {
30
+ const ids: string[] = []
31
+ if (props.hint) ids.push(hintId.value)
32
+ if (props.error) ids.push(errId.value)
33
+ return ids.length ? ids.join(' ') : undefined
34
+ })
35
+ const ariaInvalid = computed(() => (props.error ? 'true' : undefined))
36
+ // Grouped/non-native controls (role=group, teleported widgets) get required conveyed via aria-required,
37
+ // since the visual asterisk is aria-hidden and a bare `required` attr on a non-input is ignored by AT.
38
+ const ariaRequired = computed(() => (props.required ? 'true' : undefined))
39
+ return { fieldId, hintId, errId, describedby, ariaInvalid, ariaRequired }
40
+ }
@@ -0,0 +1,5 @@
1
+ // The UI design system (tokens / reset / base in app/assets/scss/main.scss) is intentionally NOT loaded
2
+ // app-globally — that would leak Kestrel's CSS reset + base styles onto a consumer's public site and fight
3
+ // their own CSS (e.g. Bootstrap). It is imported by the admin layout (layers/admin/app/layouts/admin.vue)
4
+ // instead, which scopes it to /admin: every admin route uses that layout, and /admin/** is ssr:false.
5
+ export default defineNuxtConfig({})
package/nuxt.config.ts ADDED
@@ -0,0 +1,105 @@
1
+ import kestrel from './kestrel.config'
2
+ import { layerKestrelOption } from './layers/core/server/utils/kestrel-config'
3
+
4
+ // Silence known-harmless, un-actionable THIRD-PARTY build noise (output unaffected — applied to both the
5
+ // Vite client and the Nitro server Rollup passes). Everything else still surfaces — crucially, any
6
+ // circular dependency that touches OUR `layers/` code is NOT dropped, so a real cycle we introduce stays
7
+ // visible. Anything in our own code is fixed, not silenced.
8
+ // - `#__PURE__`: @vueuse/core (pulled in via reka-ui) ships a `/* #__PURE__ */` annotation in a position
9
+ // Rollup can't interpret; it just drops the comment.
10
+ // - `module-preload-polyfill`: Nuxt's own preload-polyfill transform emits no sourcemap (cosmetic).
11
+ // - Circular dependencies entirely inside dependencies (nitropack's runtime internals: app.mjs ↔
12
+ // cache.mjs / utils.mjs, etc.) — upstream code we don't author and can't fix.
13
+ const SILENCED_BUILD_WARNINGS = ['#__PURE__', 'module-preload-polyfill']
14
+ // Typed inline — Rollup's `WarningHandlerWithDefault` isn't importable here (rollup is a transitive, not a
15
+ // direct, dep). `{ message?: string }` reads the bit we filter on; `defaultHandler` is structurally the
16
+ // rollup default handler (cast to call it with our wider warning shape).
17
+ const onwarn = (warning: { message?: string }, defaultHandler: (warning: never) => void) => {
18
+ const msg = warning.message ?? ''
19
+ if (SILENCED_BUILD_WARNINGS.some((s) => msg.includes(s))) return
20
+ // Third-party-internal cycles only (no `layers/` segment ⇒ none of our code is in the loop).
21
+ if (msg.includes('Circular dependency') && !msg.includes('layers/')) return
22
+ ;(defaultHandler as (w: unknown) => void)(warning)
23
+ }
24
+
25
+ export default defineNuxtConfig({
26
+ compatibilityDate: '2026-06-02',
27
+ future: { compatibilityVersion: 4 },
28
+ // `noUncheckedIndexedAccess` is a Nuxt tsconfig DEFAULT the project never opted into; the engine's code
29
+ // (runtime collection-derived tables → `Record<string, Column>` lookups, pervasive index access) was not
30
+ // written for it, so leaving it on would mean ~100 `!`-assertions that mostly HIDE undefined rather than
31
+ // catch it. Off → the `typecheck` gate is meaningful (real type errors surface) without that noise; it
32
+ // also keeps a consumer's own `nuxt typecheck` from drowning in errors against the shipped engine code.
33
+ // It must be set in BOTH places: `typescript.tsConfig` covers the app/shared/node tsconfigs, and
34
+ // `nitro.typescript.tsConfig` covers the separately-generated server (Nitro) tsconfig.
35
+ // Exclude tests from the typecheck (vitest covers them at runtime). The generated config's `include` uses
36
+ // parent-traversing paths (`../layers/…`), so the exclude globs must too — a bare `**/*.test.ts` won't match.
37
+ typescript: { tsConfig: { compilerOptions: { noUncheckedIndexedAccess: false }, exclude: ['../**/*.test.ts', '../../**/*.test.ts'] } },
38
+ // Explicit sub-layer composition so Kestrel works as an installable meta-layer (`extends: ['@michaelthielemann/kestrel']`):
39
+ // Nuxt does NOT transitively auto-scan an extended package's `layers/` dir, but it DOES follow its
40
+ // `extends` chain — so for a consumer this list is the ONLY thing that drives sub-layer order (core first).
41
+ // In-repo it is effectively inert: Nuxt also auto-scans the local `layers/` dir (resolved before the
42
+ // explicit `extends` and deduped by rootDir), and that auto-scan order is reverse-alphabetical — i.e.
43
+ // in-repo precedence is ui > public > media > fields > core > collections > auth > admin > access, NOT this order.
44
+ // The two contexts therefore differ; keep cross-layer basenames unique so the winner can't diverge.
45
+ extends: [
46
+ './layers/core',
47
+ './layers/fields',
48
+ './layers/ui',
49
+ './layers/media',
50
+ './layers/auth',
51
+ './layers/access',
52
+ './layers/collections',
53
+ './layers/admin',
54
+ './layers/public',
55
+ ],
56
+ // Prune `node_modules` (and the sibling `playground` app) from Nuxt's dev file-watcher. Nuxt's default
57
+ // `ignore` list does NOT include node_modules; when `@parcel/watcher` is unavailable Nuxt falls back to
58
+ // the granular chokidar watcher, whose recursively-spawned sub-watchers only apply `isIgnored` (not the
59
+ // `/node_modules/` guard the top watcher uses). Under chokidar 5 that walks the pnpm symlink farm in
60
+ // `node_modules/.pnpm` — including the `playground/node_modules/kestrel -> ..` cycle — unbounded, which
61
+ // pegs the event loop and OOMs `nuxt dev` a few minutes after boot. Ignoring node_modules here prunes it
62
+ // in every watcher mode (and benefits downstream consumers of this meta-layer, who'd hit the same bug).
63
+ // `/playground` is ANCHORED (leading slash) to this repo's own dev playground at the root — an unanchored
64
+ // `playground` is a gitignore-style any-depth match that would also exclude a downstream consumer's
65
+ // directory named `playground` from scanning. (`**/node_modules` independently prunes the OOM-causing farm.)
66
+ ignore: ['**/node_modules', '/playground'],
67
+ // `experimental.tasks` enables the `db:migrate` Nitro task — the explicit prod schema step (ADR-0002).
68
+ nitro: { externals: { external: ['better-sqlite3', 'sharp'] }, experimental: { tasks: true }, rollupConfig: { onwarn }, typescript: { tsConfig: { compilerOptions: { noUncheckedIndexedAccess: false } } } },
69
+ // Inline each prerendered page's critical CSS instead of a render-blocking <link> (the global CSS is
70
+ // small, so per-page inlining is cheap and removes a round-trip before first paint). SPA admin is
71
+ // ssr:false so it is unaffected. Explicit so the behaviour can't drift with a future Nuxt default.
72
+ features: { inlineStyles: true },
73
+ // NOTE: public pages ship the normal hydration runtime (`_nuxt`) — consumer projects build their own
74
+ // interactive Vue components, so a zero-JS (`noScripts`) default would break them. The runtime publisher
75
+ // depends on this too: `localFetch` only emits the `_nuxt` scripts + inline payload when noScripts is off.
76
+ // Single non-auth config source; the `kestrel` module (core layer) resolves it (config → KESTREL_* env
77
+ // → default) into runtimeConfig.media / runtimeConfig.public.locales + the /uploads static serving.
78
+ // `layerKestrelOption` contributes this file's values ONLY in-repo — when Kestrel is consumed as a
79
+ // dependency it yields `{}`, so Nuxt's array-concatenating layer merge can't leak the demo `locales`
80
+ // into a consumer (the consumer's own `kestrel: {}` is then the sole source).
81
+ kestrel: layerKestrelOption(kestrel, import.meta.url),
82
+ // Pre-bundle these so the dev server doesn't discover them at runtime and force a full reload.
83
+ vite: {
84
+ build: { rollupOptions: { onwarn } },
85
+ optimizeDeps: {
86
+ // Nested "kestrel > dep" form: Vite resolves each dep from the `kestrel` PACKAGE's directory
87
+ // rather than the (consumer) project root. Under pnpm these are kestrel's transitive deps and
88
+ // are NOT hoisted into a consumer's top-level node_modules, so a bare specifier is
89
+ // "Unresolvable" from the consumer root. In-repo `kestrel` is not a resolvable package name, so
90
+ // Vite's nestedResolveBasedir falls back to the repo root where these deps ARE hoisted — same
91
+ // result. The runtime `import('@tiptap/...')` still reuses the pre-bundle (tryOptimizedResolve
92
+ // matches the "> dep" id + src dir).
93
+ include: [
94
+ 'kestrel > @internationalized/date',
95
+ 'kestrel > @tiptap/extension-highlight',
96
+ 'kestrel > @tiptap/extension-subscript',
97
+ 'kestrel > @tiptap/extension-superscript',
98
+ 'kestrel > @tiptap/extension-text-align',
99
+ 'kestrel > @tiptap/starter-kit',
100
+ 'kestrel > @tiptap/vue-3',
101
+ 'kestrel > reka-ui',
102
+ ],
103
+ },
104
+ },
105
+ })
package/package.json ADDED
@@ -0,0 +1,122 @@
1
+ {
2
+ "name": "@michaelthielemann/kestrel",
3
+ "version": "1.2.1",
4
+ "description": "A slim, collection-driven Nuxt 4 CMS meta-layer with a runtime schema engine. Add `extends: ['@michaelthielemann/kestrel']`, define collections, and the database migrates itself.",
5
+ "license": "Apache-2.0",
6
+ "author": "Michael Thielemann <283621694+MichaelThielemann@users.noreply.github.com>",
7
+ "homepage": "https://github.com/MichaelThielemann/kestrel#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/MichaelThielemann/kestrel.git"
11
+ },
12
+ "bugs": "https://github.com/MichaelThielemann/kestrel/issues",
13
+ "keywords": [
14
+ "cms",
15
+ "nuxt",
16
+ "nuxt-layer",
17
+ "static-site-generator",
18
+ "drizzle",
19
+ "sqlite",
20
+ "headless-cms"
21
+ ],
22
+ "type": "module",
23
+ "main": "./nuxt.config.ts",
24
+ "packageManager": "pnpm@11.9.0",
25
+ "publishConfig": {
26
+ "access": "public",
27
+ "provenance": true
28
+ },
29
+ "//publish": "Consumed as a meta-layer: `extends: ['@michaelthielemann/kestrel']`. `main` points at the layer's nuxt.config so the BARE specifier resolves — without it c12 can't resolve the package, silently drops the whole layer (\"Cannot extend config from …\"), and every auto-import/component vanishes. Use `main`, NOT `exports` (exports would gate subpaths like `@michaelthielemann/kestrel/scripts/hash-password.mjs` + the deep config-type import). `files` ships the entry + sub-layers + operator scripts; the `!**/*.test.ts` globs strip tests. Releases run from the tag workflow via npm trusted publishing (OIDC), so no npm token exists anywhere.",
30
+ "files": [
31
+ "NOTICE",
32
+ "nuxt.config.ts",
33
+ "kestrel.config.ts",
34
+ "layers",
35
+ "scripts",
36
+ "!**/*.test.ts",
37
+ "!**/*.dom.test.ts",
38
+ "!**/*.nuxt.test.ts"
39
+ ],
40
+ "engines": {
41
+ "node": ">=22.12.0"
42
+ },
43
+ "scripts": {
44
+ "dev": "nuxt dev",
45
+ "build": "nuxt build",
46
+ "preview": "nuxt preview",
47
+ "generate": "nuxt generate",
48
+ "db:generate": "drizzle-kit generate",
49
+ "db:migrate": "drizzle-kit migrate",
50
+ "test": "vitest run",
51
+ "test:watch": "vitest",
52
+ "test:e2e": "vitest run --config vitest.e2e.config.ts",
53
+ "test:nuxt": "vitest run --config vitest.nuxt.config.ts",
54
+ "typecheck": "node scripts/typecheck.mjs",
55
+ "hash-password": "node scripts/hash-password.mjs"
56
+ },
57
+ "dependencies": {
58
+ "@babel/parser": "^7.28.0",
59
+ "@internationalized/date": "^3.12.2",
60
+ "@tiptap/extension-highlight": "^3.27.3",
61
+ "@tiptap/extension-subscript": "^3.27.3",
62
+ "@tiptap/extension-superscript": "^3.27.3",
63
+ "@tiptap/extension-text-align": "^3.27.3",
64
+ "@tiptap/pm": "^3.27.3",
65
+ "@tiptap/starter-kit": "^3.27.3",
66
+ "@tiptap/vue-3": "^3.27.3",
67
+ "aws4fetch": "^1.0.20",
68
+ "better-sqlite3": "^12.11.1",
69
+ "dompurify": "^3.4.12",
70
+ "drizzle-orm": "^0.45.2",
71
+ "drizzle-zod": "^0.8.3",
72
+ "file-type": "^22.0.1",
73
+ "jsdom": "^29.1.1",
74
+ "nanoid": "^5.1.16",
75
+ "nuxt": "^4.4.8",
76
+ "reka-ui": "^2.10.1",
77
+ "sanitize-html": "^2.17.5",
78
+ "sass": "^1.101.0",
79
+ "sharp": "^0.35.3",
80
+ "thumbhash": "^0.1.1",
81
+ "typescript": "^6.0.3",
82
+ "zod": "^4.4.3"
83
+ },
84
+ "//peers": "Framework singletons the layer sources import by bare specifier. Peers, not dependencies: a second copy breaks identity (two Vue instances; an h3 v1 `createError` handed to an h3 v2 error handler, which surfaces our 400/404/409 as bare 500s). All optional, because kestrel's own `nuxt` dependency already supplies them transitively — the entry pins identity where a copy exists rather than demanding one, and a required peer would ERESOLVE-fail npm installs on any Nuxt whose bundled range we did not anticipate. Each is also a devDependency so this repo resolves it without auto-install-peers writing a phantom root dependency into the lockfile.",
85
+ "peerDependencies": {
86
+ "@nuxt/kit": "^4.4.0",
87
+ "h3": "^1.15.0 || ^2.0.0",
88
+ "vue": "^3.5.0",
89
+ "vue-router": "^4.5.0 || ^5.0.0"
90
+ },
91
+ "peerDependenciesMeta": {
92
+ "@nuxt/kit": {
93
+ "optional": true
94
+ },
95
+ "h3": {
96
+ "optional": true
97
+ },
98
+ "vue": {
99
+ "optional": true
100
+ },
101
+ "vue-router": {
102
+ "optional": true
103
+ }
104
+ },
105
+ "devDependencies": {
106
+ "@nuxt/kit": "^4.4.8",
107
+ "@nuxt/test-utils": "^3.23.0",
108
+ "@types/better-sqlite3": "^7.6.13",
109
+ "@types/jsdom": "^28.0.3",
110
+ "@types/sanitize-html": "^2.16.1",
111
+ "@vitejs/plugin-vue": "^6.0.7",
112
+ "@vue/test-utils": "^2.4.11",
113
+ "drizzle-kit": "^0.31.10",
114
+ "h3": "^1.15.11",
115
+ "happy-dom": "^20.10.6",
116
+ "playwright-core": "^1.61.1",
117
+ "vitest": "^3.2.7",
118
+ "vue": "3.5.35",
119
+ "vue-router": "^5.1.0",
120
+ "vue-tsc": "^3.3.7"
121
+ }
122
+ }
@@ -0,0 +1,26 @@
1
+ import { scryptSync, randomBytes } from 'node:crypto'
2
+ import { createInterface } from 'node:readline'
3
+
4
+ const N = 2 ** 17
5
+ const r = 8
6
+ const p = 1
7
+ const KEYLEN = 64
8
+ const MAXMEM = 256 * 1024 * 1024
9
+
10
+ function make(password) {
11
+ const salt = randomBytes(16)
12
+ const hash = scryptSync(password, salt, KEYLEN, { N, r, p, maxmem: MAXMEM })
13
+ return `scrypt$${N}$${r}$${p}$${salt.toString('base64url')}$${hash.toString('base64url')}`
14
+ }
15
+
16
+ const arg = process.argv[2]
17
+ if (arg) {
18
+ process.stdout.write(make(arg) + '\n')
19
+ } else {
20
+ const rl = createInterface({ input: process.stdin, terminal: false })
21
+ process.stderr.write('Enter password, then press Enter:\n')
22
+ rl.on('line', (line) => {
23
+ process.stdout.write(make(line) + '\n')
24
+ rl.close()
25
+ })
26
+ }
@@ -0,0 +1,51 @@
1
+ // Typecheck gate. vitest/esbuild and `nuxt build` do NOT typecheck, so real type errors slip through
2
+ // every other gate — this is the one that catches them. It typechecks the PLAYGROUND, which composes
3
+ // the engine layers + both galleries extensions + a real consumer app/server, so this single run covers
4
+ // app, `.vue`, modules, config AND server code.
5
+ //
6
+ // Two passes, because Nuxt 4 splits the tsconfig by environment:
7
+ // 1. `tsc` over the Nitro server project (the app aggregator EXCLUDES server dirs).
8
+ // 2. `vue-tsc` (via `nuxt typecheck`) over the app/`.vue`/modules/config aggregator.
9
+ // Both exclude `*.test.ts` (vitest covers those at runtime) via the nuxt.config `typescript.tsConfig.exclude`
10
+ // and the derived server config below.
11
+ import { execFileSync } from 'node:child_process'
12
+ import { readFileSync, writeFileSync } from 'node:fs'
13
+ import { dirname, join } from 'node:path'
14
+ import { fileURLToPath } from 'node:url'
15
+
16
+ const root = join(dirname(fileURLToPath(import.meta.url)), '..')
17
+ const playground = join(root, 'playground')
18
+ const bin = (name) => join(root, 'node_modules', '.bin', name)
19
+ const env = { ...process.env, CI: 'true', NUXT_TELEMETRY_DISABLED: '1' }
20
+
21
+ // 1. (Re)generate the Nuxt/Nitro tsconfigs for the playground.
22
+ execFileSync(bin('nuxt'), ['prepare'], { cwd: playground, stdio: 'inherit', env })
23
+
24
+ // 2. Server (Nitro) project — derive a test-excluding config alongside the generated one so its relative
25
+ // include/paths still resolve.
26
+ const generated = join(playground, '.nuxt', 'tsconfig.server.json')
27
+ const cfg = JSON.parse(readFileSync(generated, 'utf8'))
28
+ cfg.exclude = [...(cfg.exclude ?? []), '../../**/*.test.ts', '../**/*.test.ts']
29
+ const gateConfig = join(playground, '.nuxt', 'tsconfig.typecheck.json')
30
+ writeFileSync(gateConfig, JSON.stringify(cfg, null, 2))
31
+ // Both passes always run, and the gate fails at the END: aborting on the first one hides every error of
32
+ // the other, and the two cover disjoint file sets — a red server pass would report zero app errors.
33
+ const failed = []
34
+ function pass(label, cmd, args) {
35
+ console.log(`› typechecking ${label}…`)
36
+ try {
37
+ execFileSync(bin(cmd), args, { cwd: playground, stdio: 'inherit', env })
38
+ } catch {
39
+ failed.push(label)
40
+ }
41
+ }
42
+
43
+ pass('server (Nitro) project', 'tsc', ['--noEmit', '-p', gateConfig])
44
+ // 3. App / `.vue` / modules / config — vue-tsc via `nuxt typecheck` (needs the committed root tsconfig.json).
45
+ pass('app + .vue (vue-tsc)', 'nuxt', ['typecheck'])
46
+
47
+ if (failed.length) {
48
+ console.error(`\n✗ typecheck failed: ${failed.join(', ')}`)
49
+ process.exit(1)
50
+ }
51
+ console.log('\n✓ typecheck passed (app + .vue + server; *.test.ts excluded)')