@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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# @morphemecms/cms
|
|
2
2
|
|
|
3
|
+
## 0.12.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 382abd8: An H1 starts a section with its own section colour, as an H2 does.
|
|
8
|
+
- 3123242: Store items have permanent ids: installed copies move to their new folder on their next update. Apps get their own id from @app/self (app contract 3).
|
|
9
|
+
|
|
10
|
+
### Patch Changes
|
|
11
|
+
|
|
12
|
+
- fd2fedf: From tablet width, tapping a block or picture opens its side panel; Remove is in the panel.
|
|
13
|
+
- 1459102: The side panel opens from 840px and collapses the drawer to the rail below 1200px; the modal side sheet is used below 840px; the top bar scrolls with the page.
|
|
14
|
+
- 79b6b33: The text toolbar and the menus over the text are white with a shadow.
|
|
15
|
+
- 25d29f7: The create button of every list, of Files and of Redirects is a floating action button instead of a + in the top bar; a list of several kinds opens a menu of them. The FAB stands above a form's save bar.
|
|
16
|
+
- d39abb5: The side panel follows Material 3: at most 400dp wide, full height, M3 spacing.
|
|
17
|
+
- ee4fbba: Preview and View on site stand at the end of the page's title row on wide screens.
|
|
18
|
+
- aa964b2: The package's checks now type-check its whole source the way a site does, so type errors a site would hit are caught before release.
|
|
19
|
+
- ca51ab8: Fix the store's installed filter for apps with an old id.
|
|
20
|
+
- 6b95110: Fix type errors that sites hit in the text editor.
|
|
21
|
+
- abef25a: Sites type-check the video block: the types of `player` and `facadeOf` make `frame` optional, as the block calls them.
|
|
22
|
+
|
|
3
23
|
## 0.11.0
|
|
4
24
|
|
|
5
25
|
### Minor Changes
|
package/astro.mjs
CHANGED
|
@@ -20,6 +20,7 @@ import { rehypeVideo } from './lib/video.mjs';
|
|
|
20
20
|
import { rehypeEmphasize } from './lib/rehype-emphasize.mjs';
|
|
21
21
|
import { csp, joined } from './lib/csp.mjs';
|
|
22
22
|
import { appBuilds, appFoldersPlugin } from './lib/apps.mjs';
|
|
23
|
+
import { appSelf } from './site/appSelf.mjs';
|
|
23
24
|
import { isNoindex } from './site/noindex.mjs';
|
|
24
25
|
import { activeTheme, aliases, iconPackLicenses, STANDARD_THEME, themeIcon, themePlugin } from './site/theme.mjs';
|
|
25
26
|
import { licensesIntegration, siteOwners } from './lib/licenses.mjs';
|
|
@@ -158,7 +159,7 @@ export async function siteConfig() {
|
|
|
158
159
|
// The editor (packages/editor/) takes this site from src/site/ as @site (packages/editor/defaults/editor.ts), and the
|
|
159
160
|
// site and the editor take the theme in use (src/data/theme.json) as @theme: its file, else the standard
|
|
160
161
|
// theme's (themePlugin, packages/editor/site/theme.mjs); and what is in each app's folder (appFoldersPlugin).
|
|
161
|
-
plugins: [themePlugin(), appFoldersPlugin(ROOT), tailwindcss()],
|
|
162
|
+
plugins: [themePlugin(), appFoldersPlugin(ROOT), appSelf(), tailwindcss()],
|
|
162
163
|
resolve: { alias: aliases() },
|
|
163
164
|
},
|
|
164
165
|
});
|
package/lib/appId.d.mts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export function appIdOf(path: string): string;
|
package/lib/appId.mjs
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// The id of an app from the path of a file in its folder. Exposes appIdOf (re-exported by src/apps.ts).
|
|
2
|
+
// No dependencies, so the Vite plugin site/appSelf.mjs can use it too.
|
|
3
|
+
|
|
4
|
+
/** The id of an app from the path of a file in its folder ("/src/apps/mailchimp/index.ts": "mailchimp",
|
|
5
|
+
* also ".../apps/mailchimp/popups/View.tsx"). @param {string} path */
|
|
6
|
+
export const appIdOf = (path) => path.split('/apps/')[1].split('/')[0];
|
package/lib/apps.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// A site folder in src/apps/ named app_ or theme_ that is not a store id stops the build (appIds, strayStoreId).
|
|
1
2
|
// What an app (src/apps/<app>/, packages/editor/src/apps.ts) can have outside the editor and the pages, each only while
|
|
2
3
|
// it is installed (src/data/apps.json):
|
|
3
4
|
// worker.js its part of the site's Worker: its own addresses, /api/<app>/… (`fetch`), and what it does each hour
|
|
@@ -11,6 +12,7 @@
|
|
|
11
12
|
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
12
13
|
import { join, sep } from 'node:path';
|
|
13
14
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
15
|
+
import { strayStoreId } from './storeEntries.mjs';
|
|
14
16
|
|
|
15
17
|
/** Where an app's files from the build are, in the build (dist/) and so on the site (served only to the Worker). */
|
|
16
18
|
export const APP_FILES = '_apps';
|
|
@@ -19,14 +21,22 @@ const APPS = 'src/apps';
|
|
|
19
21
|
const INSTALLED = 'src/data/apps.json';
|
|
20
22
|
const WORKERS = 'worker/apps.js';
|
|
21
23
|
|
|
22
|
-
/** The ids of the apps, all of them (the folders in src/apps/).
|
|
23
|
-
|
|
24
|
-
|
|
24
|
+
/** The ids of the apps, all of them (the folders in src/apps/). Stops on a folder that starts with `app_` or `theme_` but
|
|
25
|
+
* is not a store id of an app. @param {string} root */
|
|
26
|
+
export const appIds = (root) => {
|
|
27
|
+
const ids = existsSync(join(root, APPS))
|
|
25
28
|
? readdirSync(join(root, APPS), { withFileTypes: true })
|
|
26
29
|
.filter((d) => d.isDirectory())
|
|
27
30
|
.map((d) => d.name)
|
|
28
31
|
.sort()
|
|
29
32
|
: [];
|
|
33
|
+
const stray = ids.find((id) => strayStoreId(id, 'app'));
|
|
34
|
+
if (stray)
|
|
35
|
+
throw new Error(
|
|
36
|
+
`${APPS}/${stray}: the folder name looks like a store id but is not one. A store app id is app_ and 16 lowercase hex characters; rename the folder.`,
|
|
37
|
+
);
|
|
38
|
+
return ids;
|
|
39
|
+
};
|
|
30
40
|
|
|
31
41
|
/** The ids of the apps installed. @param {string} root @returns {string[]} */
|
|
32
42
|
export function installedApps(root) {
|
package/lib/apps.test.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { strict as assert } from 'node:assert/strict';
|
|
|
5
5
|
import { mkdtempSync, rmSync, writeFileSync, mkdirSync, readFileSync } from 'node:fs';
|
|
6
6
|
import { tmpdir } from 'node:os';
|
|
7
7
|
import { join } from 'node:path';
|
|
8
|
-
import { appWorkersFile, appWorkersProblems, writeAppWorkers } from './apps.mjs';
|
|
8
|
+
import { appIds, appWorkersFile, appWorkersProblems, writeAppWorkers } from './apps.mjs';
|
|
9
9
|
|
|
10
10
|
describe('lib/apps.mjs', () => {
|
|
11
11
|
let root;
|
|
@@ -47,4 +47,13 @@ describe('lib/apps.mjs', () => {
|
|
|
47
47
|
assert.equal(writeAppWorkers(root), true);
|
|
48
48
|
assert.equal(readFileSync(file, 'utf8'), appWorkersFile(root));
|
|
49
49
|
});
|
|
50
|
+
|
|
51
|
+
it('appIds lists permanent ids and own names, and stops on a folder that is not a store id', () => {
|
|
52
|
+
mkdirSync(join(root, 'src/apps/app_a030dfba69793996'), { recursive: true });
|
|
53
|
+
assert.deepEqual(appIds(root), ['app_a030dfba69793996', 'booking']);
|
|
54
|
+
mkdirSync(join(root, 'src/apps/app_xyz'), { recursive: true });
|
|
55
|
+
assert.throws(() => appIds(root), /src\/apps\/app_xyz/);
|
|
56
|
+
rmSync(join(root, 'src/apps/app_xyz'), { recursive: true });
|
|
57
|
+
rmSync(join(root, 'src/apps/app_a030dfba69793996'), { recursive: true });
|
|
58
|
+
});
|
|
50
59
|
});
|
package/lib/contracts.d.mts
CHANGED
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
/** The version of the contract between the editor and an app (apps.ts: defineApp, its folder, and the theme's parts it
|
|
6
6
|
* draws with: the standard blocks' and the UI components, @theme/blocks/… and @theme/ui/…, imported from
|
|
7
7
|
* @morphemecms/cms): an app in the store says which it is written to, and only those the editor can run are
|
|
8
|
-
* offered. Raised when an app written to the one before no longer works.
|
|
9
|
-
|
|
8
|
+
* offered. Raised when an app written to the one before no longer works. 3 adds `import self from '@app/self'` (the id
|
|
9
|
+
* of the app's own folder, site/appSelf.mjs); an app written to 2 works as before. */
|
|
10
|
+
export const APP_API: 3;
|
|
10
11
|
/** The version of the contract between the editor and a theme (what a theme must and may have, "Hvad et tema må" in
|
|
11
12
|
* CLAUDE.md, themeProblems in packages/editor/site/theme.mjs, the standard theme's UI parts with Material 3's names and
|
|
12
13
|
* variants, the motion scheme's --motion-* springs and motionSpring/motionTiming (@morphemecms/cms/lib/motion),
|
package/lib/contracts.mjs
CHANGED
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
/** The version of the contract between the editor and an app (apps.ts: defineApp, its folder, and the theme's parts it
|
|
7
7
|
* draws with: the standard blocks' and the UI components, @theme/blocks/… and @theme/ui/…, imported from
|
|
8
8
|
* @morphemecms/cms): an app in the store says which it is written to, and only those the editor can run are
|
|
9
|
-
* offered. Raised when an app written to the one before no longer works.
|
|
10
|
-
|
|
9
|
+
* offered. Raised when an app written to the one before no longer works. 3 adds `import self from '@app/self'` (the id
|
|
10
|
+
* of the app's own folder, site/appSelf.mjs); an app written to 2 works as before. */
|
|
11
|
+
export const APP_API = 3;
|
|
11
12
|
|
|
12
13
|
/** The version of the contract between the editor and a theme (what a theme must and may have, "Hvad et tema må" in
|
|
13
14
|
* CLAUDE.md, themeProblems in packages/editor/site/theme.mjs, the standard theme's UI parts with Material 3's names and
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** @typedef {{ path: string, oid: string }} RepoFile
|
|
2
|
+
* @typedef {{ path: string, content: string | Uint8Array | null, oid: string | null }} Change */
|
|
3
|
+
/** The changes that make a folder in the site (`current`, its files now) the store's version of an app or a theme, with
|
|
4
|
+
* the note of where it came from, or remove it (`next` null).
|
|
5
|
+
* @param {string} folder
|
|
6
|
+
* @param {RepoFile[]} current
|
|
7
|
+
* @param {{ files: { path: string, content: string | Uint8Array }[], record: { files: { path: string, oid: string }[] } } | null} next
|
|
8
|
+
* @returns {Change[]} */
|
|
9
|
+
export function folderChanges(folder: string, current: RepoFile[], next: {
|
|
10
|
+
files: {
|
|
11
|
+
path: string;
|
|
12
|
+
content: string | Uint8Array;
|
|
13
|
+
}[];
|
|
14
|
+
record: {
|
|
15
|
+
files: {
|
|
16
|
+
path: string;
|
|
17
|
+
oid: string;
|
|
18
|
+
}[];
|
|
19
|
+
};
|
|
20
|
+
} | null): Change[];
|
|
21
|
+
export type RepoFile = {
|
|
22
|
+
path: string;
|
|
23
|
+
oid: string;
|
|
24
|
+
};
|
|
25
|
+
export type Change = {
|
|
26
|
+
path: string;
|
|
27
|
+
content: string | Uint8Array | null;
|
|
28
|
+
oid: string | null;
|
|
29
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// The changes that make a folder in a site the store's version of an app or a theme, with its store.json note, or remove
|
|
2
|
+
// it. Exposes folderChanges (used by src/store.ts and src/legacyMove.ts). Depends on ./storeEntries.mjs (RECORD).
|
|
3
|
+
import { RECORD } from './storeEntries.mjs';
|
|
4
|
+
|
|
5
|
+
/** @typedef {{ path: string, oid: string }} RepoFile
|
|
6
|
+
* @typedef {{ path: string, content: string | Uint8Array | null, oid: string | null }} Change */
|
|
7
|
+
|
|
8
|
+
/** The changes that make a folder in the site (`current`, its files now) the store's version of an app or a theme, with
|
|
9
|
+
* the note of where it came from, or remove it (`next` null).
|
|
10
|
+
* @param {string} folder
|
|
11
|
+
* @param {RepoFile[]} current
|
|
12
|
+
* @param {{ files: { path: string, content: string | Uint8Array }[], record: { files: { path: string, oid: string }[] } } | null} next
|
|
13
|
+
* @returns {Change[]} */
|
|
14
|
+
export function folderChanges(folder, current, next) {
|
|
15
|
+
const now = new Map(current.map((f) => [f.path, f.oid]));
|
|
16
|
+
/** @type {{ path: string, content: string | Uint8Array, oid?: string }[]} */
|
|
17
|
+
const wanted = next
|
|
18
|
+
? [
|
|
19
|
+
...next.files.map((f) => ({
|
|
20
|
+
path: `${folder}/${f.path}`,
|
|
21
|
+
content: f.content,
|
|
22
|
+
oid: next.record.files.find((r) => r.path === f.path)?.oid,
|
|
23
|
+
})),
|
|
24
|
+
{ path: `${folder}/${RECORD}`, content: `${JSON.stringify(next.record, null, 2)}\n` },
|
|
25
|
+
]
|
|
26
|
+
: [];
|
|
27
|
+
return [
|
|
28
|
+
// A file the site has as it is in the store is left as it is.
|
|
29
|
+
...wanted
|
|
30
|
+
.filter((w) => !w.oid || now.get(w.path) !== w.oid)
|
|
31
|
+
.map((w) => ({ path: w.path, content: w.content, oid: now.get(w.path) ?? null })),
|
|
32
|
+
...current.filter((f) => !wanted.some((w) => w.path === f.path)).map((f) => ({ path: f.path, content: null, oid: f.oid })),
|
|
33
|
+
];
|
|
34
|
+
}
|
package/lib/section-ranges.d.mts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** The kind of a part, from its ProseMirror node name and heading level; the label is the eyebrow line before a heading. */
|
|
2
2
|
export function partKind(typeName: any, level: any): "h1" | "h2" | "other" | "label";
|
|
3
3
|
/**
|
|
4
|
-
* The sections of a text: each H2 (with a label directly before it) and every part after it up to the next
|
|
4
|
+
* The sections of a text: each H1 or H2 (with a label directly before it) and every part after it up to the next H1 or H2
|
|
5
5
|
* or the end. `parts` is the top-level parts in order; the result has the index of each section's first and last part.
|
|
6
6
|
*/
|
|
7
7
|
export function sectionRanges(parts: any): any[];
|
package/lib/section-ranges.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Which top-level parts of a page's or blog post's text form H2
|
|
1
|
+
// Which top-level parts of a page's or blog post's text form sections (an H1 or an H2 starts one), for the editor's dotted frame.
|
|
2
2
|
// Exposes sectionRanges, sectionPart and partKind. Pure, no dependencies (used by src/sectionFrame.ts).
|
|
3
3
|
|
|
4
4
|
/** The kind of a part, from its ProseMirror node name and heading level; the label is the eyebrow line before a heading. */
|
|
@@ -7,8 +7,10 @@ export function partKind(typeName, level) {
|
|
|
7
7
|
return typeName === 'eyebrow' ? 'label' : 'other';
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
const startsSection = (kind) => kind === 'h1' || kind === 'h2';
|
|
11
|
+
|
|
10
12
|
/**
|
|
11
|
-
* The sections of a text: each H2 (with a label directly before it) and every part after it up to the next
|
|
13
|
+
* The sections of a text: each H1 or H2 (with a label directly before it) and every part after it up to the next H1 or H2
|
|
12
14
|
* or the end. `parts` is the top-level parts in order; the result has the index of each section's first and last part.
|
|
13
15
|
*/
|
|
14
16
|
export function sectionRanges(parts) {
|
|
@@ -19,18 +21,10 @@ export function sectionRanges(parts) {
|
|
|
19
21
|
from = -1;
|
|
20
22
|
};
|
|
21
23
|
parts.forEach((part, i) => {
|
|
22
|
-
const
|
|
23
|
-
if (
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
from = i;
|
|
27
|
-
} else if (next === 'h1') close(i - 1);
|
|
28
|
-
} else if (part.kind === 'h2') {
|
|
29
|
-
if (parts[i - 1]?.kind !== 'label') {
|
|
30
|
-
close(i - 1);
|
|
31
|
-
from = i;
|
|
32
|
-
}
|
|
33
|
-
} else if (part.kind === 'h1') close(i - 1);
|
|
24
|
+
const starts = part.kind === 'label' ? startsSection(parts[i + 1]?.kind) : startsSection(part.kind) && parts[i - 1]?.kind !== 'label';
|
|
25
|
+
if (!starts) return;
|
|
26
|
+
close(i - 1);
|
|
27
|
+
from = i;
|
|
34
28
|
});
|
|
35
29
|
close(parts.length - 1);
|
|
36
30
|
return ranges;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Tests lib/section-ranges.mjs: which top-level parts of a text form H2
|
|
1
|
+
// Tests lib/section-ranges.mjs: which top-level parts of a text form sections, H1 or H2 (the editor's dotted frame).
|
|
2
2
|
// Run with `node --test packages/editor/lib/*.test.mjs` (part of npm run check).
|
|
3
3
|
import { test } from 'node:test';
|
|
4
4
|
import assert from 'node:assert/strict';
|
|
@@ -15,13 +15,26 @@ const ranges = (text) =>
|
|
|
15
15
|
);
|
|
16
16
|
|
|
17
17
|
test('no parts, no sections', () => assert.deepEqual(ranges(''), []));
|
|
18
|
-
test('no
|
|
18
|
+
test('no heading, no sections', () => assert.deepEqual(ranges('o o'), []));
|
|
19
|
+
test('an H1 starts a section', () => assert.deepEqual(ranges('1 o o'), [{ from: 0, to: 2 }]));
|
|
20
|
+
test('an H1 then an H2 are a section each', () =>
|
|
21
|
+
assert.deepEqual(ranges('1 o 2 o'), [
|
|
22
|
+
{ from: 0, to: 1 },
|
|
23
|
+
{ from: 2, to: 3 },
|
|
24
|
+
]));
|
|
25
|
+
test('a label before an H1 starts the section', () => assert.deepEqual(ranges('l 1 o'), [{ from: 0, to: 2 }]));
|
|
26
|
+
test('consecutive H1s are a section each', () =>
|
|
27
|
+
assert.deepEqual(ranges('o 1 1'), [
|
|
28
|
+
{ from: 1, to: 1 },
|
|
29
|
+
{ from: 2, to: 2 },
|
|
30
|
+
]));
|
|
19
31
|
test('an H2 at the start runs to the end', () => assert.deepEqual(ranges('2 o o'), [{ from: 0, to: 2 }]));
|
|
20
32
|
test('text before the first H2 is outside', () => assert.deepEqual(ranges('o 2 o'), [{ from: 1, to: 2 }]));
|
|
21
33
|
test('an H2 as the last part is a section of one', () => assert.deepEqual(ranges('o o 2'), [{ from: 2, to: 2 }]));
|
|
22
|
-
test('an H1 ends
|
|
34
|
+
test('an H1 ends the section above it and starts its own', () =>
|
|
23
35
|
assert.deepEqual(ranges('2 o 1 o 2 o'), [
|
|
24
36
|
{ from: 0, to: 1 },
|
|
37
|
+
{ from: 2, to: 3 },
|
|
25
38
|
{ from: 4, to: 5 },
|
|
26
39
|
]));
|
|
27
40
|
test('consecutive H2s are a section each', () =>
|
|
@@ -36,8 +49,16 @@ test('a label before an H2 ends the section above it', () =>
|
|
|
36
49
|
{ from: 0, to: 1 },
|
|
37
50
|
{ from: 2, to: 4 },
|
|
38
51
|
]));
|
|
39
|
-
test('a label before an H1 belongs to the H1', () =>
|
|
40
|
-
|
|
52
|
+
test('a label before an H1 belongs to the H1', () =>
|
|
53
|
+
assert.deepEqual(ranges('2 o l 1 o'), [
|
|
54
|
+
{ from: 0, to: 1 },
|
|
55
|
+
{ from: 2, to: 4 },
|
|
56
|
+
]));
|
|
57
|
+
test('a label at the start before an H1 starts its section', () =>
|
|
58
|
+
assert.deepEqual(ranges('l 1 2 o'), [
|
|
59
|
+
{ from: 0, to: 1 },
|
|
60
|
+
{ from: 2, to: 3 },
|
|
61
|
+
]));
|
|
41
62
|
test('a label not before a heading is an ordinary part', () => {
|
|
42
63
|
assert.deepEqual(ranges('2 l o'), [{ from: 0, to: 2 }]);
|
|
43
64
|
assert.deepEqual(ranges('l o 2'), [{ from: 2, to: 2 }]);
|
package/lib/storeEntries.d.mts
CHANGED
|
@@ -13,6 +13,8 @@ export function validTheme(x: any, api: number): boolean;
|
|
|
13
13
|
export function isText(v: unknown): v is string;
|
|
14
14
|
export function isPath(v: unknown): boolean;
|
|
15
15
|
export const ITEM_ID: RegExp;
|
|
16
|
-
export const
|
|
16
|
+
export const HANDLE: RegExp;
|
|
17
|
+
export const LEGACY: RegExp;
|
|
17
18
|
export const STANDARD: "saturn";
|
|
18
19
|
export const RECORD: "store.json";
|
|
20
|
+
export function strayStoreId(name: string, kind: "app" | "theme"): boolean;
|
package/lib/storeEntries.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Pure validation logic for store apps and themes.
|
|
3
|
-
* Exposes isText, isPath, ITEM_ID,
|
|
3
|
+
* Exposes isText, isPath, ITEM_ID, HANDLE, LEGACY, STANDARD, RECORD, validApp, validTheme, strayStoreId.
|
|
4
4
|
* No external dependencies.
|
|
5
5
|
*/
|
|
6
6
|
|
|
@@ -16,14 +16,28 @@ export const isText = (v) => typeof v === 'string' && v.trim() !== '';
|
|
|
16
16
|
*/
|
|
17
17
|
export const isPath = (v) => isText(v) && v.split('/').every((part) => part !== '' && part !== '.' && part !== '..');
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
// A permanent id: the kind, then 16 lowercase hex characters
|
|
20
|
+
export const ITEM_ID = /^(app|theme)_[0-9a-f]{16}$/;
|
|
21
|
+
// An item's address in the store: publisher/name
|
|
22
|
+
export const HANDLE = /^[a-z0-9][a-z0-9-]{0,39}\/[a-z0-9][a-z0-9-]{0,39}$/;
|
|
23
|
+
// An item's folder name before it had a permanent id
|
|
24
|
+
export const LEGACY = /^[a-z0-9][a-z0-9-]*$/;
|
|
22
25
|
// The standard theme, which every site has
|
|
23
26
|
export const STANDARD = 'saturn';
|
|
24
27
|
// The note in an installed folder of where it came from
|
|
25
28
|
export const RECORD = 'store.json';
|
|
26
29
|
|
|
30
|
+
const hasId = (x, kind) => ITEM_ID.test(x?.id ?? '') && x.id.startsWith(`${kind}_`);
|
|
31
|
+
const hasHandle = (x) => HANDLE.test(x?.handle ?? '') && (x.legacy === undefined || LEGACY.test(x.legacy));
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* True when a site's own folder name looks like a store id of this kind (it starts with `app_` or `theme_`) but is not one.
|
|
35
|
+
* @param {string} name
|
|
36
|
+
* @param {'app' | 'theme'} kind
|
|
37
|
+
* @returns {boolean}
|
|
38
|
+
*/
|
|
39
|
+
export const strayStoreId = (name, kind) => /^(app|theme)_/.test(name) && !(ITEM_ID.test(name) && name.startsWith(`${kind}_`));
|
|
40
|
+
|
|
27
41
|
const isAdd = (v) => isText(v?.label) && isText(v?.icon);
|
|
28
42
|
const isGoodFile = (f) => isText(f?.oid) && isPath(f?.path) && f.path !== RECORD;
|
|
29
43
|
|
|
@@ -34,8 +48,8 @@ const isGoodFile = (f) => isText(f?.oid) && isPath(f?.path) && f.path !== RECORD
|
|
|
34
48
|
*/
|
|
35
49
|
export function validApp(x, api) {
|
|
36
50
|
return (
|
|
37
|
-
|
|
38
|
-
|
|
51
|
+
hasId(x, 'app') &&
|
|
52
|
+
hasHandle(x) &&
|
|
39
53
|
isText(x.version) &&
|
|
40
54
|
x.api === api &&
|
|
41
55
|
[x.label, x.publisher, x.icon, x.hint].every(isText) &&
|
|
@@ -53,8 +67,8 @@ export function validApp(x, api) {
|
|
|
53
67
|
*/
|
|
54
68
|
export function validTheme(x, api) {
|
|
55
69
|
return (
|
|
56
|
-
|
|
57
|
-
x
|
|
70
|
+
hasId(x, 'theme') &&
|
|
71
|
+
hasHandle(x) &&
|
|
58
72
|
x.api === api &&
|
|
59
73
|
[x.version, x.name, x.author, x.icon].every(isText) &&
|
|
60
74
|
Array.isArray(x.files) &&
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Tests for store entry validation logic (validApp, validTheme, isPath).
|
|
2
|
+
* Tests for store entry validation logic (validApp, validTheme, isPath, strayStoreId).
|
|
3
3
|
* Run with `node --test packages/editor/lib/*.test.mjs` (part of npm run check).
|
|
4
4
|
*/
|
|
5
5
|
import { test } from 'node:test';
|
|
6
6
|
import assert from 'node:assert/strict';
|
|
7
|
-
import { validApp, validTheme, isPath } from './storeEntries.mjs';
|
|
7
|
+
import { validApp, validTheme, isPath, strayStoreId } from './storeEntries.mjs';
|
|
8
8
|
|
|
9
9
|
const theme = () => ({
|
|
10
|
-
id: '
|
|
10
|
+
id: 'theme_fa395ad84690acbd',
|
|
11
|
+
handle: 'morpheme/venus',
|
|
12
|
+
legacy: 'venus',
|
|
11
13
|
version: '1.0.0',
|
|
12
14
|
api: 4,
|
|
13
15
|
name: 'Venus',
|
|
@@ -16,7 +18,8 @@ const theme = () => ({
|
|
|
16
18
|
files: [{ path: 'theme.json', oid: 'a'.repeat(40) }],
|
|
17
19
|
});
|
|
18
20
|
const app = () => ({
|
|
19
|
-
id: '
|
|
21
|
+
id: 'app_a030dfba69793996',
|
|
22
|
+
handle: 'morpheme/calendly',
|
|
20
23
|
version: '1.0.0',
|
|
21
24
|
api: 2,
|
|
22
25
|
label: 'Calendly',
|
|
@@ -35,8 +38,30 @@ test('valid theme wrong api', () => {
|
|
|
35
38
|
assert.equal(validTheme({ ...theme(), api: 3 }, 4), false);
|
|
36
39
|
});
|
|
37
40
|
|
|
38
|
-
test('valid theme
|
|
39
|
-
assert.equal(validTheme({ ...theme(), id: '
|
|
41
|
+
test('valid theme old-style id', () => {
|
|
42
|
+
assert.equal(validTheme({ ...theme(), id: 'venus' }, 4), false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('valid theme app id (wrong kind)', () => {
|
|
46
|
+
assert.equal(validTheme({ ...theme(), id: 'app_a030dfba69793996' }, 4), false);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('valid theme without handle', () => {
|
|
50
|
+
const { handle, ...rest } = theme();
|
|
51
|
+
assert.equal(validTheme(rest, 4), false);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('valid theme bad handle', () => {
|
|
55
|
+
assert.equal(validTheme({ ...theme(), handle: 'venus' }, 4), false);
|
|
56
|
+
assert.equal(validTheme({ ...theme(), handle: 'Morpheme/venus' }, 4), false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('valid theme keeps legacy, and drops a bad one', () => {
|
|
60
|
+
assert.equal(validTheme(theme(), 4), true);
|
|
61
|
+
assert.equal(theme().legacy, 'venus');
|
|
62
|
+
assert.equal(validTheme({ ...theme(), legacy: 'Venus!' }, 4), false);
|
|
63
|
+
const { legacy, ...rest } = theme();
|
|
64
|
+
assert.equal(validTheme(rest, 4), true);
|
|
40
65
|
});
|
|
41
66
|
|
|
42
67
|
test('valid theme bad path', () => {
|
|
@@ -55,10 +80,25 @@ test('valid app', () => {
|
|
|
55
80
|
assert.equal(validApp(app(), 2), true);
|
|
56
81
|
});
|
|
57
82
|
|
|
58
|
-
test('valid app
|
|
83
|
+
test('valid app old-style id', () => {
|
|
84
|
+
assert.equal(validApp({ ...app(), id: 'calendly' }, 2), false);
|
|
59
85
|
assert.equal(validApp({ ...app(), id: 'butik' }, 2), false);
|
|
60
86
|
});
|
|
61
87
|
|
|
88
|
+
test('valid app theme id (wrong kind)', () => {
|
|
89
|
+
assert.equal(validApp({ ...app(), id: 'theme_fa395ad84690acbd' }, 2), false);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('valid app without handle', () => {
|
|
93
|
+
const { handle, ...rest } = app();
|
|
94
|
+
assert.equal(validApp(rest, 2), false);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('valid app bad id length', () => {
|
|
98
|
+
assert.equal(validApp({ ...app(), id: 'app_a030dfba6979399' }, 2), false);
|
|
99
|
+
assert.equal(validApp({ ...app(), id: 'app_A030DFBA69793996' }, 2), false);
|
|
100
|
+
});
|
|
101
|
+
|
|
62
102
|
test('valid app missing adds kinds', () => {
|
|
63
103
|
assert.equal(validApp({ ...app(), adds: { blocks: [], lists: [] } }, 2), false);
|
|
64
104
|
});
|
|
@@ -69,3 +109,12 @@ test('isPath cases', () => {
|
|
|
69
109
|
assert.equal(isPath('./a'), false);
|
|
70
110
|
assert.equal(isPath(''), false);
|
|
71
111
|
});
|
|
112
|
+
|
|
113
|
+
test('strayStoreId: a store-looking name that is not a store id of the kind', () => {
|
|
114
|
+
assert.equal(strayStoreId('app_a030dfba69793996', 'app'), false);
|
|
115
|
+
assert.equal(strayStoreId('app_xyz', 'app'), true);
|
|
116
|
+
assert.equal(strayStoreId('theme_fa395ad84690acbd', 'app'), true);
|
|
117
|
+
assert.equal(strayStoreId('theme_xyz', 'theme'), true);
|
|
118
|
+
assert.equal(strayStoreId('calendly', 'app'), false);
|
|
119
|
+
assert.equal(strayStoreId('my_theme', 'theme'), false);
|
|
120
|
+
});
|
package/lib/text-sections.d.mts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** The text's HTML as runs: { color, html } with the marker removed; color is null for text outside a coloured section.
|
|
2
|
-
* A marker with an unknown colour, or not followed by an H2, is dropped. Without any coloured section, the HTML is
|
|
2
|
+
* A marker with an unknown colour, or not followed by an H1 or H2, is dropped. Without any coloured section, the HTML is
|
|
3
3
|
* returned as it came, in one run.
|
|
4
4
|
* @param {string} html
|
|
5
5
|
* @returns {{ color: string | null, html: string }[]} */
|
|
@@ -7,4 +7,14 @@ export function sectionsOf(html: string): {
|
|
|
7
7
|
color: string | null;
|
|
8
8
|
html: string;
|
|
9
9
|
}[];
|
|
10
|
+
/** The text before its first section starts (a marker, a label or an H1 or H2), and the rest from there. Without a heading
|
|
11
|
+
* the whole text is the intro. For a page whose coloured H1 panel holds the header and this intro (PageLayout.astro).
|
|
12
|
+
* @param {string} html
|
|
13
|
+
* @returns {{ intro: string, rest: string }} */
|
|
14
|
+
export function introOf(html: string): {
|
|
15
|
+
intro: string;
|
|
16
|
+
rest: string;
|
|
17
|
+
};
|
|
18
|
+
export const MARKER: string;
|
|
19
|
+
export const LABEL: string;
|
|
10
20
|
export function sectionClass(color: any): string;
|
package/lib/text-sections.mjs
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
// A page's or post's rendered text cut into runs for the site: a section with a colour (the marker before its H2,
|
|
2
|
-
// lib/section-marker.mjs) is one run from the marker to the next
|
|
3
|
-
// used by theme/components/RichText.astro
|
|
1
|
+
// A page's or post's rendered text cut into runs for the site: a section with a colour (the marker before its H1 or H2,
|
|
2
|
+
// lib/section-marker.mjs) is one run from the marker to the next H1, H2 or the end. Exposes sectionsOf, introOf,
|
|
3
|
+
// sectionClass and the marker and label patterns (MARKER, LABEL), used by theme/components/RichText.astro, lib/without-h1.mjs
|
|
4
|
+
// and src/pagePreview.tsx. Depends on section-marker.mjs.
|
|
4
5
|
import { SECTION_COLORS } from './section-marker.mjs';
|
|
5
6
|
|
|
6
7
|
// The marker as the site's HTML pipeline writes it (rehype-raw keeps it as written; a space before `>` is tolerated).
|
|
7
|
-
const MARKER = String.raw`<div data-section="([^"]*)"\s*><\/div>\s*`;
|
|
8
|
+
export const MARKER = String.raw`<div data-section="([^"]*)"\s*><\/div>\s*`;
|
|
8
9
|
// A label line (text-menu's <p class="block-eyebrow">) directly before a heading starts the heading's section.
|
|
9
|
-
const LABEL = String.raw`(?:<p class="block-eyebrow">(?:(?!<\/p>)[\s\S])*<\/p>\s*)?`;
|
|
10
|
-
// A marker followed by an H2 (with its label between): where a coloured section starts. Ends just after `<h2`.
|
|
11
|
-
const START = new RegExp(`${MARKER}${LABEL}<
|
|
10
|
+
export const LABEL = String.raw`(?:<p class="block-eyebrow">(?:(?!<\/p>)[\s\S])*<\/p>\s*)?`;
|
|
11
|
+
// A marker followed by an H1 or H2 (with its label between): where a coloured section starts. Ends just after `<h1` or `<h2`.
|
|
12
|
+
const START = new RegExp(`${MARKER}${LABEL}<h[12]\\b`, 'g');
|
|
12
13
|
// Where the next section (or an H1) starts: its marker and label if it has them, else its heading.
|
|
13
14
|
const NEXT = new RegExp(`(?:${MARKER})?${LABEL}<h[12]\\b`);
|
|
14
15
|
const MARKERS = new RegExp(MARKER, 'g');
|
|
@@ -16,7 +17,7 @@ const MARKERS = new RegExp(MARKER, 'g');
|
|
|
16
17
|
const withoutMarkers = (html) => html.replace(MARKERS, '');
|
|
17
18
|
|
|
18
19
|
/** The text's HTML as runs: { color, html } with the marker removed; color is null for text outside a coloured section.
|
|
19
|
-
* A marker with an unknown colour, or not followed by an H2, is dropped. Without any coloured section, the HTML is
|
|
20
|
+
* A marker with an unknown colour, or not followed by an H1 or H2, is dropped. Without any coloured section, the HTML is
|
|
20
21
|
* returned as it came, in one run.
|
|
21
22
|
* @param {string} html
|
|
22
23
|
* @returns {{ color: string | null, html: string }[]} */
|
|
@@ -41,5 +42,14 @@ export function sectionsOf(html) {
|
|
|
41
42
|
return runs;
|
|
42
43
|
}
|
|
43
44
|
|
|
45
|
+
/** The text before its first section starts (a marker, a label or an H1 or H2), and the rest from there. Without a heading
|
|
46
|
+
* the whole text is the intro. For a page whose coloured H1 panel holds the header and this intro (PageLayout.astro).
|
|
47
|
+
* @param {string} html
|
|
48
|
+
* @returns {{ intro: string, rest: string }} */
|
|
49
|
+
export function introOf(html) {
|
|
50
|
+
const at = html.search(NEXT);
|
|
51
|
+
return at < 0 ? { intro: html, rest: '' } : { intro: html.slice(0, at), rest: html.slice(at) };
|
|
52
|
+
}
|
|
53
|
+
|
|
44
54
|
/** The classes of a coloured section on the site (blocks.css). */
|
|
45
55
|
export const sectionClass = (color) => `text-section text-section-${color}`;
|
|
@@ -4,7 +4,7 @@ import { test } from 'node:test';
|
|
|
4
4
|
import assert from 'node:assert/strict';
|
|
5
5
|
import { createMarkdownProcessor } from '@astrojs/markdown-remark';
|
|
6
6
|
import rehypeRaw from 'rehype-raw';
|
|
7
|
-
import { sectionClass, sectionsOf } from './text-sections.mjs';
|
|
7
|
+
import { introOf, sectionClass, sectionsOf } from './text-sections.mjs';
|
|
8
8
|
|
|
9
9
|
const M = (color) => `<div data-section="${color}"></div>`;
|
|
10
10
|
|
|
@@ -36,6 +36,23 @@ test('a run ends at an h1', () => {
|
|
|
36
36
|
]);
|
|
37
37
|
});
|
|
38
38
|
|
|
39
|
+
test('a marker before an h1 starts a coloured run that ends at the next h2', () => {
|
|
40
|
+
const html = `${M('tint')}<h1>A</h1><p>x</p><h2>B</h2>`;
|
|
41
|
+
assert.deepEqual(sectionsOf(html), [
|
|
42
|
+
{ color: 'tint', html: '<h1>A</h1><p>x</p>' },
|
|
43
|
+
{ color: null, html: '<h2>B</h2>' },
|
|
44
|
+
]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('a marker and a label before an h1 start a coloured run', () => {
|
|
48
|
+
const html = `<p>Intro</p>${M('accent')}<p class="block-eyebrow">Hi</p><h1>A</h1><p>x</p><h1>B</h1>`;
|
|
49
|
+
assert.deepEqual(sectionsOf(html), [
|
|
50
|
+
{ color: null, html: '<p>Intro</p>' },
|
|
51
|
+
{ color: 'accent', html: '<p class="block-eyebrow">Hi</p><h1>A</h1><p>x</p>' },
|
|
52
|
+
{ color: null, html: '<h1>B</h1>' },
|
|
53
|
+
]);
|
|
54
|
+
});
|
|
55
|
+
|
|
39
56
|
test('two coloured sections after each other are two runs', () => {
|
|
40
57
|
const html = `${M('tint')}\n<h2>A</h2>\n<p>x</p>\n${M('accent')}\n<h2>B</h2>\n<p>y</p>`;
|
|
41
58
|
assert.deepEqual(sectionsOf(html), [
|
|
@@ -61,6 +78,22 @@ test('a marker not followed by an h2 is dropped', () => {
|
|
|
61
78
|
assert.deepEqual(sectionsOf(`<p>a</p>\n${M('tint')}\n<h3>c</h3>`), [{ color: null, html: '<p>a</p>\n<h3>c</h3>' }]);
|
|
62
79
|
});
|
|
63
80
|
|
|
81
|
+
test('the intro is the text before the first section starts', () => {
|
|
82
|
+
assert.deepEqual(introOf(`<p>a</p>\n<p>b</p>\n${M('tint')}\n<h2>c</h2>\n<p>d</p>`), {
|
|
83
|
+
intro: '<p>a</p>\n<p>b</p>\n',
|
|
84
|
+
rest: `${M('tint')}\n<h2>c</h2>\n<p>d</p>`,
|
|
85
|
+
});
|
|
86
|
+
assert.deepEqual(introOf('<p>a</p><p class="block-eyebrow">L</p><h1>c</h1>'), {
|
|
87
|
+
intro: '<p>a</p>',
|
|
88
|
+
rest: '<p class="block-eyebrow">L</p><h1>c</h1>',
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('without a heading the text is all intro, and a text that starts with one has none', () => {
|
|
93
|
+
assert.deepEqual(introOf('<p>a</p>'), { intro: '<p>a</p>', rest: '' });
|
|
94
|
+
assert.deepEqual(introOf('<h2>a</h2><p>b</p>'), { intro: '', rest: '<h2>a</h2><p>b</p>' });
|
|
95
|
+
});
|
|
96
|
+
|
|
64
97
|
test('the class of a coloured section', () => {
|
|
65
98
|
assert.equal(sectionClass('tint'), 'text-section text-section-tint');
|
|
66
99
|
});
|
package/lib/video-id.d.mts
CHANGED
|
@@ -7,8 +7,12 @@ export function videoOf(text: any): {
|
|
|
7
7
|
id: string;
|
|
8
8
|
hash: string | null;
|
|
9
9
|
} | null;
|
|
10
|
-
/** The address that plays the video, with the settings in the address of the iframe it came from.
|
|
11
|
-
|
|
10
|
+
/** The address that plays the video, with the settings in the address of the iframe it came from.
|
|
11
|
+
* @param {any} video
|
|
12
|
+
* @param {string | null} [frame]
|
|
13
|
+
* @returns {string}
|
|
14
|
+
*/
|
|
15
|
+
export function player(video: any, frame?: string | null): string;
|
|
12
16
|
export function videoPage(video: any): string;
|
|
13
17
|
export function stillName(video: any): string;
|
|
14
18
|
export function vimeoInfo(video: any, width: any): string;
|