@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,85 @@
1
+ // `morpheme ci-build`, the Cloudflare build command: takes the content lane (Prettier on the changed files, the editor's
2
+ // checks, the build) when only content changed since the live commit, else the full lane (npm run check, the build).
3
+ // Exposes decideLane; running the file does the build. Depends on lib/build-lane.mjs, site/live-diff.mjs, step-command.mjs.
4
+ import { execFileSync, spawnSync } from 'node:child_process';
5
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { BUILD_SETTINGS_PATH, readBuildSettings } from '../lib/build-settings.mjs';
9
+ import { laneFor } from '../lib/build-lane.mjs';
10
+ import { changedSinceLive } from '../site/live-diff.mjs';
11
+ import { stepCommand } from './step-command.mjs';
12
+
13
+ /**
14
+ * @param {object} o
15
+ * @param {string} o.cwd the site's root; must be the working directory too, as the site config is loaded from there
16
+ * @param {typeof globalThis.fetch} [o.fetch]
17
+ * @param {NodeJS.ProcessEnv} [o.env]
18
+ * @returns {Promise<{ lane: 'content' | 'full', reason: string, paths: string[] | null }>} any doubt gives the full lane.
19
+ */
20
+ export async function decideLane({ cwd, fetch = globalThis.fetch, env = process.env }) {
21
+ let settings;
22
+ try {
23
+ const file = join(cwd, BUILD_SETTINGS_PATH);
24
+ settings = readBuildSettings(existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : undefined);
25
+ } catch {
26
+ return { ...laneFor(null, { contentLane: true }, `${BUILD_SETTINGS_PATH} unreadable`), paths: null };
27
+ }
28
+ let config;
29
+ try {
30
+ // Loaded as check-content.mjs does, from the site's root (siteConfig.mjs reads the working directory).
31
+ ({ config } = await import('../site/siteConfig.mjs'));
32
+ if (!config?.domain || !config?.repo?.branch) throw new Error('no domain or branch');
33
+ } catch {
34
+ return { ...laneFor(null, settings, 'site config unknown'), paths: null };
35
+ }
36
+ const { paths, reason } = await changedSinceLive({
37
+ siteUrl: 'https://' + config.domain,
38
+ cwd,
39
+ fetch,
40
+ branch: env.WORKERS_CI_BRANCH,
41
+ repoBranch: config.repo.branch,
42
+ });
43
+ return { ...laneFor(paths, settings, reason), paths };
44
+ }
45
+
46
+ /** Runs a command with the output shown and stops the build with its exit code when it fails. */
47
+ function step(tool, args, env = process.env) {
48
+ console.log(`ci-build: ${[tool, ...args].join(' ')}`);
49
+ const [command, commandArgs] = stepCommand(tool, args, { env });
50
+ const { status } = spawnSync(command, commandArgs, { stdio: 'inherit', env });
51
+ if (status !== 0) process.exit(status ?? 1);
52
+ }
53
+
54
+ async function main() {
55
+ const cwd = process.cwd();
56
+ const { lane, reason, paths } = await decideLane({ cwd });
57
+ console.log(`ci-build: ${lane} lane — ${reason}`);
58
+ if (lane === 'full') {
59
+ step('npm', ['run', 'check']);
60
+ } else {
61
+ const existing = paths.filter((path) => existsSync(join(cwd, path)));
62
+ if (existing.length > 0) step('prettier', ['--check', '--ignore-unknown', ...existing]);
63
+ step('npm', ['run', 'check:content']);
64
+ step('npm', ['run', 'check:editor']);
65
+ }
66
+ const head = execFileSync('git', ['rev-parse', 'HEAD'], { cwd, encoding: 'utf8' }).trim();
67
+ step('npm', ['run', 'build'], { ...process.env, MORPHEME_CONTENT_CHECKED: head });
68
+ }
69
+
70
+ /**
71
+ * Whether the module at `metaUrl` is the script Node was started with. Both sides go through realpath, so symlinks, a
72
+ * relative argv[1] and a drive letter's case (Windows) cannot make the same file look like another.
73
+ * @param {string} metaUrl import.meta.url
74
+ * @param {string | undefined} argv1 process.argv[1]
75
+ */
76
+ export function isMainModule(metaUrl, argv1) {
77
+ if (!argv1) return false;
78
+ try {
79
+ return realpathSync.native(fileURLToPath(metaUrl)) === realpathSync.native(argv1);
80
+ } catch {
81
+ return false;
82
+ }
83
+ }
84
+
85
+ if (isMainModule(import.meta.url, process.argv[1])) await main();
@@ -0,0 +1,137 @@
1
+ // Tests for scripts/ci-build.mjs: decideLane's lane for an unreadable build.json, the switch turned off, and a content-only
2
+ // diff, in temporary git repositories (GIT_* variables cleared). Run: node --test packages/editor/scripts/*.test.mjs
3
+ import { test } from 'node:test';
4
+ import assert from 'node:assert/strict';
5
+ import { execFileSync } from 'node:child_process';
6
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, realpathSync } from 'node:fs';
7
+ import { tmpdir } from 'node:os';
8
+ import { join, dirname } from 'node:path';
9
+ import { clearRepoEnv } from './git-env.mjs';
10
+ import { fileURLToPath, pathToFileURL } from 'node:url';
11
+ import { relative } from 'node:path';
12
+ import { decideLane, isMainModule } from './ci-build.mjs';
13
+
14
+ // git sets GIT_DIR and friends for hooks (the pre-push hook runs these tests), and they would point every git call here at the real repository.
15
+ clearRepoEnv();
16
+
17
+ const start = process.cwd();
18
+
19
+ const git = (dir, ...args) =>
20
+ execFileSync('git', ['-c', 'user.name=Test', '-c', 'user.email=test@example.com', ...args], { cwd: dir, encoding: 'utf8' }).trim();
21
+
22
+ function write(dir, file, text) {
23
+ mkdirSync(dirname(join(dir, file)), { recursive: true });
24
+ writeFileSync(join(dir, file), text);
25
+ }
26
+
27
+ /**
28
+ * A site repository whose first commit (the live one) has the config and `files`; the working directory is moved there,
29
+ * because the site config is loaded from it. Returns the directory and the live commit.
30
+ */
31
+ function site(files = {}) {
32
+ const dir = realpathSync(mkdtempSync(join(tmpdir(), 'ci-build-')));
33
+ execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: dir });
34
+ write(dir, 'src/site/config.ts', "export const config = { domain: 'example.com', repo: { branch: 'main' } };\n");
35
+ for (const [file, text] of Object.entries(files)) write(dir, file, text);
36
+ git(dir, 'add', '-A');
37
+ git(dir, 'commit', '-q', '-m', 'live');
38
+ process.chdir(dir);
39
+ return { dir, live: git(dir, 'rev-parse', 'HEAD') };
40
+ }
41
+
42
+ function commitContent(dir) {
43
+ write(dir, 'src/content/a.md', 'new');
44
+ git(dir, 'add', '-A');
45
+ git(dir, 'commit', '-q', '-m', 'content');
46
+ }
47
+
48
+ const serve = (commit) => async () => ({ status: 200, json: async () => ({ commit }) });
49
+
50
+ function cleanup(dir) {
51
+ process.chdir(start);
52
+ rmSync(dir, { recursive: true, force: true });
53
+ }
54
+
55
+ test('an unreadable build.json gives the full lane', async () => {
56
+ const { dir, live } = site({ 'src/data/build.json': '{ not json' });
57
+ try {
58
+ commitContent(dir);
59
+ const result = await decideLane({ cwd: dir, fetch: serve(live), env: {} });
60
+ assert.equal(result.lane, 'full');
61
+ assert.match(result.reason, /build\.json unreadable/);
62
+ assert.equal(result.paths, null);
63
+ } finally {
64
+ cleanup(dir);
65
+ }
66
+ });
67
+
68
+ test('the content lane turned off gives the full lane', async () => {
69
+ const { dir, live } = site({ 'src/data/build.json': JSON.stringify({ contentLane: false, paidBuilds: false }) });
70
+ try {
71
+ commitContent(dir);
72
+ const result = await decideLane({ cwd: dir, fetch: serve(live), env: {} });
73
+ assert.equal(result.lane, 'full');
74
+ assert.equal(result.reason, 'turned off in the editor');
75
+ } finally {
76
+ cleanup(dir);
77
+ }
78
+ });
79
+
80
+ test('a content-only diff gives the content lane with its paths', async () => {
81
+ const { dir, live } = site({ 'README.md': 'x' });
82
+ try {
83
+ commitContent(dir);
84
+ const result = await decideLane({ cwd: dir, fetch: serve(live), env: {} });
85
+ assert.equal(result.lane, 'content');
86
+ assert.deepEqual(result.paths, ['src/content/a.md']);
87
+ } finally {
88
+ cleanup(dir);
89
+ }
90
+ });
91
+
92
+ test('isMainModule matches the same file however argv[1] spells it', () => {
93
+ const file = fileURLToPath(import.meta.url);
94
+ const url = pathToFileURL(file).href;
95
+ assert.equal(isMainModule(url, file), true);
96
+ assert.equal(isMainModule(url, relative(process.cwd(), file)), true);
97
+ assert.equal(
98
+ isMainModule(
99
+ url,
100
+ file.replace(/^[A-Z]:/, (drive) => drive.toLowerCase()),
101
+ ),
102
+ true,
103
+ );
104
+ assert.equal(
105
+ isMainModule(
106
+ url,
107
+ file.replace(/^[a-z]:/, (drive) => drive.toUpperCase()),
108
+ ),
109
+ true,
110
+ );
111
+ });
112
+
113
+ test('isMainModule is false for another file, a missing path or no argument', () => {
114
+ const url = pathToFileURL(fileURLToPath(import.meta.url)).href;
115
+ assert.equal(isMainModule(url, fileURLToPath(new URL('./git-env.mjs', import.meta.url))), false);
116
+ assert.equal(isMainModule(url, join(tmpdir(), 'no-such-file.mjs')), false);
117
+ assert.equal(isMainModule(url, undefined), false);
118
+ });
119
+
120
+ test('running the file does run the build (the guard matches)', () => {
121
+ const dir = realpathSync(mkdtempSync(join(tmpdir(), 'ci-build-run-')));
122
+ try {
123
+ writeFileSync(join(dir, 'package.json'), '{"scripts":{}}'); // so npm finds no `check` script and stops
124
+ // no site config here, so main fails fast, and "ci-build:" in the output shows that it ran
125
+ const out = execFileSync(process.execPath, [fileURLToPath(new URL('./ci-build.mjs', import.meta.url))], {
126
+ cwd: dir,
127
+ encoding: 'utf8',
128
+ stdio: ['ignore', 'pipe', 'pipe'],
129
+ env: process.env,
130
+ }).toString();
131
+ assert.match(out, /ci-build: full lane/);
132
+ } catch (error) {
133
+ assert.match(String(error.stdout), /ci-build: full lane/);
134
+ } finally {
135
+ rmSync(dir, { recursive: true, force: true });
136
+ }
137
+ });
@@ -6,6 +6,7 @@
6
6
  // of text with the same heading and the same HTML. Every page is also read into sections as the front page (as the
