@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
package/src/sections.mjs CHANGED
@@ -15,9 +15,11 @@
15
15
  // The front page is an ordinary page (packages/editor/defaults/standardPages.ts): its text is read into sections the same way, by the editor
16
16
  // and by the site (partsOfMarkdown, packages/editor/site/home.ts).
17
17
  // Shared by the editor and packages/editor/scripts/editor-roundtrip.mjs, which checks that every page comes back the same.
18
+ // Depends on lib/blocks.mjs, lib/crop.mjs, lib/text-colors.mjs and the section colour marker (lib/section-marker.mjs).
18
19
  import { blockHtml, cutAtBlock, parseBlock } from '../lib/blocks.mjs';
19
20
  import { decorated, decorativeOf } from '../lib/crop.mjs';
20
21
  import { coloredHtml, withoutColors } from '../lib/text-colors.mjs';
22
+ import { SECTION_COLORS, isLabelLine } from '../lib/section-marker.mjs';
21
23
 
22
24
  // Text that is plain in the file (a heading, a picture's description); in Markdown every sign that
23
25
  // could mean something is escaped, and back from Markdown the escapes go again. The page's
@@ -96,16 +98,18 @@ function hero(lead, props) {
96
98
 
97
99
  // A section with a lead from the text before its card (`run`): what is left of the run first, as
98
100
  // sections of text, then the section with its picture, heading and text.
99
- function withLead(run, block, lead) {
101
+ function withLead(run, block, lead, blockColor) {
100
102
  let rest = [...run];
101
103
  let heading;
102
104
  let body = '';
105
+ let color = blockColor;
103
106
  if (lead.heading) {
104
107
  const at = rest.findLastIndex((p) => 'heading' in p);
105
108
  // A section with only a heading has no place for text: with text after it, the heading and the
106
109
  // text stay a section of text (as they look in the editor), and the section has no heading.
107
110
  if (at >= 0 && (lead.body || at === rest.length - 1)) {
108
111
  heading = rest[at].heading;
112
+ color = rest[at].sectionColor;
109
113
  body = rest
110
114
  .slice(at + 1)
111
115
  .map((p) => p.markdown)
@@ -135,26 +139,39 @@ function withLead(run, block, lead) {
135
139
  ...(lead.heading ? { [lead.heading]: heading ?? '' } : {}),
136
140
  ...(lead.body && paragraphs.length ? { [lead.body]: lead.list ? paragraphs : paragraphs.join('\n\n') } : {}),
137
141
  };
138
- return [...textSections(rest), { type: block.type, ...own, ...block.props }];
142
+ return [...textSections(rest), { type: block.type, ...own, ...block.props, ...colorOf(color) }];
139
143
  }
140
144
 
141
- /** The sections for the parts of the text (partsOf in markdown.mjs); leads as for sectionsToMarkdown. */
142
- export function sectionsFromParts(parts, leads = {}) {
143
- const first = parts.findIndex((p) => p.block);
144
- if (first >= 0 && parts[first].block.type === 'hero' && leads.hero)
145
- return [hero(parts.slice(0, first), parts[first].block.props), ...sectionsFromParts(parts.slice(first + 1), leads)];
145
+ const colorOf = (color) => (color ? { sectionColor: color } : {});
146
+
147
+ // Each part with the colour of the H2 it is under (`sectionColor`): a heading starts it, a heading without a colour ends it.
148
+ function withColors(parts) {
149
+ let current = null;
150
+ return parts.map((part) => {
151
+ if ('heading' in part) current = part.color ?? null;
152
+ return current ? { ...part, sectionColor: current } : part;
153
+ });
154
+ }
155
+
156
+ /** The sections for the parts of the text (partsOf in markdown.mjs); leads as for sectionsToMarkdown. `colors`: the front
157
+ * page: each section also has the colour of its H2 as `sectionColor` (the top, the "Banner", has none). */
158
+ export function sectionsFromParts(parts, leads = {}, options = {}) {
159
+ const all = options.colors ? withColors(parts) : parts;
160
+ const first = all.findIndex((p) => p.block);
161
+ if (first >= 0 && all[first].block.type === 'hero' && leads.hero)
162
+ return [hero(parts.slice(0, first), all[first].block.props), ...sectionsFromParts(parts.slice(first + 1), leads, options)];
146
163
  const sections = [];
147
164
  // The text since the last card, for the next card (its lead) or as sections of text.
148
165
  let run = [];
149
- for (const part of parts) {
166
+ for (const part of all) {
150
167
  if (!part.block) {
151
168
  run.push(part);
152
169
  continue;
153
170
  }
154
171
  // A "Hero" further down is a card with all of its fields (only the top's are written above it).
155
172
  const lead = part.block.type === 'hero' ? undefined : leads[part.block.type];
156
- if (lead) sections.push(...withLead(run, part.block, lead));
157
- else sections.push(...textSections(run), { type: part.block.type, ...part.block.props });
173
+ if (lead) sections.push(...withLead(run, part.block, lead, part.sectionColor));
174
+ else sections.push(...textSections(run), { type: part.block.type, ...part.block.props, ...colorOf(part.sectionColor) });
158
175
  run = [];
159
176
  }
160
177
  return [...sections, ...textSections(run)];
@@ -167,12 +184,12 @@ function textSections(parts) {
167
184
  let text = null;
168
185
  for (const part of parts) {
169
186
  if ('heading' in part) {
170
- text = part.heading ? { type: 'text', heading: part.heading, body: '' } : { type: 'text', body: '' };
187
+ text = { type: 'text', ...(part.heading ? { heading: part.heading } : {}), body: '', ...colorOf(part.sectionColor) };
171
188
  sections.push(text);
172
189
  } else if (text) {
173
190
  text.body = part.markdown;
174
191
  text = null;
175
- } else sections.push({ type: 'text', body: part.markdown });
192
+ } else sections.push({ type: 'text', body: part.markdown, ...colorOf(part.sectionColor) });
176
193
  }
177
194
  return sections.filter((s) => s.heading || s.body);
178
195
  }
@@ -238,31 +255,50 @@ export const plainHeading = (s) =>
238
255
  const atx = (level) => new RegExp(`^ {0,3}#{${level}}(?!#)(?:[ \t]+(.*?))?(?:[ \t]+#+)?[ \t]*$`);
239
256
  const h1Line = atx(1);
240
257
 
241
- /** A page's H1: the heading "# …" on the first line of its text that is not empty (the page's own heading, written
242
- * at the top of its text), as text, and the text without it. Without one: no heading, and the text as it is. With a
243
- * word or more in colour, `html` is the heading as HTML with its colours (coloredHtml), for the layout to draw. */
258
+ /** A page's H1: the first heading "# …" of its text outside fenced code, wherever it stands (the editor makes the first
259
+ * heading of a page its H1, src/firstHeading.mjs, but text may stand above it), as text, and the text without that line
260
+ * (what stood above it first, a blank line, then what stood below). Without one: no heading, and the text as it is.
261
+ * With a word or more in colour, `html` is the heading as HTML with its colours (coloredHtml), for the layout to draw. */
244
262
  export function pageH1(markdown) {
245
263
  const text = String(markdown ?? '');
246
- const match = text.match(/^(?:[ \t]*\r?\n)*([^\r\n]*)(?:\r?\n|$)/);
247
- const line = match?.[1] ?? '';
248
- const h1 = line.match(h1Line);
249
- if (!h1) return { heading: undefined, rest: text };
250
- const heading = plainHeading(h1[1] ?? '') || undefined;
251
- return {
252
- heading,
253
- html: heading && coloredHtml(h1[1] ?? '', plainHeading),
254
- rest: text.slice(match[0].length).replace(/^(?:[ \t]*\r?\n)+/, ''),
255
- };
264
+ const lines = text.split(/(?<=\n)/);
265
+ const bare = (line) => line.replace(/\r?\n$/, '');
266
+ let fence = null;
267
+ let at = -1;
268
+ for (let i = 0; i < lines.length && at < 0; i++) {
269
+ const line = bare(lines[i]);
270
+ const mark = line.match(/^ {0,3}(`{3,}|~{3,})/)?.[1];
271
+ if (fence) {
272
+ if (mark && mark[0] === fence[0] && mark.length >= fence.length) fence = null;
273
+ } else if (mark) fence = mark;
274
+ else if (h1Line.test(line)) at = i;
275
+ }
276
+ if (at < 0) return { heading: undefined, rest: text };
277
+ const h1 = bare(lines[at]).match(h1Line);
278
+ const heading = plainHeading(h1?.[1] ?? '') || undefined;
279
+ const before = lines.slice(0, at).join('');
280
+ const after = lines
281
+ .slice(at + 1)
282
+ .join('')
283
+ .replace(/^(?:[ \t]*\r?\n)+/, '');
284
+ const eol = text.includes('\r\n') ? '\r\n' : '\n';
285
+ const rest = !before.trim() ? after : after ? `${before.trimEnd()}${eol}${eol}${after}` : before.trimEnd();
286
+ return { heading, html: heading && coloredHtml(h1?.[1] ?? '', plainHeading), rest };
256
287
  }
257
288
 
289
+ // A section's colour marker on its own line (lib/section-marker.mjs); any colour name, so an unknown one is dropped too.
290
+ const MARKER_LINE = /^ {0,3}<div data-section="([^"]*)"><\/div>[ \t]*$/;
291
+
258
292
  /** The parts of a page's Markdown as the editor reads them (partsOf in markdown.mjs), without the editor: each block,
259
293
  * each heading at the top level (H2), and the Markdown between them; for the site, which reads the front page into
260
294
  * sections (packages/editor/site/home.ts). The page's H1 ("# …") is no section's and is left out. `npm run check:editor` checks
261
- * that it gives the editor's sections for every page. */
295
+ * that it gives the editor's sections for every page. A colour marker before an H2 is its part's `color`. */
262
296
  export function partsOfMarkdown(markdown) {
263
297
  const parts = [];
264
298
  let run = [];
265
299
  let fence = null;
300
+ // The colour of a marker line, kept for the H2 it stands before (blank lines and a label line may come between).
301
+ let pending = null;
266
302
  const flush = () => {
267
303
  const text = run.join('\n').trim();
268
304
  if (text) parts.push({ markdown: text });
@@ -277,25 +313,36 @@ export function partsOfMarkdown(markdown) {
277
313
  }
278
314
  if (mark) {
279
315
  fence = mark;
316
+ pending = null;
280
317
  run.push(line);
281
318
  continue;
282
319
  }
320
+ // The marker is never text: it is the colour of the H2 after it, or dropped (unknown colour, or no H2 after it).
321
+ const marker = line.match(MARKER_LINE);
322
+ if (marker) {
323
+ pending = SECTION_COLORS.includes(marker[1]) ? marker[1] : null;
324
+ continue;
325
+ }
283
326
  const block = parseBlock(line);
284
327
  if (block) {
285
328
  flush();
329
+ pending = null;
286
330
  parts.push({ block });
287
331
  continue;
288
332
  }
289
333
  if (h1Line.test(line)) {
290
334
  flush();
335
+ pending = null;
291
336
  continue;
292
337
  }
293
338
  const heading = line.match(atx(2));
294
339
  if (heading) {
295
340
  flush();
296
- parts.push({ heading: plainHeading(heading[1] ?? '') });
341
+ parts.push({ heading: plainHeading(heading[1] ?? ''), ...(pending ? { color: pending } : {}) });
342
+ pending = null;
297
343
  continue;
298
344
  }
345
+ if (line.trim() && !isLabelLine(line)) pending = null;
299
346
  run.push(line);
300
347
  }
301
348
  flush();
@@ -0,0 +1,59 @@
1
+ // Tests pageH1 in sections.mjs: a page's H1 is the first "# …" line of its text outside code, wherever it is.
2
+ // Run with `node --test packages/editor/src/*.test.mjs` (part of npm run check).
3
+ import { test } from 'node:test';
4
+ import assert from 'node:assert/strict';
5
+ import { pageH1 } from './sections.mjs';
6
+
7
+ test('an H1 on the first line, as before', () => {
8
+ assert.deepEqual(pageH1('# Title\n\nText'), { heading: 'Title', html: undefined, rest: 'Text' });
9
+ });
10
+
11
+ test('blank lines above the H1 are skipped', () => {
12
+ const found = pageH1('\n \n# Title\n\n\nText');
13
+ assert.equal(found.heading, 'Title');
14
+ assert.equal(found.rest, 'Text');
15
+ });
16
+
17
+ test('an H1 after some text: the text stays, without the H1', () => {
18
+ const found = pageH1('Intro\n\n# Title\n\nMore\n\n## Section');
19
+ assert.equal(found.heading, 'Title');
20
+ assert.equal(found.rest, 'Intro\n\nMore\n\n## Section');
21
+ });
22
+
23
+ test('an H1 after a heading is found too', () => {
24
+ const found = pageH1('## Section\n\ntext\n\n# Title');
25
+ assert.equal(found.heading, 'Title');
26
+ assert.equal(found.rest, '## Section\n\ntext');
27
+ });
28
+
29
+ test('only the first H1 counts, the next stays in the text', () => {
30
+ const found = pageH1('# One\n\n# Two\n\ntext');
31
+ assert.equal(found.heading, 'One');
32
+ assert.equal(found.rest, '# Two\n\ntext');
33
+ });
34
+
35
+ test('an H1 line in a code fence is not a heading', () => {
36
+ const found = pageH1('```\n# not a heading\n```\n\n# Real');
37
+ assert.equal(found.heading, 'Real');
38
+ assert.equal(found.rest, '```\n# not a heading\n```');
39
+ assert.equal(pageH1('~~~\n# code\n~~~').heading, undefined);
40
+ });
41
+
42
+ test('without an H1: no heading, the text as it is', () => {
43
+ assert.deepEqual(pageH1('## Only\n\ntext'), { heading: undefined, rest: '## Only\n\ntext' });
44
+ assert.deepEqual(pageH1(undefined), { heading: undefined, rest: '' });
45
+ });
46
+
47
+ test('an H2 or "#tag" is not an H1', () => {
48
+ assert.equal(pageH1('## Two\n\n#tag').heading, undefined);
49
+ });
50
+
51
+ test('the heading is plain text: bold, links and escapes are gone', () => {
52
+ assert.equal(pageH1('Intro\n\n# A **bold** [link](/x) title').heading, 'A bold link title');
53
+ });
54
+
55
+ test('Windows line endings are kept in the rest', () => {
56
+ const found = pageH1('Intro\r\n\r\n# Title\r\n\r\nMore');
57
+ assert.equal(found.heading, 'Title');
58
+ assert.equal(found.rest, 'Intro\r\n\r\nMore');
59
+ });
package/src/sidebar.ts CHANGED
@@ -1,5 +1,5 @@
1
- // Whether the sidebar is open on a computer (App.tsx), closed and opened with the button at its top
2
- // or, while it is closed, at the start of the top bar (Screen.tsx). Kept in this browser.
1
+ // Whether the standard navigation drawer is open (expanded and wider, MainDrawer in ui.tsx), opened and closed with the menu
2
+ // button at the top of the navigation rail and of the drawer (ui.tsx, through menuOpen.ts). Kept in this browser (`editor-sidebar`).
3
3
  import { useSyncExternalStore } from 'react';
4
4
 
5
5
  const KEY = 'editor-sidebar';
package/src/site.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  // The site as the editor shows it: what src/site/ describes (the collections and their fields, the
2
- // start screen's groups, the screens under "Virksomheden" and "Blokke") with the site's texts in the
2
+ // menu's sections (groups), the screens under "Virksomheden" and "Blokke") with the site's texts in the
3
3
  // editor's language (siteText, @site/texts). The editor takes these from here, not from
4
4
  // @site/editor. A commit says what was saved in the site's language: asWritten gives back a
5
5
  // collection, a file, a part or a block as the site wrote it.
@@ -160,9 +160,14 @@ export const appearance = each(
160
160
  site.appearance.map((p) => ({ ...p, back: { href: '#udseende', label: t.common.parts.appearance } })),
161
161
  local.part,
162
162
  );
163
- /** The site's own parts under "Søgemaskineoptimering" (#seo, Library.tsx Seo), after the redirects, each going back to it. */
163
+ /** Where a screen goes back to: the start screen. The menu's sections ("Indhold" …) are lists in the drawer, not screens,
164
+ * so Redirects and "Interne links" under "Indhold" go there too. */
165
+ export const backFrom = (_id: string) => ({ href: '#', label: site.siteName });
166
+
167
+ /** The site's own parts under "Søgemaskineoptimering" (#seo/<id>, in the menu under "Indhold"), each going back
168
+ * to the start screen (backFrom). */
164
169
  export const seoParts = each(
165
- site.seoParts.map((p) => ({ ...p, back: { href: '#seo', label: t.common.parts.seo } })),
170
+ site.seoParts.map((p) => ({ ...p, back: backFrom(p.id) })),
166
171
  local.part,
167
172
  );
168
173
  /** The site's own first ("Book en tid"), then the blocks' by their names. */
@@ -175,19 +180,14 @@ export const groups = each(site.groups, local.group);
175
180
  export const colorFields = each(site.colorFields, local.color);
176
181
  /** The pairs that must be readable (@site/colors), with what they are in the editor's language. */
177
182
  export const colorPairs = each(site.colorPairs, local.pair);
178
- /** The settings of the whole site, on "Indstillinger" (#indstillinger) instead of the start screen. */
183
+ /** The settings of the whole site, the drawer's last section. */
179
184
  export const settings: string[] = site.settings;
180
185
 
181
- /** Where a part of the start screen is, as the way there says it: its group's title, or "Indstillinger" for a setting. */
182
- export const placeOf = (id: string): string | undefined =>
183
- settings.includes(id) ? t.common.parts.settings : groups.find((g) => g.ids.includes(id))?.title;
184
-
185
- /** Where a screen goes back to: "Indstillinger" for a setting ("Udseende", "Apps", "Analytics" …), else the start screen. */
186
- export const backFrom = (id: string) =>
187
- settings.includes(id) ? { href: '#indstillinger', label: t.common.parts.settings } : { href: '#', label: site.siteName };
186
+ /** Where a part of the menu is, as the way there says it: the title of the section it is in ("Indhold › …"). */
187
+ export const placeOf = (id: string): string | undefined => groups.find((g) => g.ids.includes(id))?.title;
188
188
 
189
189
  /** Where a standard page (by its key, packages/editor/defaults/standardPages.ts) is chosen, as the way there from the start screen
190
- * ("Indstillinger › Udseende › Standardsider"): the part with its field in the file of the standard pages. */
190
+ * ("Udseende › Standardsider"): the part with its field in the file of the standard pages. */
191
191
  export function standardPageAt(key: string): string {
192
192
  const chosenIn = (p: Part) => p.file === site.config.standardPages && !!p.fields?.some((f) => f.name === key);
193
193
  const screen = [{ id: 'udseende', label: t.common.parts.appearance, parts: appearance }, ...partScreens].find((s) =>
@@ -303,15 +303,25 @@ export const listRowsOf = (files: { path: string; text: string }[]): ListRows =>
303
303
  ]),
304
304
  );
305
305
 
306
- /** The icon of each part on the start screen, in the sidebar and under "Lister", by its id: the site's, and the
307
- * standard blocks' and the apps' lists'. */
308
- export const icons = { ...site.icons, ...Object.fromEntries(ownedLists.map((l) => [l.id, l.icon])) };
306
+ /** The icon of each part on the start screen, in the sidebar and under "Lister", by its id: the site's, the
307
+ * standard blocks' and the apps' lists', and those of the parts the menu lists (appearance, seoParts, partScreens). */
308
+ export const icons = {
309
+ ...site.icons,
310
+ ...Object.fromEntries(ownedLists.map((l) => [l.id, l.icon])),
311
+ // The parts the menu lists by their id: the site's standard pages, Internal links and the parts of the screens with parts.
312
+ ...Object.fromEntries(
313
+ [...appearance, ...seoParts, ...partScreens.flatMap((s) => s.parts)].flatMap((p) => (p.icon ? [[p.id, p.icon]] : [])),
314
+ ),
315
+ };
309
316
 
310
317
  /** The short lists, under "Lister" (#lister): the site's, the standard blocks' and the apps', which go back there too. */
311
318
  export const lists = [...site.lists, ...ownedLists.map((l) => l.id)];
312
319
 
313
- /** The lists under "Lister" now: the site's, the standard blocks' and those of the apps installed. */
320
+ /** The lists under "Lister" now: the site's, the standard blocks' and those of the apps installed, without the cookie
321
+ * list, which is a row under "Privatliv" (privacy.ts). */
314
322
  export const listsShown = (files: { path: string; text: string }[]) => {
315
323
  const installed = installedApps(files);
316
- return [...site.lists, ...[...standardLists, ...listsOf(writtenApps.filter((a) => installed.includes(a.id)))].map((l) => l.id)];
324
+ return [...site.lists, ...[...standardLists, ...listsOf(writtenApps.filter((a) => installed.includes(a.id)))].map((l) => l.id)].filter(
325
+ (id) => id !== site.config.lists.cookies,
326
+ );
317
327
  };
@@ -0,0 +1,8 @@
1
+ /** The word around the cursor as positions in the document, or null (a selection, or no word there).
2
+ * @param {import('@milkdown/kit/prose/state').EditorState} state @returns {{ from: number, to: number } | null} */
3
+ export function wordRange(state: import("@milkdown/kit/prose/state").EditorState): {
4
+ from: number;
5
+ to: number;
6
+ } | null;
7
+ /** How long the cursor stands still in a word before the menu appears, in ms. */
8
+ export const STILL_MS: 700;
@@ -0,0 +1,19 @@
1
+ // The word at a cursor that has stood still, for the menu over a text (selectionMenu in MarkdownEditor.tsx): how long
2
+ // the cursor waits (STILL_MS) and the word around it as positions in the document (wordRange). Depends on
3
+ // lib/word-at.mjs and ProseMirror's state.
4
+ import { wordAt } from '../lib/word-at.mjs';
5
+
6
+ /** How long the cursor stands still in a word before the menu appears, in ms. */
7
+ export const STILL_MS = 700;
8
+
9
+ /** The word around the cursor as positions in the document, or null (a selection, or no word there).
10
+ * @param {import('@milkdown/kit/prose/state').EditorState} state @returns {{ from: number, to: number } | null} */
11
+ export function wordRange(state) {
12
+ const { $from, empty } = state.selection;
13
+ const block = $from.parent;
14
+ if (!empty || !block.isTextblock) return null;
15
+ // One character for each picture or line break, so a place in the text is the same place in the block.
16
+ const word = wordAt(block.textBetween(0, block.content.size, undefined, ''), $from.parentOffset);
17
+ const start = $from.start();
18
+ return word && { from: start + word.from, to: start + word.to };
19
+ }
@@ -0,0 +1,26 @@
1
+ // Tests for src/stillCursor.mjs: the word around the cursor as positions in the document. 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 { Schema } from '@milkdown/kit/prose/model';
5
+ import { EditorState, TextSelection } from '@milkdown/kit/prose/state';
6
+ import { STILL_MS, wordRange } from './stillCursor.mjs';
7
+
8
+ const schema = new Schema({
9
+ nodes: {
10
+ doc: { content: 'block+' },
11
+ paragraph: { group: 'block', content: 'inline*' },
12
+ text: { group: 'inline' },
13
+ image: { group: 'inline', inline: true },
14
+ },
15
+ });
16
+ // "How ", a picture, " saving works": the text starts at 1, the picture is at 5, "saving" is 7–13.
17
+ const doc = schema.node('doc', null, [
18
+ schema.node('paragraph', null, [schema.text('How '), schema.node('image'), schema.text(' saving works')]),
19
+ ]);
20
+ const at = (from, to = from) => EditorState.create({ doc, selection: TextSelection.create(doc, from, to) });
21
+
22
+ test('waits 700 ms', () => assert.equal(STILL_MS, 700));
23
+ test('the word after a picture', () => assert.deepEqual(wordRange(at(9)), { from: 7, to: 13 }));
24
+ test('the word before a picture', () => assert.deepEqual(wordRange(at(2)), { from: 1, to: 4 }));
25
+ test('no word next to the picture', () => assert.equal(wordRange(at(5)), null));
26
+ test('none for a selection', () => assert.equal(wordRange(at(7, 13)), null));
@@ -0,0 +1,47 @@
1
+ /** The tags a promotion is shown on the posts of (Promovering, "Vises på": "Blogindlæg med tags").
2
+ * @param {Record<string, unknown>} d
3
+ * @returns {string[]} */
4
+ export function promotionTags(d: Record<string, unknown>): string[];
5
+ /**
6
+ * The files that use each tag, keyed by path, each file once.
7
+ * @template {TagFile} F
8
+ * @param {F[]} files
9
+ * @param {TagFolders} folders
10
+ * @returns {Map<string, Map<string, F>>}
11
+ */
12
+ export function tagUses<F extends TagFile>(files: F[], folders: TagFolders): Map<string, Map<string, F>>;
13
+ /**
14
+ * A file record's use of a tag: the Filer address the file record is for, as a path under filer/.
15
+ * @param {TagFile} file
16
+ * @param {string} folder the file records folder
17
+ * @returns {{ path: string; name: string } | null}
18
+ */
19
+ export function recordUse(file: TagFile, folder: string): {
20
+ path: string;
21
+ name: string;
22
+ } | null;
23
+ /**
24
+ * The files that have a tag by their own categories: posts, pages and testimonials (not blocks).
25
+ * @template {TagFile} F
26
+ * @param {F[]} files
27
+ * @param {string} slug
28
+ * @param {TagFolders} folders
29
+ * @returns {{ posts: F[]; pages: F[]; testimonials: F[]; records: F[] }}
30
+ */
31
+ export function taggedWith<F extends TagFile>(files: F[], slug: string, folders: TagFolders): {
32
+ posts: F[];
33
+ pages: F[];
34
+ testimonials: F[];
35
+ records: F[];
36
+ };
37
+ export type TagFile = {
38
+ path: string;
39
+ text: string;
40
+ };
41
+ export type TagFolders = {
42
+ blog: string;
43
+ pages: string;
44
+ testimonials: string;
45
+ popups: string;
46
+ fileRecords?: string;
47
+ };
@@ -0,0 +1,121 @@
1
+ // What on the site uses each tag: posts and pages by their front matter, testimonials, promotions on tagged posts,
2
+ // file records (tags on files in Filer), and the "Blogindlæg"/"Udtalelser" blocks that pick a tag. Pure (no @site imports),
3
+ // so node --test can run it; defaults/rules.ts uses it for usedBy, usesOf and countsOf.
4
+ import { blocksIn } from '../lib/blocks.mjs';
5
+ import { isRecordPath, parseRecord } from './fileRecords.mjs';
6
+ import { readFields, splitFrontmatter } from './frontmatter.mjs';
7
+
8
+ /**
9
+ * @typedef {{ path: string; text: string }} TagFile
10
+ * @typedef {{ blog: string; pages: string; testimonials: string; popups: string; fileRecords?: string }} TagFolders
11
+ */
12
+
13
+ /** The front matter fields of a file; a broken one gives no fields (never throws). @param {TagFile} f */
14
+ const frontmatterOf = (f) => {
15
+ try {
16
+ return readFields(splitFrontmatter(f.text).yaml) ?? {};
17
+ } catch {
18
+ return {};
19
+ }
20
+ };
21
+
22
+ /** The JSON of a file; a broken one gives no fields (never throws). @param {TagFile} f */
23
+ const jsonOf = (f) => {
24
+ try {
25
+ return JSON.parse(f.text) ?? {};
26
+ } catch {
27
+ return {};
28
+ }
29
+ };
30
+
31
+ /** @param {string} path @param {string} folder @param {string} ext */
32
+ const inFolder = (path, folder, ext) => path.startsWith(`${folder}/`) && path.endsWith(ext);
33
+
34
+ /** The record of a file record, or null when the file is not one.
35
+ * @param {TagFile} f @param {TagFolders} folders */
36
+ const recordOf = (f, folders) =>
37
+ folders.fileRecords !== undefined && isRecordPath(f.path, folders.fileRecords) ? parseRecord(f.text) : null;
38
+
39
+ /** The tags a promotion is shown on the posts of (Promovering, "Vises på": "Blogindlæg med tags").
40
+ * @param {Record<string, unknown>} d
41
+ * @returns {string[]} */
42
+ export function promotionTags(d) {
43
+ return d.where === 'tags' && Array.isArray(d.tags) ? d.tags.map(String) : [];
44
+ }
45
+
46
+ /** The tags a block picks: a "Blogindlæg" or "Udtalelser" block's categories, and its link.
47
+ * @param {string} type
48
+ * @param {Record<string, any>} props
49
+ * @returns {unknown[]} */
50
+ const blockTags = (type, props) => {
51
+ if (type !== 'posts' && type !== 'testimonials') return [];
52
+ return [...(Array.isArray(props?.categories) ? props.categories : []), ...(props?.link ? [props.link] : [])];
53
+ };
54
+
55
+ /**
56
+ * The files that use each tag, keyed by path, each file once.
57
+ * @template {TagFile} F
58
+ * @param {F[]} files
59
+ * @param {TagFolders} folders
60
+ * @returns {Map<string, Map<string, F>>}
61
+ */
62
+ export function tagUses(files, folders) {
63
+ /** @type {Map<string, Map<string, F>>} */
64
+ const uses = new Map();
65
+ /** @param {F} f @param {unknown} slugs */
66
+ const add = (f, slugs) => {
67
+ for (const slug of Array.isArray(slugs) ? slugs.map(String) : []) {
68
+ const byPath = uses.get(slug) ?? new Map();
69
+ byPath.set(f.path, f);
70
+ uses.set(slug, byPath);
71
+ }
72
+ };
73
+ for (const f of files) {
74
+ if (inFolder(f.path, folders.blog, '.md') || inFolder(f.path, folders.pages, '.md')) {
75
+ add(f, frontmatterOf(f).categories);
76
+ for (const b of blocksIn(f.text)) add(f, blockTags(b.type, b.props));
77
+ } else if (inFolder(f.path, folders.testimonials, '.json')) {
78
+ add(f, jsonOf(f).categories);
79
+ } else if (inFolder(f.path, folders.popups, '.json')) {
80
+ add(f, promotionTags(jsonOf(f)));
81
+ } else {
82
+ const record = recordOf(f, folders);
83
+ if (record) add(f, record.categories);
84
+ }
85
+ }
86
+ return uses;
87
+ }
88
+
89
+ /**
90
+ * A file record's use of a tag: the Filer address the file record is for, as a path under filer/.
91
+ * @param {TagFile} file
92
+ * @param {string} folder the file records folder
93
+ * @returns {{ path: string; name: string } | null}
94
+ */
95
+ export function recordUse(file, folder) {
96
+ const record = folder !== undefined && isRecordPath(file.path, folder) ? parseRecord(file.text) : null;
97
+ if (!record) return null;
98
+ return { path: `filer${record.src}`, name: record.src.slice(record.src.lastIndexOf('/') + 1) };
99
+ }
100
+
101
+ /**
102
+ * The files that have a tag by their own categories: posts, pages and testimonials (not blocks).
103
+ * @template {TagFile} F
104
+ * @param {F[]} files
105
+ * @param {string} slug
106
+ * @param {TagFolders} folders
107
+ * @returns {{ posts: F[]; pages: F[]; testimonials: F[]; records: F[] }}
108
+ */
109
+ export function taggedWith(files, slug, folders) {
110
+ /** @param {unknown} categories */
111
+ const has = (categories) => Array.isArray(categories) && categories.map(String).includes(slug);
112
+ return {
113
+ posts: files.filter((f) => inFolder(f.path, folders.blog, '.md') && has(frontmatterOf(f).categories)),
114
+ pages: files.filter((f) => inFolder(f.path, folders.pages, '.md') && has(frontmatterOf(f).categories)),
115
+ testimonials: files.filter((f) => inFolder(f.path, folders.testimonials, '.json') && has(jsonOf(f).categories)),
116
+ records: files.filter((f) => {
117
+ const record = recordOf(f, folders);
118
+ return record !== null && has(record.categories);
119
+ }),
120
+ };
121
+ }