@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.
- package/CHANGELOG.md +6 -1
- package/bin/morpheme.mjs +1 -1
- package/lib/apps.mjs +5 -2
- package/lib/apps.test.d.mts +1 -0
- package/lib/apps.test.mjs +50 -0
- package/lib/caseCollisions.d.mts +4 -0
- package/lib/caseCollisions.mjs +62 -0
- package/lib/caseCollisions.test.d.mts +1 -0
- package/lib/caseCollisions.test.mjs +74 -0
- package/lib/contracts.d.mts +15 -0
- package/lib/contracts.mjs +17 -0
- package/lib/licenses.d.mts +1 -1
- package/lib/licenses.mjs +2 -2
- package/lib/storeEntries.d.mts +18 -0
- package/lib/storeEntries.mjs +64 -0
- package/lib/storeEntries.test.d.mts +1 -0
- package/lib/storeEntries.test.mjs +71 -0
- package/package.json +1 -1
- package/pages/editor/sw.js +1 -1
- package/scripts/built-site.mjs +37 -2
- package/scripts/check-content.mjs +4 -2
- package/scripts/check-store.mjs +160 -0
- package/site/defineSite.d.mts +2 -14
- package/site/defineSite.mjs +2 -7
- package/site/storeTheme.d.mts +6 -0
- package/site/storeTheme.mjs +29 -0
- package/site/storeTheme.test.d.mts +1 -0
- package/site/storeTheme.test.mjs +53 -0
- package/site/theme.mjs +14 -3
- package/src/App.tsx +13 -16
- package/src/AppList.tsx +22 -5
- package/src/Consents.tsx +2 -2
- package/src/Deleted.tsx +1 -1
- package/src/EntryForm.tsx +3 -3
- package/src/FormFrame.tsx +3 -3
- package/src/Library.tsx +2 -2
- package/src/Licenses.tsx +1 -1
- package/src/Media.tsx +2 -2
- package/src/Notifications.tsx +2 -2
- package/src/Redirects.tsx +2 -2
- package/src/Screen.tsx +1 -1
- package/src/ThemeStore.tsx +1 -1
- package/src/apps.ts +2 -2
- package/src/checkSite.ts +2 -2
- package/src/deploy.ts +1 -1
- package/src/i18n/da-DK/consents.ts +1 -1
- package/src/i18n/en-US/apps.ts +1 -1
- package/src/i18n/en-US/consents.ts +1 -1
- package/src/i18n/en-US/library.ts +1 -1
- package/src/i18n/en-US/licenses.ts +1 -1
- package/src/i18n/en-US/redirects.ts +2 -2
- package/src/store.ts +74 -94
- package/src/themes.ts +1 -1
- package/starter/README.md +1 -0
- package/theme/styles/ui.css +4 -4
- package/theme/ui/BottomSheet.tsx +3 -3
- package/theme/ui/Dialog.tsx +1 -1
- package/theme/ui/SideSheet.tsx +1 -1
- package/theme/ui/bottomSheet.script.ts +2 -2
- package/worker/consent.js +1 -1
- package/worker/index.js +4 -0
- package/worker/push.js +1 -1
- package/worker/store.js +46 -0
- /package/src/{consents.ts → consentData.ts} +0 -0
- /package/src/{licenses.ts → licenseData.ts} +0 -0
- /package/src/{notifications.ts → notificationData.ts} +0 -0
- /package/src/{redirects.ts → redirectData.ts} +0 -0
- /package/theme/ui/{bottomSheet.ts → bottomSheetControl.ts} +0 -0
- /package/theme/ui/{dialog.ts → dialogControl.ts} +0 -0
- /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.
|
|
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;
|
package/lib/licenses.d.mts
CHANGED
|
@@ -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/
|
|
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/
|
|
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/
|
|
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.
|
|
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",
|
package/pages/editor/sw.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// The editor's service worker, only for its notifications on the phone or computer (packages/editor/src/
|
|
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.
|
package/scripts/built-site.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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).
|