@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,33 @@
1
+ // Tests lib/drawer-swipe.mjs: when a drag across the modal drawer closes it.
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 { swipeCloses, SWIPE_DISTANCE } from './drawer-swipe.mjs';
6
+
7
+ test('a swipe toward the left edge closes it', () => {
8
+ assert.equal(swipeCloses(-SWIPE_DISTANCE, 0), true);
9
+ assert.equal(swipeCloses(-120, 10), true);
10
+ });
11
+
12
+ test('a swipe toward the right edge does not, left to right', () => {
13
+ assert.equal(swipeCloses(120, 0), false);
14
+ });
15
+
16
+ test('right to left it is the other way round', () => {
17
+ assert.equal(swipeCloses(120, 0, true), true);
18
+ assert.equal(swipeCloses(-120, 0, true), false);
19
+ });
20
+
21
+ test('a short drag does not close it', () => {
22
+ assert.equal(swipeCloses(-(SWIPE_DISTANCE - 1), 0), false);
23
+ });
24
+
25
+ test('a mostly vertical drag (scrolling the list) does not close it', () => {
26
+ assert.equal(swipeCloses(-60, 80), false);
27
+ assert.equal(swipeCloses(-60, 30), true);
28
+ assert.equal(swipeCloses(-60, 31), false);
29
+ });
30
+
31
+ test('a tap does not close it', () => {
32
+ assert.equal(swipeCloses(0, 0), false);
33
+ });
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The index to focus for a key, or null when the key is not one of ours, there are no items, or the focus is not on one.
3
+ * Up and down stop at the first and the last item.
4
+ * @param {string} key a KeyboardEvent's `key`
5
+ * @param {number} current the index of the focused item
6
+ * @param {number} count how many items there are
7
+ * @returns {number | null}
8
+ */
9
+ export function drawerKeyTarget(key: string, current: number, count: number): number | null;
@@ -0,0 +1,26 @@
1
+ // Where the arrow keys, Home and End move the focus among a navigation drawer's items (theme/ui/navigationDrawerControl.ts
2
+ // does the DOM). Exposes drawerKeyTarget; no dependencies.
3
+
4
+ /**
5
+ * The index to focus for a key, or null when the key is not one of ours, there are no items, or the focus is not on one.
6
+ * Up and down stop at the first and the last item.
7
+ * @param {string} key a KeyboardEvent's `key`
8
+ * @param {number} current the index of the focused item
9
+ * @param {number} count how many items there are
10
+ * @returns {number | null}
11
+ */
12
+ export function drawerKeyTarget(key, current, count) {
13
+ if (count === 0 || current < 0 || current >= count) return null;
14
+ switch (key) {
15
+ case 'ArrowDown':
16
+ return Math.min(current + 1, count - 1);
17
+ case 'ArrowUp':
18
+ return Math.max(current - 1, 0);
19
+ case 'Home':
20
+ return 0;
21
+ case 'End':
22
+ return count - 1;
23
+ default:
24
+ return null;
25
+ }
26
+ }
@@ -0,0 +1,21 @@
1
+ // Tests for lib/drawerKeys.mjs: where the arrow keys, Home and End move the focus in a navigation drawer's items. Run: node --test packages/editor/lib/*.test.mjs
2
+ import { test } from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import { drawerKeyTarget } from './drawerKeys.mjs';
5
+
6
+ test('ArrowDown moves to the next item', () => assert.equal(drawerKeyTarget('ArrowDown', 1, 4), 2));
7
+ test('ArrowUp moves to the previous item', () => assert.equal(drawerKeyTarget('ArrowUp', 2, 4), 1));
8
+ test('ArrowDown stays on the last item', () => assert.equal(drawerKeyTarget('ArrowDown', 3, 4), 3));
9
+ test('ArrowUp stays on the first item', () => assert.equal(drawerKeyTarget('ArrowUp', 0, 4), 0));
10
+ test('Home goes to the first item', () => assert.equal(drawerKeyTarget('Home', 3, 4), 0));
11
+ test('End goes to the last item', () => assert.equal(drawerKeyTarget('End', 0, 4), 3));
12
+ test('other keys are not ours', () => {
13
+ assert.equal(drawerKeyTarget('Enter', 1, 4), null);
14
+ assert.equal(drawerKeyTarget('ArrowLeft', 1, 4), null);
15
+ assert.equal(drawerKeyTarget('Tab', 1, 4), null);
16
+ });
17
+ test('no items, or focus not on an item, gives nothing', () => {
18
+ assert.equal(drawerKeyTarget('ArrowDown', 0, 0), null);
19
+ assert.equal(drawerKeyTarget('ArrowDown', -1, 4), null);
20
+ assert.equal(drawerKeyTarget('End', 4, 4), null);
21
+ });
@@ -0,0 +1,6 @@
1
+ /** The text of a label's line of HTML, or null for any other HTML, and for a label the editor would not write back the
2
+ * same (other HTML or entities in it). @param {string} html @returns {string | null} */
3
+ export function eyebrowText(html: string): string | null;
4
+ /** The class of a label, the same as a block's label (BlockHead.tsx). */
5
+ export const EYEBROW_CLASS: "block-eyebrow";
6
+ export function eyebrowHtml(text: string): string;
@@ -0,0 +1,24 @@
1
+ // A label above a heading in a text ("Etiket", the label button over a text): in the Markdown a line of HTML right
2
+ // before the heading, <p class="block-eyebrow">How it works</p>, which every Markdown reader shows and the editor reads
3
+ // and writes back as it was (src/eyebrow.mjs); it looks like a block's label (.block-eyebrow, blocks.css). Exposes
4
+ // EYEBROW_CLASS, eyebrowHtml and eyebrowText. No dependencies, no browser.
5
+
6
+ /** The class of a label, the same as a block's label (BlockHead.tsx). */
7
+ export const EYEBROW_CLASS = 'block-eyebrow';
8
+
9
+ const escape = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
10
+ const unescape = (s) => s.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
11
+ const LINE = new RegExp(`^<p class="${EYEBROW_CLASS}">([^<]*)</p>$`);
12
+
13
+ /** The line of HTML of a label with this text. @param {string} text */
14
+ export const eyebrowHtml = (text) => `<p class="${EYEBROW_CLASS}">${escape(String(text))}</p>`;
15
+
16
+ /** The text of a label's line of HTML, or null for any other HTML, and for a label the editor would not write back the
17
+ * same (other HTML or entities in it). @param {string} html @returns {string | null} */
18
+ export function eyebrowText(html) {
19
+ const line = String(html).trim();
20
+ const found = line.match(LINE);
21
+ if (!found) return null;
22
+ const text = unescape(found[1]);
23
+ return eyebrowHtml(text) === line ? text : null;
24
+ }
@@ -0,0 +1,20 @@
1
+ // Tests for lib/eyebrow.mjs: a label's line of HTML, written and read back as it was. 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 { EYEBROW_CLASS, eyebrowHtml, eyebrowText } from './eyebrow.mjs';
5
+
6
+ test('the class is a block label', () => assert.equal(EYEBROW_CLASS, 'block-eyebrow'));
7
+ test('writes the line', () => assert.equal(eyebrowHtml('How it works'), '<p class="block-eyebrow">How it works</p>'));
8
+ test('escapes &, < and >', () => assert.equal(eyebrowHtml('A & <B>'), '<p class="block-eyebrow">A &amp; &lt;B&gt;</p>'));
9
+ test('reads the line', () => assert.equal(eyebrowText('<p class="block-eyebrow">How it works</p>'), 'How it works'));
10
+ test('reads it with space around', () => assert.equal(eyebrowText('<p class="block-eyebrow">Hi</p>\n'), 'Hi'));
11
+ test('reads escaped text', () => assert.equal(eyebrowText('<p class="block-eyebrow">A &amp; &lt;B&gt;</p>'), 'A & <B>'));
12
+ test('round trip', () =>
13
+ assert.equal(eyebrowHtml(eyebrowText('<p class="block-eyebrow">Fast &amp; safe</p>')), '<p class="block-eyebrow">Fast &amp; safe</p>'));
14
+ test('an empty label', () => assert.equal(eyebrowText('<p class="block-eyebrow"></p>'), ''));
15
+ test('not another class', () => assert.equal(eyebrowText('<p class="intro">Hi</p>'), null));
16
+ test('not HTML inside it', () => assert.equal(eyebrowText('<p class="block-eyebrow">a <b>b</b></p>'), null));
17
+ test('not HTML it would write back otherwise', () => {
18
+ assert.equal(eyebrowText('<p class="block-eyebrow">AT&T</p>'), null);
19
+ assert.equal(eyebrowText('<p class="block-eyebrow">&quot;Hi&quot;</p>'), null);
20
+ });
@@ -0,0 +1,82 @@
1
+ // Tests the front page's reading of section colours (src/sections.mjs): the marker into the H2's part, and the colour into the sections.
2
+ // Run: node --test packages/editor/lib/front-sections.test.mjs
3
+ import { test } from 'node:test';
4
+ import assert from 'node:assert/strict';
5
+ import { blockHtml } from './blocks.mjs';
6
+ import { partsOfMarkdown, sectionsFromParts } from '../src/sections.mjs';
7
+
8
+ const tint = '<div data-section="tint"></div>';
9
+ const accent = '<div data-section="accent"></div>';
10
+ const lines = (...l) => l.join('\n\n');
11
+
12
+ test('a marker before an H2 is the colour of its part, and is not text', () => {
13
+ const parts = partsOfMarkdown(lines('# Title', tint, '## Hours', 'Open daily.'));
14
+ assert.deepEqual(parts, [{ heading: 'Hours', color: 'tint' }, { markdown: 'Open daily.' }]);
15
+ });
16
+
17
+ test('a heading without a marker has no color key', () => {
18
+ assert.deepEqual(partsOfMarkdown('## Hours'), [{ heading: 'Hours' }]);
19
+ });
20
+
21
+ test('a marker not before an H2, or with an unknown colour, is dropped and not shown', () => {
22
+ assert.deepEqual(partsOfMarkdown(lines(tint, 'Just text', '## Later')), [{ markdown: 'Just text' }, { heading: 'Later' }]);
23
+ assert.deepEqual(partsOfMarkdown(lines('<div data-section="pink"></div>', '## X')), [{ heading: 'X' }]);
24
+ assert.deepEqual(
25
+ partsOfMarkdown(lines(tint, blockHtml('steps', {}), '## Y')).filter((p) => 'heading' in p),
26
+ [{ heading: 'Y' }],
27
+ );
28
+ });
29
+
30
+ test('a marker in a code block stays text', () => {
31
+ const code = '```\n' + tint + '\n```';
32
+ assert.deepEqual(partsOfMarkdown(code), [{ markdown: code }]);
33
+ });
34
+
35
+ test('a label line may stand between the marker and the H2', () => {
36
+ const label = '<p class="block-eyebrow">New</p>';
37
+ assert.deepEqual(partsOfMarkdown(lines(tint, label, '## T')), [{ markdown: label }, { heading: 'T', color: 'tint' }]);
38
+ });
39
+
40
+ test('colors: a text section and a block with a lead take the colour of their H2', () => {
41
+ const parts = partsOfMarkdown(
42
+ lines(tint, '## About', 'About text.', '## Plain', 'Plain text.', accent, '## Steps', blockHtml('steps', {})),
43
+ );
44
+ const sections = sectionsFromParts(parts, { steps: { heading: 'heading' } }, { colors: true });
45
+ assert.deepEqual(
46
+ sections.map((s) => [s.type, s.heading, s.sectionColor]),
47
+ [
48
+ ['text', 'About', 'tint'],
49
+ ['text', 'Plain', undefined],
50
+ ['steps', 'Steps', 'accent'],
51
+ ],
52
+ );
53
+ });
54
+
55
+ test('colors: lead-less blocks and text without a heading inherit it up to the next H2', () => {
56
+ const parts = partsOfMarkdown(lines(tint, '## Hours', 'Text.', blockHtml('book', {}), 'More.', '## Plain', 'End.'));
57
+ const sections = sectionsFromParts(parts, {}, { colors: true });
58
+ assert.deepEqual(
59
+ sections.map((s) => [s.type, s.sectionColor]),
60
+ [
61
+ ['text', 'tint'],
62
+ ['book', 'tint'],
63
+ ['text', 'tint'],
64
+ ['text', undefined],
65
+ ],
66
+ );
67
+ });
68
+
69
+ test('colors: the top (hero) has none, and inheritance restarts after it', () => {
70
+ const parts = partsOfMarkdown(lines(tint, '## Welcome', 'Intro.', blockHtml('hero', {}), blockHtml('book', {})));
71
+ const sections = sectionsFromParts(parts, { hero: { body: 'text', upTo: 'buttons' } }, { colors: true });
72
+ assert.deepEqual(
73
+ sections.map((s) => s.type),
74
+ ['hero', 'book'],
75
+ );
76
+ assert.ok(sections.every((s) => s.sectionColor === undefined));
77
+ });
78
+
79
+ test('without the option no section has a colour (fixed pages, the blog page)', () => {
80
+ const parts = partsOfMarkdown(lines(tint, '## About', 'About text.'));
81
+ assert.ok(!JSON.stringify(sectionsFromParts(parts, {})).includes('sectionColor'));
82
+ });
@@ -0,0 +1,7 @@
1
+ /**
2
+ * The levels with the first set to 1 and every later 1 set to 2 (the others as they are).
3
+ * @param {number[]} levels The levels (1 to 6) of the headings of a text, in order.
4
+ * @returns {number[]}
5
+ */
6
+ export function headingLevels(levels: number[]): number[];
7
+ export function showsH1Choice(levels: number[]): boolean;
@@ -0,0 +1,16 @@
1
+ // The levels of a page's headings as the editor keeps them: the first heading is the page's H1, and no later one is.
2
+ // Exposes headingLevels and showsH1Choice (pure); the editor's plugin that applies it is src/firstHeading.mjs. No dependencies.
3
+
4
+ /**
5
+ * The levels with the first set to 1 and every later 1 set to 2 (the others as they are).
6
+ * @param {number[]} levels The levels (1 to 6) of the headings of a text, in order.
7
+ * @returns {number[]}
8
+ */
9
+ export function headingLevels(levels) {
10
+ return levels.map((level, i) => (i === 0 ? 1 : level === 1 ? 2 : level));
11
+ }
12
+
13
+ /** Whether the editor offers the H1 choice: only while the text has no H1 (a page has one H1, its first heading).
14
+ * @param {number[]} levels The levels (1 to 6) of the headings of a text, in order.
15
+ * @returns {boolean} */
16
+ export const showsH1Choice = (levels) => !levels.includes(1);
@@ -0,0 +1,37 @@
1
+ // Tests lib/heading-levels.mjs: on a page the first heading is the H1 and no later one 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 { headingLevels, showsH1Choice } from './heading-levels.mjs';
6
+
7
+ test('no headings, no levels', () => {
8
+ assert.deepEqual(headingLevels([]), []);
9
+ });
10
+
11
+ test('the first heading becomes 1, whatever it was', () => {
12
+ assert.deepEqual(headingLevels([2]), [1]);
13
+ assert.deepEqual(headingLevels([3, 2]), [1, 2]);
14
+ assert.deepEqual(headingLevels([1]), [1]);
15
+ });
16
+
17
+ test('every later 1 becomes 2', () => {
18
+ assert.deepEqual(headingLevels([1, 1, 2, 1]), [1, 2, 2, 2]);
19
+ assert.deepEqual(headingLevels([2, 1]), [1, 2]);
20
+ });
21
+
22
+ test('later 2s and 3s stay as they are', () => {
23
+ assert.deepEqual(headingLevels([1, 2, 3, 2, 3]), [1, 2, 3, 2, 3]);
24
+ });
25
+
26
+ test('the list given is not changed', () => {
27
+ const given = [2, 1, 3];
28
+ headingLevels(given);
29
+ assert.deepEqual(given, [2, 1, 3]);
30
+ });
31
+
32
+ test('the H1 choice is shown only while no heading is an H1', () => {
33
+ assert.equal(showsH1Choice([]), true);
34
+ assert.equal(showsH1Choice([2, 3]), true);
35
+ assert.equal(showsH1Choice([1]), false);
36
+ assert.equal(showsH1Choice([2, 1, 3]), false);
37
+ });
package/lib/inline.ts CHANGED
@@ -4,17 +4,18 @@
4
4
  // anything that cannot stand inside a <p> (a list, a picture) is shown as its text.
