@morphemecms/cms 0.1.1-next-20261103223426 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/CHANGELOG.md +6 -1
  2. package/bin/morpheme.mjs +1 -1
  3. package/lib/apps.mjs +5 -2
  4. package/lib/apps.test.d.mts +1 -0
  5. package/lib/apps.test.mjs +50 -0
  6. package/lib/caseCollisions.d.mts +4 -0
  7. package/lib/caseCollisions.mjs +62 -0
  8. package/lib/caseCollisions.test.d.mts +1 -0
  9. package/lib/caseCollisions.test.mjs +74 -0
  10. package/lib/contracts.d.mts +15 -0
  11. package/lib/contracts.mjs +17 -0
  12. package/lib/licenses.d.mts +1 -1
  13. package/lib/licenses.mjs +2 -2
  14. package/lib/storeEntries.d.mts +18 -0
  15. package/lib/storeEntries.mjs +64 -0
  16. package/lib/storeEntries.test.d.mts +1 -0
  17. package/lib/storeEntries.test.mjs +71 -0
  18. package/package.json +1 -1
  19. package/pages/editor/sw.js +1 -1
  20. package/scripts/built-site.mjs +37 -2
  21. package/scripts/check-content.mjs +4 -2
  22. package/scripts/check-store.mjs +160 -0
  23. package/site/defineSite.d.mts +2 -14
  24. package/site/defineSite.mjs +2 -7
  25. package/site/storeTheme.d.mts +6 -0
  26. package/site/storeTheme.mjs +29 -0
  27. package/site/storeTheme.test.d.mts +1 -0
  28. package/site/storeTheme.test.mjs +53 -0
  29. package/site/theme.mjs +14 -3
  30. package/src/App.tsx +13 -16
  31. package/src/AppList.tsx +22 -5
  32. package/src/Consents.tsx +2 -2
  33. package/src/Deleted.tsx +1 -1
  34. package/src/EntryForm.tsx +3 -3
  35. package/src/FormFrame.tsx +3 -3
  36. package/src/Library.tsx +2 -2
  37. package/src/Licenses.tsx +1 -1
  38. package/src/Media.tsx +2 -2
  39. package/src/Notifications.tsx +2 -2
  40. package/src/Redirects.tsx +2 -2
  41. package/src/Screen.tsx +1 -1
  42. package/src/ThemeStore.tsx +1 -1
  43. package/src/apps.ts +2 -2
  44. package/src/checkSite.ts +2 -2
  45. package/src/deploy.ts +1 -1
  46. package/src/i18n/da-DK/consents.ts +1 -1
  47. package/src/i18n/en-US/apps.ts +1 -1
  48. package/src/i18n/en-US/consents.ts +1 -1
  49. package/src/i18n/en-US/library.ts +1 -1
  50. package/src/i18n/en-US/licenses.ts +1 -1
  51. package/src/i18n/en-US/redirects.ts +2 -2
  52. package/src/store.ts +74 -94
  53. package/src/themes.ts +1 -1
  54. package/starter/README.md +1 -0
  55. package/theme/styles/ui.css +4 -4
  56. package/theme/ui/BottomSheet.tsx +3 -3
  57. package/theme/ui/Dialog.tsx +1 -1
  58. package/theme/ui/SideSheet.tsx +1 -1
  59. package/theme/ui/bottomSheet.script.ts +2 -2
  60. package/worker/consent.js +1 -1
  61. package/worker/index.js +4 -0
  62. package/worker/push.js +1 -1
  63. package/worker/store.js +46 -0
  64. /package/src/{consents.ts → consentData.ts} +0 -0
  65. /package/src/{licenses.ts → licenseData.ts} +0 -0
  66. /package/src/{notifications.ts → notificationData.ts} +0 -0
  67. /package/src/{redirects.ts → redirectData.ts} +0 -0
  68. /package/theme/ui/{bottomSheet.ts → bottomSheetControl.ts} +0 -0
  69. /package/theme/ui/{dialog.ts → dialogControl.ts} +0 -0
  70. /package/theme/ui/{sideSheet.ts → sideSheetControl.ts} +0 -0
