@morphemecms/cms 0.1.0 → 0.2.0-next-20261104150211

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 (908) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/LICENSE +21 -21
  3. package/README.md +51 -51
  4. package/astro.mjs +162 -162
  5. package/bin/morpheme.mjs +38 -38
  6. package/client.d.ts +10 -10
  7. package/defaults/blockTexts.ts +33 -33
  8. package/defaults/collections.ts +774 -774
  9. package/defaults/colors.ts +125 -125
  10. package/defaults/company.ts +20 -20
  11. package/defaults/editor.ts +178 -178
  12. package/defaults/internalLinks.ts +30 -30
  13. package/defaults/opens.ts +18 -18
  14. package/defaults/popup.tsx +93 -93
  15. package/defaults/privacy.ts +79 -79
  16. package/defaults/rules.ts +450 -450
  17. package/defaults/schema.ts +16 -16
  18. package/defaults/sitedata.ts +129 -129
  19. package/defaults/standard.ts +106 -106
  20. package/defaults/standardPages.ts +87 -87
  21. package/defaults/texts.ts +481 -481
  22. package/demo/README.md +26 -26
  23. package/demo/src/content/blog/build-a-page-from-blocks.md +19 -19
  24. package/demo/src/content/blog/colors-everyone-can-read.md +19 -19
  25. package/demo/src/content/blog/edit-your-website-from-your-phone.md +21 -21
  26. package/demo/src/content/blog/every-save-is-a-version.md +21 -21
  27. package/demo/src/content/blog/schedule-a-post.md +17 -17
  28. package/demo/src/content/blog/secure-from-day-one.md +17 -17
  29. package/demo/src/content/blog/seo-without-the-guesswork.md +23 -23
  30. package/demo/src/content/blog/switch-themes-without-losing-anything.md +23 -23
  31. package/demo/src/content/blog/the-app-store.md +21 -21
  32. package/demo/src/content/pages/about.md +22 -22
  33. package/demo/src/content/pages/app-store.md +30 -30
  34. package/demo/src/content/pages/blocks.md +98 -98
  35. package/demo/src/content/pages/blog-page.md +11 -11
  36. package/demo/src/content/pages/contact.md +16 -16
  37. package/demo/src/content/pages/get-started.md +20 -20
  38. package/demo/src/content/pages/home.md +26 -26
  39. package/demo/src/content/pages/page-not-found.md +11 -11
  40. package/demo/src/content/pages/privacy-policy.md +24 -24
  41. package/demo/src/data/blocks.json +25 -25
  42. package/demo/src/data/booking.json +6 -6
  43. package/demo/src/data/categories/apps.json +6 -6
  44. package/demo/src/data/categories/design.json +6 -6
  45. package/demo/src/data/categories/editing.json +6 -6
  46. package/demo/src/data/categories/security.json +6 -6
  47. package/demo/src/data/categories/seo.json +6 -6
  48. package/demo/src/data/colors.json +75 -75
  49. package/demo/src/data/contact.json +8 -8
  50. package/demo/src/data/cookies.json +21 -21
  51. package/demo/src/data/footer.json +27 -27
  52. package/demo/src/data/menu.json +35 -35
  53. package/demo/src/data/mobile-menu.json +25 -25
  54. package/demo/src/data/popups/booking-bar.json +13 -13
  55. package/demo/src/data/popups/try-the-editor.json +16 -16
  56. package/demo/src/data/standard-pages.json +8 -8
  57. package/demo/src/data/testimonials/sample-bakery.json +10 -10
  58. package/demo/src/data/testimonials/sample-clinic.json +10 -10
  59. package/demo/src/data/testimonials/sample-studio.json +10 -10
  60. package/demo/src/site/colors.ts +11 -11
  61. package/demo/src/site/config.ts +19 -19
  62. package/icons/lucide/LICENSE +43 -43
  63. package/icons/lucide/address-book.svg +1 -1
  64. package/icons/lucide/app-window.svg +1 -1
  65. package/icons/lucide/arrow-counter-clockwise.svg +1 -1
  66. package/icons/lucide/arrow-down.svg +1 -1
  67. package/icons/lucide/arrow-left.svg +1 -1
  68. package/icons/lucide/arrow-right.svg +1 -1
  69. package/icons/lucide/arrow-square-out.svg +1 -1
  70. package/icons/lucide/arrow-u-up-left.svg +1 -1
  71. package/icons/lucide/arrow-u-up-right.svg +1 -1
  72. package/icons/lucide/arrow-up-right.svg +1 -1
  73. package/icons/lucide/arrow-up.svg +1 -1
  74. package/icons/lucide/arrows-down-up.svg +1 -1
  75. package/icons/lucide/arrows-in-line-horizontal.svg +1 -1
  76. package/icons/lucide/arrows-out-line-horizontal.svg +1 -1
  77. package/icons/lucide/article.svg +1 -1
  78. package/icons/lucide/bank.svg +1 -1
  79. package/icons/lucide/bell.svg +1 -1
  80. package/icons/lucide/bicycle.svg +1 -1
  81. package/icons/lucide/binoculars.svg +1 -1
  82. package/icons/lucide/book-open-text.svg +1 -1
  83. package/icons/lucide/book-open.svg +1 -1
  84. package/icons/lucide/browser.svg +1 -1
  85. package/icons/lucide/buildings.svg +1 -1
  86. package/icons/lucide/bus.svg +1 -1
  87. package/icons/lucide/calendar-blank.svg +1 -1
  88. package/icons/lucide/calendar-check.svg +1 -1
  89. package/icons/lucide/car.svg +1 -1
  90. package/icons/lucide/cards.svg +1 -1
  91. package/icons/lucide/caret-down.svg +1 -1
  92. package/icons/lucide/caret-left.svg +1 -1
  93. package/icons/lucide/caret-right.svg +1 -1
  94. package/icons/lucide/caret-up.svg +1 -1
  95. package/icons/lucide/certificate.svg +1 -1
  96. package/icons/lucide/chart-line-up.svg +1 -1
  97. package/icons/lucide/chats.svg +1 -1
  98. package/icons/lucide/check-circle.svg +1 -1
  99. package/icons/lucide/check.svg +1 -1
  100. package/icons/lucide/circle-half.svg +1 -1
  101. package/icons/lucide/circle.svg +1 -1
  102. package/icons/lucide/clock-counter-clockwise.svg +1 -1
  103. package/icons/lucide/clock.svg +1 -1
  104. package/icons/lucide/code.svg +1 -1
  105. package/icons/lucide/coffee.svg +1 -1
  106. package/icons/lucide/columns.svg +1 -1
  107. package/icons/lucide/cookie.svg +1 -1
  108. package/icons/lucide/copy.svg +1 -1
  109. package/icons/lucide/credit-card.svg +1 -1
  110. package/icons/lucide/currency-circle-dollar.svg +1 -1
  111. package/icons/lucide/cursor-click.svg +1 -1
  112. package/icons/lucide/database.svg +1 -1
  113. package/icons/lucide/desktop.svg +1 -1
  114. package/icons/lucide/device-mobile.svg +1 -1
  115. package/icons/lucide/door-open.svg +1 -1
  116. package/icons/lucide/download-simple.svg +1 -1
  117. package/icons/lucide/envelope-simple.svg +1 -1
  118. package/icons/lucide/eye-slash.svg +1 -1
  119. package/icons/lucide/eye.svg +1 -1
  120. package/icons/lucide/file-pdf.svg +1 -1
  121. package/icons/lucide/file-text.svg +1 -1
  122. package/icons/lucide/file-video.svg +1 -1
  123. package/icons/lucide/file-zip.svg +1 -1
  124. package/icons/lucide/flower-lotus.svg +1 -1
  125. package/icons/lucide/folder-simple.svg +1 -1
  126. package/icons/lucide/funnel.svg +1 -1
  127. package/icons/lucide/gauge.svg +1 -1
  128. package/icons/lucide/gear.svg +1 -1
  129. package/icons/lucide/github-logo.svg +1 -1
  130. package/icons/lucide/globe.svg +1 -1
  131. package/icons/lucide/google-logo.svg +1 -1
  132. package/icons/lucide/graduation-cap.svg +1 -1
  133. package/icons/lucide/hammer.svg +1 -1
  134. package/icons/lucide/hand-heart.svg +1 -1
  135. package/icons/lucide/handshake.svg +1 -1
  136. package/icons/lucide/hard-drives.svg +1 -1
  137. package/icons/lucide/heart.svg +1 -1
  138. package/icons/lucide/house.svg +1 -1
  139. package/icons/lucide/image-square.svg +1 -1
  140. package/icons/lucide/image.svg +1 -1
  141. package/icons/lucide/info.svg +1 -1
  142. package/icons/lucide/keyboard.svg +1 -1
  143. package/icons/lucide/leaf.svg +1 -1
  144. package/icons/lucide/lifebuoy.svg +1 -1
  145. package/icons/lucide/lightbulb.svg +1 -1
  146. package/icons/lucide/link-break.svg +1 -1
  147. package/icons/lucide/link-simple.svg +1 -1
  148. package/icons/lucide/list-bullets.svg +1 -1
  149. package/icons/lucide/list-checks.svg +1 -1
  150. package/icons/lucide/list-numbers.svg +1 -1
  151. package/icons/lucide/list.svg +1 -1
  152. package/icons/lucide/lock-simple-open.svg +1 -1
  153. package/icons/lucide/lock-simple.svg +1 -1
  154. package/icons/lucide/magnifying-glass.svg +1 -1
  155. package/icons/lucide/map-pin.svg +1 -1
  156. package/icons/lucide/map-trifold.svg +1 -1
  157. package/icons/lucide/medal.svg +1 -1
  158. package/icons/lucide/megaphone.svg +1 -1
  159. package/icons/lucide/minus.svg +1 -1
  160. package/icons/lucide/moon.svg +1 -1
  161. package/icons/lucide/newspaper.svg +1 -1
  162. package/icons/lucide/pack.json +23 -23
  163. package/icons/lucide/paint-brush-broad.svg +1 -1
  164. package/icons/lucide/palette.svg +1 -1
  165. package/icons/lucide/paper-plane-tilt.svg +1 -1
  166. package/icons/lucide/pencil-simple.svg +1 -1
  167. package/icons/lucide/phone.svg +1 -1
  168. package/icons/lucide/play-fill.svg +1 -1
  169. package/icons/lucide/plus.svg +1 -1
  170. package/icons/lucide/prohibit.svg +1 -1
  171. package/icons/lucide/puzzle-piece.svg +1 -1
  172. package/icons/lucide/question.svg +1 -1
  173. package/icons/lucide/quotes.svg +1 -1
  174. package/icons/lucide/rows.svg +1 -1
  175. package/icons/lucide/ruler.svg +1 -1
  176. package/icons/lucide/scan.svg +1 -1
  177. package/icons/lucide/shapes.svg +1 -1
  178. package/icons/lucide/share-network.svg +1 -1
  179. package/icons/lucide/shield-check.svg +1 -1
  180. package/icons/lucide/sidebar-simple.svg +1 -1
  181. package/icons/lucide/sign-out.svg +1 -1
  182. package/icons/lucide/signpost.svg +1 -1
  183. package/icons/lucide/sliders-horizontal.svg +1 -1
  184. package/icons/lucide/smiley.svg +1 -1
  185. package/icons/lucide/sparkle.svg +1 -1
  186. package/icons/lucide/square-half-bottom.svg +1 -1
  187. package/icons/lucide/square.svg +1 -1
  188. package/icons/lucide/squares-four.svg +1 -1
  189. package/icons/lucide/sticker.svg +1 -1
  190. package/icons/lucide/storefront.svg +1 -1
  191. package/icons/lucide/sun.svg +1 -1
  192. package/icons/lucide/swatches.svg +1 -1
  193. package/icons/lucide/tag.svg +1 -1
  194. package/icons/lucide/text-aa.svg +1 -1
  195. package/icons/lucide/text-align-left.svg +1 -1
  196. package/icons/lucide/text-b.svg +1 -1
  197. package/icons/lucide/text-h-one.svg +1 -1
  198. package/icons/lucide/text-h-three.svg +1 -1
  199. package/icons/lucide/text-h-two.svg +1 -1
  200. package/icons/lucide/text-italic.svg +1 -1
  201. package/icons/lucide/text-t.svg +1 -1
  202. package/icons/lucide/ticket.svg +1 -1
  203. package/icons/lucide/toggle-right.svg +1 -1
  204. package/icons/lucide/train.svg +1 -1
  205. package/icons/lucide/trash.svg +1 -1
  206. package/icons/lucide/upload-simple.svg +1 -1
  207. package/icons/lucide/user-circle.svg +1 -1
  208. package/icons/lucide/user.svg +1 -1
  209. package/icons/lucide/users-three.svg +1 -1
  210. package/icons/lucide/users.svg +1 -1
  211. package/icons/lucide/video-camera.svg +1 -1
  212. package/icons/lucide/warning-circle.svg +1 -1
  213. package/icons/lucide/warning.svg +1 -1
  214. package/icons/lucide/wave-sine.svg +1 -1
  215. package/icons/lucide/wheelchair.svg +1 -1
  216. package/icons/lucide/wrench.svg +1 -1
  217. package/icons/lucide/x.svg +1 -1
  218. package/icons/material-symbols/LICENSE +201 -201
  219. package/icons/material-symbols/address-book.svg +1 -1
  220. package/icons/material-symbols/app-window.svg +1 -1
  221. package/icons/material-symbols/arrow-counter-clockwise.svg +1 -1
  222. package/icons/material-symbols/arrow-down.svg +1 -1
  223. package/icons/material-symbols/arrow-left.svg +1 -1
  224. package/icons/material-symbols/arrow-right.svg +1 -1
  225. package/icons/material-symbols/arrow-square-out.svg +1 -1
  226. package/icons/material-symbols/arrow-u-up-left.svg +1 -1
  227. package/icons/material-symbols/arrow-u-up-right.svg +1 -1
  228. package/icons/material-symbols/arrow-up-right.svg +1 -1
  229. package/icons/material-symbols/arrow-up.svg +1 -1
  230. package/icons/material-symbols/arrows-down-up.svg +1 -1
  231. package/icons/material-symbols/arrows-in-line-horizontal.svg +1 -1
  232. package/icons/material-symbols/arrows-out-line-horizontal.svg +1 -1
  233. package/icons/material-symbols/article.svg +1 -1
  234. package/icons/material-symbols/bank.svg +1 -1
  235. package/icons/material-symbols/bell.svg +1 -1
  236. package/icons/material-symbols/bicycle.svg +1 -1
  237. package/icons/material-symbols/binoculars.svg +1 -1
  238. package/icons/material-symbols/book-open-text.svg +1 -1
  239. package/icons/material-symbols/book-open.svg +1 -1
  240. package/icons/material-symbols/browser.svg +1 -1
  241. package/icons/material-symbols/buildings.svg +1 -1
  242. package/icons/material-symbols/bus.svg +1 -1
  243. package/icons/material-symbols/calendar-blank.svg +1 -1
  244. package/icons/material-symbols/calendar-check.svg +1 -1
  245. package/icons/material-symbols/car.svg +1 -1
  246. package/icons/material-symbols/cards.svg +1 -1
  247. package/icons/material-symbols/caret-down.svg +1 -1
  248. package/icons/material-symbols/caret-left.svg +1 -1
  249. package/icons/material-symbols/caret-right.svg +1 -1
  250. package/icons/material-symbols/caret-up.svg +1 -1
  251. package/icons/material-symbols/certificate.svg +1 -1
  252. package/icons/material-symbols/chart-line-up.svg +1 -1
  253. package/icons/material-symbols/chats.svg +1 -1
  254. package/icons/material-symbols/check-circle.svg +1 -1
  255. package/icons/material-symbols/check.svg +1 -1
  256. package/icons/material-symbols/circle-half.svg +1 -1
  257. package/icons/material-symbols/circle.svg +1 -1
  258. package/icons/material-symbols/clock-counter-clockwise.svg +1 -1
  259. package/icons/material-symbols/clock.svg +1 -1
  260. package/icons/material-symbols/code.svg +1 -1
  261. package/icons/material-symbols/coffee.svg +1 -1
  262. package/icons/material-symbols/columns.svg +1 -1
  263. package/icons/material-symbols/cookie.svg +1 -1
  264. package/icons/material-symbols/copy.svg +1 -1
  265. package/icons/material-symbols/credit-card.svg +1 -1
  266. package/icons/material-symbols/currency-circle-dollar.svg +1 -1
  267. package/icons/material-symbols/cursor-click.svg +1 -1
  268. package/icons/material-symbols/database.svg +1 -1
  269. package/icons/material-symbols/desktop.svg +1 -1
  270. package/icons/material-symbols/device-mobile.svg +1 -1
  271. package/icons/material-symbols/door-open.svg +1 -1
  272. package/icons/material-symbols/download-simple.svg +1 -1
  273. package/icons/material-symbols/envelope-simple.svg +1 -1
  274. package/icons/material-symbols/eye-slash.svg +1 -1
  275. package/icons/material-symbols/eye.svg +1 -1
  276. package/icons/material-symbols/file-pdf.svg +1 -1
  277. package/icons/material-symbols/file-text.svg +1 -1
  278. package/icons/material-symbols/file-video.svg +1 -1
  279. package/icons/material-symbols/file-zip.svg +1 -1
  280. package/icons/material-symbols/flower-lotus.svg +1 -1
  281. package/icons/material-symbols/folder-simple.svg +1 -1
  282. package/icons/material-symbols/funnel.svg +1 -1
  283. package/icons/material-symbols/gauge.svg +1 -1
  284. package/icons/material-symbols/gear.svg +1 -1
  285. package/icons/material-symbols/github-logo.svg +1 -1
  286. package/icons/material-symbols/globe.svg +1 -1
  287. package/icons/material-symbols/google-logo.svg +1 -1
  288. package/icons/material-symbols/graduation-cap.svg +1 -1
  289. package/icons/material-symbols/hammer.svg +1 -1
  290. package/icons/material-symbols/hand-heart.svg +1 -1
  291. package/icons/material-symbols/handshake.svg +1 -1
  292. package/icons/material-symbols/hard-drives.svg +1 -1
  293. package/icons/material-symbols/heart.svg +1 -1
  294. package/icons/material-symbols/house.svg +1 -1
  295. package/icons/material-symbols/image-square.svg +1 -1
  296. package/icons/material-symbols/image.svg +1 -1
  297. package/icons/material-symbols/info.svg +1 -1
  298. package/icons/material-symbols/keyboard.svg +1 -1
  299. package/icons/material-symbols/leaf.svg +1 -1
  300. package/icons/material-symbols/lifebuoy.svg +1 -1
  301. package/icons/material-symbols/lightbulb.svg +1 -1
  302. package/icons/material-symbols/link-break.svg +1 -1
  303. package/icons/material-symbols/link-simple.svg +1 -1
  304. package/icons/material-symbols/list-bullets.svg +1 -1
  305. package/icons/material-symbols/list-checks.svg +1 -1
  306. package/icons/material-symbols/list-numbers.svg +1 -1
  307. package/icons/material-symbols/list.svg +1 -1
  308. package/icons/material-symbols/lock-simple-open.svg +1 -1
  309. package/icons/material-symbols/lock-simple.svg +1 -1
  310. package/icons/material-symbols/magnifying-glass.svg +1 -1
  311. package/icons/material-symbols/map-pin.svg +1 -1
  312. package/icons/material-symbols/map-trifold.svg +1 -1
  313. package/icons/material-symbols/medal.svg +1 -1
  314. package/icons/material-symbols/megaphone.svg +1 -1
  315. package/icons/material-symbols/minus.svg +1 -1
  316. package/icons/material-symbols/moon.svg +1 -1
  317. package/icons/material-symbols/newspaper.svg +1 -1
  318. package/icons/material-symbols/pack.json +29 -29
  319. package/icons/material-symbols/paint-brush-broad.svg +1 -1
  320. package/icons/material-symbols/palette.svg +1 -1
  321. package/icons/material-symbols/paper-plane-tilt.svg +1 -1
  322. package/icons/material-symbols/pencil-simple.svg +1 -1
  323. package/icons/material-symbols/phone.svg +1 -1
  324. package/icons/material-symbols/play-fill.svg +1 -1
  325. package/icons/material-symbols/plus.svg +1 -1
  326. package/icons/material-symbols/prohibit.svg +1 -1
  327. package/icons/material-symbols/puzzle-piece.svg +1 -1
  328. package/icons/material-symbols/question.svg +1 -1
  329. package/icons/material-symbols/quotes.svg +1 -1
  330. package/icons/material-symbols/rows.svg +1 -1
  331. package/icons/material-symbols/ruler.svg +1 -1
  332. package/icons/material-symbols/scan.svg +1 -1
  333. package/icons/material-symbols/shapes.svg +1 -1
  334. package/icons/material-symbols/share-network.svg +1 -1
  335. package/icons/material-symbols/shield-check.svg +1 -1
  336. package/icons/material-symbols/sidebar-simple.svg +1 -1
  337. package/icons/material-symbols/sign-out.svg +1 -1
  338. package/icons/material-symbols/signpost.svg +1 -1
  339. package/icons/material-symbols/sliders-horizontal.svg +1 -1
  340. package/icons/material-symbols/smiley.svg +1 -1
  341. package/icons/material-symbols/sparkle.svg +1 -1
  342. package/icons/material-symbols/square-half-bottom.svg +1 -1
  343. package/icons/material-symbols/square.svg +1 -1
  344. package/icons/material-symbols/squares-four.svg +1 -1
  345. package/icons/material-symbols/sticker.svg +1 -1
  346. package/icons/material-symbols/storefront.svg +1 -1
  347. package/icons/material-symbols/sun.svg +1 -1
  348. package/icons/material-symbols/swatches.svg +1 -1
  349. package/icons/material-symbols/tag.svg +1 -1
  350. package/icons/material-symbols/text-aa.svg +1 -1
  351. package/icons/material-symbols/text-align-left.svg +1 -1
  352. package/icons/material-symbols/text-b.svg +1 -1
  353. package/icons/material-symbols/text-h-one.svg +1 -1
  354. package/icons/material-symbols/text-h-three.svg +1 -1
  355. package/icons/material-symbols/text-h-two.svg +1 -1
  356. package/icons/material-symbols/text-italic.svg +1 -1
  357. package/icons/material-symbols/text-t.svg +1 -1
  358. package/icons/material-symbols/ticket.svg +1 -1
  359. package/icons/material-symbols/toggle-right.svg +1 -1
  360. package/icons/material-symbols/train.svg +1 -1
  361. package/icons/material-symbols/trash.svg +1 -1
  362. package/icons/material-symbols/upload-simple.svg +1 -1
  363. package/icons/material-symbols/user-circle.svg +1 -1
  364. package/icons/material-symbols/user.svg +1 -1
  365. package/icons/material-symbols/users-three.svg +1 -1
  366. package/icons/material-symbols/users.svg +1 -1
  367. package/icons/material-symbols/video-camera.svg +1 -1
  368. package/icons/material-symbols/warning-circle.svg +1 -1
  369. package/icons/material-symbols/warning.svg +1 -1
  370. package/icons/material-symbols/wave-sine.svg +1 -1
  371. package/icons/material-symbols/wheelchair.svg +1 -1
  372. package/icons/material-symbols/wrench.svg +1 -1
  373. package/icons/material-symbols/x.svg +1 -1
  374. package/icons/phosphor/pack.json +8 -8
  375. package/lib/apps.mjs +142 -139
  376. package/lib/apps.test.d.mts +1 -0
  377. package/lib/apps.test.mjs +50 -0
  378. package/lib/blocks.mjs +90 -90
  379. package/lib/caseCollisions.d.mts +4 -0
  380. package/lib/caseCollisions.mjs +62 -0
  381. package/lib/caseCollisions.test.d.mts +1 -0
  382. package/lib/caseCollisions.test.mjs +74 -0
  383. package/lib/colors.ts +86 -86
  384. package/lib/contactBar.ts +18 -18
  385. package/lib/contracts.d.mts +15 -0
  386. package/lib/contracts.mjs +17 -0
  387. package/lib/cookieCategories.mjs +28 -28
  388. package/lib/crop.mjs +83 -83
  389. package/lib/csp.mjs +130 -130
  390. package/lib/cspSources.mjs +60 -60
  391. package/lib/dynamic-colors.ts +426 -426
  392. package/lib/git.mjs +64 -64
  393. package/lib/hooks.ts +51 -51
  394. package/lib/inline.ts +20 -20
  395. package/lib/license-texts/Apache-2.0.txt +190 -190
  396. package/lib/license-texts/BSD-2-Clause.txt +24 -24
  397. package/lib/license-texts/BSD-3-Clause.txt +28 -28
  398. package/lib/license-texts/ISC.txt +15 -15
  399. package/lib/license-texts/MIT.txt +21 -21
  400. package/lib/licenses.d.mts +1 -1
  401. package/lib/licenses.mjs +683 -683
  402. package/lib/markdown.ts +42 -42
  403. package/lib/motion-schemes.mjs +5 -5
  404. package/lib/motion.ts +131 -131
  405. package/lib/notice.mjs +34 -34
  406. package/lib/scheduled.mjs +37 -37
  407. package/lib/snackbar.ts +149 -149
  408. package/lib/status.mjs +18 -18
  409. package/lib/storeEntries.d.mts +18 -0
  410. package/lib/storeEntries.mjs +64 -0
  411. package/lib/storeEntries.test.d.mts +1 -0
  412. package/lib/storeEntries.test.mjs +71 -0
  413. package/lib/svg.mjs +231 -231
  414. package/lib/text-colors.mjs +43 -43
  415. package/lib/video-id.mjs +72 -72
  416. package/lib/video.mjs +91 -91
  417. package/package.json +136 -131
  418. package/pages/404.astro +10 -10
  419. package/pages/[...slug].astro +51 -51
  420. package/pages/_headers.txt +31 -31
  421. package/pages/blog/index.astro +10 -10
  422. package/pages/blog/kategori/[category].astro +18 -18
  423. package/pages/editor/code-images.json.js +25 -25
  424. package/pages/editor/index.astro +75 -75
  425. package/pages/editor/manifest.webmanifest.js +23 -23
  426. package/pages/editor/sw.js +33 -33
  427. package/pages/editor/sw.js.js +4 -4
  428. package/pages/editor/thumbnails.json.js +21 -21
  429. package/pages/editor/version.json.js +22 -22
  430. package/pages/headers.js +4 -4
  431. package/pages/index.astro +10 -10
  432. package/pages/ny.astro +6 -6
  433. package/pages/robots.txt.js +3 -3
  434. package/pages/rss.xml.js +20 -20
  435. package/scripts/app-screenshots.mjs +164 -164
  436. package/scripts/app-workers.mjs +6 -6
  437. package/scripts/bin.mjs +12 -12
  438. package/scripts/built-site.mjs +134 -99
  439. package/scripts/check-consent.mjs +113 -113
  440. package/scripts/check-content.mjs +151 -149
  441. package/scripts/check-demo.mjs +24 -24
  442. package/scripts/check-store.mjs +160 -0
  443. package/scripts/check-themes.mjs +45 -45
  444. package/scripts/check-worker.mjs +569 -569
  445. package/scripts/compare-html.mjs +81 -81
  446. package/scripts/declarations.mjs +10 -0
  447. package/scripts/declarations.test.mjs +32 -0
  448. package/scripts/demo-site.mjs +42 -42
  449. package/scripts/editor-icons.mjs +56 -56
  450. package/scripts/editor-roundtrip.mjs +134 -134
  451. package/scripts/editor-screens.mjs +101 -101
  452. package/scripts/git-env.mjs +9 -9
  453. package/scripts/icon-packs.mjs +151 -151
  454. package/scripts/link-counts.mjs +22 -22
  455. package/scripts/page-colors.mjs +42 -42
  456. package/scripts/releases.mjs +80 -80
  457. package/scripts/responsive-images.mjs +94 -94
  458. package/scripts/scheduled-list.mjs +15 -15
  459. package/scripts/theme-screenshots.mjs +88 -88
  460. package/scripts/types.mjs +17 -21
  461. package/scripts/video-thumbnails.mjs +89 -89
  462. package/scripts/worker-modules.mjs +34 -34
  463. package/site/appCookies.mjs +74 -74
  464. package/site/apps.ts +21 -21
  465. package/site/autolinks.mjs +200 -200
  466. package/site/blockScripts.ts +31 -31
  467. package/site/categories.ts +16 -16
  468. package/site/consent.ts +26 -26
  469. package/site/content.config.ts +84 -84
  470. package/site/content.ts +104 -104
  471. package/site/cookieList.ts +17 -17
  472. package/site/defineSite.d.mts +2 -14
  473. package/site/defineSite.mjs +131 -136
  474. package/site/directives/interact.mjs +31 -31
  475. package/site/editor-version.mjs +26 -26
  476. package/site/fill.ts +13 -13
  477. package/site/format.ts +11 -11
  478. package/site/home.ts +66 -66
  479. package/site/iconPacks.mjs +43 -43
  480. package/site/icons.mjs +605 -605
  481. package/site/images.mjs +157 -157
  482. package/site/motion.ts +13 -13
  483. package/site/noindex.mjs +21 -21
  484. package/site/pageColors.ts +110 -110
  485. package/site/popups.ts +98 -98
  486. package/site/schema.ts +189 -189
  487. package/site/sections.ts +3 -3
  488. package/site/seo.ts +28 -28
  489. package/site/site.ts +76 -76
  490. package/site/siteConfig.mjs +8 -8
  491. package/site/siteCsp.mjs +75 -75
  492. package/site/siteModules.mjs +27 -27
  493. package/site/standardPages.ts +59 -59
  494. package/site/storeTheme.d.mts +6 -0
  495. package/site/storeTheme.mjs +29 -0
  496. package/site/storeTheme.test.d.mts +1 -0
  497. package/site/storeTheme.test.mjs +53 -0
  498. package/site/testimonials.ts +48 -48
  499. package/site/text.ts +43 -43
  500. package/site/theme.mjs +483 -472
  501. package/site/tracking.client.ts +225 -225
  502. package/site/tracking.ts +9 -9
  503. package/src/ActionParts.tsx +205 -205
  504. package/src/Advanced.tsx +152 -152
  505. package/src/App.tsx +901 -904
  506. package/src/AppList.tsx +1362 -1345
  507. package/src/BlockDialog.tsx +360 -360
  508. package/src/BlockParts.tsx +652 -652
  509. package/src/BlockSettings.tsx +533 -533
  510. package/src/BrandForm.tsx +164 -164
  511. package/src/Colors.tsx +476 -476
  512. package/src/CommunicationParts.tsx +199 -199
  513. package/src/CompanyForm.tsx +209 -209
  514. package/src/Consents.tsx +175 -175
  515. package/src/ContainmentParts.tsx +185 -185
  516. package/src/Crop.tsx +152 -152
  517. package/src/DataForm.tsx +434 -434
  518. package/src/Deleted.tsx +177 -177
  519. package/src/EntryForm.tsx +740 -740
  520. package/src/FileView.tsx +30 -30
  521. package/src/FormFrame.tsx +237 -237
  522. package/src/Keywords.tsx +225 -225
  523. package/src/Library.tsx +1563 -1563
  524. package/src/Licenses.tsx +336 -336
  525. package/src/LinkSheet.tsx +138 -138
  526. package/src/MarkdownEditor.tsx +1075 -1075
  527. package/src/Media.tsx +677 -677
  528. package/src/NavigationParts.tsx +350 -350
  529. package/src/NavigationRestParts.tsx +247 -247
  530. package/src/Notifications.tsx +59 -59
  531. package/src/PagePicker.tsx +136 -136
  532. package/src/Parts.tsx +991 -991
  533. package/src/PartsShown.tsx +168 -168
  534. package/src/PickerParts.tsx +183 -183
  535. package/src/Redirects.tsx +450 -450
  536. package/src/Screen.tsx +184 -184
  537. package/src/SectionsField.tsx +65 -65
  538. package/src/SelectionParts.tsx +134 -134
  539. package/src/SeoScores.tsx +184 -184
  540. package/src/Sheet.tsx +35 -35
  541. package/src/ThemeStore.tsx +376 -376
  542. package/src/Tokens.tsx +346 -346
  543. package/src/Versions.tsx +214 -214
  544. package/src/addresses.ts +75 -75
  545. package/src/allIcons.ts +7 -7
  546. package/src/appFiles.ts +29 -29
  547. package/src/apps.ts +281 -281
  548. package/src/ask.tsx +123 -123
  549. package/src/blocks.ts +251 -251
  550. package/src/brand.ts +153 -153
  551. package/src/cache.ts +42 -42
  552. package/src/check.ts +285 -285
  553. package/src/checkSite.ts +256 -256
  554. package/src/{consents.ts → consentData.ts} +26 -26
  555. package/src/cookieList.ts +70 -70
  556. package/src/countries.ts +59 -59
  557. package/src/deploy.ts +155 -155
  558. package/src/editor.css +602 -602
  559. package/src/examples.ts +85 -85
  560. package/src/fields.tsx +942 -942
  561. package/src/frontmatter.mjs +42 -42
  562. package/src/github.ts +376 -376
  563. package/src/i18n/da-DK/advanced.ts +28 -28
  564. package/src/i18n/da-DK/app.ts +71 -71
  565. package/src/i18n/da-DK/apps.ts +90 -90
  566. package/src/i18n/da-DK/blockSettings.ts +38 -38
  567. package/src/i18n/da-DK/blocks.ts +408 -408
  568. package/src/i18n/da-DK/check.ts +25 -25
  569. package/src/i18n/da-DK/colors.ts +64 -64
  570. package/src/i18n/da-DK/common.ts +118 -118
  571. package/src/i18n/da-DK/company.ts +24 -24
  572. package/src/i18n/da-DK/consents.ts +23 -23
  573. package/src/i18n/da-DK/forms.ts +116 -116
  574. package/src/i18n/da-DK/history.ts +35 -35
  575. package/src/i18n/da-DK/index.ts +49 -49
  576. package/src/i18n/da-DK/keywords.ts +19 -19
  577. package/src/i18n/da-DK/library.ts +135 -135
  578. package/src/i18n/da-DK/licenses.ts +25 -25
  579. package/src/i18n/da-DK/media.ts +79 -79
  580. package/src/i18n/da-DK/parts.ts +696 -696
  581. package/src/i18n/da-DK/preview.ts +13 -13
  582. package/src/i18n/da-DK/redirects.ts +51 -51
  583. package/src/i18n/da-DK/seo.ts +20 -20
  584. package/src/i18n/da-DK/text.ts +35 -35
  585. package/src/i18n/da-DK/theme.ts +41 -41
  586. package/src/i18n/en-US/advanced.ts +32 -32
  587. package/src/i18n/en-US/app.ts +84 -84
  588. package/src/i18n/en-US/apps.ts +134 -134
  589. package/src/i18n/en-US/blockSettings.ts +48 -48
  590. package/src/i18n/en-US/blocks.ts +441 -441
  591. package/src/i18n/en-US/check.ts +31 -31
  592. package/src/i18n/en-US/colors.ts +82 -82
  593. package/src/i18n/en-US/common.ts +144 -144
  594. package/src/i18n/en-US/company.ts +25 -25
  595. package/src/i18n/en-US/consents.ts +25 -25
  596. package/src/i18n/en-US/forms.ts +145 -145
  597. package/src/i18n/en-US/history.ts +43 -43
  598. package/src/i18n/en-US/index.ts +51 -51
  599. package/src/i18n/en-US/keywords.ts +24 -24
  600. package/src/i18n/en-US/library.ts +176 -176
  601. package/src/i18n/en-US/licenses.ts +36 -36
  602. package/src/i18n/en-US/media.ts +80 -80
  603. package/src/i18n/en-US/parts.ts +718 -718
  604. package/src/i18n/en-US/preview.ts +15 -15
  605. package/src/i18n/en-US/redirects.ts +66 -66
  606. package/src/i18n/en-US/seo.ts +26 -26
  607. package/src/i18n/en-US/text.ts +41 -41
  608. package/src/i18n/en-US/theme.ts +41 -41
  609. package/src/i18n/index.ts +124 -124
  610. package/src/icon.tsx +160 -160
  611. package/src/iconPacks.ts +89 -89
  612. package/src/images.ts +163 -163
  613. package/src/{licenses.ts → licenseData.ts} +79 -79
  614. package/src/markdown.mjs +204 -204
  615. package/src/nav.ts +68 -68
  616. package/src/notFound.ts +28 -28
  617. package/src/{notifications.ts → notificationData.ts} +223 -223
  618. package/src/pagePreview.tsx +204 -204
  619. package/src/r2Caching.ts +55 -55
  620. package/src/{redirects.ts → redirectData.ts} +149 -149
  621. package/src/registry.tsx +188 -188
  622. package/src/schema.ts +417 -417
  623. package/src/sections.mjs +303 -303
  624. package/src/seo.ts +333 -333
  625. package/src/shared.ts +44 -44
  626. package/src/sidebar.ts +31 -31
  627. package/src/site.ts +317 -317
  628. package/src/standard/Block.astro +19 -19
  629. package/src/standard/BlockSite.astro +18 -18
  630. package/src/standard/accordion/index.tsx +47 -47
  631. package/src/standard/accordion/load.ts +23 -23
  632. package/src/standard/accordion/shared.ts +15 -15
  633. package/src/standard/accordion/view.ts +32 -32
  634. package/src/standard/buttons/fields.ts +98 -98
  635. package/src/standard/buttons/index.tsx +23 -23
  636. package/src/standard/buttons/load.ts +34 -34
  637. package/src/standard/buttons/shared.ts +12 -12
  638. package/src/standard/buttons/view.ts +30 -30
  639. package/src/standard/cards/index.tsx +71 -71
  640. package/src/standard/cards/load.ts +17 -17
  641. package/src/standard/cards/shared.ts +27 -27
  642. package/src/standard/cards/view.ts +29 -29
  643. package/src/standard/checklist/index.tsx +27 -27
  644. package/src/standard/checklist/load.ts +5 -5
  645. package/src/standard/checklist/view.ts +13 -13
  646. package/src/standard/cookies/index.tsx +17 -17
  647. package/src/standard/cookies/list.ts +56 -56
  648. package/src/standard/cookies/load.ts +11 -11
  649. package/src/standard/cookies/shared.ts +20 -20
  650. package/src/standard/cookies/view.ts +28 -28
  651. package/src/standard/faq/index.tsx +29 -29
  652. package/src/standard/faq/load.ts +14 -14
  653. package/src/standard/faq/view.ts +21 -21
  654. package/src/standard/head.ts +23 -23
  655. package/src/standard/hero/fields.ts +48 -48
  656. package/src/standard/hero/index.tsx +57 -57
  657. package/src/standard/hero/load.ts +23 -23
  658. package/src/standard/hero/view.ts +38 -38
  659. package/src/standard/links/index.tsx +41 -41
  660. package/src/standard/links/load.ts +17 -17
  661. package/src/standard/links/view.ts +25 -25
  662. package/src/standard/list/index.tsx +50 -50
  663. package/src/standard/list/load.ts +5 -5
  664. package/src/standard/list/view.ts +18 -18
  665. package/src/standard/load.ts +7 -7
  666. package/src/standard/notice/index.tsx +30 -30
  667. package/src/standard/notice/load.ts +16 -16
  668. package/src/standard/notice/view.ts +10 -10
  669. package/src/standard/points/index.tsx +42 -42
  670. package/src/standard/points/load.ts +5 -5
  671. package/src/standard/points/view.ts +12 -12
  672. package/src/standard/posts/index.tsx +132 -132
  673. package/src/standard/posts/load.ts +36 -36
  674. package/src/standard/posts/shared.ts +21 -21
  675. package/src/standard/posts/view.ts +63 -63
  676. package/src/standard/pricecards/index.tsx +57 -57
  677. package/src/standard/pricecards/load.ts +11 -11
  678. package/src/standard/pricecards/view.ts +50 -50
  679. package/src/standard/sharedTexts.ts +6 -6
  680. package/src/standard/site.ts +124 -124
  681. package/src/standard/siteLooks.ts +19 -19
  682. package/src/standard/sites.ts +13 -13
  683. package/src/standard/steps/index.tsx +28 -28
  684. package/src/standard/steps/load.ts +5 -5
  685. package/src/standard/steps/view.ts +11 -11
  686. package/src/standard/testimonials/index.tsx +87 -87
  687. package/src/standard/testimonials/list.ts +43 -43
  688. package/src/standard/testimonials/load.ts +58 -58
  689. package/src/standard/testimonials/shared.ts +13 -13
  690. package/src/standard/testimonials/view.ts +76 -76
  691. package/src/standard/themeBlocks.ts +13 -13
  692. package/src/standard/video/index.tsx +23 -23
  693. package/src/standard/video/load.ts +10 -10
  694. package/src/standard/video/view.ts +42 -42
  695. package/src/standard/view.tsx +105 -105
  696. package/src/standard/views.ts +37 -37
  697. package/src/store.ts +479 -499
  698. package/src/theme.ts +90 -90
  699. package/src/themeUi.ts +97 -97
  700. package/src/themes.ts +91 -91
  701. package/src/ui.tsx +3402 -3402
  702. package/src/virtual.d.ts +5 -5
  703. package/src/when.ts +22 -22
  704. package/starter/.githooks/pre-push +4 -4
  705. package/starter/.nvmrc +1 -1
  706. package/starter/.prettierignore +11 -11
  707. package/starter/.prettierrc.json +7 -7
  708. package/starter/README.md +27 -26
  709. package/starter/astro.config.mjs +5 -5
  710. package/starter/package.json +67 -67
  711. package/starter/public/_redirects +1 -1
  712. package/starter/public/favicon.svg +1 -1
  713. package/starter/public/logo.svg +1 -1
  714. package/starter/src/content/pages/blog.md +8 -8
  715. package/starter/src/content/pages/forside.md +9 -9
  716. package/starter/src/content/pages/kontakt.md +7 -7
  717. package/starter/src/content/pages/privatlivspolitik.md +9 -9
  718. package/starter/src/content/pages/siden-findes-ikke.md +7 -7
  719. package/starter/src/content.config.ts +2 -2
  720. package/starter/src/data/apps.json +1 -1
  721. package/starter/src/data/block-settings.json +3 -3
  722. package/starter/src/data/blocks.json +1 -1
  723. package/starter/src/data/booking.json +6 -6
  724. package/starter/src/data/colors.json +70 -70
  725. package/starter/src/data/contact.json +8 -8
  726. package/starter/src/data/cookies.json +21 -21
  727. package/starter/src/data/csp.json +1 -1
  728. package/starter/src/data/footer.json +15 -15
  729. package/starter/src/data/internal-links.json +4 -4
  730. package/starter/src/data/menu.json +4 -4
  731. package/starter/src/data/mobile-menu.json +4 -4
  732. package/starter/src/data/old-addresses.json +1 -1
  733. package/starter/src/data/redirects.json +1 -1
  734. package/starter/src/data/standard-pages.json +8 -8
  735. package/starter/src/data/theme.json +1 -1
  736. package/starter/src/env.d.ts +2 -2
  737. package/starter/src/site/config.ts +13 -13
  738. package/starter/src/site/worker.js +14 -14
  739. package/starter/tsconfig.json +17 -17
  740. package/starter/worker/apps.js +6 -6
  741. package/starter/wrangler.jsonc +39 -39
  742. package/theme/LICENSE +21 -21
  743. package/theme/blocks/ArrowLink.tsx +14 -14
  744. package/theme/blocks/BlockHead.tsx +26 -26
  745. package/theme/blocks/Panel.tsx +32 -32
  746. package/theme/blocks/Person.tsx +26 -26
  747. package/theme/blocks/accordion/PageRow.tsx +25 -25
  748. package/theme/blocks/accordion/View.tsx +17 -17
  749. package/theme/blocks/buttons/Buttons.tsx +28 -28
  750. package/theme/blocks/buttons/View.tsx +12 -12
  751. package/theme/blocks/cards/LinkCards.tsx +72 -72
  752. package/theme/blocks/cards/View.tsx +20 -20
  753. package/theme/blocks/checklist/Checks.tsx +15 -15
  754. package/theme/blocks/checklist/View.tsx +14 -14
  755. package/theme/blocks/cookies/View.tsx +36 -36
  756. package/theme/blocks/faq/Accordion.tsx +34 -34
  757. package/theme/blocks/faq/View.tsx +16 -16
  758. package/theme/blocks/faq/script.ts +56 -56
  759. package/theme/blocks/hero/View.tsx +44 -44
  760. package/theme/blocks/links/LinkGroups.tsx +24 -24
  761. package/theme/blocks/links/View.tsx +17 -17
  762. package/theme/blocks/list/Rows.tsx +24 -24
  763. package/theme/blocks/list/View.tsx +14 -14
  764. package/theme/blocks/notice/View.tsx +13 -13
  765. package/theme/blocks/points/PointList.tsx +34 -34
  766. package/theme/blocks/points/View.tsx +14 -14
  767. package/theme/blocks/posts/View.tsx +26 -26
  768. package/theme/blocks/pricecards/View.tsx +40 -40
  769. package/theme/blocks/steps/StepList.tsx +23 -23
  770. package/theme/blocks/steps/View.tsx +14 -14
  771. package/theme/blocks/testimonials/List.tsx +46 -46
  772. package/theme/blocks/testimonials/Slider.tsx +44 -44
  773. package/theme/blocks/testimonials/Testimonial.tsx +124 -124
  774. package/theme/blocks/testimonials/View.tsx +21 -21
  775. package/theme/blocks/testimonials/script.ts +52 -52
  776. package/theme/blocks/video/View.tsx +41 -41
  777. package/theme/components/ActionBar.astro +63 -63
  778. package/theme/components/AppSlot.astro +21 -21
  779. package/theme/components/ArrowLink.tsx +21 -21
  780. package/theme/components/BlogIndex.astro +48 -48
  781. package/theme/components/BlogPosts.astro +18 -18
  782. package/theme/components/BlogPosts.tsx +45 -45
  783. package/theme/components/BookButton.tsx +22 -22
  784. package/theme/components/Container.astro +11 -11
  785. package/theme/components/CookieConsent.astro +121 -121
  786. package/theme/components/EndBlock.astro +21 -21
  787. package/theme/components/FontPreload.astro +6 -6
  788. package/theme/components/Footer.astro +85 -85
  789. package/theme/components/Header.astro +186 -186
  790. package/theme/components/Icon.astro +16 -16
  791. package/theme/components/Img.astro +29 -29
  792. package/theme/components/Img.tsx +33 -33
  793. package/theme/components/Inline.astro +11 -11
  794. package/theme/components/Logo.astro +23 -23
  795. package/theme/components/LogoMark.astro +10 -10
  796. package/theme/components/Markdown.astro +17 -17
  797. package/theme/components/MobileMenu.tsx +224 -224
  798. package/theme/components/MobileNav.astro +33 -33
  799. package/theme/components/PageHeader.astro +22 -22
  800. package/theme/components/PageSection.astro +24 -24
  801. package/theme/components/PhoneButton.tsx +11 -11
  802. package/theme/components/Popup.astro +145 -145
  803. package/theme/components/PopupButton.tsx +24 -24
  804. package/theme/components/PopupView.tsx +48 -48
  805. package/theme/components/PostList.tsx +58 -58
  806. package/theme/components/PostMeta.tsx +17 -17
  807. package/theme/components/RichText.astro +28 -28
  808. package/theme/components/Section.astro +29 -29
  809. package/theme/components/SectionHeading.tsx +8 -8
  810. package/theme/components/TextSection.astro +16 -16
  811. package/theme/components/home/PageBlock.astro +19 -19
  812. package/theme/components/home/Text.astro +13 -13
  813. package/theme/components/loadPopup.ts +22 -22
  814. package/theme/components/loadPosts.ts +16 -16
  815. package/theme/components/posts.ts +15 -15
  816. package/theme/editor/looks.mjs +7 -7
  817. package/theme/editor/previews.tsx +56 -56
  818. package/theme/layouts/Base.astro +273 -273
  819. package/theme/layouts/PageLayout.astro +126 -126
  820. package/theme/layouts/PostLayout.astro +137 -137
  821. package/theme/scripts/motion.ts +29 -29
  822. package/theme/styles/base.css +320 -320
  823. package/theme/styles/blocks.css +1808 -1808
  824. package/theme/styles/components.css +206 -206
  825. package/theme/styles/global.css +1302 -1302
  826. package/theme/styles/ui.css +6544 -6544
  827. package/theme/templates/Blog.astro +38 -38
  828. package/theme/templates/Category.astro +19 -19
  829. package/theme/templates/Home.astro +36 -36
  830. package/theme/templates/New.astro +68 -68
  831. package/theme/templates/NotFound.astro +117 -117
  832. package/theme/theme.json +28 -28
  833. package/theme/ui/Badge.tsx +45 -45
  834. package/theme/ui/BottomSheet.tsx +148 -148
  835. package/theme/ui/Button.tsx +79 -79
  836. package/theme/ui/ButtonGroup.tsx +31 -31
  837. package/theme/ui/Card.tsx +45 -45
  838. package/theme/ui/Carousel.tsx +124 -124
  839. package/theme/ui/Checkbox.tsx +59 -59
  840. package/theme/ui/Chip.tsx +101 -101
  841. package/theme/ui/ChipSet.tsx +27 -27
  842. package/theme/ui/DatePicker.tsx +149 -149
  843. package/theme/ui/DatePickerContainer.tsx +316 -316
  844. package/theme/ui/Dialog.tsx +100 -100
  845. package/theme/ui/Divider.tsx +27 -27
  846. package/theme/ui/ExposedDropdownMenu.tsx +77 -77
  847. package/theme/ui/Fab.tsx +88 -88
  848. package/theme/ui/FabMenu.tsx +67 -67
  849. package/theme/ui/IconButton.tsx +78 -78
  850. package/theme/ui/List.tsx +287 -287
  851. package/theme/ui/LoadingIndicator.tsx +29 -29
  852. package/theme/ui/Menu.tsx +278 -278
  853. package/theme/ui/NavigationBar.tsx +62 -62
  854. package/theme/ui/NavigationDrawer.tsx +80 -80
  855. package/theme/ui/NavigationRail.tsx +154 -154
  856. package/theme/ui/ProgressIndicator.tsx +162 -162
  857. package/theme/ui/RadioButton.tsx +50 -50
  858. package/theme/ui/Search.tsx +129 -129
  859. package/theme/ui/SegmentedButton.tsx +49 -49
  860. package/theme/ui/SideSheet.tsx +54 -54
  861. package/theme/ui/Slider.tsx +155 -155
  862. package/theme/ui/Snackbar.tsx +67 -67
  863. package/theme/ui/SplitButton.tsx +67 -67
  864. package/theme/ui/Switch.tsx +60 -60
  865. package/theme/ui/Tabs.tsx +114 -114
  866. package/theme/ui/TextField.tsx +278 -278
  867. package/theme/ui/TimePicker.tsx +128 -128
  868. package/theme/ui/TimePickerContainer.tsx +234 -234
  869. package/theme/ui/Toolbar.tsx +67 -67
  870. package/theme/ui/Tooltip.tsx +58 -58
  871. package/theme/ui/TopAppBar.tsx +77 -77
  872. package/theme/ui/bottomSheet.script.ts +8 -8
  873. package/theme/ui/{bottomSheet.ts → bottomSheetControl.ts} +211 -211
  874. package/theme/ui/carousel.script.ts +28 -28
  875. package/theme/ui/carouselLayout.ts +229 -229
  876. package/theme/ui/checkbox.script.ts +8 -8
  877. package/theme/ui/datePicker.script.ts +302 -302
  878. package/theme/ui/dates.ts +185 -185
  879. package/theme/ui/{dialog.ts → dialogControl.ts} +48 -48
  880. package/theme/ui/dropdownMenu.script.ts +166 -166
  881. package/theme/ui/fabMenu.script.ts +57 -57
  882. package/theme/ui/navigationRail.script.ts +47 -47
  883. package/theme/ui/ripple.script.ts +159 -159
  884. package/theme/ui/search.script.ts +92 -92
  885. package/theme/ui/{sideSheet.ts → sideSheetControl.ts} +48 -48
  886. package/theme/ui/slider.script.ts +12 -12
  887. package/theme/ui/sliderUpdate.ts +52 -52
  888. package/theme/ui/snackbar.script.ts +16 -16
  889. package/theme/ui/splitButton.script.ts +63 -63
  890. package/theme/ui/tabs.script.ts +48 -48
  891. package/theme/ui/textField.script.ts +17 -17
  892. package/theme/ui/textFieldUpdate.ts +44 -44
  893. package/theme/ui/timePicker.script.ts +248 -248
  894. package/theme/ui/toolbar.script.ts +43 -43
  895. package/theme/ui/tooltip.script.ts +68 -68
  896. package/worker/consent.js +141 -141
  897. package/worker/editor-github.js +167 -167
  898. package/worker/editor-host.js +74 -74
  899. package/worker/files.js +401 -401
  900. package/worker/github.js +36 -36
  901. package/worker/index.js +188 -184
  902. package/worker/not-found.js +97 -97
  903. package/worker/oauth.js +95 -95
  904. package/worker/publish.js +52 -52
  905. package/worker/push.js +270 -270
  906. package/worker/r2-status.js +50 -50
  907. package/worker/session.js +58 -58
  908. package/worker/store.js +46 -0
