@morphemecms/cms 0.1.1-next-20261103223426 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -1
- package/bin/morpheme.mjs +1 -1
- package/lib/apps.mjs +5 -2
- package/lib/apps.test.d.mts +1 -0
- package/lib/apps.test.mjs +50 -0
- package/lib/caseCollisions.d.mts +4 -0
- package/lib/caseCollisions.mjs +62 -0
- package/lib/caseCollisions.test.d.mts +1 -0
- package/lib/caseCollisions.test.mjs +74 -0
- package/lib/contracts.d.mts +15 -0
- package/lib/contracts.mjs +17 -0
- package/lib/licenses.d.mts +1 -1
- package/lib/licenses.mjs +2 -2
- package/lib/storeEntries.d.mts +18 -0
- package/lib/storeEntries.mjs +64 -0
- package/lib/storeEntries.test.d.mts +1 -0
- package/lib/storeEntries.test.mjs +71 -0
- package/package.json +1 -1
- package/pages/editor/sw.js +1 -1
- package/scripts/built-site.mjs +37 -2
- package/scripts/check-content.mjs +4 -2
- package/scripts/check-store.mjs +160 -0
- package/site/defineSite.d.mts +2 -14
- package/site/defineSite.mjs +2 -7
- package/site/storeTheme.d.mts +6 -0
- package/site/storeTheme.mjs +29 -0
- package/site/storeTheme.test.d.mts +1 -0
- package/site/storeTheme.test.mjs +53 -0
- package/site/theme.mjs +14 -3
- package/src/App.tsx +13 -16
- package/src/AppList.tsx +22 -5
- package/src/Consents.tsx +2 -2
- package/src/Deleted.tsx +1 -1
- package/src/EntryForm.tsx +3 -3
- package/src/FormFrame.tsx +3 -3
- package/src/Library.tsx +2 -2
- package/src/Licenses.tsx +1 -1
- package/src/Media.tsx +2 -2
- package/src/Notifications.tsx +2 -2
- package/src/Redirects.tsx +2 -2
- package/src/Screen.tsx +1 -1
- package/src/ThemeStore.tsx +1 -1
- package/src/apps.ts +2 -2
- package/src/checkSite.ts +2 -2
- package/src/deploy.ts +1 -1
- package/src/i18n/da-DK/consents.ts +1 -1
- package/src/i18n/en-US/apps.ts +1 -1
- package/src/i18n/en-US/consents.ts +1 -1
- package/src/i18n/en-US/library.ts +1 -1
- package/src/i18n/en-US/licenses.ts +1 -1
- package/src/i18n/en-US/redirects.ts +2 -2
- package/src/store.ts +74 -94
- package/src/themes.ts +1 -1
- package/starter/README.md +1 -0
- package/theme/styles/ui.css +4 -4
- package/theme/ui/BottomSheet.tsx +3 -3
- package/theme/ui/Dialog.tsx +1 -1
- package/theme/ui/SideSheet.tsx +1 -1
- package/theme/ui/bottomSheet.script.ts +2 -2
- package/worker/consent.js +1 -1
- package/worker/index.js +4 -0
- package/worker/push.js +1 -1
- package/worker/store.js +46 -0
- /package/src/{consents.ts → consentData.ts} +0 -0
- /package/src/{licenses.ts → licenseData.ts} +0 -0
- /package/src/{notifications.ts → notificationData.ts} +0 -0
- /package/src/{redirects.ts → redirectData.ts} +0 -0
- /package/theme/ui/{bottomSheet.ts → bottomSheetControl.ts} +0 -0
- /package/theme/ui/{dialog.ts → dialogControl.ts} +0 -0
- /package/theme/ui/{sideSheet.ts → sideSheetControl.ts} +0 -0
|
@@ -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.');
|
package/site/defineSite.d.mts
CHANGED
|
@@ -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
|
|
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/
|
|
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 {
|
package/site/defineSite.mjs
CHANGED
|
@@ -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
|
|
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/
|
|
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 './
|
|
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 './
|
|
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 (
|
|
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
|
-
* (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 './
|
|
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
|
-
{
|
|
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.
|
|
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 (
|
|
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 './
|
|
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 './
|
|
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 './
|
|
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,
|
|
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 (
|
|
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 './
|
|
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 './
|
|
21
|
+
import type { EditorType } from './notificationData';
|
|
22
22
|
|
|
23
|
-
// `kind`: what kind of notification it is (
|
|
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 './
|
|
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" (
|
|
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();
|