@@ -0,0 +1,160 @@
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, and the store's refusals and failures. GitHub and the store are played by a fake
3
+ // fetch that records each request; nothing goes to either.
4
+ // Run: npm run check:worker
5
+ import { APP_API, THEME_API } from '../lib/contracts.mjs';
6
+ import { workerModules } from './worker-modules.mjs';
7
+
8
+ const [{ handleStore, STORE_URL }] = await workerModules('store.js', 'session.js');
9
+
10
+ const problems = [];
11
+ const expect = (ok, what) => ok || problems.push(what);
12
+
13
+ const KEY = 'site-key-for-test';
14
+ const OID = 'a'.repeat(40);
15
+ const BYTES = new Uint8Array([1, 2, 3, 250]);
16
+
17
+ /** A fake fetch: GitHub says what `repoAccess` says for a token; the store answers with `store(url, init)`. `storeCalls` records the store's requests. */
18
+ function world({ repoAccess = { pull: true, push: false }, store = () => new Response('{}', { status: 200 }) } = {}) {
19
+ const storeCalls = [];
20
+ const ask = async (url, init = {}) => {
21
+ if (String(url).startsWith('https://api.github.com/')) return Response.json({ permissions: repoAccess });
22
+ storeCalls.push({ url: String(url), headers: init.headers ?? {} });
23
+ return store(String(url), init);
24
+ };
25
+ return { ask, storeCalls };
26
+ }
27
+
28
+ let n = 0;
29
+ /** A request from the editor's page; a fresh token each time (the Worker remembers what GitHub said about a token). */
30
+ const request = (path, { signedIn = true, method = 'GET' } = {}) =>
31
+ new Request(`https://editor.example/api/store/${path}`, {
32
+ method,
33
+ headers: { ...(signedIn ? { cookie: `editor_session=token-${++n}` } : {}), 'sec-fetch-site': 'same-origin' },
34
+ });
35
+ const call = async (w, path, env = { MORPHEME_STORE_KEY: KEY }, options) => {
36
+ const res = await handleStore(request(path, options), env, {}, w.ask);
37
+ const bytes = new Uint8Array(await res.clone().arrayBuffer());
38
+ return { res, bytes, text: new TextDecoder().decode(bytes) };
39
+ };
40
+
41
+ // Signed out: 401, and the store is not asked.
42
+ {
43
+ const w = world();
44
+ const { res } = await call(w, 'catalog', undefined, { signedIn: false });
45
+ expect(res.status === 401, `uden login var svaret ${res.status}, ikke 401`);
46
+ expect(w.storeCalls.length === 0, 'uden login blev butikken spurgt');
47
+ }
48
+
49
+ // Signed in without the right to read the repository: 403.
50
+ {
51
+ const w = world({ repoAccess: { pull: false, push: false } });
52
+ const { res } = await call(w, 'catalog');
53
+ expect(res.status === 403, `uden læseret var svaret ${res.status}, ikke 403`);
54
+ expect(w.storeCalls.length === 0, 'uden læseret blev butikken spurgt');
55
+ }
56
+
57
+ // Without a key: the catalog says "not connected" and files are not found; the store is not asked.
58
+ {
59
+ const w = world();
60
+ const catalog = await call(w, 'catalog', {});
61
+ expect(catalog.res.status === 200 && JSON.parse(catalog.text).connected === false, `uden nøgle var kataloget ${catalog.text}`);
62
+ const file = await call(w, `files/${OID}`, {});
63
+ expect(file.res.status === 404, `uden nøgle var en fil ${file.res.status}, ikke 404`);
64
+ expect(w.storeCalls.length === 0, 'uden nøgle blev butikken spurgt');
65
+ }
66
+
67
+ // With a key: one request to the catalog with the contract versions and the bearer, and the store's JSON back.
68
+ {
69
+ const w = world({ store: () => Response.json({ apps: [{ id: 'x' }] }) });
70
+ const { res, text } = await call(w, 'catalog');
71
+ expect(res.status === 200 && JSON.parse(text).apps?.[0]?.id === 'x', `kataloget var ${res.status} ${text}`);
72
+ expect(w.storeCalls.length === 1, `butikken blev spurgt ${w.storeCalls.length} gange, ikke 1`);
73
+ expect(w.storeCalls[0]?.url === `${STORE_URL}/store/catalog?app=${APP_API}&theme=${THEME_API}`, `adressen var ${w.storeCalls[0]?.url}`);
74
+ expect(w.storeCalls[0]?.headers.authorization === `Bearer ${KEY}`, 'kataloget blev spurgt uden nøglen som bearer');
75
+ expect(!text.includes(KEY), 'kataloget indeholdt nøglen');
76
+ }
77
+
78
+ // MORPHEME_STORE_URL is used when set.
79
+ {
80
+ const w = world();
81
+ await call(w, 'catalog', { MORPHEME_STORE_KEY: KEY, MORPHEME_STORE_URL: 'https://store.example' });
82
+ expect(
83
+ w.storeCalls[0]?.url === `https://store.example/store/catalog?app=${APP_API}&theme=${THEME_API}`,
84
+ `MORPHEME_STORE_URL blev ikke brugt: ${w.storeCalls[0]?.url}`,
85
+ );
86
+ }
87
+
88
+ // A file: only a 40-character oid; the bytes, content-type and cache-control go through.
89
+ {
90
+ const w = world();
91
+ const bad = await call(w, 'files/xyz');
92
+ expect(bad.res.status === 404, `oid xyz gav ${bad.res.status}, ikke 404`);
93
+ expect(w.storeCalls.length === 0, 'oid xyz blev spurgt om i butikken');
94
+ }
95
+ {
96
+ const w = world({
97
+ store: () =>
98
+ new Response(BYTES, {
99
+ status: 200,
100
+ headers: { 'content-type': 'image/png', 'cache-control': 'public, max-age=31536000, immutable' },
101
+ }),
102
+ });
103
+ const { res, bytes } = await call(w, `files/${OID}`);
104
+ expect(res.status === 200 && bytes.join() === BYTES.join(), `filens bytes var ${bytes.join()}`);
105
+ expect(
106
+ res.headers.get('cache-control') === 'public, max-age=31536000, immutable',
107
+ `cache-control var ${res.headers.get('cache-control')}`,
108
+ );
109
+ expect(res.headers.get('content-type') === 'image/png', `content-type var ${res.headers.get('content-type')}`);
110
+ expect(w.storeCalls[0]?.url === `${STORE_URL}/store/files/${OID}`, `filens adresse var ${w.storeCalls[0]?.url}`);
111
+ expect(w.storeCalls[0]?.headers.authorization === `Bearer ${KEY}`, 'filen blev spurgt uden nøglen som bearer');
112
+ }
113
+ {
114
+ const w = world({ store: () => new Response('no', { status: 404 }) });
115
+ const { res } = await call(w, `files/${OID}`);
116
+ expect(res.status === 404, `en fil butikken ikke har gav ${res.status}, ikke 404`);
117
+ }
118
+
119
+ // The store refuses or fails: 502, and never the key.
120
+ for (const [status, error] of [
121
+ [401, 'store refused'],
122
+ [403, 'store refused'],
123
+ [500, 'store failed'],
124
+ [400, 'store failed'],
125
+ [404, 'store failed'],
126
+ ]) {
127
+ for (const path of ['catalog', ...(status === 404 || status === 400 ? [] : [`files/${OID}`])]) {
128
+ const w = world({ store: () => new Response(`secret ${KEY}`, { status }) });
129
+ const { res, text } = await call(w, path);
130
+ expect(res.status === 502 && JSON.parse(text).error === error, `butikkens ${status} på ${path} gav ${res.status} ${text}`);
131
+ expect(
132
+ !text.includes(KEY) && ![...res.headers.values()].some((v) => v.includes(KEY)),
133
+ `svaret på butikkens ${status} indeholdt nøglen`,
134
+ );
135
+ }
136
+ }
137
+ {
138
+ const w = world({
139
+ store: () => {
140
+ throw new Error(`no network, ${KEY}`);
141
+ },
142
+ });
143
+ const { res, text } = await call(w, 'catalog');
144
+ expect(res.status === 502 && JSON.parse(text).error === 'store failed', `en netværksfejl gav ${res.status} ${text}`);
145
+ expect(!text.includes(KEY), 'svaret på en netværksfejl indeholdt nøglen');
146
+ }
147
+
148
+ // Other methods and addresses.
149
+ {
150
+ const w = world();
151
+ expect((await call(w, 'catalog', undefined, { method: 'POST' })).res.status === 405, 'POST gav ikke 405');
152
+ expect((await call(w, 'other')).res.status === 404, 'en ukendt adresse gav ikke 404');
153
+ expect(w.storeCalls.length === 0, 'en forkert metode eller adresse blev spurgt om i butikken');
154
+ }
155
+
156
+ if (problems.length) {
157
+ console.error(problems.map((p) => `- ${p}`).join('\n'));
158
+ process.exit(1);
159
+ }
160
+ console.log('Workerens spørgsmål til butikken er i orden.');
@@ -45,7 +45,7 @@ export type SiteOwn = {
45
45
  * What a site can say of itself, else the standard: the owner's picture (beside a blog post, and shown when a page with
46
46
  * none of its own is shared), the booking page and the pages a booking ends on, the page about the owner, the block