5
5
  import { createMarkdownProcessor } from '@astrojs/markdown-remark';
6
6
  import { inlineOf } from './markdown';
7
+ import { rehypeEmphasize } from './rehype-emphasize.mjs';
7
8
 
8
9
  let processor: ReturnType<typeof createMarkdownProcessor> | undefined;
9
10
 
10
11
  export async function inlineHtml(markdown: string): Promise<string> {
11
- processor ??= createMarkdownProcessor();
12
+ processor ??= createMarkdownProcessor({ rehypePlugins: [rehypeEmphasize] });
12
13
  return inlineOf((await (await processor).render(markdown)).code.trim());
13
14
  }
14
15
 
15
16
  /** A longer text the site's owner writes in the editor (an answer in "Spørgsmål og svar") as HTML: paragraphs,
16
17
  * lists, bold, italic and links. */
17
18
  export async function markdownHtml(markdown: string): Promise<string> {
18
- processor ??= createMarkdownProcessor();
19
+ processor ??= createMarkdownProcessor({ rehypePlugins: [rehypeEmphasize] });
19
20
  return (await (await processor).render(markdown)).code.trim();
20
21
  }
package/lib/markdown.ts CHANGED
@@ -9,6 +9,7 @@ import remarkSmartypants from 'remark-smartypants';
9
9
  import remarkRehype from 'remark-rehype';
