@morphemecms/cms 0.4.0 → 0.5.0-next-20261104215254
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 +13 -0
- package/bin/morpheme.mjs +2 -2
- package/lib/licenses.mjs +1 -1
- package/package.json +6 -1
- package/scripts/built-site.mjs +24 -7
- package/scripts/check-store-connection.mjs +226 -0
- package/scripts/check-store.mjs +53 -3
- package/scripts/editor-screens.mjs +26 -0
- package/scripts/types-include.test.mjs +93 -0
- package/site/theme.d.mts +6 -0
- package/site/theme.mjs +30 -5
- package/site/theme.test.mjs +54 -0
- package/src/AppList.tsx +16 -2
- package/src/CmsVersion.tsx +14 -0
- package/src/Library.tsx +3 -2
- package/src/StartStatus.tsx +24 -0
- package/src/StoreConnectNotice.tsx +34 -0
- package/src/StoreConnectedRow.tsx +36 -0
- package/src/SwapText.tsx +22 -0
- package/src/allIcons.ts +3 -6
- package/src/cmsVersionView.d.mts +7 -0
- package/src/cmsVersionView.mjs +11 -0
- package/src/cmsVersionView.test.mjs +32 -0
- package/src/i18n/da-DK/apps.ts +7 -0
- package/src/i18n/da-DK/library.ts +2 -0
- package/src/i18n/en-US/apps.ts +8 -0
- package/src/i18n/en-US/library.ts +4 -0
- package/src/store.ts +5 -0
- package/src/storeConnection.ts +24 -0
- package/src/storeConnectionCall.test.mjs +57 -0
- package/src/storeConnectionCall.ts +37 -0
- package/src/virtual.d.ts +5 -0
- package/starter/package.json +5 -0
- package/theme/editor/looks.d.mts +2 -0
- package/worker/index.js +4 -1
- package/worker/store-connection.js +106 -0
- package/worker/store.js +14 -6
- package/lib/apps.test.d.mts +0 -1
- package/lib/caseCollisions.test.d.mts +0 -1
- package/lib/changelog.test.d.mts +0 -1
- package/lib/cms-version.test.d.mts +0 -1
- package/lib/lockfile.test.d.mts +0 -1
- package/lib/range.test.d.mts +0 -1
- package/lib/side-block.test.d.mts +0 -1
- package/lib/storeEntries.test.d.mts +0 -1
- package/site/storeTheme.test.d.mts +0 -1
- package/src/cmsView.test.d.mts +0 -1
- package/src/frontmatter.test.d.mts +0 -1
- package/src/standard/leads.test.d.mts +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# @morphemecms/cms
|
|
2
2
|
|
|
3
|
+
## 0.5.0-next-20261104215254
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 322a404: Connect a site to the Morpheme store from the editor's Appstore. A key set by hand in MORPHEME_STORE_KEY still works.
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- eb1f36f: A site's build reads Phosphor's icons one at a time, so it passes on Cloudflare's build image, which allows 1024 open files.
|
|
12
|
+
- 6844676: The editor's start screen shows the installed CMS version before the update time, e.g. "Morpheme 0.5.0 · Updated 19.26". A pre-release shows short ("0.5.0-next"); a click shows the full version.
|
|
13
|
+
- 2fa2bb3: New sites install a fixed postcss-selector-parser (6.1.5) under @tailwindcss/typography, which closes CVE-2026-104844.
|
|
14
|
+
- f343523: The package has the types of the theme's `editor/looks.mjs`, so a site's type check (`tsc`) passes again.
|
|
15
|
+
|
|
3
16
|
## 0.4.0
|
|
4
17
|
|
|
5
18
|
### Minor Changes
|
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', 'check-store', 'check-cms-update'],
|
|
15
|
+
'check:worker': ['check-worker', 'check-consent', 'check-store', 'check-cms-update', 'check-store-connection'],
|
|
16
16
|
'check:screens': ['editor-screens'],
|
|
17
17
|
'check:themes': ['check-themes'],
|
|
18
18
|
'check:demo': ['check-demo'],
|
|
@@ -24,7 +24,7 @@ const COMMANDS = {
|
|
|
24
24
|
};
|
|
25
25
|
|
|
26
26
|
// Node warns that node:sqlite is experimental, which the consent log's check uses as D1.
|
|
27
|
-
const QUIET = new Set(['check-consent']);
|
|
27
|
+
const QUIET = new Set(['check-consent', 'check-store-connection']);
|
|
28
28
|
|
|
29
29
|
const [command, ...args] = process.argv.slice(2);
|
|
30
30
|
if (!COMMANDS[command]) {
|
package/lib/licenses.mjs
CHANGED
|
@@ -428,7 +428,7 @@ const HELPERS = [
|
|
|
428
428
|
[/^\0vite\//, 'vite'],
|
|
429
429
|
[/^\0rolldown\//, 'rolldown'],
|
|
430
430
|
];
|
|
431
|
-
const OWN_VIRTUAL = [/^\0virtual:app-folders$/, /^\0site-theme-icons?(:|$)/];
|
|
431
|
+
const OWN_VIRTUAL = [/^\0virtual:(app-folders|all-icons)$/, /^\0site-theme-icons?(:|$)/];
|
|
432
432
|
|
|
433
433
|
/** An app or a theme as licensesIntegration takes it: its kind and id, its name, version and who made it, its manifest
|
|
434
434
|
* (its defineApp or its theme.json, with `license` and `thirdParty`), its folder, and whether the site is built with it
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@morphemecms/cms",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0-next-20261104215254",
|
|
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",
|
|
@@ -138,5 +138,10 @@
|
|
|
138
138
|
"vite": "^8.3.4",
|
|
139
139
|
"wrangler": "^4.152.0",
|
|
140
140
|
"yaml": "^2.9.1"
|
|
141
|
+
},
|
|
142
|
+
"overrides": {
|
|
143
|
+
"@tailwindcss/typography": {
|
|
144
|
+
"postcss-selector-parser": "^6.1.5"
|
|
145
|
+
}
|
|
141
146
|
}
|
|
142
147
|
}
|
package/scripts/built-site.mjs
CHANGED
|
@@ -124,19 +124,36 @@ const CMS_STATUS = {
|
|
|
124
124
|
notes: [{ version: '99.0.0', text: 'A made-up note for the checks.\n\nA second paragraph.' }],
|
|
125
125
|
};
|
|
126
126
|
|
|
127
|
+
// The site's key to the store (/api/store/connection, packages/editor/worker/store-connection.js), made up, in the three
|
|
128
|
+
// states "Appstore" shows: a key from the button, no key and the editor may connect, no key and it may only read. A POST
|
|
129
|
+
// or DELETE moves a fake store between the first two, as the Worker would.
|
|
130
|
+
export const STORE_STATES = {
|
|
131
|
+
button: { connected: true, by: 'button', site: 'site_sample', since: '2026-11-04T08:00:00.000Z', mayChange: true },
|
|
132
|
+
open: { connected: false, mayChange: true },
|
|
133
|
+
readOnly: { connected: false, mayChange: false },
|
|
134
|
+
};
|
|
135
|
+
|
|
127
136
|
/** Opens the editor (/editor/) of the site at `site` in `page`, signed in, with GitHub answered from the files on disk
|
|
128
|
-
* (`texts`: some with other texts), and waits until it has loaded the site.
|
|
129
|
-
|
|
137
|
+
* (`texts`: some with other texts), and waits until it has loaded the site. `store`: the state of the site's key to the
|
|
138
|
+
* store (a key of STORE_STATES). */
|
|
139
|
+
export async function openEditor(page, site, texts = {}, store = 'button') {
|
|
140
|
+
let state = STORE_STATES[store];
|
|
130
141
|
// R2 caching under "Avanceret": Cloudflare has the bucket (packages/editor/worker/r2-status.js).
|
|
131
142
|
await page.route(`${site}/api/r2?**`, (r) =>
|
|
132
143
|
r.fulfill({ contentType: 'application/json', body: JSON.stringify({ bound: false, bucket: 'ready' }) }),
|
|
133
144
|
);
|
|
134
145
|
await page.route(`${site}/api/cms`, (r) => r.fulfill({ contentType: 'application/json', body: JSON.stringify(CMS_STATUS) }));
|
|
135
|
-
await page.route(`${site}/api/store/**`, (r) =>
|
|
136
|
-
new URL(r.request().url()).pathname
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
146
|
+
await page.route(`${site}/api/store/**`, (r) => {
|
|
147
|
+
const path = new URL(r.request().url()).pathname;
|
|
148
|
+
const answer = (body) => r.fulfill({ contentType: 'application/json', body: JSON.stringify(body) });
|
|
149
|
+
if (path === '/api/store/catalog') return answer(state.connected ? STORE_CATALOG : { connected: false });
|
|
150
|
+
if (path === '/api/store/connection') {
|
|
151
|
+
if (r.request().method() === 'POST') state = STORE_STATES.button;
|
|
152
|
+
if (r.request().method() === 'DELETE') state = STORE_STATES.open;
|
|
153
|
+
return answer(state);
|
|
154
|
+
}
|
|
155
|
+
return r.fulfill({ status: 404 });
|
|
156
|
+
});
|
|
140
157
|
await page.route(`${site}/api/github/**`, (r) => {
|
|
141
158
|
const route = new URL(r.request().url()).pathname.slice('/api/github/'.length);
|
|
142
159
|
const found = workerAnswer(route, JSON.parse(r.request().postData() || '{}'), texts);
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// Checks how the site's Worker gets and keeps a store key from "Connect to Morpheme" (packages/editor/worker/store-connection.js):
|
|
2
|
+
// /api/store/connection for GET, POST and DELETE, who may do what, the table with the key (D1 played by Node's SQLite), and
|
|
3
|
+
// storeKey(). GitHub and the store are played by a fake fetch that records each request; nothing goes to either.
|
|
4
|
+
// Run: npm run check:worker
|
|
5
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
6
|
+
import { workerModules } from './worker-modules.mjs';
|
|
7
|
+
|
|
8
|
+
const [{ handleConnection, storeKey, forgetKey }] = await workerModules('store-connection.js', 'session.js', 'store.js');
|
|
9
|
+
|
|
10
|
+
const problems = [];
|
|
11
|
+
const expect = (ok, what) => ok || problems.push(what);
|
|
12
|
+
|
|
13
|
+
/** D1, as much of it as the Worker uses, on an SQLite database in memory. */
|
|
14
|
+
function d1() {
|
|
15
|
+
const db = new DatabaseSync(':memory:');
|
|
16
|
+
const statement = (sql, args = []) => ({
|
|
17
|
+
bind: (...values) => statement(sql, values),
|
|
18
|
+
run: async () => ({ meta: { changes: Number(db.prepare(sql).run(...args).changes) } }),
|
|
19
|
+
all: async () => ({ results: db.prepare(sql).all(...args) }),
|
|
20
|
+
first: async (column) => db.prepare(sql).get(...args)?.[column] ?? null,
|
|
21
|
+
});
|
|
22
|
+
return { db, prepare: (sql) => statement(sql), batch: async (list) => Promise.all(list.map((s) => s.run())) };
|
|
23
|
+
}
|
|
24
|
+
const rowsOf = (store) => store.db.prepare('SELECT site, key FROM store_connection').all();
|
|
25
|
+
|
|
26
|
+
const SITE = `site_${'a1'.repeat(12)}`;
|
|
27
|
+
const KEY = 'isk_x';
|
|
28
|
+
const HAND = 'hand-key-for-test';
|
|
29
|
+
|
|
30
|
+
/** A fake fetch: GitHub says what `repoAccess` says; the store answers with `store(url, init)`. `storeCalls` records its requests. */
|
|
31
|
+
function world({ repoAccess = { pull: true, push: true }, store = () => Response.json({ site: SITE, key: KEY }, { status: 201 }) } = {}) {
|
|
32
|
+
const storeCalls = [];
|
|
33
|
+
const ask = async (url, init = {}) => {
|
|
34
|
+
if (String(url).startsWith('https://api.github.com/')) return Response.json({ permissions: repoAccess });
|
|
35
|
+
storeCalls.push({ url: String(url), method: init.method ?? 'GET', headers: init.headers ?? {}, body: init.body });
|
|
36
|
+
return store(String(url), init);
|
|
37
|
+
};
|
|
38
|
+
return { ask, storeCalls };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let n = 0;
|
|
42
|
+
/** A request from the editor's page; a fresh token each time (the Worker remembers what GitHub said about a token). */
|
|
43
|
+
const call = async (w, method, env, { signedIn = true } = {}) => {
|
|
44
|
+
const request = new Request('https://site.example/api/store/connection', {
|
|
45
|
+
method,
|
|
46
|
+
headers: { ...(signedIn ? { cookie: `editor_session=token-${++n}` } : {}), 'sec-fetch-site': 'same-origin' },
|
|
47
|
+
});
|
|
48
|
+
const res = await handleConnection(request, env, {}, w.ask);
|
|
49
|
+
const text = await res.text();
|
|
50
|
+
return { res, text, body: text.startsWith('{') ? JSON.parse(text) : null };
|
|
51
|
+
};
|
|
52
|
+
/** An env with a fresh database, with a row for each [site, key] given. */
|
|
53
|
+
const envWith = async (rows = [], extra = {}) => {
|
|
54
|
+
const CONSENT = d1();
|
|
55
|
+
if (rows.length) {
|
|
56
|
+
await call(world(), 'GET', { CONSENT }); // makes the table on first use
|
|
57
|
+
for (const [site, key] of rows) {
|
|
58
|
+
CONSENT.db.prepare('INSERT INTO store_connection (site, key, connected) VALUES (?, ?, ?)').run(site, key, '2026-11-04T10:00:00.000Z');
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return { CONSENT, ...extra };
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// Signed out: 401 for each method, and the store is not asked.
|
|
65
|
+
for (const method of ['GET', 'POST', 'DELETE']) {
|
|
66
|
+
const w = world();
|
|
67
|
+
const { res } = await call(w, method, await envWith(), { signedIn: false });
|
|
68
|
+
expect(res.status === 401, `uden login var ${method} ${res.status}, ikke 401`);
|
|
69
|
+
expect(w.storeCalls.length === 0, `uden login blev butikken spurgt (${method})`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Not even allowed to read: 403.
|
|
73
|
+
{
|
|
74
|
+
const w = world({ repoAccess: { pull: false, push: false } });
|
|
75
|
+
const { res } = await call(w, 'GET', await envWith());
|
|
76
|
+
expect(res.status === 403, `uden læseret var svaret ${res.status}, ikke 403`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Read-only: GET works, POST and DELETE are 403, and the store is not asked.
|
|
80
|
+
{
|
|
81
|
+
const w = world({ repoAccess: { pull: true, push: false } });
|
|
82
|
+
const env = await envWith();
|
|
83
|
+
const get = await call(w, 'GET', env);
|
|
84
|
+
expect(get.res.status === 200 && get.body.connected === false && get.body.mayChange === false, `GET med kun læseret gav ${get.text}`);
|
|
85
|
+
for (const method of ['POST', 'DELETE']) {
|
|
86
|
+
const { res } = await call(w, method, env);
|
|
87
|
+
expect(res.status === 403, `${method} med kun læseret gav ${res.status}, ikke 403`);
|
|
88
|
+
}
|
|
89
|
+
expect(w.storeCalls.length === 0, 'med kun læseret blev butikken spurgt');
|
|
90
|
+
expect(rowsOf(env.CONSENT).length === 0, 'med kun læseret blev der gemt en række');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Connect: the store gets the origin and owner/name; the row has the key; no answer has the key; a second connect is 409.
|
|
94
|
+
{
|
|
95
|
+
const w = world();
|
|
96
|
+
const env = await envWith();
|
|
97
|
+
const { res, text, body } = await call(w, 'POST', env);
|
|
98
|
+
expect(
|
|
99
|
+
res.status === 200 &&
|
|
100
|
+
body.connected === true &&
|
|
101
|
+
body.by === 'button' &&
|
|
102
|
+
body.site === SITE &&
|
|
103
|
+
body.mayChange === true &&
|
|
104
|
+
typeof body.since === 'string',
|
|
105
|
+
`POST gav ${res.status} ${text}`,
|
|
106
|
+
);
|
|
107
|
+
expect(!text.includes(KEY), 'svaret på POST indeholdt nøglen');
|
|
108
|
+
expect(w.storeCalls.length === 1 && w.storeCalls[0].method === 'POST', `butikken blev spurgt ${w.storeCalls.length} gange`);
|
|
109
|
+
expect(w.storeCalls[0]?.url.endsWith('/store/connect'), `adressen var ${w.storeCalls[0]?.url}`);
|
|
110
|
+
const sent = JSON.parse(w.storeCalls[0]?.body ?? '{}');
|
|
111
|
+
expect(sent.address === 'https://site.example' && /^[^/]+\/[^/]+$/.test(sent.repo), `butikken fik ${w.storeCalls[0]?.body}`);
|
|
112
|
+
const rows = rowsOf(env.CONSENT);
|
|
113
|
+
expect(rows.length === 1 && rows[0].site === SITE && rows[0].key === KEY, 'rækken har ikke nøglen');
|
|
114
|
+
const get = await call(w, 'GET', env);
|
|
115
|
+
expect(get.body.by === 'button' && get.body.site === SITE && !get.text.includes(KEY), `GET efter POST gav ${get.text}`);
|
|
116
|
+
const second = await call(w, 'POST', env);
|
|
117
|
+
expect(second.res.status === 409, `en anden POST gav ${second.res.status}, ikke 409`);
|
|
118
|
+
expect(w.storeCalls.length === 1, 'en anden POST spurgte butikken');
|
|
119
|
+
// MORPHEME_STORE_URL is used when set.
|
|
120
|
+
const w2 = world();
|
|
121
|
+
await call(w2, 'POST', await envWith([], { MORPHEME_STORE_URL: 'https://store.example' }));
|
|
122
|
+
expect(w2.storeCalls[0]?.url === 'https://store.example/store/connect', `MORPHEME_STORE_URL blev ikke brugt: ${w2.storeCalls[0]?.url}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// The store's 429: 429 and no row. Its 500, an answer without a key, or a thrown fetch: 502 and no row.
|
|
126
|
+
{
|
|
127
|
+
const env = await envWith();
|
|
128
|
+
const { res, body } = await call(world({ store: () => new Response('{}', { status: 429 }) }), 'POST', env);
|
|
129
|
+
expect(res.status === 429 && body.error === 'busy', `butikkens 429 gav ${res.status}`);
|
|
130
|
+
expect(rowsOf(env.CONSENT).length === 0, 'butikkens 429 gav en række');
|
|
131
|
+
}
|
|
132
|
+
for (const [what, store] of [
|
|
133
|
+
['500', () => new Response(`secret ${KEY}`, { status: 500 })],
|
|
134
|
+
['et svar uden nøgle', () => Response.json({ site: SITE }, { status: 201 })],
|
|
135
|
+
[
|
|
136
|
+
'en netværksfejl',
|
|
137
|
+
() => {
|
|
138
|
+
throw new Error(`no network, ${KEY}`);
|
|
139
|
+
},
|
|
140
|
+
],
|
|
141
|
+
]) {
|
|
142
|
+
const env = await envWith();
|
|
143
|
+
const { res, text, body } = await call(world({ store }), 'POST', env);
|
|
144
|
+
expect(res.status === 502 && body.error === 'store failed', `butikkens ${what} gav ${res.status} ${text}`);
|
|
145
|
+
expect(!text.includes(KEY), `svaret på butikkens ${what} indeholdt nøglen`);
|
|
146
|
+
expect(rowsOf(env.CONSENT).length === 0, `butikkens ${what} gav en række`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// No database (a branch preview): GET says not connected, POST is 503.
|
|
150
|
+
{
|
|
151
|
+
const w = world();
|
|
152
|
+
const get = await call(w, 'GET', {});
|
|
153
|
+
expect(get.res.status === 200 && get.body.connected === false, `GET uden CONSENT gav ${get.text}`);
|
|
154
|
+
const post = await call(w, 'POST', {});
|
|
155
|
+
expect(post.res.status === 503 && post.body.error === 'no database', `POST uden CONSENT gav ${post.res.status}`);
|
|
156
|
+
expect(w.storeCalls.length === 0, 'POST uden CONSENT spurgte butikken');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// A key set by hand: GET says so, POST and DELETE are 409, and the store is not asked.
|
|
160
|
+
{
|
|
161
|
+
const w = world();
|
|
162
|
+
const env = await envWith([], { MORPHEME_STORE_KEY: HAND });
|
|
163
|
+
const get = await call(w, 'GET', env);
|
|
164
|
+
expect(get.body.connected === true && get.body.by === 'hand' && !get.text.includes(HAND), `GET med nøgle i hånden gav ${get.text}`);
|
|
165
|
+
expect((await call(w, 'DELETE', env)).res.status === 409, 'DELETE med nøgle i hånden gav ikke 409');
|
|
166
|
+
expect((await call(w, 'POST', env)).res.status === 409, 'POST med nøgle i hånden gav ikke 409');
|
|
167
|
+
expect(w.storeCalls.length === 0, 'med nøgle i hånden blev butikken spurgt');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Disconnect: on the store's 200 or 401 the row goes (the bearer is the row's key); on 500 or a thrown fetch it stays.
|
|
171
|
+
for (const status of [200, 401]) {
|
|
172
|
+
const w = world({ store: () => new Response('{}', { status }) });
|
|
173
|
+
const env = await envWith([[SITE, KEY]]);
|
|
174
|
+
const { res, body, text } = await call(w, 'DELETE', env);
|
|
175
|
+
expect(
|
|
176
|
+
res.status === 200 && body.connected === false && body.mayChange === true,
|
|
177
|
+
`DELETE med butikkens ${status} gav ${res.status} ${text}`,
|
|
178
|
+
);
|
|
179
|
+
expect(rowsOf(env.CONSENT).length === 0, `DELETE med butikkens ${status} lod rækken stå`);
|
|
180
|
+
expect(w.storeCalls[0]?.method === 'DELETE' && w.storeCalls[0].url.endsWith('/store/connect'), 'DELETE spurgte ikke butikken rigtigt');
|
|
181
|
+
expect(w.storeCalls[0]?.headers.authorization === `Bearer ${KEY}`, 'DELETE brugte ikke rækkens nøgle som bearer');
|
|
182
|
+
}
|
|
183
|
+
for (const store of [
|
|
184
|
+
() => new Response('{}', { status: 500 }),
|
|
185
|
+
() => {
|
|
186
|
+
throw new Error('no network');
|
|
187
|
+
},
|
|
188
|
+
]) {
|
|
189
|
+
const env = await envWith([[SITE, KEY]]);
|
|
190
|
+
const { res, body } = await call(world({ store }), 'DELETE', env);
|
|
191
|
+
expect(res.status === 502 && body.error === 'store failed', `DELETE med en fejl i butikken gav ${res.status}`);
|
|
192
|
+
expect(rowsOf(env.CONSENT).length === 1, 'DELETE med en fejl i butikken slettede rækken');
|
|
193
|
+
}
|
|
194
|
+
// No key at all: DELETE is a quiet 200 and the store is not asked.
|
|
195
|
+
{
|
|
196
|
+
const w = world();
|
|
197
|
+
const { res, body } = await call(w, 'DELETE', await envWith());
|
|
198
|
+
expect(res.status === 200 && body.connected === false, `DELETE uden nøgle gav ${res.status}`);
|
|
199
|
+
expect(w.storeCalls.length === 0, 'DELETE uden nøgle spurgte butikken');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Other methods: 405.
|
|
203
|
+
expect((await call(world(), 'PUT', await envWith())).res.status === 405, 'PUT gav ikke 405');
|
|
204
|
+
|
|
205
|
+
// storeKey: the hand key first, then the row, else null; forgetKey deletes the rows.
|
|
206
|
+
{
|
|
207
|
+
const env = await envWith([[SITE, KEY]], { MORPHEME_STORE_KEY: HAND });
|
|
208
|
+
const hand = await storeKey(env);
|
|
209
|
+
expect(hand?.key === HAND && hand.by === 'hand', `storeKey med begge gav ${JSON.stringify(hand)}`);
|
|
210
|
+
const row = await storeKey({ CONSENT: env.CONSENT });
|
|
211
|
+
expect(
|
|
212
|
+
row?.key === KEY && row.by === 'button' && row.site === SITE && typeof row.since === 'string',
|
|
213
|
+
`storeKey med en række gav ${JSON.stringify(row)}`,
|
|
214
|
+
);
|
|
215
|
+
expect((await storeKey({ MORPHEME_STORE_KEY: HAND }))?.by === 'hand', 'storeKey uden CONSENT gav ikke nøglen i hånden');
|
|
216
|
+
expect((await storeKey({})) === null, 'storeKey uden noget gav ikke null');
|
|
217
|
+
await forgetKey(env);
|
|
218
|
+
expect(rowsOf(env.CONSENT).length === 0, 'forgetKey slettede ikke rækken');
|
|
219
|
+
expect((await storeKey({ CONSENT: env.CONSENT })) === null, 'storeKey efter forgetKey gav ikke null');
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (problems.length) {
|
|
223
|
+
console.error(problems.map((p) => `- ${p}`).join('\n'));
|
|
224
|
+
process.exit(1);
|
|
225
|
+
}
|
|
226
|
+
console.log('Workerens nøgle fra Forbind til Morpheme er i orden.');
|
package/scripts/check-store.mjs
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
// Checks the site's Worker's questions to the store (packages/editor/worker/store.js): /api/store/catalog and /api/store/files/<oid>,
|
|
2
|
-
// who may ask, what happens without a key,
|
|
3
|
-
//
|
|
2
|
+
// who may ask, what happens without a key, the key from the button (in D1, played by Node's SQLite) that is forgotten when
|
|
3
|
+
// the store refuses it, and the store's refusals and failures. GitHub and the store are played by a fake fetch that records
|
|
4
|
+
// each request; nothing goes to either.
|
|
4
5
|
// Run: npm run check:worker
|
|
5
6
|
import { APP_API, THEME_API } from '../lib/contracts.mjs';
|
|
7
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
6
8
|
import { workerModules } from './worker-modules.mjs';
|
|
7
9
|
|
|
8
|
-
const [{ handleStore, STORE_URL }] = await workerModules('store.js', 'session.js');
|
|
10
|
+
const [{ handleStore, STORE_URL }, , { storeKey }] = await workerModules('store.js', 'session.js', 'store-connection.js');
|
|
9
11
|
|
|
10
12
|
const problems = [];
|
|
11
13
|
const expect = (ok, what) => ok || problems.push(what);
|
|
@@ -25,6 +27,28 @@ function world({ repoAccess = { pull: true, push: false }, store = () => new Res
|
|
|
25
27
|
return { ask, storeCalls };
|
|
26
28
|
}
|
|
27
29
|
|
|
30
|
+
/** D1, as much of it as the Worker uses, on an SQLite database in memory. */
|
|
31
|
+
function d1() {
|
|
32
|
+
const db = new DatabaseSync(':memory:');
|
|
33
|
+
const statement = (sql, args = []) => ({
|
|
34
|
+
bind: (...values) => statement(sql, values),
|
|
35
|
+
run: async () => ({ meta: { changes: Number(db.prepare(sql).run(...args).changes) } }),
|
|
36
|
+
all: async () => ({ results: db.prepare(sql).all(...args) }),
|
|
37
|
+
first: async (column) => db.prepare(sql).get(...args)?.[column] ?? null,
|
|
38
|
+
});
|
|
39
|
+
return { db, prepare: (sql) => statement(sql), batch: async (list) => Promise.all(list.map((s) => s.run())) };
|
|
40
|
+
}
|
|
41
|
+
/** An env with a key from the button in its database (the table is made by storeKey, as on first use). */
|
|
42
|
+
async function envWithRow(key) {
|
|
43
|
+
const CONSENT = d1();
|
|
44
|
+
await storeKey({ CONSENT });
|
|
45
|
+
CONSENT.db
|
|
46
|
+
.prepare('INSERT INTO store_connection (site, key, connected) VALUES (?, ?, ?)')
|
|
47
|
+
.run(`site_${'a1'.repeat(12)}`, key, '2026-11-04T10:00:00.000Z');
|
|
48
|
+
return { CONSENT };
|
|
49
|
+
}
|
|
50
|
+
const rowCount = (env) => env.CONSENT.db.prepare('SELECT COUNT(*) AS n FROM store_connection').get().n;
|
|
51
|
+
|
|
28
52
|
let n = 0;
|
|
29
53
|
/** A request from the editor's page; a fresh token each time (the Worker remembers what GitHub said about a token). */
|
|
30
54
|
const request = (path, { signedIn = true, method = 'GET' } = {}) =>
|
|
@@ -145,6 +169,32 @@ for (const [status, error] of [
|
|
|
145
169
|
expect(!text.includes(KEY), 'svaret på en netværksfejl indeholdt nøglen');
|
|
146
170
|
}
|
|
147
171
|
|
|
172
|
+
// A key from the button: the store gets it as the bearer; when the store refuses it, the row goes, the catalog says
|
|
173
|
+
// "not connected" and a file is not found. A key set by hand that is refused stays a 502.
|
|
174
|
+
{
|
|
175
|
+
const w = world();
|
|
176
|
+
await call(w, 'catalog', await envWithRow('isk_row'));
|
|
177
|
+
expect(
|
|
178
|
+
w.storeCalls[0]?.headers.authorization === 'Bearer isk_row',
|
|
179
|
+
`kataloget blev spurgt med ${w.storeCalls[0]?.headers.authorization}`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
for (const status of [401, 403]) {
|
|
183
|
+
for (const path of ['catalog', `files/${OID}`]) {
|
|
184
|
+
const env = await envWithRow('isk_row');
|
|
185
|
+
const { res, text } = await call(world({ store: () => new Response('no', { status }) }), path, env);
|
|
186
|
+
const want = path === 'catalog' ? res.status === 200 && JSON.parse(text).connected === false : res.status === 404;
|
|
187
|
+
expect(want, `butikkens ${status} på ${path} med en nøgle fra knappen gav ${res.status} ${text}`);
|
|
188
|
+
expect(rowCount(env) === 0, `butikkens ${status} på ${path} lod nøglen fra knappen stå`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
{
|
|
192
|
+
const env = { ...(await envWithRow('isk_row')), MORPHEME_STORE_KEY: KEY };
|
|
193
|
+
const { res, text } = await call(world({ store: () => new Response('no', { status: 401 }) }), 'catalog', env);
|
|
194
|
+
expect(res.status === 502 && JSON.parse(text).error === 'store refused', `en nøgle i hånden, der blev afvist, gav ${res.status} ${text}`);
|
|
195
|
+
expect(rowCount(env) === 1, 'en nøgle i hånden, der blev afvist, slettede rækken');
|
|
196
|
+
}
|
|
197
|
+
|
|
148
198
|
// Other methods and addresses.
|
|
149
199
|
{
|
|
150
200
|
const w = world();
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
11
11
|
import { join, relative, resolve } from 'node:path';
|
|
12
12
|
import { chromium } from 'playwright';
|
|
13
|
+
import { mkdirSync } from 'node:fs';
|
|
14
|
+
import { tmpdir } from 'node:os';
|
|
13
15
|
import { openEditor, serveBuilt } from './built-site.mjs';
|
|
14
16
|
|
|
15
17
|
const ROOT = `${process.cwd()}/`;
|
|
@@ -92,6 +94,30 @@ for (const [width, height] of [
|
|
|
92
94
|
await page.close();
|
|
93
95
|
}
|
|
94
96
|
|
|
97
|
+
// "Appstore" in the three states of the site's key to the store (STORE_STATES): what each shows, and a picture of it in
|
|
98
|
+
// SCREENS_DIR (the temporary folder's morpheme-screens by default).
|
|
99
|
+
const shots = process.env.SCREENS_DIR || join(tmpdir(), 'morpheme-screens');
|
|
100
|
+
mkdirSync(shots, { recursive: true });
|
|
101
|
+
const keyStates = {
|
|
102
|
+
open: { shows: ['Hjemmesiden er ikke forbundet til Morpheme', 'Forbind til Morpheme'], hides: ['Afbryd forbindelsen til Morpheme'] },
|
|
103
|
+
readOnly: { shows: ['Hjemmesiden har ingen Appstore'], hides: ['Forbind til Morpheme', 'Afbryd forbindelsen til Morpheme'] },
|
|
104
|
+
button: { shows: ['Forbundet til Morpheme', 'site_sample', 'Afbryd forbindelsen til Morpheme'], hides: ['Forbind til Morpheme'] },
|
|
105
|
+
};
|
|
106
|
+
for (const [state, { shows, hides }] of Object.entries(keyStates)) {
|
|
107
|
+
const page = await browser.newPage({ viewport: { width: 390, height: 844 }, locale: 'da-DK' });
|
|
108
|
+
page.on('pageerror', (e) => problems.push(`Appstore (${state}): ${e.message}`));
|
|
109
|
+
await openEditor(page, site, {}, state);
|
|
110
|
+
await page.evaluate(() => (location.hash = 'indstillinger/appstore'));
|
|
111
|
+
await page.waitForTimeout(500);
|
|
112
|
+
const text = await page.evaluate(() => document.body.innerText);
|
|
113
|
+
for (const x of shows) if (!text.includes(x)) problems.push(`Appstore (${state}): "${x}" mangler`);
|
|
114
|
+
for (const x of hides) if (text.includes(x)) problems.push(`Appstore (${state}): "${x}" skal ikke vises`);
|
|
115
|
+
const file = join(shots, `appstore-${state}.png`);
|
|
116
|
+
await page.screenshot({ path: file });
|
|
117
|
+
console.log(`Appstore (${state}): ${file}`);
|
|
118
|
+
await page.close();
|
|
119
|
+
}
|
|
120
|
+
|
|
95
121
|
await browser.close();
|
|
96
122
|
server.close();
|
|
97
123
|
if (problems.length) {
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Tests that every .mjs module imported by TypeScript or Astro files in the package is included in tsconfig.types.json.
|
|
2
|
+
// This is required because TypeScript reads no types from JavaScript in node_modules, so a site's tsc fails without a .d.mts.
|
|
3
|
+
|
|
4
|
+
import assert from 'node:assert/strict';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import test from 'node:test';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
|
|
10
|
+
const PACKAGE = fileURLToPath(new URL('..', import.meta.url));
|
|
11
|
+
|
|
12
|
+
const tsconfigRaw = fs.readFileSync(path.join(PACKAGE, 'tsconfig.types.json'), 'utf-8');
|
|
13
|
+
const tsconfigJson = JSON.parse(
|
|
14
|
+
tsconfigRaw
|
|
15
|
+
.split('\n')
|
|
16
|
+
.filter((line) => !line.trimStart().startsWith('//'))
|
|
17
|
+
.join('\n'),
|
|
18
|
+
);
|
|
19
|
+
const include = tsconfigJson.include;
|
|
20
|
+
|
|
21
|
+
function matches(filePath, pattern) {
|
|
22
|
+
if (pattern.includes('**')) {
|
|
23
|
+
const dir = pattern.replace('/**/*.mjs', '');
|
|
24
|
+
return filePath.startsWith(`${dir}/`) && filePath.endsWith('.mjs');
|
|
25
|
+
}
|
|
26
|
+
return filePath === pattern;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function sources(dir) {
|
|
30
|
+
const results = [];
|
|
31
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
32
|
+
for (const entry of entries) {
|
|
33
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules') {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
const fullPath = path.join(dir, entry.name);
|
|
37
|
+
if (entry.isDirectory()) {
|
|
38
|
+
results.push(...sources(fullPath));
|
|
39
|
+
} else if (entry.name.endsWith('.ts') || entry.name.endsWith('.tsx') || entry.name.endsWith('.astro')) {
|
|
40
|
+
if (!entry.name.endsWith('.d.ts')) {
|
|
41
|
+
results.push(path.relative(PACKAGE, fullPath).replace(/\\/g, '/'));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return results;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function mjsImports(text) {
|
|
49
|
+
const regex = /(?:from\s+|import\s*\()\s*(['"])([^'"]+\.mjs)\1/g;
|
|
50
|
+
const specs = [];
|
|
51
|
+
let match;
|
|
52
|
+
while ((match = regex.exec(text)) !== null) {
|
|
53
|
+
specs.push(match[2]);
|
|
54
|
+
}
|
|
55
|
+
return specs;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function resolve(file, spec) {
|
|
59
|
+
if (spec.startsWith('@theme/')) {
|
|
60
|
+
return `theme/${spec.slice('@theme/'.length)}`;
|
|
61
|
+
}
|
|
62
|
+
if (spec.startsWith('.')) {
|
|
63
|
+
const dir = path.dirname(file);
|
|
64
|
+
return path.relative(PACKAGE, path.join(dir, spec)).replace(/\\/g, '/');
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
test('each .mjs a .ts, .tsx or .astro file imports is in tsconfig.types.json include', () => {
|
|
70
|
+
const missing = [];
|
|
71
|
+
const files = sources(PACKAGE);
|
|
72
|
+
for (const file of files) {
|
|
73
|
+
const content = fs.readFileSync(path.join(PACKAGE, file), 'utf-8');
|
|
74
|
+
const imports = mjsImports(content);
|
|
75
|
+
for (const spec of imports) {
|
|
76
|
+
const resolvedPath = resolve(file, spec);
|
|
77
|
+
if (resolvedPath !== null) {
|
|
78
|
+
const isMatched = include.some((pattern) => matches(resolvedPath, pattern));
|
|
79
|
+
if (!isMatched) {
|
|
80
|
+
missing.push({ file, spec, path: resolvedPath });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
assert.deepEqual(missing, []);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('matches', () => {
|
|
89
|
+
assert.equal(matches('theme/editor/looks.mjs', 'theme/**/*.mjs'), true);
|
|
90
|
+
assert.equal(matches('theme/editor/looks.mjs', 'lib/**/*.mjs'), false);
|
|
91
|
+
assert.equal(matches('scripts/releases.mjs', 'scripts/releases.mjs'), true);
|
|
92
|
+
assert.equal(matches('scripts/other.mjs', 'scripts/releases.mjs'), false);
|
|
93
|
+
});
|
package/site/theme.d.mts
CHANGED
|
@@ -30,6 +30,10 @@ export function motionProblems(): string[];
|
|
|
30
30
|
/** What is wrong with the system's icon packs (packProblems; a theme's own are its themeProblems) and the choice of one:
|
|
31
31
|
* src/data/icons.json naming one that is not there. */
|
|
32
32
|
export function iconPackProblems(): string[];
|
|
33
|
+
/** The module code of all Phosphor's icons in `folder` (its `regular/` folder), by the keys `import.meta.glob` gave, the
|
|
34
|
+
* theme's (`icons`, as themeIcons returns them) in place of Phosphor's own. The files are read one at a time: a glob
|
|
35
|
+
* opens them all at once, and the build image's limit of open files (1024) stops the build. */
|
|
36
|
+
export function allIconsModule(folder: any, icons: any): string;
|
|
33
37
|
/** What a theme is missing (the standard theme: of THEME_FILES and the look of every standard block, another: of
|
|
34
38
|
* OWN_FILES), what is wrong in its theme.json (name, version, author, icon), a block of its own (a folder in its
|
|
35
39
|
* blocks/ that is not the look of a standard block, or with a block's contract in it), a part nothing uses (in a
|
|
@@ -76,6 +80,8 @@ export const THEME_FILES: string[];
|
|
|
76
80
|
export function installedThemes(): string[];
|
|
77
81
|
/** What every theme has itself. */
|
|
78
82
|
export const OWN_FILES: string[];
|
|
83
|
+
/** The virtual module with all Phosphor's icons (regular) as text (src/allIcons.ts). */
|
|
84
|
+
export const ALL_ICONS: "virtual:all-icons";
|
|
79
85
|
export function aliases(): {
|
|
80
86
|
'@data': string;
|
|
81
87
|
'@saturn': string;
|
package/site/theme.mjs
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
// theme or the look of a block, is the standard theme's, styled by the theme in use's CSS. The site and the editor
|
|
5
5
|
// reach the theme as @theme (themePlugin: the theme in use's file, else the standard theme's), and the site's own
|
|
6
6
|
// parts (src/site/) as @site (aliases): for packages/editor/astro.mjs and packages/editor/scripts/check-content.mjs. Stops with what is
|
|
7
|
-
// wrong, when the theme named is not installed.
|
|
7
|
+
// wrong, when the theme named is not installed. Also the virtual module with all Phosphor's icons (allIconsModule).
|
|
8
8
|
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
9
|
-
import { relative } from 'node:path';
|
|
9
|
+
import { join, relative } from 'node:path';
|
|
10
10
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
11
11
|
import { svgParts } from '@morphemecms/cms/lib/svg.mjs';
|
|
12
12
|
import { declarationProblems, ownerId } from '@morphemecms/cms/lib/licenses.mjs';
|
|
@@ -268,13 +268,35 @@ function reactIcons(icons) {
|
|
|
268
268
|
return lines.join('\n');
|
|
269
269
|
}
|
|
270
270
|
|
|
271
|
-
/**
|
|
272
|
-
const
|
|
271
|
+
/** The text of an icon's SVG file as Phosphor's are (in the text's colour, `fill="currentColor"`), not JSON-encoded. */
|
|
272
|
+
const themedSvg = (svg) => {
|
|
273
273
|
const { viewBox, inner } = svgParts(svg);
|
|
274
274
|
const box = viewBox ? ` viewBox="${viewBox}"` : '';
|
|
275
|
-
return
|
|
275
|
+
return `<svg xmlns="http://www.w3.org/2000/svg"${box} fill="currentColor">${inner}</svg>`;
|
|
276
276
|
};
|
|
277
277
|
|
|
278
|
+
/** An icon's SVG file as Phosphor's are, for an import of Phosphor's. */
|
|
279
|
+
const fileIcon = (svg) => `export default ${JSON.stringify(themedSvg(svg))};`;
|
|
280
|
+
|
|
281
|
+
/** The virtual module with all Phosphor's icons (regular) as text (src/allIcons.ts). */
|
|
282
|
+
export const ALL_ICONS = 'virtual:all-icons';
|
|
283
|
+
|
|
284
|
+
/** The module code of all Phosphor's icons in `folder` (its `regular/` folder), by the keys `import.meta.glob` gave, the
|
|
285
|
+
* theme's (`icons`, as themeIcons returns them) in place of Phosphor's own. The files are read one at a time: a glob
|
|
286
|
+
* opens them all at once, and the build image's limit of open files (1024) stops the build. */
|
|
287
|
+
export function allIconsModule(folder, icons) {
|
|
288
|
+
const all = {};
|
|
289
|
+
for (const file of readdirSync(folder)
|
|
290
|
+
.filter((f) => f.endsWith('.svg'))
|
|
291
|
+
.sort()) {
|
|
292
|
+
const name = file.slice(0, -'.svg'.length);
|
|
293
|
+
all[`/node_modules/@phosphor-icons/core/assets/regular/${file}`] = svgParts(icons[name])
|
|
294
|
+
? themedSvg(icons[name])
|
|
295
|
+
: readFileSync(join(folder, file), 'utf8');
|
|
296
|
+
}
|
|
297
|
+
return `export const allIcons = ${JSON.stringify(all)};`;
|
|
298
|
+
}
|
|
299
|
+
|
|
278
300
|
/** What a theme is missing (the standard theme: of THEME_FILES and the look of every standard block, another: of
|
|
279
301
|
* OWN_FILES), what is wrong in its theme.json (name, version, author, icon), a block of its own (a folder in its
|
|
280
302
|
* blocks/ that is not the look of a standard block, or with a block's contract in it), a part nothing uses (in a
|
|
@@ -444,6 +466,7 @@ export function themePlugin() {
|
|
|
444
466
|
resolveId(source, importer) {
|
|
445
467
|
// The theme's icons import Phosphor's own.
|
|
446
468
|
if (source === PHOSPHOR_REACT) return own && importer !== REACT_ICONS ? REACT_ICONS : null;
|
|
469
|
+
if (source === ALL_ICONS) return '\0' + ALL_ICONS;
|
|
447
470
|
const icon = source.match(PHOSPHOR_FILE)?.[1];
|
|
448
471
|
if (icon) return svgParts(icons[icon]) ? FILE_ICON + icon : null;
|
|
449
472
|
if (source.startsWith('@site/')) return siteFile(source.slice('@site/'.length));
|
|
@@ -458,6 +481,8 @@ export function themePlugin() {
|
|
|
458
481
|
},
|
|
459
482
|
load(id) {
|
|
460
483
|
if (id === REACT_ICONS) return reactIcons(icons);
|
|
484
|
+
if (id === '\0' + ALL_ICONS)
|
|
485
|
+
return allIconsModule(fileURLToPath(new URL('node_modules/@phosphor-icons/core/assets/regular/', root)), icons);
|
|
461
486
|
if (id.startsWith(FILE_ICON)) return fileIcon(icons[id.slice(FILE_ICON.length)]);
|
|
462
487
|
return null;
|
|
463
488
|
},
|