@morphemecms/cms 0.1.1-next-20261103223426 → 0.2.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 (70) hide show
  1. package/CHANGELOG.md +6 -1
  2. package/bin/morpheme.mjs +1 -1
  3. package/lib/apps.mjs +5 -2
  4. package/lib/apps.test.d.mts +1 -0
  5. package/lib/apps.test.mjs +50 -0
  6. package/lib/caseCollisions.d.mts +4 -0
  7. package/lib/caseCollisions.mjs +62 -0
  8. package/lib/caseCollisions.test.d.mts +1 -0
  9. package/lib/caseCollisions.test.mjs +74 -0
  10. package/lib/contracts.d.mts +15 -0
  11. package/lib/contracts.mjs +17 -0
  12. package/lib/licenses.d.mts +1 -1
  13. package/lib/licenses.mjs +2 -2
  14. package/lib/storeEntries.d.mts +18 -0
  15. package/lib/storeEntries.mjs +64 -0
  16. package/lib/storeEntries.test.d.mts +1 -0
  17. package/lib/storeEntries.test.mjs +71 -0
  18. package/package.json +1 -1
  19. package/pages/editor/sw.js +1 -1
  20. package/scripts/built-site.mjs +37 -2
  21. package/scripts/check-content.mjs +4 -2
  22. package/scripts/check-store.mjs +160 -0
  23. package/site/defineSite.d.mts +2 -14
  24. package/site/defineSite.mjs +2 -7
  25. package/site/storeTheme.d.mts +6 -0
  26. package/site/storeTheme.mjs +29 -0
  27. package/site/storeTheme.test.d.mts +1 -0
  28. package/site/storeTheme.test.mjs +53 -0
  29. package/site/theme.mjs +14 -3
  30. package/src/App.tsx +13 -16
  31. package/src/AppList.tsx +22 -5
  32. package/src/Consents.tsx +2 -2
  33. package/src/Deleted.tsx +1 -1
  34. package/src/EntryForm.tsx +3 -3
  35. package/src/FormFrame.tsx +3 -3
  36. package/src/Library.tsx +2 -2
  37. package/src/Licenses.tsx +1 -1
  38. package/src/Media.tsx +2 -2
  39. package/src/Notifications.tsx +2 -2
  40. package/src/Redirects.tsx +2 -2
  41. package/src/Screen.tsx +1 -1
  42. package/src/ThemeStore.tsx +1 -1
  43. package/src/apps.ts +2 -2
  44. package/src/checkSite.ts +2 -2
  45. package/src/deploy.ts +1 -1
  46. package/src/i18n/da-DK/consents.ts +1 -1
  47. package/src/i18n/en-US/apps.ts +1 -1
  48. package/src/i18n/en-US/consents.ts +1 -1
  49. package/src/i18n/en-US/library.ts +1 -1
  50. package/src/i18n/en-US/licenses.ts +1 -1
  51. package/src/i18n/en-US/redirects.ts +2 -2
  52. package/src/store.ts +74 -94
  53. package/src/themes.ts +1 -1
  54. package/starter/README.md +1 -0
  55. package/theme/styles/ui.css +4 -4
  56. package/theme/ui/BottomSheet.tsx +3 -3
  57. package/theme/ui/Dialog.tsx +1 -1
  58. package/theme/ui/SideSheet.tsx +1 -1
  59. package/theme/ui/bottomSheet.script.ts +2 -2
  60. package/worker/consent.js +1 -1
  61. package/worker/index.js +4 -0
  62. package/worker/push.js +1 -1
  63. package/worker/store.js +46 -0
  64. /package/src/{consents.ts → consentData.ts} +0 -0
  65. /package/src/{licenses.ts → licenseData.ts} +0 -0
  66. /package/src/{notifications.ts → notificationData.ts} +0 -0
  67. /package/src/{redirects.ts → redirectData.ts} +0 -0
  68. /package/theme/ui/{bottomSheet.ts → bottomSheetControl.ts} +0 -0
  69. /package/theme/ui/{dialog.ts → dialogControl.ts} +0 -0
  70. /package/theme/ui/{sideSheet.ts → sideSheetControl.ts} +0 -0
package/CHANGELOG.md CHANGED
@@ -1,6 +1,11 @@
1
1
  # @morphemecms/cms