10
10
  import rehypeRaw from 'rehype-raw';
11
11
  import rehypeStringify from 'rehype-stringify';
12
+ import { rehypeEmphasize } from './rehype-emphasize.mjs';
12
13
 
13
14
  /** The pipeline, also for a whole page's text (pagePreview.tsx). */
14
15
  export const processor = unified()
@@ -17,6 +18,7 @@ export const processor = unified()
17
18
  .use(remarkSmartypants)
18
19
  .use(remarkRehype, { allowDangerousHtml: true })
19
20
  .use(rehypeRaw)
21
+ .use(rehypeEmphasize)
20
22
  .use(rehypeStringify, { allowDangerousHtml: true });
21
23
 
22
24
  /** A short text's HTML made to stand inside a <p> of its own: its paragraphs separated by a line break, and anything
@@ -0,0 +1,21 @@
1
+ /** A name as Material Symbols writes it (`drag_indicator`): lower case letters, digits and `_`, a hyphen read as an
2
+ * underscore (`drag-indicator`). undefined for a name that cannot be a Material file (so it never reaches a path).
3
+ * @param {string} name @returns {string | undefined} */
4
+ export function materialName(name: string): string | undefined;
5
+ /** What an icon asked for is drawn from, in order: the list's file (for `fill`: the list's filled one, else its regular
6
+ * one), else Material's (for `fill`: Material's filled one, else its regular one), else nothing (MISSING).
7
+ * `material` is undefined while Material's set is not loaded yet: `pending` when the list has none.
8
+ * @param {string} name
9
+ * @param {'fill' | undefined} weight
10
+ * @param {{ list: (name: string, weight?: 'fill') => string | undefined, material: ((key: string) => string | undefined) | undefined }} lookup
11
+ * `material` is asked for `<name>` and `<name>-fill` (a Material name, see materialName).
12
+ * @returns {{ file?: string, fallback?: string, pending?: boolean }}
13
+ * `fallback`: the name asked for, when the file is Material's. */
14
+ export function resolveIcon(name: string, weight: "fill" | undefined, { list, material }: {
15
+ list: (name: string, weight?: "fill") => string | undefined;
16
+ material: ((key: string) => string | undefined) | undefined;
17
+ }): {
18
+ file?: string;
19
+ fallback?: string;
20
+ pending?: boolean;
21
+ };
@@ -0,0 +1,29 @@
1
+ // How an icon name that is not on the site's list is found in Material Symbols (Outlined, `@material-symbols/svg-400`):
2
+ // the name as Material writes it, and the order an icon is looked for in (the list, then Material, then MISSING).
3
+ // Pure functions, so they are tested (materialIcon.test.mjs); the files themselves are looked up by the caller (icon.tsx).
4
+
5
+ /** A name as Material Symbols writes it (`drag_indicator`): lower case letters, digits and `_`, a hyphen read as an
6
+ * underscore (`drag-indicator`). undefined for a name that cannot be a Material file (so it never reaches a path).
7
+ * @param {string} name @returns {string | undefined} */
8
+ export function materialName(name) {
9
+ return /^[a-z0-9_-]+$/.test(name) ? name.replaceAll('-', '_') : undefined;
10
+ }
11
+
12
+ /** What an icon asked for is drawn from, in order: the list's file (for `fill`: the list's filled one, else its regular
13
+ * one), else Material's (for `fill`: Material's filled one, else its regular one), else nothing (MISSING).
14
+ * `material` is undefined while Material's set is not loaded yet: `pending` when the list has none.
15
+ * @param {string} name
16
+ * @param {'fill' | undefined} weight
17
+ * @param {{ list: (name: string, weight?: 'fill') => string | undefined, material: ((key: string) => string | undefined) | undefined }} lookup
18
+ * `material` is asked for `<name>` and `<name>-fill` (a Material name, see materialName).
19
+ * @returns {{ file?: string, fallback?: string, pending?: boolean }}
20
+ * `fallback`: the name asked for, when the file is Material's. */
21
+ export function resolveIcon(name, weight, { list, material }) {
22
+ const own = (weight ? list(name, weight) : undefined) ?? list(name);
23
+ if (own) return { file: own };
24
+ const key = materialName(name);
25
+ if (!key) return {};
26
+ if (!material) return { pending: true };
27
+ const file = (weight ? material(`${key}-fill`) : undefined) ?? material(key);
28
+ return file ? { file, fallback: name } : {};
29
+ }
@@ -0,0 +1,45 @@
1
+ // Tests for lib/materialIcon.mjs: a Material Symbols name, and the order an icon is looked for in. Run: node --test packages/editor/lib/*.test.mjs
2
+ import { test } from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import { materialName, resolveIcon } from './materialIcon.mjs';
5
+
6
+ const listFiles = { check: 'list-check', 'heart-fill': 'list-heart-fill', heart: 'list-heart' };
7
+ const list = (name, weight) => listFiles[weight ? `${name}-${weight}` : name];
8
+ const materialFiles = {
9
+ check: 'material-check',
10
+ drag_indicator: 'material-drag',
11
+ 'drag_indicator-fill': 'material-drag-fill',
12
+ star: 'material-star',
13
+ };
14
+ const material = (key) => materialFiles[key];
15
+
16
+ test('a hyphen is read as an underscore, a name that is no file name is nothing', () => {
17
+ assert.equal(materialName('drag-indicator'), 'drag_indicator');
18
+ assert.equal(materialName('drag_indicator'), 'drag_indicator');
19
+ assert.equal(materialName('10k'), '10k');
20
+ assert.equal(materialName('../x'), undefined);
21
+ assert.equal(materialName('Star'), undefined);
22
+ assert.equal(materialName(''), undefined);
23
+ });
24
+
25
+ test('the list wins over Material for a name both have', () => {
26
+ assert.deepEqual(resolveIcon('check', undefined, { list, material }), { file: 'list-check' });
27
+ assert.deepEqual(resolveIcon('check', 'fill', { list, material }), { file: 'list-check' });
28
+ });
29
+
30
+ test('fill: the list filled, then the list regular, then Material filled, then Material regular', () => {
31
+ assert.deepEqual(resolveIcon('heart', 'fill', { list, material }), { file: 'list-heart-fill' });
32
+ assert.deepEqual(resolveIcon('drag-indicator', 'fill', { list, material }), { file: 'material-drag-fill', fallback: 'drag-indicator' });
33
+ assert.deepEqual(resolveIcon('star', 'fill', { list, material }), { file: 'material-star', fallback: 'star' });
34
+ });
35
+
36
+ test('regular: a name only Material has is Material (hyphens as underscores), marked with the name asked for', () => {
37
+ assert.deepEqual(resolveIcon('drag-indicator', undefined, { list, material }), { file: 'material-drag', fallback: 'drag-indicator' });
38
+ });
39
+
40
+ test('a name neither has is missing; before Material is loaded it is pending', () => {
41
+ assert.deepEqual(resolveIcon('nope', undefined, { list, material }), {});
42
+ assert.deepEqual(resolveIcon('Nope!', 'fill', { list, material }), {});
43
+ assert.deepEqual(resolveIcon('star', undefined, { list, material: undefined }), { pending: true });
44
+ assert.deepEqual(resolveIcon('check', undefined, { list, material: undefined }), { file: 'list-check' });
45
+ });
@@ -0,0 +1,11 @@
1
+ /** @typedef {import('./breakpoints.mjs').Breakpoint} Breakpoint */
2
+ /** The main menu's layout in a window in breakpoint `width`: compact has no rail (modal drawer, menu button in the top bar);
3
+ * medium has the rail and expands it as a modal drawer over the content; expanded and wider expand it beside the content.
4
+ * @param {Breakpoint} width
5
+ * @returns {{ rail: boolean, expanded: 'modal' | 'standard', menuButtonInTopBar: boolean }} */
6
+ export function menuLayout(width: Breakpoint): {
7
+ rail: boolean;
8
+ expanded: "modal" | "standard";
9
+ menuButtonInTopBar: boolean;
10
+ };
11
+ export type Breakpoint = import("./breakpoints.mjs").Breakpoint;
@@ -0,0 +1,14 @@
1
+ // How the editor's main menu is laid out at a window width (Material 3 breakpoint): whether the full-height navigation rail
2
+ // stands, which way the rail expands, and whether the top bar keeps the menu button. Exposes menuLayout (pure). No dependencies.
3
+
4
+ /** @typedef {import('./breakpoints.mjs').Breakpoint} Breakpoint */
5
+
6
+ /** The main menu's layout in a window in breakpoint `width`: compact has no rail (modal drawer, menu button in the top bar);
7
+ * medium has the rail and expands it as a modal drawer over the content; expanded and wider expand it beside the content.
8
+ * @param {Breakpoint} width
9
+ * @returns {{ rail: boolean, expanded: 'modal' | 'standard', menuButtonInTopBar: boolean }} */
10
+ export function menuLayout(width) {
11
+ if (width === 'compact') return { rail: false, expanded: 'modal', menuButtonInTopBar: true };
12
+ if (width === 'medium') return { rail: true, expanded: 'modal', menuButtonInTopBar: false };
13
+ return { rail: true, expanded: 'standard', menuButtonInTopBar: false };
14
+ }
@@ -0,0 +1,23 @@
1
+ // Tests lib/menu-layout.mjs (the main menu's layout per breakpoint). Run with `node --test packages/editor/lib/*.test.mjs`.
2
+ import { test } from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import { BREAKPOINTS } from './breakpoints.mjs';
5
+ import { menuLayout } from './menu-layout.mjs';
6
+
7
+ test('compact: no rail, modal drawer, menu button in the top bar', () => {
8
+ assert.deepEqual(menuLayout('compact'), { rail: false, expanded: 'modal', menuButtonInTopBar: true });
9
+ });
10
+
11
+ test('medium: rail that expands as a modal drawer, no menu button in the top bar', () => {
12
+ assert.deepEqual(menuLayout('medium'), { rail: true, expanded: 'modal', menuButtonInTopBar: false });
13
+ });
14
+
15
+ test('expanded and wider: rail that expands beside the content', () => {
16
+ for (const width of ['expanded', 'large', 'extraLarge']) {
17
+ assert.deepEqual(menuLayout(width), { rail: true, expanded: 'standard', menuButtonInTopBar: false }, width);
18
+ }
19
+ });
20
+
21
+ test('every breakpoint has a layout', () => {
22
+ for (const width of Object.keys(BREAKPOINTS)) assert.ok(menuLayout(width), width);
23
+ });
package/lib/menu.d.mts ADDED
@@ -0,0 +1,59 @@
1
+ /**
2
+ * @param {{ id: string, title: string, href?: string, ids: string[] }[]} groups
3
+ * @param {(id: string) => { label: string, href: string, count: number | null }} partOf
4
+ * @param {(id: string) => { id: string, label: string, href: string }[]} childrenOf
5
+ * @returns {{ id: string, label: string, href?: string, items: { id: string, label: string, href: string, count: number | null, children?: { id: string, label: string, href: string }[] }[] }[]}
6
+ */
7
+ export function menuTree(groups: {
8
+ id: string;
9
+ title: string;
10
+ href?: string;
11
+ ids: string[];
12
+ }[], partOf: (id: string) => {
13
+ label: string;
14
+ href: string;
15
+ count: number | null;
16
+ }, childrenOf: (id: string) => {
17
+ id: string;
18
+ label: string;
19
+ href: string;
20
+ }[]): {
21
+ id: string;
22
+ label: string;
23
+ href?: string;
24
+ items: {
25
+ id: string;
26
+ label: string;
27
+ href: string;
28
+ count: number | null;
29
+ children?: {
30
+ id: string;
31
+ label: string;
32
+ href: string;
33
+ }[];
34
+ }[];
35
+ }[];
36
+ /**
37
+ * @param {string} route
38
+ * @param {{ open?: string, lists: string[], header: string[], partScreens: string[], cookies: string, store: string }} known
39
+ * @returns {string}
40
+ */
41
+ export function currentPart(route: string, known: {
42
+ open?: string;
43
+ lists: string[];
44
+ header: string[];
45
+ partScreens: string[];
46
+ cookies: string;
47
+ store: string;
48
+ }): string;
49
+ /**
50
+ * @param {string} route
51
+ * @param {ReturnType<typeof menuTree>} tree
52
+ * @param {Parameters<typeof currentPart>[1]} known
53
+ * @returns {{ section: string | null, item?: string, child?: string }}
54
+ */
55
+ export function currentPlace(route: string, tree: ReturnType<typeof menuTree>, known: Parameters<typeof currentPart>[1]): {
56
+ section: string | null;
57
+ item?: string;
58
+ child?: string;
59
+ };