@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
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// Tests for src/legacyMove.ts: when a legacy store copy counts as installed, and the one list of changes that moves it
|
|
2
|
+
// to its permanent folder. Run: node --test src/legacyMove.test.mjs
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import { test } from 'node:test';
|
|
5
|
+
import { appliedTo, installedHere, legacyOf, mergeChanges, moveChanges } from './legacyMove.ts';
|
|
6
|
+
|
|
7
|
+
const PATHS = {
|
|
8
|
+
appsFolder: 'src/apps',
|
|
9
|
+
themesFolder: 'src/themes',
|
|
10
|
+
installed: 'src/data/apps.json',
|
|
11
|
+
settings: 'src/data/apps',
|
|
12
|
+
theme: 'src/data/theme.json',
|
|
13
|
+
icons: 'src/data/icons.json',
|
|
14
|
+
blockSettings: 'src/data/block-settings.json',
|
|
15
|
+
popups: 'src/data/popups',
|
|
16
|
+
cookieList: 'src/data/cookie-list',
|
|
17
|
+
};
|
|
18
|
+
const VENUS = { id: 'theme_fa395ad84690acbd', legacy: 'venus' };
|
|
19
|
+
const MOBILEPAY = { id: 'app_ae0b5e17636668bc', legacy: 'mobilepay' };
|
|
20
|
+
const file = (path, text, oid = `oid-${path}`) => ({ path, oid, text });
|
|
21
|
+
const json = (x) => `${JSON.stringify(x, null, 2)}\n`;
|
|
22
|
+
const next = { files: [{ path: 'theme.json', content: '{}' }], record: { version: '2.0.0', ref: '2.0.0', files: [] } };
|
|
23
|
+
const find = (changes, path) => changes.find((c) => c.path === path);
|
|
24
|
+
|
|
25
|
+
test('a legacy folder with a store.json is installed', () => {
|
|
26
|
+
assert.equal(legacyOf(VENUS, [{ id: 'venus', store: { version: '1.2.0' } }]), 'venus');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('a legacy folder without a store.json is the site own', () => {
|
|
30
|
+
assert.equal(legacyOf(VENUS, [{ id: 'venus' }]), null);
|
|
31
|
+
assert.equal(legacyOf(VENUS, []), null);
|
|
32
|
+
assert.equal(legacyOf({ id: VENUS.id }, [{ id: 'venus', store: { version: '1.2.0' } }]), null);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('moving a theme writes the new folder, deletes the old files and rewrites theme.json and icons.json', () => {
|
|
36
|
+
const old = [file('src/themes/venus/theme.json', '{"a":1}'), file('src/themes/venus/store.json', '{}')];
|
|
37
|
+
const site = [file(PATHS.theme, json({ theme: 'venus' })), file(PATHS.icons, json({ pack: 'venus/x' }))];
|
|
38
|
+
const changes = moveChanges(VENUS, 'venus', next, old, site, PATHS);
|
|
39
|
+
assert.equal(find(changes, `src/themes/${VENUS.id}/theme.json`).content, '{}');
|
|
40
|
+
assert.ok(find(changes, `src/themes/${VENUS.id}/store.json`));
|
|
41
|
+
for (const f of old) assert.deepEqual(find(changes, f.path), { path: f.path, content: null, oid: f.oid });
|
|
42
|
+
assert.deepEqual(JSON.parse(find(changes, PATHS.theme).content), { theme: VENUS.id });
|
|
43
|
+
assert.deepEqual(JSON.parse(find(changes, PATHS.icons).content), { pack: `${VENUS.id}/x` });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('a system icon pack and another theme stay as they are', () => {
|
|
47
|
+
const site = [file(PATHS.theme, json({ theme: 'saturn' })), file(PATHS.icons, json({ pack: 'phosphor' }))];
|
|
48
|
+
const changes = moveChanges(VENUS, 'venus', next, [], site, PATHS);
|
|
49
|
+
assert.equal(find(changes, PATHS.theme), undefined);
|
|
50
|
+
assert.equal(find(changes, PATHS.icons), undefined);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('moving an app renames its settings file and keeps the sorted list', () => {
|
|
54
|
+
const settings = '{ "key": "kept as it is" }\n';
|
|
55
|
+
const site = [
|
|
56
|
+
file(PATHS.installed, json({ installed: ['calendly', 'mobilepay', 'zeta'] })),
|
|
57
|
+
file('src/data/apps/mobilepay.json', settings),
|
|
58
|
+
];
|
|
59
|
+
const changes = moveChanges(MOBILEPAY, 'mobilepay', { files: [], record: next.record }, [], site, PATHS);
|
|
60
|
+
assert.deepEqual(find(changes, 'src/data/apps/mobilepay.json'), {
|
|
61
|
+
path: 'src/data/apps/mobilepay.json',
|
|
62
|
+
content: null,
|
|
63
|
+
oid: 'oid-src/data/apps/mobilepay.json',
|
|
64
|
+
});
|
|
65
|
+
assert.deepEqual(find(changes, `src/data/apps/${MOBILEPAY.id}.json`), {
|
|
66
|
+
path: `src/data/apps/${MOBILEPAY.id}.json`,
|
|
67
|
+
content: settings,
|
|
68
|
+
oid: null,
|
|
69
|
+
});
|
|
70
|
+
assert.deepEqual(JSON.parse(find(changes, PATHS.installed).content).installed, ['calendly', MOBILEPAY.id, 'zeta'].sort());
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('moving an app rewrites block-settings, popups and the cookie list, and only the legacy values', () => {
|
|
74
|
+
const site = [
|
|
75
|
+
file(PATHS.blockSettings, json({ off: ['popups/mobilepay', 'popups/other', 'text', 'x/mobilepay-2'] })),
|
|
76
|
+
file('src/data/popups/a.json', json({ type: 'mobilepay', title: 'A' })),
|
|
77
|
+
file('src/data/popups/b.json', json({ type: 'other' })),
|
|
78
|
+
file('src/data/cookie-list/c.json', json({ name: 'mobilepay', app: 'mobilepay' })),
|
|
79
|
+
file('src/data/cookie-list/d.json', json({ app: 'other' })),
|
|
80
|
+
];
|
|
81
|
+
const changes = moveChanges(MOBILEPAY, 'mobilepay', { files: [], record: next.record }, [], site, PATHS);
|
|
82
|
+
assert.deepEqual(JSON.parse(find(changes, PATHS.blockSettings).content).off, [
|
|
83
|
+
`popups/${MOBILEPAY.id}`,
|
|
84
|
+
'popups/other',
|
|
85
|
+
'text',
|
|
86
|
+
'x/mobilepay-2',
|
|
87
|
+
]);
|
|
88
|
+
assert.equal(JSON.parse(find(changes, 'src/data/popups/a.json').content).type, MOBILEPAY.id);
|
|
89
|
+
assert.equal(JSON.parse(find(changes, 'src/data/popups/a.json').content).title, 'A');
|
|
90
|
+
assert.equal(find(changes, 'src/data/popups/b.json'), undefined);
|
|
91
|
+
const cookie = JSON.parse(find(changes, 'src/data/cookie-list/c.json').content);
|
|
92
|
+
assert.deepEqual(cookie, { name: 'mobilepay', app: MOBILEPAY.id });
|
|
93
|
+
assert.equal(find(changes, 'src/data/cookie-list/d.json'), undefined);
|
|
94
|
+
assert.equal(find(changes, PATHS.theme), undefined);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('an item without a legacy gets the normal update and no delete', () => {
|
|
98
|
+
const old = [file(`src/themes/${VENUS.id}/old.json`, '{}'), file(`src/themes/${VENUS.id}/theme.json`, '{}')];
|
|
99
|
+
const changes = moveChanges({ id: VENUS.id }, null, next, old, [file(PATHS.theme, json({ theme: 'venus' }))], PATHS);
|
|
100
|
+
assert.deepEqual(find(changes, `src/themes/${VENUS.id}/old.json`), {
|
|
101
|
+
path: `src/themes/${VENUS.id}/old.json`,
|
|
102
|
+
content: null,
|
|
103
|
+
oid: 'oid-src/themes/theme_fa395ad84690acbd/old.json',
|
|
104
|
+
});
|
|
105
|
+
assert.equal(find(changes, PATHS.theme), undefined);
|
|
106
|
+
assert.equal(changes.filter((c) => c.content === null).length, 1);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('a move with a release that adds a cookie changes each cookie-list file once', () => {
|
|
110
|
+
const old = file('src/data/cookie-list/a.json', json({ name: 'a', app: 'mobilepay' }), 'oid-a');
|
|
111
|
+
const site = [old];
|
|
112
|
+
const moved = moveChanges(MOBILEPAY, 'mobilepay', { files: [], record: next.record }, [], site, PATHS);
|
|
113
|
+
// What the editor's cookieChanges does for the new release on the site after the move: the new cookie is a new file.
|
|
114
|
+
const after = appliedTo(site, moved);
|
|
115
|
+
assert.equal(JSON.parse(after[0].text).app, MOBILEPAY.id);
|
|
116
|
+
const cookies = [
|
|
117
|
+
{ path: 'src/data/cookie-list/b.json', content: json({ name: 'b', app: MOBILEPAY.id }), oid: null },
|
|
118
|
+
{ path: 'src/data/cookie-list/a.json', content: null, oid: after[0].oid },
|
|
119
|
+
];
|
|
120
|
+
const all = mergeChanges(moved, cookies);
|
|
121
|
+
assert.equal(new Set(all.map((c) => c.path)).size, all.length);
|
|
122
|
+
assert.deepEqual(find(all, 'src/data/cookie-list/a.json'), { path: 'src/data/cookie-list/a.json', content: null, oid: 'oid-a' });
|
|
123
|
+
assert.equal(JSON.parse(find(all, 'src/data/cookie-list/b.json').content).app, MOBILEPAY.id);
|
|
124
|
+
// A cookie that stays: only the rewrite, once, under the new id.
|
|
125
|
+
const kept = mergeChanges(moved, []);
|
|
126
|
+
assert.equal(kept.filter((c) => c.path === old.path).length, 1);
|
|
127
|
+
assert.equal(JSON.parse(find(kept, old.path).content).app, MOBILEPAY.id);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('installedHere: counts a new id, or a legacy copy from the store, as installed', () => {
|
|
131
|
+
const entry = { id: 'app_ae0b5e17636668bc', legacy: 'mobilepay' };
|
|
132
|
+
assert.equal(installedHere(entry, ['app_ae0b5e17636668bc'], []), true);
|
|
133
|
+
assert.equal(installedHere(entry, ['mobilepay'], [{ id: 'mobilepay', store: {} }]), true);
|
|
134
|
+
assert.equal(installedHere(entry, ['mobilepay'], [{ id: 'mobilepay' }]), false);
|
|
135
|
+
assert.equal(installedHere(entry, [], [{ id: 'mobilepay', store: {} }]), false);
|
|
136
|
+
assert.equal(installedHere({ id: 'app_1' }, ['other'], [{ id: 'other', store: {} }]), false);
|
|
137
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// The legacy move: a store app or theme installed under its old id (folder `src/apps/mailchimp/`) moves to its permanent
|
|
2
|
+
// id (`app_` or `theme_` plus 16 hex) when it is updated. Exposes legacyOf (is a legacy copy installed?), installedHere (does the store list show an entry as installed?) and moveChanges
|
|
3
|
+
// (one list of changes: new folder, old files deleted, every id rewritten; docs/store-ids.md Table 1). Pure; the site's
|
|
4
|
+
// paths are passed in (store.ts binds them to the site's config). Depends on lib/folderChanges.mjs.
|
|
5
|
+
import type { Change, RepoFile } from './github';
|
|
6
|
+
import { folderChanges } from '../lib/folderChanges.mjs';
|
|
7
|
+
import type { StoreRecord } from './store';
|
|
8
|
+
|
|
9
|
+
/** Where a site keeps what names an app or a theme (the site's config, and the popups' folder). */
|
|
10
|
+
export type SitePaths = {
|
|
11
|
+
appsFolder: string;
|
|
12
|
+
themesFolder: string;
|
|
13
|
+
installed: string;
|
|
14
|
+
settings: string;
|
|
15
|
+
theme: string;
|
|
16
|
+
icons: string;
|
|
17
|
+
blockSettings: string;
|
|
18
|
+
popups: string;
|
|
19
|
+
cookieList: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
type Next = { files: { path: string; content: string | Uint8Array }[]; record: StoreRecord };
|
|
23
|
+
|
|
24
|
+
/** The old id of a catalog entry (`entry.legacy`) when a folder of that name is installed from the store (`folders`: the
|
|
25
|
+
* apps or themes the site has, with `store` when the folder has a store.json); else null (none, or the site's own). */
|
|
26
|
+
export function legacyOf(entry: { legacy?: string }, folders: { id: string; store?: unknown }[]): string | null {
|
|
27
|
+
return entry.legacy && folders.some((f) => f.id === entry.legacy && f.store) ? entry.legacy : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Whether the store's list counts a catalog entry as installed: its own id is installed, or its old id (`entry.legacy`)
|
|
31
|
+
* is, as a copy from the store (`folders`: the apps or themes the site has). `installed`: the ids installed. */
|
|
32
|
+
export function installedHere(
|
|
33
|
+
entry: { id: string; legacy?: string },
|
|
34
|
+
installed: string[],
|
|
35
|
+
folders: { id: string; store?: unknown }[],
|
|
36
|
+
): boolean {
|
|
37
|
+
const old = legacyOf(entry, folders);
|
|
38
|
+
return installed.includes(entry.id) || (old !== null && installed.includes(old));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const parse = (text: string): any => {
|
|
42
|
+
try {
|
|
43
|
+
return JSON.parse(text);
|
|
44
|
+
} catch {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** A file with a value changed (`change` gives the new JSON, or undefined when nothing in it names the legacy id). */
|
|
50
|
+
function rewrite(files: RepoFile[], change: (json: any) => any): Change[] {
|
|
51
|
+
return files.flatMap((f) => {
|
|
52
|
+
const json = parse(f.text);
|
|
53
|
+
const out = json && typeof json === 'object' ? change(json) : undefined;
|
|
54
|
+
return out === undefined ? [] : [{ path: f.path, content: `${JSON.stringify(out, null, 2)}\n`, oid: f.oid }];
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const inFolder = (site: RepoFile[], folder: string) => site.filter((f) => f.path.startsWith(`${folder}/`) && f.path.endsWith('.json'));
|
|
59
|
+
const only = (site: RepoFile[], path: string) => site.filter((f) => f.path === path);
|
|
60
|
+
|
|
61
|
+
/** The changes that make the folder `current` (the legacy folder, or the item's own when it has no legacy) the store's
|
|
62
|
+
* version of `entry` in its permanent folder. With a legacy id they also delete the legacy folder's files and rewrite
|
|
63
|
+
* every value that names it (`site`: the site's files now). In one commit. */
|
|
64
|
+
export function moveChanges(
|
|
65
|
+
entry: { id: string },
|
|
66
|
+
legacy: string | null,
|
|
67
|
+
next: Next,
|
|
68
|
+
current: RepoFile[],
|
|
69
|
+
site: RepoFile[],
|
|
70
|
+
paths: SitePaths,
|
|
71
|
+
): Change[] {
|
|
72
|
+
const isApp = entry.id.startsWith('app_');
|
|
73
|
+
const folder = (id: string) => `${isApp ? paths.appsFolder : paths.themesFolder}/${id}`;
|
|
74
|
+
if (!legacy) return folderChanges(folder(entry.id), current, next);
|
|
75
|
+
const swap = (v: unknown) => (v === legacy ? entry.id : undefined);
|
|
76
|
+
const changes = [...folderChanges(folder(entry.id), [], next), ...folderChanges(folder(legacy), current, null)];
|
|
77
|
+
if (isApp) {
|
|
78
|
+
changes.push(
|
|
79
|
+
...rewrite(only(site, paths.installed), (j) =>
|
|
80
|
+
Array.isArray(j.installed) && j.installed.includes(legacy)
|
|
81
|
+
? { ...j, installed: [...new Set(j.installed.map((x: unknown) => (x === legacy ? entry.id : x)))].sort() }
|
|
82
|
+
: undefined,
|
|
83
|
+
),
|
|
84
|
+
...rewrite(inFolder(site, paths.popups), (j) => (swap(j.type) ? { ...j, type: entry.id } : undefined)),
|
|
85
|
+
...rewrite(inFolder(site, paths.cookieList), (j) => (swap(j.app) ? { ...j, app: entry.id } : undefined)),
|
|
86
|
+
...rewrite(only(site, paths.blockSettings), (j) => {
|
|
87
|
+
const off = Array.isArray(j.off)
|
|
88
|
+
? j.off.map((x: unknown) => (typeof x === 'string' && x.endsWith(`/${legacy}`) ? `${x.slice(0, -legacy.length)}${entry.id}` : x))
|
|
89
|
+
: [];
|
|
90
|
+
return off.some((x: unknown, i: number) => x !== j.off[i]) ? { ...j, off } : undefined;
|
|
91
|
+
}),
|
|
92
|
+
);
|
|
93
|
+
// The settings file is renamed with its content kept.
|
|
94
|
+
const old = site.find((f) => f.path === `${paths.settings}/${legacy}.json`);
|
|
95
|
+
if (old)
|
|
96
|
+
changes.push(
|
|
97
|
+
{ path: old.path, content: null, oid: old.oid },
|
|
98
|
+
{ path: `${paths.settings}/${entry.id}.json`, content: old.text, oid: null },
|
|
99
|
+
);
|
|
100
|
+
} else {
|
|
101
|
+
changes.push(
|
|
102
|
+
...rewrite(only(site, paths.theme), (j) => (swap(j.theme) ? { ...j, theme: entry.id } : undefined)),
|
|
103
|
+
...rewrite(only(site, paths.icons), (j) =>
|
|
104
|
+
typeof j.pack === 'string' && j.pack.startsWith(`${legacy}/`)
|
|
105
|
+
? { ...j, pack: `${entry.id}${j.pack.slice(legacy.length)}` }
|
|
106
|
+
: undefined,
|
|
107
|
+
),
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
return changes;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The site's files as they are after `changes` (a changed file with its new text, a deleted one gone, a new one added),
|
|
114
|
+
* for what must be worked out from the site after a move (the cookie list). */
|
|
115
|
+
export function appliedTo(site: RepoFile[], changes: Change[]): RepoFile[] {
|
|
116
|
+
const by = new Map(changes.map((c) => [c.path, c]));
|
|
117
|
+
const kept = site.flatMap((f) => {
|
|
118
|
+
const c = by.get(f.path);
|
|
119
|
+
return !c ? [f] : typeof c.content === 'string' ? [{ ...f, text: c.content }] : [];
|
|
120
|
+
});
|
|
121
|
+
const added = changes.filter((c) => typeof c.content === 'string' && !site.some((f) => f.path === c.path));
|
|
122
|
+
return [...kept, ...added.map((c) => ({ path: c.path, oid: '', text: c.content as string }))];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Two lists of changes as one: for a path in both, the later one wins (a file rewritten by the move, then deleted by the
|
|
126
|
+
* cookie list), its `oid` that of the site's file; so no file is changed twice. */
|
|
127
|
+
export function mergeChanges(first: Change[], later: Change[]): Change[] {
|
|
128
|
+
const oid = new Map(first.map((c) => [c.path, c.oid]));
|
|
129
|
+
const paths = new Set(later.map((c) => c.path));
|
|
130
|
+
return [...first.filter((c) => !paths.has(c.path)), ...later.map((c) => (oid.has(c.path) ? { ...c, oid: oid.get(c.path)! } : c))];
|
|
131
|
+
}
|
package/src/markdown.mjs
CHANGED
|
@@ -130,8 +130,8 @@ export const textColorSchema = $markSchema('textColor', () => ({
|
|
|
130
130
|
},
|
|
131
131
|
}));
|
|
132
132
|
|
|
133
|
-
// The colour of a section (packages/editor/lib/section-marker.mjs): the marker line before an H2, or before the label above it
|
|
134
|
-
// (eyebrow), is read into the
|
|
133
|
+
// The colour of a section (packages/editor/lib/section-marker.mjs): the marker line before an H1 or H2, or before the label above it
|
|
134
|
+
// (eyebrow), is read into the heading's attribute `section` and written back before it. It must be read before remarkSiteBlocks
|
|
135
135
|
// sees it, so it is the first plugin of that kind in makeEditor. A marker with nothing to colour is dropped.
|
|
136
136
|
const htmlOf = (node) => {
|
|
137
137
|
const only = node?.type === 'paragraph' && node.children?.length === 1 ? node.children[0] : node;
|
|
@@ -143,12 +143,12 @@ const remarkSectionMarkers = $remark('remarkSectionMarkers', () => () => (tree)
|
|
|
143
143
|
const color = markerColor(htmlOf(child));
|
|
144
144
|
if (color === null) return true;
|
|
145
145
|
const next = isLabelLine(htmlOf(parts[i + 1])) ? parts[i + 2] : parts[i + 1];
|
|
146
|
-
if (next?.type === 'heading' && next.depth
|
|
146
|
+
if (next?.type === 'heading' && next.depth <= 2) next.section = color;
|
|
147
147
|
return false;
|
|
148
148
|
});
|
|
149
149
|
});
|
|
150
150
|
|
|
151
|
-
// The commonmark heading with the section colour as an attribute (only an H2 has one). Written back before the heading, or
|
|
151
|
+
// The commonmark heading with the section colour as an attribute (only an H1 or H2 has one). Written back before the heading, or
|
|
152
152
|
// before its label, which is already the last thing written (a line of HTML).
|
|
153
153
|
/** @type {import('@milkdown/kit/utils').$NodeSchema<'heading'>} */
|
|
154
154
|
export const headingWithSection = headingSchema.extendSchema((prev) => (ctx) => {
|
|
@@ -159,7 +159,7 @@ export const headingWithSection = headingSchema.extendSchema((prev) => (ctx) =>
|
|
|
159
159
|
parseMarkdown: {
|
|
160
160
|
match: base.parseMarkdown.match,
|
|
161
161
|
runner: (state, node, type) => {
|
|
162
|
-
state.openNode(type, { level: node.depth, section: node.depth
|
|
162
|
+
state.openNode(type, { level: node.depth, section: node.depth <= 2 ? (node.section ?? null) : null });
|
|
163
163
|
state.next(node.children);
|
|
164
164
|
state.closeNode();
|
|
165
165
|
},
|
|
@@ -168,8 +168,11 @@ export const headingWithSection = headingSchema.extendSchema((prev) => (ctx) =>
|
|
|
168
168
|
match: base.toMarkdown.match,
|
|
169
169
|
runner: (state, node) => {
|
|
170
170
|
const { level, section } = node.attrs;
|
|
171
|
-
const
|
|
172
|
-
|
|
171
|
+
const top = state.top();
|
|
172
|
+
// The document has no children before its first node is written: a heading at the very top starts them.
|
|
173
|
+
if (top && !top.children) top.children = [];
|
|
174
|
+
const written = top?.children;
|
|
175
|
+
if (level <= 2 && SECTION_COLORS.includes(section) && Array.isArray(written)) {
|
|
173
176
|
const last = written.at(-1);
|
|
174
177
|
const at = last?.type === 'html' && isLabelLine(last.value) ? written.length - 1 : written.length;
|
|
175
178
|
written.splice(at, 0, { type: 'html', value: sectionMarker(section) });
|
package/src/markdown.test.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Tests for src/markdown.mjs: the section colour marker is read into the H2's `section` attribute and written back as it was
|
|
1
|
+
// Tests for src/markdown.mjs: the section colour marker is read into the H1's or H2's `section` attribute and written back as it was
|
|
2
2
|
// (before the label, if any). Milkdown needs a DOM; jsdom stands in. Run: node --test packages/editor/src/markdown.test.mjs
|
|
3
3
|
import { test } from 'node:test';
|
|
4
4
|
import assert from 'node:assert/strict';
|
|
@@ -46,6 +46,20 @@ test('a coloured section comes back byte for byte', async () => {
|
|
|
46
46
|
assert.deepEqual(nodes, ['heading', 'paragraph', 'heading:tint', 'paragraph', 'heading:accent', 'paragraph']);
|
|
47
47
|
});
|
|
48
48
|
|
|
49
|
+
test('a coloured H1 comes back byte for byte', async () => {
|
|
50
|
+
const text = marked('tint', '# About\n\nText\n');
|
|
51
|
+
const { saved, nodes } = await through(text);
|
|
52
|
+
assert.equal(saved, text);
|
|
53
|
+
assert.deepEqual(nodes, ['heading:tint', 'paragraph']);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('the marker comes before a label above the H1', async () => {
|
|
57
|
+
const text = marked('accent', '<p class="block-eyebrow">Welcome</p>\n\n# About\n\nText\n');
|
|
58
|
+
const { saved, nodes } = await through(text);
|
|
59
|
+
assert.equal(saved, text);
|
|
60
|
+
assert.ok(nodes.includes('heading:accent'));
|
|
61
|
+
});
|
|
62
|
+
|
|
49
63
|
test('a text without markers is written as before', async () => {
|
|
50
64
|
const text = '# Title\n\n## One\n\nText\n';
|
|
51
65
|
assert.equal((await through(text)).saved, text);
|
|
@@ -58,7 +72,7 @@ test('the marker comes before a label above the H2', async () => {
|
|
|
58
72
|
assert.ok(nodes.includes('heading:surface'));
|
|
59
73
|
});
|
|
60
74
|
|
|
61
|
-
test('a marker not followed by an H2 is dropped', async () => {
|
|
75
|
+
test('a marker not followed by an H1 or H2 is dropped', async () => {
|
|
62
76
|
assert.deepEqual(await through(marked('tint', 'Plain text\n\n## B\n')), {
|
|
63
77
|
saved: 'Plain text\n\n## B\n',
|
|
64
78
|
nodes: ['paragraph', 'heading'],
|
package/src/menuOpen.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
// Whether the main menu's drawer is open, for the drawer (MainMenu.tsx) and the menu buttons (Screen.tsx, the rail): at expanded
|
|
2
2
|
// and wider the standard drawer's remembered state (sidebar.ts), below it the modal drawer's, which is not remembered.
|
|
3
|
-
// Which one is on screen is lib/menu-layout.mjs.
|
|
3
|
+
// Which one is on screen is lib/menu-layout.mjs. From expanded to below large the standard drawer yields to the rail while
|
|
4
|
+
// the standard side panel is open (drawerYield.mjs, sidePanel.ts); the remembered state does not change for that.
|
|
5
|
+
// Exposes useMenuOpen.
|
|
4
6
|
import { useCallback, useSyncExternalStore } from 'react';
|
|
5
7
|
import { setSidebar, useSidebar } from './sidebar';
|
|
6
|
-
import {
|
|
8
|
+
import { drawerShown } from './drawerYield.mjs';
|
|
9
|
+
import { setDrawerReopened, useDrawerReopened, usePanelOpen } from './sidePanel';
|
|
10
|
+
import { useAtLeast, useMenuLayout } from './ui';
|
|
7
11
|
|
|
8
12
|
let modalOpen = false;
|
|
9
13
|
const listeners = new Set<() => void>();
|
|
@@ -28,7 +32,19 @@ export function useMenuOpen() {
|
|
|
28
32
|
const wide = useMenuLayout().expanded === 'standard';
|
|
29
33
|
const standard = useSidebar();
|
|
30
34
|
const modal = useModalOpen();
|
|
31
|
-
const
|
|
32
|
-
const
|
|
35
|
+
const panel = usePanelOpen();
|
|
36
|
+
const large = useAtLeast('large');
|
|
37
|
+
const reopened = useDrawerReopened();
|
|
38
|
+
const open = wide ? drawerShown(standard, panel, large, reopened) : modal;
|
|
39
|
+
const setOpen = useCallback(
|
|
40
|
+
(next: boolean) => {
|
|
41
|
+
if (!wide) return setModal(next);
|
|
42
|
+
// While the drawer yields, opening it is for as long as the panel stays open; closing it is remembered.
|
|
43
|
+
if (next && panel && !large) return setDrawerReopened(true);
|
|
44
|
+
setDrawerReopened(false);
|
|
45
|
+
setSidebar(next);
|
|
46
|
+
},
|
|
47
|
+
[wide, panel, large],
|
|
48
|
+
);
|
|
33
49
|
return { open, setOpen, toggle: () => setOpen(!open) };
|
|
34
50
|
}
|
package/src/movedRoutes.mjs
CHANGED
|
@@ -18,8 +18,8 @@ export const MOVED = /** @type {Record<string, string>} */ ({
|
|
|
18
18
|
'videresendelser/ikke-fundet': 'seo/videresendelser/ikke-fundet',
|
|
19
19
|
// The settings of the booking and newsletter apps were on a screen "Promovering"; they are on the apps' own pages now.
|
|
20
20
|
promovering: 'apps',
|
|
21
|
-
'promovering/calendly': 'apps/
|
|
22
|
-
'promovering/mailchimp': 'apps/
|
|
21
|
+
'promovering/calendly': 'apps/app_a030dfba69793996',
|
|
22
|
+
'promovering/mailchimp': 'apps/app_5c4b6ba067ef127f',
|
|
23
23
|
// The header's menus and settings were on a screen of their own under "Globalt"; they are under "Udseende" now.
|
|
24
24
|
header: 'udseende/header',
|
|
25
25
|
// The footer's form was the file's own screen; it is under "Udseende" now.
|
package/src/movedRoutes.test.mjs
CHANGED
|
@@ -20,3 +20,8 @@ test('the extra moves come first', () => {
|
|
|
20
20
|
assert.equal(movedRoute('butik', { butik: 'indstillinger/appstore' }), 'indstillinger/appstore');
|
|
21
21
|
assert.equal(movedRoute('tema', { butik: 'x' }), 'udseende');
|
|
22
22
|
});
|
|
23
|
+
|
|
24
|
+
test('the old booking and newsletter settings open the apps by their permanent ids', () => {
|
|
25
|
+
assert.equal(movedRoute('promovering/calendly'), 'apps/app_a030dfba69793996');
|
|
26
|
+
assert.equal(movedRoute('promovering/mailchimp'), 'apps/app_5c4b6ba067ef127f');
|
|
27
|
+
});
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Puts the page's header (the layout's `article > header`) in a panel of this colour, with the intro (HTML, in the text's own
|
|
2
|
+
* class) after it; no colour (null) leaves the header without a panel. A panel the site's page has is first taken away.
|
|
3
|
+
* @param {Document} doc @param {string | null} color @param {string} introHtml */
|
|
4
|
+
export function setH1Panel(doc: Document, color: string | null, introHtml: string): void;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// The preview's copy of the coloured H1 panel of a page (theme/layouts/PageLayout.astro): the heading and the text up to the first
|
|
2
|
+
// H2 in one section (data-editor="h1-section"). Exposes setH1Panel, which makes, changes or takes away that panel in the page's
|
|
3
|
+
// document, whatever the page on the site has. Depends on lib/text-sections.mjs (sectionClass); used by pagePreview.tsx.
|
|
4
|
+
import { sectionClass } from '../lib/text-sections.mjs';
|
|
5
|
+
|
|
6
|
+
/** Puts the page's header (the layout's `article > header`) in a panel of this colour, with the intro (HTML, in the text's own
|
|
7
|
+
* class) after it; no colour (null) leaves the header without a panel. A panel the site's page has is first taken away.
|
|
8
|
+
* @param {Document} doc @param {string | null} color @param {string} introHtml */
|
|
9
|
+
export function setH1Panel(doc, color, introHtml) {
|
|
10
|
+
const header = doc.querySelector('article > header');
|
|
11
|
+
const text = doc.querySelector('[data-editor="text"]');
|
|
12
|
+
if (!header || !text) return;
|
|
13
|
+
const old = header.querySelector('[data-editor="h1-section"]');
|
|
14
|
+
if (old) {
|
|
15
|
+
old.querySelector('[data-editor="intro"]')?.remove();
|
|
16
|
+
old.replaceWith(...Array.from(old.childNodes));
|
|
17
|
+
}
|
|
18
|
+
if (!color) return;
|
|
19
|
+
const panel = doc.createElement('section');
|
|
20
|
+
panel.setAttribute('data-editor', 'h1-section');
|
|
21
|
+
panel.className = sectionClass(color);
|
|
22
|
+
panel.append(...Array.from(header.childNodes));
|
|
23
|
+
if (introHtml.trim()) {
|
|
24
|
+
const intro = doc.createElement(text.tagName);
|
|
25
|
+
intro.setAttribute('data-editor', 'intro');
|
|
26
|
+
intro.className = text.className;
|
|
27
|
+
intro.innerHTML = introHtml;
|
|
28
|
+
panel.append(intro);
|
|
29
|
+
}
|
|
30
|
+
header.append(panel);
|
|
31
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Tests src/pageH1Panel.mjs: the preview puts a page's header and intro in a coloured panel, or takes the panel away.
|
|
2
|
+
// Run: node --test src/pageH1Panel.test.mjs
|
|
3
|
+
import { test } from 'node:test';
|
|
4
|
+
import assert from 'node:assert/strict';
|
|
5
|
+
import { JSDOM } from 'jsdom';
|
|
6
|
+
import { setH1Panel } from './pageH1Panel.mjs';
|
|
7
|
+
|
|
8
|
+
const PAGE = (header) =>
|
|
9
|
+
new JSDOM(`<body><article><header>${header}</header><div><div data-editor="text" class="prose">x</div></div></article></body>`).window
|
|
10
|
+
.document;
|
|
11
|
+
const PLAIN = '<h1 data-editor="heading">Title</h1>';
|
|
12
|
+
|
|
13
|
+
test('the header and the intro go in a panel of the colour', () => {
|
|
14
|
+
const doc = PAGE(PLAIN);
|
|
15
|
+
setH1Panel(doc, 'tint', '<p>Intro</p>');
|
|
16
|
+
const panel = doc.querySelector('header > section.text-section.text-section-tint[data-editor="h1-section"]');
|
|
17
|
+
assert.ok(panel);
|
|
18
|
+
assert.equal(panel.querySelector('h1').textContent, 'Title');
|
|
19
|
+
const intro = panel.querySelector('[data-editor="intro"]');
|
|
20
|
+
assert.equal(intro.innerHTML, '<p>Intro</p>');
|
|
21
|
+
assert.equal(intro.className, 'prose');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('without an intro the panel holds the header only', () => {
|
|
25
|
+
const doc = PAGE(PLAIN);
|
|
26
|
+
setH1Panel(doc, 'accent', ' ');
|
|
27
|
+
assert.ok(doc.querySelector('[data-editor="h1-section"] h1'));
|
|
28
|
+
assert.equal(doc.querySelector('[data-editor="intro"]'), null);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('no colour takes a panel away and keeps the header', () => {
|
|
32
|
+
const doc = PAGE(
|
|
33
|
+
`<section data-editor="h1-section" class="text-section text-section-tint">${PLAIN}<div data-editor="intro">a</div></section>`,
|
|
34
|
+
);
|
|
35
|
+
setH1Panel(doc, null, '');
|
|
36
|
+
assert.equal(doc.querySelector('[data-editor="h1-section"]'), null);
|
|
37
|
+
assert.equal(doc.querySelector('[data-editor="intro"]'), null);
|
|
38
|
+
assert.equal(doc.querySelector('header > h1').textContent, 'Title');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('another colour replaces the panel of the site', () => {
|
|
42
|
+
const doc = PAGE(
|
|
43
|
+
`<section data-editor="h1-section" class="text-section text-section-tint">${PLAIN}<div data-editor="intro">a</div></section>`,
|
|
44
|
+
);
|
|
45
|
+
setH1Panel(doc, 'surface', '<p>b</p>');
|
|
46
|
+
assert.equal(doc.querySelectorAll('[data-editor="h1-section"]').length, 1);
|
|
47
|
+
assert.ok(doc.querySelector('.text-section-surface'));
|
|
48
|
+
assert.equal(doc.querySelector('.text-section-tint'), null);
|
|
49
|
+
assert.equal(doc.querySelector('[data-editor="intro"]').innerHTML, '<p>b</p>');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('a page without a header is left as it is', () => {
|
|
53
|
+
const doc = new JSDOM('<body><div data-editor="text">x</div></body>').window.document;
|
|
54
|
+
setH1Panel(doc, 'tint', '<p>a</p>');
|
|
55
|
+
assert.equal(doc.querySelector('section'), null);
|
|
56
|
+
});
|
package/src/pagePreview.tsx
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// puts a block that ends the text (data-editor="end", with its type in data-editor-block). Only what
|
|
9
9
|
// was changed is put in, so the rest is exactly as on the site. The text is the site's Markdown
|
|
10
10
|
// (Astro's pipeline, with the HTML written in it), and its blocks are drawn as in the editor
|
|
11
|
-
// (registry.tsx preview), with the site's classes; a coloured section is a panel as on the site (lib/text-sections.mjs).
|
|
11
|
+
// (registry.tsx preview), with the site's classes; a coloured section, and a coloured H1 with the text up to the first H2 (pageH1Panel.mjs), is a panel as on the site (lib/text-sections.mjs).
|
|
12
12
|
//
|
|
13
13
|
// Exposes pageDocument, usePagePreview and PageDraft, and for the preview of a part every page has (partPreview.tsx) siteShell,
|
|
14
14
|
// documentHtml, htmlOf and PREVIEW_WIDTH.
|
|
@@ -25,7 +25,8 @@ import { useRef, useState, type ReactNode } from 'react';
|
|
|
25
25
|
import { createRoot } from 'react-dom/client';
|
|
26
26
|
import { flushSync } from 'react-dom';
|
|
27
27
|
import { parseBlock, splitBlocks } from '../lib/blocks.mjs';
|
|
28
|
-
import { sectionClass, sectionsOf } from '../lib/text-sections.mjs';
|
|
28
|
+
import { introOf, sectionClass, sectionsOf } from '../lib/text-sections.mjs';
|
|
29
|
+
import { h1SectionColor, withoutH1 } from '../lib/without-h1.mjs';
|
|
29
30
|
import { BREAKPOINTS, breakpointOf, type Breakpoint } from '../lib/breakpoints.mjs';
|
|
30
31
|
import { processor } from '../lib/markdown';
|
|
31
32
|
import { splitPicture } from '../lib/crop.mjs';
|
|
@@ -33,6 +34,7 @@ import { isPostPage, leadFill } from './postLead.mjs';
|
|
|
33
34
|
import { preview as drawBlock, siteBlocks } from './registry';
|
|
34
35
|
import { shownAt } from './images';
|
|
35
36
|
import { pageH1 } from './sections.mjs';
|
|
37
|
+
import { setH1Panel } from './pageH1Panel.mjs';
|
|
36
38
|
import type { BlockData } from './blocks';
|
|
37
39
|
import { PageFrame, BottomSheet, SegmentedButton, SheetTitle } from './ui';
|
|
38
40
|
import { t } from './i18n';
|
|
@@ -144,8 +146,11 @@ export async function pageDocument(draft: PageDraft): Promise<string> {
|
|
|
144
146
|
}
|
|
145
147
|
|
|
146
148
|
if (draft.body !== undefined) {
|
|
147
|
-
// A page's H1 at the top of its text is drawn in its own place above (draft.heading), as on the site
|
|
148
|
-
|
|
149
|
+
// A page's H1 at the top of its text is drawn in its own place above (draft.heading), as on the site; the colour of its
|
|
150
|
+
// section (the marker before it) puts the heading and the text up to the first H2 in one panel (setH1Panel).
|
|
151
|
+
const whole = isPostPage(draft) ? '' : await markdownHtml(draft.body);
|
|
152
|
+
const h1Color = h1SectionColor(whole);
|
|
153
|
+
let html = isPostPage(draft) ? await markdownHtml(pageH1(draft.body).rest) : withoutH1(whole);
|
|
149
154
|
// A block that ends the text, where the layout puts it.
|
|
150
155
|
const end = doc.querySelector('[data-editor="end"]');
|
|
151
156
|
const type = end?.getAttribute('data-editor-block');
|
|
@@ -171,14 +176,19 @@ export async function pageDocument(draft: PageDraft): Promise<string> {
|
|
|
171
176
|
leadBox.innerHTML = lead;
|
|
172
177
|
}
|
|
173
178
|
const types = siteBlocks.map((b) => b.type);
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
179
|
+
const runs = (part: string) =>
|
|
180
|
+
sectionsOf(part)
|
|
181
|
+
.map((run) => {
|
|
182
|
+
const inner = splitBlocks(run.html, types)
|
|
183
|
+
.map((p) => ('html' in p ? p.html : blockHtml(p.type, p.props, draft.data)))
|
|
184
|
+
.join('');
|
|
185
|
+
return run.color ? `<section class="${sectionClass(run.color)}">${inner}</section>` : inner;
|
|
186
|
+
})
|
|
187
|
+
.join('');
|
|
188
|
+
// With a column beside the text the panel holds the heading only, as on the site.
|
|
189
|
+
const { intro, rest: below } = h1Color && !text.closest('[data-side]') ? introOf(rest) : { intro: '', rest };
|
|
190
|
+
setH1Panel(doc, h1Color, runs(intro));
|
|
191
|
+
text.innerHTML = runs(below);
|
|
182
192
|
}
|
|
183
193
|
return documentHtml(doc);
|
|
184
194
|
}
|
package/src/sectionFrame.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// The dotted frame round each H2 section of a page or blog post: sets data-section-part (start, middle, end, only) and
|
|
2
|
-
// data-section-color (the
|
|
1
|
+
// The dotted frame round each H1 or H2 section of a page or blog post: sets data-section-part (start, middle, end, only) and
|
|
2
|
+
// data-section-color (the heading's section colour) on the top-level nodes of a section, which editor.css draws. Exposes
|
|
3
3
|
// sectionFrame (a Milkdown plugin). Uses lib/section-ranges.mjs and src/sectionState.mjs.
|
|
4
4
|
import { $prose } from '@milkdown/kit/utils';
|
|
5
5
|
import type { Node } from '@milkdown/kit/prose/model';
|
|
@@ -29,7 +29,7 @@ function sectionDecorations(doc: Node): Decoration[] {
|
|
|
29
29
|
);
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
/** on: false for a fixed page's text (MarkdownEditor's `sections`), whose
|
|
32
|
+
/** on: false for a fixed page's text (MarkdownEditor's `sections`), whose headings are the names of its sections: no frame. */
|
|
33
33
|
export const sectionFrame = (on: boolean) =>
|
|
34
34
|
$prose(
|
|
35
35
|
() =>
|
package/src/sectionState.d.mts
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
|
-
/** The top-level H2 the cursor is in, with its colour, or null. @param {import('@milkdown/kit/prose/state').EditorState} state
|
|
1
|
+
/** The top-level H1 or H2 the cursor is in, with its colour, or null. @param {import('@milkdown/kit/prose/state').EditorState} state
|
|
2
2
|
* @returns {{ pos: number, color: string | null } | null} */
|
|
3
3
|
export function sectionHeading(state: import("@milkdown/kit/prose/state").EditorState): {
|
|
4
4
|
pos: number;
|
|
5
5
|
color: string | null;
|
|
6
6
|
} | null;
|
|
7
|
-
/** Gives the H2 the cursor is in this colour (null: none). False when it is not in
|
|
7
|
+
/** 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.
|
|
8
8
|
* @param {{ state: import('@milkdown/kit/prose/state').EditorState, dispatch: (tr: import('@milkdown/kit/prose/state').Transaction) => void }} view
|
|
9
9
|
* @param {string | null} color */
|
|
10
10
|
export function setSectionColor(view: {
|
|
11
11
|
state: import("@milkdown/kit/prose/state").EditorState;
|
|
12
12
|
dispatch: (tr: import("@milkdown/kit/prose/state").Transaction) => void;
|
|
13
13
|
}, color: string | null): boolean;
|
|
14
|
-
/** The colour of the section made of nodes[range.from..range.to] (sectionRanges): its H2's, null without one.
|
|
14
|
+
/** The colour of the section made of nodes[range.from..range.to] (sectionRanges): its H1's or H2's, null without one.
|
|
15
15
|
* @param {import('@milkdown/kit/prose/model').Node[]} nodes @param {{ from: number, to: number }} range @returns {string | null} */
|
|
16
16
|
export function sectionColorOf(nodes: import("@milkdown/kit/prose/model").Node[], range: {
|
|
17
17
|
from: number;
|