7
7
  // site reads the page that is the front page without the editor, partsOfMarkdown in sections.mjs, packages/editor/site/home.ts):
8
8
  // they must be those the editor reads. A page with the switch "Sidekolonne" off and on again has the same front matter. Run: npm run check:editor [files...]
9
+ // A built-in sample with section colours checks that the editor and the site give the front page's sections the same colours.
9
10
  // Milkdown needs a DOM; jsdom stands in for the browser.
10
11
  import { existsSync, readFileSync, readdirSync } from 'node:fs';
11
12
  import { join } from 'node:path';
@@ -60,6 +61,31 @@ const processor = await createMarkdownProcessor();
60
61
  const render = async (md) => (await processor.render(md)).code.replace(/\s+/g, ' ').trim();
61
62
 
62
63
  let failed = 0;
64
+ // On a page the first heading is the H1 by itself (src/firstHeading.mjs): after a change to the text, and only then.
65
+ const { firstHeading } = await import('@morphemecms/cms/src/firstHeading.mjs');
66
+ const headingLevelsOf = (view) => {
67
+ const levels = [];
68
+ view.state.doc.forEach((node) => void (node.type.name === 'heading' && levels.push(node.attrs.level)));
69
+ return levels;
70
+ };
71
+ for (const [markdown, edit, expected] of [
72
+ ['## First\n\ntext\n\n# Second\n\n### Third', true, [1, 2, 3]],
73
+ ['## First\n\ntext\n\n# Second\n\n### Third', false, [2, 1, 3]],
74
+ ['# First\n\n# Second\n\ntext', true, [1, 2]],
75
+ ['text only', true, []],
76
+ ]) {
77
+ const editor = await makeEditor(document.createElement('div'), markdown).use(firstHeading).create();
78
+ const view = editor.action((ctx) => ctx.get(editorViewCtx));
79
+ if (edit) view.dispatch(view.state.tr.insertText('x', view.state.doc.content.size - 1));
80
+ const levels = headingLevelsOf(view);
81
+ await editor.destroy();
82
+ if (levels.join() === expected.join()) continue;
83
+ failed++;
84
+ console.log(
85
+ `\n✗ første overskrift er H1 (packages/editor/src/firstHeading.mjs)\n tekst: ${JSON.stringify(markdown)}${edit ? ' (ændret)' : ''}\n vente: ${expected}\n fik: ${levels}`,
86
+ );
87
+ }
88
+
63
89
  // A fixed page's introduction and sections, as the editor reads them back.