47
47
  * drawn after every blog post, the word a page's title can end in, pictures that are the same though their names say
48
- * otherwise, the file of its redirects from old addresses, and its app store.
48
+ * otherwise, and the file of its redirects from old addresses.
49
49
  */
50
50
  export type SiteChoices = {
51
51
  /**
@@ -85,24 +85,12 @@ export type SiteChoices = {
85
85
  /**
86
86
  * The site's other redirects: rules ("<from> <to> 301", one to a
87
87
  * line) and the Worker's map (`{ ids, paths }`, worker/index.js), shown and changed with the editor's own under
88
- * Videresendelser (src/redirects.ts).
88
+ * Videresendelser (src/redirectData.ts).
89
89
  */
90
90
  siteRedirects: {
91
91
  rules: string;
92
92
  map: string;
93
93
  };
94
- /**
95
- * The app store ("Apps" ›
96
- * "Appstore", src/store.ts): the GitHub repository and branch of its catalog (`catalog`, the apps and themes that can
97
- * be installed, each with where its folder is in the repository, at which commit), or none: then "Appstore" has only
98
- * the site's own.
99
- */
100
- store: {
101
- owner: string;
102
- name: string;
103
- branch: string;
104
- catalog: string;
105
- } | null;
106
94
  };
107
95
  export type SiteConfig = SiteOwn & SiteChoices & typeof STANDARD;
108
96
  declare namespace STANDARD {
@@ -21,7 +21,7 @@
21
21
  * What a site can say of itself, else the standard: the owner's picture (beside a blog post, and shown when a page with
22
22
  * none of its own is shared), the booking page and the pages a booking ends on, the page about the owner, the block
23
23
  * drawn after every blog post, the word a page's title can end in, pictures that are the same though their names say
24
- * otherwise, the file of its redirects from old addresses, and its app store.
24
+ * otherwise, and the file of its redirects from old addresses.
25
25
  * @typedef {object} SiteChoices
26
26
  * @property {{ src: string; alt: string } | null} picture The owner's picture, or none.
27
27
  * @property {{ path: string; thanks: string[] }} booking The booking page, where "Book en tid" goes when no booking sheet
@@ -35,11 +35,7 @@
35
35
  * saved under a new name), by name (samePicture in site/images.mjs).
36
36
  * @property {{ rules: string; map: string }} siteRedirects The site's other redirects: rules ("<from> <to> 301", one to a
37
37
  * line) and the Worker's map (`{ ids, paths }`, worker/index.js), shown and changed with the editor's own under
38
- * Videresendelser (src/redirects.ts).
39
- * @property {{ owner: string; name: string; branch: string; catalog: string } | null} store The app store ("Apps" ›
40
- * "Appstore", src/store.ts): the GitHub repository and branch of its catalog (`catalog`, the apps and themes that can
41
- * be installed, each with where its folder is in the repository, at which commit), or none: then "Appstore" has only
42
- * the site's own.
38
+ * Videresendelser (src/redirectData.ts).
43
39
  */
