@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.
Files changed (49) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/bin/morpheme.mjs +2 -2
  3. package/lib/licenses.mjs +1 -1
  4. package/package.json +6 -1
  5. package/scripts/built-site.mjs +24 -7
  6. package/scripts/check-store-connection.mjs +226 -0
  7. package/scripts/check-store.mjs +53 -3
  8. package/scripts/editor-screens.mjs +26 -0
  9. package/scripts/types-include.test.mjs +93 -0
  10. package/site/theme.d.mts +6 -0
  11. package/site/theme.mjs +30 -5
  12. package/site/theme.test.mjs +54 -0
  13. package/src/AppList.tsx +16 -2
  14. package/src/CmsVersion.tsx +14 -0
  15. package/src/Library.tsx +3 -2
  16. package/src/StartStatus.tsx +24 -0
  17. package/src/StoreConnectNotice.tsx +34 -0
  18. package/src/StoreConnectedRow.tsx +36 -0
  19. package/src/SwapText.tsx +22 -0
  20. package/src/allIcons.ts +3 -6
  21. package/src/cmsVersionView.d.mts +7 -0
  22. package/src/cmsVersionView.mjs +11 -0
  23. package/src/cmsVersionView.test.mjs +32 -0
  24. package/src/i18n/da-DK/apps.ts +7 -0
  25. package/src/i18n/da-DK/library.ts +2 -0
  26. package/src/i18n/en-US/apps.ts +8 -0
  27. package/src/i18n/en-US/library.ts +4 -0
  28. package/src/store.ts +5 -0
  29. package/src/storeConnection.ts +24 -0
  30. package/src/storeConnectionCall.test.mjs +57 -0
  31. package/src/storeConnectionCall.ts +37 -0
  32. package/src/virtual.d.ts +5 -0
  33. package/starter/package.json +5 -0
  34. package/theme/editor/looks.d.mts +2 -0
  35. package/worker/index.js +4 -1
  36. package/worker/store-connection.js +106 -0
  37. package/worker/store.js +14 -6
  38. package/lib/apps.test.d.mts +0 -1
  39. package/lib/caseCollisions.test.d.mts +0 -1
  40. package/lib/changelog.test.d.mts +0 -1
  41. package/lib/cms-version.test.d.mts +0 -1
  42. package/lib/lockfile.test.d.mts +0 -1
  43. package/lib/range.test.d.mts +0 -1
  44. package/lib/side-block.test.d.mts +0 -1
  45. package/lib/storeEntries.test.d.mts +0 -1
  46. package/site/storeTheme.test.d.mts +0 -1
  47. package/src/cmsView.test.d.mts +0 -1
  48. package/src/frontmatter.test.d.mts +0 -1
  49. 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({ env, files, tab }: { env: Env; files: RepoFile[]; tab: 'apps' | 'themes' }) {
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
- <EmptyNote>{t.apps.noStore}</EmptyNote>
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 (built on Cloudflare, App.tsx) under the title;
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={builtAt ? t.library.siteUpdated(when(builtAt, { short: true })) : undefined}
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
+ }
@@ -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, as for Svg (themePlugin, packages/editor/site/theme.mjs).
3
- export const allIcons = import.meta.glob<string>('/node_modules/@phosphor-icons/core/assets/regular/*.svg', {
4
- query: '?raw',
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
+ });
@@ -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',
@@ -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
+ }
@@ -63,5 +63,10 @@
63
63
  "prettier-plugin-astro": "^1.1.2",
64
64
  "prettier-plugin-tailwindcss": "^0.8.1",
65
65
  "typescript": "^6.0.2"
66
+ },
67
+ "overrides": {
68
+ "@tailwindcss/typography": {
69
+ "postcss-selector-parser": "^6.1.5"
70
+ }
66
71
  }
67
72
  }
@@ -0,0 +1,2 @@
1
+ export const LOOKS: {};
2
+ export const HEADINGS: {};
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) that never leaves the Worker.
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);