@morphemecms/cms 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (396) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/astro.mjs +3 -0
  3. package/bin/morpheme.mjs +3 -1
  4. package/defaults/blockTexts.ts +2 -2
  5. package/defaults/collections.ts +10 -0
  6. package/defaults/editor.ts +57 -53
  7. package/defaults/privacy.ts +4 -1
  8. package/defaults/rules.ts +36 -38
  9. package/defaults/standardPages.ts +0 -3
  10. package/defaults/texts.ts +11 -2
  11. package/demo/src/content/pages/about.md +2 -0
  12. package/icons/lucide/browser-fill.svg +1 -0
  13. package/icons/lucide/buildings-fill.svg +1 -0
  14. package/icons/lucide/certificate-fill.svg +1 -0
  15. package/icons/lucide/chart-line-up-fill.svg +1 -0
  16. package/icons/lucide/chats-fill.svg +1 -0
  17. package/icons/lucide/envelope-simple-fill.svg +1 -0
  18. package/icons/lucide/file-text-fill.svg +1 -0
  19. package/icons/lucide/folder-simple-fill.svg +1 -0
  20. package/icons/lucide/gauge.svg +1 -1
  21. package/icons/lucide/gear-six.svg +1 -0
  22. package/icons/lucide/globe-simple.svg +1 -0
  23. package/icons/lucide/heart-fill.svg +1 -0
  24. package/icons/lucide/layout.svg +1 -0
  25. package/icons/lucide/list-checks-fill.svg +1 -0
  26. package/icons/lucide/magnifying-glass-fill.svg +1 -0
  27. package/icons/lucide/megaphone-fill.svg +1 -0
  28. package/icons/lucide/newspaper-fill.svg +1 -0
  29. package/icons/lucide/paint-brush-broad-fill.svg +1 -0
  30. package/icons/lucide/puzzle-piece-fill.svg +1 -0
  31. package/icons/lucide/shield-check-fill.svg +1 -0
  32. package/icons/lucide/sidebar-simple.svg +1 -1
  33. package/icons/lucide/sidebar.svg +1 -0
  34. package/icons/lucide/square-half-bottom-fill.svg +1 -0
  35. package/icons/lucide/square-half.svg +1 -0
  36. package/icons/lucide/squares-four-fill.svg +1 -0
  37. package/icons/lucide/storefront-fill.svg +1 -0
  38. package/icons/lucide/tag-fill.svg +1 -0
  39. package/icons/lucide/toggle-right-fill.svg +1 -0
  40. package/icons/lucide/trash-fill.svg +1 -0
  41. package/icons/lucide/user-fill.svg +1 -0
  42. package/icons/lucide/wrench-fill.svg +1 -0
  43. package/icons/material-symbols/browser-fill.svg +1 -0
  44. package/icons/material-symbols/buildings-fill.svg +1 -0
  45. package/icons/material-symbols/certificate-fill.svg +1 -0
  46. package/icons/material-symbols/chart-line-up-fill.svg +1 -0
  47. package/icons/material-symbols/chats-fill.svg +1 -0
  48. package/icons/material-symbols/envelope-simple-fill.svg +1 -0
  49. package/icons/material-symbols/file-text-fill.svg +1 -0
  50. package/icons/material-symbols/folder-simple-fill.svg +1 -0
  51. package/icons/material-symbols/gauge.svg +1 -1
  52. package/icons/material-symbols/gear-six.svg +1 -0
  53. package/icons/material-symbols/globe-simple.svg +1 -0
  54. package/icons/material-symbols/heart-fill.svg +1 -0
  55. package/icons/material-symbols/layout.svg +1 -0
  56. package/icons/material-symbols/list-checks-fill.svg +1 -0
  57. package/icons/material-symbols/magnifying-glass-fill.svg +1 -0
  58. package/icons/material-symbols/megaphone-fill.svg +1 -0
  59. package/icons/material-symbols/newspaper-fill.svg +1 -0
  60. package/icons/material-symbols/paint-brush-broad-fill.svg +1 -0
  61. package/icons/material-symbols/puzzle-piece-fill.svg +1 -0
  62. package/icons/material-symbols/shield-check-fill.svg +1 -0
  63. package/icons/material-symbols/sidebar-simple.svg +1 -1
  64. package/icons/material-symbols/sidebar.svg +1 -0
  65. package/icons/material-symbols/square-half-bottom-fill.svg +1 -0
  66. package/icons/material-symbols/square-half.svg +1 -0
  67. package/icons/material-symbols/squares-four-fill.svg +1 -0
  68. package/icons/material-symbols/storefront-fill.svg +1 -0
  69. package/icons/material-symbols/tag-fill.svg +1 -0
  70. package/icons/material-symbols/toggle-right-fill.svg +1 -0
  71. package/icons/material-symbols/trash-fill.svg +1 -0
  72. package/icons/material-symbols/user-fill.svg +1 -0
  73. package/icons/material-symbols/wrench-fill.svg +1 -0
  74. package/icons/phosphor/browser-fill.svg +1 -0
  75. package/icons/phosphor/buildings-fill.svg +1 -0
  76. package/icons/phosphor/certificate-fill.svg +1 -0
  77. package/icons/phosphor/chart-line-up-fill.svg +1 -0
  78. package/icons/phosphor/chats-fill.svg +1 -0
  79. package/icons/phosphor/envelope-simple-fill.svg +1 -0
  80. package/icons/phosphor/file-text-fill.svg +1 -0
  81. package/icons/phosphor/folder-simple-fill.svg +1 -0
  82. package/icons/phosphor/gear-six.svg +1 -0
  83. package/icons/phosphor/globe-simple.svg +1 -0
  84. package/icons/phosphor/heart-fill.svg +1 -0
  85. package/icons/phosphor/layout.svg +1 -0
  86. package/icons/phosphor/list-checks-fill.svg +1 -0
  87. package/icons/phosphor/magnifying-glass-fill.svg +1 -0
  88. package/icons/phosphor/megaphone-fill.svg +1 -0
  89. package/icons/phosphor/newspaper-fill.svg +1 -0
  90. package/icons/phosphor/paint-brush-broad-fill.svg +1 -0
  91. package/icons/phosphor/puzzle-piece-fill.svg +1 -0
  92. package/icons/phosphor/shield-check-fill.svg +1 -0
  93. package/icons/phosphor/sidebar.svg +1 -0
  94. package/icons/phosphor/square-half-bottom-fill.svg +1 -0
  95. package/icons/phosphor/square-half.svg +1 -0
  96. package/icons/phosphor/squares-four-fill.svg +1 -0
  97. package/icons/phosphor/storefront-fill.svg +1 -0
  98. package/icons/phosphor/tag-fill.svg +1 -0
  99. package/icons/phosphor/toggle-right-fill.svg +1 -0
  100. package/icons/phosphor/trash-fill.svg +1 -0
  101. package/icons/phosphor/user-fill.svg +1 -0
  102. package/icons/phosphor/wrench-fill.svg +1 -0
  103. package/lib/breakpoints.d.mts +13 -0
  104. package/lib/breakpoints.mjs +20 -0
  105. package/lib/breakpoints.test.mjs +39 -0
  106. package/lib/build-lane.d.mts +15 -0
  107. package/lib/build-lane.mjs +23 -0
  108. package/lib/build-lane.test.mjs +74 -0
  109. package/lib/build-settings.d.mts +16 -0
  110. package/lib/build-settings.mjs +39 -0
  111. package/lib/build-settings.test.mjs +41 -0
  112. package/lib/contracts.d.mts +3 -2
  113. package/lib/contracts.mjs +3 -2
  114. package/lib/drawer-swipe.d.mts +10 -0
  115. package/lib/drawer-swipe.mjs +17 -0
  116. package/lib/drawer-swipe.test.mjs +33 -0
  117. package/lib/drawerKeys.d.mts +9 -0
  118. package/lib/drawerKeys.mjs +26 -0
  119. package/lib/drawerKeys.test.mjs +21 -0
  120. package/lib/eyebrow.d.mts +6 -0
  121. package/lib/eyebrow.mjs +24 -0
  122. package/lib/eyebrow.test.mjs +20 -0
  123. package/lib/front-sections.test.mjs +82 -0
  124. package/lib/heading-levels.d.mts +7 -0
  125. package/lib/heading-levels.mjs +16 -0
  126. package/lib/heading-levels.test.mjs +37 -0
  127. package/lib/inline.ts +3 -2
  128. package/lib/markdown.ts +2 -0
  129. package/lib/materialIcon.d.mts +21 -0
  130. package/lib/materialIcon.mjs +29 -0
  131. package/lib/materialIcon.test.mjs +45 -0
  132. package/lib/menu-layout.d.mts +11 -0
  133. package/lib/menu-layout.mjs +14 -0
  134. package/lib/menu-layout.test.mjs +23 -0
  135. package/lib/menu.d.mts +59 -0
  136. package/lib/menu.mjs +129 -0
  137. package/lib/menu.test.mjs +291 -0
  138. package/lib/rehype-emphasize.d.mts +4 -0
  139. package/lib/rehype-emphasize.mjs +20 -0
  140. package/lib/rehype-emphasize.test.mjs +39 -0
  141. package/lib/section-in-view.d.mts +8 -0
  142. package/lib/section-in-view.mjs +25 -0
  143. package/lib/section-in-view.test.mjs +35 -0
  144. package/lib/section-marker.d.mts +5 -0
  145. package/lib/section-marker.mjs +18 -0
  146. package/lib/section-marker.test.mjs +22 -0
  147. package/lib/section-ranges.d.mts +12 -0
  148. package/lib/section-ranges.mjs +43 -0
  149. package/lib/section-ranges.test.mjs +61 -0
  150. package/lib/sheet-stops.d.mts +68 -0
  151. package/lib/sheet-stops.mjs +198 -0
  152. package/lib/sheet-stops.test.mjs +155 -0
  153. package/lib/still-timer.d.mts +5 -0
  154. package/lib/still-timer.mjs +17 -0
  155. package/lib/still-timer.test.mjs +42 -0
  156. package/lib/text-sections.d.mts +10 -0
  157. package/lib/text-sections.mjs +45 -0
  158. package/lib/text-sections.test.mjs +78 -0
  159. package/lib/video.mjs +2 -1
  160. package/lib/without-h1.d.mts +1 -0
  161. package/lib/without-h1.mjs +7 -0
  162. package/lib/without-h1.test.mjs +26 -0
  163. package/lib/word-at.d.mts +6 -0
  164. package/lib/word-at.mjs +20 -0
  165. package/lib/word-at.test.mjs +19 -0
  166. package/package.json +3 -1
  167. package/pages/editor/version.json.js +22 -2
  168. package/scripts/already-checked.mjs +11 -0
  169. package/scripts/already-checked.test.mjs +18 -0
  170. package/scripts/build-settings-problems.mjs +27 -0
  171. package/scripts/build-settings-problems.test.mjs +35 -0
  172. package/scripts/check-content.mjs +42 -4
  173. package/scripts/check-worker.mjs +96 -4
  174. package/scripts/ci-build.mjs +85 -0
  175. package/scripts/ci-build.test.mjs +137 -0
  176. package/scripts/editor-roundtrip.mjs +62 -3
  177. package/scripts/editor-screens.mjs +21 -7
  178. package/scripts/git-env.mjs +6 -0
  179. package/scripts/step-command.mjs +34 -0
  180. package/scripts/step-command.test.mjs +50 -0
  181. package/scripts/text-problems.mjs +83 -0
  182. package/scripts/text-problems.test.mjs +74 -0
  183. package/scripts/worker-modules.mjs +11 -1
  184. package/site/breakpoints.d.mts +6 -0
  185. package/site/breakpoints.mjs +52 -0
  186. package/site/breakpoints.test.mjs +36 -0
  187. package/site/casing.d.mts +3 -0
  188. package/site/casing.mjs +26 -0
  189. package/site/content.config.ts +2 -0
  190. package/site/content.ts +2 -4
  191. package/site/defineSite.d.mts +1 -0
  192. package/site/defineSite.mjs +3 -0
  193. package/site/home.ts +3 -2
  194. package/site/icons.d.mts +116 -4
  195. package/site/icons.mjs +62 -6
  196. package/site/images.mjs +1 -1
  197. package/site/live-diff.d.mts +22 -0
  198. package/site/live-diff.mjs +98 -0
  199. package/site/live-diff.test.mjs +235 -0
  200. package/site/materialSymbols.d.mts +5 -0
  201. package/site/materialSymbols.mjs +19 -0
  202. package/site/sections.ts +1 -1
  203. package/site/theme.d.mts +1 -1
  204. package/site/theme.mjs +7 -1
  205. package/src/ActionParts.tsx +1 -1
  206. package/src/Advanced.tsx +101 -20
  207. package/src/App.tsx +47 -121
  208. package/src/AppList.tsx +1 -1
  209. package/src/BlockParts.tsx +3 -3
  210. package/src/Colors.tsx +1 -1
  211. package/src/CommunicationParts.tsx +1 -1
  212. package/src/ContainmentParts.tsx +80 -28
  213. package/src/Deleted.tsx +18 -4
  214. package/src/EditorFooter.tsx +14 -0
  215. package/src/EntryForm.tsx +75 -39
  216. package/src/FormFrame.tsx +3 -8
  217. package/src/Library.tsx +46 -51
  218. package/src/Licenses.tsx +27 -11
  219. package/src/MainDrawerParts.tsx +60 -0
  220. package/src/MainMenu.tsx +136 -0
  221. package/src/MarkdownEditor.tsx +175 -32
  222. package/src/Media.tsx +69 -16
  223. package/src/MediaTags.tsx +91 -0
  224. package/src/ModalSheetSample.tsx +47 -0
  225. package/src/NavigationParts.tsx +67 -21
  226. package/src/NavigationRestParts.tsx +1 -1
  227. package/src/Parts.tsx +71 -0
  228. package/src/PartsShown.tsx +1 -1
  229. package/src/PickerParts.tsx +2 -2
  230. package/src/Redirects.tsx +3 -2
  231. package/src/Screen.tsx +93 -90
  232. package/src/SelectionParts.tsx +22 -6
  233. package/src/SiteLogoLink.tsx +14 -0
  234. package/src/StartFab.tsx +22 -0
  235. package/src/StartStatus.tsx +1 -1
  236. package/src/autoTags.d.mts +28 -0
  237. package/src/autoTags.mjs +68 -0
  238. package/src/autoTags.test.mjs +109 -0
  239. package/src/buildSettings.test.mjs +20 -0
  240. package/src/buildSettings.ts +20 -0
  241. package/src/checkSite.ts +14 -1
  242. package/src/deploy.ts +7 -9
  243. package/src/editor.css +150 -17
  244. package/src/eyebrow.d.mts +5 -0
  245. package/src/eyebrow.mjs +53 -0
  246. package/src/eyebrowState.d.mts +21 -0
  247. package/src/eyebrowState.mjs +62 -0
  248. package/src/eyebrowState.test.mjs +78 -0
  249. package/src/fileRecords.d.mts +115 -0
  250. package/src/fileRecords.mjs +224 -0
  251. package/src/fileRecords.test.mjs +224 -0
  252. package/src/fileTagFilter.d.mts +30 -0
  253. package/src/fileTagFilter.mjs +44 -0
  254. package/src/fileTagFilter.test.mjs +57 -0
  255. package/src/fileTags.d.mts +56 -0
  256. package/src/fileTags.mjs +100 -0
  257. package/src/fileTags.test.mjs +127 -0
  258. package/src/firstHeading.d.mts +6 -0
  259. package/src/firstHeading.mjs +37 -0
  260. package/src/i18n/da-DK/advanced.ts +27 -12
  261. package/src/i18n/da-DK/app.ts +9 -7
  262. package/src/i18n/da-DK/apps.ts +16 -16
  263. package/src/i18n/da-DK/blockSettings.ts +9 -9
  264. package/src/i18n/da-DK/blocks.ts +2 -2
  265. package/src/i18n/da-DK/check.ts +5 -2
  266. package/src/i18n/da-DK/cms.ts +14 -12
  267. package/src/i18n/da-DK/colors.ts +5 -5
  268. package/src/i18n/da-DK/common.ts +2 -2
  269. package/src/i18n/da-DK/company.ts +3 -3
  270. package/src/i18n/da-DK/consents.ts +6 -6
  271. package/src/i18n/da-DK/forms.ts +6 -6
  272. package/src/i18n/da-DK/history.ts +6 -6
  273. package/src/i18n/da-DK/keywords.ts +2 -2
  274. package/src/i18n/da-DK/library.ts +17 -15
  275. package/src/i18n/da-DK/licenses.ts +4 -4
  276. package/src/i18n/da-DK/media.ts +10 -4
  277. package/src/i18n/da-DK/parts.ts +62 -25
  278. package/src/i18n/da-DK/preview.ts +5 -6
  279. package/src/i18n/da-DK/redirects.ts +7 -7
  280. package/src/i18n/da-DK/seo.ts +1 -1
  281. package/src/i18n/da-DK/text.ts +2 -0
  282. package/src/i18n/da-DK/theme.ts +5 -5
  283. package/src/i18n/en-US/advanced.ts +34 -18
  284. package/src/i18n/en-US/app.ts +7 -5
  285. package/src/i18n/en-US/apps.ts +9 -9
  286. package/src/i18n/en-US/blockSettings.ts +4 -4
  287. package/src/i18n/en-US/blocks.ts +6 -6
  288. package/src/i18n/en-US/check.ts +6 -3
  289. package/src/i18n/en-US/cms.ts +15 -14
  290. package/src/i18n/en-US/colors.ts +18 -18
  291. package/src/i18n/en-US/common.ts +3 -3
  292. package/src/i18n/en-US/consents.ts +4 -4
  293. package/src/i18n/en-US/forms.ts +5 -5
  294. package/src/i18n/en-US/history.ts +2 -2
  295. package/src/i18n/en-US/library.ts +21 -15
  296. package/src/i18n/en-US/licenses.ts +6 -6
  297. package/src/i18n/en-US/media.ts +7 -1
  298. package/src/i18n/en-US/parts.ts +60 -21
  299. package/src/i18n/en-US/preview.ts +5 -6
  300. package/src/i18n/en-US/redirects.ts +3 -3
  301. package/src/i18n/en-US/seo.ts +1 -1
  302. package/src/i18n/en-US/text.ts +3 -0
  303. package/src/i18n/en-US/theme.ts +6 -6
  304. package/src/icon.tsx +90 -27
  305. package/src/licenseData.test.mjs +28 -0
  306. package/src/licenseData.ts +12 -1
  307. package/src/markdown.d.mts +2 -0
  308. package/src/markdown.mjs +73 -5
  309. package/src/markdown.test.mjs +74 -0
  310. package/src/materialIcons.ts +8 -0
  311. package/src/menuChildren.ts +36 -0
  312. package/src/menuOpen.ts +34 -0
  313. package/src/pagePreview.tsx +23 -19
  314. package/src/r2Caching.ts +14 -0
  315. package/src/sectionFrame.ts +39 -0
  316. package/src/sectionState.d.mts +19 -0
  317. package/src/sectionState.mjs +31 -0
  318. package/src/sectionState.test.mjs +59 -0
  319. package/src/sections.d.mts +9 -7
  320. package/src/sections.mjs +74 -27
  321. package/src/sections.test.mjs +59 -0
  322. package/src/sidebar.ts +2 -2
  323. package/src/site.ts +27 -17
  324. package/src/stillCursor.d.mts +8 -0
  325. package/src/stillCursor.mjs +19 -0
  326. package/src/stillCursor.test.mjs +26 -0
  327. package/src/tagUses.d.mts +47 -0
  328. package/src/tagUses.mjs +121 -0
  329. package/src/tagUses.test.mjs +141 -0
  330. package/src/themeUi.ts +10 -0
  331. package/src/topBarSlot.tsx +37 -0
  332. package/src/ui.tsx +654 -150
  333. package/src/virtual.d.ts +5 -0
  334. package/starter/README.md +2 -1
  335. package/starter/package.json +2 -1
  336. package/starter/wrangler.jsonc +3 -2
  337. package/theme/blocks/cards/LinkCards.tsx +2 -1
  338. package/theme/blocks/hero/View.tsx +2 -1
  339. package/theme/blocks/video/View.tsx +2 -1
  340. package/theme/components/BlogIndex.astro +2 -2
  341. package/theme/components/BlogPosts.astro +1 -1
  342. package/theme/components/BlogPosts.tsx +9 -6
  343. package/theme/components/Container.astro +1 -1
  344. package/theme/components/CookieConsent.astro +1 -1
  345. package/theme/components/EndBlock.astro +1 -1
  346. package/theme/components/Footer.astro +5 -5
  347. package/theme/components/Header.astro +3 -3
  348. package/theme/components/Icon.astro +2 -2
  349. package/theme/components/MobileMenu.tsx +72 -61
  350. package/theme/components/MobileNav.astro +2 -2
  351. package/theme/components/PageHeader.astro +2 -2
  352. package/theme/components/PageSection.astro +1 -1
  353. package/theme/components/PopupButton.tsx +2 -2
  354. package/theme/components/PopupView.tsx +2 -2
  355. package/theme/components/PostList.tsx +7 -3
  356. package/theme/components/RichText.astro +22 -7
  357. package/theme/components/Section.astro +17 -10
  358. package/theme/components/SectionHeading.tsx +1 -1
  359. package/theme/components/home/PageBlock.astro +4 -4
  360. package/theme/components/home/Text.astro +3 -3
  361. package/theme/layouts/PageLayout.astro +3 -3
  362. package/theme/layouts/PostLayout.astro +9 -8
  363. package/theme/styles/base.css +13 -3
  364. package/theme/styles/blocks.css +97 -10
  365. package/theme/styles/components.css +9 -9
  366. package/theme/styles/elevation.test.mjs +168 -0
  367. package/theme/styles/global.css +65 -59
  368. package/theme/styles/ui.css +460 -131
  369. package/theme/templates/Home.astro +1 -1
  370. package/theme/templates/New.astro +5 -5
  371. package/theme/templates/NotFound.astro +2 -2
  372. package/theme/ui/BottomSheet.tsx +118 -21
  373. package/theme/ui/BottomSheetResizeIcon.tsx +13 -0
  374. package/theme/ui/Button.tsx +4 -3
  375. package/theme/ui/Dialog.tsx +1 -1
  376. package/theme/ui/FabMenu.tsx +13 -12
  377. package/theme/ui/LoadingIndicator.tsx +1 -1
  378. package/theme/ui/NavigationDrawer.tsx +116 -15
  379. package/theme/ui/NavigationRail.tsx +22 -13
  380. package/theme/ui/Search.tsx +1 -1
  381. package/theme/ui/SettingRow.tsx +31 -0
  382. package/theme/ui/Switch.tsx +11 -11
  383. package/theme/ui/TextField.tsx +17 -6
  384. package/theme/ui/bottomSheet.script.ts +3 -2
  385. package/theme/ui/bottomSheetControl.ts +131 -46
  386. package/theme/ui/fabMenu.script.ts +69 -53
  387. package/theme/ui/modalSheetSize.ts +214 -0
  388. package/theme/ui/navigationDrawerControl.ts +35 -0
  389. package/theme/ui/search.script.ts +4 -2
  390. package/worker/index.js +3 -0
  391. package/worker/plan-status.js +46 -0
  392. package/worker/push.js +7 -5
  393. package/worker/r2-status.js +2 -1
  394. package/icons/lucide/desktop.svg +0 -1
  395. package/icons/material-symbols/desktop.svg +0 -1
  396. package/icons/phosphor/desktop.svg +0 -1
