@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
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { filesIn, newlyUsed, autoTagChanges } from './autoTags.mjs';
|
|
4
|
+
import { recordText } from './fileRecords.mjs';
|
|
5
|
+
|
|
6
|
+
const F = 'src/data/files';
|
|
7
|
+
const A = '/images/uploads/2026/10/a.webp';
|
|
8
|
+
const B = '/images/uploads/2026/10/b.webp';
|
|
9
|
+
const P = '/filer/price-list.pdf';
|
|
10
|
+
const stored = (src, categories, removed, oid = 'o1', id = 'r1') => ({
|
|
11
|
+
path: `${F}/${id}.json`,
|
|
12
|
+
oid,
|
|
13
|
+
record: { src, categories, removed },
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('filesIn finds valid address in markdown image', () => {
|
|
17
|
+
assert.deepEqual(filesIn('text  end', [A, B]), [A]);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('filesIn finds valid address in JSON string', () => {
|
|
21
|
+
assert.deepEqual(filesIn('"image": "' + A + '"', [A]), [A]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('filesIn finds valid address after colon', () => {
|
|
25
|
+
assert.deepEqual(filesIn('file: ' + P, [P]), [P]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('filesIn finds valid address in markdown link', () => {
|
|
29
|
+
assert.deepEqual(filesIn('[Prisliste](' + P + ')', [P]), [P]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('filesIn finds valid address in HTML entity', () => {
|
|
33
|
+
assert.deepEqual(filesIn('data-props="{"file":"' + P + '"}"', [P]), [P]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('filesIn does not match if followed by hyphen and more text', () => {
|
|
37
|
+
assert.deepEqual(filesIn('/images/uploads/2026/10/a.webp-old.webp', [A]), []);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('filesIn returns unique matches in input order', () => {
|
|
41
|
+
assert.deepEqual(filesIn(A + ' ' + A, [A, A]), [A]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('newlyUsed returns files added but not removed', () => {
|
|
45
|
+
assert.deepEqual(newlyUsed('', ' ', [A, B]), [B]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('newlyUsed returns file added from empty before', () => {
|
|
49
|
+
assert.deepEqual(newlyUsed('', '[p](' + P + ')', [P]), [P]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('newlyUsed returns empty if file removed', () => {
|
|
53
|
+
assert.deepEqual(newlyUsed('', '', [A]), []);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('autoTagChanges creates new record with page tags', () => {
|
|
57
|
+
const expected = [
|
|
58
|
+
{
|
|
59
|
+
path: 'src/data/files/aaaaaaaa.json',
|
|
60
|
+
content: recordText({ src: A, categories: ['design'], removed: [] }),
|
|
61
|
+
oid: null,
|
|
62
|
+
},
|
|
63
|
+
];
|
|
64
|
+
assert.deepEqual(
|
|
65
|
+
autoTagChanges([], F, [A], ['design'], () => 0),
|
|
66
|
+
expected,
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('autoTagChanges updates existing record with new tags', () => {
|
|
71
|
+
const expected = [
|
|
72
|
+
{
|
|
73
|
+
path: F + '/r1.json',
|
|
74
|
+
content: recordText({ src: A, categories: ['seo', 'design'], removed: [] }),
|
|
75
|
+
oid: 'o1',
|
|
76
|
+
},
|
|
77
|
+
];
|
|
78
|
+
assert.deepEqual(autoTagChanges([stored(A, ['seo'], [])], F, [A], ['design']), expected);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('autoTagChanges does not add removed tags', () => {
|
|
82
|
+
assert.deepEqual(autoTagChanges([stored(A, [], ['design'])], F, [A], ['design']), []);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('autoTagChanges does not update if tags already present', () => {
|
|
86
|
+
assert.deepEqual(autoTagChanges([stored(A, ['design'], [])], F, [A], ['design']), []);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('autoTagChanges returns empty for no page tags', () => {
|
|
90
|
+
assert.deepEqual(autoTagChanges([], F, [A], []), []);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('autoTagChanges returns empty for no sources', () => {
|
|
94
|
+
assert.deepEqual(autoTagChanges([], F, [], ['design']), []);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('autoTagChanges handles duplicate sources', () => {
|
|
98
|
+
assert.equal(autoTagChanges([], F, [A, A], ['design']).length, 1);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('autoTagChanges creates unique paths for multiple new files', () => {
|
|
102
|
+
let n = 0;
|
|
103
|
+
const random = () => (n++ < 8 ? 0 : 0.99);
|
|
104
|
+
const changes = autoTagChanges([], F, [A, B], ['design'], random);
|
|
105
|
+
assert.equal(changes.length, 2);
|
|
106
|
+
assert.equal(changes[0].oid, null);
|
|
107
|
+
assert.equal(changes[1].oid, null);
|
|
108
|
+
assert.notEqual(changes[0].path, changes[1].path);
|
|
109
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Tests for src/buildSettings.ts: the build settings read from and written to the text of src/data/build.json, so the
|
|
2
|
+
// editor's switches keep the file valid. Run: node --test packages/editor/src/*.test.mjs
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import { test } from 'node:test';
|
|
5
|
+
import { buildSettingsOf, withBuildSetting } from './buildSettings.ts';
|
|
6
|
+
|
|
7
|
+
test('a missing or broken file gives the defaults', () => {
|
|
8
|
+
assert.deepEqual(buildSettingsOf(undefined), { contentLane: true, paidBuilds: false });
|
|
9
|
+
assert.deepEqual(buildSettingsOf('{ nope'), { contentLane: true, paidBuilds: false });
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test('a change on a missing file writes both settings', () => {
|
|
13
|
+
assert.equal(withBuildSetting(undefined, 'contentLane', false), '{\n "contentLane": false,\n "paidBuilds": false\n}\n');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('a change keeps the other setting', () => {
|
|
17
|
+
const once = withBuildSetting(undefined, 'paidBuilds', true);
|
|
18
|
+
assert.deepEqual(buildSettingsOf(once), { contentLane: true, paidBuilds: true });
|
|
19
|
+
assert.deepEqual(buildSettingsOf(withBuildSetting(once, 'contentLane', false)), { contentLane: false, paidBuilds: true });
|
|
20
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// The build settings the editor writes under "Indstillinger" › "Avanceret" (Advanced.tsx): src/data/build.json, read by
|
|
2
|
+
// the build (fast content lane) and by the editor's wait for a build. Exposes buildSettingsOf and withBuildSetting; the
|
|
3
|
+
// defaults come from packages/editor/lib/build-settings.mjs.
|
|
4
|
+
import { readBuildSettings } from '../lib/build-settings.mjs';
|
|
5
|
+
|
|
6
|
+
export type BuildSettings = { contentLane: boolean; paidBuilds: boolean };
|
|
7
|
+
export type BuildSetting = keyof BuildSettings;
|
|
8
|
+
|
|
9
|
+
/** The settings in the file's text, with the defaults filled in; a missing file (undefined) or text that is not JSON gives the defaults. */
|
|
10
|
+
export function buildSettingsOf(text: string | undefined): BuildSettings {
|
|
11
|
+
try {
|
|
12
|
+
return readBuildSettings(text === undefined ? undefined : JSON.parse(text));
|
|
13
|
+
} catch {
|
|
14
|
+
return readBuildSettings(undefined);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The file's text with one setting changed; both settings are written, as the file is read with the defaults. */
|
|
19
|
+
export const withBuildSetting = (text: string | undefined, key: BuildSetting, value: boolean): string =>
|
|
20
|
+
`${JSON.stringify({ ...buildSettingsOf(text), [key]: value }, null, 2)}\n`;
|
package/src/checkSite.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// The rules of a page's template (collections.ts) too: what may be there, how many, and what must.
|
|
8
8
|
// The redirects (redirectData.ts): none sends on through another, and none in a loop. And the site's own rules
|
|
9
9
|
// (siteProblems in its rules.ts: a button that opens a promotion not on its page).
|
|
10
|
+
// The file records (src/data/files: the tags of files in Filer): each is valid, no tag is both chosen and removed, one per address.
|
|
10
11
|
// And the site's texts the editor has no translation of (untranslated).
|
|
11
12
|
import { check } from './check';
|
|
12
13
|
import {
|
|
@@ -36,6 +37,7 @@ import { appFolder, SCREENSHOTS, shotFile, shotOf } from './store';
|
|
|
36
37
|
import { appFolders } from './appFiles';
|
|
37
38
|
import { colorPairs as shownPairs, sharedParts, translator, writtenApps, writtenBlockParts, writtenCollections } from './site';
|
|
38
39
|
import { splitBlocks } from '../lib/blocks.mjs';
|
|
40
|
+
import { isRecordPath, recordProblems } from './fileRecords.mjs';
|
|
39
41
|
import { appTexts, DEFAULTS_LOCALE, locales, siteLocale, t, type Texts } from './i18n';
|
|
40
42
|
import { flatten, readAll, REDIRECT_FILES, type AllRedirects } from './redirectData';
|
|
41
43
|
|
|
@@ -181,6 +183,17 @@ export function problemsOf(files: SiteFile[]): { problems: string[]; files: numb
|
|
|
181
183
|
}
|
|
182
184
|
// The site's own rules (a button that opens a promotion that is not on its page).
|
|
183
185
|
problems.push(...siteProblems(files));
|
|
186
|
+
// The file records directly in the records folder: a line for each problem, and each one counts as checked.
|
|
187
|
+
for (const f of files) if (isRecordPath(f.path, config.fileRecords)) checked.add(f.path);
|
|
188
|
+
for (const p of recordProblems(files, config.fileRecords)) {
|
|
189
|
+
const text =
|
|
190
|
+
p.problem === 'shape'
|
|
191
|
+
? t.check.recordShape
|
|
192
|
+
: p.problem === 'overlap'
|
|
193
|
+
? t.check.recordOverlap(p.value!)
|
|
194
|
+
: t.check.recordDuplicate(p.value!);
|
|
195
|
+
problems.push(`${p.path}: ${text}`);
|
|
196
|
+
}
|
|
184
197
|
return { problems, files: checked.size, blocks };
|
|
185
198
|
}
|
|
186
199
|
|
|
@@ -232,7 +245,7 @@ export function untranslated(): Record<string, Record<string, string[]>> {
|
|
|
232
245
|
'src/site/texts.ts': missing(l, site, (w) => {
|
|
233
246
|
collections.forEach(w.collection);
|
|
234
247
|
[...companyParts, ...partScreens.flatMap((s) => s.parts), ...appearance, ...seoParts, ...blockParts].forEach(w.part);
|
|
235
|
-
partScreens.forEach((s) => w.group({ title: s.label, ids: [] }));
|
|
248
|
+
partScreens.forEach((s) => w.group({ id: s.id, title: s.label, ids: [] }));
|
|
236
249
|
groups.forEach(w.group);
|
|
237
250
|
colorFields.forEach(w.color);
|
|
238
251
|
colorPairs.forEach(w.pair);
|
package/src/deploy.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// After a save: tells when the site has been rebuilt with it. Each build writes the commit it was
|
|
2
2
|
// made from to /editor/version.json (packages/editor/pages/editor/version.json.js), and the editor asks for that
|
|
3
3
|
// until it is the saved commit or a later one. The message is a notification of the type `site` (notificationData.ts),
|
|
4
|
-
// which also comes on the phone or computer when the editor is not on screen.
|
|
4
|
+
// which also comes on the phone or computer when the editor is not on screen. It gives up after 20 minutes, or 10 with paidBuilds.
|
|
5
5
|
// Each save says what it changed ("farver ændret", "side “Om” ændret", t.app.changes): right after it (savedText),
|
|
6
6
|
// and, with the other saves since, when the website has been updated with them (changesText, App.tsx save).
|
|
7
7
|
import { graphql, REPO } from './github';
|
|
8
|
+
import { giveUpAfter } from '../lib/build-settings.mjs';
|
|
8
9
|
import { entryName, type Collection } from './schema';
|
|
9
10
|
import { readFields, splitFrontmatter } from './frontmatter.mjs';
|
|
10
11
|
import { locale, t } from './i18n';
|
|
@@ -39,10 +40,9 @@ export function changesText(changes: string[]): string {
|
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
const EVERY = 10_000;
|
|
42
|
-
const GIVE_UP = 10 * 60_000;
|
|
43
43
|
|
|
44
44
|
// What the site was last built from: the commit, the fingerprint of the editor's code and when.
|
|
45
|
-
type Version = { commit?: string; editor?: string; builtAt?: string };
|
|
45
|
+
type Version = { commit?: string; editor?: string; builtAt?: string; paidBuilds?: boolean };
|
|
46
46
|
async function version(): Promise<Version> {
|
|
47
47
|
try {
|
|
48
48
|
const res = await fetch('/editor/version.json', { cache: 'no-store' });
|
|
@@ -52,8 +52,6 @@ async function version(): Promise<Version> {
|
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
const built = async () => (await version()).commit ?? null;
|
|
56
|
-
|
|
57
55
|
// Whether `commit` is among the last commits up to `build` (the site may be built from a later one).
|
|
58
56
|
async function includes(build: string, commit: string): Promise<boolean> {
|
|
59
57
|
const data = await graphql<{ repository: { object: { history?: { nodes: { oid: string }[] } } | null } }>(
|
|
@@ -78,16 +76,16 @@ async function includes(build: string, commit: string): Promise<boolean> {
|
|
|
78
76
|
}
|
|
79
77
|
|
|
80
78
|
// Waits for `commit` to be on the site: done(true) when it is, done(false) when it is not after
|
|
81
|
-
// 10
|
|
79
|
+
// giveUpAfter(paidBuilds) (20 minutes on the free plan, 10 on Workers Paid; paidBuilds is in version.json). Returns a function that stops waiting (a newer save takes over).
|
|
82
80
|
export function waitForSite(commit: string, done: (online: boolean) => void): () => void {
|
|
83
|
-
const
|
|
81
|
+
const started = Date.now();
|
|
84
82
|
// The build last looked at, so GitHub is asked about each build once.
|
|
85
83
|
let seen: string | null = null;
|
|
86
84
|
let stopped = false;
|
|
87
85
|
let timer: ReturnType<typeof setTimeout>;
|
|
88
86
|
|
|
89
87
|
async function check() {
|
|
90
|
-
const build = await
|
|
88
|
+
const { commit: build, paidBuilds } = await version();
|
|
91
89
|
if (stopped) return;
|
|
92
90
|
if (build && build !== seen) {
|
|
93
91
|
seen = build;
|
|
@@ -95,7 +93,7 @@ export function waitForSite(commit: string, done: (online: boolean) => void): ()
|
|
|
95
93
|
if (stopped) return;
|
|
96
94
|
if (online) return done(true);
|
|
97
95
|
}
|
|
98
|
-
if (Date.now() >
|
|
96
|
+
if (Date.now() > started + giveUpAfter(paidBuilds === true)) {
|
|
99
97
|
// Without any version file (a build from before it existed) there is nothing to tell.
|
|
100
98
|
if (seen) done(false);
|
|
101
99
|
return;
|
package/src/editor.css
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/* The editor's parts are the theme's UI parts (ui.tsx), in the theme's design; its own parts take the theme's variables
|
|
2
2
|
(--block-radius, --block-radius-small, --block-shadow). Only what is the editor's alone is here. Low specificity
|
|
3
3
|
(:where), so a theme's :root wins. */
|
|
4
|
+
@reference '../theme/styles/base.css';
|
|
5
|
+
|
|
4
6
|
:where(:root) {
|
|
5
7
|
/* How a check went (CheckList: the SEO rating), as PageSpeed shows it: the mark before a check that is fine and one
|
|
6
8
|
that can be done better, and the score's ring (from 90 good, from 50 average, else bad) with its number and the
|
|
@@ -14,6 +16,17 @@
|
|
|
14
16
|
--editor-status-bad: #ff4e42;
|
|
15
17
|
--editor-status-bad-text: #cc0000;
|
|
16
18
|
--editor-status-bad-surface: color-mix(in srgb, #ff4e42 10%, var(--surface));
|
|
19
|
+
|
|
20
|
+
/* The dotted frame round an H2 section of a page or blog post (sectionFrame.ts): its line, the space between the line and
|
|
21
|
+
the text, the space above a section, and the space between two parts inside it (em: it scales with the part's text). */
|
|
22
|
+
--editor-section-frame: 2px dotted var(--block-line);
|
|
23
|
+
--editor-section-pad: 1rem;
|
|
24
|
+
--editor-section-gap: 2rem;
|
|
25
|
+
--editor-section-rhythm: 0.625em;
|
|
26
|
+
|
|
27
|
+
/* A form's sections pane (SectionPane in ui.tsx): Material 3's 412dp pane against the window's end edge
|
|
28
|
+
(m3.material.io/foundations/layout/breakpoints/large-extra-large). */
|
|
29
|
+
--section-pane-width: 25.75rem;
|
|
17
30
|
}
|
|
18
31
|
|
|
19
32
|
/* In dark mode the score's number in the light colours, which are read on the dark. */
|
|
@@ -52,6 +65,51 @@
|
|
|
52
65
|
margin-block: 0;
|
|
53
66
|
}
|
|
54
67
|
|
|
68
|
+
/* The dotted frame round an H2 section (data-section-part, sectionFrame.ts): one line made of the parts' own borders. The space
|
|
69
|
+
between two parts is padding, not margin, so the line is never broken; the space above a section is outside its line. */
|
|
70
|
+
.editor-text .ProseMirror > [data-section-part] {
|
|
71
|
+
margin-block: 0;
|
|
72
|
+
border-inline: var(--editor-section-frame);
|
|
73
|
+
padding-inline: var(--editor-section-pad);
|
|
74
|
+
padding-block: var(--editor-section-rhythm);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.editor-text .ProseMirror > :is(h2, h3)[data-section-part] {
|
|
78
|
+
padding-block-start: calc(var(--editor-section-rhythm) * 2);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.editor-text .ProseMirror > :is([data-section-part='start'], [data-section-part='only']) {
|
|
82
|
+
margin-block-start: var(--editor-section-gap);
|
|
83
|
+
border-block-start: var(--editor-section-frame);
|
|
84
|
+
padding-block-start: var(--editor-section-pad);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.editor-text .ProseMirror > :is([data-section-part='end'], [data-section-part='only']) {
|
|
88
|
+
border-block-end: var(--editor-section-frame);
|
|
89
|
+
padding-block-end: var(--editor-section-pad);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/* The colour of an H2 section (data-section-color, sectionFrame.ts): the theme's own colours, as the site draws the panel (wave 4).
|
|
93
|
+
On the accent the text takes the colour made for it, through the prose variables, so headings, bold and links stay readable. */
|
|
94
|
+
.editor-text .ProseMirror > [data-section-color='surface'] {
|
|
95
|
+
background: var(--block-surface);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.editor-text .ProseMirror > [data-section-color='tint'] {
|
|
99
|
+
background: var(--block-tint);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.editor-text .ProseMirror > [data-section-color='accent'] {
|
|
103
|
+
--tw-prose-body: var(--block-on-accent);
|
|
104
|
+
--tw-prose-headings: var(--block-on-accent);
|
|
105
|
+
--tw-prose-bold: var(--block-on-accent);
|
|
106
|
+
--tw-prose-links: var(--block-on-accent);
|
|
107
|
+
--tw-prose-bullets: var(--block-on-accent);
|
|
108
|
+
--tw-prose-counters: var(--block-on-accent);
|
|
109
|
+
background: var(--block-accent);
|
|
110
|
+
color: var(--block-on-accent);
|
|
111
|
+
}
|
|
112
|
+
|
|
55
113
|
/* HTML in a line of text is kept as it is but cannot be edited here (on a line of its own it is a
|
|
56
114
|
block, HTML in markdown.mjs). */
|
|
57
115
|
.editor-text [data-type='html'] {
|
|
@@ -136,7 +194,7 @@
|
|
|
136
194
|
|
|
137
195
|
/* A sheet that is dragged up by its top on a phone (SheetTitle in ui.tsx) is as tall as the screen,
|
|
138
196
|
below the clock, or as tall as what is in it, if that is less. */
|
|
139
|
-
@media
|
|
197
|
+
@media (width < theme(--breakpoint-medium)) {
|
|
140
198
|
.editor-sheet[data-tall] {
|
|
141
199
|
max-height: calc(100dvh - env(safe-area-inset-top));
|
|
142
200
|
}
|
|
@@ -153,27 +211,31 @@
|
|
|
153
211
|
animation: editor-sheet-in var(--motion-spatial);
|
|
154
212
|
}
|
|
155
213
|
|
|
156
|
-
|
|
157
|
-
.editor-drawer[open] {
|
|
158
|
-
animation: editor-drawer-in var(--motion-spatial);
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
:is(.editor-sheet, .editor-drawer)[open]::backdrop {
|
|
214
|
+
.editor-sheet[open]::backdrop {
|
|
162
215
|
animation: editor-fade var(--motion-effects);
|
|
163
216
|
}
|
|
164
217
|
|
|
165
|
-
@media (
|
|
218
|
+
@media (width >= theme(--breakpoint-medium)) {
|
|
166
219
|
.editor-sheet[open] {
|
|
167
220
|
animation: editor-pop var(--motion-spatial-fast);
|
|
168
221
|
}
|
|
169
222
|
}
|
|
170
223
|
|
|
224
|
+
/* The main drawer (MainDrawer in ui.tsx) slides in from the start edge: the left, the right when the text runs right to left. */
|
|
225
|
+
.editor-main-drawer[open] {
|
|
226
|
+
animation: editor-main-drawer-in var(--motion-spatial);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
.editor-main-drawer[open]::backdrop {
|
|
230
|
+
animation: editor-fade var(--motion-effects);
|
|
231
|
+
}
|
|
232
|
+
|
|
171
233
|
.editor-screen {
|
|
172
234
|
animation: editor-enter var(--motion-spatial);
|
|
173
|
-
/* A chip set that scrolls reaches out to the screen's edges: the screen's gutter (px-4,
|
|
235
|
+
/* A chip set that scrolls reaches out to the screen's edges: the screen's gutter (px-4, medium:px-6 in Screen.tsx). */
|
|
174
236
|
--chip-set-bleed: 1rem;
|
|
175
237
|
}
|
|
176
|
-
@media (
|
|
238
|
+
@media (width >= theme(--breakpoint-medium)) {
|
|
177
239
|
.editor-screen {
|
|
178
240
|
--chip-set-bleed: 1.5rem;
|
|
179
241
|
}
|
|
@@ -210,12 +272,49 @@
|
|
|
210
272
|
}
|
|
211
273
|
}
|
|
212
274
|
|
|
213
|
-
@keyframes editor-drawer-in {
|
|
275
|
+
@keyframes editor-main-drawer-in {
|
|
276
|
+
from {
|
|
277
|
+
transform: translateX(-100%);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
@keyframes editor-main-drawer-in-rtl {
|
|
214
282
|
from {
|
|
215
283
|
transform: translateX(100%);
|
|
216
284
|
}
|
|
217
285
|
}
|
|
218
286
|
|
|
287
|
+
/* A drag along the drawer reaches MainDrawer's swipe to close; scrolling its list still works. */
|
|
288
|
+
.editor-main-drawer {
|
|
289
|
+
touch-action: pan-y;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
.editor-main-drawer:dir(rtl)[open] {
|
|
293
|
+
animation-name: editor-main-drawer-in-rtl;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/* From medium the drawers (MainDrawer in ui.tsx) take the rail's place (MainRail): they share its icon column, the middle
|
|
297
|
+
of the rail. On a phone the drawer keeps the theme's (Material 3's navigation drawer). The rail's menu button stands
|
|
298
|
+
level with the top bar's logo, as the drawers' does; the rail's items keep their place. */
|
|
299
|
+
@media (width >= theme(--breakpoint-medium)) {
|
|
300
|
+
.editor-main-drawer-panel {
|
|
301
|
+
--navigation-drawer-inset: 1.25rem;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
.editor-main-rail {
|
|
306
|
+
--navigation-rail-padding: 0.5rem 0.5rem 1.5rem;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
.editor-main-rail .navigation-rail-header {
|
|
310
|
+
margin-bottom: 1rem;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/* The rail (and the room above it for a phone's notch) in the open drawer's colour, so it does not change when the drawer opens. */
|
|
314
|
+
.editor-main-rail-frame {
|
|
315
|
+
--navigation-rail-surface: var(--navigation-drawer-surface, var(--surface-container-low, var(--bg)));
|
|
316
|
+
}
|
|
317
|
+
|
|
219
318
|
@keyframes editor-pop {
|
|
220
319
|
from {
|
|
221
320
|
opacity: 0;
|
|
@@ -240,13 +339,47 @@
|
|
|
240
339
|
bottom: calc(5.5rem + env(safe-area-inset-bottom));
|
|
241
340
|
}
|
|
242
341
|
|
|
243
|
-
/*
|
|
244
|
-
|
|
245
|
-
:
|
|
246
|
-
|
|
342
|
+
/* The screen's main actions (FabMenu in ui.tsx) at the bottom right, above the content; the messages above them. */
|
|
343
|
+
.editor-fab {
|
|
344
|
+
position: fixed;
|
|
345
|
+
right: calc(1rem + env(safe-area-inset-right));
|
|
346
|
+
bottom: calc(1rem + env(safe-area-inset-bottom));
|
|
347
|
+
z-index: 30;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
:root:has(.editor-fab) .snackbar-stack {
|
|
351
|
+
bottom: calc(6rem + env(safe-area-inset-bottom));
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/* From medium the navigation rail stands at the start edge of the window (MainMenu.tsx), so the messages are in the middle of
|
|
355
|
+
the content next to it and the save bar starts where it ends; at expanded and wider, while the standard drawer is open
|
|
356
|
+
in the rail's place, they keep clear of the drawer instead. The widths are the theme's. The top bar and its tabs (Screen.tsx)
|
|
357
|
+
stand beside the rail and the drawer, so they need no room. */
|
|
358
|
+
@media (width >= theme(--breakpoint-medium)) {
|
|
359
|
+
.snackbar-stack {
|
|
360
|
+
padding-left: var(--navigation-rail-width, 5.5rem);
|
|
361
|
+
}
|
|
362
|
+
.editor-save-bar {
|
|
363
|
+
left: var(--navigation-rail-width, 5.5rem);
|
|
247
364
|
}
|
|
365
|
+
}
|
|
366
|
+
@media (width >= theme(--breakpoint-expanded)) {
|
|
248
367
|
:root[data-sidebar='open'] .snackbar-stack {
|
|
249
|
-
padding-left:
|
|
368
|
+
padding-left: var(--navigation-drawer-width, 22.5rem);
|
|
369
|
+
}
|
|
370
|
+
:root[data-sidebar='open'] .editor-save-bar {
|
|
371
|
+
left: var(--navigation-drawer-width, 22.5rem);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/* The sections pane (FormColumns' `pane` in ui.tsx) stands against the window's end edge on large and wider: the content
|
|
376
|
+
keeps clear of it, so the form is centred to its left, and the save bar ends where the pane begins. */
|
|
377
|
+
@media (width >= theme(--breakpoint-large)) {
|
|
378
|
+
.editor-content:has(.editor-section-pane) {
|
|
379
|
+
padding-inline-end: var(--section-pane-width);
|
|
380
|
+
}
|
|
381
|
+
:root:has(.editor-section-pane) .editor-save-bar {
|
|
382
|
+
right: var(--section-pane-width);
|
|
250
383
|
}
|
|
251
384
|
}
|
|
252
385
|
|
|
@@ -514,7 +647,7 @@ html {
|
|
|
514
647
|
line-height: 1.625;
|
|
515
648
|
}
|
|
516
649
|
|
|
517
|
-
@media (
|
|
650
|
+
@media (width >= theme(--breakpoint-medium)) {
|
|
518
651
|
.editor-text .ProseMirror > [data-look='section'] {
|
|
519
652
|
font-size: 3rem;
|
|
520
653
|
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** @type {import('@milkdown/kit/utils').$NodeSchema<'eyebrow'>} */
|
|
2
|
+
export const eyebrowSchema: import("@milkdown/kit/utils").$NodeSchema<"eyebrow">;
|
|
3
|
+
/** @type {import('@milkdown/kit/utils').$Prose} */
|
|
4
|
+
export const eyebrowKeeper: import("@milkdown/kit/utils").$Prose;
|
|
5
|
+
export const eyebrowKeymap: any;
|
package/src/eyebrow.mjs
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// A label above a heading in the editor (lib/eyebrow.mjs: its HTML): the node `eyebrow`, one line of plain text, read
|
|
2
|
+
// from its line of HTML (remarkSiteBlocks in markdown.mjs) and written back as it; a label with no heading after it
|
|
3
|
+
// becomes a paragraph (eyebrowKeeper), and Enter in it goes to its heading (eyebrowKeymap). Exposes eyebrowSchema,
|
|
4
|
+
// eyebrowKeeper and eyebrowKeymap. Depends on Milkdown and src/eyebrowState.mjs.
|
|
5
|
+
import { $nodeSchema, $prose, $useKeymap } from '@milkdown/kit/utils';
|
|
6
|
+
import { Plugin } from '@milkdown/kit/prose/state';
|
|
7
|
+
import { EYEBROW_CLASS, eyebrowHtml } from '../lib/eyebrow.mjs';
|
|
8
|
+
import { enterInLabel, looseLabels } from './eyebrowState.mjs';
|
|
9
|
+
|
|
10
|
+
/** @type {import('@milkdown/kit/utils').$NodeSchema<'eyebrow'>} */
|
|
11
|
+
export const eyebrowSchema = $nodeSchema('eyebrow', () => ({
|
|
12
|
+
group: 'block',
|
|
13
|
+
content: 'text*',
|
|
14
|
+
marks: '',
|
|
15
|
+
defining: true,
|
|
16
|
+
// Before a paragraph's own rule.
|
|
17
|
+
parseDOM: [{ tag: `p.${EYEBROW_CLASS}`, priority: 60 }],
|
|
18
|
+
toDOM: () => ['p', { class: EYEBROW_CLASS }, 0],
|
|
19
|
+
parseMarkdown: {
|
|
20
|
+
match: (node) => node.type === 'eyebrow',
|
|
21
|
+
runner: (state, node, type) => {
|
|
22
|
+
state.openNode(type);
|
|
23
|
+
if (node.value) state.addText(String(node.value));
|
|
24
|
+
state.closeNode();
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
toMarkdown: {
|
|
28
|
+
match: (node) => node.type.name === 'eyebrow',
|
|
29
|
+
runner: (state, node) => {
|
|
30
|
+
state.addNode('html', undefined, eyebrowHtml(node.textContent));
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
// A label whose heading has gone (deleted, or turned into text) is a paragraph with its text.
|
|
36
|
+
/** @type {import('@milkdown/kit/utils').$Prose} */
|
|
37
|
+
export const eyebrowKeeper = $prose(
|
|
38
|
+
() =>
|
|
39
|
+
new Plugin({
|
|
40
|
+
appendTransaction: (_, __, state) => {
|
|
41
|
+
const loose = looseLabels(state.doc);
|
|
42
|
+
if (!loose.length) return null;
|
|
43
|
+
const tr = state.tr;
|
|
44
|
+
for (const at of loose) tr.setNodeMarkup(at, state.schema.nodes.paragraph);
|
|
45
|
+
return tr;
|
|
46
|
+
},
|
|
47
|
+
}),
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
// Before the text's own Enter (priority 50), which would split the label into two lines.
|
|
51
|
+
export const eyebrowKeymap = $useKeymap('eyebrow', {
|
|
52
|
+
EnterInLabel: { shortcuts: 'Enter', priority: 100, command: () => enterInLabel },
|
|
53
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** The positions of the labels at the top of the text with no heading right after them.
|
|
2
|
+
* @param {import('@milkdown/kit/prose/model').Node} doc @returns {number[]} */
|
|
3
|
+
export function looseLabels(doc: import("@milkdown/kit/prose/model").Node): number[];
|
|
4
|
+
/** The heading the label button is for: the one the cursor is in, or the one after the label it is in, at the top of
|
|
5
|
+
* the text; null elsewhere. @param {import('@milkdown/kit/prose/state').EditorState} state
|
|
6
|
+
* @returns {{ pos: number, level: number } | null} */
|
|
7
|
+
export function labelledHeading(state: import("@milkdown/kit/prose/state").EditorState): {
|
|
8
|
+
pos: number;
|
|
9
|
+
level: number;
|
|
10
|
+
} | null;
|
|
11
|
+
/** Whether the heading the label button is for has a label. @param {import('@milkdown/kit/prose/state').EditorState} state */
|
|
12
|
+
export function hasLabel(state: import("@milkdown/kit/prose/state").EditorState): boolean;
|
|
13
|
+
/** The label button: an empty label above the heading, with the cursor in it, or the heading's label taken away.
|
|
14
|
+
* @param {{ state: import('@milkdown/kit/prose/state').EditorState, dispatch: (tr: import('@milkdown/kit/prose/state').Transaction) => void }} view */
|
|
15
|
+
export function toggleLabel(view: {
|
|
16
|
+
state: import("@milkdown/kit/prose/state").EditorState;
|
|
17
|
+
dispatch: (tr: import("@milkdown/kit/prose/state").Transaction) => void;
|
|
18
|
+
}): void;
|
|
19
|
+
/** Enter in a label: the cursor goes to the start of its heading, as a label is one line.
|
|
20
|
+
* @type {import('@milkdown/kit/prose/state').Command} */
|
|
21
|
+
export const enterInLabel: import("@milkdown/kit/prose/state").Command;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Labels above headings in the editor's document: a label with no heading after it is loose (looseLabels), Enter in a
|
|
2
|
+
// label goes to its heading (enterInLabel; both used by src/eyebrow.mjs), and the label button's heading, state and
|
|
3
|
+
// command (labelledHeading, hasLabel, toggleLabel). Only ProseMirror's state; no Milkdown, no browser.
|
|
4
|
+
import { TextSelection } from '@milkdown/kit/prose/state';
|
|
5
|
+
|
|
6
|
+
/** The positions of the labels at the top of the text with no heading right after them.
|
|
7
|
+
* @param {import('@milkdown/kit/prose/model').Node} doc @returns {number[]} */
|
|
8
|
+
export function looseLabels(doc) {
|
|
9
|
+
/** @type {number[]} */
|
|
10
|
+
const loose = [];
|
|
11
|
+
doc.forEach((node, at, i) => {
|
|
12
|
+
if (node.type.name === 'eyebrow' && doc.maybeChild(i + 1)?.type.name !== 'heading') loose.push(at);
|
|
13
|
+
});
|
|
14
|
+
return loose;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Enter in a label: the cursor goes to the start of its heading, as a label is one line.
|
|
18
|
+
* @type {import('@milkdown/kit/prose/state').Command} */
|
|
19
|
+
export const enterInLabel = (state, dispatch) => {
|
|
20
|
+
const { $from } = state.selection;
|
|
21
|
+
if ($from.parent.type.name !== 'eyebrow') return false;
|
|
22
|
+
dispatch?.(state.tr.setSelection(TextSelection.create(state.doc, $from.after() + 1)));
|
|
23
|
+
return true;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** The heading the label button is for: the one the cursor is in, or the one after the label it is in, at the top of
|
|
27
|
+
* the text; null elsewhere. @param {import('@milkdown/kit/prose/state').EditorState} state
|
|
28
|
+
* @returns {{ pos: number, level: number } | null} */
|
|
29
|
+
export function labelledHeading(state) {
|
|
30
|
+
const { $from } = state.selection;
|
|
31
|
+
if ($from.depth !== 1) return null;
|
|
32
|
+
const block = $from.parent;
|
|
33
|
+
const at = $from.before(1);
|
|
34
|
+
if (block.type.name === 'heading') return { pos: at, level: block.attrs.level };
|
|
35
|
+
if (block.type.name !== 'eyebrow') return null;
|
|
36
|
+
const next = state.doc.nodeAt(at + block.nodeSize);
|
|
37
|
+
return next?.type.name === 'heading' ? { pos: at + block.nodeSize, level: next.attrs.level } : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// The position of the label right before the heading at pos, or null.
|
|
41
|
+
const labelBefore = (state, pos) => {
|
|
42
|
+
const before = state.doc.resolve(pos).nodeBefore;
|
|
43
|
+
return before?.type.name === 'eyebrow' ? pos - before.nodeSize : null;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** Whether the heading the label button is for has a label. @param {import('@milkdown/kit/prose/state').EditorState} state */
|
|
47
|
+
export function hasLabel(state) {
|
|
48
|
+
const heading = labelledHeading(state);
|
|
49
|
+
return !!heading && labelBefore(state, heading.pos) !== null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The label button: an empty label above the heading, with the cursor in it, or the heading's label taken away.
|
|
53
|
+
* @param {{ state: import('@milkdown/kit/prose/state').EditorState, dispatch: (tr: import('@milkdown/kit/prose/state').Transaction) => void }} view */
|
|
54
|
+
export function toggleLabel(view) {
|
|
55
|
+
const { state } = view;
|
|
56
|
+
const heading = labelledHeading(state);
|
|
57
|
+
if (!heading) return;
|
|
58
|
+
const label = labelBefore(state, heading.pos);
|
|
59
|
+
if (label !== null) return view.dispatch(state.tr.delete(label, heading.pos));
|
|
60
|
+
const tr = state.tr.insert(heading.pos, state.schema.nodes.eyebrow.create());
|
|
61
|
+
view.dispatch(tr.setSelection(TextSelection.create(tr.doc, heading.pos + 1)).scrollIntoView());
|
|
62
|
+
}
|