@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
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Tests of allIconsModule (site/theme.mjs): all Phosphor's icons as one module, read one file at a time.
|
|
2
|
+
// Run with `node --test site/theme.test.mjs`.
|
|
3
|
+
import { strict as assert } from 'node:assert';
|
|
4
|
+
import { mkdtempSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { registerHooks } from 'node:module';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { test } from 'node:test';
|
|
9
|
+
|
|
10
|
+
// theme.mjs imports this package by its name, which only works from an installed copy: point the name at this folder.
|
|
11
|
+
const PACKAGE = '@morphemecms/cms/';
|
|
12
|
+
const pkg = new URL('../', import.meta.url);
|
|
13
|
+
registerHooks({
|
|
14
|
+
resolve: (specifier, context, next) =>
|
|
15
|
+
specifier.startsWith(PACKAGE) ? next(new URL(specifier.slice(PACKAGE.length), pkg).href, context) : next(specifier, context),
|
|
16
|
+
});
|
|
17
|
+
const { allIconsModule } = await import('./theme.mjs');
|
|
18
|
+
|
|
19
|
+
const PREFIX = '/node_modules/@phosphor-icons/core/assets/regular/';
|
|
20
|
+
const A = '<svg viewBox="0 0 1 1"><path d="A"/></svg>';
|
|
21
|
+
const B = '<svg viewBox="0 0 2 2"><path d="B"/></svg>';
|
|
22
|
+
|
|
23
|
+
const folder = mkdtempSync(join(tmpdir(), 'icons-'));
|
|
24
|
+
writeFileSync(join(folder, 'a.svg'), A);
|
|
25
|
+
writeFileSync(join(folder, 'b.svg'), B);
|
|
26
|
+
writeFileSync(join(folder, 'c.txt'), 'not an icon');
|
|
27
|
+
|
|
28
|
+
const parse = (code) => JSON.parse(code.slice('export const allIcons = '.length, -1));
|
|
29
|
+
|
|
30
|
+
test('the keys are the files, as import.meta.glob gave them', () => {
|
|
31
|
+
assert.deepEqual(Object.keys(parse(allIconsModule(folder, {}))), [`${PREFIX}a.svg`, `${PREFIX}b.svg`]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("Phosphor's icons are the file text, unchanged", () => {
|
|
35
|
+
assert.deepEqual(parse(allIconsModule(folder, {})), { [`${PREFIX}a.svg`]: A, [`${PREFIX}b.svg`]: B });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("a theme's icon is in Phosphor's place", () => {
|
|
39
|
+
const themed = '<svg viewBox="0 0 10 10"><path d="M0 0"/></svg>';
|
|
40
|
+
const all = parse(allIconsModule(folder, { a: themed }));
|
|
41
|
+
assert.equal(
|
|
42
|
+
all[`${PREFIX}a.svg`],
|
|
43
|
+
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10" fill="currentColor"><path d="M0 0"/></svg>',
|
|
44
|
+
);
|
|
45
|
+
assert.equal(all[`${PREFIX}b.svg`], B);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('a file that is not an svg has no key', () => {
|
|
49
|
+
assert.ok(!Object.keys(parse(allIconsModule(folder, {}))).some((k) => k.endsWith('c.txt')));
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('the code is a module that exports allIcons', () => {
|
|
53
|
+
assert.ok(allIconsModule(folder, {}).startsWith('export const allIcons = '));
|
|
54
|
+
});
|
package/src/AppList.tsx
CHANGED
|
@@ -98,6 +98,9 @@ import { config } from '@site/editor';
|
|
|
98
98
|
import { allThemes, ThemeRow, themeInstalled, useThemeCatalog } from './ThemeStore';
|
|
99
99
|
import { siteT, siteText, t } from './i18n';
|
|
100
100
|
import { OwnerLicenses, StoreLicenseRows } from './Licenses';
|
|
101
|
+
import { StoreConnectNotice } from './StoreConnectNotice';
|
|
102
|
+
import { StoreConnectedRow } from './StoreConnectedRow';
|
|
103
|
+
import { connection, type StoreConnection } from './storeConnection';
|
|
101
104
|
import { appType, setOn, told, useNotificationChoices } from './notificationData';
|
|
102
105
|
import { kindAndName } from './deploy';
|
|
103
106
|
|
|
@@ -594,8 +597,18 @@ export function DeveloperRow({ name }: { name: string }) {
|
|
|
594
597
|
* and those installed, each with its version and who made it; each opening its page. With search (by name, what it
|
|
595
598
|
* does, who made it and the words it is found by, in both languages), the order and the filters by category (apps)
|
|
596
599
|
* and by whether it is installed, each picked one as a chip. */
|
|
597
|
-
export function StorePage(
|
|
600
|
+
export function StorePage(props: { env: Env; files: RepoFile[]; tab: 'apps' | 'themes' }) {
|
|
601
|
+
// Connecting or disconnecting draws the screen again from the start, so the catalogs and the key are asked again.
|
|
602
|
+
const [round, setRound] = useState(0);
|
|
603
|
+
return <StoreScreen key={round} {...props} again={() => setRound((r) => r + 1)} />;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function StoreScreen({ env, files, tab, again }: { env: Env; files: RepoFile[]; tab: 'apps' | 'themes'; again: () => void }) {
|
|
598
607
|
const installed = installedApps(files);
|
|
608
|
+
const [key, setKey] = useState<StoreConnection | null>(null);
|
|
609
|
+
useEffect(() => {
|
|
610
|
+
connection().then(setKey, () => {});
|
|
611
|
+
}, []);
|
|
599
612
|
const catalog = useCatalog(env);
|
|
600
613
|
const themeCatalog = useThemeCatalog(env);
|
|
601
614
|
const connected = useStoreConnected(env);
|
|
@@ -689,7 +702,7 @@ export function StorePage({ env, files, tab }: { env: Env; files: RepoFile[]; ta
|
|
|
689
702
|
)}
|
|
690
703
|
{/* The site's own are shown also when the store cannot be read. */}
|
|
691
704
|
{connected === false ? (
|
|
692
|
-
<
|
|
705
|
+
<StoreConnectNotice mayChange={key?.mayChange ?? false} onConnected={again} />
|
|
693
706
|
) : read === null || connected === null ? (
|
|
694
707
|
<EmptyNote busy>{t.apps.loadingStore}</EmptyNote>
|
|
695
708
|
) : read === 'failed' ? (
|
|
@@ -714,6 +727,7 @@ export function StorePage({ env, files, tab }: { env: Env; files: RepoFile[]; ta
|
|
|
714
727
|
</Group>
|
|
715
728
|
))
|
|
716
729
|
)}
|
|
730
|
+
{key?.connected && key.by === 'button' && key.mayChange && <StoreConnectedRow site={key.site} onDisconnected={again} />}
|
|
717
731
|
</Screen>
|
|
718
732
|
);
|
|
719
733
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// The CMS's version as plain text on the start screen's status line; a pre-release's short form swaps to the full version on a click.
|
|
2
|
+
// Exposes CmsVersion. Depends on cmsVersionView.mjs (shortVersion), SwapText and package.json's version.
|
|
3
|
+
import { version } from '../package.json';
|
|
4
|
+
import { shortVersion } from './cmsVersionView.mjs';
|
|
5
|
+
import { SwapText } from './SwapText';
|
|
6
|
+
import { t } from './i18n';
|
|
7
|
+
|
|
8
|
+
// A release shows its version as it is: plain text. A pre-release shows it short; clicking swaps it to the full version, with
|
|
9
|
+
// the build stamp, in place. `title` shows the full version on mouse hover. No tooltip: the phone's status line clips overflow.
|
|
10
|
+
export function CmsVersion() {
|
|
11
|
+
const short = shortVersion(version) as string;
|
|
12
|
+
if (short === version) return <>{t.library.cmsVersion(version)}</>;
|
|
13
|
+
return <SwapText text={t.library.cmsVersion(short)} swapped={t.library.cmsVersion(version)} title={version} />;
|
|
14
|
+
}
|
package/src/Library.tsx
CHANGED
|
@@ -33,6 +33,7 @@ import { byText, entryData, type Collection, type Part, type PartScreen } from '
|
|
|
33
33
|
import { config, countsOf, header, relationsOf, siteName } from '@site/editor';
|
|
34
34
|
import { iconPack, iconPacks } from './iconPacks';
|
|
35
35
|
import { theme, themes, type ThemeInfo } from './themes';
|
|
36
|
+
import { StartStatus } from './StartStatus';
|
|
36
37
|
import {
|
|
37
38
|
appearance,
|
|
38
39
|
backFrom,
|
|
@@ -155,14 +156,14 @@ function PartRow({ id, label, href, count }: { id: string } & ReturnType<typeof
|
|
|
155
156
|
return <ListItem href={href} icon={icons[id]} headline={label} count={count} arrow />;
|
|
156
157
|
}
|
|
157
158
|
|
|
158
|
-
// The start screen, with when the site was last updated (
|
|
159
|
+
// The start screen, with the CMS version and when the site was last updated (StartStatus.tsx) under the title;
|
|
159
160
|
// on a computer the groups two side by side.
|
|
160
161
|
export function Start({ files, builtAt }: { files: RepoFile[]; builtAt: Date | null }) {
|
|
161
162
|
return (
|
|
162
163
|
<Screen
|
|
163
164
|
title={siteName}
|
|
164
165
|
wide
|
|
165
|
-
status={
|
|
166
|
+
status={<StartStatus builtAt={builtAt} />}
|
|
166
167
|
// The notifications, the settings and the account in the top bar, on a phone too (Screen.tsx).
|
|
167
168
|
home
|
|
168
169
|
>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// The start screen's status line: from expanded up the CMS version, a separator and the update time; below it (a phone) only the
|
|
2
|
+
// update time, which a tap swaps for the short version. Exposes StartStatus. Depends on CmsVersion, SwapText, when and i18n.
|
|
3
|
+
import { CmsVersion } from './CmsVersion';
|
|
4
|
+
import { SwapText } from './SwapText';
|
|
5
|
+
import { shortVersion } from './cmsVersionView.mjs';
|
|
6
|
+
import { version } from '../package.json';
|
|
7
|
+
import { t } from './i18n';
|
|
8
|
+
import { when } from './when';
|
|
9
|
+
|
|
10
|
+
// Screen.tsx draws the status twice, but only one place shows at a width; both forms are here, switched with the M3 breakpoint
|
|
11
|
+
// classes (`expanded:`), never a width of their own.
|
|
12
|
+
export function StartStatus({ builtAt }: { builtAt: Date | null }) {
|
|
13
|
+
const cms = t.library.cmsVersion(shortVersion(version) as string);
|
|
14
|
+
const updated = builtAt ? t.library.siteUpdated(when(builtAt, { short: true })) : null;
|
|
15
|
+
return (
|
|
16
|
+
<>
|
|
17
|
+
<span className="expanded:inline hidden">
|
|
18
|
+
<CmsVersion />
|
|
19
|
+
{updated && `${t.library.statusSeparator}${updated}`}
|
|
20
|
+
</span>
|
|
21
|
+
<span className="expanded:hidden">{updated ? <SwapText text={updated} swapped={cms} /> : cms}</span>
|
|
22
|
+
</>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// The box in "Appstore" for a site without a key to the store: the button "Connect to Morpheme", or a note for editors who may only read.
|
|
2
|
+
// Exposes StoreConnectNotice({ mayChange, onConnected }); uses Notice, Button, EmptyNote (ui.tsx), snackbar (lib/snackbar.ts) and connect (storeConnection.ts).
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { snackbar } from '../lib/snackbar';
|
|
5
|
+
import { connect } from './storeConnection';
|
|
6
|
+
import { Button, EmptyNote, Notice } from './ui';
|
|
7
|
+
import { t } from './i18n';
|
|
8
|
+
|
|
9
|
+
export function StoreConnectNotice({ mayChange, onConnected }: { mayChange: boolean; onConnected: () => void }) {
|
|
10
|
+
const [busy, setBusy] = useState(false);
|
|
11
|
+
if (!mayChange) return <EmptyNote>{t.apps.noStore}</EmptyNote>;
|
|
12
|
+
async function connectNow() {
|
|
13
|
+
setBusy(true);
|
|
14
|
+
try {
|
|
15
|
+
await connect();
|
|
16
|
+
onConnected();
|
|
17
|
+
} catch (e) {
|
|
18
|
+
snackbar({ text: (e as Error).message, error: true });
|
|
19
|
+
setBusy(false);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return (
|
|
23
|
+
<Notice
|
|
24
|
+
title={t.apps.connectTitle}
|
|
25
|
+
action={
|
|
26
|
+
<Button disabled={busy} onClick={connectNow}>
|
|
27
|
+
{t.apps.connect}
|
|
28
|
+
</Button>
|
|
29
|
+
}
|
|
30
|
+
>
|
|
31
|
+
{t.apps.connectText}
|
|
32
|
+
</Notice>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// The row at the bottom of "Appstore" that says the site is connected to Morpheme, with the button "Disconnect from Morpheme" (asks first).
|
|
2
|
+
// Exposes StoreConnectedRow({ site, onDisconnected }); uses Group, ListItem, Button (ui.tsx), ask (ask.tsx), snackbar (lib/snackbar.ts) and disconnect (storeConnection.ts).
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { snackbar } from '../lib/snackbar';
|
|
5
|
+
import { ask } from './ask';
|
|
6
|
+
import { disconnect } from './storeConnection';
|
|
7
|
+
import { Button, Group, ListItem } from './ui';
|
|
8
|
+
import { t } from './i18n';
|
|
9
|
+
|
|
10
|
+
export function StoreConnectedRow({ site, onDisconnected }: { site: string; onDisconnected: () => void }) {
|
|
11
|
+
const [busy, setBusy] = useState(false);
|
|
12
|
+
async function disconnectNow() {
|
|
13
|
+
if (!(await ask(t.apps.disconnectAsk, t.apps.disconnect))) return;
|
|
14
|
+
setBusy(true);
|
|
15
|
+
try {
|
|
16
|
+
await disconnect();
|
|
17
|
+
onDisconnected();
|
|
18
|
+
} catch (e) {
|
|
19
|
+
snackbar({ text: (e as Error).message, error: true });
|
|
20
|
+
setBusy(false);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return (
|
|
24
|
+
<Group>
|
|
25
|
+
<ListItem
|
|
26
|
+
headline={t.apps.connected}
|
|
27
|
+
supporting={site}
|
|
28
|
+
actions={
|
|
29
|
+
<Button disabled={busy} onClick={disconnectNow}>
|
|
30
|
+
{t.apps.disconnect}
|
|
31
|
+
</Button>
|
|
32
|
+
}
|
|
33
|
+
/>
|
|
34
|
+
</Group>
|
|
35
|
+
);
|
|
36
|
+
}
|
package/src/SwapText.tsx
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// A focusable span that swaps its text for another on a click or Enter/Space, and back (no link or button look: it inherits the text).
|
|
2
|
+
// Exposes SwapText. Depends on nothing.
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
|
|
5
|
+
export function SwapText({ text, swapped, title }: { text: string; swapped: string; title?: string }) {
|
|
6
|
+
const [on, setOn] = useState(false);
|
|
7
|
+
return (
|
|
8
|
+
<span
|
|
9
|
+
tabIndex={0}
|
|
10
|
+
title={title}
|
|
11
|
+
onClick={() => setOn((was) => !was)}
|
|
12
|
+
onKeyDown={(e) => {
|
|
13
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
14
|
+
e.preventDefault();
|
|
15
|
+
setOn((was) => !was);
|
|
16
|
+
}
|
|
17
|
+
}}
|
|
18
|
+
>
|
|
19
|
+
{on ? swapped : text}
|
|
20
|
+
</span>
|
|
21
|
+
);
|
|
22
|
+
}
|
package/src/allIcons.ts
CHANGED
|
@@ -1,7 +1,4 @@
|
|
|
1
1
|
// All Phosphor's icons (regular) as text, by their file, for AnyIcon (icon.tsx): loaded only when it draws one the site
|
|
2
|
-
// does not (an app's in the app store). The theme's are in their place
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
import: 'default',
|
|
6
|
-
eager: true,
|
|
7
|
-
});
|
|
2
|
+
// does not (an app's in the app store). The theme's are in their place. One virtual module that themePlugin makes
|
|
3
|
+
// (allIconsModule, packages/editor/site/theme.mjs), so the build does not open 1500 files at once.
|
|
4
|
+
export { allIcons } from 'virtual:all-icons';
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provides a short form of the CMS version for the editor's start screen.
|
|
3
|
+
* Exposes the `shortVersion` function.
|
|
4
|
+
* Depends on nothing.
|
|
5
|
+
*/
|
|
6
|
+
/** The CMS version as the start screen shows it: a pre-release without its build stamp, else the version as it is. */
|
|
7
|
+
export function shortVersion(version: any): any;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provides a short form of the CMS version for the editor's start screen.
|
|
3
|
+
* Exposes the `shortVersion` function.
|
|
4
|
+
* Depends on nothing.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** The CMS version as the start screen shows it: a pre-release without its build stamp, else the version as it is. */
|
|
8
|
+
export function shortVersion(version) {
|
|
9
|
+
const match = /^(\d+\.\d+\.\d+-[a-z]+)-\d+$/.exec(version);
|
|
10
|
+
return match ? match[1] : version;
|
|
11
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the CMS version short form logic.
|
|
3
|
+
* Exposes test cases for `shortVersion`.
|
|
4
|
+
* Depends on `node:test`, `node:assert/strict`, and `./cmsVersionView.mjs`.
|
|
5
|
+
*/
|
|
6
|
+
import { test } from 'node:test';
|
|
7
|
+
import * as assert from 'node:assert/strict';
|
|
8
|
+
import { shortVersion } from './cmsVersionView.mjs';
|
|
9
|
+
|
|
10
|
+
test('strips build stamp from pre-release versions', () => {
|
|
11
|
+
assert.equal(shortVersion('0.5.0-next-20261104200208'), '0.5.0-next');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('strips build stamp from beta versions', () => {
|
|
15
|
+
assert.equal(shortVersion('1.2.3-beta-7'), '1.2.3-beta');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('keeps stable versions unchanged', () => {
|
|
19
|
+
assert.equal(shortVersion('0.5.0'), '0.5.0');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('keeps pre-release versions without build stamp unchanged', () => {
|
|
23
|
+
assert.equal(shortVersion('0.5.0-next'), '0.5.0-next');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('keeps versions with non-numeric build metadata unchanged', () => {
|
|
27
|
+
assert.equal(shortVersion('0.5.0-rc.1'), '0.5.0-rc.1');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('handles empty string', () => {
|
|
31
|
+
assert.equal(shortVersion(''), '');
|
|
32
|
+
});
|
package/src/i18n/da-DK/apps.ts
CHANGED
|
@@ -53,6 +53,13 @@ export const apps: Messages['apps'] = {
|
|
|
53
53
|
loadingStore: 'Henter apps',
|
|
54
54
|
storeFailed: 'Kunne ikke hente Appstore.',
|
|
55
55
|
noStore: 'Sitet har ingen Appstore.',
|
|
56
|
+
connectTitle: 'Hjemmesiden er ikke forbundet til Morpheme',
|
|
57
|
+
connectText: 'Forbind den for at installere apps og temaer fra Appstore.',
|
|
58
|
+
connect: 'Forbind til Morpheme',
|
|
59
|
+
connected: 'Forbundet til Morpheme',
|
|
60
|
+
disconnect: 'Afbryd forbindelsen til Morpheme',
|
|
61
|
+
disconnectAsk: 'Afbryd hjemmesidens forbindelse til Morpheme? Installerede apps og temaer bliver.',
|
|
62
|
+
connectBusy: 'Mange hjemmesider forbinder lige nu. Prøv igen om en time.',
|
|
56
63
|
storeBroken: (app) => `${app} kunne ikke hentes rigtigt fra Appstore.`,
|
|
57
64
|
notInStore: 'Appen findes ikke i Appstore.',
|
|
58
65
|
categoryFilter: 'Kategori',
|
|
@@ -2,6 +2,8 @@ import type { Messages } from '../en-US';
|
|
|
2
2
|
|
|
3
3
|
export const library: Messages['library'] = {
|
|
4
4
|
siteUpdated: (when) => `Opdateret ${when}`,
|
|
5
|
+
cmsVersion: (version) => `Morpheme ${version}`,
|
|
6
|
+
statusSeparator: ' · ',
|
|
5
7
|
githubAccount: (login) => `GitHub-konto: ${login}`,
|
|
6
8
|
logOut: 'Log ud',
|
|
7
9
|
notifications: 'Notifikationer',
|
package/src/i18n/en-US/apps.ts
CHANGED
|
@@ -80,6 +80,14 @@ export const apps = {
|
|
|
80
80
|
storeFailed: 'Couldn’t load the Appstore',
|
|
81
81
|
/** On "Appstore" when the site's Worker has no store key: only the site's own are there. */
|
|
82
82
|
noStore: 'The site has no Appstore',
|
|
83
|
+
/** On "Appstore": the box that connects the site to the Morpheme store, the row that disconnects it, and the answer at the store's hourly limit. */
|
|
84
|
+
connectTitle: 'The website is not connected to Morpheme',
|
|
85
|
+
connectText: 'Connect it to install apps and themes from the Appstore.',
|
|
86
|
+
connect: 'Connect to Morpheme',
|
|
87
|
+
connected: 'Connected to Morpheme',
|
|
88
|
+
disconnect: 'Disconnect from Morpheme',
|
|
89
|
+
disconnectAsk: 'Disconnect the website from Morpheme? Installed apps and themes stay.',
|
|
90
|
+
connectBusy: 'Many websites are connecting right now. Try again in an hour.',
|
|
83
91
|
/** An app's files did not come from the store as its catalog says. */
|
|
84
92
|
storeBroken: (app: string) => `${app} couldn’t be loaded correctly from the Appstore`,
|
|
85
93
|
/** At an address of an app the store does not have. */
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
// with choices (Sheet.tsx).
|
|
3
3
|
export const library = {
|
|
4
4
|
siteUpdated: (when: string) => `Updated ${when}`,
|
|
5
|
+
/** The CMS's name and version on the start screen (CmsVersion.tsx), before the update time. */
|
|
6
|
+
cmsVersion: (version: string) => `Morpheme ${version}`,
|
|
7
|
+
/** Between the CMS version and the update time in the start screen's status. */
|
|
8
|
+
statusSeparator: ' · ',
|
|
5
9
|
githubAccount: (login: string) => `GitHub account: ${login}`,
|
|
6
10
|
logOut: 'Log out',
|
|
7
11
|
/** The bell in the top bar and its screen (#notifikationer, Notifications.tsx): the messages shown at the bottom. */
|
package/src/store.ts
CHANGED
|
@@ -310,6 +310,11 @@ function catalogText(): Promise<string> {
|
|
|
310
310
|
return catalog;
|
|
311
311
|
}
|
|
312
312
|
|
|
313
|
+
/** Makes the catalog be read again, the next time it is asked for (after the site is connected or disconnected). */
|
|
314
|
+
export function forgetCatalog(): void {
|
|
315
|
+
catalog = null;
|
|
316
|
+
}
|
|
317
|
+
|
|
313
318
|
/** Whether the site's Worker has a store key; without one, "Appstore" has only the site's own apps and themes. Fails when
|
|
314
319
|
* the catalog cannot be read. */
|
|
315
320
|
export const storeConnected = (): Promise<boolean> =>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// The site's connection to the Morpheme store, from the editor: connection() reads it, connect() and disconnect() change it.
|
|
2
|
+
// Exposes connection, connect, disconnect and StoreConnection; used by StoreConnectNotice.tsx, StoreConnectedRow.tsx and AppList.tsx.
|
|
3
|
+
// Depends on storeConnectionCall.ts (the request), store.ts (forgetCatalog) and i18n (the texts of a failure).
|
|
4
|
+
import { callConnection, type ConnectionDeps, type StoreConnection } from './storeConnectionCall';
|
|
5
|
+
import { forgetCatalog } from './store';
|
|
6
|
+
import { t } from './i18n';
|
|
7
|
+
|
|
8
|
+
export type { StoreConnection };
|
|
9
|
+
|
|
10
|
+
const deps = (): ConnectionDeps => ({
|
|
11
|
+
fetch: (url, init) => fetch(url, init),
|
|
12
|
+
forgetCatalog,
|
|
13
|
+
busy: t.apps.connectBusy,
|
|
14
|
+
failed: t.apps.storeFailed,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
/** Whether the site has a key to the store, and who may change that. */
|
|
18
|
+
export const connection = () => callConnection('GET', deps());
|
|
19
|
+
|
|
20
|
+
/** Asks the store for a key for this site. */
|
|
21
|
+
export const connect = () => callConnection('POST', deps());
|
|
22
|
+
|
|
23
|
+
/** Tells the store to drop the site's key. */
|
|
24
|
+
export const disconnect = () => callConnection('DELETE', deps());
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Tests for src/storeConnectionCall.ts: the request for "Connect to Morpheme" and the text of a failure.
|
|
2
|
+
// Run: node --test packages/editor/src/storeConnectionCall.test.mjs
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import { test } from 'node:test';
|
|
5
|
+
import { callConnection } from './storeConnectionCall.ts';
|
|
6
|
+
|
|
7
|
+
const make = (answer) => {
|
|
8
|
+
const calls = { fetched: [], forgot: 0 };
|
|
9
|
+
const deps = {
|
|
10
|
+
fetch: async (url, init) => {
|
|
11
|
+
calls.fetched.push([url, init.method]);
|
|
12
|
+
if (answer instanceof Error) throw answer;
|
|
13
|
+
return answer;
|
|
14
|
+
},
|
|
15
|
+
forgetCatalog: () => calls.forgot++,
|
|
16
|
+
busy: 'BUSY',
|
|
17
|
+
failed: 'FAILED',
|
|
18
|
+
};
|
|
19
|
+
return { deps, calls };
|
|
20
|
+
};
|
|
21
|
+
const ok = (body) => ({ ok: true, status: 200, json: async () => body });
|
|
22
|
+
|
|
23
|
+
test('a read gives the answer and keeps the catalog', async () => {
|
|
24
|
+
const { deps, calls } = make(ok({ connected: false, mayChange: true }));
|
|
25
|
+
assert.deepEqual(await callConnection('GET', deps), { connected: false, mayChange: true });
|
|
26
|
+
assert.deepEqual(calls.fetched, [['/api/store/connection', 'GET']]);
|
|
27
|
+
assert.equal(calls.forgot, 0);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('connect and disconnect make the catalog be read again', async () => {
|
|
31
|
+
for (const method of ['POST', 'DELETE']) {
|
|
32
|
+
const { deps, calls } = make(ok({ connected: false, mayChange: true }));
|
|
33
|
+
await callConnection(method, deps);
|
|
34
|
+
assert.equal(calls.fetched[0][1], method);
|
|
35
|
+
assert.equal(calls.forgot, 1);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('429 says the store is busy', async () => {
|
|
40
|
+
const { deps, calls } = make({ ok: false, status: 429, json: async () => ({}) });
|
|
41
|
+
await assert.rejects(callConnection('POST', deps), { message: 'BUSY' });
|
|
42
|
+
assert.equal(calls.forgot, 0);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const failures = [
|
|
46
|
+
{ ok: false, status: 502, json: async () => ({}) },
|
|
47
|
+
new Error('offline'),
|
|
48
|
+
{ ok: true, status: 200, json: async () => Promise.reject(new Error('not json')) },
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
test('any other failure says the store failed', async () => {
|
|
52
|
+
for (const answer of failures) {
|
|
53
|
+
const { deps, calls } = make(answer);
|
|
54
|
+
await assert.rejects(callConnection('DELETE', deps), { message: 'FAILED' });
|
|
55
|
+
assert.equal(calls.forgot, 0);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// The request to the site's Worker for "Connect to Morpheme" (/api/store/connection): GET, POST, DELETE, and what a failure says.
|
|
2
|
+
// Exposes StoreConnection and callConnection(method, deps); no imports of the editor, so node --test runs it (storeConnectionCall.test.mjs).
|
|
3
|
+
// Used by storeConnection.ts, which gives it the real fetch, forgetCatalog and texts.
|
|
4
|
+
|
|
5
|
+
/** What the Worker says about the site's key to the store (packages/editor/worker/store-connection.js). */
|
|
6
|
+
export type StoreConnection =
|
|
7
|
+
| { connected: false; mayChange: boolean }
|
|
8
|
+
| { connected: true; by: 'hand'; mayChange: boolean }
|
|
9
|
+
| { connected: true; by: 'button'; site: string; since: string; mayChange: boolean };
|
|
10
|
+
|
|
11
|
+
export type ConnectionDeps = {
|
|
12
|
+
fetch: (url: string, init: { method: string }) => Promise<{ ok: boolean; status: number; json: () => Promise<unknown> }>;
|
|
13
|
+
/** Asks the store's catalog again (store.ts forgetCatalog). */
|
|
14
|
+
forgetCatalog: () => void;
|
|
15
|
+
/** The texts of a failure: the store's hourly limit (429) and any other. */
|
|
16
|
+
busy: string;
|
|
17
|
+
failed: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** Sends one request. A failure throws an Error with the text for it; POST and DELETE that succeed make the catalog be read again. */
|
|
21
|
+
export async function callConnection(method: 'GET' | 'POST' | 'DELETE', deps: ConnectionDeps): Promise<StoreConnection> {
|
|
22
|
+
let res;
|
|
23
|
+
try {
|
|
24
|
+
res = await deps.fetch('/api/store/connection', { method });
|
|
25
|
+
} catch {
|
|
26
|
+
throw new Error(deps.failed);
|
|
27
|
+
}
|
|
28
|
+
if (!res.ok) throw new Error(res.status === 429 ? deps.busy : deps.failed);
|
|
29
|
+
let found: StoreConnection;
|
|
30
|
+
try {
|
|
31
|
+
found = (await res.json()) as StoreConnection;
|
|
32
|
+
} catch {
|
|
33
|
+
throw new Error(deps.failed);
|
|
34
|
+
}
|
|
35
|
+
if (method !== 'GET') deps.forgetCatalog();
|
|
36
|
+
return found;
|
|
37
|
+
}
|
package/src/virtual.d.ts
CHANGED
|
@@ -3,3 +3,8 @@ declare module 'virtual:app-folders' {
|
|
|
3
3
|
const folders: Record<string, { files: string[]; csp?: Partial<Record<string, string[]>> }>;
|
|
4
4
|
export default folders;
|
|
5
5
|
}
|
|
6
|
+
|
|
7
|
+
// All Phosphor's icons (regular) as text, by their file (allIconsModule in site/theme.mjs).
|
|
8
|
+
declare module 'virtual:all-icons' {
|
|
9
|
+
export const allIcons: Record<string, string>;
|
|
10
|
+
}
|
package/starter/package.json
CHANGED
package/worker/index.js
CHANGED
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
// (packages/editor/worker/session.js), and what the editor asks of GitHub with it (/api/github/*, packages/editor/worker/editor-github.js), the site's
|
|
15
15
|
// files through the cache in R2 (packages/editor/worker/files.js).
|
|
16
16
|
// - what the editor asks of the app store (/api/store/catalog and /api/store/files/<oid>, packages/editor/worker/store.js), with the
|
|
17
|
-
// site's key (MORPHEME_STORE_KEY
|
|
17
|
+
// site's key (MORPHEME_STORE_KEY, or the one from "Connect to Morpheme", /api/store/connection, packages/editor/worker/store-connection.js, in D1)
|
|
18
|
+
// that never leaves the Worker.
|
|
18
19
|
// - the addresses visitors came to that do not exist, for the editor (/api/not-found, packages/editor/worker/not-found.js).
|
|
19
20
|
// - the visitors' answers in the cookie banner, the consent log (/api/consent, packages/editor/worker/consent.js, in D1), and each hour
|
|
20
21
|
// those older than two years deleted.
|
|
@@ -36,6 +37,7 @@ import { handleNotFound, isVisit, json, recordNotFound } from './not-found.js';
|
|
|
36
37
|
import { forgetOldConsents, handleConsent } from './consent.js';
|
|
37
38
|
import { access, mayEdit } from './session.js';
|
|
38
39
|
import { handleGitHub } from './editor-github.js';
|
|
40
|
+
import { handleConnection } from './store-connection.js';
|
|
39
41
|
import { handleStore } from './store.js';
|
|
40
42
|
import { handleR2 } from './r2-status.js';
|
|
41
43
|
import { publishScheduled, SCHEDULED_LIST } from './publish.js';
|
|
@@ -149,6 +151,7 @@ export default {
|
|
|
149
151
|
if (url.pathname === '/api/consent') return handleConsent(request, env);
|
|
150
152
|
if (url.pathname === '/api/r2') return handleR2(request, env);
|
|
151
153
|
if (url.pathname.startsWith('/api/github/')) return handleGitHub(request, env, ctx);
|
|
154
|
+
if (url.pathname === '/api/store/connection') return handleConnection(request, env, ctx);
|
|
152
155
|
if (url.pathname.startsWith('/api/store/')) return handleStore(request, env, ctx);
|
|
153
156
|
if (/^\/api\/push(\/wait)?\/?$/.test(url.pathname)) return handlePush(request, env);
|
|
154
157
|
if (/^\/api\/cms(\/update)?\/?$/.test(url.pathname)) return handleCms(request, env, ctx);
|