@morphemecms/cms 0.11.0 → 0.12.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 +20 -0
- package/astro.mjs +2 -1
- package/lib/appId.d.mts +1 -0
- package/lib/appId.mjs +6 -0
- package/lib/apps.mjs +13 -3
- package/lib/apps.test.mjs +10 -1
- package/lib/contracts.d.mts +3 -2
- package/lib/contracts.mjs +3 -2
- package/lib/folderChanges.d.mts +29 -0
- package/lib/folderChanges.mjs +34 -0
- package/lib/section-ranges.d.mts +1 -1
- package/lib/section-ranges.mjs +8 -14
- package/lib/section-ranges.test.mjs +26 -5
- package/lib/storeEntries.d.mts +3 -1
- package/lib/storeEntries.mjs +22 -8
- package/lib/storeEntries.test.mjs +56 -7
- package/lib/text-sections.d.mts +11 -1
- package/lib/text-sections.mjs +18 -8
- package/lib/text-sections.test.mjs +34 -1
- package/lib/video-id.d.mts +6 -2
- package/lib/video-id.mjs +5 -1
- package/lib/video.d.mts +6 -2
- package/lib/video.mjs +5 -1
- package/lib/without-h1.d.mts +1 -0
- package/lib/without-h1.mjs +19 -4
- package/lib/without-h1.test.mjs +30 -1
- package/package.json +3 -2
- package/scripts/app-screenshots.mjs +2 -1
- package/scripts/check-content.mjs +2 -1
- package/scripts/check-site-types.mjs +26 -0
- package/scripts/site-types.mjs +14 -0
- package/scripts/site-types.test.mjs +21 -0
- package/site/activeTheme.test.mjs +52 -0
- package/site/appSelf.d.mts +2 -0
- package/site/appSelf.mjs +23 -0
- package/site/appSelf.test.mjs +35 -0
- package/site/content.ts +5 -2
- package/site/siteModules.mjs +2 -1
- package/site/theme.mjs +19 -8
- package/src/App.tsx +1 -1
- package/src/AppList.tsx +24 -9
- package/src/BlockDialog.tsx +38 -17
- package/src/CreateFab.tsx +8 -0
- package/src/DataForm.tsx +7 -17
- package/src/EntryForm.tsx +1 -1
- package/src/FormFrame.tsx +4 -3
- package/src/Library.tsx +4 -11
- package/src/ListFab.tsx +13 -0
- package/src/MarkdownEditor.tsx +88 -47
- package/src/Media.tsx +4 -13
- package/src/Redirects.tsx +6 -14
- package/src/Screen.tsx +13 -3
- package/src/StartFab.tsx +5 -7
- package/src/ThemeStore.tsx +33 -12
- package/src/apps.ts +2 -3
- package/src/drawerYield.d.mts +3 -0
- package/src/drawerYield.mjs +10 -0
- package/src/drawerYield.test.mjs +22 -0
- package/src/editor.css +55 -22
- package/src/fabScreens.test.mjs +28 -0
- package/src/fabScreens.ts +16 -0
- package/src/legacyMove.test.mjs +137 -0
- package/src/legacyMove.ts +131 -0
- package/src/markdown.mjs +10 -7
- package/src/markdown.test.mjs +16 -2
- package/src/menuOpen.ts +20 -4
- package/src/movedRoutes.mjs +2 -2
- package/src/movedRoutes.test.mjs +5 -0
- package/src/pageH1Panel.d.mts +4 -0
- package/src/pageH1Panel.mjs +31 -0
- package/src/pageH1Panel.test.mjs +56 -0
- package/src/pagePreview.tsx +22 -12
- package/src/sectionFrame.ts +3 -3
- package/src/sectionState.d.mts +3 -3
- package/src/sectionState.mjs +6 -6
- package/src/sectionState.test.mjs +14 -2
- package/src/sheetPlacement.d.mts +6 -4
- package/src/sheetPlacement.mjs +15 -10
- package/src/sheetPlacement.test.mjs +16 -9
- package/src/sidePanel.ts +38 -0
- package/src/store.ts +32 -27
- package/src/topBarSlot.tsx +7 -15
- package/src/ui.tsx +26 -15
- package/src/virtual.d.ts +6 -0
- package/starter/package.json +1 -1
- package/theme/components/PageHeading.astro +22 -0
- package/theme/layouts/PageLayout.astro +37 -20
- package/theme/styles/global.css +2 -1
- package/theme/styles/ui.css +40 -10
package/src/sectionState.mjs
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
// The section colour in the editor's document (the *Sektionsfarve* button, MarkdownEditor.tsx; the frame, sectionFrame.ts): the
|
|
2
|
-
// top-level H2 the cursor is in (sectionHeading), setting its colour (setSectionColor) and the colour of a section (sectionColorOf).
|
|
2
|
+
// top-level H1 or H2 the cursor is in (sectionHeading), setting its colour (setSectionColor) and the colour of a section (sectionColorOf).
|
|
3
3
|
// Only ProseMirror's state; depends on lib/section-marker.mjs. No Milkdown, no browser.
|
|
4
4
|
import { SECTION_COLORS } from '../lib/section-marker.mjs';
|
|
5
5
|
|
|
6
|
-
/** The top-level H2 the cursor is in, with its colour, or null. @param {import('@milkdown/kit/prose/state').EditorState} state
|
|
6
|
+
/** The top-level H1 or H2 the cursor is in, with its colour, or null. @param {import('@milkdown/kit/prose/state').EditorState} state
|
|
7
7
|
* @returns {{ pos: number, color: string | null } | null} */
|
|
8
8
|
export function sectionHeading(state) {
|
|
9
9
|
const { $from } = state.selection;
|
|
10
10
|
const node = $from.parent;
|
|
11
|
-
if ($from.depth !== 1 || node.type.name !== 'heading' || node.attrs.level
|
|
11
|
+
if ($from.depth !== 1 || node.type.name !== 'heading' || node.attrs.level > 2) return null;
|
|
12
12
|
return { pos: $from.before(1), color: node.attrs.section ?? null };
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
/** Gives the H2 the cursor is in this colour (null: none). False when it is not in
|
|
15
|
+
/** Gives the H1 or H2 the cursor is in this colour (null: none). False when it is not in one, or the colour is not one of the theme's.
|
|
16
16
|
* @param {{ state: import('@milkdown/kit/prose/state').EditorState, dispatch: (tr: import('@milkdown/kit/prose/state').Transaction) => void }} view
|
|
17
17
|
* @param {string | null} color */
|
|
18
18
|
export function setSectionColor(view, color) {
|
|
@@ -23,9 +23,9 @@ export function setSectionColor(view, color) {
|
|
|
23
23
|
return true;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
/** The colour of the section made of nodes[range.from..range.to] (sectionRanges): its H2's, null without one.
|
|
26
|
+
/** The colour of the section made of nodes[range.from..range.to] (sectionRanges): its H1's or H2's, null without one.
|
|
27
27
|
* @param {import('@milkdown/kit/prose/model').Node[]} nodes @param {{ from: number, to: number }} range @returns {string | null} */
|
|
28
28
|
export function sectionColorOf(nodes, range) {
|
|
29
|
-
const heading = nodes.slice(range.from, range.to + 1).find((n) => n.type.name === 'heading' && n.attrs.level
|
|
29
|
+
const heading = nodes.slice(range.from, range.to + 1).find((n) => n.type.name === 'heading' && n.attrs.level <= 2);
|
|
30
30
|
return heading?.attrs.section ?? null;
|
|
31
31
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Tests for src/sectionState.mjs: the H2 the cursor is in, setting its section colour, and the colour of a section.
|
|
1
|
+
// Tests for src/sectionState.mjs: the H1 or H2 the cursor is in, setting its section colour, and the colour of a section.
|
|
2
2
|
// Run: node --test packages/editor/src/sectionState.test.mjs
|
|
3
3
|
import { test } from 'node:test';
|
|
4
4
|
import assert from 'node:assert/strict';
|
|
@@ -16,11 +16,12 @@ const schema = new Schema({
|
|
|
16
16
|
},
|
|
17
17
|
});
|
|
18
18
|
const node = (type, text, attrs) => schema.node(type, attrs, text ? [schema.text(text)] : []);
|
|
19
|
-
// An H2 "Hours" (0-7, coloured tint), a paragraph (7-10), an H3 (10-
|
|
19
|
+
// An H2 "Hours" (0-7, coloured tint), a paragraph (7-10), an H3 (10-15), an H1 "Top" (15-20, coloured tint).
|
|
20
20
|
const doc = schema.node('doc', null, [
|
|
21
21
|
node('heading', 'Hours', { level: 2, section: 'tint' }),
|
|
22
22
|
node('paragraph', 'x'),
|
|
23
23
|
node('heading', 'Sub', { level: 3 }),
|
|
24
|
+
node('heading', 'Top', { level: 1, section: 'tint' }),
|
|
24
25
|
]);
|
|
25
26
|
const viewAt = (pos) => ({
|
|
26
27
|
state: EditorState.create({ doc, selection: TextSelection.create(doc, pos) }),
|
|
@@ -51,6 +52,17 @@ test('a colour the theme has not is refused, and nothing happens outside an H2',
|
|
|
51
52
|
assert.equal(view.state.doc.child(0).attrs.section, 'tint');
|
|
52
53
|
assert.equal(setSectionColor(viewAt(8), 'tint'), false);
|
|
53
54
|
});
|
|
55
|
+
test('the H1 the cursor is in', () => assert.deepEqual(sectionHeading(viewAt(17).state), { pos: 15, color: 'tint' }));
|
|
56
|
+
test('sets a colour on an H1', () => {
|
|
57
|
+
const view = viewAt(17);
|
|
58
|
+
assert.equal(setSectionColor(view, 'accent'), true);
|
|
59
|
+
assert.equal(view.state.doc.child(3).attrs.section, 'accent');
|
|
60
|
+
assert.equal(view.state.doc.child(3).attrs.level, 1);
|
|
61
|
+
});
|
|
62
|
+
test('the colour of a section is its H1’s', () => {
|
|
63
|
+
const kids = [node('heading', 'Top', { level: 1, section: 'accent' }), node('paragraph', 'x')];
|
|
64
|
+
assert.equal(sectionColorOf(kids, { from: 0, to: 1 }), 'accent');
|
|
65
|
+
});
|
|
54
66
|
test('the colour of a section is its H2’s, also after a label', () => {
|
|
55
67
|
const kids = [node('eyebrow', 'Label'), node('heading', 'Hours', { level: 2, section: 'surface' }), node('paragraph', 'x')];
|
|
56
68
|
assert.equal(sectionColorOf(kids, { from: 0, to: 2 }), 'surface');
|
package/src/sheetPlacement.d.mts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
/** step: 'choose' | 'fields' | 'section' | undefined;
|
|
2
|
-
export function sheetLook(step: any,
|
|
3
|
-
/** true exactly when sheetLook(step,
|
|
4
|
-
export function inPanel(step: any,
|
|
1
|
+
/** step: 'choose' | 'fields' | 'section' | undefined; expanded: the window is at least the `expanded` breakpoint. */
|
|
2
|
+
export function sheetLook(step: any, expanded: any): "side-sheet" | "modal-side-sheet" | "sheet";
|
|
3
|
+
/** true exactly when sheetLook(step, expanded) === 'side-sheet'. */
|
|
4
|
+
export function inPanel(step: any, expanded: any): boolean;
|
|
5
|
+
/** true when a card's pen and trash move into the side panel: the fields sheet is the side panel (inPanel('fields', expanded)) and the card has fields. */
|
|
6
|
+
export function editsInPanel(expanded: any, hasFields: any): any;
|
package/src/sheetPlacement.mjs
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
|
-
// The look of each step of the block sheet (BlockDialog.tsx): a side sheet beside the page from
|
|
2
|
-
// and the section settings, a modal side sheet below
|
|
3
|
-
// Exposes sheetLook(step,
|
|
1
|
+
// The look of each step of the block sheet (BlockDialog.tsx): a side sheet beside the page from expanded for the fields
|
|
2
|
+
// and the section settings, a modal side sheet below expanded for the fields, else the sheet from the bottom.
|
|
3
|
+
// Exposes sheetLook(step, expanded), inPanel(step, expanded) and editsInPanel(expanded, hasFields); no dependencies.
|
|
4
4
|
|
|
5
|
-
/** step: 'choose' | 'fields' | 'section' | undefined;
|
|
6
|
-
export function sheetLook(step,
|
|
7
|
-
if (step === 'fields') return
|
|
8
|
-
if (step === 'section') return
|
|
5
|
+
/** step: 'choose' | 'fields' | 'section' | undefined; expanded: the window is at least the `expanded` breakpoint. */
|
|
6
|
+
export function sheetLook(step, expanded) {
|
|
7
|
+
if (step === 'fields') return expanded ? 'side-sheet' : 'modal-side-sheet';
|
|
8
|
+
if (step === 'section') return expanded ? 'side-sheet' : 'sheet';
|
|
9
9
|
return 'sheet';
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
/** true exactly when sheetLook(step,
|
|
13
|
-
export function inPanel(step,
|
|
14
|
-
return sheetLook(step,
|
|
12
|
+
/** true exactly when sheetLook(step, expanded) === 'side-sheet'. */
|
|
13
|
+
export function inPanel(step, expanded) {
|
|
14
|
+
return sheetLook(step, expanded) === 'side-sheet';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** true when a card's pen and trash move into the side panel: the fields sheet is the side panel (inPanel('fields', expanded)) and the card has fields. */
|
|
18
|
+
export function editsInPanel(expanded, hasFields) {
|
|
19
|
+
return hasFields && inPanel('fields', expanded);
|
|
15
20
|
}
|
|
@@ -1,44 +1,51 @@
|
|
|
1
1
|
// Tests for sheetPlacement.mjs: the look (side-sheet, modal-side-sheet, or sheet) and the panel flag of each step.
|
|
2
2
|
import test from 'node:test';
|
|
3
3
|
import assert from 'node:assert/strict';
|
|
4
|
-
import { sheetLook, inPanel } from './sheetPlacement.mjs';
|
|
4
|
+
import { sheetLook, inPanel, editsInPanel } from './sheetPlacement.mjs';
|
|
5
5
|
|
|
6
|
-
test("'fields' below
|
|
6
|
+
test("'fields' below expanded is a modal side sheet and not in the panel", () => {
|
|
7
7
|
assert.equal(sheetLook('fields', false), 'modal-side-sheet');
|
|
8
8
|
assert.equal(inPanel('fields', false), false);
|
|
9
9
|
});
|
|
10
10
|
|
|
11
|
-
test("'fields' at
|
|
11
|
+
test("'fields' at expanded is a side sheet and in the panel", () => {
|
|
12
12
|
assert.equal(sheetLook('fields', true), 'side-sheet');
|
|
13
13
|
assert.equal(inPanel('fields', true), true);
|
|
14
14
|
});
|
|
15
15
|
|
|
16
|
-
test("'section' below
|
|
16
|
+
test("'section' below expanded is a sheet and not in the panel", () => {
|
|
17
17
|
assert.equal(sheetLook('section', false), 'sheet');
|
|
18
18
|
assert.equal(inPanel('section', false), false);
|
|
19
19
|
});
|
|
20
20
|
|
|
21
|
-
test("'section' at
|
|
21
|
+
test("'section' at expanded is a side sheet and in the panel", () => {
|
|
22
22
|
assert.equal(sheetLook('section', true), 'side-sheet');
|
|
23
23
|
assert.equal(inPanel('section', true), true);
|
|
24
24
|
});
|
|
25
25
|
|
|
26
|
-
test("'choose' below
|
|
26
|
+
test("'choose' below expanded is a sheet and not in the panel", () => {
|
|
27
27
|
assert.equal(sheetLook('choose', false), 'sheet');
|
|
28
28
|
assert.equal(inPanel('choose', false), false);
|
|
29
29
|
});
|
|
30
30
|
|
|
31
|
-
test("'choose' at
|
|
31
|
+
test("'choose' at expanded is a sheet and not in the panel", () => {
|
|
32
32
|
assert.equal(sheetLook('choose', true), 'sheet');
|
|
33
33
|
assert.equal(inPanel('choose', true), false);
|
|
34
34
|
});
|
|
35
35
|
|
|
36
|
-
test('a closed sheet below
|
|
36
|
+
test('a closed sheet below expanded is a sheet and not in the panel', () => {
|
|
37
37
|
assert.equal(sheetLook(undefined, false), 'sheet');
|
|
38
38
|
assert.equal(inPanel(undefined, false), false);
|
|
39
39
|
});
|
|
40
40
|
|
|
41
|
-
test('a closed sheet at
|
|
41
|
+
test('a closed sheet at expanded is a sheet and not in the panel', () => {
|
|
42
42
|
assert.equal(sheetLook(undefined, true), 'sheet');
|
|
43
43
|
assert.equal(inPanel(undefined, true), false);
|
|
44
44
|
});
|
|
45
|
+
|
|
46
|
+
test('a card edits in the panel only at expanded and with fields', () => {
|
|
47
|
+
assert.equal(editsInPanel(true, true), true);
|
|
48
|
+
assert.equal(editsInPanel(false, true), false);
|
|
49
|
+
assert.equal(editsInPanel(true, false), false);
|
|
50
|
+
assert.equal(editsInPanel(false, false), false);
|
|
51
|
+
});
|
package/src/sidePanel.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Whether the standard side panel (BlockDialog's standard side sheet) is open, and whether the menu button reopened the drawer
|
|
2
|
+
// while it is. Not remembered. Exposes setPanelOpen, setDrawerReopened, usePanelOpen, useDrawerReopened; read by menuOpen.ts.
|
|
3
|
+
import { useSyncExternalStore } from 'react';
|
|
4
|
+
|
|
5
|
+
let panelOpen = false;
|
|
6
|
+
let reopened = false;
|
|
7
|
+
const listeners = new Set<() => void>();
|
|
8
|
+
const notify = () => listeners.forEach((l) => l());
|
|
9
|
+
const subscribe = (changed: () => void) => {
|
|
10
|
+
listeners.add(changed);
|
|
11
|
+
return () => listeners.delete(changed);
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** The panel opens or closes; closing ends a reopening of the drawer. */
|
|
15
|
+
export function setPanelOpen(next: boolean) {
|
|
16
|
+
panelOpen = next;
|
|
17
|
+
if (!next) reopened = false;
|
|
18
|
+
notify();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function setDrawerReopened(next: boolean) {
|
|
22
|
+
reopened = next;
|
|
23
|
+
notify();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const usePanelOpen = () =>
|
|
27
|
+
useSyncExternalStore(
|
|
28
|
+
subscribe,
|
|
29
|
+
() => panelOpen,
|
|
30
|
+
() => false,
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
export const useDrawerReopened = () =>
|
|
34
|
+
useSyncExternalStore(
|
|
35
|
+
subscribe,
|
|
36
|
+
() => reopened,
|
|
37
|
+
() => false,
|
|
38
|
+
);
|
package/src/store.ts
CHANGED
|
@@ -29,6 +29,8 @@ import type { StoreLicenses } from './licenseData';
|
|
|
29
29
|
import { APP_API, THEME_API } from '../lib/contracts.mjs';
|
|
30
30
|
import { RECORD, STANDARD, isText, validApp, validTheme } from '../lib/storeEntries.mjs';
|
|
31
31
|
import { timedFetch } from './storeFetch';
|
|
32
|
+
import { folderChanges } from '../lib/folderChanges.mjs';
|
|
33
|
+
import { moveChanges as moveItem, type SitePaths } from './legacyMove';
|
|
32
34
|
|
|
33
35
|
// The contracts this editor runs (lib/contracts.mjs, also read by the site's Worker).
|
|
34
36
|
export { APP_API, THEME_API };
|
|
@@ -111,6 +113,9 @@ export const SCREENSHOTS = 'screenshots';
|
|
|
111
113
|
/** An app in the store's catalog. */
|
|
112
114
|
export type StoreApp = {
|
|
113
115
|
id: string;
|
|
116
|
+
/** Its readable name for addresses (`publisher/name`) and the id it had before its permanent one (`legacy`). */
|
|
117
|
+
handle?: string;
|
|
118
|
+
legacy?: string;
|
|
114
119
|
version: string;
|
|
115
120
|
api: number;
|
|
116
121
|
label: string;
|
|
@@ -145,6 +150,8 @@ export type StoreApp = {
|
|
|
145
150
|
* site in it (screenshots/<n>-<key>.jpg, t.library.themeShots) and its folder, file by file. */
|
|
146
151
|
export type StoreTheme = {
|
|
147
152
|
id: string;
|
|
153
|
+
handle?: string;
|
|
154
|
+
legacy?: string;
|
|
148
155
|
version: string;
|
|
149
156
|
api: number;
|
|
150
157
|
name: string;
|
|
@@ -420,33 +427,6 @@ export function themeChanges(
|
|
|
420
427
|
return folderChanges(themeFolder(id), current, next);
|
|
421
428
|
}
|
|
422
429
|
|
|
423
|
-
/** The changes that make a folder in the site (`current`, its files now) the store's version of an app or a theme, with
|
|
424
|
-
* the note of where it came from, or remove it (`next` null). */
|
|
425
|
-
function folderChanges(
|
|
426
|
-
folder: string,
|
|
427
|
-
current: RepoFile[],
|
|
428
|
-
next: { files: { path: string; content: string | Uint8Array }[]; record: StoreRecord } | null,
|
|
429
|
-
): Change[] {
|
|
430
|
-
const now = new Map(current.map((f) => [f.path, f.oid]));
|
|
431
|
-
const wanted: { path: string; content: string | Uint8Array; oid?: string }[] = next
|
|
432
|
-
? [
|
|
433
|
-
...next.files.map((f) => ({
|
|
434
|
-
path: `${folder}/${f.path}`,
|
|
435
|
-
content: f.content,
|
|
436
|
-
oid: next.record.files.find((r) => r.path === f.path)?.oid,
|
|
437
|
-
})),
|
|
438
|
-
{ path: `${folder}/${RECORD}`, content: `${JSON.stringify(next.record, null, 2)}\n` },
|
|
439
|
-
]
|
|
440
|
-
: [];
|
|
441
|
-
return [
|
|
442
|
-
// A file the site has as it is in the store is left as it is.
|
|
443
|
-
...wanted
|
|
444
|
-
.filter((w) => !w.oid || now.get(w.path) !== w.oid)
|
|
445
|
-
.map((w) => ({ path: w.path, content: w.content, oid: now.get(w.path) ?? null })),
|
|
446
|
-
...current.filter((f) => !wanted.some((w) => w.path === f.path)).map((f) => ({ path: f.path, content: null, oid: f.oid })),
|
|
447
|
-
];
|
|
448
|
-
}
|
|
449
|
-
|
|
450
430
|
/** The changes that make an app's folder in the site (`current`, its files now) the store's version, with the note of
|
|
451
431
|
* where it came from, or remove it (`next` null: uninstalling); and the site's file of apps with it among those
|
|
452
432
|
* installed or not (`installed`: their ids now, `file`: the file). In one commit. */
|
|
@@ -474,6 +454,31 @@ export function recordIn(current: RepoFile[], root: string): StoreRecord | undef
|
|
|
474
454
|
}
|
|
475
455
|
}
|
|
476
456
|
|
|
457
|
+
/** Where the site keeps what names an app or a theme (docs/store-ids.md); the popups' folder is the collection's. */
|
|
458
|
+
const sitePaths: SitePaths = {
|
|
459
|
+
appsFolder: config.apps.folder,
|
|
460
|
+
themesFolder: config.themes,
|
|
461
|
+
installed: config.apps.installed,
|
|
462
|
+
settings: config.apps.settings,
|
|
463
|
+
theme: config.theme,
|
|
464
|
+
icons: config.icons,
|
|
465
|
+
blockSettings: config.blockSettings,
|
|
466
|
+
popups: 'src/data/popups',
|
|
467
|
+
cookieList: config.cookieList,
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
/** The changes that update a store app or theme to its permanent folder: from its legacy folder (`legacy`, its files now
|
|
471
|
+
* `current`) with every id in the site's files (`site`) rewritten, or, without `legacy`, as appChanges or themeChanges
|
|
472
|
+
* do for the folder. In one commit (legacyMove.ts). */
|
|
473
|
+
export const moveChanges = (
|
|
474
|
+
entry: { id: string },
|
|
475
|
+
legacy: string | null,
|
|
476
|
+
next: Parameters<typeof moveItem>[2],
|
|
477
|
+
current: RepoFile[],
|
|
478
|
+
site: RepoFile[],
|
|
479
|
+
) => moveItem(entry, legacy, next, current, site, sitePaths);
|
|
480
|
+
export { appliedTo, installedHere, legacyOf, mergeChanges } from './legacyMove';
|
|
481
|
+
|
|
477
482
|
/** The files of an app installed from the store that were changed in the site since (in its folder, `current`, not
|
|
478
483
|
* as its note says), by their paths in the folder: an update writes over them. */
|
|
479
484
|
export function changedFiles(id: string, record: StoreRecord, current: RepoFile[], root = appFolder(id)): string[] {
|
package/src/topBarSlot.tsx
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
// Where the editor's top bar stands: one
|
|
2
|
-
// rail or drawer (`aside`; App.tsx wraps the layout in TopBarLayout), which Screen.tsx draws its bar into.
|
|
3
|
-
//
|
|
4
|
-
|
|
1
|
+
// Where the editor's top bar stands: one place at the top of the content column, beside the full-height navigation
|
|
2
|
+
// rail or drawer (`aside`; App.tsx wraps the layout in TopBarLayout), which Screen.tsx draws its bar into. The bar scrolls
|
|
3
|
+
// away with the page. Exposes TopBarLayout and useTopBarSlot.
|
|
4
|
+
// While the side panel is open the place ends at the panel's start edge (.editor-top-bar-slot in editor.css).
|
|
5
|
+
import { createContext, useContext, useState, type ReactNode } from 'react';
|
|
5
6
|
|
|
6
7
|
const Slot = createContext<HTMLElement | null>(null);
|
|
7
8
|
|
|
@@ -13,22 +14,13 @@ export const useTopBarSlot = () => useContext(Slot);
|
|
|
13
14
|
* screen's bar does not first stand in the screen and then move. */
|
|
14
15
|
export function TopBarLayout({ above, aside, children }: { above?: ReactNode; aside?: ReactNode; children: ReactNode }) {
|
|
15
16
|
const [slot, setSlot] = useState<HTMLDivElement | null>(null);
|
|
16
|
-
const [layout, setLayout] = useState<HTMLDivElement | null>(null);
|
|
17
|
-
useEffect(() => {
|
|
18
|
-
if (!slot || !layout) return;
|
|
19
|
-
const measure = () => layout.style.setProperty('--top-bar-height', `${slot.offsetHeight}px`);
|
|
20
|
-
measure();
|
|
21
|
-
const watch = new ResizeObserver(measure);
|
|
22
|
-
watch.observe(slot);
|
|
23
|
-
return () => watch.disconnect();
|
|
24
|
-
}, [slot, layout]);
|
|
25
17
|
return (
|
|
26
|
-
<div
|
|
18
|
+
<div>
|
|
27
19
|
{above}
|
|
28
20
|
<div className="flex">
|
|
29
21
|
{aside}
|
|
30
22
|
<div className="min-w-0 flex-1">
|
|
31
|
-
<div ref={setSlot} className="
|
|
23
|
+
<div ref={setSlot} className="editor-top-bar-slot" />
|
|
32
24
|
<Slot.Provider value={slot}>{slot && children}</Slot.Provider>
|
|
33
25
|
</div>
|
|
34
26
|
</div>
|
package/src/ui.tsx
CHANGED
|
@@ -72,7 +72,7 @@ import {
|
|
|
72
72
|
SiteDatePicker,
|
|
73
73
|
SiteDialog,
|
|
74
74
|
siteDialogModalClass,
|
|
75
|
-
|
|
75
|
+
SiteFab,
|
|
76
76
|
SiteFabMenu,
|
|
77
77
|
SiteIconButton,
|
|
78
78
|
SiteLoadingIndicator,
|
|
@@ -2541,6 +2541,9 @@ export function useAtLeast(name: Exclude<Breakpoint, 'compact'>) {
|
|
|
2541
2541
|
return useMedia(atLeast(name));
|
|
2542
2542
|
}
|
|
2543
2543
|
|
|
2544
|
+
/** Whether the window is at least as wide as the breakpoint right now (not kept up to date: for a tap's handler). */
|
|
2545
|
+
export const atLeastNow = (name: Exclude<Breakpoint, 'compact'>) => matchMedia(atLeast(name)).matches;
|
|
2546
|
+
|
|
2544
2547
|
/** How the main menu is laid out in this window (lib/menu-layout.mjs): whether the navigation rail stands, which way it
|
|
2545
2548
|
* expands, and whether the top bar has the menu button. */
|
|
2546
2549
|
export function useMenuLayout() {
|
|
@@ -2602,7 +2605,7 @@ export function SideSheet({ title, onClose, children }: { title: string; onClose
|
|
|
2602
2605
|
headlineId={id}
|
|
2603
2606
|
closeLabel={t.common.close}
|
|
2604
2607
|
onClose={onClose}
|
|
2605
|
-
className="editor-reveal sticky top-
|
|
2608
|
+
className="editor-reveal sticky top-8 max-h-[calc(100dvh-3rem)] self-start"
|
|
2606
2609
|
>
|
|
2607
2610
|
<div className="space-y-5">{children}</div>
|
|
2608
2611
|
</SiteSideSheet>
|
|
@@ -2649,12 +2652,12 @@ export function SectionList({
|
|
|
2649
2652
|
}
|
|
2650
2653
|
|
|
2651
2654
|
/** The "on this page" card at the end of a long form on large and wider screens: the theme's outlined card, as tall as
|
|
2652
|
-
* its content (it scrolls inside only when that is taller than the window
|
|
2655
|
+
* its content (it scrolls inside only when that is taller than the window), with the small `caption`
|
|
2653
2656
|
* (Afsnit), the form's `title` under it and the form's SectionList (`children`). It stays in view while the form
|
|
2654
2657
|
* scrolls. Its place is FormColumns' `pane`, against the window's end edge; below large it is not drawn. */
|
|
2655
2658
|
export function SectionPane({ caption, title, children }: { caption: string; title: string; children: ReactNode }) {
|
|
2656
2659
|
return (
|
|
2657
|
-
<div className={`${siteCardClass('outlined')} max-h-[calc(100dvh-
|
|
2660
|
+
<div className={`${siteCardClass('outlined')} max-h-[calc(100dvh-2*var(--section-pane-gap))] overflow-y-auto`}>
|
|
2658
2661
|
<p className="section-pane-caption m-0 text-muted">{caption}</p>
|
|
2659
2662
|
<h2 className="section-pane-title m-0 mb-3 text-ink">{title}</h2>
|
|
2660
2663
|
{children}
|
|
@@ -3114,13 +3117,13 @@ export function FormColumns({
|
|
|
3114
3117
|
cards?: boolean;
|
|
3115
3118
|
sticky?: boolean;
|
|
3116
3119
|
/** A SectionPane, on large and wider screens against the window's end edge under the top bar (--section-pane-width
|
|
3117
|
-
* wide,
|
|
3120
|
+
* wide, in an absolute strip along the content that its card sticks in while the form scrolls; editor.css lets the content column keep clear of it); below large nothing.
|
|
3118
3121
|
* Instead of `first` and `side`. */
|
|
3119
3122
|
pane?: ReactNode;
|
|
3120
3123
|
}) {
|
|
3121
3124
|
// On a wide computer screen (large) each of the right column's parts on the theme's outlined card.
|
|
3122
3125
|
const carded = useAtLeast('large') && cards;
|
|
3123
|
-
const right = `space-y-6 large:col-start-2 ${carded ? siteCardClass('outlined') : ''} ${sticky ? 'large:sticky large:top-
|
|
3126
|
+
const right = `space-y-6 large:col-start-2 ${carded ? siteCardClass('outlined') : ''} ${sticky ? 'large:sticky large:top-8' : ''}`;
|
|
3124
3127
|
return (
|
|
3125
3128
|
<div
|
|
3126
3129
|
className={`flex flex-col gap-6 ${
|
|
@@ -3131,8 +3134,8 @@ export function FormColumns({
|
|
|
3131
3134
|
<div className="min-w-0 space-y-6 large:col-start-1 large:row-span-2 large:row-start-1">{main}</div>
|
|
3132
3135
|
{side && <div className={`${right} ${first ? 'large:row-start-2' : 'large:row-span-2 large:row-start-1'}`}>{side}</div>}
|
|
3133
3136
|
{pane && (
|
|
3134
|
-
<div className="editor-section-pane hidden large:
|
|
3135
|
-
{pane}
|
|
3137
|
+
<div className="editor-section-pane hidden large:absolute large:inset-y-0 large:end-0 large:z-10 large:block large:w-(--section-pane-width) large:pe-(--section-pane-gap)">
|
|
3138
|
+
<div className="large:sticky large:top-(--section-pane-gap) large:mt-(--section-pane-gap)">{pane}</div>
|
|
3136
3139
|
</div>
|
|
3137
3140
|
)}
|
|
3138
3141
|
</div>
|
|
@@ -3183,7 +3186,7 @@ export function LeadingToken({ kind, value, weight }: { kind: TokenKind; value:
|
|
|
3183
3186
|
// ---------------------------------------------------------------------------------------------------
|
|
3184
3187
|
// The sheet from the bottom
|
|
3185
3188
|
|
|
3186
|
-
/** A screen's top bar (Screen.tsx): the theme's top app bar (Material 3's), at the top of the screen
|
|
3189
|
+
/** A screen's top bar (Screen.tsx): the theme's top app bar (Material 3's), at the top of the screen (it scrolls away with the page),
|
|
3187
3190
|
* below a phone's clock, and marked as the theme marks it once the screen has scrolled under it (data-stuck). */
|
|
3188
3191
|
export function TopAppBar({ children }: { children: ReactNode }) {
|
|
3189
3192
|
const [stuck, setStuck] = useState(false);
|
|
@@ -3194,7 +3197,7 @@ export function TopAppBar({ children }: { children: ReactNode }) {
|
|
|
3194
3197
|
return () => removeEventListener('scroll', check);
|
|
3195
3198
|
}, []);
|
|
3196
3199
|
return (
|
|
3197
|
-
<SiteTopAppBar className="
|
|
3200
|
+
<SiteTopAppBar className="pt-[env(safe-area-inset-top)]" data-stuck={stuck || undefined}>
|
|
3198
3201
|
{children}
|
|
3199
3202
|
</SiteTopAppBar>
|
|
3200
3203
|
);
|
|
@@ -3855,14 +3858,22 @@ export function NewChoice<T extends string>({
|
|
|
3855
3858
|
);
|
|
3856
3859
|
}
|
|
3857
3860
|
|
|
3858
|
-
/** One action of a FAB menu: its icon, its name and
|
|
3859
|
-
|
|
3861
|
+
/** One action of a FAB menu: its icon, its name and what it does: a link (`href`) or a button (`onClick`). While `busy` its
|
|
3862
|
+
* action runs, and it is disabled as with `disabled`. */
|
|
3863
|
+
export type FabAction = { icon: Icon; label: string; href?: string; onClick?: () => void; busy?: boolean; disabled?: boolean };
|
|
3860
3864
|
|
|
3861
3865
|
// An action's icon, drawn.
|
|
3862
3866
|
const iconOf = ({ icon: Item }: FabAction) => <Item />;
|
|
3863
3867
|
|
|
3868
|
+
// What an action's element gets besides its icon and name: busy is disabled and aria-busy (a link has neither).
|
|
3869
|
+
const fabProps = ({ href, onClick, busy, disabled }: FabAction) => ({
|
|
3870
|
+
href,
|
|
3871
|
+
onClick,
|
|
3872
|
+
...(href === undefined ? { disabled: disabled || busy, 'aria-busy': busy || undefined } : {}),
|
|
3873
|
+
});
|
|
3874
|
+
|
|
3864
3875
|
/** The screen's main actions at the bottom right (.editor-fab, editor.css): the theme's FAB menu, opened and closed as
|
|
3865
|
-
* on the site (setUpFabMenu), or with one action that action as
|
|
3876
|
+
* on the site (setUpFabMenu), or with one action that action as a FAB (its icon, its name as its tooltip and read by screen readers; a link, or a button); nothing without any. Drawn in a portal
|
|
3866
3877
|
* on <body>, since the screen's entrance animation (a transform) would make its <main> the containing block of a fixed FAB. */
|
|
3867
3878
|
export function FabMenu({ icon: Glyph, label, items }: { icon: Icon; label: string; items: FabAction[] }) {
|
|
3868
3879
|
const ref = useRef<HTMLDivElement>(null);
|
|
@@ -3879,10 +3890,10 @@ export function FabMenu({ icon: Glyph, label, items }: { icon: Icon; label: stri
|
|
|
3879
3890
|
icon={<Glyph />}
|
|
3880
3891
|
label={label}
|
|
3881
3892
|
closeLabel={t.common.close}
|
|
3882
|
-
items={items.map((item) => ({
|
|
3893
|
+
items={items.map((item) => ({ label: item.label, icon: iconOf(item), ...fabProps(item) }))}
|
|
3883
3894
|
/>
|
|
3884
3895
|
) : (
|
|
3885
|
-
<
|
|
3896
|
+
<SiteFab icon={iconOf(items[0])} label={items[0].label} title={items[0].label} {...fabProps(items[0])} />
|
|
3886
3897
|
)}
|
|
3887
3898
|
</div>,
|
|
3888
3899
|
document.body,
|
package/src/virtual.d.ts
CHANGED
|
@@ -27,3 +27,9 @@ declare module 'virtual:theme-coverage' {
|
|
|
27
27
|
} | null;
|
|
28
28
|
export default coverage;
|
|
29
29
|
}
|
|
30
|
+
|
|
31
|
+
// The id of the app whose folder the importing file is in (appSelf in site/appSelf.mjs; app contract 3).
|
|
32
|
+
declare module '@app/self' {
|
|
33
|
+
const self: { readonly id: string };
|
|
34
|
+
export default self;
|
|
35
|
+
}
|
package/starter/package.json
CHANGED
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"@material-symbols/svg-400": "0.48.2",
|
|
33
33
|
"@material/material-color-utilities": "^0.3.0",
|
|
34
34
|
"@milkdown/kit": "^7.22.3",
|
|
35
|
-
"@morphemecms/cms": "0.
|
|
35
|
+
"@morphemecms/cms": "0.12.0",
|
|
36
36
|
"@phosphor-icons/core": "^2.1.1",
|
|
37
37
|
"@phosphor-icons/react": "^2.1.10",
|
|
38
38
|
"@tailwindcss/typography": "^0.5.20",
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
---
|
|
2
|
+
// The top of a page's text (PageLayout.astro): the link back to its parent page and the page's H1 (data-editor="heading", which
|
|
3
|
+
// the editor's preview fills in). Props: parent ({ href, title }), heading, headingHtml (the heading with its coloured words).
|
|
4
|
+
import Icon from '@theme/components/Icon.astro';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
parent?: { href: string; title: string };
|
|
8
|
+
heading: string;
|
|
9
|
+
headingHtml?: string;
|
|
10
|
+
}
|
|
11
|
+
const { parent, heading, headingHtml } = Astro.props;
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
{parent && (
|
|
15
|
+
<a href={parent.href} class="inline-flex items-center gap-1.5 type-body-medium text-muted hover:text-accent">
|
|
16
|
+
<Icon name="arrow-left" size={16} />
|
|
17
|
+
{parent.title}
|
|
18
|
+
</a>
|
|
19
|
+
)}
|
|
20
|
+
<h1 data-editor="heading" class="theme-title mt-3 max-w-[22ch]">
|
|
21
|
+
{headingHtml ? <Fragment set:html={headingHtml} /> : heading}
|
|
22
|
+
</h1>
|
|
@@ -8,12 +8,23 @@
|
|
|
8
8
|
// paragraph is the lead (type-lead, ui.css: the --type-lead-* tokens), muted and 52ch wide.
|
|
9
9
|
// data-editor marks what the editor's preview fills in with what is in its form ("Forhåndsvis",
|
|
10
10
|
// packages/editor/src/pagePreview.tsx): the heading, the text, and the booking prompt at the end.
|
|
11
|
+
// A page whose H1 has a section colour (the marker before it, lib/section-marker.mjs) draws the heading and the text up to the
|
|
12
|
+
// first H2 in one panel (data-editor="h1-section", sectionClass); with a column beside the text the panel holds the heading only.
|
|
11
13
|
import Base from '@theme/layouts/Base.astro';
|
|
12
|
-
import
|
|
14
|
+
import PageHeading from '@theme/components/PageHeading.astro';
|
|
13
15
|
import EndBlock from '@theme/components/EndBlock.astro';
|
|
14
16
|
import Block from '@morphemecms/cms/src/standard/Block.astro';
|
|
15
17
|
import { blockLoads, endBlock, sideBlock } from '@morphemecms/cms/src/standard/views';
|
|
16
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
getPages,
|
|
20
|
+
h1SectionColor,
|
|
21
|
+
pageHeading,
|
|
22
|
+
pageHeadingHtml,
|
|
23
|
+
parentPage,
|
|
24
|
+
stateOf,
|
|
25
|
+
withoutH1,
|
|
26
|
+
type Page,
|
|
27
|
+
} from '@morphemecms/cms/site/content';
|
|
17
28
|
import { blockTexts, nav } from '@morphemecms/cms/site/site';
|
|
18
29
|
import { firstPicture, picturesOf } from '@morphemecms/cms/site/images.mjs';
|
|
19
30
|
import { splitBlocks } from '@morphemecms/cms/lib/blocks.mjs';
|
|
@@ -22,6 +33,7 @@ import { offeredTemplates } from '@morphemecms/cms/site/templates';
|
|
|
22
33
|
import { markdownHtml } from '@morphemecms/cms/lib/inline';
|
|
23
34
|
import Container from '@theme/components/Container.astro';
|
|
24
35
|
import RichText from '@theme/components/RichText.astro';
|
|
36
|
+
import { introOf, sectionClass } from '@morphemecms/cms/lib/text-sections.mjs';
|
|
25
37
|
import Snackbar from '@theme/ui/Snackbar';
|
|
26
38
|
import { isPreview, livePath, PREVIEW, previewId } from '@morphemecms/cms/site/noindex.mjs';
|
|
27
39
|
import { siteT } from '@morphemecms/cms/src/i18n';
|
|
@@ -51,7 +63,10 @@ const picture = firstPicture(page.body);
|
|
|
51
63
|
// e.g. "Book en tid", as on most pages) is shown across the page below the text and the column next to it; not when
|
|
52
64
|
// it is hidden (RichText.astro leaves hidden blocks out).
|
|
53
65
|
// Without the H1 at the top of the text (the page's heading, drawn above).
|
|
54
|
-
const
|
|
66
|
+
const text = await Astro.slots.render('default');
|
|
67
|
+
const html = withoutH1(text);
|
|
68
|
+
// The colour of the H1's section (the marker before the H1): the heading and the text up to the first H2 are one panel.
|
|
69
|
+
const h1Color = h1SectionColor(text);
|
|
55
70
|
const end = endBlock(html);
|
|
56
71
|
// The questions in the text's "Spørgsmål og svar" blocks (not the hidden ones), for the structured data
|
|
57
72
|
// (an FAQPage, packages/editor/site/schema.ts); the answers as HTML, as on the page (the block's load.ts).
|
|
@@ -76,6 +91,12 @@ const side =
|
|
|
76
91
|
(await blockLoads[sideType].load({}, { texts: blockTexts[sideType] ?? {}, index: 0, path: current, preview, html: sideText })) !== null
|
|
77
92
|
? sideType
|
|
78
93
|
: null;
|
|
94
|
+
// In a coloured H1 section the text up to the first H2 (intro) is in the panel with the heading; with a column beside the text
|
|
95
|
+
// the text stays whole in its grid and the panel holds the heading only.
|
|
96
|
+
const { intro, rest } = h1Color && !side ? introOf(sideText) : { intro: '', rest: sideText };
|
|
97
|
+
const textClass = side
|
|
98
|
+
? 'prose max-w-[66ch]'
|
|
99
|
+
: 'prose max-w-none [&>:not(.not-prose)]:max-w-[66ch] [&>p:first-child]:type-lead [&>p:first-child]:text-muted [&>p:first-child]:max-w-[52ch]';
|
|
79
100
|
---
|
|
80
101
|
|
|
81
102
|
<Base
|
|
@@ -96,28 +117,24 @@ const side =
|
|
|
96
117
|
>
|
|
97
118
|
<article data-template={template}>
|
|
98
119
|
<Container as="header" class="pt-8 medium:pt-14">
|
|
99
|
-
{
|
|
100
|
-
<
|
|
101
|
-
<
|
|
102
|
-
{
|
|
103
|
-
|
|
120
|
+
{h1Color ? (
|
|
121
|
+
<section class={sectionClass(h1Color)} data-editor="h1-section">
|
|
122
|
+
<PageHeading parent={parent} heading={heading} headingHtml={headingHtml} />
|
|
123
|
+
{intro && (
|
|
124
|
+
<div data-editor="intro" class={textClass}>
|
|
125
|
+
<RichText html={intro} path={current} preview={preview} />
|
|
126
|
+
</div>
|
|
127
|
+
)}
|
|
128
|
+
</section>
|
|
129
|
+
) : (
|
|
130
|
+
<PageHeading parent={parent} heading={heading} headingHtml={headingHtml} />
|
|
104
131
|
)}
|
|
105
|
-
<h1 data-editor="heading" class="theme-title mt-3 max-w-[22ch]">
|
|
106
|
-
{headingHtml ? <Fragment set:html={headingHtml} /> : heading}
|
|
107
|
-
</h1>
|
|
108
132
|
</Container>
|
|
109
133
|
|
|
110
134
|
<Container as="div" class="mt-8 medium:mt-12">
|
|
111
135
|
<div {...(side ? { class: 'page-with-side', 'data-side': template === 'sidebar-left' ? 'left' : 'right' } : {})}>
|
|
112
|
-
<div
|
|
113
|
-
|
|
114
|
-
class={
|
|
115
|
-
side
|
|
116
|
-
? 'prose max-w-[66ch]'
|
|
117
|
-
: 'prose max-w-none [&>:not(.not-prose)]:max-w-[66ch] [&>p:first-child]:type-lead [&>p:first-child]:text-muted [&>p:first-child]:max-w-[52ch]'
|
|
118
|
-
}
|
|
119
|
-
>
|
|
120
|
-
<RichText html={sideText} path={current} preview={preview} />
|
|
136
|
+
<div data-editor="text" class={textClass}>
|
|
137
|
+
<RichText html={rest} path={current} preview={preview} />
|
|
121
138
|
</div>
|
|
122
139
|
{side && (
|
|
123
140
|
<aside class="page-side">
|
package/theme/styles/global.css
CHANGED