@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.
- package/CHANGELOG.md +16 -0
- package/astro.mjs +3 -0
- package/bin/morpheme.mjs +3 -1
- package/defaults/blockTexts.ts +2 -2
- package/defaults/collections.ts +10 -0
- package/defaults/editor.ts +57 -53
- package/defaults/privacy.ts +4 -1
- package/defaults/rules.ts +36 -38
- package/defaults/standardPages.ts +0 -3
- package/defaults/texts.ts +11 -2
- package/demo/src/content/pages/about.md +2 -0
- package/icons/lucide/browser-fill.svg +1 -0
- package/icons/lucide/buildings-fill.svg +1 -0
- package/icons/lucide/certificate-fill.svg +1 -0
- package/icons/lucide/chart-line-up-fill.svg +1 -0
- package/icons/lucide/chats-fill.svg +1 -0
- package/icons/lucide/envelope-simple-fill.svg +1 -0
- package/icons/lucide/file-text-fill.svg +1 -0
- package/icons/lucide/folder-simple-fill.svg +1 -0
- package/icons/lucide/gauge.svg +1 -1
- package/icons/lucide/gear-six.svg +1 -0
- package/icons/lucide/globe-simple.svg +1 -0
- package/icons/lucide/heart-fill.svg +1 -0
- package/icons/lucide/layout.svg +1 -0
- package/icons/lucide/list-checks-fill.svg +1 -0
- package/icons/lucide/magnifying-glass-fill.svg +1 -0
- package/icons/lucide/megaphone-fill.svg +1 -0
- package/icons/lucide/newspaper-fill.svg +1 -0
- package/icons/lucide/paint-brush-broad-fill.svg +1 -0
- package/icons/lucide/puzzle-piece-fill.svg +1 -0
- package/icons/lucide/shield-check-fill.svg +1 -0
- package/icons/lucide/sidebar-simple.svg +1 -1
- package/icons/lucide/sidebar.svg +1 -0
- package/icons/lucide/square-half-bottom-fill.svg +1 -0
- package/icons/lucide/square-half.svg +1 -0
- package/icons/lucide/squares-four-fill.svg +1 -0
- package/icons/lucide/storefront-fill.svg +1 -0
- package/icons/lucide/tag-fill.svg +1 -0
- package/icons/lucide/toggle-right-fill.svg +1 -0
- package/icons/lucide/trash-fill.svg +1 -0
- package/icons/lucide/user-fill.svg +1 -0
- package/icons/lucide/wrench-fill.svg +1 -0
- package/icons/material-symbols/browser-fill.svg +1 -0
- package/icons/material-symbols/buildings-fill.svg +1 -0
- package/icons/material-symbols/certificate-fill.svg +1 -0
- package/icons/material-symbols/chart-line-up-fill.svg +1 -0
- package/icons/material-symbols/chats-fill.svg +1 -0
- package/icons/material-symbols/envelope-simple-fill.svg +1 -0
- package/icons/material-symbols/file-text-fill.svg +1 -0
- package/icons/material-symbols/folder-simple-fill.svg +1 -0
- package/icons/material-symbols/gauge.svg +1 -1
- package/icons/material-symbols/gear-six.svg +1 -0
- package/icons/material-symbols/globe-simple.svg +1 -0
- package/icons/material-symbols/heart-fill.svg +1 -0
- package/icons/material-symbols/layout.svg +1 -0
- package/icons/material-symbols/list-checks-fill.svg +1 -0
- package/icons/material-symbols/magnifying-glass-fill.svg +1 -0
- package/icons/material-symbols/megaphone-fill.svg +1 -0
- package/icons/material-symbols/newspaper-fill.svg +1 -0
- package/icons/material-symbols/paint-brush-broad-fill.svg +1 -0
- package/icons/material-symbols/puzzle-piece-fill.svg +1 -0
- package/icons/material-symbols/shield-check-fill.svg +1 -0
- package/icons/material-symbols/sidebar-simple.svg +1 -1
- package/icons/material-symbols/sidebar.svg +1 -0
- package/icons/material-symbols/square-half-bottom-fill.svg +1 -0
- package/icons/material-symbols/square-half.svg +1 -0
- package/icons/material-symbols/squares-four-fill.svg +1 -0
- package/icons/material-symbols/storefront-fill.svg +1 -0
- package/icons/material-symbols/tag-fill.svg +1 -0
- package/icons/material-symbols/toggle-right-fill.svg +1 -0
- package/icons/material-symbols/trash-fill.svg +1 -0
- package/icons/material-symbols/user-fill.svg +1 -0
- package/icons/material-symbols/wrench-fill.svg +1 -0
- package/icons/phosphor/browser-fill.svg +1 -0
- package/icons/phosphor/buildings-fill.svg +1 -0
- package/icons/phosphor/certificate-fill.svg +1 -0
- package/icons/phosphor/chart-line-up-fill.svg +1 -0
- package/icons/phosphor/chats-fill.svg +1 -0
- package/icons/phosphor/envelope-simple-fill.svg +1 -0
- package/icons/phosphor/file-text-fill.svg +1 -0
- package/icons/phosphor/folder-simple-fill.svg +1 -0
- package/icons/phosphor/gear-six.svg +1 -0
- package/icons/phosphor/globe-simple.svg +1 -0
- package/icons/phosphor/heart-fill.svg +1 -0
- package/icons/phosphor/layout.svg +1 -0
- package/icons/phosphor/list-checks-fill.svg +1 -0
- package/icons/phosphor/magnifying-glass-fill.svg +1 -0
- package/icons/phosphor/megaphone-fill.svg +1 -0
- package/icons/phosphor/newspaper-fill.svg +1 -0
- package/icons/phosphor/paint-brush-broad-fill.svg +1 -0
- package/icons/phosphor/puzzle-piece-fill.svg +1 -0
- package/icons/phosphor/shield-check-fill.svg +1 -0
- package/icons/phosphor/sidebar.svg +1 -0
- package/icons/phosphor/square-half-bottom-fill.svg +1 -0
- package/icons/phosphor/square-half.svg +1 -0
- package/icons/phosphor/squares-four-fill.svg +1 -0
- package/icons/phosphor/storefront-fill.svg +1 -0
- package/icons/phosphor/tag-fill.svg +1 -0
- package/icons/phosphor/toggle-right-fill.svg +1 -0
- package/icons/phosphor/trash-fill.svg +1 -0
- package/icons/phosphor/user-fill.svg +1 -0
- package/icons/phosphor/wrench-fill.svg +1 -0
- package/lib/breakpoints.d.mts +13 -0
- package/lib/breakpoints.mjs +20 -0
- package/lib/breakpoints.test.mjs +39 -0
- package/lib/build-lane.d.mts +15 -0
- package/lib/build-lane.mjs +23 -0
- package/lib/build-lane.test.mjs +74 -0
- package/lib/build-settings.d.mts +16 -0
- package/lib/build-settings.mjs +39 -0
- package/lib/build-settings.test.mjs +41 -0
- package/lib/contracts.d.mts +3 -2
- package/lib/contracts.mjs +3 -2
- package/lib/drawer-swipe.d.mts +10 -0
- package/lib/drawer-swipe.mjs +17 -0
- package/lib/drawer-swipe.test.mjs +33 -0
- package/lib/drawerKeys.d.mts +9 -0
- package/lib/drawerKeys.mjs +26 -0
- package/lib/drawerKeys.test.mjs +21 -0
- package/lib/eyebrow.d.mts +6 -0
- package/lib/eyebrow.mjs +24 -0
- package/lib/eyebrow.test.mjs +20 -0
- package/lib/front-sections.test.mjs +82 -0
- package/lib/heading-levels.d.mts +7 -0
- package/lib/heading-levels.mjs +16 -0
- package/lib/heading-levels.test.mjs +37 -0
- package/lib/inline.ts +3 -2
- package/lib/markdown.ts +2 -0
- package/lib/materialIcon.d.mts +21 -0
- package/lib/materialIcon.mjs +29 -0
- package/lib/materialIcon.test.mjs +45 -0
- package/lib/menu-layout.d.mts +11 -0
- package/lib/menu-layout.mjs +14 -0
- package/lib/menu-layout.test.mjs +23 -0
- package/lib/menu.d.mts +59 -0
- package/lib/menu.mjs +129 -0
- package/lib/menu.test.mjs +291 -0
- package/lib/rehype-emphasize.d.mts +4 -0
- package/lib/rehype-emphasize.mjs +20 -0
- package/lib/rehype-emphasize.test.mjs +39 -0
- package/lib/section-in-view.d.mts +8 -0
- package/lib/section-in-view.mjs +25 -0
- package/lib/section-in-view.test.mjs +35 -0
- package/lib/section-marker.d.mts +5 -0
- package/lib/section-marker.mjs +18 -0
- package/lib/section-marker.test.mjs +22 -0
- package/lib/section-ranges.d.mts +12 -0
- package/lib/section-ranges.mjs +43 -0
- package/lib/section-ranges.test.mjs +61 -0
- package/lib/sheet-stops.d.mts +68 -0
- package/lib/sheet-stops.mjs +198 -0
- package/lib/sheet-stops.test.mjs +155 -0
- package/lib/still-timer.d.mts +5 -0
- package/lib/still-timer.mjs +17 -0
- package/lib/still-timer.test.mjs +42 -0
- package/lib/text-sections.d.mts +10 -0
- package/lib/text-sections.mjs +45 -0
- package/lib/text-sections.test.mjs +78 -0
- package/lib/video.mjs +2 -1
- package/lib/without-h1.d.mts +1 -0
- package/lib/without-h1.mjs +7 -0
- package/lib/without-h1.test.mjs +26 -0
- package/lib/word-at.d.mts +6 -0
- package/lib/word-at.mjs +20 -0
- package/lib/word-at.test.mjs +19 -0
- package/package.json +3 -1
- package/pages/editor/version.json.js +22 -2
- package/scripts/already-checked.mjs +11 -0
- package/scripts/already-checked.test.mjs +18 -0
- package/scripts/build-settings-problems.mjs +27 -0
- package/scripts/build-settings-problems.test.mjs +35 -0
- package/scripts/check-content.mjs +42 -4
- package/scripts/check-worker.mjs +96 -4
- package/scripts/ci-build.mjs +85 -0
- package/scripts/ci-build.test.mjs +137 -0
- package/scripts/editor-roundtrip.mjs +62 -3
- package/scripts/editor-screens.mjs +21 -7
- package/scripts/git-env.mjs +6 -0
- package/scripts/step-command.mjs +34 -0
- package/scripts/step-command.test.mjs +50 -0
- package/scripts/text-problems.mjs +83 -0
- package/scripts/text-problems.test.mjs +74 -0
- package/scripts/worker-modules.mjs +11 -1
- package/site/breakpoints.d.mts +6 -0
- package/site/breakpoints.mjs +52 -0
- package/site/breakpoints.test.mjs +36 -0
- package/site/casing.d.mts +3 -0
- package/site/casing.mjs +26 -0
- package/site/content.config.ts +2 -0
- package/site/content.ts +2 -4
- package/site/defineSite.d.mts +1 -0
- package/site/defineSite.mjs +3 -0
- package/site/home.ts +3 -2
- package/site/icons.d.mts +116 -4
- package/site/icons.mjs +62 -6
- package/site/images.mjs +1 -1
- package/site/live-diff.d.mts +22 -0
- package/site/live-diff.mjs +98 -0
- package/site/live-diff.test.mjs +235 -0
- package/site/materialSymbols.d.mts +5 -0
- package/site/materialSymbols.mjs +19 -0
- package/site/sections.ts +1 -1
- package/site/theme.d.mts +1 -1
- package/site/theme.mjs +7 -1
- package/src/ActionParts.tsx +1 -1
- package/src/Advanced.tsx +101 -20
- package/src/App.tsx +47 -121
- package/src/AppList.tsx +1 -1
- package/src/BlockParts.tsx +3 -3
- package/src/Colors.tsx +1 -1
- package/src/CommunicationParts.tsx +1 -1
- package/src/ContainmentParts.tsx +80 -28
- package/src/Deleted.tsx +18 -4
- package/src/EditorFooter.tsx +14 -0
- package/src/EntryForm.tsx +75 -39
- package/src/FormFrame.tsx +3 -8
- package/src/Library.tsx +46 -51
- package/src/Licenses.tsx +27 -11
- package/src/MainDrawerParts.tsx +60 -0
- package/src/MainMenu.tsx +136 -0
- package/src/MarkdownEditor.tsx +175 -32
- package/src/Media.tsx +69 -16
- package/src/MediaTags.tsx +91 -0
- package/src/ModalSheetSample.tsx +47 -0
- package/src/NavigationParts.tsx +67 -21
- package/src/NavigationRestParts.tsx +1 -1
- package/src/Parts.tsx +71 -0
- package/src/PartsShown.tsx +1 -1
- package/src/PickerParts.tsx +2 -2
- package/src/Redirects.tsx +3 -2
- package/src/Screen.tsx +93 -90
- package/src/SelectionParts.tsx +22 -6
- package/src/SiteLogoLink.tsx +14 -0
- package/src/StartFab.tsx +22 -0
- package/src/StartStatus.tsx +1 -1
- package/src/autoTags.d.mts +28 -0
- package/src/autoTags.mjs +68 -0
- package/src/autoTags.test.mjs +109 -0
- package/src/buildSettings.test.mjs +20 -0
- package/src/buildSettings.ts +20 -0
- package/src/checkSite.ts +14 -1
- package/src/deploy.ts +7 -9
- package/src/editor.css +150 -17
- package/src/eyebrow.d.mts +5 -0
- package/src/eyebrow.mjs +53 -0
- package/src/eyebrowState.d.mts +21 -0
- package/src/eyebrowState.mjs +62 -0
- package/src/eyebrowState.test.mjs +78 -0
- package/src/fileRecords.d.mts +115 -0
- package/src/fileRecords.mjs +224 -0
- package/src/fileRecords.test.mjs +224 -0
- package/src/fileTagFilter.d.mts +30 -0
- package/src/fileTagFilter.mjs +44 -0
- package/src/fileTagFilter.test.mjs +57 -0
- package/src/fileTags.d.mts +56 -0
- package/src/fileTags.mjs +100 -0
- package/src/fileTags.test.mjs +127 -0
- package/src/firstHeading.d.mts +6 -0
- package/src/firstHeading.mjs +37 -0
- package/src/i18n/da-DK/advanced.ts +27 -12
- package/src/i18n/da-DK/app.ts +9 -7
- package/src/i18n/da-DK/apps.ts +16 -16
- package/src/i18n/da-DK/blockSettings.ts +9 -9
- package/src/i18n/da-DK/blocks.ts +2 -2
- package/src/i18n/da-DK/check.ts +5 -2
- package/src/i18n/da-DK/cms.ts +14 -12
- package/src/i18n/da-DK/colors.ts +5 -5
- package/src/i18n/da-DK/common.ts +2 -2
- package/src/i18n/da-DK/company.ts +3 -3
- package/src/i18n/da-DK/consents.ts +6 -6
- package/src/i18n/da-DK/forms.ts +6 -6
- package/src/i18n/da-DK/history.ts +6 -6
- package/src/i18n/da-DK/keywords.ts +2 -2
- package/src/i18n/da-DK/library.ts +17 -15
- package/src/i18n/da-DK/licenses.ts +4 -4
- package/src/i18n/da-DK/media.ts +10 -4
- package/src/i18n/da-DK/parts.ts +62 -25
- package/src/i18n/da-DK/preview.ts +5 -6
- package/src/i18n/da-DK/redirects.ts +7 -7
- package/src/i18n/da-DK/seo.ts +1 -1
- package/src/i18n/da-DK/text.ts +2 -0
- package/src/i18n/da-DK/theme.ts +5 -5
- package/src/i18n/en-US/advanced.ts +34 -18
- package/src/i18n/en-US/app.ts +7 -5
- package/src/i18n/en-US/apps.ts +9 -9
- package/src/i18n/en-US/blockSettings.ts +4 -4
- package/src/i18n/en-US/blocks.ts +6 -6
- package/src/i18n/en-US/check.ts +6 -3
- package/src/i18n/en-US/cms.ts +15 -14
- package/src/i18n/en-US/colors.ts +18 -18
- package/src/i18n/en-US/common.ts +3 -3
- package/src/i18n/en-US/consents.ts +4 -4
- package/src/i18n/en-US/forms.ts +5 -5
- package/src/i18n/en-US/history.ts +2 -2
- package/src/i18n/en-US/library.ts +21 -15
- package/src/i18n/en-US/licenses.ts +6 -6
- package/src/i18n/en-US/media.ts +7 -1
- package/src/i18n/en-US/parts.ts +60 -21
- package/src/i18n/en-US/preview.ts +5 -6
- package/src/i18n/en-US/redirects.ts +3 -3
- package/src/i18n/en-US/seo.ts +1 -1
- package/src/i18n/en-US/text.ts +3 -0
- package/src/i18n/en-US/theme.ts +6 -6
- package/src/icon.tsx +90 -27
- package/src/licenseData.test.mjs +28 -0
- package/src/licenseData.ts +12 -1
- package/src/markdown.d.mts +2 -0
- package/src/markdown.mjs +73 -5
- package/src/markdown.test.mjs +74 -0
- package/src/materialIcons.ts +8 -0
- package/src/menuChildren.ts +36 -0
- package/src/menuOpen.ts +34 -0
- package/src/pagePreview.tsx +23 -19
- package/src/r2Caching.ts +14 -0
- package/src/sectionFrame.ts +39 -0
- package/src/sectionState.d.mts +19 -0
- package/src/sectionState.mjs +31 -0
- package/src/sectionState.test.mjs +59 -0
- package/src/sections.d.mts +9 -7
- package/src/sections.mjs +74 -27
- package/src/sections.test.mjs +59 -0
- package/src/sidebar.ts +2 -2
- package/src/site.ts +27 -17
- package/src/stillCursor.d.mts +8 -0
- package/src/stillCursor.mjs +19 -0
- package/src/stillCursor.test.mjs +26 -0
- package/src/tagUses.d.mts +47 -0
- package/src/tagUses.mjs +121 -0
- package/src/tagUses.test.mjs +141 -0
- package/src/themeUi.ts +10 -0
- package/src/topBarSlot.tsx +37 -0
- package/src/ui.tsx +654 -150
- package/src/virtual.d.ts +5 -0
- package/starter/README.md +2 -1
- package/starter/package.json +2 -1
- package/starter/wrangler.jsonc +3 -2
- package/theme/blocks/cards/LinkCards.tsx +2 -1
- package/theme/blocks/hero/View.tsx +2 -1
- package/theme/blocks/video/View.tsx +2 -1
- package/theme/components/BlogIndex.astro +2 -2
- package/theme/components/BlogPosts.astro +1 -1
- package/theme/components/BlogPosts.tsx +9 -6
- package/theme/components/Container.astro +1 -1
- package/theme/components/CookieConsent.astro +1 -1
- package/theme/components/EndBlock.astro +1 -1
- package/theme/components/Footer.astro +5 -5
- package/theme/components/Header.astro +3 -3
- package/theme/components/Icon.astro +2 -2
- package/theme/components/MobileMenu.tsx +72 -61
- package/theme/components/MobileNav.astro +2 -2
- package/theme/components/PageHeader.astro +2 -2
- package/theme/components/PageSection.astro +1 -1
- package/theme/components/PopupButton.tsx +2 -2
- package/theme/components/PopupView.tsx +2 -2
- package/theme/components/PostList.tsx +7 -3
- package/theme/components/RichText.astro +22 -7
- package/theme/components/Section.astro +17 -10
- package/theme/components/SectionHeading.tsx +1 -1
- package/theme/components/home/PageBlock.astro +4 -4
- package/theme/components/home/Text.astro +3 -3
- package/theme/layouts/PageLayout.astro +3 -3
- package/theme/layouts/PostLayout.astro +9 -8
- package/theme/styles/base.css +13 -3
- package/theme/styles/blocks.css +97 -10
- package/theme/styles/components.css +9 -9
- package/theme/styles/elevation.test.mjs +168 -0
- package/theme/styles/global.css +65 -59
- package/theme/styles/ui.css +460 -131
- package/theme/templates/Home.astro +1 -1
- package/theme/templates/New.astro +5 -5
- package/theme/templates/NotFound.astro +2 -2
- package/theme/ui/BottomSheet.tsx +118 -21
- package/theme/ui/BottomSheetResizeIcon.tsx +13 -0
- package/theme/ui/Button.tsx +4 -3
- package/theme/ui/Dialog.tsx +1 -1
- package/theme/ui/FabMenu.tsx +13 -12
- package/theme/ui/LoadingIndicator.tsx +1 -1
- package/theme/ui/NavigationDrawer.tsx +116 -15
- package/theme/ui/NavigationRail.tsx +22 -13
- package/theme/ui/Search.tsx +1 -1
- package/theme/ui/SettingRow.tsx +31 -0
- package/theme/ui/Switch.tsx +11 -11
- package/theme/ui/TextField.tsx +17 -6
- package/theme/ui/bottomSheet.script.ts +3 -2
- package/theme/ui/bottomSheetControl.ts +131 -46
- package/theme/ui/fabMenu.script.ts +69 -53
- package/theme/ui/modalSheetSize.ts +214 -0
- package/theme/ui/navigationDrawerControl.ts +35 -0
- package/theme/ui/search.script.ts +4 -2
- package/worker/index.js +3 -0
- package/worker/plan-status.js +46 -0
- package/worker/push.js +7 -5
- package/worker/r2-status.js +2 -1
- package/icons/lucide/desktop.svg +0 -1
- package/icons/material-symbols/desktop.svg +0 -1
- 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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
|
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 =
|
|
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 "# …"
|
|
242
|
-
*
|
|
243
|
-
*
|
|
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
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
|
2
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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:
|
|
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,
|
|
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
|
|
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
|
-
* ("
|
|
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,
|
|
307
|
-
* standard blocks' and the apps' lists'. */
|
|
308
|
-
export const icons = {
|
|
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
|
+
};
|
package/src/tagUses.mjs
ADDED
|
@@ -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
|
+
}
|