@morphemecms/cms 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (396) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/astro.mjs +3 -0
  3. package/bin/morpheme.mjs +3 -1
  4. package/defaults/blockTexts.ts +2 -2
  5. package/defaults/collections.ts +10 -0
  6. package/defaults/editor.ts +57 -53
  7. package/defaults/privacy.ts +4 -1
  8. package/defaults/rules.ts +36 -38
  9. package/defaults/standardPages.ts +0 -3
  10. package/defaults/texts.ts +11 -2
  11. package/demo/src/content/pages/about.md +2 -0
  12. package/icons/lucide/browser-fill.svg +1 -0
  13. package/icons/lucide/buildings-fill.svg +1 -0
  14. package/icons/lucide/certificate-fill.svg +1 -0
  15. package/icons/lucide/chart-line-up-fill.svg +1 -0
  16. package/icons/lucide/chats-fill.svg +1 -0
  17. package/icons/lucide/envelope-simple-fill.svg +1 -0
  18. package/icons/lucide/file-text-fill.svg +1 -0
  19. package/icons/lucide/folder-simple-fill.svg +1 -0
  20. package/icons/lucide/gauge.svg +1 -1
  21. package/icons/lucide/gear-six.svg +1 -0
  22. package/icons/lucide/globe-simple.svg +1 -0
  23. package/icons/lucide/heart-fill.svg +1 -0
  24. package/icons/lucide/layout.svg +1 -0
  25. package/icons/lucide/list-checks-fill.svg +1 -0
  26. package/icons/lucide/magnifying-glass-fill.svg +1 -0
  27. package/icons/lucide/megaphone-fill.svg +1 -0
  28. package/icons/lucide/newspaper-fill.svg +1 -0
  29. package/icons/lucide/paint-brush-broad-fill.svg +1 -0
  30. package/icons/lucide/puzzle-piece-fill.svg +1 -0
  31. package/icons/lucide/shield-check-fill.svg +1 -0
  32. package/icons/lucide/sidebar-simple.svg +1 -1
  33. package/icons/lucide/sidebar.svg +1 -0
  34. package/icons/lucide/square-half-bottom-fill.svg +1 -0
  35. package/icons/lucide/square-half.svg +1 -0
  36. package/icons/lucide/squares-four-fill.svg +1 -0
  37. package/icons/lucide/storefront-fill.svg +1 -0
  38. package/icons/lucide/tag-fill.svg +1 -0
  39. package/icons/lucide/toggle-right-fill.svg +1 -0
  40. package/icons/lucide/trash-fill.svg +1 -0
  41. package/icons/lucide/user-fill.svg +1 -0
  42. package/icons/lucide/wrench-fill.svg +1 -0
  43. package/icons/material-symbols/browser-fill.svg +1 -0
  44. package/icons/material-symbols/buildings-fill.svg +1 -0
  45. package/icons/material-symbols/certificate-fill.svg +1 -0
  46. package/icons/material-symbols/chart-line-up-fill.svg +1 -0
  47. package/icons/material-symbols/chats-fill.svg +1 -0
  48. package/icons/material-symbols/envelope-simple-fill.svg +1 -0
  49. package/icons/material-symbols/file-text-fill.svg +1 -0
  50. package/icons/material-symbols/folder-simple-fill.svg +1 -0
  51. package/icons/material-symbols/gauge.svg +1 -1
  52. package/icons/material-symbols/gear-six.svg +1 -0
  53. package/icons/material-symbols/globe-simple.svg +1 -0
  54. package/icons/material-symbols/heart-fill.svg +1 -0
  55. package/icons/material-symbols/layout.svg +1 -0
  56. package/icons/material-symbols/list-checks-fill.svg +1 -0
  57. package/icons/material-symbols/magnifying-glass-fill.svg +1 -0
  58. package/icons/material-symbols/megaphone-fill.svg +1 -0
  59. package/icons/material-symbols/newspaper-fill.svg +1 -0
  60. package/icons/material-symbols/paint-brush-broad-fill.svg +1 -0
  61. package/icons/material-symbols/puzzle-piece-fill.svg +1 -0
  62. package/icons/material-symbols/shield-check-fill.svg +1 -0
  63. package/icons/material-symbols/sidebar-simple.svg +1 -1
  64. package/icons/material-symbols/sidebar.svg +1 -0
  65. package/icons/material-symbols/square-half-bottom-fill.svg +1 -0
  66. package/icons/material-symbols/square-half.svg +1 -0
  67. package/icons/material-symbols/squares-four-fill.svg +1 -0
  68. package/icons/material-symbols/storefront-fill.svg +1 -0
  69. package/icons/material-symbols/tag-fill.svg +1 -0
  70. package/icons/material-symbols/toggle-right-fill.svg +1 -0
  71. package/icons/material-symbols/trash-fill.svg +1 -0
  72. package/icons/material-symbols/user-fill.svg +1 -0
  73. package/icons/material-symbols/wrench-fill.svg +1 -0
  74. package/icons/phosphor/browser-fill.svg +1 -0
  75. package/icons/phosphor/buildings-fill.svg +1 -0
  76. package/icons/phosphor/certificate-fill.svg +1 -0
  77. package/icons/phosphor/chart-line-up-fill.svg +1 -0
  78. package/icons/phosphor/chats-fill.svg +1 -0
  79. package/icons/phosphor/envelope-simple-fill.svg +1 -0
  80. package/icons/phosphor/file-text-fill.svg +1 -0
  81. package/icons/phosphor/folder-simple-fill.svg +1 -0
  82. package/icons/phosphor/gear-six.svg +1 -0
  83. package/icons/phosphor/globe-simple.svg +1 -0
  84. package/icons/phosphor/heart-fill.svg +1 -0
  85. package/icons/phosphor/layout.svg +1 -0
  86. package/icons/phosphor/list-checks-fill.svg +1 -0
  87. package/icons/phosphor/magnifying-glass-fill.svg +1 -0
  88. package/icons/phosphor/megaphone-fill.svg +1 -0
  89. package/icons/phosphor/newspaper-fill.svg +1 -0
  90. package/icons/phosphor/paint-brush-broad-fill.svg +1 -0
  91. package/icons/phosphor/puzzle-piece-fill.svg +1 -0
  92. package/icons/phosphor/shield-check-fill.svg +1 -0
  93. package/icons/phosphor/sidebar.svg +1 -0
  94. package/icons/phosphor/square-half-bottom-fill.svg +1 -0
  95. package/icons/phosphor/square-half.svg +1 -0
  96. package/icons/phosphor/squares-four-fill.svg +1 -0
  97. package/icons/phosphor/storefront-fill.svg +1 -0
  98. package/icons/phosphor/tag-fill.svg +1 -0
  99. package/icons/phosphor/toggle-right-fill.svg +1 -0
  100. package/icons/phosphor/trash-fill.svg +1 -0
  101. package/icons/phosphor/user-fill.svg +1 -0
  102. package/icons/phosphor/wrench-fill.svg +1 -0
  103. package/lib/breakpoints.d.mts +13 -0
  104. package/lib/breakpoints.mjs +20 -0
  105. package/lib/breakpoints.test.mjs +39 -0
  106. package/lib/build-lane.d.mts +15 -0
  107. package/lib/build-lane.mjs +23 -0
  108. package/lib/build-lane.test.mjs +74 -0
  109. package/lib/build-settings.d.mts +16 -0
  110. package/lib/build-settings.mjs +39 -0
  111. package/lib/build-settings.test.mjs +41 -0
  112. package/lib/contracts.d.mts +3 -2
  113. package/lib/contracts.mjs +3 -2
  114. package/lib/drawer-swipe.d.mts +10 -0
  115. package/lib/drawer-swipe.mjs +17 -0
  116. package/lib/drawer-swipe.test.mjs +33 -0
  117. package/lib/drawerKeys.d.mts +9 -0
  118. package/lib/drawerKeys.mjs +26 -0
  119. package/lib/drawerKeys.test.mjs +21 -0
  120. package/lib/eyebrow.d.mts +6 -0
  121. package/lib/eyebrow.mjs +24 -0
  122. package/lib/eyebrow.test.mjs +20 -0
  123. package/lib/front-sections.test.mjs +82 -0
  124. package/lib/heading-levels.d.mts +7 -0
  125. package/lib/heading-levels.mjs +16 -0
  126. package/lib/heading-levels.test.mjs +37 -0
  127. package/lib/inline.ts +3 -2
  128. package/lib/markdown.ts +2 -0
  129. package/lib/materialIcon.d.mts +21 -0
  130. package/lib/materialIcon.mjs +29 -0
  131. package/lib/materialIcon.test.mjs +45 -0
  132. package/lib/menu-layout.d.mts +11 -0
  133. package/lib/menu-layout.mjs +14 -0
  134. package/lib/menu-layout.test.mjs +23 -0
  135. package/lib/menu.d.mts +59 -0
  136. package/lib/menu.mjs +129 -0
  137. package/lib/menu.test.mjs +291 -0
  138. package/lib/rehype-emphasize.d.mts +4 -0
  139. package/lib/rehype-emphasize.mjs +20 -0
  140. package/lib/rehype-emphasize.test.mjs +39 -0
  141. package/lib/section-in-view.d.mts +8 -0
  142. package/lib/section-in-view.mjs +25 -0
  143. package/lib/section-in-view.test.mjs +35 -0
  144. package/lib/section-marker.d.mts +5 -0
  145. package/lib/section-marker.mjs +18 -0
  146. package/lib/section-marker.test.mjs +22 -0
  147. package/lib/section-ranges.d.mts +12 -0
  148. package/lib/section-ranges.mjs +43 -0
  149. package/lib/section-ranges.test.mjs +61 -0
  150. package/lib/sheet-stops.d.mts +68 -0
  151. package/lib/sheet-stops.mjs +198 -0
  152. package/lib/sheet-stops.test.mjs +155 -0
  153. package/lib/still-timer.d.mts +5 -0
  154. package/lib/still-timer.mjs +17 -0
  155. package/lib/still-timer.test.mjs +42 -0
  156. package/lib/text-sections.d.mts +10 -0
  157. package/lib/text-sections.mjs +45 -0
  158. package/lib/text-sections.test.mjs +78 -0
  159. package/lib/video.mjs +2 -1
  160. package/lib/without-h1.d.mts +1 -0
  161. package/lib/without-h1.mjs +7 -0
  162. package/lib/without-h1.test.mjs +26 -0
  163. package/lib/word-at.d.mts +6 -0
  164. package/lib/word-at.mjs +20 -0
  165. package/lib/word-at.test.mjs +19 -0
  166. package/package.json +3 -1
  167. package/pages/editor/version.json.js +22 -2
  168. package/scripts/already-checked.mjs +11 -0
  169. package/scripts/already-checked.test.mjs +18 -0
  170. package/scripts/build-settings-problems.mjs +27 -0
  171. package/scripts/build-settings-problems.test.mjs +35 -0
  172. package/scripts/check-content.mjs +42 -4
  173. package/scripts/check-worker.mjs +96 -4
  174. package/scripts/ci-build.mjs +85 -0
  175. package/scripts/ci-build.test.mjs +137 -0
  176. package/scripts/editor-roundtrip.mjs +62 -3
  177. package/scripts/editor-screens.mjs +21 -7
  178. package/scripts/git-env.mjs +6 -0
  179. package/scripts/step-command.mjs +34 -0
  180. package/scripts/step-command.test.mjs +50 -0
  181. package/scripts/text-problems.mjs +83 -0
  182. package/scripts/text-problems.test.mjs +74 -0
  183. package/scripts/worker-modules.mjs +11 -1
  184. package/site/breakpoints.d.mts +6 -0
  185. package/site/breakpoints.mjs +52 -0
  186. package/site/breakpoints.test.mjs +36 -0
  187. package/site/casing.d.mts +3 -0
  188. package/site/casing.mjs +26 -0
  189. package/site/content.config.ts +2 -0
  190. package/site/content.ts +2 -4
  191. package/site/defineSite.d.mts +1 -0
  192. package/site/defineSite.mjs +3 -0
  193. package/site/home.ts +3 -2
  194. package/site/icons.d.mts +116 -4
  195. package/site/icons.mjs +62 -6
  196. package/site/images.mjs +1 -1
  197. package/site/live-diff.d.mts +22 -0
  198. package/site/live-diff.mjs +98 -0
  199. package/site/live-diff.test.mjs +235 -0
  200. package/site/materialSymbols.d.mts +5 -0
  201. package/site/materialSymbols.mjs +19 -0
  202. package/site/sections.ts +1 -1
  203. package/site/theme.d.mts +1 -1
  204. package/site/theme.mjs +7 -1
  205. package/src/ActionParts.tsx +1 -1
  206. package/src/Advanced.tsx +101 -20
  207. package/src/App.tsx +47 -121
  208. package/src/AppList.tsx +1 -1
  209. package/src/BlockParts.tsx +3 -3
  210. package/src/Colors.tsx +1 -1
  211. package/src/CommunicationParts.tsx +1 -1
  212. package/src/ContainmentParts.tsx +80 -28
  213. package/src/Deleted.tsx +18 -4
  214. package/src/EditorFooter.tsx +14 -0
  215. package/src/EntryForm.tsx +75 -39
  216. package/src/FormFrame.tsx +3 -8
  217. package/src/Library.tsx +46 -51
  218. package/src/Licenses.tsx +27 -11
  219. package/src/MainDrawerParts.tsx +60 -0
  220. package/src/MainMenu.tsx +136 -0
  221. package/src/MarkdownEditor.tsx +175 -32
  222. package/src/Media.tsx +69 -16
  223. package/src/MediaTags.tsx +91 -0
  224. package/src/ModalSheetSample.tsx +47 -0
  225. package/src/NavigationParts.tsx +67 -21
  226. package/src/NavigationRestParts.tsx +1 -1
  227. package/src/Parts.tsx +71 -0
  228. package/src/PartsShown.tsx +1 -1
  229. package/src/PickerParts.tsx +2 -2
  230. package/src/Redirects.tsx +3 -2
  231. package/src/Screen.tsx +93 -90
  232. package/src/SelectionParts.tsx +22 -6
  233. package/src/SiteLogoLink.tsx +14 -0
  234. package/src/StartFab.tsx +22 -0
  235. package/src/StartStatus.tsx +1 -1
  236. package/src/autoTags.d.mts +28 -0
  237. package/src/autoTags.mjs +68 -0
  238. package/src/autoTags.test.mjs +109 -0
  239. package/src/buildSettings.test.mjs +20 -0
  240. package/src/buildSettings.ts +20 -0
  241. package/src/checkSite.ts +14 -1
  242. package/src/deploy.ts +7 -9
  243. package/src/editor.css +150 -17
  244. package/src/eyebrow.d.mts +5 -0
  245. package/src/eyebrow.mjs +53 -0
  246. package/src/eyebrowState.d.mts +21 -0
  247. package/src/eyebrowState.mjs +62 -0
  248. package/src/eyebrowState.test.mjs +78 -0
  249. package/src/fileRecords.d.mts +115 -0
  250. package/src/fileRecords.mjs +224 -0
  251. package/src/fileRecords.test.mjs +224 -0
  252. package/src/fileTagFilter.d.mts +30 -0
  253. package/src/fileTagFilter.mjs +44 -0
  254. package/src/fileTagFilter.test.mjs +57 -0
  255. package/src/fileTags.d.mts +56 -0
  256. package/src/fileTags.mjs +100 -0
  257. package/src/fileTags.test.mjs +127 -0
  258. package/src/firstHeading.d.mts +6 -0
  259. package/src/firstHeading.mjs +37 -0
  260. package/src/i18n/da-DK/advanced.ts +27 -12
  261. package/src/i18n/da-DK/app.ts +9 -7
  262. package/src/i18n/da-DK/apps.ts +16 -16
  263. package/src/i18n/da-DK/blockSettings.ts +9 -9
  264. package/src/i18n/da-DK/blocks.ts +2 -2
  265. package/src/i18n/da-DK/check.ts +5 -2
  266. package/src/i18n/da-DK/cms.ts +14 -12
  267. package/src/i18n/da-DK/colors.ts +5 -5
  268. package/src/i18n/da-DK/common.ts +2 -2
  269. package/src/i18n/da-DK/company.ts +3 -3
  270. package/src/i18n/da-DK/consents.ts +6 -6
  271. package/src/i18n/da-DK/forms.ts +6 -6
  272. package/src/i18n/da-DK/history.ts +6 -6
  273. package/src/i18n/da-DK/keywords.ts +2 -2
  274. package/src/i18n/da-DK/library.ts +17 -15
  275. package/src/i18n/da-DK/licenses.ts +4 -4
  276. package/src/i18n/da-DK/media.ts +10 -4
  277. package/src/i18n/da-DK/parts.ts +62 -25
  278. package/src/i18n/da-DK/preview.ts +5 -6
  279. package/src/i18n/da-DK/redirects.ts +7 -7
  280. package/src/i18n/da-DK/seo.ts +1 -1
  281. package/src/i18n/da-DK/text.ts +2 -0
  282. package/src/i18n/da-DK/theme.ts +5 -5
  283. package/src/i18n/en-US/advanced.ts +34 -18
  284. package/src/i18n/en-US/app.ts +7 -5
  285. package/src/i18n/en-US/apps.ts +9 -9
  286. package/src/i18n/en-US/blockSettings.ts +4 -4
  287. package/src/i18n/en-US/blocks.ts +6 -6
  288. package/src/i18n/en-US/check.ts +6 -3
  289. package/src/i18n/en-US/cms.ts +15 -14
  290. package/src/i18n/en-US/colors.ts +18 -18
  291. package/src/i18n/en-US/common.ts +3 -3
  292. package/src/i18n/en-US/consents.ts +4 -4
  293. package/src/i18n/en-US/forms.ts +5 -5
  294. package/src/i18n/en-US/history.ts +2 -2
  295. package/src/i18n/en-US/library.ts +21 -15
  296. package/src/i18n/en-US/licenses.ts +6 -6
  297. package/src/i18n/en-US/media.ts +7 -1
  298. package/src/i18n/en-US/parts.ts +60 -21
  299. package/src/i18n/en-US/preview.ts +5 -6
  300. package/src/i18n/en-US/redirects.ts +3 -3
  301. package/src/i18n/en-US/seo.ts +1 -1
  302. package/src/i18n/en-US/text.ts +3 -0
  303. package/src/i18n/en-US/theme.ts +6 -6
  304. package/src/icon.tsx +90 -27
  305. package/src/licenseData.test.mjs +28 -0
  306. package/src/licenseData.ts +12 -1
  307. package/src/markdown.d.mts +2 -0
  308. package/src/markdown.mjs +73 -5
  309. package/src/markdown.test.mjs +74 -0
  310. package/src/materialIcons.ts +8 -0
  311. package/src/menuChildren.ts +36 -0
  312. package/src/menuOpen.ts +34 -0
  313. package/src/pagePreview.tsx +23 -19
  314. package/src/r2Caching.ts +14 -0
  315. package/src/sectionFrame.ts +39 -0
  316. package/src/sectionState.d.mts +19 -0
  317. package/src/sectionState.mjs +31 -0
  318. package/src/sectionState.test.mjs +59 -0
  319. package/src/sections.d.mts +9 -7
  320. package/src/sections.mjs +74 -27
  321. package/src/sections.test.mjs +59 -0
  322. package/src/sidebar.ts +2 -2
  323. package/src/site.ts +27 -17
  324. package/src/stillCursor.d.mts +8 -0
  325. package/src/stillCursor.mjs +19 -0
  326. package/src/stillCursor.test.mjs +26 -0
  327. package/src/tagUses.d.mts +47 -0
  328. package/src/tagUses.mjs +121 -0
  329. package/src/tagUses.test.mjs +141 -0
  330. package/src/themeUi.ts +10 -0
  331. package/src/topBarSlot.tsx +37 -0
  332. package/src/ui.tsx +654 -150
  333. package/src/virtual.d.ts +5 -0
  334. package/starter/README.md +2 -1
  335. package/starter/package.json +2 -1
  336. package/starter/wrangler.jsonc +3 -2
  337. package/theme/blocks/cards/LinkCards.tsx +2 -1
  338. package/theme/blocks/hero/View.tsx +2 -1
  339. package/theme/blocks/video/View.tsx +2 -1
  340. package/theme/components/BlogIndex.astro +2 -2
  341. package/theme/components/BlogPosts.astro +1 -1
  342. package/theme/components/BlogPosts.tsx +9 -6
  343. package/theme/components/Container.astro +1 -1
  344. package/theme/components/CookieConsent.astro +1 -1
  345. package/theme/components/EndBlock.astro +1 -1
  346. package/theme/components/Footer.astro +5 -5
  347. package/theme/components/Header.astro +3 -3
  348. package/theme/components/Icon.astro +2 -2
  349. package/theme/components/MobileMenu.tsx +72 -61
  350. package/theme/components/MobileNav.astro +2 -2
  351. package/theme/components/PageHeader.astro +2 -2
  352. package/theme/components/PageSection.astro +1 -1
  353. package/theme/components/PopupButton.tsx +2 -2
  354. package/theme/components/PopupView.tsx +2 -2
  355. package/theme/components/PostList.tsx +7 -3
  356. package/theme/components/RichText.astro +22 -7
  357. package/theme/components/Section.astro +17 -10
  358. package/theme/components/SectionHeading.tsx +1 -1
  359. package/theme/components/home/PageBlock.astro +4 -4
  360. package/theme/components/home/Text.astro +3 -3
  361. package/theme/layouts/PageLayout.astro +3 -3
  362. package/theme/layouts/PostLayout.astro +9 -8
  363. package/theme/styles/base.css +13 -3
  364. package/theme/styles/blocks.css +97 -10
  365. package/theme/styles/components.css +9 -9
  366. package/theme/styles/elevation.test.mjs +168 -0
  367. package/theme/styles/global.css +65 -59
  368. package/theme/styles/ui.css +460 -131
  369. package/theme/templates/Home.astro +1 -1
  370. package/theme/templates/New.astro +5 -5
  371. package/theme/templates/NotFound.astro +2 -2
  372. package/theme/ui/BottomSheet.tsx +118 -21
  373. package/theme/ui/BottomSheetResizeIcon.tsx +13 -0
  374. package/theme/ui/Button.tsx +4 -3
  375. package/theme/ui/Dialog.tsx +1 -1
  376. package/theme/ui/FabMenu.tsx +13 -12
  377. package/theme/ui/LoadingIndicator.tsx +1 -1
  378. package/theme/ui/NavigationDrawer.tsx +116 -15
  379. package/theme/ui/NavigationRail.tsx +22 -13
  380. package/theme/ui/Search.tsx +1 -1
  381. package/theme/ui/SettingRow.tsx +31 -0
  382. package/theme/ui/Switch.tsx +11 -11
  383. package/theme/ui/TextField.tsx +17 -6
  384. package/theme/ui/bottomSheet.script.ts +3 -2
  385. package/theme/ui/bottomSheetControl.ts +131 -46
  386. package/theme/ui/fabMenu.script.ts +69 -53
  387. package/theme/ui/modalSheetSize.ts +214 -0
  388. package/theme/ui/navigationDrawerControl.ts +35 -0
  389. package/theme/ui/search.script.ts +4 -2
  390. package/worker/index.js +3 -0
  391. package/worker/plan-status.js +46 -0
  392. package/worker/push.js +7 -5
  393. package/worker/r2-status.js +2 -1
  394. package/icons/lucide/desktop.svg +0 -1
  395. package/icons/material-symbols/desktop.svg +0 -1
  396. package/icons/phosphor/desktop.svg +0 -1