2
2
 
3
- ## 0.1.1-next-20261103223426
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 545e832: Themes and apps are installed from the Morpheme store, through the site's Worker (`/api/store/`); set the secret `MORPHEME_STORE_KEY` to connect a site. `store` in `src/site/config.ts` is gone, and a store theme may import CSS only from `@morphemecms/cms` and its own files.
8
+ - 9dbb555: Builds on Windows: the theme's helpers `ui/dialog.ts`, `ui/bottomSheet.ts` and `ui/sideSheet.ts` are now `ui/dialogControl.ts`, `ui/bottomSheetControl.ts` and `ui/sideSheetControl.ts` (app API 2, theme API 3), the editor's screen data modules are `*Data.ts`, `worker/apps.js` with CRLF line endings passes the content check, and the check stops two files whose names differ only by case.
4
9
 
5
10
  ### Patch Changes
6
11
 
package/bin/morpheme.mjs CHANGED
@@ -12,7 +12,7 @@ const COMMANDS = {
12
12
  'after-build': ['scheduled-list', 'link-counts', 'page-colors'],
13
13
  'check:content': ['check-content'],
14
14
  'check:editor': ['editor-roundtrip'],
15
- 'check:worker': ['check-worker', 'check-consent'],
15
+ 'check:worker': ['check-worker', 'check-consent', 'check-store'],
16
16
  'check:screens': ['editor-screens'],
17
17
  'check:themes': ['check-themes'],
18
18
  'check:demo': ['check-demo'],
package/lib/apps.mjs CHANGED
@@ -61,11 +61,14 @@ export function appWorkersFile(root) {
61
61
  ].join('\n');
62
62
  }
63
63
 
64
+ /** The text with its line endings as \n, as git on Windows may check it out with \r\n. @param {string} text */
65
+ const lf = (text) => text.replace(/\r\n/g, '\n');
66
+
64
67
  /** Writes worker/apps.js when it is not as it must be; true when it wrote it. @param {string} root */
65
68
  export function writeAppWorkers(root) {
66
69
  const file = join(root, WORKERS);
67
70
  const text = appWorkersFile(root);
68
- if (existsSync(file) && readFileSync(file, 'utf8') === text) return false;
71
+ if (existsSync(file) && lf(readFileSync(file, 'utf8')) === text) return false;
69
72
  writeFileSync(file, text);
70
73
  return true;
71
74
  }