package/src/EntryForm.tsx CHANGED
@@ -1,740 +1,740 @@
1
- // One blog post or page: its fields and its text, and "Gem", which saves the file as a commit.
2
- // Only what was changed is written: untouched fields keep their exact text, and the body is only
3
- // rewritten by Milkdown when the text was edited (packages/editor/scripts/editor-roundtrip.mjs checks that this
4
- // gives the same page). A new address (Adresse) moves the page or post, with a redirect from the
5
- // old one (addresses.ts). An earlier version (Versions.tsx) is opened as changes that are not saved,
6
- // at the address the page or post has now.
7
- import { useEffect, useMemo, useRef, useState } from 'react';
8
- import { flushSync } from 'react-dom';
9
- import { slugify, slugOf, type Field, type MarkdownCollection } from './schema';
10
- import { config, frontLeads, headingLooks, sectionLooks, sharing, standardPages, standardPaths } from '@site/editor';
11
- import { asWritten, placeOf, standardPageAt } from './site';
12
- import { ConflictError, type Change, type RepoFile } from './github';
13
- import { addressOf, moveRedirects, REDIRECTS, retarget } from './addresses';
14
- import { changesOf, flatten, readAll, REDIRECT_FILES, setRedirect } from './redirects';
15
- import { TAB_ROUTES } from './Redirects';
16
- import { choose } from './ask';
17
- import { readFields, splitFrontmatter, writeFields } from './frontmatter.mjs';
18
- import MarkdownEditor, { type MarkdownHandle } from './MarkdownEditor';
19
- import { useBlockDialog } from './BlockDialog';
20
- import { blockHtml } from '../lib/blocks.mjs';
21
- import { sectionBlocks } from './blocks';
22
- import { looksOf, pageH1 } from './sections.mjs';
23
- import { shownAt, useUploads } from './images';
24
- import { useMediaPicker } from './Media';
25
- import { inUse, problemAt } from './check';
26
- import { isOver } from '../lib/notice.mjs';
27
- import { splitPicture } from '../lib/crop.mjs';
28
- import { asStandardTemplate, blockProblems, notOffered, siteBlocks } from './registry';
29
- import { FieldInput, Fields, useWrongField, WrongField, type FieldContext } from './fields';
30
- import { Article, ClockCounterClockwise, Eye, MagnifyingGlass, ShareNetwork, SlidersHorizontal } from '@phosphor-icons/react';
31
- import {
32
- CheckList,
33
- FoldSection,
34
- FormColumns,
35
- ModalNavigationDrawer,
36
- NavigationDrawer,
37
- NavigationDrawerButton,
38
- type NavigationItem,
39
- SharePreview,
40
- useInView,
41
- } from './ui';
42
- import FormFrame, { savedStatus, useSavedAt } from './FormFrame';
43
- import { usePagePreview } from './pagePreview';
44
- import { savedOn, VersionList, VersionNotice, WithVersions, type ShownVersion } from './Versions';
45
- import type { Env } from './App';
46
- import { kindAndName, savedText } from './deploy';
47
- import { siteT, siteText, t } from './i18n';
48
- import { firstPicture, SEO_FIELDS, seoInputOf, seoRating, SHARE_FIELDS, sharedAs, type SeoCheck } from './seo';
49
-
50
- // At the top: the status (Kladde, Skjult, Udgivet, Planlagt and the time for Planlagt), then the title.
51
- // On a wide computer screen the status is at the right above the other fields (FormColumns).
52
- const TOP = ['status', 'publishAt', 'title', 'heading'];
53
- const STATUS = ['status', 'publishAt'];
54
- // Under their own headings at the bottom: the Google texts, the keywords and the canonical address; then the picture, title
55
- // and description when the page is shared.
56
- const SEO = SEO_FIELDS;
57
- const SHARE = SHARE_FIELDS;
58
- // The sections of the form, under each other on one page, each folding in and out under its heading (FoldSection in
59
- // ui.tsx): the text with the title and heading (open from the start), the status, the other fields, those for search
60
- // engines, those for social media, and the earlier versions (folded from the start, so the page is short; nothing is remembered till
61
- // the next time). Its navigation drawer (on a wide computer screen in the right column, narrower from the icon at the
62
- // right of the top bar) opens one and scrolls to it, and marks the one in view. A field that keeps the form from being
63
- // saved has its section opened and is scrolled to (FieldInput in fields.tsx).
64
- type Section = 'content' | 'status' | 'other' | 'seo' | 'share' | 'versions';
65
- const sectionOf = (path: string): Section => {
66
- const name = path.split('.')[0];
67
- return STATUS.includes(name)
68
- ? 'status'
69
- : TOP.includes(name)
70
- ? 'content'
71
- : SEO.includes(name)
72
- ? 'seo'
73
- : SHARE.includes(name)
74
- ? 'share'
75
- : 'other';
76
- };
77
- // The section the next page or post opened starts at: "Søgemaskiner" when it is opened from "SEO-vurdering"
78
- // (SeoScores.tsx), else the text.
79
- let opening: Section | null = null;
80
- export const openAtSeo = () => {
81
- opening = 'seo';
82
- };
83
- // The fields of a section (the other fields are those of none of the others).
84
- const inSection = (id: Section) => (f: Field) => sectionOf(f.name) === id;
85
-
86
- // A new file: a blog post gets today's date; a page has the blocks its template
87
- // starts it with (Template.start), e.g. "Book en tid" at the end like the other pages, but not one that
88
- // is turned off (off, "Blokindstillinger").
89
- export const newMarkdown = (collection: MarkdownCollection, off: string[] = []): RepoFile => ({
90
- path: '',
91
- oid: '',
92
- text:
93
- collection.id === 'blog'
94
- ? `---\ndate: ${new Date().toLocaleDateString('sv')}\n---\n`
95
- : `---\n---\n${(collection.template?.start ?? [])
96
- .filter((type) => !off.includes(type))
97
- .map((type) => `\n${blockHtml(type)}\n`)
98
- .join('')}`,
99
- });
100
-
101
- // The address of a saved page or post: a page's own `path`, or else /<file name>/ (packages/editor/site/content.ts).
102
- // '' for a new one.
103
- // Where the redirects are, as the start screen has it: its group (the site's), or "Indstillinger", › "Søgemaskineoptimering" › "Videresendelser" ›
104
- // "Alle videresendelser".
105
- const redirectsPath = () => [placeOf('seo'), t.common.parts.seo, t.common.parts.redirects, t.redirects.all].filter(Boolean).join(' › ');
106
-
107
- function addressNow(collection: MarkdownCollection, file: RepoFile, data: Record<string, unknown>) {
108
- if (!file.path) return '';
109
- return (collection.id === 'pages' && data.path ? String(data.path) : '') || `/${slugOf(file.path, collection.folder)}/`;
110
- }
111
-
112
- function valuesOf(collection: MarkdownCollection, file: RepoFile): Record<string, unknown> {
113
- const data = readFields(splitFrontmatter(file.text).yaml);
114
- return Object.fromEntries(
115
- collection.fields.map((f) => {
116
- const v = data[f.name];
117
- if (f.name === 'path') return [f.name, addressNow(collection, file, data)];
118
- if (f.type === 'boolean') return [f.name, v === true];
119
- if (f.multiple) return [f.name, Array.isArray(v) ? v.map(String) : []];
120
- return [f.name, v == null ? '' : String(v)];
121
- }),
122
- );
123
- }
124
-
125
- // Scrolling to a part of the form: smoothly, unless less motion is asked for.
126
- const scrolling = (): ScrollBehavior => (matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth');
127
-
128
- const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
129
-
130
- // The values as they are saved: a field only in use with another's value (Field.only: "Tidspunkt" with
131
- // "Planlagt") is empty without it, so it leaves the file.
132
- const inForm = (fields: Field[], values: Record<string, unknown>) =>
133
- Object.fromEntries(fields.map((f) => [f.name, inUse(f, fields, values) ? values[f.name] : '']));
134
-
135
- type Props = { collection: MarkdownCollection; file: RepoFile; env: Env };
136
-
137
- // The form starts again with an earlier version, or with the one saved now.
138
- export default function EntryForm(props: Props) {
139
- return (
140
- <WithVersions notify={props.env.notify}>
141
- {(version, onVersion) => <Form {...props} version={version} onVersion={onVersion} />}
142
- </WithVersions>
143
- );
144
- }
145
-
146
- function Form({
147
- collection,
148
- file,
149
- env,
150
- version,
151
- onVersion,
152
- }: Props & { version: ShownVersion | null; onVersion: (v: ShownVersion | null) => void }) {
153
- // The file as last loaded or saved; what the form compares with and writes on top of.
154
- const [base, setBase] = useState(file);
155
- const parts = useMemo(() => splitFrontmatter(base.text), [base]);
156
- const initial = useMemo(() => valuesOf(collection, base), [collection, base]);
157
- // An earlier version: its fields and text, at the address there is now.
158
- const [shown] = useState(() => version && splitFrontmatter(version.text));
159
- const [values, setValues] = useState(() =>
160
- version ? { ...valuesOf(collection, { ...base, text: version.text }), path: initial.path } : initial,
161
- );
162
- const [bodyChanged, setBodyChanged] = useState(() => !!shown && shown.body !== parts.body);
163
- const [saving, setSaving] = useState(false);
164
- const [deleting, setDeleting] = useState(false);
165
- const text = useRef<MarkdownHandle>(null);
166
- // Where the page or post is on the site now.
167
- const url = String(initial.path);
168
- // Which of the site's blocks to put in the text, and their fields (pages only).
169
- // What can be put in: what the template allows, but not the blocks turned off under
170
- // "Blokindstillinger", nor those of an app not installed (those already in the text stay, and are
171
- // checked by the template alone when saved).
172
- const off = useMemo(() => notOffered(env.files), [env.files]);
173
- // A page chosen as a standard page ("Indstillinger" › "Udseende" › "Standardsider", packages/editor/defaults/standardPages.ts) has its
174
- // rules too (the blog page has "Blogindlæg"). The front page is written as it looks: the heading, text and picture of a
175
- // block as the text above its card (its lead), and its top ("Hero") first.
176
- const paths = standardPaths(env.files.find((f) => f.path === config.standardPages)?.text);
177
- const standard = url ? standardPages.find((p) => paths[p.key] === url) : undefined;
178
- const front = standard?.key === 'front';
179
- const template = useMemo(() => asStandardTemplate(collection.template, standard), [standard, collection]);
180
- const insertable = useMemo(
181
- () =>
182
- off.length ? { ...template, blocks: (template?.blocks ?? siteBlocks.map((b) => b.type)).filter((t) => !off.includes(t)) } : template,
183
- [off, template],
184
- );
185
- const [blockAsk, blockDialog] = useBlockDialog(
186
- useMemo(() => (front ? sectionBlocks(siteBlocks, frontLeads) : undefined), [front]),
187
- {
188
- lists: env.lists,
189
- testimonials: env.testimonials,
190
- site: env.site,
191
- relations: env.relations,
192
- path: url,
193
- // A standard page's blocks drawn as the theme draws them there (its editor/previews.tsx, by `<file>#<key>`).
194
- ...(standard ? { page: { file: `${config.standardPages}#${standard.key}`, values } } : {}),
195
- },
196
- insertable,
197
- );
198
- // When the file was last saved, shown in the top bar.
199
- const [savedAt, setSavedAt] = useSavedAt(env, file.path);
200
-
201
- const isNew = !base.path;
202
- const asSaved = useMemo(() => inForm(collection.fields, values), [collection, values]);
203
- const was = useMemo(() => inForm(collection.fields, initial), [collection, initial]);
204
- const changed = collection.fields.filter((f) => !same(asSaved[f.name], was[f.name]));
205
- const dirty = bodyChanged || changed.length > 0;
206
-
207
- const set = (name: string, value: unknown) => setValues((v) => ({ ...v, [name]: value }));
208
- const notify = env.notify;
209
- // A form that cannot be saved: why, as a message, and the field marked (useWrongField in fields.tsx), with its section
210
- // opened first (unfold, below).
211
- const wrongField = useWrongField(
212
- (text) => notify({ text, error: true }),
213
- (path) => unfold(sectionOf(path)),
214
- );
215
- // On a wide computer screen: the navigation drawer beside the form, until its icon puts it away.
216
- const [drawer, setDrawer] = useState(true);
217
- const has = (id: Section) => collection.fields.some(inSection(id));
218
- const sections: NavigationItem[] = [
219
- { id: 'content', label: t.forms.sections.content, icon: Article },
220
- ...(has('status') ? [{ id: 'status', label: t.forms.sections.status, icon: Eye }] : []),
221
- ...(has('other') ? [{ id: 'other', label: t.forms.otherFields, icon: SlidersHorizontal }] : []),
222
- ...(has('seo') ? [{ id: 'seo', label: t.forms.seo, icon: MagnifyingGlass }] : []),
223
- ...(has('share') ? [{ id: 'share', label: t.forms.share, icon: ShareNetwork }] : []),
224
- ...(isNew ? [] : [{ id: 'versions', label: t.history.versions, icon: ClockCounterClockwise }]),
225
- ];
226
- const shows = (id: Section) => sections.some((s) => s.id === id);
227
- // The section in view, marked in the drawer: while the page scrolls, the last one whose top is above a third of the
228
- // screen (at the bottom of the page, the last one in view). A tap in the drawer marks its own and scrolls there; the
229
- // mark stays meanwhile (`steering`, until the scroll ends).
230
- const [section, setSection] = useState<Section>('content');
231
- const boxes = useRef<Partial<Record<Section, HTMLElement | null>>>({});
232
- const order = useRef<Section[]>([]);
233
- order.current = sections.map((s) => s.id as Section);
234
- const steering = useRef(0);
235
- const spy = useRef(() => {});
236
- const steer = (id: Section) => {
237
- setSection(id);
238
- steering.current = performance.now() + 1500;
239
- addEventListener('scrollend', () => (steering.current = 0), { once: true });
240
- };
241
- // The sections that are open; at first only the text (and "Søgemaskiner" when it is opened at it, `opening`).
242
- const [open, setOpen] = useState<ReadonlySet<Section>>(() => new Set(['content', ...(opening === 'seo' ? ['seo' as const] : [])]));
243
- const unfold = (id: Section) => setOpen((was) => (was.has(id) ? was : new Set([...was, id])));
244
- // A section opened by its heading is marked in the drawer; one folded, the one in view now (the page is shorter,
245
- // without a scroll).
246
- const toggle = (id: Section) => {
247
- const opening = !open.has(id);
248
- setOpen((was) => {
249
- const next = new Set(was);
250
- if (opening) next.add(id);
251
- else next.delete(id);
252
- return next;
253
- });
254
- if (opening) setSection(id);
255
- else requestAnimationFrame(() => spy.current());
256
- };
257
- useEffect(() => {
258
- let frame = 0;
259
- spy.current = () => {
260
- frame = 0;
261
- if (performance.now() < steering.current) return;
262
- const bottom = innerHeight + scrollY >= document.documentElement.scrollHeight - 8;
263
- const line = bottom ? innerHeight - 96 : innerHeight / 3;
264
- let here: Section | undefined;
265
- for (const id of order.current) {
266
- const top = boxes.current[id]?.getBoundingClientRect().top;
267
- if (top !== undefined && (here === undefined || top <= line)) here = id;
268
- }
269
- if (here) setSection(here);
270
- };
271
- const onScroll = () => {
272
- if (!frame) frame = requestAnimationFrame(spy.current);
273
- };
274
- addEventListener('scroll', onScroll, { passive: true });
275
- return () => {
276
- removeEventListener('scroll', onScroll);
277
- cancelAnimationFrame(frame);
278
- };
279
- }, []);
280
- // Opens a section and scrolls to it (or to `box` in it: the text), after the drawer on a phone has closed (the page does
281
- // not scroll while a dialog is open). It is opened at once (flushSync), so it is there to scroll to.
282
- const textBox = useRef<HTMLDivElement>(null);
283
- const goTo = (id: Section, box?: HTMLElement | null) => {
284
- if (!open.has(id)) flushSync(() => unfold(id));
285
- steer(id);
286
- requestAnimationFrame(() => (box ?? boxes.current[id])?.scrollIntoView({ block: 'start', behavior: scrolling() }));
287
- };
288
- const pick = (id: string) => goTo(id as Section);
289
- // Opened from "SEO-vurdering" (SeoScores.tsx): straight to "Søgemaskiner" (open from the start, `open`).
290
- useEffect(() => {
291
- if (opening !== 'seo') return;
292
- opening = null;
293
- if (shows('seo')) goTo('seo');
294
- }, []);
295
- // The field marked as wrong is scrolled to by itself (FieldInput), its section opened (useWrongField) and marked in the
296
- // drawer.
297
- useEffect(() => {
298
- if (wrongField.field) steer(sectionOf(wrongField.field.path));
299
- }, [wrongField.field?.at]);
300
- // A section: where it is, for the drawer, its heading (its name in the drawer), and whether it is open.
301
- const sectionProps = (id: Section) => ({
302
- ref: (el: HTMLElement | null) => void (boxes.current[id] = el),
303
- title: sections.find((s) => s.id === id)!.label,
304
- open: open.has(id),
305
- onToggle: () => toggle(id),
306
- });
307
-
308
- // The SEO rating at the top of "Søgemaskiner" (seo.ts), again with every change of a field while that is open and near
309
- // the screen (`seoBox`, what is in the section, is hidden while it is folded); the same for the preview of a shared link
310
- // at the top of "Sociale medier" (its first picture in the text). The text is read from the editor when one of them is
311
- // opened or comes near the screen, and while it is there a moment after the text was last changed: not on every
312
- // keystroke, nor while they are folded or out of view (then the rating keeps what it had; it is worked out once from
313
- // the start, so it does not move what is under it when it comes into view).
314
- const post = collection.id === 'blog';
315
- const seoBox = useRef<HTMLDivElement>(null);
316
- const seoNear = useInView(seoBox, '200px');
317
- const shareBox = useRef<HTMLDivElement>(null);
318
- const shareNear = useInView(shareBox, '200px');
319
- const textNear = seoNear || shareNear;
320
- const near = useRef(textNear);
321
- near.current = textNear;
322
- const [body, setBody] = useState(() => shown?.body ?? parts.body);
323
- const readBody = () => setBody(text.current?.markdown() ?? shown?.body ?? parts.body);
324
- const reading = useRef(0);
325
- useEffect(() => {
326
- if (textNear && bodyChanged) readBody();
327
- }, [textNear]);
328
- useEffect(() => () => clearTimeout(reading.current), []);
329
- const onText = () => {
330
- setBodyChanged(true);
331
- if (!near.current) return;
332
- clearTimeout(reading.current);
333
- reading.current = window.setTimeout(readBody, 600);
334
- };
335
- const lastRating = useRef<SeoCheck[] | null>(null);
336
- const rating = useMemo(
337
- () => (seoNear || !lastRating.current ? seoRating(seoInputOf(values, body, post, env.relations)) : lastRating.current),
338
- [seoNear, post, body, values, env.relations],
339
- );
340
- lastRating.current = rating;
341
- // The link as it is shared: as Base.astro writes it, from a page's H1 (pageH1) or a post's title, and its picture
342
- // (a post's "Billede øverst", else the first in the text; a page has its pictures in its text, and the front page shows
343
- // the site's picture, as Home.astro).
344
- const card = sharedAs({
345
- title: (!post && pageH1(body).heading) || String(values.title ?? ''),
346
- front,
347
- seoTitle: String(values.seoTitle ?? ''),
348
- seoDescription: String(values.seoDescription ?? ''),
349
- shareTitle: String(values.shareTitle ?? ''),
350
- shareDescription: String(values.shareDescription ?? ''),
351
- shareImage: String(values.shareImage ?? ''),
352
- description: String(values.description ?? ''),
353
- image: front ? undefined : String(values.image ?? '') || firstPicture(body),
354
- site: sharing,
355
- });
356
-
357
- // A picture is kept in the browser until "Gem", which uploads it with the page.
358
- const pictures = useUploads(notify);
359
- const [pickImage, picker] = useMediaPicker(env.media, pictures);
360
-
361
- const fields: FieldContext = {
362
- relations: env.relations,
363
- lists: env.lists,
364
- testimonials: env.testimonials,
365
- site: env.site,
366
- pickImage,
367
- // A page's address, for what is shown on it (a button that opens a promotion); a new one has none yet.
368
- page: collection.id === 'pages' && url ? url : undefined,
369
- };
370
-
371
- // A new page or post without a title is called "Ny side 1", "Ny side 2" … (in the site's language,
372
- // the first that no page or post of its kind has as its title or file name).
373
- function untitled() {
374
- const own = env.files.filter((f) => f.path.startsWith(`${collection.folder}/`) && f.path.endsWith('.md'));
375
- const titles = new Set(own.map((f) => String(readFields(splitFrontmatter(f.text).yaml).title ?? '')));
376
- const names = new Set(own.map((f) => slugOf(f.path, collection.folder).split('/').at(-1)));
377
- for (let n = 1; ; n++) {
378
- const title = `${asWritten(collection).create} ${n}`;
379
- if (!titles.has(title) && !names.has(slugify(title))) return title;
380
- }
381
- }
382
- const named = () => (isNew && !String(values.title ?? '').trim() ? { ...values, title: untitled() } : values);
383
-
384
- // `other`: an address chosen in the dialog for a taken one (below), saved instead of the one typed.
385
- async function save(other?: string, values = named()) {
386
- if (wrongField.stop(problemAt(collection.fields, values))) return;
387
- // Planlagt, at a time chosen now: one still to come.
388
- if (
389
- asSaved.status === 'scheduled' &&
390
- changed.some((f) => f.name === 'status' || f.name === 'publishAt') &&
391
- isOver(String(asSaved.publishAt)) &&
392
- wrongField.stop({ text: t.forms.entry.scheduledPast, path: 'publishAt' })
393
- )
394
- return;
395
- // A standard page is on the site: it cannot be a draft or wait for its time.
396
- if (
397
- standard &&
398
- (asSaved.status === 'draft' || asSaved.status === 'scheduled') &&
399
- wrongField.stop({ text: t.forms.entry.standardPage(siteText(standard.label), standardPageAt(standard.key)), path: 'status' })
400
- )
401
- return;
402
- const slug = slugify(values.title as string);
403
- if (isNew && !slug) return notify({ text: t.forms.entry.titleNeedsLetters, error: true });
404
- // The address: the one typed, or for a new one /<title>/. A post's is its file name; a page's
405
- // is its file name too when new, and its own `path` when that is another.
406
- const typed = addressOf(other ?? String(values.path ?? ''), post ? 'post' : 'page');
407
- const address = typed || (isNew ? `/${slug}/` : url);
408
- const moved = !isNew && address !== url;
409
- const name = post || isNew ? address.split('/').at(-2)! : slugOf(base.path, collection.folder);
410
- const path = isNew || (post && moved) ? `${collection.folder}/${name}.md` : base.path;
411
- // A new page or post, or a new address: not where another page is, or an address the site sends on
412
- // elsewhere (the files of the redirects as they are now, redirects.ts).
413
- let loaded: RepoFile[] = [];
414
- if (isNew || moved)
415
- try {
416
- loaded = await env.load(REDIRECT_FILES);
417
- } catch (e) {
418
- return notify({ text: t.forms.entry.redirectsNotLoaded((e as Error).message), error: true });
419
- }
420
- const reason = isNew || moved ? env.taken(address, loaded) : null;
421
- // An address the site sends on elsewhere: the owner is told where to, and chooses to remove the redirect
422
- // (saved with the page) or another address.
423
- const freeing = !!reason?.redirect;
424
- if (reason?.redirect) {
425
- const { name, via } = reason.redirect;
426
- const answer = await choose({
427
- title: t.forms.entry.takenTitle(address),
428
- text: t.forms.entry.takenText(address, name, via, post),
429
- choices: [
430
- { value: 'free', label: t.forms.entry.freeAndSave },
431
- { value: 'other', label: t.forms.entry.pickOther },
432
- ],
433
- });
434
- if (!answer) return;
435
- if (answer.choice === 'other') {
436
- const next = await choose({
437
- title: t.forms.entry.otherTitle,
438
- text: t.forms.entry.otherText(post),
439
- input: { label: t.forms.address, value: address },
440
- choices: [{ value: 'save', label: t.common.save }],
441
- });
442
- const chosen = next && addressOf(next.text, post ? 'post' : 'page');
443
- if (!next) return;
444
- if (!chosen) return notify({ text: t.forms.writeAddress, error: true });
445
- set('path', chosen);
446
- return save(chosen);
447
- }
448
- } else if (reason)
449
- return notify({
450
- text: t.forms.entry.taken(reason.text, !!typed, post),
451
- error: true,
452
- });
453
-
454
- if (
455
- path !== base.path &&
456
- env.files.some(
457
- (f) => f.path !== base.path && slugOf(f.path, collection.folder) === name && f.path.startsWith(`${collection.folder}/`),
458
- )
459
- )
460
- return notify({ text: t.forms.entry.fileExists(name), error: true });
461
-
462
- // A new file gets all its filled-in fields in the order of the form; an existing one only the changed. As saved:
463
- // the values given (a new one without a title has its name, "Ny side 1", named()), not only those in the form.
464
- const saved = inForm(collection.fields, values);
465
- const written = (isNew ? collection.fields.filter((f) => !same(saved[f.name], []) && f.type !== 'readonly') : changed).filter(
466
- (f) => f.name !== 'path',
467
- );
468
- const own = !post && (moved || (isNew && address !== `/${name}/`)) ? { path: address } : {};
469
- const yaml = writeFields(isNew ? '' : parts.yaml, { ...Object.fromEntries(written.map((f) => [f.name, saved[f.name]])), ...own });
470
- const markdown = bodyChanged ? text.current?.markdown() : null;
471
- if (isNew && !markdown?.trim()) {
472
- goTo('content', textBox.current);
473
- return notify({ text: t.forms.entry.empty(post), error: true });
474
- }
475
- const body = markdown == null ? parts.body : `\n${markdown.trim()}\n`;
476
- // The blocks in the text by their own fields and the template, as the site checks them before it is built.
477
- const [wrongBlock] = blockProblems(body, template, front ? frontLeads : undefined);
478
- if (wrongBlock) {
479
- goTo('content', textBox.current);
480
- return notify({ text: wrongBlock, error: true });
481
- }
482
- let next = `---\n${yaml}\n---\n${body}`;
483
-
484
- // The redirects (redirects.ts): a freed address no longer sends on (those that went on through it
485
- // first go straight to where it sent them), the old address sends on to the new one, the new
486
- // address is the page's (the editor's own redirect from it goes), and none sends on through another.
487
- let redirectChanges: Change[] = [];
488
- if (isNew || moved) {
489
- let all = readAll(loaded);
490
- if (freeing) {
491
- const flat = flatten(all);
492
- if (typeof flat === 'string') return notify({ text: flat, error: true });
493
- all = setRedirect(flat, address, null);
494
- }
495
- const own = moveRedirects(all.own, moved ? url : null, address);
496
- if (typeof own === 'string') return notify({ text: own, error: true });
497
- const flat = flatten({ ...all, own });
498
- if (typeof flat === 'string') return notify({ text: flat, error: true });
499
- redirectChanges = changesOf(flat, loaded);
500
- }
501
- // The links to the old address everywhere, and the posts picked in the blocks on the pages (by their file name).
502
- const others: RepoFile[] = [];
503
- if (moved) {
504
- next = retarget(next, url, address);
505
- const was = slugOf(base.path, collection.folder);
506
- for (const f of env.files) {
507
- if (f.path === base.path || f.path === REDIRECTS || !/\.(md|json)$/.test(f.path)) continue;
508
- let changedText = retarget(f.text, url, address);
509
- if (post && f.path.startsWith(`${config.folders.pages}/`)) changedText = pickedRenamed(changedText, was, name);
510
- if (changedText !== f.text) others.push({ ...f, text: changedText });
511
- }
512
- }
513
- if (next === base.text && !moved && !redirectChanges.length) {
514
- setBodyChanged(false);
515
- return notify({ text: t.forms.nothingToSave });
516
- }
517
- // Pictures that were added and are still used.
518
- const used = pictures.usedIn(next);
519
- const removed = base.path && path !== base.path ? [base.path] : [];
520
- const changes: Change[] = [
521
- { path, content: next, oid: path === base.path ? base.oid || null : null },
522
- ...removed.map((p) => ({ path: p, content: null, oid: base.oid })),
523
- ...others.map((f) => ({ path: f.path, content: f.text, oid: f.oid })),
524
- ...redirectChanges,
525
- ...used.map((u) => ({ path: u.path, content: u.bytes, oid: null })),
526
- ];
527
- const what = `"${slugOf(path, collection.folder)}"`;
528
- const message = moved
529
- ? siteT.forms.commit.move(asWritten(collection).singular, url, address)
530
- : isNew
531
- ? siteT.forms.commit.create(asWritten(collection).create!, what)
532
- : version
533
- ? siteT.history.commit.restoreVersion(`${asWritten(collection).singular} ${what}`, savedOn(version))
534
- : siteT.forms.commit.editOne(asWritten(collection).singular, what);
535
- const entry = kindAndName(collection.singular, String(values.title ?? '').trim() || name);
536
- const change = moved
537
- ? t.app.changes.moved(entry, address)
538
- : isNew
539
- ? t.app.changes.created(entry)
540
- : version
541
- ? t.app.changes.restored(entry)
542
- : t.app.changes.edited(entry);
543
- setSaving(true);
544
- notify({ text: t.common.saving });
545
- try {
546
- const oids = await env.commit(message, changes, change);
547
- const saved = { path, oid: oids[path], text: next };
548
- pictures.markSaved(used);
549
- setBase(saved);
550
- setValues(valuesOf(collection, saved));
551
- setBodyChanged(false);
552
- setSavedAt(new Date());
553
- env.onSaved(saved, {
554
- files: [
555
- ...others.map((f) => ({ ...f, oid: oids[f.path] })),
556
- // The editor's own redirects are among its files (the others are loaded when needed).
557
- ...redirectChanges
558
- .filter((c) => c.path === REDIRECTS)
559
- .map((c) => ({ path: REDIRECTS, oid: oids[REDIRECTS], text: String(c.content) })),
560
- ],
561
- removed,
562
- });
563
- notify({ text: savedText(change), kind: 'saved' });
564
- // The version is now the one saved: the form as usual again.
565
- if (version) onVersion(null);
566
- // A new address: the owner is told, in a dialog that stays until it is closed, that the old one now
567
- // sends on to it, and where the redirects are.
568
- if (moved) {
569
- void choose({
570
- title: t.forms.entry.movedTitle,
571
- text: t.forms.entry.movedText(address, url, post, redirectsPath()),
572
- choices: [
573
- { value: 'ok', label: t.forms.entry.ok },
574
- { value: 'see', label: t.forms.entry.seeRedirects },
575
- ],
576
- cancel: false,
577
- }).then((seen) => seen?.choice === 'see' && (location.hash = TAB_ROUTES.redirects));
578
- }
579
- return true;
580
- } catch (e) {
581
- notify({ text: e instanceof ConflictError ? e.message : t.common.notSaved((e as Error).message), error: true, kind: 'failed' });
582
- } finally {
583
- setSaving(false);
584
- }
585
- }
586
-
587
- // "Forhåndsvis": the page or post with what is changed in the form; a new one, or one whose layout is changed in the
588
- // form (a field with `layout`, "Hele bredden"), in the page of another one of its kind saved with that layout, with
589
- // all of the form put in it (pagePreview.tsx).
590
- const [openPreview, previewSheet] = usePagePreview();
591
- const showPreview = () =>
592
- openPreview(() => {
593
- const layout = collection.fields.filter((f) => f.layout);
594
- const relaid = !isNew && changed.some((f) => f.layout);
595
- const sameLayout = (data: Record<string, unknown>) =>
596
- layout.every((f) => String(data[f.name] || '') === String(values[f.name] || ''));
597
- const changes = (...names: string[]) => isNew || relaid || changed.some((f) => names.includes(f.name));
598
- const others =
599
- isNew || relaid
600
- ? env.files
601
- .filter((f) => f.path.startsWith(`${collection.folder}/`) && f.path.endsWith('.md') && f.path !== base.path)
602
- .map((f) => ({ f, data: readFields(splitFrontmatter(f.text).yaml) }))
603
- .filter(({ data }) => sameLayout(data))
604
- .map(({ f, data }) => addressNow(collection, f, data))
605
- .slice(0, 5)
606
- : [];
607
- const markdown = bodyChanged ? text.current?.markdown() : null;
608
- // A page's H1 is at the top of its text (pageH1); a blog post's is its title.
609
- const h1 = collection.blocks ? pageH1(markdown ?? parts.body) : undefined;
610
- return {
611
- shell: isNew || (relaid && others.length) ? others : url,
612
- heading: h1?.heading ?? (changes('title', 'heading') ? String(values.heading || named().title) : undefined),
613
- headingHtml: h1?.html,
614
- // A post's picture at the top (a page has its pictures in its text).
615
- image:
616
- collection.fields.some((f) => f.name === 'image') && changes('image', 'imageAlt', 'imageDecorative')
617
- ? values.image
618
- ? { src: String(values.image), alt: values.imageDecorative === true ? '' : String(values.imageAlt ?? '') }
619
- : null
620
- : undefined,
621
- body: isNew || relaid || markdown != null ? (markdown ?? parts.body) : undefined,
622
- data: { lists: env.lists, testimonials: env.testimonials, site: env.site, relations: env.relations, path: url },
623
- };
624
- });
625
-
626
- // A post planned for a time gets that day as its date, so it comes out as the newest.
627
- const onChange = (f: Field, v: unknown) => {
628
- set(f.name, v);
629
- if (f.name === 'publishAt' && /^\d{4}-\d{2}-\d{2}T/.test(String(v)) && collection.fields.some((d) => d.name === 'date'))
630
- set('date', String(v).slice(0, 10));
631
- };
632
- // The fields in use (Field.only).
633
- const field = (f: Field) =>
634
- inUse(f, collection.fields, values) && <FieldInput key={f.name} field={f} value={values[f.name]} onChange={(v) => onChange(f, v)} />;
635
-
636
- return (
637
- <Fields.Provider value={fields}>
638
- <WrongField.Provider value={wrongField.field}>
639
- <FormFrame
640
- title={isNew ? collection.create! : String(initial.title || collection.singular)}
641
- back={{ href: `#liste/${collection.id}`, label: collection.label }}
642
- status={savedStatus({ savedAt, saving, deleting, isNew })}
643
- view={isNew ? undefined : url}
644
- drawer={
645
- <>
646
- <div className="contents xl:hidden">
647
- <ModalNavigationDrawer title={t.forms.sections.title} items={sections} value={section} onChange={pick} />
648
- </div>
649
- <div className="hidden xl:contents">
650
- <NavigationDrawerButton label={t.forms.sections.title} open={drawer} onClick={() => setDrawer(!drawer)} />
651
- </div>
652
- </>
653
- }
654
- dirty={dirty}
655
- saving={saving || deleting}
656
- deleting={deleting}
657
- onSave={() => save()}
658
- onPreview={showPreview}
659
- onDelete={collection.deletable && !isNew ? () => env.remove(collection, base, setDeleting) : undefined}
660
- deleteLabel={t.forms.deleteOne(collection.singular)}
661
- panel
662
- >
663
- {version && <VersionNotice version={version} onCurrent={() => onVersion(null)} />}
664
- <FormColumns
665
- sticky
666
- wideOnly
667
- main={
668
- <>
669
- <FoldSection {...sectionProps('content')}>
670
- {collection.fields.filter((f) => TOP.includes(f.name) && !STATUS.includes(f.name)).map(field)}
671
- <div ref={textBox} className="scroll-mt-[calc(5rem+env(safe-area-inset-top))]">
672
- <span className="mb-1 block font-medium">{t.forms.text}</span>
673
- <MarkdownEditor
674
- ref={text}
675
- value={shown?.body ?? parts.body}
676
- onChange={onText}
677
- addImage={pickImage}
678
- blocks={collection.blocks ? blockAsk : undefined}
679
- h1={!!collection.blocks}
680
- sheet={blockAsk}
681
- looks={
682
- front
683
- ? (items) =>
684
- looksOf(items, { file: `${config.standardPages}#front`, leads: frontLeads, sectionLooks, headingLooks })
685
- : undefined
686
- }
687
- sections={front}
688
- />
689
- </div>
690
- </FoldSection>
691
- {shows('status') && (
692
- <FoldSection {...sectionProps('status')}>
693
- {collection.fields.filter((f) => STATUS.includes(f.name)).map(field)}
694
- </FoldSection>
695
- )}
696
- {shows('other') && (
697
- <FoldSection {...sectionProps('other')}>{collection.fields.filter(inSection('other')).map(field)}</FoldSection>
698
- )}
699
- {shows('seo') && (
700
- <FoldSection {...sectionProps('seo')}>
701
- <div ref={seoBox} className="space-y-6">
702
- {rating.length > 0 && <CheckList title={t.forms.seoRating.title} items={rating} />}
703
- {collection.fields.filter(inSection('seo')).map(field)}
704
- </div>
705
- </FoldSection>
706
- )}
707
- {shows('share') && (
708
- <FoldSection {...sectionProps('share')}>
709
- <div ref={shareBox} className="space-y-6">
710
- <SharePreview
711
- image={shownAt(splitPicture(card.image).src)}
712
- domain={config.domain}
713
- title={card.title}
714
- description={card.description}
715
- />
716
- {collection.fields.filter(inSection('share')).map(field)}
717
- </div>
718
- </FoldSection>
719
- )}
720
- {shows('versions') && (
721
- <FoldSection {...sectionProps('versions')}>
722
- <VersionList env={env} path={base.path} oid={base.oid} onPick={onVersion} />
723
- </FoldSection>
724
- )}
725
- </>
726
- }
727
- side={drawer && <NavigationDrawer title={t.forms.sections.title} items={sections} value={section} onChange={pick} />}
728
- />
729
- </FormFrame>
730
- {blockDialog}
731
- {picker}
732
- {previewSheet}
733
- </WrongField.Provider>
734
- </Fields.Provider>
735
- );
736
- }
737
-
738
- // A page's text with a post's new file name in its blocks with blog posts ("Blogindlæg", `picked`).
739
- const pickedRenamed = (text: string, was: string, name: string) =>
740
- text.replace(/^<div data-block="posts".*$/gm, (line) => line.replaceAll(`&quot;${was}&quot;`, `&quot;${name}&quot;`));
1
+ // One blog post or page: its fields and its text, and "Gem", which saves the file as a commit.
2
+ // Only what was changed is written: untouched fields keep their exact text, and the body is only
3
+ // rewritten by Milkdown when the text was edited (packages/editor/scripts/editor-roundtrip.mjs checks that this
4
+ // gives the same page). A new address (Adresse) moves the page or post, with a redirect from the
5
+ // old one (addresses.ts). An earlier version (Versions.tsx) is opened as changes that are not saved,
6
+ // at the address the page or post has now.
7
+ import { useEffect, useMemo, useRef, useState } from 'react';
8
+ import { flushSync } from 'react-dom';
9
+ import { slugify, slugOf, type Field, type MarkdownCollection } from './schema';
10
+ import { config, frontLeads, headingLooks, sectionLooks, sharing, standardPages, standardPaths } from '@site/editor';
11
+ import { asWritten, placeOf, standardPageAt } from './site';
12
+ import { ConflictError, type Change, type RepoFile } from './github';
13
+ import { addressOf, moveRedirects, REDIRECTS, retarget } from './addresses';
14
+ import { changesOf, flatten, readAll, REDIRECT_FILES, setRedirect } from './redirectData';
15
+ import { TAB_ROUTES } from './Redirects';
16
+ import { choose } from './ask';
17
+ import { readFields, splitFrontmatter, writeFields } from './frontmatter.mjs';
18
+ import MarkdownEditor, { type MarkdownHandle } from './MarkdownEditor';
19
+ import { useBlockDialog } from './BlockDialog';
20
+ import { blockHtml } from '../lib/blocks.mjs';
21
+ import { sectionBlocks } from './blocks';
22
+ import { looksOf, pageH1 } from './sections.mjs';
23
+ import { shownAt, useUploads } from './images';
24
+ import { useMediaPicker } from './Media';
25
+ import { inUse, problemAt } from './check';
26
+ import { isOver } from '../lib/notice.mjs';
27
+ import { splitPicture } from '../lib/crop.mjs';
28
+ import { asStandardTemplate, blockProblems, notOffered, siteBlocks } from './registry';
29
+ import { FieldInput, Fields, useWrongField, WrongField, type FieldContext } from './fields';
30
+ import { Article, ClockCounterClockwise, Eye, MagnifyingGlass, ShareNetwork, SlidersHorizontal } from '@phosphor-icons/react';
31
+ import {
32
+ CheckList,
33
+ FoldSection,
34
+ FormColumns,
35
+ ModalNavigationDrawer,
36
+ NavigationDrawer,
37
+ NavigationDrawerButton,
38
+ type NavigationItem,
39
+ SharePreview,
40
+ useInView,
41
+ } from './ui';
42
+ import FormFrame, { savedStatus, useSavedAt } from './FormFrame';
43
+ import { usePagePreview } from './pagePreview';
44
+ import { savedOn, VersionList, VersionNotice, WithVersions, type ShownVersion } from './Versions';
45
+ import type { Env } from './App';
46
+ import { kindAndName, savedText } from './deploy';
47
+ import { siteT, siteText, t } from './i18n';
48
+ import { firstPicture, SEO_FIELDS, seoInputOf, seoRating, SHARE_FIELDS, sharedAs, type SeoCheck } from './seo';
49
+
50
+ // At the top: the status (Kladde, Skjult, Udgivet, Planlagt and the time for Planlagt), then the title.
51
+ // On a wide computer screen the status is at the right above the other fields (FormColumns).
52
+ const TOP = ['status', 'publishAt', 'title', 'heading'];
53
+ const STATUS = ['status', 'publishAt'];
54
+ // Under their own headings at the bottom: the Google texts, the keywords and the canonical address; then the picture, title
55
+ // and description when the page is shared.
56
+ const SEO = SEO_FIELDS;
57
+ const SHARE = SHARE_FIELDS;
58
+ // The sections of the form, under each other on one page, each folding in and out under its heading (FoldSection in
59
+ // ui.tsx): the text with the title and heading (open from the start), the status, the other fields, those for search
60
+ // engines, those for social media, and the earlier versions (folded from the start, so the page is short; nothing is remembered till
61
+ // the next time). Its navigation drawer (on a wide computer screen in the right column, narrower from the icon at the
62
+ // right of the top bar) opens one and scrolls to it, and marks the one in view. A field that keeps the form from being
63
+ // saved has its section opened and is scrolled to (FieldInput in fields.tsx).
64
+ type Section = 'content' | 'status' | 'other' | 'seo' | 'share' | 'versions';
65
+ const sectionOf = (path: string): Section => {
66
+ const name = path.split('.')[0];
67
+ return STATUS.includes(name)
68
+ ? 'status'
69
+ : TOP.includes(name)
70
+ ? 'content'
71
+ : SEO.includes(name)
72
+ ? 'seo'
73
+ : SHARE.includes(name)
74
+ ? 'share'
75
+ : 'other';
76
+ };
77
+ // The section the next page or post opened starts at: "Søgemaskiner" when it is opened from "SEO-vurdering"
78
+ // (SeoScores.tsx), else the text.
79
+ let opening: Section | null = null;
80
+ export const openAtSeo = () => {
81
+ opening = 'seo';
82
+ };
83
+ // The fields of a section (the other fields are those of none of the others).
84
+ const inSection = (id: Section) => (f: Field) => sectionOf(f.name) === id;
85
+
86
+ // A new file: a blog post gets today's date; a page has the blocks its template
87
+ // starts it with (Template.start), e.g. "Book en tid" at the end like the other pages, but not one that
88
+ // is turned off (off, "Blokindstillinger").
89
+ export const newMarkdown = (collection: MarkdownCollection, off: string[] = []): RepoFile => ({
90
+ path: '',
91
+ oid: '',
92
+ text:
93
+ collection.id === 'blog'
94
+ ? `---\ndate: ${new Date().toLocaleDateString('sv')}\n---\n`
95
+ : `---\n---\n${(collection.template?.start ?? [])
96
+ .filter((type) => !off.includes(type))
97
+ .map((type) => `\n${blockHtml(type)}\n`)
98
+ .join('')}`,
99
+ });
100
+
101
+ // The address of a saved page or post: a page's own `path`, or else /<file name>/ (packages/editor/site/content.ts).
102
+ // '' for a new one.
103
+ // Where the redirects are, as the start screen has it: its group (the site's), or "Indstillinger", › "Søgemaskineoptimering" › "Videresendelser" ›
104
+ // "Alle videresendelser".
105
+ const redirectsPath = () => [placeOf('seo'), t.common.parts.seo, t.common.parts.redirects, t.redirects.all].filter(Boolean).join(' › ');
106
+
107
+ function addressNow(collection: MarkdownCollection, file: RepoFile, data: Record<string, unknown>) {
108
+ if (!file.path) return '';
109
+ return (collection.id === 'pages' && data.path ? String(data.path) : '') || `/${slugOf(file.path, collection.folder)}/`;
110
+ }
111
+
112
+ function valuesOf(collection: MarkdownCollection, file: RepoFile): Record<string, unknown> {
113
+ const data = readFields(splitFrontmatter(file.text).yaml);
114
+ return Object.fromEntries(
115
+ collection.fields.map((f) => {
116
+ const v = data[f.name];
117
+ if (f.name === 'path') return [f.name, addressNow(collection, file, data)];
118
+ if (f.type === 'boolean') return [f.name, v === true];
119
+ if (f.multiple) return [f.name, Array.isArray(v) ? v.map(String) : []];
120
+ return [f.name, v == null ? '' : String(v)];
121
+ }),
122
+ );
123
+ }
124
+
125
+ // Scrolling to a part of the form: smoothly, unless less motion is asked for.
126
+ const scrolling = (): ScrollBehavior => (matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth');
127
+
128
+ const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
129
+
130
+ // The values as they are saved: a field only in use with another's value (Field.only: "Tidspunkt" with
131
+ // "Planlagt") is empty without it, so it leaves the file.
132
+ const inForm = (fields: Field[], values: Record<string, unknown>) =>
133
+ Object.fromEntries(fields.map((f) => [f.name, inUse(f, fields, values) ? values[f.name] : '']));
134
+
135
+ type Props = { collection: MarkdownCollection; file: RepoFile; env: Env };
136
+
137
+ // The form starts again with an earlier version, or with the one saved now.
138
+ export default function EntryForm(props: Props) {
139
+ return (
140
+ <WithVersions notify={props.env.notify}>
141
+ {(version, onVersion) => <Form {...props} version={version} onVersion={onVersion} />}
142
+ </WithVersions>
143
+ );
144
+ }
145
+
146
+ function Form({
147
+ collection,
148
+ file,
149
+ env,
150
+ version,
151
+ onVersion,
152
+ }: Props & { version: ShownVersion | null; onVersion: (v: ShownVersion | null) => void }) {
153
+ // The file as last loaded or saved; what the form compares with and writes on top of.
154
+ const [base, setBase] = useState(file);
155
+ const parts = useMemo(() => splitFrontmatter(base.text), [base]);
156
+ const initial = useMemo(() => valuesOf(collection, base), [collection, base]);
157
+ // An earlier version: its fields and text, at the address there is now.
158
+ const [shown] = useState(() => version && splitFrontmatter(version.text));
159
+ const [values, setValues] = useState(() =>
160
+ version ? { ...valuesOf(collection, { ...base, text: version.text }), path: initial.path } : initial,
161
+ );
162
+ const [bodyChanged, setBodyChanged] = useState(() => !!shown && shown.body !== parts.body);
163
+ const [saving, setSaving] = useState(false);
164
+ const [deleting, setDeleting] = useState(false);
165
+ const text = useRef<MarkdownHandle>(null);
166
+ // Where the page or post is on the site now.
167
+ const url = String(initial.path);
168
+ // Which of the site's blocks to put in the text, and their fields (pages only).
169
+ // What can be put in: what the template allows, but not the blocks turned off under
170
+ // "Blokindstillinger", nor those of an app not installed (those already in the text stay, and are
171
+ // checked by the template alone when saved).
172
+ const off = useMemo(() => notOffered(env.files), [env.files]);
173
+ // A page chosen as a standard page ("Indstillinger" › "Udseende" › "Standardsider", packages/editor/defaults/standardPages.ts) has its
174
+ // rules too (the blog page has "Blogindlæg"). The front page is written as it looks: the heading, text and picture of a
175
+ // block as the text above its card (its lead), and its top ("Hero") first.
176
+ const paths = standardPaths(env.files.find((f) => f.path === config.standardPages)?.text);
177
+ const standard = url ? standardPages.find((p) => paths[p.key] === url) : undefined;
178
+ const front = standard?.key === 'front';
179
+ const template = useMemo(() => asStandardTemplate(collection.template, standard), [standard, collection]);
180
+ const insertable = useMemo(
181
+ () =>
182
+ off.length ? { ...template, blocks: (template?.blocks ?? siteBlocks.map((b) => b.type)).filter((t) => !off.includes(t)) } : template,
183
+ [off, template],
184
+ );
185
+ const [blockAsk, blockDialog] = useBlockDialog(
186
+ useMemo(() => (front ? sectionBlocks(siteBlocks, frontLeads) : undefined), [front]),
187
+ {
188
+ lists: env.lists,
189
+ testimonials: env.testimonials,
190
+ site: env.site,
191
+ relations: env.relations,
192
+ path: url,
193
+ // A standard page's blocks drawn as the theme draws them there (its editor/previews.tsx, by `<file>#<key>`).
194
+ ...(standard ? { page: { file: `${config.standardPages}#${standard.key}`, values } } : {}),
195
+ },
196
+ insertable,
197
+ );
198
+ // When the file was last saved, shown in the top bar.
199
+ const [savedAt, setSavedAt] = useSavedAt(env, file.path);
200
+
201
+ const isNew = !base.path;
202
+ const asSaved = useMemo(() => inForm(collection.fields, values), [collection, values]);
203
+ const was = useMemo(() => inForm(collection.fields, initial), [collection, initial]);
204
+ const changed = collection.fields.filter((f) => !same(asSaved[f.name], was[f.name]));
205
+ const dirty = bodyChanged || changed.length > 0;
206
+
207
+ const set = (name: string, value: unknown) => setValues((v) => ({ ...v, [name]: value }));
208
+ const notify = env.notify;
209
+ // A form that cannot be saved: why, as a message, and the field marked (useWrongField in fields.tsx), with its section
210
+ // opened first (unfold, below).
211
+ const wrongField = useWrongField(
212
+ (text) => notify({ text, error: true }),
213
+ (path) => unfold(sectionOf(path)),
214
+ );
215
+ // On a wide computer screen: the navigation drawer beside the form, until its icon puts it away.
216
+ const [drawer, setDrawer] = useState(true);
217
+ const has = (id: Section) => collection.fields.some(inSection(id));
218
+ const sections: NavigationItem[] = [
219
+ { id: 'content', label: t.forms.sections.content, icon: Article },
220
+ ...(has('status') ? [{ id: 'status', label: t.forms.sections.status, icon: Eye }] : []),
221
+ ...(has('other') ? [{ id: 'other', label: t.forms.otherFields, icon: SlidersHorizontal }] : []),
222
+ ...(has('seo') ? [{ id: 'seo', label: t.forms.seo, icon: MagnifyingGlass }] : []),
223
+ ...(has('share') ? [{ id: 'share', label: t.forms.share, icon: ShareNetwork }] : []),
224
+ ...(isNew ? [] : [{ id: 'versions', label: t.history.versions, icon: ClockCounterClockwise }]),
225
+ ];
226
+ const shows = (id: Section) => sections.some((s) => s.id === id);
227
+ // The section in view, marked in the drawer: while the page scrolls, the last one whose top is above a third of the
228
+ // screen (at the bottom of the page, the last one in view). A tap in the drawer marks its own and scrolls there; the
229
+ // mark stays meanwhile (`steering`, until the scroll ends).
230
+ const [section, setSection] = useState<Section>('content');
231
+ const boxes = useRef<Partial<Record<Section, HTMLElement | null>>>({});
232
+ const order = useRef<Section[]>([]);
233
+ order.current = sections.map((s) => s.id as Section);
234
+ const steering = useRef(0);
235
+ const spy = useRef(() => {});
236
+ const steer = (id: Section) => {
237
+ setSection(id);
238
+ steering.current = performance.now() + 1500;
239
+ addEventListener('scrollend', () => (steering.current = 0), { once: true });
240
+ };
241
+ // The sections that are open; at first only the text (and "Søgemaskiner" when it is opened at it, `opening`).
242
+ const [open, setOpen] = useState<ReadonlySet<Section>>(() => new Set(['content', ...(opening === 'seo' ? ['seo' as const] : [])]));
243
+ const unfold = (id: Section) => setOpen((was) => (was.has(id) ? was : new Set([...was, id])));
244
+ // A section opened by its heading is marked in the drawer; one folded, the one in view now (the page is shorter,
245
+ // without a scroll).
246
+ const toggle = (id: Section) => {
247
+ const opening = !open.has(id);
248
+ setOpen((was) => {
249
+ const next = new Set(was);
250
+ if (opening) next.add(id);
251
+ else next.delete(id);
252
+ return next;
253
+ });
254
+ if (opening) setSection(id);
255
+ else requestAnimationFrame(() => spy.current());
256
+ };
257
+ useEffect(() => {
258
+ let frame = 0;
259
+ spy.current = () => {
260
+ frame = 0;
261
+ if (performance.now() < steering.current) return;
262
+ const bottom = innerHeight + scrollY >= document.documentElement.scrollHeight - 8;
263
+ const line = bottom ? innerHeight - 96 : innerHeight / 3;
264
+ let here: Section | undefined;
265
+ for (const id of order.current) {
266
+ const top = boxes.current[id]?.getBoundingClientRect().top;
267
+ if (top !== undefined && (here === undefined || top <= line)) here = id;
268
+ }
269
+ if (here) setSection(here);
270
+ };
271
+ const onScroll = () => {
272
+ if (!frame) frame = requestAnimationFrame(spy.current);
273
+ };
274
+ addEventListener('scroll', onScroll, { passive: true });
275
+ return () => {
276
+ removeEventListener('scroll', onScroll);
277
+ cancelAnimationFrame(frame);
278
+ };
279
+ }, []);
280
+ // Opens a section and scrolls to it (or to `box` in it: the text), after the drawer on a phone has closed (the page does
281
+ // not scroll while a dialog is open). It is opened at once (flushSync), so it is there to scroll to.
282
+ const textBox = useRef<HTMLDivElement>(null);
283
+ const goTo = (id: Section, box?: HTMLElement | null) => {
284
+ if (!open.has(id)) flushSync(() => unfold(id));
285
+ steer(id);
286
+ requestAnimationFrame(() => (box ?? boxes.current[id])?.scrollIntoView({ block: 'start', behavior: scrolling() }));
287
+ };
288
+ const pick = (id: string) => goTo(id as Section);
289
+ // Opened from "SEO-vurdering" (SeoScores.tsx): straight to "Søgemaskiner" (open from the start, `open`).
290
+ useEffect(() => {
291
+ if (opening !== 'seo') return;
292
+ opening = null;
293
+ if (shows('seo')) goTo('seo');
294
+ }, []);
295
+ // The field marked as wrong is scrolled to by itself (FieldInput), its section opened (useWrongField) and marked in the
296
+ // drawer.
297
+ useEffect(() => {
298
+ if (wrongField.field) steer(sectionOf(wrongField.field.path));
299
+ }, [wrongField.field?.at]);
300
+ // A section: where it is, for the drawer, its heading (its name in the drawer), and whether it is open.
301
+ const sectionProps = (id: Section) => ({
302
+ ref: (el: HTMLElement | null) => void (boxes.current[id] = el),
303
+ title: sections.find((s) => s.id === id)!.label,
304
+ open: open.has(id),
305
+ onToggle: () => toggle(id),
306
+ });
307
+
308
+ // The SEO rating at the top of "Søgemaskiner" (seo.ts), again with every change of a field while that is open and near
309
+ // the screen (`seoBox`, what is in the section, is hidden while it is folded); the same for the preview of a shared link
310
+ // at the top of "Sociale medier" (its first picture in the text). The text is read from the editor when one of them is
311
+ // opened or comes near the screen, and while it is there a moment after the text was last changed: not on every
312
+ // keystroke, nor while they are folded or out of view (then the rating keeps what it had; it is worked out once from
313
+ // the start, so it does not move what is under it when it comes into view).
314
+ const post = collection.id === 'blog';
315
+ const seoBox = useRef<HTMLDivElement>(null);
316
+ const seoNear = useInView(seoBox, '200px');
317
+ const shareBox = useRef<HTMLDivElement>(null);
318
+ const shareNear = useInView(shareBox, '200px');
319
+ const textNear = seoNear || shareNear;
320
+ const near = useRef(textNear);
321
+ near.current = textNear;
322
+ const [body, setBody] = useState(() => shown?.body ?? parts.body);
323
+ const readBody = () => setBody(text.current?.markdown() ?? shown?.body ?? parts.body);
324
+ const reading = useRef(0);
325
+ useEffect(() => {
326
+ if (textNear && bodyChanged) readBody();
327
+ }, [textNear]);
328
+ useEffect(() => () => clearTimeout(reading.current), []);
329
+ const onText = () => {
330
+ setBodyChanged(true);
331
+ if (!near.current) return;
332
+ clearTimeout(reading.current);
333
+ reading.current = window.setTimeout(readBody, 600);
334
+ };
335
+ const lastRating = useRef<SeoCheck[] | null>(null);
336
+ const rating = useMemo(
337
+ () => (seoNear || !lastRating.current ? seoRating(seoInputOf(values, body, post, env.relations)) : lastRating.current),
338
+ [seoNear, post, body, values, env.relations],
339
+ );
340
+ lastRating.current = rating;
341
+ // The link as it is shared: as Base.astro writes it, from a page's H1 (pageH1) or a post's title, and its picture
342
+ // (a post's "Billede øverst", else the first in the text; a page has its pictures in its text, and the front page shows
343
+ // the site's picture, as Home.astro).
344
+ const card = sharedAs({
345
+ title: (!post && pageH1(body).heading) || String(values.title ?? ''),
346
+ front,
347
+ seoTitle: String(values.seoTitle ?? ''),
348
+ seoDescription: String(values.seoDescription ?? ''),
349
+ shareTitle: String(values.shareTitle ?? ''),
350
+ shareDescription: String(values.shareDescription ?? ''),
351
+ shareImage: String(values.shareImage ?? ''),
352
+ description: String(values.description ?? ''),
353
+ image: front ? undefined : String(values.image ?? '') || firstPicture(body),
354
+ site: sharing,
355
+ });
356
+
357
+ // A picture is kept in the browser until "Gem", which uploads it with the page.
358
+ const pictures = useUploads(notify);
359
+ const [pickImage, picker] = useMediaPicker(env.media, pictures);
360
+
361
+ const fields: FieldContext = {
362
+ relations: env.relations,
363
+ lists: env.lists,
364
+ testimonials: env.testimonials,
365
+ site: env.site,
366
+ pickImage,
367
+ // A page's address, for what is shown on it (a button that opens a promotion); a new one has none yet.
368
+ page: collection.id === 'pages' && url ? url : undefined,
369
+ };
370
+
371
+ // A new page or post without a title is called "Ny side 1", "Ny side 2" … (in the site's language,
372
+ // the first that no page or post of its kind has as its title or file name).
373
+ function untitled() {
374
+ const own = env.files.filter((f) => f.path.startsWith(`${collection.folder}/`) && f.path.endsWith('.md'));
375
+ const titles = new Set(own.map((f) => String(readFields(splitFrontmatter(f.text).yaml).title ?? '')));
376
+ const names = new Set(own.map((f) => slugOf(f.path, collection.folder).split('/').at(-1)));
377
+ for (let n = 1; ; n++) {
378
+ const title = `${asWritten(collection).create} ${n}`;
379
+ if (!titles.has(title) && !names.has(slugify(title))) return title;
380
+ }
381
+ }
382
+ const named = () => (isNew && !String(values.title ?? '').trim() ? { ...values, title: untitled() } : values);
383
+
384
+ // `other`: an address chosen in the dialog for a taken one (below), saved instead of the one typed.
385
+ async function save(other?: string, values = named()) {
386
+ if (wrongField.stop(problemAt(collection.fields, values))) return;
387
+ // Planlagt, at a time chosen now: one still to come.
388
+ if (
389
+ asSaved.status === 'scheduled' &&
390
+ changed.some((f) => f.name === 'status' || f.name === 'publishAt') &&
391
+ isOver(String(asSaved.publishAt)) &&
392
+ wrongField.stop({ text: t.forms.entry.scheduledPast, path: 'publishAt' })
393
+ )
394
+ return;
395
+ // A standard page is on the site: it cannot be a draft or wait for its time.
396
+ if (
397
+ standard &&
398
+ (asSaved.status === 'draft' || asSaved.status === 'scheduled') &&
399
+ wrongField.stop({ text: t.forms.entry.standardPage(siteText(standard.label), standardPageAt(standard.key)), path: 'status' })
400
+ )
401
+ return;
402
+ const slug = slugify(values.title as string);
403
+ if (isNew && !slug) return notify({ text: t.forms.entry.titleNeedsLetters, error: true });
404
+ // The address: the one typed, or for a new one /<title>/. A post's is its file name; a page's
405
+ // is its file name too when new, and its own `path` when that is another.
406
+ const typed = addressOf(other ?? String(values.path ?? ''), post ? 'post' : 'page');
407
+ const address = typed || (isNew ? `/${slug}/` : url);
408
+ const moved = !isNew && address !== url;
409
+ const name = post || isNew ? address.split('/').at(-2)! : slugOf(base.path, collection.folder);
410
+ const path = isNew || (post && moved) ? `${collection.folder}/${name}.md` : base.path;
411
+ // A new page or post, or a new address: not where another page is, or an address the site sends on
412
+ // elsewhere (the files of the redirects as they are now, redirectData.ts).
413
+ let loaded: RepoFile[] = [];
414
+ if (isNew || moved)
415
+ try {
416
+ loaded = await env.load(REDIRECT_FILES);
417
+ } catch (e) {
418
+ return notify({ text: t.forms.entry.redirectsNotLoaded((e as Error).message), error: true });
419
+ }
420
+ const reason = isNew || moved ? env.taken(address, loaded) : null;
421
+ // An address the site sends on elsewhere: the owner is told where to, and chooses to remove the redirect
422
+ // (saved with the page) or another address.
423
+ const freeing = !!reason?.redirect;
424
+ if (reason?.redirect) {
425
+ const { name, via } = reason.redirect;
426
+ const answer = await choose({
427
+ title: t.forms.entry.takenTitle(address),
428
+ text: t.forms.entry.takenText(address, name, via, post),
429
+ choices: [
430
+ { value: 'free', label: t.forms.entry.freeAndSave },
431
+ { value: 'other', label: t.forms.entry.pickOther },
432
+ ],
433
+ });
434
+ if (!answer) return;
435
+ if (answer.choice === 'other') {
436
+ const next = await choose({
437
+ title: t.forms.entry.otherTitle,
438
+ text: t.forms.entry.otherText(post),
439
+ input: { label: t.forms.address, value: address },
440
+ choices: [{ value: 'save', label: t.common.save }],
441
+ });
442
+ const chosen = next && addressOf(next.text, post ? 'post' : 'page');
443
+ if (!next) return;
444
+ if (!chosen) return notify({ text: t.forms.writeAddress, error: true });
445
+ set('path', chosen);
446
+ return save(chosen);
447
+ }
448
+ } else if (reason)
449
+ return notify({
450
+ text: t.forms.entry.taken(reason.text, !!typed, post),
451
+ error: true,
452
+ });
453
+
454
+ if (
455
+ path !== base.path &&
456
+ env.files.some(
457
+ (f) => f.path !== base.path && slugOf(f.path, collection.folder) === name && f.path.startsWith(`${collection.folder}/`),
458
+ )
459
+ )
460
+ return notify({ text: t.forms.entry.fileExists(name), error: true });
461
+
462
+ // A new file gets all its filled-in fields in the order of the form; an existing one only the changed. As saved:
463
+ // the values given (a new one without a title has its name, "Ny side 1", named()), not only those in the form.
464
+ const saved = inForm(collection.fields, values);
465
+ const written = (isNew ? collection.fields.filter((f) => !same(saved[f.name], []) && f.type !== 'readonly') : changed).filter(
466
+ (f) => f.name !== 'path',
467
+ );
468
+ const own = !post && (moved || (isNew && address !== `/${name}/`)) ? { path: address } : {};
469
+ const yaml = writeFields(isNew ? '' : parts.yaml, { ...Object.fromEntries(written.map((f) => [f.name, saved[f.name]])), ...own });
470
+ const markdown = bodyChanged ? text.current?.markdown() : null;
471
+ if (isNew && !markdown?.trim()) {
472
+ goTo('content', textBox.current);
473
+ return notify({ text: t.forms.entry.empty(post), error: true });
474
+ }
475
+ const body = markdown == null ? parts.body : `\n${markdown.trim()}\n`;
476
+ // The blocks in the text by their own fields and the template, as the site checks them before it is built.
477
+ const [wrongBlock] = blockProblems(body, template, front ? frontLeads : undefined);
478
+ if (wrongBlock) {
479
+ goTo('content', textBox.current);
480
+ return notify({ text: wrongBlock, error: true });
481
+ }
482
+ let next = `---\n${yaml}\n---\n${body}`;
483
+
484
+ // The redirects (redirectData.ts): a freed address no longer sends on (those that went on through it
485
+ // first go straight to where it sent them), the old address sends on to the new one, the new
486
+ // address is the page's (the editor's own redirect from it goes), and none sends on through another.
487
+ let redirectChanges: Change[] = [];
488
+ if (isNew || moved) {
489
+ let all = readAll(loaded);
490
+ if (freeing) {
491
+ const flat = flatten(all);
492
+ if (typeof flat === 'string') return notify({ text: flat, error: true });
493
+ all = setRedirect(flat, address, null);
494
+ }
495
+ const own = moveRedirects(all.own, moved ? url : null, address);
496
+ if (typeof own === 'string') return notify({ text: own, error: true });
497
+ const flat = flatten({ ...all, own });
498
+ if (typeof flat === 'string') return notify({ text: flat, error: true });
499
+ redirectChanges = changesOf(flat, loaded);
500
+ }
501
+ // The links to the old address everywhere, and the posts picked in the blocks on the pages (by their file name).
502
+ const others: RepoFile[] = [];
503
+ if (moved) {
504
+ next = retarget(next, url, address);
505
+ const was = slugOf(base.path, collection.folder);
506
+ for (const f of env.files) {
507
+ if (f.path === base.path || f.path === REDIRECTS || !/\.(md|json)$/.test(f.path)) continue;
508
+ let changedText = retarget(f.text, url, address);
509
+ if (post && f.path.startsWith(`${config.folders.pages}/`)) changedText = pickedRenamed(changedText, was, name);
510
+ if (changedText !== f.text) others.push({ ...f, text: changedText });
511
+ }
512
+ }
513
+ if (next === base.text && !moved && !redirectChanges.length) {
514
+ setBodyChanged(false);
515
+ return notify({ text: t.forms.nothingToSave });
516
+ }
517
+ // Pictures that were added and are still used.
518
+ const used = pictures.usedIn(next);
519
+ const removed = base.path && path !== base.path ? [base.path] : [];
520
+ const changes: Change[] = [
521
+ { path, content: next, oid: path === base.path ? base.oid || null : null },
522
+ ...removed.map((p) => ({ path: p, content: null, oid: base.oid })),
523
+ ...others.map((f) => ({ path: f.path, content: f.text, oid: f.oid })),
524
+ ...redirectChanges,
525
+ ...used.map((u) => ({ path: u.path, content: u.bytes, oid: null })),
526
+ ];
527
+ const what = `"${slugOf(path, collection.folder)}"`;
528
+ const message = moved
529
+ ? siteT.forms.commit.move(asWritten(collection).singular, url, address)
530
+ : isNew
531
+ ? siteT.forms.commit.create(asWritten(collection).create!, what)
532
+ : version
533
+ ? siteT.history.commit.restoreVersion(`${asWritten(collection).singular} ${what}`, savedOn(version))
534
+ : siteT.forms.commit.editOne(asWritten(collection).singular, what);
535
+ const entry = kindAndName(collection.singular, String(values.title ?? '').trim() || name);
536
+ const change = moved
537
+ ? t.app.changes.moved(entry, address)
538
+ : isNew
539
+ ? t.app.changes.created(entry)
540
+ : version
541
+ ? t.app.changes.restored(entry)
542
+ : t.app.changes.edited(entry);
543
+ setSaving(true);
544
+ notify({ text: t.common.saving });
545
+ try {
546
+ const oids = await env.commit(message, changes, change);
547
+ const saved = { path, oid: oids[path], text: next };
548
+ pictures.markSaved(used);
549
+ setBase(saved);
550
+ setValues(valuesOf(collection, saved));
551
+ setBodyChanged(false);
552
+ setSavedAt(new Date());
553
+ env.onSaved(saved, {
554
+ files: [
555
+ ...others.map((f) => ({ ...f, oid: oids[f.path] })),
556
+ // The editor's own redirects are among its files (the others are loaded when needed).
557
+ ...redirectChanges
558
+ .filter((c) => c.path === REDIRECTS)
559
+ .map((c) => ({ path: REDIRECTS, oid: oids[REDIRECTS], text: String(c.content) })),
560
+ ],
561
+ removed,
562
+ });
563
+ notify({ text: savedText(change), kind: 'saved' });
564
+ // The version is now the one saved: the form as usual again.
565
+ if (version) onVersion(null);
566
+ // A new address: the owner is told, in a dialog that stays until it is closed, that the old one now
567
+ // sends on to it, and where the redirects are.
568
+ if (moved) {
569
+ void choose({
570
+ title: t.forms.entry.movedTitle,
571
+ text: t.forms.entry.movedText(address, url, post, redirectsPath()),
572
+ choices: [
573
+ { value: 'ok', label: t.forms.entry.ok },
574
+ { value: 'see', label: t.forms.entry.seeRedirects },
575
+ ],
576
+ cancel: false,
577
+ }).then((seen) => seen?.choice === 'see' && (location.hash = TAB_ROUTES.redirects));
578
+ }
579
+ return true;
580
+ } catch (e) {
581
+ notify({ text: e instanceof ConflictError ? e.message : t.common.notSaved((e as Error).message), error: true, kind: 'failed' });
582
+ } finally {
583
+ setSaving(false);
584
+ }
585
+ }
586
+
587
+ // "Forhåndsvis": the page or post with what is changed in the form; a new one, or one whose layout is changed in the
588
+ // form (a field with `layout`, "Hele bredden"), in the page of another one of its kind saved with that layout, with
589
+ // all of the form put in it (pagePreview.tsx).
590
+ const [openPreview, previewSheet] = usePagePreview();
591
+ const showPreview = () =>
592
+ openPreview(() => {
593
+ const layout = collection.fields.filter((f) => f.layout);
594
+ const relaid = !isNew && changed.some((f) => f.layout);
595
+ const sameLayout = (data: Record<string, unknown>) =>
596
+ layout.every((f) => String(data[f.name] || '') === String(values[f.name] || ''));
597
+ const changes = (...names: string[]) => isNew || relaid || changed.some((f) => names.includes(f.name));
598
+ const others =
599
+ isNew || relaid
600
+ ? env.files
601
+ .filter((f) => f.path.startsWith(`${collection.folder}/`) && f.path.endsWith('.md') && f.path !== base.path)
602
+ .map((f) => ({ f, data: readFields(splitFrontmatter(f.text).yaml) }))
603
+ .filter(({ data }) => sameLayout(data))
604
+ .map(({ f, data }) => addressNow(collection, f, data))
605
+ .slice(0, 5)
606
+ : [];
607
+ const markdown = bodyChanged ? text.current?.markdown() : null;
608
+ // A page's H1 is at the top of its text (pageH1); a blog post's is its title.
609
+ const h1 = collection.blocks ? pageH1(markdown ?? parts.body) : undefined;
610
+ return {
611
+ shell: isNew || (relaid && others.length) ? others : url,
612
+ heading: h1?.heading ?? (changes('title', 'heading') ? String(values.heading || named().title) : undefined),
613
+ headingHtml: h1?.html,
614
+ // A post's picture at the top (a page has its pictures in its text).
615
+ image:
616
+ collection.fields.some((f) => f.name === 'image') && changes('image', 'imageAlt', 'imageDecorative')
617
+ ? values.image
618
+ ? { src: String(values.image), alt: values.imageDecorative === true ? '' : String(values.imageAlt ?? '') }
619
+ : null
620
+ : undefined,
621
+ body: isNew || relaid || markdown != null ? (markdown ?? parts.body) : undefined,
622
+ data: { lists: env.lists, testimonials: env.testimonials, site: env.site, relations: env.relations, path: url },
623
+ };
624
+ });
625
+
626
+ // A post planned for a time gets that day as its date, so it comes out as the newest.
627
+ const onChange = (f: Field, v: unknown) => {
628
+ set(f.name, v);
629
+ if (f.name === 'publishAt' && /^\d{4}-\d{2}-\d{2}T/.test(String(v)) && collection.fields.some((d) => d.name === 'date'))
630
+ set('date', String(v).slice(0, 10));
631
+ };
632
+ // The fields in use (Field.only).
633
+ const field = (f: Field) =>
634
+ inUse(f, collection.fields, values) && <FieldInput key={f.name} field={f} value={values[f.name]} onChange={(v) => onChange(f, v)} />;
635
+
636
+ return (
637
+ <Fields.Provider value={fields}>
638
+ <WrongField.Provider value={wrongField.field}>
639
+ <FormFrame
640
+ title={isNew ? collection.create! : String(initial.title || collection.singular)}
641
+ back={{ href: `#liste/${collection.id}`, label: collection.label }}
642
+ status={savedStatus({ savedAt, saving, deleting, isNew })}
643
+ view={isNew ? undefined : url}
644
+ drawer={
645
+ <>
646
+ <div className="contents xl:hidden">
647
+ <ModalNavigationDrawer title={t.forms.sections.title} items={sections} value={section} onChange={pick} />
648
+ </div>
649
+ <div className="hidden xl:contents">
650
+ <NavigationDrawerButton label={t.forms.sections.title} open={drawer} onClick={() => setDrawer(!drawer)} />
651
+ </div>
652
+ </>
653
+ }
654
+ dirty={dirty}
655
+ saving={saving || deleting}
656
+ deleting={deleting}
657
+ onSave={() => save()}
658
+ onPreview={showPreview}
659
+ onDelete={collection.deletable && !isNew ? () => env.remove(collection, base, setDeleting) : undefined}
660
+ deleteLabel={t.forms.deleteOne(collection.singular)}
661
+ panel
662
+ >
663
+ {version && <VersionNotice version={version} onCurrent={() => onVersion(null)} />}
664
+ <FormColumns
665
+ sticky
666
+ wideOnly
667
+ main={
668
+ <>
669
+ <FoldSection {...sectionProps('content')}>
670
+ {collection.fields.filter((f) => TOP.includes(f.name) && !STATUS.includes(f.name)).map(field)}
671
+ <div ref={textBox} className="scroll-mt-[calc(5rem+env(safe-area-inset-top))]">
672
+ <span className="mb-1 block font-medium">{t.forms.text}</span>
673
+ <MarkdownEditor
674
+ ref={text}
675
+ value={shown?.body ?? parts.body}
676
+ onChange={onText}
677
+ addImage={pickImage}
678
+ blocks={collection.blocks ? blockAsk : undefined}
679
+ h1={!!collection.blocks}
680
+ sheet={blockAsk}
681
+ looks={
682
+ front
683
+ ? (items) =>
684
+ looksOf(items, { file: `${config.standardPages}#front`, leads: frontLeads, sectionLooks, headingLooks })
685
+ : undefined
686
+ }
687
+ sections={front}
688
+ />
689
+ </div>
690
+ </FoldSection>
691
+ {shows('status') && (
692
+ <FoldSection {...sectionProps('status')}>
693
+ {collection.fields.filter((f) => STATUS.includes(f.name)).map(field)}
694
+ </FoldSection>
695
+ )}
696
+ {shows('other') && (
697
+ <FoldSection {...sectionProps('other')}>{collection.fields.filter(inSection('other')).map(field)}</FoldSection>
698
+ )}
699
+ {shows('seo') && (
700
+ <FoldSection {...sectionProps('seo')}>
701
+ <div ref={seoBox} className="space-y-6">
702
+ {rating.length > 0 && <CheckList title={t.forms.seoRating.title} items={rating} />}
703
+ {collection.fields.filter(inSection('seo')).map(field)}
704
+ </div>
705
+ </FoldSection>
706
+ )}
707
+ {shows('share') && (
708
+ <FoldSection {...sectionProps('share')}>
709
+ <div ref={shareBox} className="space-y-6">
710
+ <SharePreview
711
+ image={shownAt(splitPicture(card.image).src)}
712
+ domain={config.domain}
713
+ title={card.title}
714
+ description={card.description}
715
+ />
716
+ {collection.fields.filter(inSection('share')).map(field)}
717
+ </div>
718
+ </FoldSection>
719
+ )}
720
+ {shows('versions') && (
721
+ <FoldSection {...sectionProps('versions')}>
722
+ <VersionList env={env} path={base.path} oid={base.oid} onPick={onVersion} />
723
+ </FoldSection>
724
+ )}
725
+ </>
726
+ }
727
+ side={drawer && <NavigationDrawer title={t.forms.sections.title} items={sections} value={section} onChange={pick} />}
728
+ />
729
+ </FormFrame>
730
+ {blockDialog}
731
+ {picker}
732
+ {previewSheet}
733
+ </WrongField.Provider>
734
+ </Fields.Provider>
735
+ );
736
+ }
737
+
738
+ // A page's text with a post's new file name in its blocks with blog posts ("Blogindlæg", `picked`).
739
+ const pickedRenamed = (text: string, was: string, name: string) =>
740
+ text.replace(/^<div data-block="posts".*$/gm, (line) => line.replaceAll(`&quot;${was}&quot;`, `&quot;${name}&quot;`));