@@ -0,0 +1,78 @@
1
+ // Tests for src/eyebrowState.mjs: where the labels are in the document, and the keys in a label. 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 { enterInLabel, hasLabel, labelledHeading, looseLabels, toggleLabel } from './eyebrowState.mjs';
7
+
8
+ const schema = new Schema({
9
+ nodes: {
10
+ doc: { content: 'block+' },
11
+ paragraph: { group: 'block', content: 'text*' },
12
+ heading: { group: 'block', content: 'text*', attrs: { level: { default: 2 } } },
13
+ eyebrow: { group: 'block', content: 'text*' },
14
+ text: {},
15
+ },
16
+ });
17
+ const node = (type, text, attrs) => schema.node(type, attrs, text ? [schema.text(text)] : []);
18
+ // A label (0–7), its heading (7–14) and a paragraph (14–17).
19
+ const labelled = () => schema.node('doc', null, [node('eyebrow', 'Label'), node('heading', 'Title'), node('paragraph', 'x')]);
20
+ const stateAt = (doc, pos) => EditorState.create({ doc, selection: TextSelection.create(doc, pos) });
21
+
22
+ test('a label before its heading is not loose', () => assert.deepEqual(looseLabels(labelled()), []));
23
+ test('a label without a heading after it is loose', () => {
24
+ const doc = schema.node('doc', null, [node('eyebrow', 'Label'), node('paragraph', 'x'), node('eyebrow', 'End')]);
25
+ assert.deepEqual(looseLabels(doc), [0, 10]);
26
+ });
27
+ test('Enter in a label goes to the start of its heading', () => {
28
+ let next;
29
+ assert.equal(
30
+ enterInLabel(stateAt(labelled(), 3), (tr) => (next = tr)),
31
+ true,
32
+ );
33
+ assert.equal(next.selection.from, 8);
34
+ assert.equal(next.doc.childCount, 3);
35
+ });
36
+ test('Enter elsewhere is not the label’s', () =>
37
+ assert.equal(
38
+ enterInLabel(stateAt(labelled(), 9), () => {}),
39
+ false,
40
+ ));
41
+
42
+ // A view as the menu uses it: its state, and dispatch.
43
+ const viewOf = (state) => ({
44
+ state,
45
+ dispatch(tr) {
46
+ this.state = this.state.apply(tr);
47
+ },
48
+ });
49
+ const plain = () => schema.node('doc', null, [node('heading', 'Title', { level: 3 }), node('paragraph', 'x')]);
50
+
51
+ test('the heading the cursor is in', () => assert.deepEqual(labelledHeading(stateAt(labelled(), 9)), { pos: 7, level: 2 }));
52
+ test('the heading after the label the cursor is in', () => assert.deepEqual(labelledHeading(stateAt(labelled(), 3)), { pos: 7, level: 2 }));
53
+ test('no heading in a paragraph', () => assert.equal(labelledHeading(stateAt(labelled(), 15)), null));
54
+ test('whether it has a label', () => {
55
+ assert.equal(hasLabel(stateAt(labelled(), 9)), true);
56
+ assert.equal(hasLabel(stateAt(plain(), 2)), false);
57
+ });
58
+ test('adds an empty label with the cursor in it', () => {
59
+ const view = viewOf(stateAt(plain(), 2));
60
+ toggleLabel(view);
61
+ assert.equal(view.state.doc.child(0).type.name, 'eyebrow');
62
+ assert.equal(view.state.doc.child(0).content.size, 0);
63
+ assert.equal(view.state.doc.child(1).textContent, 'Title');
64
+ assert.equal(view.state.selection.from, 1);
65
+ });
66
+ test('takes the label away, from the heading or from the label', () => {
67
+ for (const pos of [9, 3]) {
68
+ const view = viewOf(stateAt(labelled(), pos));
69
+ toggleLabel(view);
70
+ assert.equal(view.state.doc.child(0).type.name, 'heading');
71
+ assert.equal(view.state.doc.childCount, 2);
72
+ }
73
+ });
74
+ test('does nothing in a paragraph', () => {
75
+ const view = viewOf(stateAt(labelled(), 15));
76
+ toggleLabel(view);
77
+ assert.equal(view.state.doc.childCount, 3);
78
+ });
@@ -0,0 +1,115 @@
1
+ /**
2
+ * The record in a file's text, or null when the text is not a record.
3
+ * @param {string} text
4
+ * @returns {FileRecord | null}
5
+ */
6
+ export function parseRecord(text: string): FileRecord | null;
7
+ /**
8
+ * The records directly in a folder, sorted by path. Files that are not records are left out.
9
+ * @param {{ path: string; oid: string; text: string }[]} files
10
+ * @param {string} folder
11
+ * @returns {StoredRecord[]}
12
+ */
13
+ export function recordsIn(files: {
14
+ path: string;
15
+ oid: string;
16
+ text: string;
17
+ }[], folder: string): StoredRecord[];
18
+ /**
19
+ * The problems in the record files directly in a folder. Paths are compared and returned with `/`.
20
+ * `shape`: not a record. `overlap`: a slug is both chosen and removed. `duplicate`: a later record has the same address.
21
+ * @param {{ path: string; text: string }[]} files
22
+ * @param {string} folder
23
+ * @returns {RecordProblem[]}
24
+ */
25
+ export function recordProblems(files: {
26
+ path: string;
27
+ text: string;
28
+ }[], folder: string): RecordProblem[];
29
+ /**
30
+ * The record of a file address, or undefined.
31
+ * @param {StoredRecord[]} records
32
+ * @param {string} src
33
+ * @returns {StoredRecord | undefined}
34
+ */
35
+ export function recordFor(records: StoredRecord[], src: string): StoredRecord | undefined;
36
+ /**
37
+ * The path of a record file.
38
+ * @param {string} folder
39
+ * @param {string} id
40
+ * @returns {string}
41
+ */
42
+ export function recordPath(folder: string, id: string): string;
43
+ /**
44
+ * A new record id: 8 characters from a-z and 0-9, not in `taken`.
45
+ * @param {ReadonlySet<string>} taken
46
+ * @param {() => number} [random]
47
+ * @returns {string}
48
+ */
49
+ export function newRecordId(taken: ReadonlySet<string>, random?: () => number): string;
50
+ /**
51
+ * The text of a record file: keys in order, 2-space JSON, newline at the end.
52
+ * @param {FileRecord} record
53
+ * @returns {string}
54
+ */
55
+ export function recordText(record: FileRecord): string;
56
+ /**
57
+ * The changes that bring the records to the updated ones: delete when empty, update when the text differs,
58
+ * create under a new id when there was no record.
59
+ * @param {StoredRecord[]} records
60
+ * @param {string} folder
61
+ * @param {{ src: string; next: FileRecord }[]} updates
62
+ * @param {() => number} [random]
63
+ * @returns {RecordChange[]}
64
+ */
65
+ export function writeRecords(records: StoredRecord[], folder: string, updates: {
66
+ src: string;
67
+ next: FileRecord;
68
+ }[], random?: () => number): RecordChange[];
69
+ /**
70
+ * The change that moves a record to a new file address (same file, same id), or null when there is no record.
71
+ * @param {StoredRecord[]} records
72
+ * @param {string} from
73
+ * @param {string} to
74
+ * @returns {RecordChange | null}
75
+ */
76
+ export function renameRecord(records: StoredRecord[], from: string, to: string): RecordChange | null;
77
+ /**
78
+ * The change that deletes the record of a file address, or null when there is no record.
79
+ * @param {StoredRecord[]} records
80
+ * @param {string} src
81
+ * @returns {RecordChange | null}
82
+ */
83
+ export function deleteRecord(records: StoredRecord[], src: string): RecordChange | null;
84
+ /**
85
+ * The deleted entry that holds the record of a deleted file: its text is a record for `src` and it was deleted at `at`.
86
+ * Null when a live record already has `src`, or when no entry matches.
87
+ * @template {{ path: string; text: string; at: Date | string }} D
88
+ * @param {string} src
89
+ * @param {Date | string} at
90
+ * @param {D[]} deleted
91
+ * @param {StoredRecord[]} live
92
+ * @returns {D | null}
93
+ */
94
+ export function deletedRecordFor<D extends {
95
+ path: string;
96
+ text: string;
97
+ at: Date | string;
98
+ }>(src: string, at: Date | string, deleted: D[], live: StoredRecord[]): D | null;
99
+ export function isRecordPath(path: string, folder: string): boolean;
100
+ export type FileRecord = import("./fileTags.mjs").FileRecord;
101
+ export type StoredRecord = {
102
+ path: string;
103
+ oid: string;
104
+ record: FileRecord;
105
+ };
106
+ export type RecordChange = {
107
+ path: string;
108
+ content: string | null;
109
+ oid: string | null;
110
+ };
111
+ export type RecordProblem = {
112
+ path: string;
113
+ problem: "shape" | "overlap" | "duplicate";
114
+ value?: string;
115
+ };
@@ -0,0 +1,224 @@
1
+ // The file records (src/data/files/<id>.json): which tags a file in Filer has and which were removed from it, one record per file address.
2
+ // Reads them from the editor's files, gives the changes (as env.commit takes them) a save, rename or delete makes, checks them, and finds the record of a deleted file. Pure, so node --test can run it.
3
+ // Uses fileTags.mjs (isEmpty).
4
+ import { isEmpty } from './fileTags.mjs';
5
+
6
+ /**
7
+ * @typedef {import('./fileTags.mjs').FileRecord} FileRecord
8
+ * @typedef {{ path: string; oid: string; record: FileRecord }} StoredRecord
9
+ * @typedef {{ path: string; content: string | null; oid: string | null }} RecordChange
10
+ * @typedef {{ path: string; problem: 'shape' | 'overlap' | 'duplicate'; value?: string }} RecordProblem
11
+ */
12
+
13
+ const ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789';
14
+
15
+ /** @param {unknown} list */
16
+ const isStringArray = (list) => Array.isArray(list) && list.every((item) => typeof item === 'string');
17
+
18
+ /**
19
+ * The record in a file's text, or null when the text is not a record.
20
+ * @param {string} text
21
+ * @returns {FileRecord | null}
22
+ */
23
+ export function parseRecord(text) {
24
+ let data;
25
+ try {
26
+ data = JSON.parse(text);
27
+ } catch {
28
+ return null;
29
+ }
30
+ if (!data || typeof data !== 'object' || Array.isArray(data)) return null;
31
+ if (typeof data.src !== 'string' || !data.src.startsWith('/')) return null;
32
+ const categories = data.categories === undefined ? [] : data.categories;
33
+ const removed = data.removed === undefined ? [] : data.removed;
34
+ if (!isStringArray(categories) || !isStringArray(removed)) return null;
35
+ return { src: data.src, categories: [...categories], removed: [...removed] };
36
+ }
37
+
38
+ /**
39
+ * Whether a path is a .json file directly in the folder (no subfolders).
40
+ * @param {string} path
41
+ * @param {string} folder
42
+ * @returns {boolean}
43
+ */
44
+ export const isRecordPath = (path, folder) => {
45
+ const prefix = `${folder}/`;
46
+ if (!path.startsWith(prefix)) return false;
47
+ const name = path.slice(prefix.length);
48
+ return !name.includes('/') && name.endsWith('.json');
49
+ };
50
+
51
+ /**
52
+ * Sorts by path.
53
+ * @template {{ path: string }} T
54
+ * @param {T[]} items
55
+ * @returns {T[]}
56
+ */
57
+ const byPath = (items) => items.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
58
+
59
+ /**
60
+ * The records directly in a folder, sorted by path. Files that are not records are left out.
61
+ * @param {{ path: string; oid: string; text: string }[]} files
62
+ * @param {string} folder
63
+ * @returns {StoredRecord[]}
64
+ */
65
+ export function recordsIn(files, folder) {
66
+ const out = [];
67
+ for (const file of files) {
68
+ if (!isRecordPath(file.path, folder)) continue;
69
+ const record = parseRecord(file.text);
70
+ if (record) out.push({ path: file.path, oid: file.oid, record });
71
+ }
72
+ return byPath(out);
73
+ }
74
+
75
+ /**
76
+ * The problems in the record files directly in a folder. Paths are compared and returned with `/`.
77
+ * `shape`: not a record. `overlap`: a slug is both chosen and removed. `duplicate`: a later record has the same address.
78
+ * @param {{ path: string; text: string }[]} files
79
+ * @param {string} folder
80
+ * @returns {RecordProblem[]}
81
+ */
82
+ export function recordProblems(files, folder) {
83
+ /** @type {RecordProblem[]} */
84
+ const problems = [];
85
+ /** @type {{ path: string; record: FileRecord }[]} */
86
+ const valid = [];
87
+ for (const file of files) {
88
+ const path = file.path.replace(/\\/g, '/');
89
+ if (!isRecordPath(path, folder)) continue;
90
+ const record = parseRecord(file.text);
91
+ if (!record) {
92
+ problems.push({ path, problem: 'shape' });
93
+ continue;
94
+ }
95
+ const both = record.categories.find((tag) => record.removed.includes(tag));
96
+ if (both !== undefined) problems.push({ path, problem: 'overlap', value: both });
97
+ valid.push({ path, record });
98
+ }
99
+ /** @type {Set<string>} */
100
+ const seen = new Set();
101
+ for (const { path, record } of byPath(valid)) {
102
+ if (seen.has(record.src)) problems.push({ path, problem: 'duplicate', value: record.src });
103
+ seen.add(record.src);
104
+ }
105
+ return problems;
106
+ }
107
+
108
+ /**
109
+ * The record of a file address, or undefined.
110
+ * @param {StoredRecord[]} records
111
+ * @param {string} src
112
+ * @returns {StoredRecord | undefined}
113
+ */
114
+ export function recordFor(records, src) {
115
+ return records.find((stored) => stored.record.src === src);
116
+ }
117
+
118
+ /**
119
+ * The path of a record file.
120
+ * @param {string} folder
121
+ * @param {string} id
122
+ * @returns {string}
123
+ */
124
+ export function recordPath(folder, id) {
125
+ return `${folder}/${id}.json`;
126
+ }
127
+
128
+ /**
129
+ * A new record id: 8 characters from a-z and 0-9, not in `taken`.
130
+ * @param {ReadonlySet<string>} taken
131
+ * @param {() => number} [random]
132
+ * @returns {string}
133
+ */
134
+ export function newRecordId(taken, random = Math.random) {
135
+ for (;;) {
136
+ let id = '';
137
+ for (let i = 0; i < 8; i++) id += ALPHABET[Math.floor(random() * ALPHABET.length)];
138
+ if (!taken.has(id)) return id;
139
+ }
140
+ }
141
+
142
+ /**
143
+ * The text of a record file: keys in order, 2-space JSON, newline at the end.
144
+ * @param {FileRecord} record
145
+ * @returns {string}
146
+ */
147
+ export function recordText(record) {
148
+ return `${JSON.stringify({ src: record.src, categories: record.categories, removed: record.removed }, null, 2)}\n`;
149
+ }
150
+
151
+ /** @param {string} path */
152
+ const idOf = (path) => path.slice(path.lastIndexOf('/') + 1).replace(/\.json$/, '');
153
+
154
+ /**
155
+ * The changes that bring the records to the updated ones: delete when empty, update when the text differs,
156
+ * create under a new id when there was no record.
157
+ * @param {StoredRecord[]} records
158
+ * @param {string} folder
159
+ * @param {{ src: string; next: FileRecord }[]} updates
160
+ * @param {() => number} [random]
161
+ * @returns {RecordChange[]}
162
+ */
163
+ export function writeRecords(records, folder, updates, random = Math.random) {
164
+ const taken = new Set(records.map((stored) => idOf(stored.path)));
165
+ /** @type {RecordChange[]} */
166
+ const changes = [];
167
+ for (const { src, next } of updates) {
168
+ const existing = recordFor(records, src);
169
+ if (existing) {
170
+ if (isEmpty(next)) {
171
+ changes.push({ path: existing.path, content: null, oid: existing.oid });
172
+ continue;
173
+ }
174
+ const content = recordText(next);
175
+ if (content !== recordText(existing.record)) changes.push({ path: existing.path, content, oid: existing.oid });
176
+ } else if (!isEmpty(next)) {
177
+ const id = newRecordId(taken, random);
178
+ taken.add(id);
179
+ changes.push({ path: recordPath(folder, id), content: recordText(next), oid: null });
180
+ }
181
+ }
182
+ return changes;
183
+ }
184
+
185
+ /**
186
+ * The change that moves a record to a new file address (same file, same id), or null when there is no record.
187
+ * @param {StoredRecord[]} records
188
+ * @param {string} from
189
+ * @param {string} to
190
+ * @returns {RecordChange | null}
191
+ */
192
+ export function renameRecord(records, from, to) {
193
+ const existing = recordFor(records, from);
194
+ if (!existing) return null;
195
+ return { path: existing.path, content: recordText({ ...existing.record, src: to }), oid: existing.oid };
196
+ }
197
+
198
+ /**
199
+ * The change that deletes the record of a file address, or null when there is no record.
200
+ * @param {StoredRecord[]} records
201
+ * @param {string} src
202
+ * @returns {RecordChange | null}
203
+ */
204
+ export function deleteRecord(records, src) {
205
+ const existing = recordFor(records, src);
206
+ if (!existing) return null;
207
+ return { path: existing.path, content: null, oid: existing.oid };
208
+ }
209
+
210
+ /**
211
+ * The deleted entry that holds the record of a deleted file: its text is a record for `src` and it was deleted at `at`.
212
+ * Null when a live record already has `src`, or when no entry matches.
213
+ * @template {{ path: string; text: string; at: Date | string }} D
214
+ * @param {string} src
215
+ * @param {Date | string} at
216
+ * @param {D[]} deleted
217
+ * @param {StoredRecord[]} live
218
+ * @returns {D | null}
219
+ */
220
+ export function deletedRecordFor(src, at, deleted, live) {
221
+ if (recordFor(live, src)) return null;
222
+ const instant = +new Date(at);
223
+ return deleted.find((entry) => +new Date(entry.at) === instant && parseRecord(entry.text)?.src === src) ?? null;
224
+ }
@@ -0,0 +1,224 @@
1
+ // Tests for src/fileRecords.mjs. Run: node --test packages/editor/src/fileRecords.test.mjs
2
+ import assert from 'node:assert/strict';
3
+ import { test } from 'node:test';
4
+ import {
5
+ deletedRecordFor,
6
+ deleteRecord,
7
+ newRecordId,
8
+ parseRecord,
9
+ recordFor,
10
+ recordProblems,
11
+ recordsIn,
12
+ recordText,
13
+ renameRecord,
14
+ writeRecords,
15
+ } from './fileRecords.mjs';
16
+
17
+ const F = 'src/data/files';
18
+ const S = '/images/uploads/2026/10/andromeda.webp';
19
+ const G = '/images/uploads/2026/10/galaxy.webp';
20
+
21
+ /** A stored record as read from the repo. @param {string} path @param {{ src: string; categories: string[]; removed: string[] }} record */
22
+ const stored = (path, record) => ({ path, oid: 'o1', record });
23
+
24
+ /** A random source that gives 0 eight times, then 0.99 eight times. */
25
+ const zeroThenNines = () => {
26
+ let n = 0;
27
+ return () => (n++ < 8 ? 0 : 0.99);
28
+ };
29
+
30
+ test('parseRecord reads a record with its lists', () => {
31
+ assert.deepEqual(parseRecord('{"src":"/a.webp","categories":["seo"],"removed":[]}'), {
32
+ src: '/a.webp',
33
+ categories: ['seo'],
34
+ removed: [],
35
+ });
36
+ });
37
+
38
+ test('parseRecord reads missing lists as empty', () => {
39
+ assert.deepEqual(parseRecord('{"src":"/a.webp"}'), { src: '/a.webp', categories: [], removed: [] });
40
+ });
41
+
42
+ test('parseRecord rejects a src that does not start with a slash', () => {
43
+ assert.equal(parseRecord('{"src":"a.webp"}'), null);
44
+ });
45
+
46
+ test('parseRecord rejects a list that is not an array of strings', () => {
47
+ assert.equal(parseRecord('{"src":"/a","categories":"seo"}'), null);
48
+ });
49
+
50
+ test('parseRecord rejects text that is not JSON', () => {
51
+ assert.equal(parseRecord('['), null);
52
+ });
53
+
54
+ test('recordsIn keeps only the record files directly in the folder', () => {
55
+ const valid = '{"src":"/a.webp","categories":[],"removed":[]}';
56
+ const files = [
57
+ { path: `${F}/ab12cd34.json`, oid: 'o1', text: valid },
58
+ { path: `${F}/sub/x.json`, oid: 'o2', text: valid },
59
+ { path: `${F}/bad.json`, oid: 'o3', text: '{' },
60
+ { path: 'src/data/categories/seo.json', oid: 'o4', text: valid },
61
+ ];
62
+ const records = recordsIn(files, F);
63
+ assert.deepEqual(
64
+ records.map((r) => r.path),
65
+ [`${F}/ab12cd34.json`],
66
+ );
67
+ assert.equal(records[0].oid, 'o1');
68
+ });
69
+
70
+ test('recordFor finds the record of a file address', () => {
71
+ const records = [stored(`${F}/aaaaaaaa.json`, { src: S, categories: ['seo'], removed: [] })];
72
+ assert.equal(recordFor(records, S), records[0]);
73
+ assert.equal(recordFor(records, '/other.webp'), undefined);
74
+ });
75
+
76
+ test('newRecordId gives 8 characters from the first try when none is taken', () => {
77
+ assert.equal(
78
+ newRecordId(new Set(), () => 0),
79
+ 'aaaaaaaa',
80
+ );
81
+ });
82
+
83
+ test('newRecordId draws again when the id is taken', () => {
84
+ assert.equal(newRecordId(new Set(['aaaaaaaa']), zeroThenNines()), '99999999');
85
+ });
86
+
87
+ test('newRecordId with Math.random gives 8 lower case letters or digits', () => {
88
+ assert.match(newRecordId(new Set()), /^[a-z0-9]{8}$/);
89
+ });
90
+
91
+ test('recordText writes the keys in order, 2-space JSON, with a newline at the end', () => {
92
+ assert.equal(
93
+ recordText({ src: '/a.webp', categories: ['seo'], removed: [] }),
94
+ '{\n "src": "/a.webp",\n "categories": [\n "seo"\n ],\n "removed": []\n}\n',
95
+ );
96
+ });
97
+
98
+ test('writeRecords creates a record for a new file address under a new id', () => {
99
+ const next = { src: S, categories: ['seo'], removed: [] };
100
+ assert.deepEqual(
101
+ writeRecords([], F, [{ src: S, next }], () => 0),
102
+ [{ path: `${F}/aaaaaaaa.json`, content: recordText(next), oid: null }],
103
+ );
104
+ });
105
+
106
+ test('writeRecords updates a record whose lists changed', () => {
107
+ const records = [stored(`${F}/aaaaaaaa.json`, { src: S, categories: ['seo'], removed: [] })];
108
+ const next = { src: S, categories: [], removed: ['seo'] };
109
+ assert.deepEqual(writeRecords(records, F, [{ src: S, next }]), [{ path: `${F}/aaaaaaaa.json`, content: recordText(next), oid: 'o1' }]);
110
+ });
111
+
112
+ test('writeRecords changes nothing when the record is the same', () => {
113
+ const records = [stored(`${F}/aaaaaaaa.json`, { src: S, categories: ['seo'], removed: [] })];
114
+ const next = { src: S, categories: ['seo'], removed: [] };
115
+ assert.deepEqual(writeRecords(records, F, [{ src: S, next }]), []);
116
+ });
117
+
118
+ test('writeRecords deletes a record whose lists are now empty', () => {
119
+ const records = [stored(`${F}/aaaaaaaa.json`, { src: S, categories: ['seo'], removed: [] })];
120
+ const next = { src: S, categories: [], removed: [] };
121
+ assert.deepEqual(writeRecords(records, F, [{ src: S, next }]), [{ path: `${F}/aaaaaaaa.json`, content: null, oid: 'o1' }]);
122
+ });
123
+
124
+ test('writeRecords changes nothing when there is no record and the lists are empty', () => {
125
+ const next = { src: S, categories: [], removed: [] };
126
+ assert.deepEqual(writeRecords([], F, [{ src: S, next }]), []);
127
+ });
128
+
129
+ test('writeRecords gives two new file addresses two different ids', () => {
130
+ const a = { src: S, categories: ['seo'], removed: [] };
131
+ const b = { src: G, categories: ['seo'], removed: [] };
132
+ // A constant 0 would give the same id again and loop forever, so the second id draws 0.99 values.
133
+ const changes = writeRecords(
134
+ [],
135
+ F,
136
+ [
137
+ { src: S, next: a },
138
+ { src: G, next: b },
139
+ ],
140
+ zeroThenNines(),
141
+ );
142
+ assert.deepEqual(
143
+ changes.map((c) => c.path),
144
+ [`${F}/aaaaaaaa.json`, `${F}/99999999.json`],
145
+ );
146
+ });
147
+
148
+ test('renameRecord keeps the id and the lists and sets the new address', () => {
149
+ const records = [stored(`${F}/aaaaaaaa.json`, { src: S, categories: ['seo'], removed: ['x'] })];
150
+ assert.deepEqual(renameRecord(records, S, G), {
151
+ path: `${F}/aaaaaaaa.json`,
152
+ content: recordText({ src: G, categories: ['seo'], removed: ['x'] }),
153
+ oid: 'o1',
154
+ });
155
+ });
156
+
157
+ test('renameRecord gives null when there is no record', () => {
158
+ const records = [stored(`${F}/aaaaaaaa.json`, { src: S, categories: ['seo'], removed: [] })];
159
+ assert.equal(renameRecord(records, '/x.webp', '/y.webp'), null);
160
+ });
161
+
162
+ test('deleteRecord deletes the record of the address', () => {
163
+ const records = [stored(`${F}/aaaaaaaa.json`, { src: S, categories: ['seo'], removed: [] })];
164
+ assert.deepEqual(deleteRecord(records, S), { path: `${F}/aaaaaaaa.json`, content: null, oid: 'o1' });
165
+ });
166
+
167
+ test('deleteRecord gives null when there is no record', () => {
168
+ assert.equal(deleteRecord([], S), null);
169
+ });
170
+
171
+ const P = `${F}/aaa.json`;
172
+ const valid = (src) => JSON.stringify({ src, categories: [], removed: [] });
173
+
174
+ test('recordProblems gives nothing for a valid record', () => {
175
+ assert.deepEqual(recordProblems([{ path: P, text: valid(S) }], F), []);
176
+ });
177
+
178
+ test('recordProblems reports a record without a slash-led src as shape', () => {
179
+ assert.deepEqual(recordProblems([{ path: P, text: '{"src":"a.webp"}' }], F), [{ path: P, problem: 'shape' }]);
180
+ });
181
+
182
+ test('recordProblems reports a slug that is both chosen and removed as overlap', () => {
183
+ const text = '{"src":"/a.webp","categories":["seo"],"removed":["seo"]}';
184
+ assert.deepEqual(recordProblems([{ path: P, text }], F), [{ path: P, problem: 'overlap', value: 'seo' }]);
185
+ });
186
+
187
+ test('recordProblems reports the later of two records for one address as duplicate', () => {
188
+ const files = [
189
+ { path: `${F}/aaa.json`, text: valid('/a.webp') },
190
+ { path: `${F}/bbb.json`, text: valid('/a.webp') },
191
+ ];
192
+ assert.deepEqual(recordProblems(files, F), [{ path: `${F}/bbb.json`, problem: 'duplicate', value: '/a.webp' }]);
193
+ });
194
+
195
+ test('recordProblems reads backslash paths and returns them with slashes', () => {
196
+ const files = [{ path: 'src\\data\\files\\aaa.json', text: '{"src":"a"}' }];
197
+ assert.deepEqual(recordProblems(files, F), [{ path: 'src/data/files/aaa.json', problem: 'shape' }]);
198
+ });
199
+
200
+ test('recordProblems ignores files outside the folder', () => {
201
+ assert.deepEqual(recordProblems([{ path: 'src/data/categories/seo.json', text: '{"src":"a"}' }], F), []);
202
+ });
203
+
204
+ test('deletedRecordFor finds the deleted entry of a file at the time it was deleted', () => {
205
+ const at = new Date('2026-11-03T10:00:00Z');
206
+ const deleted = [{ path: `${F}/x.json`, text: valid(S), at }];
207
+ assert.equal(deletedRecordFor(S, '2026-11-03T10:00:00Z', deleted, []), deleted[0]);
208
+ });
209
+
210
+ test('deletedRecordFor gives null when the deletion time differs by one second', () => {
211
+ const deleted = [{ path: `${F}/x.json`, text: valid(S), at: new Date('2026-11-03T10:00:00Z') }];
212
+ assert.equal(deletedRecordFor(S, '2026-11-03T10:00:01Z', deleted, []), null);
213
+ });
214
+
215
+ test('deletedRecordFor gives null when a live record already has the address', () => {
216
+ const deleted = [{ path: `${F}/x.json`, text: valid(S), at: new Date('2026-11-03T10:00:00Z') }];
217
+ const live = [stored(`${F}/aaaaaaaa.json`, { src: S, categories: [], removed: [] })];
218
+ assert.equal(deletedRecordFor(S, '2026-11-03T10:00:00Z', deleted, live), null);
219
+ });
220
+
221
+ test('deletedRecordFor gives null for an entry of another address', () => {
222
+ const deleted = [{ path: `${F}/x.json`, text: valid(G), at: new Date('2026-11-03T10:00:00Z') }];
223
+ assert.equal(deletedRecordFor(S, '2026-11-03T10:00:00Z', deleted, []), null);
224
+ });
@@ -0,0 +1,30 @@
1
+ /**
2
+ * @typedef {import('./fileRecords.mjs').StoredRecord} StoredRecord
3
+ */
4
+ /**
5
+ * The tags of each file, keyed by file address (src). Records with no tags are left out.
6
+ * @param {StoredRecord[]} records
7
+ * @returns {Map<string, string[]>}
8
+ */
9
+ export function tagIndex(records: StoredRecord[]): Map<string, string[]>;
10
+ /**
11
+ * The filter's choices: the tags (in their order) that some file in the index has, plus the chosen tag when it is among the tags.
12
+ * @template {{ value: string; label: string }} O
13
+ * @param {Map<string, string[]>} index
14
+ * @param {O[]} tags
15
+ * @param {string} chosen
16
+ * @returns {O[]}
17
+ */
18
+ export function tagFilterOptions<O extends {
19
+ value: string;
20
+ label: string;
21
+ }>(index: Map<string, string[]>, tags: O[], chosen: string): O[];
22
+ /**
23
+ * The files that have a tag, in their order. With an empty tag, all files are kept.
24
+ * @param {string[]} srcs
25
+ * @param {Map<string, string[]>} index
26
+ * @param {string} tag
27
+ * @returns {string[]}
28
+ */
29
+ export function withTag(srcs: string[], index: Map<string, string[]>, tag: string): string[];
30
+ export type StoredRecord = import("./fileRecords.mjs").StoredRecord;