@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.
Files changed (89) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/astro.mjs +2 -1
  3. package/lib/appId.d.mts +1 -0
  4. package/lib/appId.mjs +6 -0
  5. package/lib/apps.mjs +13 -3
  6. package/lib/apps.test.mjs +10 -1
  7. package/lib/contracts.d.mts +3 -2
  8. package/lib/contracts.mjs +3 -2
  9. package/lib/folderChanges.d.mts +29 -0
  10. package/lib/folderChanges.mjs +34 -0
  11. package/lib/section-ranges.d.mts +1 -1
  12. package/lib/section-ranges.mjs +8 -14
  13. package/lib/section-ranges.test.mjs +26 -5
  14. package/lib/storeEntries.d.mts +3 -1
  15. package/lib/storeEntries.mjs +22 -8
  16. package/lib/storeEntries.test.mjs +56 -7
  17. package/lib/text-sections.d.mts +11 -1
  18. package/lib/text-sections.mjs +18 -8
  19. package/lib/text-sections.test.mjs +34 -1
  20. package/lib/video-id.d.mts +6 -2
  21. package/lib/video-id.mjs +5 -1
  22. package/lib/video.d.mts +6 -2
  23. package/lib/video.mjs +5 -1
  24. package/lib/without-h1.d.mts +1 -0
  25. package/lib/without-h1.mjs +19 -4
  26. package/lib/without-h1.test.mjs +30 -1
  27. package/package.json +3 -2
  28. package/scripts/app-screenshots.mjs +2 -1
  29. package/scripts/check-content.mjs +2 -1
  30. package/scripts/check-site-types.mjs +26 -0
  31. package/scripts/site-types.mjs +14 -0
  32. package/scripts/site-types.test.mjs +21 -0
  33. package/site/activeTheme.test.mjs +52 -0
  34. package/site/appSelf.d.mts +2 -0
  35. package/site/appSelf.mjs +23 -0
  36. package/site/appSelf.test.mjs +35 -0
  37. package/site/content.ts +5 -2
  38. package/site/siteModules.mjs +2 -1
  39. package/site/theme.mjs +19 -8
  40. package/src/App.tsx +1 -1
  41. package/src/AppList.tsx +24 -9
  42. package/src/BlockDialog.tsx +38 -17
  43. package/src/CreateFab.tsx +8 -0
  44. package/src/DataForm.tsx +7 -17
  45. package/src/EntryForm.tsx +1 -1
  46. package/src/FormFrame.tsx +4 -3
  47. package/src/Library.tsx +4 -11
  48. package/src/ListFab.tsx +13 -0
  49. package/src/MarkdownEditor.tsx +88 -47
  50. package/src/Media.tsx +4 -13
  51. package/src/Redirects.tsx +6 -14
  52. package/src/Screen.tsx +13 -3
  53. package/src/StartFab.tsx +5 -7
  54. package/src/ThemeStore.tsx +33 -12
  55. package/src/apps.ts +2 -3
  56. package/src/drawerYield.d.mts +3 -0
  57. package/src/drawerYield.mjs +10 -0
  58. package/src/drawerYield.test.mjs +22 -0
  59. package/src/editor.css +55 -22
  60. package/src/fabScreens.test.mjs +28 -0
  61. package/src/fabScreens.ts +16 -0
  62. package/src/legacyMove.test.mjs +137 -0
  63. package/src/legacyMove.ts +131 -0
  64. package/src/markdown.mjs +10 -7
  65. package/src/markdown.test.mjs +16 -2
  66. package/src/menuOpen.ts +20 -4
  67. package/src/movedRoutes.mjs +2 -2
  68. package/src/movedRoutes.test.mjs +5 -0
  69. package/src/pageH1Panel.d.mts +4 -0
  70. package/src/pageH1Panel.mjs +31 -0
  71. package/src/pageH1Panel.test.mjs +56 -0
  72. package/src/pagePreview.tsx +22 -12
  73. package/src/sectionFrame.ts +3 -3
  74. package/src/sectionState.d.mts +3 -3
  75. package/src/sectionState.mjs +6 -6
  76. package/src/sectionState.test.mjs +14 -2
  77. package/src/sheetPlacement.d.mts +6 -4
  78. package/src/sheetPlacement.mjs +15 -10
  79. package/src/sheetPlacement.test.mjs +16 -9
  80. package/src/sidePanel.ts +38 -0
  81. package/src/store.ts +32 -27
  82. package/src/topBarSlot.tsx +7 -15
  83. package/src/ui.tsx +26 -15
  84. package/src/virtual.d.ts +6 -0
  85. package/starter/package.json +1 -1
  86. package/theme/components/PageHeading.astro +22 -0
  87. package/theme/layouts/PageLayout.astro +37 -20
  88. package/theme/styles/global.css +2 -1
  89. 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
  });
@@ -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/). @param {string} root */
23
- export const appIds = (root) =>
24
- existsSync(join(root, APPS))
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
  });
@@ -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
- export const APP_API: 2;
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
- export const APP_API = 2;
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
+ }
@@ -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 H2, the next H1
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[];
@@ -1,4 +1,4 @@
1
- // Which top-level parts of a page's or blog post's text form H2 sections, for the editor's dotted frame.
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 H2, the next H1
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 next = parts[i + 1]?.kind;
23
- if (part.kind === 'label') {
24
- if (next === 'h2') {
25
- close(i - 1);
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 sections (the editor's dotted frame).
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 H2, no sections', () => assert.deepEqual(ranges('1 o o'), []));
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 a section', () =>
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', () => assert.deepEqual(ranges('2 o l 1 o'), [{ from: 0, to: 1 }]));
40
- test('a label at the start before an H1 starts nothing', () => assert.deepEqual(ranges('l 1 2 o'), [{ from: 2, to: 3 }]));
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 }]);
@@ -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 REFUSED_APPS: string[];
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;
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Pure validation logic for store apps and themes.
3
- * Exposes isText, isPath, ITEM_ID, REFUSED_APPS, STANDARD, RECORD, validApp, validTheme.
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
- export const ITEM_ID = /^[a-z0-9][a-z0-9-]*$/;
20
- // The store's address before and a developer's page (STORE_BEFORE and DEVELOPER_ROUTE in src/store.ts)
21
- export const REFUSED_APPS = ['butik', 'udvikler'];
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
- ITEM_ID.test(x?.id ?? '') &&
38
- !REFUSED_APPS.includes(x.id) &&
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
- ITEM_ID.test(x?.id ?? '') &&
57
- x.id !== STANDARD &&
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: 'venus',
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: 'calendly',
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 standard id', () => {
39
- assert.equal(validTheme({ ...theme(), id: 'saturn' }, 4), false);
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 refused id', () => {
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
+ });
@@ -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;
@@ -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 H2, H1 or the end. Exposes sectionsOf and sectionClass,
3
- // used by theme/components/RichText.astro and src/pagePreview.tsx. Depends on section-marker.mjs.
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}<h2\\b`, 'g');
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
  });
@@ -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
- export function player(video: any, frame: any): string;
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;