@@ -0,0 +1,78 @@
1
+ // Tests lib/text-sections.mjs: a text's HTML cut into coloured and uncoloured runs, and the marker as the site's real
2
+ // Markdown pipeline (rehype-raw, as in astro.mjs) writes it. Run with `node --test packages/editor/lib/text-sections.test.mjs`.
3
+ import { test } from 'node:test';
4
+ import assert from 'node:assert/strict';
5
+ import { createMarkdownProcessor } from '@astrojs/markdown-remark';
6
+ import rehypeRaw from 'rehype-raw';
7
+ import { sectionClass, sectionsOf } from './text-sections.mjs';
8
+
9
+ const M = (color) => `<div data-section="${color}"></div>`;
10
+
11
+ test('html without markers passes through unchanged, in one run', () => {
12
+ const html = '<h1>T</h1>\n<p>a</p>\n<h2 id="x">X</h2>\n<p>b</p>';
13
+ assert.deepEqual(sectionsOf(html), [{ color: null, html }]);
14
+ assert.deepEqual(sectionsOf(''), [{ color: null, html: '' }]);
15
+ });
16
+
17
+ test('a coloured run goes from its marker to the next h2, markers removed', () => {
18
+ const html = `<p>Intro</p>\n${M('tint')}\n<h2 id="a">A</h2>\n<p>One</p>\n<h2 id="b">B</h2>\n<p>Two</p>`;
19
+ assert.deepEqual(sectionsOf(html), [
20
+ { color: null, html: '<p>Intro</p>\n' },
21
+ { color: 'tint', html: '<h2 id="a">A</h2>\n<p>One</p>\n' },
22
+ { color: null, html: '<h2 id="b">B</h2>\n<p>Two</p>' },
23
+ ]);
24
+ });
25
+
26
+ test('a run goes on to the end of the text, and over an h3 and blocks', () => {
27
+ const html = `${M('surface')}\n<h2>A</h2>\n<h3>s</h3>\n<div data-block="faq"></div>\n<p>x</p>`;
28
+ assert.deepEqual(sectionsOf(html), [{ color: 'surface', html: '<h2>A</h2>\n<h3>s</h3>\n<div data-block="faq"></div>\n<p>x</p>' }]);
29
+ });
30
+
31
+ test('a run ends at an h1', () => {
32
+ const html = `${M('tint')}\n<h2>A</h2>\n<p>x</p>\n<h1>Big</h1>\n<p>y</p>`;
33
+ assert.deepEqual(sectionsOf(html), [
34
+ { color: 'tint', html: '<h2>A</h2>\n<p>x</p>\n' },
35
+ { color: null, html: '<h1>Big</h1>\n<p>y</p>' },
36
+ ]);
37
+ });
38
+
39
+ test('two coloured sections after each other are two runs', () => {
40
+ const html = `${M('tint')}\n<h2>A</h2>\n<p>x</p>\n${M('accent')}\n<h2>B</h2>\n<p>y</p>`;
41
+ assert.deepEqual(sectionsOf(html), [
42
+ { color: 'tint', html: '<h2>A</h2>\n<p>x</p>\n' },
43
+ { color: 'accent', html: '<h2>B</h2>\n<p>y</p>' },
44
+ ]);
45
+ });
46
+
47
+ test('a label line between the marker and the h2 belongs to the section, and the next label starts the next', () => {
48
+ const html = `${M('tint')}\n<p class="block-eyebrow">Hours</p>\n<h2>A</h2>\n<p>x</p>\n<p class="block-eyebrow">Next</p>\n<h2>B</h2>`;
49
+ assert.deepEqual(sectionsOf(html), [
50
+ { color: 'tint', html: '<p class="block-eyebrow">Hours</p>\n<h2>A</h2>\n<p>x</p>\n' },
51
+ { color: null, html: '<p class="block-eyebrow">Next</p>\n<h2>B</h2>' },
52
+ ]);
53
+ });
54
+
55
+ test('an unknown colour is dropped, the text stays', () => {
56
+ assert.deepEqual(sectionsOf(`${M('pink')}\n<h2>A</h2>\n<p>x</p>`), [{ color: null, html: '<h2>A</h2>\n<p>x</p>' }]);
57
+ });
58
+
59
+ test('a marker not followed by an h2 is dropped', () => {
60
+ assert.deepEqual(sectionsOf(`<p>a</p>\n${M('tint')}\n<p>b</p>\n<h2>c</h2>`), [{ color: null, html: '<p>a</p>\n<p>b</p>\n<h2>c</h2>' }]);
61
+ assert.deepEqual(sectionsOf(`<p>a</p>\n${M('tint')}\n<h3>c</h3>`), [{ color: null, html: '<p>a</p>\n<h3>c</h3>' }]);
62
+ });
63
+
64
+ test('the class of a coloured section', () => {
65
+ assert.equal(sectionClass('tint'), 'text-section text-section-tint');
66
+ });
67
+
68
+ test("in Astro's pipeline the marker comes out as the matcher reads it", async () => {
69
+ const processor = await createMarkdownProcessor({ rehypePlugins: [rehypeRaw] });
70
+ const { code } = await processor.render(`Intro\n\n${M('tint')}\n\n## Opening hours\n\nMon–Fri\n\n## Contact\n\nCall`);
71
+ assert.ok(code.includes(M('tint')), code);
72
+ const runs = sectionsOf(code);
73
+ assert.deepEqual(
74
+ runs.map((r) => r.color),
75
+ [null, 'tint', null],
76
+ );
77
+ assert.ok(runs[1].html.startsWith('<h2') && runs[1].html.includes('Mon–Fri') && !runs[1].html.includes('Contact'), runs[1].html);
78
+ });
package/lib/video.mjs CHANGED
@@ -8,6 +8,7 @@
8
8
  // from the video, from the site itself (packages/editor/scripts/video-thumbnails.mjs), when it could be fetched.