@@ -73,7 +76,7 @@ export function writeAppWorkers(root) {
73
76
  /** What is wrong with worker/apps.js (npm run check:content), as sentences. @param {string} root */
74
77
  export function appWorkersProblems(root) {
75
78
  const file = join(root, WORKERS);
76
- return existsSync(file) && readFileSync(file, 'utf8') === appWorkersFile(root)
79
+ return existsSync(file) && lf(readFileSync(file, 'utf8')) === appWorkersFile(root)
77
80
  ? []
78
81
  : [`${WORKERS} passer ikke til appenes worker.js. Kør npx morpheme app-workers, og commit filen.`];
79
82
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,50 @@
1
+ // Tests for worker/apps.js in lib/apps.mjs: appWorkersProblems and writeAppWorkers accept the file with CRLF line
2
+ // endings (git on Windows) and still find and rewrite one that is out of date.
3
+ import { describe, it, after, before } from 'node:test';
4
+ import { strict as assert } from 'node:assert/strict';
5
+ import { mkdtempSync, rmSync, writeFileSync, mkdirSync, readFileSync } from 'node:fs';
6
+ import { tmpdir } from 'node:os';
7
+ import { join } from 'node:path';
8
+ import { appWorkersFile, appWorkersProblems, writeAppWorkers } from './apps.mjs';
9
+
10
+ describe('lib/apps.mjs', () => {
11
+ let root;
12
+
13
+ before(() => {
14
+ root = mkdtempSync(join(tmpdir(), 'apps-'));
15
+ mkdirSync(join(root, 'src/apps/booking'), { recursive: true });
16
+ writeFileSync(join(root, 'src/apps/booking/worker.js'), 'export default {};\n');
17
+ mkdirSync(join(root, 'worker'), { recursive: true });
18
+ });
19
+
20
+ after(() => {
21
+ rmSync(root, { recursive: true, force: true });
22
+ });
23
+
24
+ it('appWorkersProblems accepts worker/apps.js with CRLF line endings', () => {
25
+ const file = join(root, 'worker/apps.js');
26
+ writeFileSync(file, appWorkersFile(root).replace(/\n/g, '\r\n'));
27
+ assert.deepEqual(appWorkersProblems(root), []);
28
+ });
29
+
30
+ it('writeAppWorkers leaves worker/apps.js with CRLF line endings as it is', () => {
31
+ const file = join(root, 'worker/apps.js');
32
+ const crlfText = appWorkersFile(root).replace(/\n/g, '\r\n');
33
+ writeFileSync(file, crlfText);
34
+ assert.equal(writeAppWorkers(root), false);
35
+ assert.equal(readFileSync(file, 'utf8'), crlfText);
36
+ });
37
+
38
+ it('appWorkersProblems finds worker/apps.js out of date', () => {
39
+ const file = join(root, 'worker/apps.js');
40
+ writeFileSync(file, '// old\n');
41
+ assert.equal(appWorkersProblems(root).length, 1);
42
+ });
43
+
44
+ it('writeAppWorkers rewrites worker/apps.js out of date', () => {
45
+ const file = join(root, 'worker/apps.js');
46
+ writeFileSync(file, '// old\r\n');
47
+ assert.equal(writeAppWorkers(root), true);
48
+ assert.equal(readFileSync(file, 'utf8'), appWorkersFile(root));
49
+ });
50
+ });
@@ -0,0 +1,4 @@
1
+ /** Groups of paths whose stems differ only by case, each sorted. @param {string[]} paths @returns {string[][]} */
2
+ export function caseCollisions(paths: string[]): string[][];
3
+ /** One Danish sentence per such group among the files under each dir (relative to root). @param {string} root @param {string[]} dirs */
4
+ export function caseCollisionProblems(root: string, dirs: string[]): string[];
@@ -0,0 +1,62 @@
1
+ // Files whose names differ only by case (Dialog.tsx beside dialog.ts): on a case-insensitive disk (Windows,
2
+ // macOS) an import without its extension finds the wrong one. Exposes caseCollisions (pure) and caseCollisionProblems
3
+ // (npm run check:content). Only node:fs and node:path.
4
+ import { readdirSync, existsSync } from 'node:fs';
5
+ import { join, relative } from 'node:path';
6
+
7
+ /** A path without its last extension (theme/ui/Dialog.tsx -> theme/ui/Dialog). @param {string} path */
8
+ function stemOf(path) {
9
+ const lastSlash = path.lastIndexOf('/');
10
+ const folder = lastSlash === -1 ? '' : path.slice(0, lastSlash + 1);
11
+ const name = lastSlash === -1 ? path : path.slice(lastSlash + 1);
12
+ const lastDot = name.lastIndexOf('.');
13
+ if (lastDot <= 0) return path;
14
+ return `${folder}${name.slice(0, lastDot)}`;
15
+ }
16
+
17
+ /** Groups of paths whose stems differ only by case, each sorted. @param {string[]} paths @returns {string[][]} */
18
+ export function caseCollisions(paths) {
19
+ const groups = new Map();
20
+ for (const p of paths) {
21
+ const key = stemOf(p).toLowerCase();
22
+ if (!groups.has(key)) groups.set(key, new Set());
23
+ groups.get(key).add(stemOf(p));
24
+ }
25
+ const result = [];
26
+ for (const stems of groups.values()) {
27
+ if (stems.size >= 2) {
28
+ const group = paths.filter((p) => stems.has(stemOf(p))).sort();
29
+ result.push(group);
30
+ }
31
+ }
32
+ return result.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
33
+ }
34
+
35
+ /** One Danish sentence per such group among the files under each dir (relative to root). @param {string} root @param {string[]} dirs */
36
+ export function caseCollisionProblems(root, dirs) {
37
+ const problems = [];
38
+ for (const dir of dirs) {
39
+ const absDir = join(root, dir);
40
+ if (!existsSync(absDir)) continue;
41
+
42
+ const files = [];
43
+ const walk = (current) => {
44
+ for (const e of readdirSync(current, { withFileTypes: true })) {
45
+ if (e.isDirectory()) {
46
+ if (e.name !== 'node_modules' && !e.name.startsWith('.')) walk(join(current, e.name));
47
+ } else if (e.isFile()) {
48
+ const rel = relative(root, join(current, e.name)).split('\\').join('/');
49
+ files.push(rel);
50
+ }
51
+ }
52
+ };
53
+ walk(absDir);
54
+
55
+ for (const group of caseCollisions(files)) {
56
+ problems.push(
57
+ `${group.join(', ')}: navnene er ens, når store og små bogstaver ikke tæller, så en import uden filtype finder den forkerte på Windows og macOS. Giv den ene et andet navn.`,
58
+ );
59
+ }
60
+ }
61
+ return problems;
62
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,74 @@
1
+ // Tests for lib/caseCollisions.mjs: which paths collide by case, and caseCollisionProblems on a folder,
2
+ // skipping node_modules and taking a dir with the system's separators (Windows).
3
+ import { test } from 'node:test';
4
+ import assert from 'node:assert/strict';
5
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { tmpdir } from 'node:os';
8
+ import { caseCollisions, caseCollisionProblems } from './caseCollisions.mjs';
9
+
10
+ test('caseCollisions detects Dialog collision', () => {
11
+ const input = ['theme/ui/Dialog.tsx', 'theme/ui/dialog.ts', 'theme/ui/Button.tsx'];
12
+ const expected = [['theme/ui/Dialog.tsx', 'theme/ui/dialog.ts']];
13
+ assert.deepEqual(caseCollisions(input), expected);
14
+ });
15
+
16
+ test('caseCollisions no collision for different stems', () => {
17
+ const input = ['src/Redirects.tsx', 'src/redirectData.ts'];
18
+ assert.deepEqual(caseCollisions(input), []);
19
+ });
20
+
21
+ test('caseCollisions no collision for identical stems', () => {
22
+ const input = ['a.ts', 'a.tsx'];
23
+ assert.deepEqual(caseCollisions(input), []);
24
+ });
25
+
26
+ test('caseCollisions detects mixed collision', () => {
27
+ const input = ['x/A.ts', 'x/a.tsx', 'x/a.css'];
28
+ const expected = [['x/A.ts', 'x/a.css', 'x/a.tsx']];
29
+ assert.deepEqual(caseCollisions(input), expected);
30
+ });
31
+
32
+ test('caseCollisions no collision for different folders', () => {
33
+ const input = ['one/A.ts', 'two/a.ts'];
34
+ assert.deepEqual(caseCollisions(input), []);
35
+ });
36
+
37
+ test('caseCollisionProblems checks folder and skips node_modules', () => {
38
+ const root = mkdtempSync(join(tmpdir(), 'case-'));
39
+ try {
40
+ const base = join(root, 'pkg');
41
+ mkdirSync(join(base, 'ui'), { recursive: true });
42
+ writeFileSync(join(base, 'ui', 'Dialog.tsx'), '');
43
+ writeFileSync(join(base, 'ui', 'dialog.ts'), '');
44
+ writeFileSync(join(base, 'ui', 'Button.tsx'), '');
45
+
46
+ const nm = join(base, 'node_modules', 'x');
47
+ mkdirSync(nm, { recursive: true });
48
+ writeFileSync(join(nm, 'A.js'), '');
49
+ writeFileSync(join(nm, 'a.ts'), '');
50
+
51
+ const problems = caseCollisionProblems(root, ['pkg', 'missing']);
52
+ assert.equal(problems.length, 1);
53
+ assert.ok(problems[0].startsWith('pkg/ui/Dialog.tsx, pkg/ui/dialog.ts: '));
54
+ } finally {
55
+ rmSync(root, { recursive: true, force: true });
56
+ }
57
+ });
58
+
59
+ test('caseCollisionProblems takes a dir with the system separator', () => {
60
+ const root = mkdtempSync(join(tmpdir(), 'case-'));
61
+ try {
62
+ const base = join(root, 'pkg');
63
+ mkdirSync(join(base, 'ui'), { recursive: true });
64
+ writeFileSync(join(base, 'ui', 'Dialog.tsx'), '');
65
+ writeFileSync(join(base, 'ui', 'dialog.ts'), '');
66
+ writeFileSync(join(base, 'ui', 'Button.tsx'), '');
67
+
68
+ const problems = caseCollisionProblems(root, [join('pkg', 'ui')]);
69
+ assert.equal(problems.length, 1);
70
+ assert.ok(problems[0].startsWith('pkg/ui/Dialog.tsx, pkg/ui/dialog.ts: '));
71
+ } finally {
72
+ rmSync(root, { recursive: true, force: true });
73
+ }
74
+ });
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Store contract versions: the API levels for apps and themes.
3
+ * Exposes APP_API and THEME_API.
4
+ */
5
+ /** The version of the contract between the editor and an app (apps.ts: defineApp, its folder, and the theme's parts it
6
+ * draws with: the standard blocks' and the UI components, @theme/blocks/… and @theme/ui/…, imported from
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;
10
+ /** The version of the contract between the editor and a theme (what a theme must and may have, "Hvad et tema må" in
11
+ * CLAUDE.md, themeProblems in packages/editor/site/theme.mjs, the standard theme's UI parts with Material 3's names and
12
+ * variants, the motion scheme's --motion-* springs and motionSpring/motionTiming (@morphemecms/cms/lib/motion),
13
+ * and the classes it styles): only themes written to it are offered. Raised when a theme written to the one before no
14
+ * longer works. */
15
+ export const THEME_API: 3;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Store contract versions: the API levels for apps and themes.
3
+ * Exposes APP_API and THEME_API.
4
+ */
5
+
6
+ /** The version of the contract between the editor and an app (apps.ts: defineApp, its folder, and the theme's parts it
7
+ * draws with: the standard blocks' and the UI components, @theme/blocks/… and @theme/ui/…, imported from
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;
11
+
12
+ /** The version of the contract between the editor and a theme (what a theme must and may have, "Hvad et tema må" in
13
+ * CLAUDE.md, themeProblems in packages/editor/site/theme.mjs, the standard theme's UI parts with Material 3's names and
14
+ * variants, the motion scheme's --motion-* springs and motionSpring/motionTiming (@morphemecms/cms/lib/motion),
15
+ * and the classes it styles): only themes written to it are offered. Raised when a theme written to the one before no
16
+ * longer works. */
17
+ export const THEME_API = 3;
@@ -120,7 +120,7 @@ export function licensesIntegration({ root, iconPacks, owners }: {
120
120
  iconPacks: () => IconPack[];
121
121
  owners: () => Promise<Owner[]>;
122
122
  }): import("astro").AstroIntegration;
123
- /** An app's or a theme's licences as the store's catalog has them (StoreLicenses in src/licenses.ts): its own licence's
123
+ /** An app's or a theme's licences as the store's catalog has them (StoreLicenses in src/licenseData.ts): its own licence's
124
124
  * name, and each work's name, version, who made it, its licence's name and kind, without the texts.
125
125
  * @param {{ license?: string, thirdParty?: object[] }} manifest @param {{ folder: string, owner: string }} where */
126
126
  export function storeLicenses(manifest: {
package/lib/licenses.mjs CHANGED
@@ -16,7 +16,7 @@
16
16
  // License asks to be passed on), else the licence's standard text (license-texts/<SPDX>.txt) with who made it; who made
17
17
  // it is its `author`, else its first contributor, the holder its licence's copyright line names, its first maintainer or
18
18
  // the owner of its repository.
19
- // The build writes dist/editor/licenses.json (LicensesFile in src/licenses.ts), which the editor fetches, and stops when
19
+ // The build writes dist/editor/licenses.json (LicensesFile in src/licenseData.ts), which the editor fetches, and stops when
20
20
  // something shipped has no licence it can show. npm run check:content checks, before a build: every package the site's
21
21
  // and the editor's code imports, and what they depend on (codePackageProblems, more than is shipped, so nothing shipped
22
22
  // slips through) and each app's and theme's declaration (declarationProblems); the icon packs' manifests and licences
@@ -674,7 +674,7 @@ export function licensesIntegration({ root, iconPacks, owners }) {
674
674
  // ---------------------------------------------------------------------------------------------------
675
675
  // The store
676
676
 
677
- /** An app's or a theme's licences as the store's catalog has them (StoreLicenses in src/licenses.ts): its own licence's
677
+ /** An app's or a theme's licences as the store's catalog has them (StoreLicenses in src/licenseData.ts): its own licence's
678
678
  * name, and each work's name, version, who made it, its licence's name and kind, without the texts.
679
679
  * @param {{ license?: string, thirdParty?: object[] }} manifest @param {{ folder: string, owner: string }} where */
680
680
  export function storeLicenses(manifest, where) {
@@ -0,0 +1,18 @@
1
+ /**
2
+ * @param {any} x
3
+ * @param {number} api
4
+ * @returns {boolean}
5
+ */
6
+ export function validApp(x: any, api: number): boolean;
7
+ /**
8
+ * @param {any} x
9
+ * @param {number} api
10
+ * @returns {boolean}
11
+ */
12
+ export function validTheme(x: any, api: number): boolean;
13
+ export function isText(v: unknown): v is string;
14
+ export function isPath(v: unknown): boolean;
15
+ export const ITEM_ID: RegExp;
16
+ export const REFUSED_APPS: string[];
17
+ export const STANDARD: "saturn";
18
+ export const RECORD: "store.json";
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Pure validation logic for store apps and themes.
3
+ * Exposes isText, isPath, ITEM_ID, REFUSED_APPS, STANDARD, RECORD, validApp, validTheme.
4
+ * No external dependencies.
5
+ */
6
+
7
+ /**
8
+ * @param {unknown} v
9
+ * @returns {v is string}
10
+ */
11
+ export const isText = (v) => typeof v === 'string' && v.trim() !== '';
12
+
13
+ /**
14
+ * @param {unknown} v
15
+ * @returns {boolean}
16
+ */
17
+ export const isPath = (v) => isText(v) && v.split('/').every((part) => part !== '' && part !== '.' && part !== '..');
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'];
22
+ // The standard theme, which every site has
23
+ export const STANDARD = 'saturn';
24
+ // The note in an installed folder of where it came from
25
+ export const RECORD = 'store.json';
26
+
27
+ const isAdd = (v) => isText(v?.label) && isText(v?.icon);
28
+ const isGoodFile = (f) => isText(f?.oid) && isPath(f?.path) && f.path !== RECORD;
29
+
30
+ /**
31
+ * @param {any} x
32
+ * @param {number} api
33
+ * @returns {boolean}
34
+ */
35
+ export function validApp(x, api) {
36
+ return (
37
+ ITEM_ID.test(x?.id ?? '') &&
38
+ !REFUSED_APPS.includes(x.id) &&
39
+ isText(x.version) &&
40
+ x.api === api &&
41
+ [x.label, x.publisher, x.icon, x.hint].every(isText) &&
42
+ Array.isArray(x.files) &&
43
+ x.files.length > 0 &&
44
+ x.files.every(isGoodFile) &&
45
+ ['blocks', 'lists', 'kinds'].every((k) => Array.isArray(x.adds?.[k]) && x.adds[k].every(isAdd))
46
+ );
47
+ }
48
+
49
+ /**
50
+ * @param {any} x
51
+ * @param {number} api
52
+ * @returns {boolean}
53
+ */
54
+ export function validTheme(x, api) {
55
+ return (
56
+ ITEM_ID.test(x?.id ?? '') &&
57
+ x.id !== STANDARD &&
58
+ x.api === api &&
59
+ [x.version, x.name, x.author, x.icon].every(isText) &&
60
+ Array.isArray(x.files) &&
61
+ x.files.length > 0 &&
62
+ x.files.every(isGoodFile)
63
+ );
64
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Tests for store entry validation logic (validApp, validTheme, isPath).
3
+ * Run with `node --test packages/editor/lib/*.test.mjs` (part of npm run check).
4
+ */
5
+ import { test } from 'node:test';
6
+ import assert from 'node:assert/strict';
7
+ import { validApp, validTheme, isPath } from './storeEntries.mjs';
8
+
9
+ const theme = () => ({
10
+ id: 'venus',
11
+ version: '1.0.0',
12
+ api: 4,
13
+ name: 'Venus',
14
+ author: 'Morpheme',
15
+ icon: 'palette',
16
+ files: [{ path: 'theme.json', oid: 'a'.repeat(40) }],
17
+ });
18
+ const app = () => ({
19
+ id: 'calendly',
20
+ version: '1.0.0',
21
+ api: 2,
22
+ label: 'Calendly',
23
+ publisher: 'Morpheme',
24
+ icon: 'event',
25
+ hint: 'Bookings',
26
+ files: [{ path: 'index.ts', oid: 'b'.repeat(40) }],
27
+ adds: { blocks: [{ label: 'Booking', icon: 'event' }], lists: [], kinds: [] },
28
+ });
29
+
30
+ test('valid theme', () => {
31
+ assert.equal(validTheme(theme(), 4), true);
32
+ });
33
+
34
+ test('valid theme wrong api', () => {
35
+ assert.equal(validTheme({ ...theme(), api: 3 }, 4), false);
36
+ });
37
+
38
+ test('valid theme standard id', () => {
39
+ assert.equal(validTheme({ ...theme(), id: 'saturn' }, 4), false);
40
+ });
41
+
42
+ test('valid theme bad path', () => {
43
+ assert.equal(validTheme({ ...theme(), files: [{ path: '../x', oid: 'a'.repeat(40) }] }, 4), false);
44
+ });
45
+
46
+ test('valid theme record path', () => {
47
+ assert.equal(validTheme({ ...theme(), files: [{ path: 'store.json', oid: 'a'.repeat(40) }] }, 4), false);
48
+ });
49
+
50
+ test('valid theme empty files', () => {
51
+ assert.equal(validTheme({ ...theme(), files: [] }, 4), false);
52
+ });
53
+
54
+ test('valid app', () => {
55
+ assert.equal(validApp(app(), 2), true);
56
+ });
57
+
58
+ test('valid app refused id', () => {
59
+ assert.equal(validApp({ ...app(), id: 'butik' }, 2), false);
60
+ });
61
+
62
+ test('valid app missing adds kinds', () => {
63
+ assert.equal(validApp({ ...app(), adds: { blocks: [], lists: [] } }, 2), false);
64
+ });
65
+
66
+ test('isPath cases', () => {
67
+ assert.equal(isPath('styles/global.css'), true);
68
+ assert.equal(isPath('a//b'), false);
69
+ assert.equal(isPath('./a'), false);
70
+ assert.equal(isPath(''), false);
71
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morphemecms/cms",
3
- "version": "0.1.1-next-20261103223426",
3
+ "version": "0.2.0",
4
4
  "description": "Morpheme CMS: the editor for a site built with Astro, its screens, its blocks and the rules a site gives it. See README.md.",
5
5
  "license": "MIT",
6
6
  "author": "Lars Adolfsen",
@@ -1,4 +1,4 @@
1
- // The editor's service worker, only for its notifications on the phone or computer (packages/editor/src/notifications.ts):
1
+ // The editor's service worker, only for its notifications on the phone or computer (packages/editor/src/notificationData.ts):
2
2
  // while the editor is open but not on screen, it shows them (on Android that works only through a service worker), and
3
3
  // when the site's Worker sends one (Web Push, packages/editor/worker/push.js: the site is updated after a save, an app's notification),
4
4
  // the browser wakes it, also when the editor is closed, and it shows that. It does not cache or touch any request.
@@ -1,9 +1,10 @@
1
1
  // The built site served on this computer, with what the editor asks of GitHub (through the site's Worker) answered from
2
- // the files in this repository, for the scripts that open the editor in a browser (editor-screens.mjs,
3
- // app-screenshots.mjs): no sign-in, and nothing changes.
2
+ // the files in this repository, and the app store from a made-up catalog, for the scripts that open the editor in a
3
+ // browser (editor-screens.mjs, app-screenshots.mjs): no sign-in, and nothing changes.
4
4
  import { createServer } from 'node:http';
5
5
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
6
6
  import { extname, join, posix } from 'node:path';
7
+ import { APP_API, THEME_API } from '../lib/contracts.mjs';
7
8
 
8
9
  const ROOT = `${process.cwd()}/`;
9
10
 
@@ -81,6 +82,35 @@ function workerAnswer(route, body, texts) {
81
82
  return null;
82
83
  }
83
84
 
85
+ // The app store, as the site's Worker passes it on (packages/editor/worker/store.js), made up: one app and one theme in
86
+ // the catalog's shape, and no file by any id.
87
+ const STORE_CATALOG = {
88
+ apps: [
89
+ {
90
+ id: 'sample-app',
91
+ version: '1.0.0',
92
+ api: APP_API,
93
+ label: 'Sample app',
94
+ publisher: 'Example Studio',
95
+ icon: 'star',
96
+ hint: 'A made-up app for the checks.',
97
+ adds: { blocks: [], lists: [], kinds: [] },
98
+ files: [{ path: 'index.ts', oid: '0'.repeat(40) }],
99
+ },
100
+ ],
101
+ themes: [
102
+ {
103
+ id: 'sample-theme',
104
+ version: '1.0.0',
105
+ api: THEME_API,
106
+ name: 'Sample theme',
107
+ author: 'Example Studio',
108
+ icon: 'palette',
109
+ files: [{ path: 'theme.json', oid: '1'.repeat(40) }],
110
+ },
111
+ ],
112
+ };
113
+
84
114
  /** Opens the editor (/editor/) of the site at `site` in `page`, signed in, with GitHub answered from the files on disk
85
115
  * (`texts`: some with other texts), and waits until it has loaded the site. */
86
116
  export async function openEditor(page, site, texts = {}) {
@@ -88,6 +118,11 @@ export async function openEditor(page, site, texts = {}) {
88
118
  await page.route(`${site}/api/r2?**`, (r) =>
89
119
  r.fulfill({ contentType: 'application/json', body: JSON.stringify({ bound: false, bucket: 'ready' }) }),
90
120
  );
121
+ await page.route(`${site}/api/store/**`, (r) =>
122
+ new URL(r.request().url()).pathname === '/api/store/catalog'
123
+ ? r.fulfill({ contentType: 'application/json', body: JSON.stringify(STORE_CATALOG) })
124
+ : r.fulfill({ status: 404 }),
125
+ );
91
126
  await page.route(`${site}/api/github/**`, (r) => {
92
127
  const route = new URL(r.request().url()).pathname.slice('/api/github/'.length);
93
128
  const found = workerAnswer(route, JSON.parse(r.request().postData() || '{}'), texts);
@@ -22,6 +22,7 @@ import {
22
22
  themeProblems,
23
23
  } from '@morphemecms/cms/site/theme.mjs';
24
24
  import { appFoldersPlugin, appIds, appWorkersProblems } from '@morphemecms/cms/lib/apps.mjs';
25
+ import { caseCollisionProblems } from '@morphemecms/cms/lib/caseCollisions.mjs';
25
26
  import { codePackageProblems, declarationProblems, ownerId } from '@morphemecms/cms/lib/licenses.mjs';
26
27
  import { notesOf, releaseProblems } from './releases.mjs';
27
28
 
@@ -64,8 +65,7 @@ try {
64
65
  const { problemsOf, untranslated } = await server.ssrLoadModule('@morphemecms/cms/src/checkSite.ts');
65
66
  result = problemsOf(files);
66
67
  missing = untranslated();
67
- const { config } = await server.ssrLoadModule('/src/site/config.ts');
68
- if (config.store && existsSync(config.store.catalog)) catalog = JSON.parse(readFileSync(config.store.catalog, 'utf8'));
68
+ if (existsSync('store/catalog.json')) catalog = JSON.parse(readFileSync('store/catalog.json', 'utf8'));
69
69
  for (const id of appIds(root)) {
70
70
  const app = (await server.ssrLoadModule(`/src/apps/${id}/index.ts`)).default;
71
71
  for (const x of app.thirdParty ?? []) if (x?.package) declared.add(x.package);
@@ -116,6 +116,8 @@ result.problems.push(...allowedCspProblems());
116
116
  result.problems.push(...appCookieProblems());
117
117
  // The Worker has the part of each app that has one (worker/apps.js).
118
118
  result.problems.push(...appWorkersProblems(root));
119
+ // No two files of the package or the site differ only by case (an import would find the wrong one on Windows).
120
+ result.problems.push(...caseCollisionProblems(root, [own, 'src']));
119
121
  // Every app and theme says what its licence is and what it brings (the icon packs say theirs, themeProblems), and
120
122
  // every package the site's and the editor's code imports, and those they depend on, has a licence the editor can show
121
123
  // under "Licenser" (more than the build ships, which stops on its own when something it ships has none).