@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
package/src/Media.tsx CHANGED
@@ -23,7 +23,7 @@ import {
23
23
  X,
24
24
  } from '@phosphor-icons/react';
25
25
  import { moveRedirects, REDIRECTS } from './addresses';
26
- import { changesOf, flatten, readAll, REDIRECT_FILES } from './redirects';
26
+ import { changesOf, flatten, readAll, REDIRECT_FILES } from './redirectData';
27
27
  import { ask } from './ask';
28
28
  import { dateName, headed, letterOf } from './Sheet';
29
29
  import Screen from './Screen';
@@ -501,7 +501,7 @@ export default function Media({ files, env }: { files: RepoFile[]; env: Env }) {
501
501
  setRenaming(true);
502
502
  env.notify({ text: t.common.saving });
503
503
  try {
504
- // The redirects (redirects.ts): the old address sends on to the new one, and those that sent to
504
+ // The redirects (redirectData.ts): the old address sends on to the new one, and those that sent to
505
505
  // the old address send straight to the new one (no chain).
506
506
  const loaded = await env.load(REDIRECT_FILES);
507
507
  const all = readAll(loaded);
@@ -1,5 +1,5 @@
1
1
  // "Notifikationer" (#notifikationer), which the bell in the top bar opens (Screen.tsx NotificationsLink): the
2
- // notifications of the editor and the apps (notifications.ts), newest first, each with when it came and, when it had
2
+ // notifications of the editor and the apps (notificationData.ts), newest first, each with when it came and, when it had
3
3
  // one, a link to what it is about; an app's with the app's icon. Opening it marks them seen; at the top the bin clears
4
4
  // them and the sliders open "Konto", where each type is turned on or off.
5
5
  import { useEffect } from 'react';
@@ -8,7 +8,7 @@ import { siteName } from '@site/editor';
8
8
  import Screen from './Screen';
9
9
  import { EmptyNote, Group, IconButton, IconLink, ListItem } from './ui';
10
10
  import { apps } from './registry';
11
- import { clearNotifications, markSeen, useNotifications, type Notification } from './notifications';
11
+ import { clearNotifications, markSeen, useNotifications, type Notification } from './notificationData';
12
12
  import { when } from './when';
13
13
  import { t } from './i18n';
14
14
 
package/src/Redirects.tsx CHANGED
@@ -1,7 +1,7 @@
1
1
  // "Videresendelser" under Indstillinger › "Søgemaskineoptimering" (#seo/videresendelser): two rows with how many
2
2
  // each has, each opening a screen of its own.
3
3
  // "Alle videresendelser" (#seo/videresendelser/alle): every address of a page the site sends on elsewhere
4
- // (redirects.ts), by address, with where it sends to, and search. Tapping one opens a sheet where
4
+ // (redirectData.ts), by address, with where it sends to, and search. Tapping one opens a sheet where
5
5
  // where it sends to can be changed, or the redirect deleted; "+" adds a new one (in the editor's own,
6
6
  // src/data/redirects.json). Each save is one commit, and none sends on through another afterwards
7
7
  // (flatten): a redirect to an address that is sent on goes straight to where that ends, and those
@@ -37,7 +37,7 @@ import {
37
37
  } from './ui';
38
38
  import type { Env } from './App';
39
39
  import type { RepoFile } from './github';
40
- import { changesOf, entriesOf, flatten, lookup, readAll, REDIRECT_FILES, setRedirect, type AllRedirects } from './redirects';
40
+ import { changesOf, entriesOf, flatten, lookup, readAll, REDIRECT_FILES, setRedirect, type AllRedirects } from './redirectData';
41
41
  import type { Missing } from './notFound';
42
42
  import { when } from './when';
43
43
  import { headed, letterOf } from './Sheet';
package/src/Screen.tsx CHANGED
@@ -16,7 +16,7 @@ import { settings, siteName } from '@site/editor';
16
16
  import { t } from './i18n';
17
17
  import { IconButton, IconLink, TabPanel, TabRow, TopAppBar, type TabChoice } from './ui';
18
18
  import { up } from './nav';
19
- import { unseen, useNotifications } from './notifications';
19
+ import { unseen, useNotifications } from './notificationData';
20
20
 
21
21
  // `label` is the screen back goes to, named by the back arrow and the breadcrumbs. `guard` takes every
22
22
  // way out of the screen (back, the breadcrumbs and the logo) and goes it, after warning about changes
@@ -110,7 +110,7 @@ const canUninstall = (id: string, files: RepoFile[]) =>
110
110
  * Each says true when it was saved. */
111
111
  function useThemeActions(env: Env) {
112
112
  const [saving, setSaving] = useState(false);
113
- const recordOf = (x: ShownTheme): StoreRecord => ({ version: x.version, ref: x.ref, files: x.files });
113
+ const recordOf = (x: ShownTheme): StoreRecord => ({ version: x.version, ref: x.version, files: x.files });
114
114
 
115
115
  async function change(
116
116
  id: string,
package/src/apps.ts CHANGED
@@ -44,7 +44,7 @@ import type { BlockData } from './blocks';
44
44
  import type { Field, FolderCollection, KindPart, Opens, OwnedList, PreviewVariant } from './schema';
45
45
  import type { ConsentCategory } from '../lib/cookieCategories.mjs';
46
46
  import type { AppCategory, AppCookie, AppNeeds, AppShot, StoreRecord } from './store';
47
- import type { ThirdParty } from './licenses';
47
+ import type { ThirdParty } from './licenseData';
48
48
  import { config } from '@site/config';
49
49
 
50
50
  /** An app's settings, on its page once it is installed (#apps/<app>, AppList.tsx). */
@@ -101,7 +101,7 @@ export type AppScreen = { title: string; page: ComponentType<AppPageProps> };
101
101
  * the editor's parts and given the same `api`. */
102
102
  export type AppTab = { label: string; icon: Icon; page: ComponentType<AppPageProps> };
103
103
 
104
- /** The notifications an app sends ("Notifikationer", notifications.ts). `types`: each kind it sends, by its id, with its
104
+ /** The notifications an app sends ("Notifikationer", notificationData.ts). `types`: each kind it sends, by its id, with its
105
105
  * name (in the site's language, shown with siteText), under "Konto" › "Notifikationer", where each can be turned off; it
106
106
  * sends no other. `check`: what it has to say now, asked with its `api` when the editor opens and every 15 minutes while
107
107
  * it is open; each comes once (by its `id`). */
package/src/checkSite.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  // screens under "Virksomheden" and "Blokke", the apps installed and their settings (src/apps/), and the
6
6
  // colours ("Farver": colours, readable together). A hidden block is not on the site, so it may be unfinished.
7
7
  // The rules of a page's template (collections.ts) too: what may be there, how many, and what must.
8
- // The redirects (redirects.ts): none sends on through another, and none in a loop. And the site's own rules
8
+ // The redirects (redirectData.ts): none sends on through another, and none in a loop. And the site's own rules
9
9
  // (siteProblems in its rules.ts: a button that opens a promotion not on its page).
10
10
  // And the site's texts the editor has no translation of (untranslated).
11
11
  import { check } from './check';
@@ -37,7 +37,7 @@ import { appFolders } from './appFiles';
37
37
  import { colorPairs as shownPairs, sharedParts, translator, writtenApps, writtenBlockParts, writtenCollections } from './site';
38
38
  import { splitBlocks } from '../lib/blocks.mjs';
39
39
  import { appTexts, DEFAULTS_LOCALE, locales, siteLocale, t, type Texts } from './i18n';
40
- import { flatten, readAll, REDIRECT_FILES, type AllRedirects } from './redirects';
40
+ import { flatten, readAll, REDIRECT_FILES, type AllRedirects } from './redirectData';
41
41
 
42
42
  const ratio = (n: number) => n.toLocaleString(config.locale, { maximumFractionDigits: 1 });
43
43
 
package/src/deploy.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  // After a save: tells when the site has been rebuilt with it. Each build writes the commit it was
2
2
  // made from to /editor/version.json (packages/editor/pages/editor/version.json.js), and the editor asks for that
3
- // until it is the saved commit or a later one. The message is a notification of the type `site` (notifications.ts),
3
+ // until it is the saved commit or a later one. The message is a notification of the type `site` (notificationData.ts),
4
4
  // which also comes on the phone or computer when the editor is not on screen.
5
5
  // Each save says what it changed ("farver ændret", "side “Om” ændret", t.app.changes): right after it (savedText),
6
6
  // and, with the other saves since, when the website has been updated with them (changesText, App.tsx save).
@@ -1,4 +1,4 @@
1
- // "Consents" under "Privacy" (Consents.tsx, consents.ts), as in Danish (../da-DK/consents.ts).
1
+ // "Consents" under "Privacy" (Consents.tsx, consentData.ts), as in Danish (../da-DK/consents.ts).
2
2
  import type { Messages } from '../en-US';
3
3
 
4
4
  export const consents: Messages['consents'] = {
@@ -78,7 +78,7 @@ export const apps = {
78
78
  searchStore: 'Search for apps',
79
79
  loadingStore: 'Loading apps',
80
80
  storeFailed: 'Couldn’t load the Appstore',
81
- /** On "Appstore" when the site has none (config.store): only the site's own are there. */
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
83
  /** An app's files did not come from the store as its catalog says. */
84
84
  storeBroken: (app: string) => `${app} couldn’t be loaded correctly from the Appstore`,
@@ -1,4 +1,4 @@
1
- // "Samtykker" under "Privatliv" (Consents.tsx, consents.ts): the visitors' answers in the cookie banner.
1
+ // "Samtykker" under "Privatliv" (Consents.tsx, consentData.ts): the visitors' answers in the cookie banner.
2
2
  export const consents = {
3
3
  title: 'Consents',
4
4
  intro: 'The visitors’ answers in the cookie banner, with the consent ID the banner shows them. They are deleted after 2 years.',
@@ -10,7 +10,7 @@ export const library = {
10
10
  notificationsNew: (count: number) => `Notifications, ${count} new`,
11
11
  noNotifications: 'No notifications',
12
12
  clearNotifications: 'Clear notifications',
13
- /** On "Konto" › "Notifikationer" (Library.tsx NotificationChoices, notifications.ts). */
13
+ /** On "Konto" › "Notifikationer" (Library.tsx NotificationChoices, notificationData.ts). */
14
14
  /** The sliders on "Notifikationer": to "Konto", where they are turned on or off. */
15
15
  notificationChoices: 'Choose notifications',
16
16
  onDevice: 'On the phone',
@@ -1,6 +1,6 @@
1
1
  // "Licenser" under "Indstillinger" (Licenses.tsx): the third-party work the site and the editor are made with, and the
2
2
  // licences on an app's and a theme's page.
3
- import type { LicenseKind } from '../../licenses';
3
+ import type { LicenseKind } from '../../licenseData';
4
4
 
5
5
  export const licenses = {
6
6
  /** The groups: the icon packs, what the editor and the site's own code bring, and each app and theme in use. */
@@ -1,4 +1,4 @@
1
- // "Videresendelser" (Redirects.tsx, redirects.ts).
1
+ // "Videresendelser" (Redirects.tsx, redirectData.ts).
2
2
  export const redirects = {
3
3
  notLoaded: (error: string) => `Couldn’t load the redirects: ${error}`,
4
4
  noFrom: 'Type the address that should redirect.',
@@ -8,7 +8,7 @@ export const redirects = {
8
8
  alreadyRedirects: (from: string) => `There’s already a redirect from ${from}. Edit it in the list.`,
9
9
  /** Deleting one; `via`: how many other addresses send on through it. */
10
10
  deleteAsk: (from: string, via: number) => `Delete the redirect from ${from}? The address then shows the not-found page.`,
11
- /** redirects.ts: the addresses that come back to one, joined by arrows. */
11
+ /** redirectData.ts: the addresses that come back to one, joined by arrows. */
12
12
  loop: (addresses: string) => `The redirects would go in a loop (${addresses}).`,
13
13
  /** checkSite.ts: a redirect that sends on through another, before the site is built. */
14
14
  chain: (from: string, to: string, end: string) =>
package/src/store.ts CHANGED
@@ -1,13 +1,13 @@
1
1
  // The app store ("Indstillinger" › "Appstore", #indstillinger/appstore, AppList.tsx): the apps that are not in the site
2
- // and can be installed. The store is a GitHub repository (config.store) with a catalog (catalog.json): each app with
2
+ // and can be installed. The store is reached through the site's Worker (/api/store/catalog, /api/store/files/<oid>;
3
+ // the Worker holds the site's key to it, and the editor never calls the store itself): each app of its catalog with
3
4
  // its id, its version and the version of the contract it is written to (APP_API), its name, who made it, its icon
4
5
  // (Phosphor's name), what it does (in a sentence, and more fully), its category and the words it is found by, what it
5
6
  // adds (its blocks, the lists it brings and its kinds of the site's lists), what it needs (needsOf), the cookies it
6
7
  // sets, its pictures, what is new in each version (its release notes), its texts in the editor's other languages (as an app's texts.ts), its
7
- // licence and those of the third-party work it brings (StoreLicenses), and where its folder is in the
8
- // repository at which commit, file by file with their blob ids.
9
- // Installing one copies its folder into the site's (config.apps.folder/<id>/, at that commit, each file checked
10
- // against its id), with a note of where it came from (store.json, StoreRecord), and puts it among the apps installed,
8
+ // licence and those of the third-party work it brings (StoreLicenses), and its folder file by file with their blob ids.
9
+ // Installing one copies its folder into the site's (config.apps.folder/<id>/, each file checked against its id), with a
10
+ // note of where it came from (store.json, StoreRecord; its `ref` is the version), and puts it among the apps installed,
11
11
  // in one commit (appChanges); the site's next build has it. Updating copies the newer version over it, and
12
12
  // uninstalling deletes the folder, also in one commit each. What was made with it (its settings, its entries) is the
13
13
  // site's data and stays.
@@ -17,26 +17,19 @@
17
17
  // with its store.json), and it can then be chosen under "Udseende" › "Temaer"; updating and uninstalling are as for
18
18
  // an app (themeChanges), and the theme in use cannot be uninstalled.
19
19
  // The catalog is what is released: an app's or a theme's entry is written from its folder by `npm run store:release
20
- // -- <id>` (scripts/store-release.mjs) when the owner releases it, not by each merge (packages/editor/scripts/releases.mjs). Until the real store exists, it is this repository's own (store/catalog.json, with the apps' folders in
21
- // src/apps/ and the themes' in src/themes/).
20
+ // -- <id>` (scripts/store-release.mjs) when the owner releases it, not by each merge (packages/editor/scripts/releases.mjs).
21
+ // A site whose Worker has no store key has no store (storeConnected): "Appstore" has only the site's own.
22
22
  import { config } from '@site/config';
23
- import { loadBytes, loadFrom, type Change, type RepoFile } from './github';
23
+ import type { Change, RepoFile } from './github';
24
+ import { blobId } from '../lib/git.mjs';
24
25
  import { locale, t } from './i18n';
25
26
  import { hooksOf, type AppHook } from '../lib/hooks';
26
- import type { StoreLicenses } from './licenses';
27
+ import type { StoreLicenses } from './licenseData';
28
+ import { APP_API, THEME_API } from '../lib/contracts.mjs';
29
+ import { RECORD, STANDARD, isText, validApp, validTheme } from '../lib/storeEntries.mjs';
27
30
 
28
- /** The version of the contract between the editor and an app (apps.ts: defineApp, its folder, and the theme's parts it
29
- * draws with: the standard blocks' and the UI components, @theme/blocks/… and @theme/ui/…, imported from
30
- * @morphemecms/cms): an app in the store says which it is written to, and only those the editor can run are
31
- * offered. Raised when an app written to the one before no longer works. */
32
- export const APP_API = 1;
33
-
34
- /** The version of the contract between the editor and a theme (what a theme must and may have, "Hvad et tema må" in
35
- * CLAUDE.md, themeProblems in packages/editor/site/theme.mjs, the standard theme's UI parts with Material 3's names and
36
- * variants, the motion scheme's --motion-* springs and motionSpring/motionTiming (@morphemecms/cms/lib/motion),
37
- * and the classes it styles): only themes written to it are offered. Raised when a theme written to the one before no
38
- * longer works. */
39
- export const THEME_API = 2;
31
+ // The contracts this editor runs (lib/contracts.mjs, also read by the site's Worker).
32
+ export { APP_API, THEME_API };
40
33
 
41
34
  /** The kinds of apps, a filter in the store (t.apps.categories). */
42
35
  export const appCategories = ['booking', 'newsletter', 'payment', 'analytics', 'content', 'design'] as const;
@@ -142,14 +135,12 @@ export type StoreApp = {
142
135
  adds: { blocks: StoreAdd[]; lists: StoreAdd[]; kinds: (StoreAdd & { list: string })[] };
143
136
  /** Its texts in the editor's other languages, by the text as it is written. */
144
137
  texts?: Partial<Record<string, Record<string, string>>>;
145
- /** Where its folder is in the store's repository, and at which commit. */
146
- path: string;
147
- ref: string;
138
+ /** Its folder, file by file. */
148
139
  files: StoreFile[];
149
140
  };
150
141
 
151
142
  /** A theme in the store's catalog: as its theme.json has it (its texts by the editor's language), its pictures of a
152
- * site in it (screenshots/<n>-<key>.jpg, t.library.themeShots) and where its folder is in the store's repository. */
143
+ * site in it (screenshots/<n>-<key>.jpg, t.library.themeShots) and its folder, file by file. */
153
144
  export type StoreTheme = {
154
145
  id: string;
155
146
  version: string;
@@ -168,8 +159,6 @@ export type StoreTheme = {
168
159
  /** The icon packs it brings (its icon-packs/<id>/, with their pack.json: name, who made it, licence), installed with it
169
160
  * and chosen under "Udseende" › "Ikonpakker". */
170
161
  iconPacks?: { id: string; name: string; author: string; license: string }[];
171
- path: string;
172
- ref: string;
173
162
  files: StoreFile[];
174
163
  };
175
164
 
@@ -185,7 +174,8 @@ export type ShownTheme = Omit<StoreTheme, 'description' | 'highlights' | 'releas
185
174
  * it (for the commits), and all its words in both, for the search. */
186
175
  export type ShownApp = StoreApp & { written: string; words: string };
187
176
 
188
- /** Where an app installed from the store came from: its version, the store's commit and its files (store.json). */
177
+ /** Where an app installed from the store came from: its version, the version again as `ref` (an older store.json has
178
+ * the store's commit there) and its files (store.json). */
189
179
  export type StoreRecord = { version: string; ref: string; files: StoreFile[] };
190
180
 
191
181
  /** The store's address in the editor, under "Indstillinger" (its apps; its themes at STORE_THEMES), its address
@@ -200,8 +190,8 @@ export const DEVELOPER_ROUTE = 'apps/udvikler';
200
190
  export const appFolder = (id: string) => `${config.apps.folder}/${id}`;
201
191
  /** A theme's folder in the site; the standard theme's is always there. */
202
192
  export const themeFolder = (id: string) => `${config.themes}/${id}`;
203
- export const STANDARD_THEME = 'saturn';
204
- export const RECORD = 'store.json';
193
+ export const STANDARD_THEME = STANDARD;
194
+ export { RECORD };
205
195
 
206
196
  /** Whether a version is newer than another ("1.10.0" than "1.9.2"). */
207
197
  export function newer(a: string, b: string): boolean {
@@ -230,27 +220,8 @@ const notesOf = (x: unknown) =>
230
220
  ? Object.fromEntries(Object.entries(x).filter((e): e is [string, string] => isText(e[1])))
231
221
  : undefined;
232
222
 
233
- const isText = (v: unknown): v is string => typeof v === 'string' && v.trim() !== '';
234
- const isAdd = (v: any) => isText(v?.label) && isText(v?.icon);
235
- const ID = /^[a-z0-9][a-z0-9-]*$/;
236
- // A file's path in an app's folder: only within it.
237
- const isPath = (v: unknown) => isText(v) && v.split('/').every((part) => part !== '' && part !== '.' && part !== '..');
238
-
239
- /** Whether an entry of the catalog has what an app needs, written to the contract the editor runs, with files only in
240
- * its own folder, and not the store's own address. */
241
- function valid(a: any): a is StoreApp {
242
- return (
243
- ID.test(a?.id ?? '') &&
244
- ![STORE_BEFORE, DEVELOPER_ROUTE].includes(`apps/${a.id}`) &&
245
- isText(a.version) &&
246
- a.api === APP_API &&
247
- [a.label, a.publisher, a.icon, a.hint, a.path, a.ref].every(isText) &&
248
- Array.isArray(a.files) &&
249
- a.files.length > 0 &&
250
- a.files.every((f: any) => isText(f?.oid) && isPath(f?.path) && f.path !== RECORD) &&
251
- ['blocks', 'lists', 'kinds'].every((k) => Array.isArray(a.adds?.[k]) && a.adds[k].every(isAdd))
252
- );
253
- }
223
+ const validStoreApp = (a: any): a is StoreApp => validApp(a, APP_API);
224
+ const validStoreTheme = (x: any): x is StoreTheme => validTheme(x, THEME_API);
254
225
 
255
226
  /** The apps in a catalog (catalog.json, as text) the editor can install, with their texts in its language. */
256
227
  export function catalogApps(text: string): ShownApp[] {
@@ -261,7 +232,7 @@ export function catalogApps(text: string): ShownApp[] {
261
232
  return [];
262
233
  }
263
234
  const apps: unknown[] = Array.isArray(json?.apps) ? json.apps : [];
264
- return apps.filter(valid).map((a) => {
235
+ return apps.filter(validStoreApp).map((a) => {
265
236
  const own = a.texts?.[locale];
266
237
  const say = (s: string) => own?.[s] ?? s;
267
238
  const add = <A extends StoreAdd>(x: A): A => ({ ...x, label: say(x.label), ...(x.hint ? { hint: say(x.hint) } : {}) });
@@ -291,20 +262,6 @@ export function catalogApps(text: string): ShownApp[] {
291
262
  });
292
263
  }
293
264
 
294
- /** Whether an entry of the catalog has what a theme needs, written to the contract the editor runs, with files only
295
- * in its own folder; never the standard theme, which every site has. */
296
- function validTheme(x: any): x is StoreTheme {
297
- return (
298
- ID.test(x?.id ?? '') &&
299
- x.id !== STANDARD_THEME &&
300
- x.api === THEME_API &&
301
- [x.version, x.name, x.author, x.icon, x.path, x.ref].every(isText) &&
302
- Array.isArray(x.files) &&
303
- x.files.length > 0 &&
304
- x.files.every((f: any) => isText(f?.oid) && isPath(f?.path) && f.path !== RECORD)
305
- );
306
- }
307
-
308
265
  /** The themes in a catalog (catalog.json, as text) the editor can install, with their texts in its language (else the
309
266
  * first they have). */
310
267
  export function catalogThemes(text: string): ShownTheme[] {
@@ -316,7 +273,7 @@ export function catalogThemes(text: string): ShownTheme[] {
316
273
  }
317
274
  const themes: unknown[] = Array.isArray(json?.themes) ? json.themes : [];
318
275
  const inLocale = <T>(x?: Record<string, T>) => x && (x[locale] ?? Object.values(x)[0]);
319
- return themes.filter(validTheme).map((x) => {
276
+ return themes.filter(validStoreTheme).map((x) => {
320
277
  const description = inLocale(x.description);
321
278
  const highlights = inLocale(x.highlights) ?? [];
322
279
  return {
@@ -338,49 +295,72 @@ export function catalogThemes(text: string): ShownTheme[] {
338
295
  // The catalog, read once while the editor is open (again after it could not be read).
339
296
  let catalog: Promise<string> | null = null;
340
297
 
341
- /** Whether the site has an app store (config.store); without one, "Appstore" has only the site's own apps and themes. */
342
- export const hasStore = !!config.store;
343
-
344
- // The store's repository; only asked for what is in its catalog, so never without one.
345
- const storeRepo = () => {
346
- if (!config.store) throw new Error(t.apps.noStore);
347
- return config.store;
348
- };
349
-
298
+ // The catalog as the site's Worker passes it on: `{ connected: false }` when the site has no key to the store.
350
299
  function catalogText(): Promise<string> {
351
- if (!config.store) return Promise.resolve('{}');
352
- catalog ??= loadFrom(config.store, config.store.branch, [config.store.catalog]).then(([file]) => {
353
- if (file?.text == null) throw new Error(t.apps.storeFailed);
354
- return file.text;
355
- });
300
+ catalog ??= fetch('/api/store/catalog').then(
301
+ (res) => {
302
+ if (!res.ok) throw new Error(t.apps.storeFailed);
303
+ return res.text();
304
+ },
305
+ () => {
306
+ throw new Error(t.apps.storeFailed);
307
+ },
308
+ );
356
309
  catalog.catch(() => (catalog = null));
357
310
  return catalog;
358
311
  }
359
312
 
313
+ /** Whether the site's Worker has a store key; without one, "Appstore" has only the site's own apps and themes. Fails when
314
+ * the catalog cannot be read. */
315
+ export const storeConnected = (): Promise<boolean> =>
316
+ catalogText().then((text) => {
317
+ try {
318
+ return JSON.parse(text)?.connected !== false;
319
+ } catch {
320
+ return true;
321
+ }
322
+ });
323
+
360
324
  /** The store's apps. Fails when the catalog cannot be read. */
361
325
  export const readCatalog = (): Promise<ShownApp[]> => catalogText().then(catalogApps);
362
326
 
363
327
  /** The store's themes. Fails when the catalog cannot be read. */
364
328
  export const readThemes = (): Promise<ShownTheme[]> => catalogText().then(catalogThemes);
365
329
 
366
- /** An app's (or a theme's) files from the store (its folder at the catalog's commit), each checked against its blob id. */
330
+ // A file from the store by its blob id (through the site's Worker), checked against the id.
331
+ async function readFile(oid: string): Promise<Uint8Array> {
332
+ const res = await fetch(`/api/store/files/${oid}`);
333
+ if (!res.ok) throw new Error(t.apps.storeFailed);
334
+ const bytes = new Uint8Array(await res.arrayBuffer());
335
+ if ((await blobId(bytes)) !== oid) throw new Error(t.apps.storeFailed);
336
+ return bytes;
337
+ }
338
+
339
+ // The text of a file, or null when it is binary (a NUL, or not UTF-8), as GitHub told them apart before.
340
+ function textOf(bytes: Uint8Array): string | null {
341
+ if (bytes.includes(0)) return null;
342
+ try {
343
+ // ignoreBOM keeps a leading BOM in the text, so it encodes back to the same bytes (and the same blob id).
344
+ return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
345
+ } catch {
346
+ return null;
347
+ }
348
+ }
349
+
350
+ /** An app's (or a theme's) files from the store, each checked against its blob id; text files as text. */
367
351
  export async function readApp(app: {
368
- path: string;
369
- ref: string;
370
352
  files: StoreFile[];
371
353
  label?: string;
372
354
  name?: string;
373
355
  }): Promise<{ path: string; content: string | Uint8Array }[]> {
374
- const found = await loadFrom(
375
- storeRepo(),
376
- app.ref,
377
- app.files.map((f) => `${app.path}/${f.path}`),
378
- );
379
356
  return Promise.all(
380
357
  app.files.map(async (f) => {
381
- const file = found.find((x) => x.path === `${app.path}/${f.path}`);
382
- if (!file || file.oid !== f.oid) throw new Error(t.apps.storeBroken(app.label ?? app.name ?? app.path));
383
- return { path: f.path, content: file.text ?? (await loadBytes(f.oid, undefined, storeRepo())) };
358
+ try {
359
+ const bytes = await readFile(f.oid);
360
+ return { path: f.path, content: textOf(bytes) ?? bytes };
361
+ } catch {
362
+ throw new Error(t.apps.storeBroken(app.label ?? app.name ?? f.path));
363
+ }
384
364
  }),
385
365
  );
386
366
  }
@@ -395,7 +375,7 @@ export async function readScreenshots(app: StoreApp): Promise<{ src: string; lab
395
375
  const type = types[x.file.split('.').pop()!.toLowerCase()];
396
376
  if (!oid || !type) return null;
397
377
  try {
398
- const bytes = await loadBytes(oid, undefined, storeRepo());
378
+ const bytes = await readFile(oid);
399
379
  return { src: URL.createObjectURL(new Blob([bytes as BlobPart], { type })), label: x.label };
400
380
  } catch {
401
381
  return null;
@@ -413,7 +393,7 @@ export async function readThemeScreenshots(theme: Pick<StoreTheme, 'screenshots'
413
393
  const oid = theme.files.find((f) => f.path === `${SCREENSHOTS}/${x.file}`)?.oid;
414
394
  if (!oid) return null;
415
395
  try {
416
- const bytes = await loadBytes(oid, undefined, storeRepo());
396
+ const bytes = await readFile(oid);
417
397
  return { key: x.key, src: URL.createObjectURL(new Blob([bytes as BlobPart], { type: 'image/jpeg' })) };
418
398
  } catch {
419
399
  return null;
package/src/themes.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  // from the store (packages/editor/src/store.ts), where it came from (store.json) are read: their code is not loaded.
6
6
  import chosen from '@data/theme.json';
7
7
  import { STANDARD_THEME, type StoreRecord } from './store';
8
- import type { ThirdParty } from './licenses';
8
+ import type { ThirdParty } from './licenseData';
9
9
 
10
10
  export type ThemeInfo = {
11
11
  /** Its folder in src/themes/, as saved in src/data/theme.json. */
package/starter/README.md CHANGED
@@ -9,6 +9,7 @@ Editoren, standardtemaet *Material* og sitets Worker er pakken [`@morphemecms/cm
9
9
 
10
10
  - `src/site/config.ts`: sitets navn, domæne, sprog og GitHub-repo; resten er pakkens standard (`defineSite`).
11
11
  - `src/site/worker.js`: det, Workeren har fra sitet.
12
+ - Hemmeligheden `MORPHEME_STORE_KEY` (`npx wrangler secret put MORPHEME_STORE_KEY`) forbinder sitet til Morpheme-butikken; uden den har *Appstore* kun sitets egne apps og temaer.
12
13
  - `src/content/`: siderne og indlæggene, `src/data/`: sitets indstillinger, `public/`: billeder, logo og ikoner.
13
14
  - `src/themes/` og `src/apps/`: temaer og apps fra appbutikken, når der er installeret nogen.
14
15
 
@@ -1272,7 +1272,7 @@
1272
1272
  /* ---- Containment: Dialog, BottomSheet, Carousel ---- */
1273
1273
  /*
1274
1274
  Dialogs (Dialog.tsx, dialogClass, dialogClassOf, dialogModalClass, dialogFullScreenModalClass, and dialog() in
1275
- dialog.ts for what a modal one does in a browser; m3.material.io/components/dialogs): .dialog is the container (a
1275
+ dialogControl.ts for what a modal one does in a browser; m3.material.io/components/dialogs): .dialog is the container (a
1276
1276
  pop-up's box, the cookie question), and .dialog-modal the <dialog> it is shown in over the page, in the middle with
1277
1277
  the scrim behind it (a pop-up, Popup.astro). With its parts it is Material's basic dialog, .dialog-basic: evt. its
1278
1278
  icon (.dialog-icon, centred over the headline with .dialog-with-icon), .dialog-headline, .dialog-supporting-text,
@@ -1290,7 +1290,7 @@
1290
1290
  --dialog-full-screen-max-width, --dialog-full-screen-headline-size a full-screen dialog from 48rem, and its
1291
1291
  headline in its header
1292
1292
  Bottom sheets (BottomSheet.tsx, bottomSheetClass, bottomSheetContainerClass, and bottomSheet() and
1293
- standardBottomSheet() in bottomSheet.ts for what it does in a browser, bottomSheet.script.ts on the site;
1293
+ standardBottomSheet() in bottomSheetControl.ts for what it does in a browser, bottomSheet.script.ts on the site;
1294
1294
  m3.material.io/components/bottom-sheets): a modal one's <dialog> is .bottom-sheet (an app's: Calendly's calendar), at
1295
1295
  most 44rem wide and nearly as high as the screen, from the bottom on a phone and in the middle of the screen from
1296
1296
  48rem (a dialog there, rounded all round, without the drag handle; with .bottom-sheet-fit as high as what is in
@@ -1669,7 +1669,7 @@
1669
1669
  }
1670
1670
 
1671
1671
  /* A standard bottom sheet: at the bottom of the screen with the page usable behind it, no scrim; as high as the state
1672
- it rests in (data-state), its height followed by bottomSheet.ts while it moves. 100cqh is the screen's height, or
1672
+ it rests in (data-state), its height followed by bottomSheetControl.ts while it moves. 100cqh is the screen's height, or
1673
1673
  the frame's that is a size container (the editor's drawing, which holds it with contain: layout). */
1674
1674
  .bottom-sheet-standard {
1675
1675
  position: fixed;
@@ -3866,7 +3866,7 @@
3866
3866
  .divider-inset (in from the start) or .divider-middle-inset (in from both sides), and .divider-vertical (top to
3867
3867
  bottom, between things side by side).
3868
3868
  --divider-color, --divider-thickness, --divider-inset the line, and how far in an inset one starts
3869
- Side sheets (SideSheet.tsx, sideSheetClass, sideSheetContainerClass, and sideSheet() in sideSheet.ts for what a modal
3869
+ Side sheets (SideSheet.tsx, sideSheetClass, sideSheetContainerClass, and sideSheet() in sideSheetControl.ts for what a modal
3870
3870
  one does in a browser; m3.material.io/components/side-sheets): .side-sheet-container with its kind,
3871
3871
  .side-sheet-standard (beside what it is about, with a line between) or .side-sheet-modal (over the page, in the
3872
3872
  <dialog> .side-sheet at the side of the screen, over the scrim like a dialog's); in it .side-sheet-header
@@ -3,14 +3,14 @@
3
3
  // close button, `closeLabel`), what it shows (`children`, filling the rest; what stands in place of a frame while it
4
4
  // loads has bottomSheetCoverClass) and evt. a footer (`footer`). Its kind (`kind`) is Material's:
5
5
  // - 'modal' (the default): over the page and its scrim, in a <dialog> with bottomSheetClass (an app's: Calendly's
6
- // Sheet.astro), which bottomSheet() in bottomSheet.ts opens, closes and lets be dragged down; in the editor only
6
+ // Sheet.astro), which bottomSheet() in bottomSheetControl.ts opens, closes and lets be dragged down; in the editor only
7
7
  // drawn, as high as `height` says.
8
8
  // - 'standard': at the bottom of the screen with the page usable behind it, without a scrim, a <section> named by its
9
9
  // headline. It rests in one of its states (`state`, where it starts: 'collapsed', only the handle and the header;
10
10
  // 'half-expanded', at most half the screen's height; 'expanded', as high as what it shows, nearly to the top; or
11
11
  // 'hidden'), and its handle is a button (`dragHandleLabel`, what it says to a screen reader) that is dragged between
12
12
  // them or tapped to expand and collapse it; with a close button it can be hidden. On the site bottomSheet.script.ts
13
- // lets it be dragged (standardBottomSheet() in bottomSheet.ts, which a block's or an app's script can also use); in
13
+ // lets it be dragged (standardBottomSheet() in bottomSheetControl.ts, which a block's or an app's script can also use); in
14
14
  // the editor it is drawn in its state and dragged here, at the bottom of its frame (which holds it with
15
15
  // contain: layout, and is a size container).
16
16
  // The editor's own sheets (choices, fields) are modal ones in a <dialog> with bottomSheetFitClass, as high as what is in
@@ -21,7 +21,7 @@
21
21
  import { useEffect, useRef, type ReactNode } from 'react';
22
22
  import { useOnSite } from '@morphemecms/cms/src/standard/view';
23
23
  import IconButton from '@theme/ui/IconButton';
24
- import { standardBottomSheet } from '@theme/ui/bottomSheet';
24
+ import { standardBottomSheet } from '@theme/ui/bottomSheetControl';
25
25
 
26
26
  /** The kinds of a bottom sheet, and the states a standard one rests in, as Material 3 names them. */
27
27
  export type BottomSheetKind = 'modal' | 'standard';
@@ -10,7 +10,7 @@
10
10
  // (`divider`), and under them what it shows (`supportingText` and `children`, scrolling). From 48rem, where Material
11
11
  // shows it as a dialog, it is in the middle of the screen over the scrim, with the same header.
12
12
  // Shown modally it is in a <dialog> with dialogModalClass (a full-screen one with dialogFullScreenModalClass), which
13
- // dialog() in dialog.ts (or the script that shows it: Popup.astro) opens and closes. How it looks is the theme's
13
+ // dialog() in dialogControl.ts (or the script that shows it: Popup.astro) opens and closes. How it looks is the theme's
14
14
  // (--dialog-*). Other attributes go on it, and `className` after its own classes (its place). An Astro component
15
15
  // without React gives an element the same classes with dialogClass.
16
16
  import type { ReactNode } from 'react';
@@ -3,7 +3,7 @@
3
3
  // close button, `closeLabel`, and what it does in the editor, `onClose`; none without it), what it shows (`children`, scrolling when it is long) and evt. its
4
4
  // actions at the bottom (`actions`: buttons). Its kind (`kind`): 'standard', beside what it is about on the page, an
5
5
  // <aside> named by its headline, its place the page's; or 'modal', over the page in a <dialog> with sideSheetClass over
6
- // the scrim, which sideSheet() in sideSheet.ts opens, closes and lets slide in from the side (the <dialog> gets
6
+ // the scrim, which sideSheet() in sideSheetControl.ts opens, closes and lets slide in from the side (the <dialog> gets
7
7
  // aria-labelledby with `headlineId`). How it looks is the theme's (--side-sheet-*). In the editor only drawn, as high as
8
8
  // its place.
9
9
  import type { ReactNode } from 'react';
@@ -1,7 +1,7 @@
1
1
  // What a standard bottom sheet (BottomSheet.tsx with kind 'standard') does on the site: it can be dragged between its
2
- // states and tapped open and closed (standardBottomSheet in bottomSheet.ts). Run on every page by the theme's Base.astro
2
+ // states and tapped open and closed (standardBottomSheet in bottomSheetControl.ts). Run on every page by the theme's Base.astro
3
3
  // (blockScripts, packages/editor/site/blockScripts.ts); without a standard bottom sheet on the page it does nothing.
4
- import { standardBottomSheet } from '@theme/ui/bottomSheet';
4
+ import { standardBottomSheet } from '@theme/ui/bottomSheetControl';
5
5
 
6
6
  export default function bottomSheets() {
7
7
  document.querySelectorAll<HTMLElement>('[data-bottom-sheet-standard]').forEach((sheet) => standardBottomSheet(sheet));
package/worker/consent.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // The visitors' answers in the cookie banner, so the site can show that a visitor said yes (GDPR article 7(1)): the
2
- // consent log, "Samtykker" under "Privatliv" in the editor (packages/editor/src/consents.ts, Consents.tsx).
2
+ // consent log, "Samtykker" under "Privatliv" in the editor (packages/editor/src/consentData.ts, Consents.tsx).
3
3
  // Kept in Cloudflare D1 (the binding CONSENT in wrangler.jsonc), never in the repository: one row per answer, with the
4
4
  // random consent id the visitor's browser keeps (consentId in src/scripts/tracking.ts, shown in the banner under
5
5
  // "Cookieindstillinger"), when, the yes or no to each category the banner asked about, and the banner it was given in
package/worker/index.js CHANGED
@@ -13,6 +13,8 @@
13
13
  // - GitHub sign-in for the editor at /editor/ (/oauth/*, packages/editor/worker/oauth.js), kept in a cookie only the Worker reads
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
+ // - 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.
16
18
  // - the addresses visitors came to that do not exist, for the editor (/api/not-found, packages/editor/worker/not-found.js).
17
19
  // - the visitors' answers in the cookie banner, the consent log (/api/consent, packages/editor/worker/consent.js, in D1), and each hour
18
20
  // those older than two years deleted.
@@ -32,6 +34,7 @@ import { handleNotFound, isVisit, json, recordNotFound } from './not-found.js';
32
34
  import { forgetOldConsents, handleConsent } from './consent.js';
33
35
  import { access, mayEdit } from './session.js';
34
36
  import { handleGitHub } from './editor-github.js';
37
+ import { handleStore } from './store.js';
35
38
  import { handleR2 } from './r2-status.js';
36
39
  import { publishScheduled, SCHEDULED_LIST } from './publish.js';
37
40
  import { commitFiles, readFiles } from './github.js';
@@ -143,6 +146,7 @@ export default {
143
146
  if (url.pathname === '/api/consent') return handleConsent(request, env);
144
147
  if (url.pathname === '/api/r2') return handleR2(request, env);
145
148
  if (url.pathname.startsWith('/api/github/')) return handleGitHub(request, env, ctx);
149
+ if (url.pathname.startsWith('/api/store/')) return handleStore(request, env, ctx);
146
150
  if (/^\/api\/push(\/wait)?\/?$/.test(url.pathname)) return handlePush(request, env);
147
151
  const answer = appAnswer(request, url, env, ctx);
148
152
  if (answer) return answer;