9
9
  import fs from 'node:fs';
10
10
  import path from 'node:path';
11
+ import { BREAKPOINTS } from './breakpoints.mjs';
11
12
  import { svgHtml } from './svg.mjs';
12
13
  import { player, stillName, videoOf, videoPage } from './video-id.mjs';
13
14
 
@@ -26,7 +27,7 @@ const unescapeHtml = (s) => s.replace(/&(amp|lt|gt|quot|#39);/g, (_, e) => ({ am
26
27
  function facade(video, title, frame, play) {
27
28
  const { href, player, title: name, still } = facadeOf(video, title, frame);
28
29
  const image = still
29
- ? `<img class="video-facade-image" src="${still.src}" srcset="${still.srcset}" sizes="(min-width: 768px) 720px, 100vw" alt="" loading="lazy" decoding="async">`
30
+ ? `<img class="video-facade-image" src="${still.src}" srcset="${still.srcset}" sizes="(min-width: ${BREAKPOINTS.expanded}px) 720px, 100vw" alt="" loading="lazy" decoding="async">`
30
31
  : '';
31
32
  return (
32
33
  `<a class="video-facade${image ? ' video-facade-still' : ''}" href="${escapeHtml(href)}" data-video="${escapeHtml(player)}" data-title="${escapeHtml(name)}">` +
@@ -0,0 +1 @@
1
+ export function withoutH1(html: string): string;
@@ -0,0 +1,7 @@
1
+ // A page's text as HTML without its H1, which the layout draws in its own place (above the picture, PageLayout.astro).
2
+ // Exposes withoutH1 (pure); the H1 is the first one wherever it stands, as pageH1 (src/sections.mjs) reads it. No dependencies.
3
+
4
+ /** The html without its first <h1>…</h1> and the white space after it.
5
+ * @param {string} html
6
+ * @returns {string} */
7
+ export const withoutH1 = (html) => html.replace(/<h1\b[^>]*>[\s\S]*?<\/h1>\s*/, '');
@@ -0,0 +1,26 @@
1
+ // Tests lib/without-h1.mjs: the first <h1> of a page's rendered text is taken out, wherever it is.
2
+ // Run with `node --test packages/editor/lib/*.test.mjs` (part of npm run check).
3
+ import { test } from 'node:test';
4
+ import assert from 'node:assert/strict';
5
+ import { withoutH1 } from './without-h1.mjs';
6
+
7
+ test('an H1 at the top is taken out, with the space after it', () => {
8
+ assert.equal(withoutH1('<h1 id="title">Title</h1>\n<p>Text</p>'), '<p>Text</p>');
9
+ });
10
+
11
+ test('an H1 after other parts is taken out where it is', () => {
12
+ assert.equal(withoutH1('<p>Intro</p>\n<h1 id="t">Title</h1>\n<h2>Section</h2>'), '<p>Intro</p>\n<h2>Section</h2>');
13
+ });
14
+
15
+ test('an H1 with a coloured word is taken out whole', () => {
16
+ assert.equal(withoutH1('<h1>One <span class="color-accent">two</span></h1><p>x</p>'), '<p>x</p>');
17
+ });
18
+
19
+ test('only the first H1 goes', () => {
20
+ assert.equal(withoutH1('<h1>A</h1><p>x</p><h1>B</h1>'), '<p>x</p><h1>B</h1>');
21
+ });
22
+
23
+ test('without an H1 the html is as it was', () => {
24
+ assert.equal(withoutH1('<p>Text</p><h2>Section</h2>'), '<p>Text</p><h2>Section</h2>');
25
+ assert.equal(withoutH1('<header>x</header>'), '<header>x</header>');
26
+ });
@@ -0,0 +1,6 @@
1
+ /** The word `text` has at `offset` (a place between two characters), as offsets in `text`, or null when there is no
2
+ * word on either side of it. wordAt('How saving works', 6) is { from: 4, to: 10 }. */
3
+ export function wordAt(text: any, offset: any): {
4
+ from: any;
5
+ to: any;
6
+ } | null;
@@ -0,0 +1,20 @@
1
+ // The word around a place in a line of text (the menu over a text at a still cursor, src/stillCursor.mjs): letters,
2
+ // digits, apostrophes and hyphens, without the apostrophes and hyphens at its ends. Exposes wordAt. No dependencies,
3
+ // no browser.
4
+
5
+ const IN_WORD = /[\p{L}\p{M}\p{N}_'’-]/u;
6
+ const LETTER = /[\p{L}\p{N}]/u;
7
+
8
+ /** The word `text` has at `offset` (a place between two characters), as offsets in `text`, or null when there is no
9
+ * word on either side of it. wordAt('How saving works', 6) is { from: 4, to: 10 }. */
10
+ export function wordAt(text, offset) {
11
+ const inWord = (i) => i >= 0 && i < text.length && IN_WORD.test(text[i]);
12
+ if (!inWord(offset - 1) && !inWord(offset)) return null;
13
+ let from = offset;
14
+ let to = offset;
15
+ while (inWord(from - 1)) from--;
16
+ while (inWord(to)) to++;
17
+ while (from < to && !LETTER.test(text[from])) from++;
18
+ while (to > from && !LETTER.test(text[to - 1])) to--;
19
+ return from < to ? { from, to } : null;
20
+ }
@@ -0,0 +1,19 @@
1
+ // Tests for lib/word-at.mjs: the word around a place in a line of text. Run: node --test packages/editor/lib/*.test.mjs packages/editor/src/*.test.mjs
2
+ import { test } from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import { wordAt } from './word-at.mjs';
5
+
6
+ test('inside a word', () => assert.deepEqual(wordAt('How saving works', 6), { from: 4, to: 10 }));
7
+ test('at the start of a word', () => assert.deepEqual(wordAt('How saving works', 4), { from: 4, to: 10 }));
8
+ test('at the end of a word', () => assert.deepEqual(wordAt('How saving works', 10), { from: 4, to: 10 }));
9
+ test('between two spaces', () => assert.equal(wordAt('How saving', 4), null));
10
+ test('after a full stop', () => assert.equal(wordAt('works.', 6), null));
11
+ test('before a full stop', () => assert.deepEqual(wordAt('works.', 5), { from: 0, to: 5 }));
12
+ test('an apostrophe and a hyphen are inside a word', () => {
13
+ assert.deepEqual(wordAt("don't", 2), { from: 0, to: 5 });
14
+ assert.deepEqual(wordAt('e-mail', 1), { from: 0, to: 6 });
15
+ });
16
+ test('quotes around a word are not part of it', () => assert.deepEqual(wordAt("'hi'", 2), { from: 1, to: 3 }));
17
+ test('Danish letters', () => assert.deepEqual(wordAt('Gem ændringer', 6), { from: 4, to: 13 }));
18
+ test('a lone hyphen is no word', () => assert.equal(wordAt('a - b', 3), null));
19
+ test('an empty text', () => assert.equal(wordAt('', 0), null));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morphemecms/cms",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Morpheme CMS: the editor for a site built with Astro, its screens, its blocks and the rules a site gives it. See README.md.",
5
5
  "license": "MIT",
6
6
  "author": "Lars Adolfsen",
@@ -53,6 +53,7 @@
53
53
  "@astrojs/rss": "^4.0.19",
54
54
  "@astrojs/sitemap": "^3.7.4",
55
55
  "@fontsource-variable/roboto": "^5.3.0",
56
+ "@material-symbols/svg-400": "0.48.2",
56
57
  "@material/material-color-utilities": "^0.3.0",
57
58
  "@milkdown/kit": "^7.22.3",
58
59
  "@phosphor-icons/core": "^2.1.1",
@@ -108,6 +109,7 @@
108
109
  "@changesets/changelog-git": "^1.0.0",
109
110
  "@changesets/cli": "^3.0.3",
110
111
  "@fontsource-variable/roboto": "^5.3.0",
112
+ "@material-symbols/svg-400": "0.48.2",
111
113
  "@material/material-color-utilities": "^0.3.0",
112
114
  "@milkdown/kit": "^7.22.3",
113
115
  "@phosphor-icons/core": "^2.1.1",
@@ -3,9 +3,24 @@
3
3
  // until it is the saved commit (or a later one), and then says that the site has been updated.
4
4
  // Cloudflare's builds set WORKERS_CI_COMMIT_SHA; a build elsewhere asks git. `editor` is the
5
5
  // fingerprint of the editor's code (packages/editor/site/editor-version.mjs): an open editor with another one
6
- // reloads itself.
6
+ // reloads itself. `paidBuilds` (src/data/build.json, packages/editor/lib/build-settings.mjs) is how long the editor waits for
7
+ // a build before it says it failed: 20 minutes on the free plan, 10 on Workers Paid.
7
8
  import { execSync } from 'node:child_process';
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
8
11
  import { editorVersion } from '@morphemecms/cms/site/editor-version.mjs';
12
+ import { BUILD_SETTINGS_PATH, readBuildSettings } from '../../lib/build-settings.mjs';
13
+
14
+ // From where the build runs, as packages/editor/site/images.mjs (this file is bundled elsewhere first).
15
+ const SETTINGS = path.resolve(BUILD_SETTINGS_PATH);
16
+
17
+ function paidBuilds() {
18
+ try {
19
+ return readBuildSettings(JSON.parse(fs.readFileSync(SETTINGS, 'utf8'))).paidBuilds;
20
+ } catch {
21
+ return readBuildSettings(undefined).paidBuilds;
22
+ }
23
+ }
9
24
 
10
25
  function head() {
11
26
  try {
@@ -17,6 +32,11 @@ function head() {
17
32
 
18
33
  export function GET() {
19
34
  return new Response(
20
- JSON.stringify({ commit: process.env.WORKERS_CI_COMMIT_SHA || head(), editor: editorVersion(), builtAt: new Date().toISOString() }),
35
+ JSON.stringify({
36
+ commit: process.env.WORKERS_CI_COMMIT_SHA || head(),
37
+ editor: editorVersion(),
38
+ builtAt: new Date().toISOString(),
39
+ paidBuilds: paidBuilds(),
40
+ }),
21
41
  );
22
42
  }
@@ -0,0 +1,11 @@
1
+ // Whether check:content already ran for this commit (morpheme ci-build sets MORPHEME_CONTENT_CHECKED before the build).
2
+ // Exposes alreadyChecked. No dependencies; apart from check-content.mjs so a test can import it without running the check.
3
+
4
+ /**
5
+ * @param {string | undefined} envValue the MORPHEME_CONTENT_CHECKED variable
6
+ * @param {string} head `git rev-parse HEAD`
7
+ * @returns {boolean} true only when the variable is the commit being built
8
+ */
9
+ export function alreadyChecked(envValue, head) {
10
+ return typeof envValue === 'string' && envValue !== '' && envValue === head;
11
+ }
@@ -0,0 +1,18 @@
1
+ // Tests for scripts/already-checked.mjs: alreadyChecked() skips check:content in the build only when ci-build has run it
2
+ // for exactly this commit. Run: node --test packages/editor/scripts/*.test.mjs
3
+ import assert from 'node:assert/strict';
4
+ import test from 'node:test';
5
+ import { alreadyChecked } from './already-checked.mjs';
6
+
7
+ const head = 'a'.repeat(40);
8
+
9
+ test('alreadyChecked is true for the same sha', () => {
10
+ assert.equal(alreadyChecked(head, head), true);
11
+ });
12
+
13
+ test('alreadyChecked is false for another sha, an empty string or undefined', () => {
14
+ assert.equal(alreadyChecked('b'.repeat(40), head), false);
15
+ assert.equal(alreadyChecked('', head), false);
16
+ assert.equal(alreadyChecked(undefined, head), false);
17
+ assert.equal(alreadyChecked('', ''), false);
18
+ });
@@ -0,0 +1,27 @@
1
+ // The check of the build settings file (src/data/build.json, Indstillinger › Avanceret): an object with only the
2
+ // boolean keys contentLane and paidBuilds. Exposes buildSettingsProblems; used by check-content.mjs. No dependencies.
3
+
4
+ const KEYS = ['contentLane', 'paidBuilds'];
5
+
6
+ /**
7
+ * @param {string} path the file's path, for the messages
8
+ * @param {string | undefined} text the file's text, or undefined when it is missing (which passes)
9
+ * @returns {string[]} what is wrong with the file, one line each
10
+ */
11
+ export function buildSettingsProblems(path, text) {
12
+ if (text === undefined) return [];
13
+ let json;
14
+ try {
15
+ json = JSON.parse(text);
16
+ } catch (e) {
17
+ return [`${path}: er ikke gyldig JSON (${e.message})`];
18
+ }
19
+ if (typeof json !== 'object' || json === null || Array.isArray(json)) return [`${path}: skal være et objekt med ${KEYS.join(' og ')}`];
20
+ return Object.entries(json).flatMap(([key, value]) =>
21
+ !KEYS.includes(key)
22
+ ? [`${path}: kender ikke nøglen "${key}" (kun ${KEYS.join(' og ')})`]
23
+ : typeof value !== 'boolean'
24
+ ? [`${path}: "${key}" skal være true eller false`]
25
+ : [],
26
+ );
27
+ }
@@ -0,0 +1,35 @@
1
+ // Tests buildSettingsProblems (build-settings-problems.mjs): the build settings file passes when it is missing or an
2
+ // object with only boolean contentLane and paidBuilds, and is reported otherwise.
3
+ import assert from 'node:assert/strict';
4
+ import test from 'node:test';
5
+ import { buildSettingsProblems } from './build-settings-problems.mjs';
6
+
7
+ const PATH = 'src/data/build.json';
8
+
9
+ test('a missing file passes', () => {
10
+ assert.deepEqual(buildSettingsProblems(PATH, undefined), []);
11
+ });
12
+
13
+ test('both keys, one key and an empty object pass', () => {
14
+ assert.deepEqual(buildSettingsProblems(PATH, '{ "contentLane": true, "paidBuilds": false }'), []);
15
+ assert.deepEqual(buildSettingsProblems(PATH, '{ "paidBuilds": true }'), []);
16
+ assert.deepEqual(buildSettingsProblems(PATH, '{}'), []);
17
+ });
18
+
19
+ test('an unknown key is reported', () => {
20
+ const problems = buildSettingsProblems(PATH, '{ "contentLane": true, "fastLane": true }');
21
+ assert.equal(problems.length, 1);
22
+ assert.match(problems[0], /fastLane/);
23
+ assert.match(problems[0], new RegExp(PATH));
24
+ });
25
+
26
+ test('a value that is not a boolean is reported', () => {
27
+ const problems = buildSettingsProblems(PATH, '{ "contentLane": "yes", "paidBuilds": 1 }');
28
+ assert.equal(problems.length, 2);
29
+ assert.match(problems[0], /contentLane/);
30
+ assert.match(problems[1], /paidBuilds/);
31
+ });
32
+
33
+ test('JSON that is not an object, or not JSON, is reported', () => {
34
+ for (const text of ['[]', 'true', 'null', '"x"', '{ nope']) assert.equal(buildSettingsProblems(PATH, text).length, 1, text);
35
+ });
@@ -3,9 +3,12 @@
3
3
  // (checkSite.ts in the editor, packages/editor/). Stops with what is wrong, so a broken page never goes
4
4
  // out; the site that is out stays as it is; among it every theme (themeProblems, with the icon packs it brings) and
5
5
  // the system's icon packs and the choice of one (iconPackProblems), and so an icon the site or the editor draws that a
6
- // pack has none for, and the choice of motion scheme (motionProblems). Says too which of the site's texts the
6
+ // pack has none for, and the choice of motion scheme (motionProblems), and names in a folder that differ only in case
7
+ // (casingProblems). Says too which of the site's texts the
7
8
  // editor has no translation of (@site/texts, a block's texts.ts); that does not stop the build. The editor's
8
- // code is TypeScript and TSX, so it is loaded with Vite, as the site's own build does.
9
+ // code is TypeScript and TSX, so it is loaded with Vite, as the site's own build does. Exits early, without checking, when
10
+ // MORPHEME_CONTENT_CHECKED names the commit being built (morpheme ci-build already ran it; already-checked.mjs).
11
+ import { execFileSync } from 'node:child_process';
9
12
  import { existsSync, readFileSync, readdirSync } from 'node:fs';
10
13
  import { join, relative } from 'node:path';
11
14
  import { fileURLToPath } from 'node:url';
@@ -24,7 +27,26 @@ import {
24
27
  import { appFoldersPlugin, appIds, appWorkersProblems } from '@morphemecms/cms/lib/apps.mjs';
25
28
  import { caseCollisionProblems } from '@morphemecms/cms/lib/caseCollisions.mjs';
26
29
  import { codePackageProblems, declarationProblems, ownerId } from '@morphemecms/cms/lib/licenses.mjs';
30
+ import { breakpointProblems } from '@morphemecms/cms/site/breakpoints.mjs';
31
+ import { casingProblems } from '../site/casing.mjs';
32
+ import { BUILD_SETTINGS_PATH } from '../lib/build-settings.mjs';
33
+ import { buildSettingsProblems } from './build-settings-problems.mjs';
34
+ import { textProblems } from './text-problems.mjs';
27
35
  import { notesOf, releaseProblems } from './releases.mjs';
36
+ import { alreadyChecked } from './already-checked.mjs';
37
+
38
+ // morpheme ci-build has run this check for the commit being built.
39
+ // Git is asked only then; no repository or no commit yet means "not checked".
40
+ if (process.env.MORPHEME_CONTENT_CHECKED) {
41
+ let head = '';
42
+ try {
43
+ head = execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
44
+ } catch {}
45
+ if (alreadyChecked(process.env.MORPHEME_CONTENT_CHECKED, head)) {
46
+ console.log(`check:content already ran for ${head.slice(0, 7)}`);
47
+ process.exit(0);
48
+ }
49
+ }
28
50
 
29
51
  const root = `${process.cwd()}/`;
30
52
  // This package's own code, from the site's root (packages/editor/ or node_modules/@morphemecms/cms/).
@@ -38,7 +60,7 @@ const files = ['src/content', 'src/data']
38
60
  .flatMap((dir) =>
39
61
  readdirSync(dir, { recursive: true, withFileTypes: true })
40
62
  .filter((e) => e.isFile() && /\.(md|json)$/.test(e.name))
41
- .map((e) => join(e.parentPath, e.name)),
63
+ .map((e) => join(e.parentPath, e.name).replaceAll('\\', '/')),
42
64
  )
43
65
  .concat([config.siteRedirects.rules, config.siteRedirects.map])
44
66
  .map((path) => ({ path, text: readFileSync(path, 'utf8') }));
@@ -52,7 +74,7 @@ const server = await createServer({
52
74
  resolve: { alias: aliases() },
53
75
  server: { middlewareMode: true, hmr: false, ws: false },
54
76
  });
55
- let result, missing;
77
+ let result, missing, enTexts;
56
78
  // The licences ("Licenser" under "Indstillinger", @morphemecms/cms/lib/licenses.mjs): each app's own and those of the
57
79
  // third-party work it brings (its defineApp), installed or not, as each theme's (themeProblems), with the packages they
58
80
  // declare, which they give what a package lacks for.
@@ -65,6 +87,8 @@ try {
65
87
  const { problemsOf, untranslated } = await server.ssrLoadModule('@morphemecms/cms/src/checkSite.ts');
66
88
  result = problemsOf(files);
67
89
  missing = untranslated();
90
+ // The editor's English texts against Material 3's rules for writing (text-problems.mjs).
91
+ enTexts = textProblems((await server.ssrLoadModule('@morphemecms/cms/src/i18n/en-US/index.ts')).enUS);
68
92
  if (existsSync('store/catalog.json')) catalog = JSON.parse(readFileSync('store/catalog.json', 'utf8'));
69
93
  for (const id of appIds(root)) {
70
94
  const app = (await server.ssrLoadModule(`/src/apps/${id}/index.ts`)).default;
@@ -103,11 +127,14 @@ for (const [locale, files] of Object.entries(missing)) {
103
127
  }
104
128
  }
105
129
 
130
+ // The editor's English texts follow Material 3's rules for writing (text-problems.mjs).
131
+ result.problems.push(...enTexts);
106
132
  // Every theme installed has what a theme must have (the standard theme all of it), so the owner can swap to any of them
107
133
  // on "Udseende".
108
134
  result.problems.push(...installedThemes().flatMap(themeProblems), ...iconPackProblems(), ...motionProblems());
109
135
  // Every block is drawn from its view, on the site and in the editor alike.
110
136
  result.problems.push(...blockProblems());
137
+ result.problems.push(...breakpointProblems(root));
111
138
  // What each app loads from elsewhere is in its csp.json, so the pages' Content-Security-Policy lets it through.
112
139
  result.problems.push(...appCspProblems());
113
140
  // The addresses allowed in the editor after the policy stopped them are written as an app's csp.json.
@@ -141,6 +168,17 @@ for (const theme of installedThemes()) {
141
168
  );
142
169
  }
143
170
  result.problems.push(...releases);
171
+ // No two names in a folder differ only in case, so the site builds on Windows and macOS too (casing.mjs). From git's
172
+ // list of the files; a site that is no git repository (a fresh starter) has none to give.
173
+ let tracked = [];
174
+ try {
175
+ tracked = execFileSync('git', ['ls-files'], { cwd: root, encoding: 'utf8' }).split('\n').filter(Boolean);
176
+ } catch {}
177
+ result.problems.push(...casingProblems(tracked));
178
+ // The build settings the editor writes under "Avanceret": only boolean contentLane and paidBuilds; a missing file passes.
179
+ result.problems.push(
180
+ ...buildSettingsProblems(BUILD_SETTINGS_PATH, existsSync(BUILD_SETTINGS_PATH) ? readFileSync(BUILD_SETTINGS_PATH, 'utf8') : undefined),
181
+ );
144
182
 
145
183
  if (result.problems.length) {
146
184
  console.error(`\nIndholdet følger ikke editorens regler (${result.problems.length}):\n`);
@@ -4,7 +4,7 @@
4
4
  // as GitHub's GraphQL and REST APIs give it), with R2 in memory. With the cache, without it (no binding FILES) and
5
5
  // with an R2 that fails, the editor must get exactly the files git has, save, see the versions and the deleted files
6
6
  // and restore them; with the cache, an opening after a save may ask GitHub for nothing but the branch's head, and
7
- // everything in R2 must give its git id. Nothing goes to GitHub or Cloudflare. Run: npm run check:worker
7
+ // everything in R2 must give its git id. /api/r2 and /api/plan must say what Cloudflare says. Nothing goes to GitHub or Cloudflare. Run: npm run check:worker
8
8
  import { execFileSync } from 'node:child_process';
9
9
  import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
10
10
  import { tmpdir } from 'node:os';
@@ -58,7 +58,8 @@ const out = (...args) => git(args).toString().trim();
58
58
  execFileSync('git', ['init', '-q', '-b', branch, REPO], { env: ownRepoEnv() });
59
59
  const tarball = join(REPO, '.git', 'head.tar');
60
60
  execFileSync('git', ['-C', ROOT, 'archive', '-o', tarball, 'HEAD']);
61
- execFileSync('tar', ['-x', '-f', tarball, '-C', REPO]);
61
+ // From inside the folder, by a relative path: GNU tar (Git's on Windows) reads C:\… as a remote host's file.
62
+ execFileSync('tar', ['-x', '-f', join('.git', 'head.tar')], { cwd: REPO });
62
63
  rmSync(tarball);
63
64
  git(['add', '-A']);
64
65
  git(['commit', '-q', '-m', 'Start']);
@@ -213,6 +214,8 @@ const requests = [];
213
214
  let cloudflare = [500, {}];
214
215
  let cloudflareJurisdiction = null;
215
216
  let r2Env = {};
217
+ // Cloudflare's answer about the account's subscriptions, for /api/plan ([status, body]).
218
+ let subscriptions = [500, {}];
216
219
  async function github(url, init) {
217
220
  asked++;
218
221
  requests.push(`${url} ${init.body ?? ''}`);
@@ -228,7 +231,12 @@ async function github(url, init) {
228
231
  // ---------------------------------------------------------------------------------------------------
229
232
  // The Worker, with R2 in memory (or none, or one that fails), and the editor's github.ts in a "browser" whose
230
233
  // requests to /api/github/ go to it, signed in.
231
- const [{ handleGitHub }, { readFiles, commitFiles }, { handleR2 }] = await workerModules('editor-github.js', 'github.js', 'r2-status.js');
234
+ const [{ handleGitHub }, { readFiles, commitFiles }, { handleR2 }, { handlePlan, paidFrom }] = await workerModules(
235
+ 'editor-github.js',
236
+ 'github.js',
237
+ 'r2-status.js',
238
+ 'plan-status.js',
239
+ );
232
240
 
233
241
  function memoryBucket() {
234
242
  const objects = new Map();
@@ -280,6 +288,8 @@ globalThis.fetch = async (input, init = {}) => {
280
288
  return new Headers(init.headers).get('cf-r2-jurisdiction') === cloudflareJurisdiction
281
289
  ? Response.json(cloudflare[1], { status: cloudflare[0] })
282
290
  : Response.json({ success: false, errors: [{ code: 10006, message: 'The specified bucket does not exist.' }] }, { status: 404 });
291
+ if (url === 'https://api.cloudflare.com/client/v4/accounts/account/subscriptions')
292
+ return Response.json(subscriptions[1], { status: subscriptions[0] });
283
293
  if (!url.startsWith('/api/github/')) return new Response(null, { status: 404 });
284
294
  const request = new Request(`https://editor.example${url}`, {
285
295
  ...init,
@@ -313,7 +323,7 @@ await build({
313
323
  ],
314
324
  });
315
325
  let browsers = 0;
316
- const browser = () => import(`${bundle}?${browsers++}`);
326
+ const browser = () => import(`${pathToFileURL(bundle).href}?${browsers++}`);
317
327
 
318
328
  // ---------------------------------------------------------------------------------------------------
319
329
  // What git has, as the editor had it before: each file in a folder `depth` levels down, with its text ('' for binary).
@@ -542,6 +552,44 @@ try {
542
552
  expect(other.status === 403, `/api/r2 svarede en anden side med ${other.status}, ikke 403`);
543
553
  }
544
554
 
555
+ // /api/plan (packages/editor/worker/plan-status.js): whether the account has Workers Paid, for "Betalt bygning".
556
+ {
557
+ const plan = (id, state) => ({ rate_plan: { id }, ...(state === undefined ? {} : { state }) });
558
+ expect(paidFrom([plan('WORKERS_PAID', 'Paid')]) === true, 'paidFrom: WORKERS_PAID med store bogstaver er ikke betalt');
559
+ expect(paidFrom([plan('workers_paid')]) === true, 'paidFrom: workers_paid uden tilstand er ikke betalt');
560
+ expect(paidFrom([plan('workers_paid', 'trial')]) === true, 'paidFrom: en prøveperiode er ikke betalt');
561
+ expect(paidFrom([plan('workers_paid', 'Cancelled')]) === false, 'paidFrom: en opsagt plan er betalt');
562
+ expect(paidFrom([plan('cf_free', 'Paid'), plan('zone_pro', 'Paid')]) === false, 'paidFrom: en zoneplan er Workers Paid');
563
+ expect(
564
+ paidFrom(undefined) === false && paidFrom({}) === false && paidFrom(null) === false,
565
+ 'paidFrom: noget, der ikke er en liste, er betalt',
566
+ );
567
+ const signedIn = { cookie: 'editor_session=token', 'sec-fetch-site': 'same-origin' };
568
+ const askPlan = (headers = signedIn, method = 'GET') =>
569
+ handlePlan(new Request('https://editor.example/api/plan', { headers, method }), r2Env).then(async (r) => ({
570
+ status: r.status,
571
+ ...(await r.json()),
572
+ }));
573
+ const keyed = { CLOUDFLARE_ACCOUNT_ID: 'account', CLOUDFLARE_R2_TOKEN: 'key' };
574
+ r2Env = {};
575
+ expect((await askPlan()).paid === 'no-key', '/api/plan uden Workerens nøgle sagde ikke no-key');
576
+ r2Env = keyed;
577
+ subscriptions = [403, { success: false, errors: [{ code: 10000, message: 'Authentication error' }] }];
578
+ expect((await askPlan()).paid === 'refused', '/api/plan sagde ikke refused, da Cloudflare svarede 403');
579
+ subscriptions = [500, { success: false, errors: [] }];
580
+ expect((await askPlan()).paid === 'failed', '/api/plan sagde ikke failed, da Cloudflare svarede 500');
581
+ subscriptions = [200, { success: true, errors: [], result: [plan('WORKERS_PAID', 'Paid')] }];
582
+ expect((await askPlan()).paid === 'yes', '/api/plan sagde ikke yes med Workers Paid');
583
+ subscriptions = [200, { success: true, errors: [], result: [plan('cf_free', 'Paid')] }];
584
+ expect((await askPlan()).paid === 'no', '/api/plan sagde ikke no uden Workers Paid');
585
+ expect(
586
+ (await askPlan({ cookie: 'editor_session=token', 'sec-fetch-site': 'same-site' })).status === 403,
587
+ '/api/plan svarede en anden side ikke med 403',
588
+ );
589
+ expect((await askPlan(signedIn, 'POST')).status === 405, '/api/plan svarede en POST ikke med 405');
590
+ r2Env = {};
591
+ }
592
+
545
593
  // Everything in R2 is the repository's, gives its git id, and was written once.
546
594
  for (const [key, value] of bucket.objects) {
547
595
  const [o, r, kind, oid] = key.split('/');
@@ -554,6 +602,50 @@ try {
554
602
  for (const [key, n] of bucket.puts) expect(n === 1, `R2: ${key} blev skrevet ${n} gange`);
555
603
  for (const oid of truncated)
556
604
  expect(!bucket.objects.has(`${owner}/${name}/blob/${oid}`), `R2: en fil, GitHub kun gav en del af, blev gemt`);
605
+
606
+ // How long a wait for a build lasts (packages/editor/worker/push.js waitForBuilds, build-settings.mjs giveUpAfter): a wait of
607
+ // 15 minutes is still waiting when version.json has no paidBuilds (free plan: 20 minutes), is given up with
608
+ // paidBuilds: true (10 minutes), and every wait is given up after 20.
609
+ {
610
+ const [{ waitForBuilds }] = await workerModules('push.js');
611
+ const minute = 60_000;
612
+ const now = Date.now();
613
+ const commit = 'a'.repeat(40);
614
+ const kv = (waits) => {
615
+ const store = new Map(waits.map(([key, at]) => [key, { metadata: { commit, device: key, at: new Date(at).toISOString() } }]));
616
+ store.set('waiting', { value: String(now + 30 * minute) });
617
+ return {
618
+ store,
619
+ get: async (key) => store.get(key)?.value ?? null,
620
+ put: async () => {},
621
+ delete: async (key) => void store.delete(key),
622
+ list: async ({ prefix }) => ({
623
+ keys: [...store].filter(([key]) => key.startsWith(prefix)).map(([name, { metadata }]) => ({ name, metadata })),
624
+ }),
625
+ };
626
+ };
627
+ // A build from before the saves, so none of them is on the site.
628
+ const assets = (paid) => ({
629
+ fetch: async () =>
630
+ Response.json({
631
+ commit: 'b'.repeat(40),
632
+ builtAt: new Date(now - 60 * minute).toISOString(),
633
+ ...(paid === undefined ? {} : { paidBuilds: paid }),
634
+ }),
635
+ });
636
+ for (const [paid, minutes, waiting] of [
637
+ [undefined, 15, true],
638
+ [false, 15, true],
639
+ [true, 15, false],
640
+ [undefined, 25, false],
641
+ [true, 25, false],
642
+ ]) {
643
+ const PUSH = kv([['wait:x', now - minutes * minute]]);
644
+ await waitForBuilds({ PUSH, ASSETS: assets(paid) }, now);
645
+ const label = `et ventende gem på ${minutes} minutter, paidBuilds ${paid}`;
646
+ expect(PUSH.store.has('wait:x') === waiting, `${label}: ${waiting ? 'blev opgivet for tidligt' : 'blev ikke opgivet'}`);
647
+ }
648
+ }
557
649
  } finally {
558
650
  console.log = log;
559
651
  rmSync(WORK, { recursive: true, force: true });