64
90
  async function sectionsBack(sections, intro, field) {
65
91
  const leads = leadsOfField(field);
@@ -84,6 +110,35 @@ const comparable = async (intro, sections) =>
84
110
  )),
85
111
  ]);
86
112
 
113
+ // A text with coloured sections must give the same sections (with their colours) in the editor as on the site.
114
+ const { blockHtml } = await import('@morphemecms/cms/lib/blocks.mjs');
115
+ const colorSample = [
116
+ '# Sample',
117
+ '<div data-section="tint"></div>',
118
+ '## Hours',
119
+ 'Open daily.',
120
+ '<div data-section="accent"></div>',
121
+ '## Steps',
122
+ blockHtml('steps', {}),
123
+ '## Plain',
124
+ 'End.',
125
+ ].join('\n\n');
126
+ const frontSections = async (body, parts) => [
127
+ await comparable('', sectionsFromParts(parts, frontLeads, { colors: true })),
128
+ await comparable('', sectionsFromParts(partsOfMarkdown(body), frontLeads, { colors: true })),
129
+ ];
130
+ {
131
+ const editor = await makeEditor(document.createElement('div'), colorSample).use(spacing).create();
132
+ const [editors, site] = await frontSections(colorSample, editor.action(partsOf));
133
+ await editor.destroy();
134
+ if (editors !== site || !editors.includes('"sectionColor":"tint"') || !editors.includes('"sectionColor":"accent"')) {
135
+ console.log(
136
+ `\n✗ en tekst med sektionsfarver giver ikke de samme sektioner i editoren og på sitet\n editoren: ${editors}\n sitet: ${site}`,
137
+ );
138
+ process.exit(1);
139
+ }
140
+ }
141
+
87
142
  for (const file of files) {
88
143
  if (file.endsWith('.json')) {
89
144
  const { sections, intro } = JSON.parse(readFileSync(file, 'utf8'));
@@ -116,7 +171,9 @@ for (const file of files) {
116
171
  }
117
172
  const root = document.createElement('div');
118
173
  // As in the editor: with the free lines between the blocks, written without them.
119
- const editor = await makeEditor(root, body).use(spacing).create();
174
+ // A page also has the first-heading rule: opening and saving it must not change it.
175
+ const made = makeEditor(root, body).use(spacing);
176
+ const editor = await (file.includes('src/content/pages/') ? made.use(firstHeading) : made).create();
120
177
  editor.action((ctx) => ctx.get(editorViewCtx).dispatch(ctx.get(editorViewCtx).state.tr));
121
178
  const saved = editor.action(markdownOf);
122
179
  const parts = editor.action(partsOf);
@@ -126,9 +183,11 @@ for (const file of files) {
126
183
  await comparable('', sectionsFromParts(parts, frontLeads)),
127
184
  await comparable('', sectionsFromParts(partsOfMarkdown(body), frontLeads)),
128
185
  ];
129
- if (editors !== site) {
186
+ const [editorColors, siteColors] = await frontSections(body, parts);
187
+ if (editors !== site || editorColors !== siteColors) {
130
188
  failed++;
131
- console.log(`\n✗ ${file} som forside\n editoren: ${editors.slice(0, 300)}\n sitet: ${site.slice(0, 300)}`);
189
+ const [shownEditor, shownSite] = editors !== site ? [editors, site] : [editorColors, siteColors];
190
+ console.log(`\n✗ ${file} som forside\n editoren: ${shownEditor.slice(0, 300)}\n sitet: ${shownSite.slice(0, 300)}`);
132
191
  continue;
133
192
  }
134
193
  }
@@ -1,9 +1,10 @@
1
1
  // Opens every screen of the editor (/editor/) in a browser, on a phone and on a computer, and fails
2
2
  // when one breaks: an error on the page, something its Content-Security-Policy stopped, an empty app, or a link to an
3
3
  // address with nothing on it, or an icon that is not found (drawn as the question mark, data-icon-missing: Svg in
4
- // packages/editor/src/icon.tsx), there or on one of the built site's pages.
5
- // The screens are found as the editor's own links: from the start screen (and "Indstillinger", #indstillinger, "Dele", #udseende/dele, "Temaer",
6
- // #udseende/temaer, "Søgemaskineoptimering", #seo, and "Appstore", #indstillinger/appstore), and
4
+ // packages/editor/src/icon.tsx), there or on one of the built site's pages. An icon drawn from Material Symbols instead
5
+ // of the icon pack (data-icon-fallback) is only listed, with where it is: it does not fail.
6
+ // The screens are found as the editor's own links: from the start screen (the drawer's links are in the page also while it is
7
+ // closed) and "Dele", #udseende/dele, "Temaer", #udseende/temaer, "Søgemaskineoptimering", #seo, "Appstore", #indstillinger/appstore, and "Apps", #apps, which the menu no longer links to), and
7
8
  // from each screen it opens, two steps down. GitHub is answered from the files in this repository, so it needs no login
8
9
  // and changes nothing. It runs on the built site: npm run build && npm run check:screens (or on another
9
10
  // folder with a built site, its path after the command: packages/editor/scripts/check-themes.mjs).
@@ -26,14 +27,21 @@ const missingIcon = (name) =>
26
27
  ? `ikonet "${name}" findes ikke (et spørgsmålstegn i stedet): skriv det i packages/editor/src/icon.tsx`
27
28
  : 'et ikon er ikke en SVG-fil (et spørgsmålstegn i stedet)';
28
29
 
30
+ const fallbackIcon = (name) =>
31
+ `ikonet "${name}" er fra Material Symbols (ikke i ikonpakken): giv det et ikon i hver pakkes kort, hvis det skal blive`;
32
+
29
33
  // The icons not found on the built site's pages: each one once, with the pages it is on.
30
34
  const problems = [];
31
35
  const missingOn = new Map();
36
+ // The icons drawn from Material Symbols (named, with where they are: up to 3 places), listed, not failed on.
37
+ const fallbackOn = new Map();
38
+ const fallbackAt = (name, place) => fallbackOn.set(name, new Set([...(fallbackOn.get(name) ?? []), place]));
32
39
  for (const entry of readdirSync(DIST, { recursive: true, withFileTypes: true })) {
33
40
  if (!entry.isFile() || !entry.name.endsWith('.html')) continue;
34
41
  const file = join(entry.parentPath, entry.name);
35
42
  for (const [, name] of readFileSync(file, 'utf8').matchAll(/data-icon-missing="([^"]*)"/g))
36
43
  missingOn.set(name, new Set([...(missingOn.get(name) ?? []), `/${relative(DIST, file)}`]));
44
+ for (const [, name] of readFileSync(file, 'utf8').matchAll(/data-icon-fallback="([^"]*)"/g)) fallbackAt(name, `/${relative(DIST, file)}`);
37
45
  }
38
46
  for (const [name, pages] of missingOn) {
39
47
  const shown = [...pages].slice(0, 3).join(', ');
@@ -60,10 +68,10 @@ for (const [width, height] of [
60
68
  });
61
69
  await openEditor(page, site);
62
70
 
63
- // From the start screen, and from "Indstillinger" (the gear in the top bar), "Dele", "Temaer",
64
- // "Søgemaskineoptimering" and "Appstore" (the apps' pages and their developers'), whose screens are a step further
65
- // down than the others.
66
- const starts = ['', 'indstillinger', 'udseende/dele', 'udseende/temaer', 'seo', 'indstillinger/appstore'];
71
+ // From the start screen (the drawer's links), and from "Dele", "Temaer", "Søgemaskineoptimering" and "Appstore"
72
+ // (the apps' pages and their developers'), whose screens are a step further down than the others. "Apps" (the list of
73
+ // the apps installed) has no row in the menu, so it is a start of its own.
74
+ const starts = ['', 'udseende/dele', 'udseende/temaer', 'seo', 'indstillinger/appstore', 'apps'];
67
75
  const seen = new Set(starts);
68
76
  let next = starts;
69
77
  for (let depth = 0; depth <= 2 && next.length; depth++) {
@@ -78,7 +86,9 @@ for (const [width, height] of [
78
86
  text: document.body.innerText,
79
87
  links: [...document.querySelectorAll('a[href^="#"]')].map((a) => a.getAttribute('href').slice(1)),
80
88
  missing: [...document.querySelectorAll('[data-icon-missing]')].map((e) => e.getAttribute('data-icon-missing')),
89
+ fallback: [...document.querySelectorAll('[data-icon-fallback]')].map((e) => e.getAttribute('data-icon-fallback')),
81
90
  }));
91
+ for (const name of new Set(state.fallback)) fallbackAt(name, `${width}px #${hash}`);
82
92
  for (const name of new Set(state.missing)) problems.push(`${width}px #${hash}: ${missingIcon(name)}`);
83
93
  if (state.empty) problems.push(`${width}px #${hash}: skærmen er tom`);
84
94
  if (state.text.includes(`Der er ikke noget på adressen ${hash}.`))
@@ -120,6 +130,10 @@ for (const [state, { shows, hides }] of Object.entries(keyStates)) {
120
130
 
121
131
  await browser.close();
122
132
  server.close();
133
+ for (const [name, places] of fallbackOn) {
134
+ const shown = [...places].slice(0, 3).join(', ');
135
+ console.log(`${shown}${places.size > 3 ? ` og ${places.size - 3} steder til` : ''}: ${fallbackIcon(name)}`);
136
+ }
123
137
  if (problems.length) {
124
138
  console.error(`${problems.length} fejl i editoren eller på sitet:\n${[...new Set(problems)].join('\n')}`);
125
139
  process.exit(1);
@@ -6,4 +6,10 @@ import { execFileSync } from 'node:child_process';
6
6
  // The variables, as this git names them.
7
7
  const local = execFileSync('git', ['rev-parse', '--local-env-vars'], { encoding: 'utf8' }).split('\n').filter(Boolean);
8
8
 
9
+ // Removes those variables from process.env itself, for a test whose code calls git with the inherited environment.
10
+ export const clearRepoEnv = () => {
11
+ const own = ownRepoEnv();
12
+ for (const name of Object.keys(process.env)) if (!(name in own)) delete process.env[name];
13
+ };
14
+
9
15
  export const ownRepoEnv = (env = process.env) => Object.fromEntries(Object.entries(env).filter(([name]) => !local.includes(name)));
@@ -0,0 +1,34 @@
1
+ // How ci-build starts Prettier and npm without a shell: Node runs their JavaScript entry points itself, so a path with a
2
+ // space or a character cmd.exe reads (& | ^ %) reaches them as one argument. Exposes stepCommand, npmCli. Depends on bin.mjs.
3
+ import { existsSync } from 'node:fs';
4
+ import { basename, dirname, join } from 'node:path';
5
+ import { binOf } from './bin.mjs';
6
+
7
+ /**
8
+ * npm's entry point: the one running this build (npm_execpath, from npm or npx), else the one installed beside Node.
9
+ * @param {NodeJS.ProcessEnv} env
10
+ * @param {string} nodePath the node executable
11
+ * @returns {string | null}
12
+ */
13
+ export function npmCli(env = process.env, nodePath = process.execPath) {
14
+ const fromNpm = env.npm_execpath ? join(dirname(env.npm_execpath), basename(env.npm_execpath).replace(/^npx-cli/, 'npm-cli')) : null;
15
+ const beside = [
16
+ join(dirname(nodePath), 'node_modules', 'npm', 'bin', 'npm-cli.js'), // Windows
17
+ join(dirname(nodePath), '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'), // Linux and macOS
18
+ ];
19
+ return [fromNpm, ...beside].find((file) => file && /npm-cli\.c?js$/.test(file) && existsSync(file)) ?? null;
20
+ }
21
+
22
+ /**
23
+ * The program and arguments for a build step; never needs a shell.
24
+ * @param {'npm' | 'prettier'} tool
25
+ * @param {string[]} args
26
+ * @param {{ env?: NodeJS.ProcessEnv, from?: string }} [o] `from` is the site's root, where Prettier is found
27
+ * @returns {[string, string[]]}
28
+ */
29
+ export function stepCommand(tool, args, { env = process.env, from = process.cwd() } = {}) {
30
+ if (tool === 'prettier') return [process.execPath, [binOf('prettier', 'prettier', from), ...args]];
31
+ const cli = npmCli(env);
32
+ // Without npm's script, plain `npm` still starts on Linux (Cloudflare); on Windows the spawn fails and the build stops.
33
+ return cli ? [process.execPath, [cli, ...args]] : ['npm', args];
34
+ }
@@ -0,0 +1,50 @@
1
+ // Tests for scripts/step-command.mjs: npm and Prettier start through Node with no shell, and a path with a space or an &
2
+ // reaches Prettier whole. Run: node --test packages/editor/scripts/*.test.mjs
3
+ import { test } from 'node:test';
4
+ import assert from 'node:assert/strict';
5
+ import { spawnSync } from 'node:child_process';
6
+ import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
7
+ import { tmpdir } from 'node:os';
8
+ import { dirname, join } from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { npmCli, stepCommand } from './step-command.mjs';
11
+
12
+ const here = dirname(fileURLToPath(import.meta.url));
13
+
14
+ test('npm and Prettier run through Node', () => {
15
+ const [npmProgram, npmArgs] = stepCommand('npm', ['--version'], { from: here });
16
+ assert.equal(npmProgram, process.execPath);
17
+ const npm = spawnSync(npmProgram, npmArgs, { encoding: 'utf8' });
18
+ assert.equal(npm.status, 0, npm.stderr);
19
+ assert.match(npm.stdout, /^\d+\.\d+\.\d+/);
20
+
21
+ const [program, args] = stepCommand('prettier', ['--version'], { from: here });
22
+ assert.equal(program, process.execPath);
23
+ const prettier = spawnSync(program, args, { encoding: 'utf8' });
24
+ assert.equal(prettier.status, 0, prettier.stderr);
25
+ assert.match(prettier.stdout, /^\d+\.\d+\.\d+/);
26
+ });
27
+
28
+ test('Prettier gets a path with a space and an & as one argument', () => {
29
+ const dir = mkdtempSync(join(tmpdir(), 'step-command-'));
30
+ try {
31
+ const name = 'a b & c.json';
32
+ writeFileSync(join(dir, name), '{"a":1}'); // not formatted, so --check fails by naming this very file
33
+ const [program, args] = stepCommand('prettier', ['--check', '--ignore-unknown', name], { from: here });
34
+ const result = spawnSync(program, args, { cwd: dir, encoding: 'utf8' });
35
+ assert.equal(result.status, 1);
36
+ assert.match(result.stderr + result.stdout, /a b & c\.json/);
37
+ } finally {
38
+ rmSync(dir, { recursive: true, force: true });
39
+ }
40
+ });
41
+
42
+ test('npmCli follows npm_execpath, also from npx, and is null when there is none', () => {
43
+ const real = npmCli({}, process.execPath);
44
+ assert.ok(real === null || /npm-cli\.c?js$/.test(real));
45
+ if (real) {
46
+ assert.equal(npmCli({ npm_execpath: real.replace(/npm-cli/, 'npx-cli') }, '/nowhere/node'), real);
47
+ assert.equal(npmCli({ npm_execpath: real }, '/nowhere/node'), real);
48
+ }
49
+ assert.equal(npmCli({ npm_execpath: '/nowhere/yarn.js' }, '/nowhere/node'), null);
50
+ });
@@ -0,0 +1,83 @@
1
+ // The check of the editor's English texts (packages/editor/src/i18n/en-US/) against Material 3's rules for writing: no
2
+ // em dash, no "...", no abbreviations, no politeness, no "we", no "!", no "&", American spelling, no internal
3
+ // names (Worker, bucket, jurisdiction, wrangler.jsonc). Exposes RULES, ALLOWED
4
+ // (the exceptions, with reasons) and textProblems; used by check-content.mjs. No dependencies.
5
+
6
+ const word = (pattern, flags = 'i') => new RegExp(`\\b(?:${pattern})\\b`, flags);
7
+
8
+ /** @type {{ id: string, message: string, test: (text: string) => boolean }[]} */
9
+ export const RULES = [
10
+ { id: 'em-dash', message: 'no em dash, use a comma, a colon or a new sentence', test: (s) => /—/.test(s) },
11
+ { id: 'ellipsis', message: 'write the ellipsis as one … with no space before it', test: (s) => /\.\.\.|\s…/.test(s) },
12
+ {
13
+ id: 'abbreviation',
14
+ message: 'no abbreviations (e.g., i.e., etc., vs.), write it out',
15
+ test: (s) => /(?:^|[^\w])(?:e\.g\.|i\.e\.|etc\b|vs\b)/i.test(s),
16
+ },
17
+ { id: 'politeness', message: 'no please, sorry or are you sure', test: (s) => word('please|sorry|are you sure').test(s) },
18
+ {
19
+ // "I" is a word only as a capital, so "i" inside words and "Id" never match.
20
+ id: 'we',
21
+ message: 'no we, our, us or I, the editor does not speak about itself',
22
+ test: (s) => word('we|our|us').test(s) || word('I', '').test(s),
23
+ },
24
+ { id: 'exclamation', message: 'no exclamation marks', test: (s) => /!/.test(s) },
25
+ { id: 'ampersand', message: 'write "and", not &', test: (s) => /&/.test(s) },
26
+ {
27
+ id: 'british',
28
+ message: 'American spelling (color, gray, license, favorite, check, organize, recognize, customize)',
29
+ test: (s) => word('colour\\w*|grey\\w*|licences?|favourite\\w*|tick(?:s|ed|ing)?|organis\\w*|recognis\\w*|customis\\w*').test(s),
30
+ },
31
+ {
32
+ // "Workers Paid" (a Cloudflare plan name) passes: only the singular word Worker is an internal name.
33
+ id: 'internal-name',
34
+ message: 'no internal names (Worker, bucket, jurisdiction, wrangler.jsonc), say what it is for the site owner',
35
+ test: (s) => word('worker|buckets?|jurisdictions?').test(s) || /(?:^|[^a-z0-9_])wrangler[.]jsonc(?![a-z0-9_])/i.test(s),
36
+ },
37
+ ];
38
+
39
+ // Exceptions: key path -> the rule ids it may break. Every entry says why.
40
+ export const ALLOWED = {
41
+ // Sample card text, written in the site owner's own voice.
42
+ 'blocks.cards.example.0.text': ['we'],
43
+ 'blocks.cards.example.1.text': ['we'],
44
+ // Sample message a visitor types into a form.
45
+ 'parts.screens.fieldMessageValue': ['we'],
46
+ };
47
+
48
+ /**
49
+ * @param {object} messages the English texts (enUS)
50
+ * @param {Record<string, string[]>} [allowed] key path -> rule ids it may break
51
+ * @returns {string[]}
52
+ */
53
+ export function textProblems(messages, allowed = ALLOWED) {
54
+ const problems = [];
55
+ const check = (path, text) => {
56
+ for (const rule of RULES) {
57
+ if (rule.test(text) && !allowed[path]?.includes(rule.id)) {
58
+ problems.push(`i18n/en-US › ${path}: ${rule.message} (${JSON.stringify(text)})`);
59
+ }
60
+ }
61
+ };
62
+ const walk = (path, value) => {
63
+ if (typeof value === 'string') return check(path, value);
64
+ if (typeof value === 'function') {
65
+ let result;
66
+ try {
67
+ result = value(...Array.from({ length: value.length }, () => 'x'));
68
+ } catch {
69
+ return;
70
+ }
71
+ if (typeof result === 'string') check(path, result);
72
+ return;
73
+ }
74
+ if (Array.isArray(value)) return value.forEach((item, i) => walk(`${path}.${i}`, item));
75
+ if (value && typeof value === 'object') {
76
+ for (const [key, item] of Object.entries(value)) {
77
+ if (!key.endsWith('Commit')) walk(path ? `${path}.${key}` : key, item);
78
+ }
79
+ }
80
+ };
81
+ walk('', messages);
82
+ return problems;
83
+ }
@@ -0,0 +1,74 @@
1
+ // Tests textProblems (text-problems.mjs): each rule reports its string and passes the right one, nested and array keys
2
+ // report their full path, functions are called, throwing functions and keys ending in Commit are skipped, and an
3
+ // exception hides only its own key and rule.
4
+ import assert from 'node:assert/strict';
5
+ import test from 'node:test';
6
+ import { textProblems } from './text-problems.mjs';
7
+
8
+ const none = {};
9
+
10
+ const CASES = [
11
+ ['em-dash', 'Save — now', 'Save now'],
12
+ ['ellipsis', 'Saving...', 'Saving…'],
13
+ ['ellipsis', 'Saving …', 'Saving…'],
14
+ ['abbreviation', 'E.g. a video', 'For example, a video'],
15
+ ['politeness', 'Please try again', 'Try again'],
16
+ ['we', 'We saved it', 'Status'],
17
+ ['we', 'Back to our page', 'Page ids'],
18
+ ['we', 'Then I wrote it', 'Id and i18n'],
19
+ ['exclamation', 'Done!', 'Done'],
20
+ ['ampersand', 'Text & pictures', 'Text and pictures'],
21
+ ['british', 'Colour', 'Color'],
22
+ ['british', 'Grey boxes', 'Gray boxes'],
23
+ ['internal-name', 'Loading the Worker’s settings', 'Loading settings…'],
24
+ ['internal-name', "Loading the Worker's settings", 'Workers Paid'],
25
+ ['internal-name', 'Open the BUCKET', 'Bucketing the photos'],
26
+ ['internal-name', 'Empty the buckets', 'Open the bucketlist'],
27
+ ['internal-name', 'Pick a jurisdiction', 'Pick a region'],
28
+ ['internal-name', 'Edit wrangler.jsonc', 'Edit the settings file'],
29
+ ];
30
+
31
+ for (const [id, wrong, right] of CASES) {
32
+ test(`${id}: reports ${JSON.stringify(wrong)} and passes ${JSON.stringify(right)}`, () => {
33
+ const problems = textProblems({ k: wrong }, none);
34
+ assert.equal(problems.length, 1);
35
+ assert.ok(problems[0].startsWith('i18n/en-US › k: '));
36
+ assert.ok(problems[0].endsWith(`(${JSON.stringify(wrong)})`));
37
+ assert.deepEqual(textProblems({ k: right }, none), []);
38
+ });
39
+ }
40
+
41
+ test('a nested key reports its full path', () => {
42
+ const [problem] = textProblems({ media: { empty: 'Nothing here!' } }, none);
43
+ assert.match(problem, /^i18n\/en-US › media\.empty: /);
44
+ });
45
+
46
+ test('array items get their index in the path', () => {
47
+ const [problem] = textProblems({ blocks: { cards: { example: [{ text: 'fine' }, { text: 'Done!' }] } } }, none);
48
+ assert.match(problem, /› blocks\.cards\.example\.1\.text: /);
49
+ });
50
+
51
+ test('a function value is checked', () => {
52
+ const problems = textProblems({ backTo: (to) => `Back to ${to}!` }, none);
53
+ assert.equal(problems.length, 1);
54
+ assert.match(problems[0], /› backTo: /);
55
+ });
56
+
57
+ test('a function that throws is skipped', () => {
58
+ const throws = () => {
59
+ throw new Error('no');
60
+ };
61
+ assert.deepEqual(textProblems({ f: throws }, none), []);
62
+ });
63
+
64
+ test('a key ending in Commit is skipped', () => {
65
+ assert.deepEqual(textProblems({ saveCommit: 'Editor: we saved' }, none), []);
66
+ });
67
+
68
+ test('an exception hides its own key and rule only', () => {
69
+ const allowed = { 'a.b': ['we'] };
70
+ assert.deepEqual(textProblems({ a: { b: 'We saved it' } }, allowed), []);
71
+ assert.equal(textProblems({ a: { c: 'We saved it' } }, allowed).length, 1);
72
+ assert.equal(textProblems({ a: { b: 'We saved it!' } }, allowed).length, 1);
73
+ assert.equal(textProblems({ a: { b: 'We saved it' } }, { 'a.b': ['exclamation'] }).length, 1);
74
+ });