44
40
 
45
41
  /** @type {SiteChoices} */
@@ -51,7 +47,6 @@ const CHOICES = {
51
47
  titleEnd: '',
52
48
  samePictures: {},
53
49
  siteRedirects: { rules: 'public/_redirects', map: 'src/data/old-addresses.json' },
54
- store: null,
55
50
  };
56
51
 
57
52
  /** The same on every site. */
@@ -0,0 +1,6 @@
1
+ /** What is wrong with the imports in a store theme's CSS file.
2
+ * @param {string} css the file's text
3
+ * @param {string} file its path in the theme, e.g. 'styles/global.css'
4
+ * @returns {string[]}
5
+ */
6
+ export function storeImportProblems(css: string, file: string): string[];
@@ -0,0 +1,29 @@
1
+ // Keeps a store theme's CSS to imports from the CMS and its own files, which is all that is there on a site that
2
+ // installs it. Exposes storeImportProblems, the problems in one CSS file's imports. Depends on node:path only.
3
+ import { posix } from 'node:path';
4
+
5
+ /** What is wrong with the imports in a store theme's CSS file.
6
+ * @param {string} css the file's text
7
+ * @param {string} file its path in the theme, e.g. 'styles/global.css'
8
+ * @returns {string[]}
9
+ */
10
+ export function storeImportProblems(css, file) {
11
+ const text = css.replace(/\/\*[\s\S]*?\*\//g, '');
12
+ // One pass over both forms, so the targets come in the order they appear in the text.
13
+ const targets = [];
14
+ const targetRe = /@import\s+(['"])(.*?)\1|url\(\s*(['"]?)(.*?)\3\s*\)/g;
15
+ let match;
16
+ while ((match = targetRe.exec(text))) targets.push(match[2] !== undefined ? match[2] : match[4].trim());
17
+
18
+ return targets
19
+ .filter((target) => {
20
+ if (target.startsWith('data:')) return false;
21
+ if (target.startsWith('@morphemecms/cms/')) return false;
22
+ if (target.startsWith('./') || target.startsWith('../')) {
23
+ const joined = posix.normalize(posix.join(posix.dirname(file), target));
24
+ if (joined !== '..' && !joined.startsWith('../')) return false;
25
+ }
26
+ return true;
27
+ })
28
+ .map((target) => `${file}: et tema i butikken må kun importere fra @morphemecms/cms og sine egne filer, ikke "${target}".`);
29
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,53 @@
1
+ // What is wrong with a store theme's CSS imports: only the CMS and its own files are allowed.
2
+ // Run with `node --test packages/editor/site/*.test.mjs` (part of npm run check).
3
+ import { strict as assert } from 'node:assert';
4
+ import { test } from 'node:test';
5
+ import { storeImportProblems } from './storeTheme.mjs';
6
+
7
+ const F = 'styles/global.css';
8
+ const expected = (target) => `${F}: et tema i butikken må kun importere fra @morphemecms/cms og sine egne filer, ikke "${target}".`;
9
+
10
+ test('an import of the CMS is allowed', () => {
11
+ const css = `@import '@morphemecms/cms/theme/styles/base.css';`;
12
+ assert.deepEqual(storeImportProblems(css, F), []);
13
+ });
14
+
15
+ test('an npm package is not there', () => {
16
+ const css = `@import '@fontsource-variable/outfit';`;
17
+ assert.deepEqual(storeImportProblems(css, F), [expected('@fontsource-variable/outfit')]);
18
+ });
19
+
20
+ test('a font in the theme is allowed', () => {
21
+ const css = `@font-face { src: url('../fonts/a.woff2') format('woff2'); }`;
22
+ assert.deepEqual(storeImportProblems(css, F), []);
23
+ });
24
+
25
+ test('a path that leaves the theme is not there', () => {
26
+ const css = `@font-face { src: url('../../x.woff2'); }`;
27
+ assert.deepEqual(storeImportProblems(css, F), [expected('../../x.woff2')]);
28
+ });
29
+
30
+ test('a font from another host is not there', () => {
31
+ const css = `@font-face { src: url(https://fonts.example/a.woff2); }`;
32
+ assert.deepEqual(storeImportProblems(css, F), [expected('https://fonts.example/a.woff2')]);
33
+ });
34
+
35
+ test('a data URI is allowed', () => {
36
+ const css = `@font-face { src: url(data:font/woff2;base64,AA); }`;
37
+ assert.deepEqual(storeImportProblems(css, F), []);
38
+ });
39
+
40
+ test('two targets outside the theme are two problems', () => {
41
+ const css = `@import 'https://a.example/x.css';\n@import url("other-package/x.css");`;
42
+ assert.deepEqual(storeImportProblems(css, F), [expected('https://a.example/x.css'), expected('other-package/x.css')]);
43
+ });
44
+
45
+ test('the problems come in the order of the text', () => {
46
+ const css = `@font-face { src: url(https://a.example/x); }\n@import 'b';`;
47
+ assert.deepEqual(storeImportProblems(css, F), [expected('https://a.example/x'), expected('b')]);
48
+ });
49
+
50
+ test('an import in a comment is not there', () => {
51
+ const css = `/* @import 'left-out'; */ @import './ui.css';`;
52
+ assert.deepEqual(storeImportProblems(css, F), []);
53
+ });
package/site/theme.mjs CHANGED
@@ -13,6 +13,7 @@ import { declarationProblems, ownerId } from '@morphemecms/cms/lib/licenses.mjs'
13
13
  import { installedPacks, isIconName, packFolder, packProblems, phosphorFile, svgProblems, usedIcons } from './icons.mjs';
14
14
  import { ICON_PACK_FILE, ICON_PACK_LICENSE, SYSTEM_PACKS, THEME_PACKS_FOLDER, packInUse, packTheme, themePackId } from './iconPacks.mjs';
15
15
  import { MOTION_SCHEMES } from '@morphemecms/cms/lib/motion-schemes.mjs';
16
+ import { storeImportProblems } from './storeTheme.mjs';
16
17
 
17
18
  /** The site's choice of icon pack, `{ "pack": "<id>" }` (config.icons in src/site/config.ts). */
18
19
  const ICONS_FILE = 'src/data/icons.json';
@@ -336,6 +337,16 @@ export function themeProblems(theme) {
336
337
  problems.push(
337
338
  `${themeFolder(theme)}/theme.json: "store" kan kun være true (temaet står i Appstore, npm run store:release), og standardtemaet har det ikke.`,
338
339
  );
340
+ // A theme from the store is copied in as its folder only: its CSS imports nothing from outside it but the CMS.
341
+ if (manifest.store === true) {
342
+ const styles = `${themeFolder(theme)}/styles`;
343
+ for (const f of has(styles) ? readdirSync(new URL(`${styles}/`, root), { recursive: true }) : [])
344
+ if (String(f).endsWith('.css')) {
345
+ const inTheme = `styles/${String(f).replaceAll('\\', '/')}`;
346
+ for (const p of storeImportProblems(readFileSync(new URL(`${themeFolder(theme)}/${inTheme}`, root), 'utf8'), inTheme))
347
+ problems.push(`${themeFolder(theme)}/${p}`);
348
+ }
349
+ }
339
350
  // The icon pack it uses by default: a system pack, or one of its own by its folder; the standard theme's a system pack.
340
351
  const own = ownPacks(theme);
341
352
  if (
@@ -383,8 +394,8 @@ export function themeProblems(theme) {
383
394
  return [...problems, ...iconProblems(theme), ...packs.flatMap((p) => packProblems(themePackId(theme, p), used))];
384
395
  }
385
396
 
386
- // A site's own parts (src/site/<file>), else the package's standard ones (defaults/<file>): `@site/<file>`.
387
- const SITE_DIRS = [fileURLToPath(new URL('src/site/', root)), fileURLToPath(new URL('defaults/', pkg))];
397
+ // A site's own parts (src/site/<file>), else the package's standard ones (defaults/<file>): `@site/<file>`. With `/` only, as Vite's ids (Windows).
398
+ const SITE_DIRS = [fileURLToPath(new URL('src/site/', root)), fileURLToPath(new URL('defaults/', pkg))].map((d) => d.replaceAll('\\', '/'));
388
399
 
389
400
  /** The file `@site/<file>` is: the site's src/site/<file>, else the package's defaults/<file>, with or without its
390
401
  * extension (`@site/editor`, as TypeScript writes it), or null. */
@@ -422,7 +433,7 @@ export const aliases = () => ({
422
433
  * pack and no icons of its own nothing is changed. The pack is read when the site is built, so a new choice
423
434
  * (src/data/icons.json) or theme is in the site and the editor after the next build. */
424
435
  export function themePlugin() {
425
- const themes = [activeTheme(), STANDARD_THEME].map((t) => fileURLToPath(new URL(`${themeFolder(t)}/`, root)));
436
+ const themes = [activeTheme(), STANDARD_THEME].map((t) => fileURLToPath(new URL(`${themeFolder(t)}/`, root)).replaceAll('\\', '/'));
426
437
  const icons = themeIcons();
427
438
  const own = Object.keys(icons).length > 0;
428
439
  return {
package/src/App.tsx CHANGED
@@ -112,6 +112,7 @@ import {
112
112
  readApp,
113
113
  readCatalog,
114
114
  readScreenshots,
115
+ storeConnected,
115
116
  readThemes,
116
117
  readThemeScreenshots,
117
118
  STORE_BEFORE,
@@ -167,12 +168,12 @@ import { setSidebar, useSidebar } from './sidebar';
167
168
  import { Ask, ask } from './ask';
168
169
  import { LinkAsk } from './LinkSheet';
169
170
  import { type ListRows, type Option, type TestimonialRow } from './fields';
170
- import { endOf, entriesOf, lookup, readAll } from './redirects';
171
+ import { endOf, entriesOf, lookup, readAll } from './redirectData';
171
172
  import { inForm, leaveTo, type Status } from './FormFrame';
172
173
  import { arrived, screenOf, up } from './nav';
173
174
  import { siteT, siteText, t } from './i18n';
174
175
  import { snackbar } from '../lib/snackbar';
175
- import { appType, clearNotifications, isOn, remember, signOff, stopServerWait, syncDevice, told, waitOnServer } from './notifications';
176
+ import { appType, clearNotifications, isOn, remember, signOff, stopServerWait, syncDevice, told, waitOnServer } from './notificationData';
176
177
  import Notifications from './Notifications';
177
178
  import { EditorOwn, InEditor } from './standard/view';
178
179
  import './editor.css';
@@ -203,7 +204,7 @@ export type Env = {
203
204
  /** The files deleted from a folder (github.ts deletedFiles; Deleted.tsx). */
204
205
  deleted: (folder: string, options?: { text?: boolean }) => Promise<DeletedFile[]>;
205
206
  /** Why a new page or post cannot have this address (it is taken), or null; redirects: the files of
206
- * the redirects as they are now (redirects.ts, loaded with `load`). */
207
+ * the redirects as they are now (redirectData.ts, loaded with `load`). */
207
208
  taken: (address: string, redirects: RepoFile[]) => Taken | null;
208
209
  /** Single files outside the folders the app loads (github.ts loadFiles). */
209
210
  load: (paths: string[]) => Promise<RepoFile[]>;
@@ -212,15 +213,10 @@ export type Env = {
212
213
  /** The app store (store.ts): its apps and its themes, an app's or a theme's files from it, and their pictures (as
213
214
  * addresses in this browser). */
214
215
  store: {
216
+ connected: () => Promise<boolean>;
215
217
  catalog: () => Promise<ShownApp[]>;
216
218
  themes: () => Promise<ShownTheme[]>;
217
- files: (of: {
218
- path: string;
219
- ref: string;
220
- files: StoreFile[];
221
- label?: string;
222
- name?: string;
223
- }) => Promise<{ path: string; content: string | Uint8Array }[]>;
219
+ files: (of: { files: StoreFile[]; label?: string; name?: string }) => Promise<{ path: string; content: string | Uint8Array }[]>;
224
220
  screenshots: (app: StoreApp) => Promise<{ src: string; label: string }[]>;
225
221
  themeScreenshots: (theme: Pick<StoreTheme, 'screenshots' | 'files'>) => Promise<{ key: string; src: string }[]>;
226
222
  };
@@ -253,11 +249,11 @@ const RESERVED = config.reserved;
253
249
 
254
250
  /** Why a new page or post cannot have an address (/<slug>/): a message; with `redirect` when the site
255
251
  * sends it on elsewhere, which the site's owner may remove (EntryForm.tsx): where it ends after every redirect
256
- * (redirects.ts), its name, and how many other addresses send on through it. */
252
+ * (redirectData.ts), its name, and how many other addresses send on through it. */
257
253
  export type Taken = { text: string; redirect?: { end: string; name: string; via: number } };
258
254
 
259
255
  // Why a new page or post cannot have an address (/<slug>/), or null. redirects: the files of the
260
- // redirects (redirects.ts), as they are now.
256
+ // redirects (redirectData.ts), as they are now.
261
257
  function taken(address: string, relations: Record<Relation, Option[]>, redirects: RepoFile[]): Taken | null {
262
258
  const pages = [...relations.pages, ...relations.fixed].map((o) => o.value);
263
259
  const posts = relations.blog.map((o) => `/${o.value}/`);
@@ -342,7 +338,7 @@ function Editor() {
342
338
  useEffect(registerWorker, []);
343
339
  useEffect(startRipples, []);
344
340
  // Signed up for the notifications on the phone from the site's Worker, also when the editor is closed, while
345
- // "På telefonen" is on (notifications.ts).
341
+ // "På telefonen" is on (notificationData.ts).
346
342
  useEffect(() => {
347
343
  if (signedIn) syncDevice();
348
344
  }, [signedIn]);
@@ -478,7 +474,7 @@ function Editor() {
478
474
  const media = useMemo(() => mediaOf(files ?? []), [files]);
479
475
 
480
476
  // A message at the bottom (snackbar in lib/snackbar.ts, the same as on the site), with a link to what it
481
- // is about; one of a kind kept in the notifications too (notifications.ts).
477
+ // is about; one of a kind kept in the notifications too (notificationData.ts).
482
478
  function notify(next: Status, device = true) {
483
479
  const edit = next?.edit;
484
480
  if (next?.kind)
@@ -512,7 +508,7 @@ function Editor() {
512
508
  stopWaiting.current?.();
513
509
  if (!waitingFor.current.includes(change)) waitingFor.current.push(change);
514
510
  const what = changesText(waitingFor.current);
515
- // The Worker says it on the phone too, also when the editor is closed by then (notifications.ts).
511
+ // The Worker says it on the phone too, also when the editor is closed by then (notificationData.ts).
516
512
  const onServer = waitOnServer(saved.commit, t.app.siteUpdated(what), t.app.siteNotUpdated(what));
517
513
  stopWaiting.current = waitForSite(saved.commit, async (online) => {
518
514
  waitingFor.current = [];
@@ -562,6 +558,7 @@ function Editor() {
562
558
  load: (paths) => loadFiles(paths),
563
559
  folder: (path) => loadFolders({ all: path }, 8).then((r) => r.folders.all),
564
560
  store: {
561
+ connected: storeConnected,
565
562
  catalog: readCatalog,
566
563
  themes: readThemes,
567
564
  files: readApp,
@@ -632,7 +629,7 @@ function Editor() {
632
629
  }, [signedIn, loaded]);
633
630
 
634
631
  // What the installed apps have to say (AppNotifications in apps.ts): asked when the editor opens and every 15 minutes
635
- // while it is open; each comes once, at the bottom and under "Notifikationer", while its type is on (notifications.ts).
632
+ // while it is open; each comes once, at the bottom and under "Notifikationer", while its type is on (notificationData.ts).
636
633
  const notifying = installedApps(files ?? [])
637
634
  .filter((id) => apps.some((a) => a.id === id && a.notifications))
638
635
  .join();
package/src/AppList.tsx CHANGED
@@ -80,7 +80,6 @@ import {
80
80
  notesSince,
81
81
  recordIn,
82
82
  DEVELOPER_ROUTE,
83
- hasStore,
84
83
  newer,
85
84
  STORE_ROUTE,
86
85
  STORE_THEMES,
@@ -99,7 +98,7 @@ import { config } from '@site/editor';
99
98
  import { allThemes, ThemeRow, themeInstalled, useThemeCatalog } from './ThemeStore';
100
99
  import { siteT, siteText, t } from './i18n';
101
100
  import { OwnerLicenses, StoreLicenseRows } from './Licenses';
102
- import { appType, setOn, told, useNotificationChoices } from './notifications';
101
+ import { appType, setOn, told, useNotificationChoices } from './notificationData';
103
102
  import { kindAndName } from './deploy';
104
103
 
105
104
  export const appHref = (app: SiteApp) => `#apps/${app.id}`;
@@ -266,6 +265,23 @@ function useCatalog(env: Env, skip = false): ShownApp[] | null | 'failed' {
266
265
  return catalog;
267
266
  }
268
267
 
268
+ /** Whether the site's Worker has a store key (store.ts storeConnected): null while it is not known; a catalog that could
269
+ * not be read counts as connected (the list says it failed). */
270
+ function useStoreConnected(env: Env): boolean | null {
271
+ const [connected, setConnected] = useState<boolean | null>(null);
272
+ useEffect(() => {
273
+ let on = true;
274
+ env.store.connected().then(
275
+ (yes) => on && setConnected(yes),
276
+ () => on && setConnected(true),
277
+ );
278
+ return () => {
279
+ on = false;
280
+ };
281
+ }, []);
282
+ return connected;
283
+ }
284
+
269
285
  // The apps updated while the editor is open, by their id, to their version: their page and the list say so until the
270
286
  // site's build has it (and the editor reloads with it, deploy.ts keepCurrent).
271
287
  const updated = new Map<string, string>();
@@ -582,6 +598,7 @@ export function StorePage({ env, files, tab }: { env: Env; files: RepoFile[]; ta
582
598
  const installed = installedApps(files);
583
599
  const catalog = useCatalog(env);
584
600
  const themeCatalog = useThemeCatalog(env);
601
+ const connected = useStoreConnected(env);
585
602
  const [query, setQuery] = useKept('butik-soeg', '');
586
603
  const [sort, pickSort] = useSort();
587
604
  const [category, setCategory] = useKept<AppCategory | ''>('butik-kategori', '');
@@ -671,9 +688,9 @@ export function StorePage({ env, files, tab }: { env: Env; files: RepoFile[]; ta
671
688
  </div>
672
689
  )}
673
690
  {/* The site's own are shown also when the store cannot be read. */}
674
- {!hasStore ? (
691
+ {connected === false ? (
675
692
  <EmptyNote>{t.apps.noStore}</EmptyNote>
676
- ) : read === null ? (
693
+ ) : read === null || connected === null ? (
677
694
  <EmptyNote busy>{t.apps.loadingStore}</EmptyNote>
678
695
  ) : read === 'failed' ? (
679
696
  <EmptyNote>{t.apps.storeFailed}</EmptyNote>
@@ -701,7 +718,7 @@ export function StorePage({ env, files, tab }: { env: Env; files: RepoFile[]; ta
701
718
  );
702
719
  }
703
720
 
704
- const recordOf = (app: ShownApp): StoreRecord => ({ version: app.version, ref: app.ref, files: app.files });
721
+ const recordOf = (app: ShownApp): StoreRecord => ({ version: app.version, ref: app.version, files: app.files });
705
722
 
706
723
  /** The cookies an app sets (its cookies.json among its files from the store), in the site's language; none when it
707
724
  * has no such file. */
package/src/Consents.tsx CHANGED
@@ -1,5 +1,5 @@
1
1
  // "Samtykker" (#privatliv/samtykker, Part.opens 'consents'): the visitors' answers in the cookie banner, as the site's
2
- // Worker keeps them (consents.ts, packages/editor/worker/consent.js), newest first, so the site can show that a visitor said yes. Over
2
+ // Worker keeps them (consentData.ts, packages/editor/worker/consent.js), newest first, so the site can show that a visitor said yes. Over
3
3
  // the list the search by consent id (the one the banner shows the visitor under "Cookieindstillinger", asked of the
4
4
  // Worker, so older answers are found too) and the order (kept in this browser); in the top bar the download of all of
5
5
  // them as CSV. Tapping one opens a sheet with when, the banner's version and each yes or no, and the button that deletes
@@ -25,7 +25,7 @@ import {
25
25
  ListItem,
26
26
  } from './ui';
27
27
  import type { Env } from './App';
28
- import { CONSENT_API, deleteConsents, loadConsents, type ConsentAnswer } from './consents';
28
+ import { CONSENT_API, deleteConsents, loadConsents, type ConsentAnswer } from './consentData';
29
29
  import { CONSENT_CATEGORIES } from '../lib/cookieCategories.mjs';
30
30
  import { when } from './when';
31
31
  import { t } from './i18n';
package/src/Deleted.tsx CHANGED
@@ -12,7 +12,7 @@ import { Button, listClass, BottomSheet, SheetTitle, EmptyNote, SheetBody, Probl
12
12
  import { fileKindOf, typeOf } from './images';
13
13
  import { fileIcons, FileView, kindOf } from './FileView';
14
14
  import { ask } from './ask';
15
- import { REDIRECT_FILES } from './redirects';
15
+ import { REDIRECT_FILES } from './redirectData';
16
16
  import { slugOf, type Collection } from './schema';
17
17
  import { asWritten, collections } from './site';
18
18
  import { ConflictError, type DeletedFile, type RepoFile } from './github';
package/src/EntryForm.tsx CHANGED
@@ -11,7 +11,7 @@ import { config, frontLeads, headingLooks, sectionLooks, sharing, standardPages,
11
11
  import { asWritten, placeOf, standardPageAt } from './site';
12
12
  import { ConflictError, type Change, type RepoFile } from './github';
13
13
  import { addressOf, moveRedirects, REDIRECTS, retarget } from './addresses';
14
- import { changesOf, flatten, readAll, REDIRECT_FILES, setRedirect } from './redirects';
14
+ import { changesOf, flatten, readAll, REDIRECT_FILES, setRedirect } from './redirectData';
15
15
  import { TAB_ROUTES } from './Redirects';
16
16
  import { choose } from './ask';
17
17
  import { readFields, splitFrontmatter, writeFields } from './frontmatter.mjs';
@@ -409,7 +409,7 @@ function Form({
409
409
  const name = post || isNew ? address.split('/').at(-2)! : slugOf(base.path, collection.folder);
410
410
  const path = isNew || (post && moved) ? `${collection.folder}/${name}.md` : base.path;
411
411
  // A new page or post, or a new address: not where another page is, or an address the site sends on
412
- // elsewhere (the files of the redirects as they are now, redirects.ts).
412
+ // elsewhere (the files of the redirects as they are now, redirectData.ts).
413
413
  let loaded: RepoFile[] = [];
414
414
  if (isNew || moved)
415
415
  try {
@@ -481,7 +481,7 @@ function Form({
481
481
  }
482
482
  let next = `---\n${yaml}\n---\n${body}`;
483
483
 
484
- // The redirects (redirects.ts): a freed address no longer sends on (those that went on through it
484
+ // The redirects (redirectData.ts): a freed address no longer sends on (those that went on through it
485
485
  // first go straight to where it sent them), the old address sends on to the new one, the new
486
486
  // address is the page's (the editor's own redirect from it goes), and none sends on through another.
487
487
  let redirectChanges: Change[] = [];
package/src/FormFrame.tsx CHANGED
@@ -10,7 +10,7 @@ import { ArrowSquareOut, Binoculars, Trash } from '@phosphor-icons/react';
10
10
  import Screen, { type Back } from './Screen';
11
11
  import { Button, Group, IconButton, IconLink, LoadingIndicator, type TabChoice, ListItem } from './ui';
12
12
  import { hasUnsaved, setUnsaved } from './deploy';
13
- import { askToNotify } from './notifications';
13
+ import { askToNotify } from './notificationData';
14
14
  import { choose } from './ask';
15
15
  import { when } from './when';
16
16
  import { useSidebar } from './sidebar';
@@ -18,9 +18,9 @@ import type { Env } from './App';
18
18
  import { t } from './i18n';
19
19
 
20
20
  import type { Edit } from './schema';
21
- import type { EditorType } from './notifications';
21
+ import type { EditorType } from './notificationData';
22
22
 
23
- // `kind`: what kind of notification it is (notifications.ts: `site`, `saved`, `failed`), kept under "Notifikationer";
23
+ // `kind`: what kind of notification it is (notificationData.ts: `site`, `saved`, `failed`), kept under "Notifikationer";
24
24
  // without it, it is only shown at the bottom (something going on, a form that cannot be saved).
25
25
  export type Status = { text: string; error?: boolean; edit?: Edit; kind?: EditorType } | null;
26
26
 
package/src/Library.tsx CHANGED
@@ -87,7 +87,7 @@ import { when } from './when';
87
87
  import { untilTime } from '../lib/notice.mjs';
88
88
  import { savedTheme, setTheme, shownMode, type Theme } from './theme';
89
89
  import { browserLocale, locale, localeName, locales, savedLocale, setLocale, siteLocale, siteT, siteText, t, type Locale } from './i18n';
90
- import { appType, EDITOR_TYPES, setDevice, setOn, useNotificationChoices } from './notifications';
90
+ import { appType, EDITOR_TYPES, setDevice, setOn, useNotificationChoices } from './notificationData';
91
91
  import { snackbar } from '../lib/snackbar';
92
92
  import { ask } from './ask';
93
93
  import { pagesWith } from './BlockSettings';
@@ -738,7 +738,7 @@ export function Account({ user, files, onLogout }: { user: Viewer | null; files:
738
738
  );
739
739
  }
740
740
 
741
- // "Notifikationer" (notifications.ts): "På telefonen", a switch for each of the editor's types, and under the name of each
741
+ // "Notifikationer" (notificationData.ts): "På telefonen", a switch for each of the editor's types, and under the name of each
742
742
  // installed app that sends any, a switch for each of its types (`notifications` in its index.ts).
743
743
  function NotificationChoices({ files }: { files: RepoFile[] }) {
744
744
  const { off, device } = useNotificationChoices();
package/src/Licenses.tsx CHANGED
@@ -26,7 +26,7 @@ import {
26
26
  type LicenseWhere,
27
27
  type StoreLicenses,
28
28
  type ThirdParty,
29
- } from './licenses';
29
+ } from './licenseData';
30
30
 
31
31
  export const LICENSES_ROUTE = 'licenser';
32
32