@morphemecms/cms 0.13.0 → 0.14.0-next-20261107093528

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 (166) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/defaults/editor.ts +3 -14
  3. package/icons/lucide/dots-three-vertical.svg +1 -0
  4. package/icons/material-symbols/dots-three-vertical.svg +1 -0
  5. package/icons/phosphor/dots-three-vertical.svg +1 -0
  6. package/lib/appChanges.d.mts +16 -0
  7. package/lib/appChanges.mjs +20 -0
  8. package/lib/appChanges.test.mjs +30 -0
  9. package/lib/apps.d.mts +2 -2
  10. package/lib/apps.mjs +13 -7
  11. package/lib/appsFile.d.mts +20 -0
  12. package/lib/appsFile.mjs +57 -0
  13. package/lib/appsFile.test.mjs +53 -0
  14. package/lib/build-settings.mjs +1 -1
  15. package/lib/changelog.d.mts +7 -0
  16. package/lib/changelog.mjs +16 -5
  17. package/lib/footerClearance.d.mts +1 -0
  18. package/lib/footerClearance.mjs +9 -0
  19. package/lib/footerClearance.test.mjs +24 -0
  20. package/lib/footerLinks.d.mts +12 -0
  21. package/lib/footerLinks.mjs +26 -0
  22. package/lib/footerLinks.test.mjs +32 -0
  23. package/lib/google-result.test.mjs +40 -9
  24. package/lib/menu.mjs +1 -1
  25. package/lib/menu.test.mjs +9 -8
  26. package/lib/releaseNotes.d.mts +136 -0
  27. package/lib/releaseNotes.mjs +249 -0
  28. package/lib/releaseNotes.test.mjs +267 -0
  29. package/lib/serviceFeed.d.mts +2 -0
  30. package/lib/serviceFeed.mjs +13 -0
  31. package/lib/serviceFeed.test.mjs +53 -0
  32. package/lib/serviceStatus.d.mts +72 -0
  33. package/lib/serviceStatus.mjs +143 -0
  34. package/lib/serviceStatus.test.mjs +136 -0
  35. package/lib/strayDividers.d.mts +6 -0
  36. package/lib/strayDividers.mjs +15 -0
  37. package/lib/strayDividers.test.mjs +25 -0
  38. package/package.json +5 -3
  39. package/release-notes/i18n/da-DK/pending/apps-turn-off.json +6 -0
  40. package/release-notes/i18n/da-DK/pending/apps-turn-off.webp +0 -0
  41. package/release-notes/i18n/da-DK/pending/developer-menu.json +5 -0
  42. package/release-notes/i18n/da-DK/pending/dynamic-lists-page.json +5 -0
  43. package/release-notes/i18n/da-DK/pending/editor-footer.json +6 -0
  44. package/release-notes/i18n/da-DK/pending/editor-footer.webp +0 -0
  45. package/release-notes/i18n/da-DK/pending/editor-toolbar-dock.json +5 -0
  46. package/release-notes/i18n/da-DK/pending/google-preview-closer.json +6 -0
  47. package/release-notes/i18n/da-DK/pending/google-preview-closer.webp +0 -0
  48. package/release-notes/i18n/da-DK/pending/morpheme-connection-page.json +5 -0
  49. package/release-notes/i18n/da-DK/pending/quiet-lamps-wander.json +6 -0
  50. package/release-notes/i18n/da-DK/pending/quiet-lamps-wander.webp +0 -0
  51. package/release-notes/i18n/en-US/pending/apps-turn-off.json +6 -0
  52. package/release-notes/i18n/en-US/pending/apps-turn-off.webp +0 -0
  53. package/release-notes/i18n/en-US/pending/developer-menu.json +5 -0
  54. package/release-notes/i18n/en-US/pending/dynamic-lists-page.json +5 -0
  55. package/release-notes/i18n/en-US/pending/editor-footer.json +6 -0
  56. package/release-notes/i18n/en-US/pending/editor-footer.webp +0 -0
  57. package/release-notes/i18n/en-US/pending/editor-toolbar-dock.json +5 -0
  58. package/release-notes/i18n/en-US/pending/google-preview-closer.json +6 -0
  59. package/release-notes/i18n/en-US/pending/google-preview-closer.webp +0 -0
  60. package/release-notes/i18n/en-US/pending/morpheme-connection-page.json +5 -0
  61. package/release-notes/i18n/en-US/pending/quiet-lamps-wander.json +6 -0
  62. package/release-notes/i18n/en-US/pending/quiet-lamps-wander.webp +0 -0
  63. package/scripts/app-screenshots.mjs +4 -4
  64. package/scripts/build-settings-problems.mjs +1 -1
  65. package/scripts/built-site.mjs +1 -1
  66. package/scripts/check-content.mjs +1 -1
  67. package/scripts/check-release-notes.mjs +12 -0
  68. package/scripts/check-worker.mjs +1 -1
  69. package/scripts/editor-screens.mjs +32 -16
  70. package/scripts/release-notes-pending.mjs +46 -0
  71. package/scripts/release-notes.mjs +39 -0
  72. package/site/apps.ts +10 -4
  73. package/site/consent.ts +3 -3
  74. package/site/defineSite.mjs +1 -1
  75. package/site/icons.d.mts +4 -0
  76. package/site/icons.mjs +2 -0
  77. package/site/pageColors.ts +3 -3
  78. package/site/siteCsp.d.mts +1 -1
  79. package/site/siteCsp.mjs +3 -2
  80. package/src/ActionParts.tsx +1 -1
  81. package/src/Advanced.tsx +4 -4
  82. package/src/App.tsx +22 -17
  83. package/src/AppEnabledSwitch.tsx +21 -0
  84. package/src/AppList.tsx +47 -19
  85. package/src/AppSettingsTab.tsx +9 -5
  86. package/src/BlockParts.tsx +7 -7
  87. package/src/BlockSettings.tsx +3 -3
  88. package/src/CmsPage.tsx +11 -10
  89. package/src/Colors.tsx +2 -2
  90. package/src/CommunicationParts.tsx +18 -2
  91. package/src/ContainmentParts.tsx +1 -1
  92. package/src/DataForm.tsx +1 -0
  93. package/src/EditorFooter.tsx +19 -9
  94. package/src/EntryForm.tsx +1 -0
  95. package/src/FooterCopyright.tsx +7 -0
  96. package/src/FooterDivider.tsx +5 -0
  97. package/src/FooterLink.tsx +18 -0
  98. package/src/FooterLinks.tsx +34 -0
  99. package/src/Library.tsx +21 -10
  100. package/src/MainDrawerParts.tsx +1 -1
  101. package/src/MarkdownEditor.tsx +1 -1
  102. package/src/ModalSheetSample.tsx +1 -1
  103. package/src/MorphemeConnection.tsx +30 -0
  104. package/src/MorphemeLogo.tsx +9 -0
  105. package/src/NavigationParts.tsx +1 -1
  106. package/src/NavigationRestParts.tsx +1 -1
  107. package/src/Parts.tsx +9 -9
  108. package/src/PartsShown.tsx +1 -1
  109. package/src/PickerParts.tsx +1 -1
  110. package/src/ReleaseNoteCard.tsx +17 -0
  111. package/src/ReleaseNotes.tsx +52 -0
  112. package/src/Screen.tsx +21 -12
  113. package/src/SearchPreview.tsx +37 -14
  114. package/src/SelectionParts.tsx +1 -1
  115. package/src/ServiceStatus.tsx +59 -0
  116. package/src/SideColumn.tsx +2 -2
  117. package/src/StoreConnectNotice.tsx +1 -1
  118. package/src/StoreConnectedRow.tsx +20 -14
  119. package/src/StoreStatus.tsx +2 -10
  120. package/src/ThemeStoreScreen.tsx +1 -2
  121. package/src/Tokens.tsx +2 -2
  122. package/src/apps.ts +16 -14
  123. package/src/buildSettings.ts +1 -1
  124. package/src/cmsView.d.mts +1 -0
  125. package/src/cmsView.mjs +4 -2
  126. package/src/cmsView.test.mjs +6 -0
  127. package/src/editor.css +13 -2
  128. package/src/examples.ts +2 -2
  129. package/src/fabPresence.tsx +24 -0
  130. package/src/googleResult.d.mts +12 -1
  131. package/src/googleResult.mjs +19 -2
  132. package/src/i18n/da-DK/apps.ts +5 -1
  133. package/src/i18n/da-DK/cms.ts +3 -0
  134. package/src/i18n/da-DK/common.ts +3 -2
  135. package/src/i18n/da-DK/footer.ts +26 -0
  136. package/src/i18n/da-DK/index.ts +2 -0
  137. package/src/i18n/da-DK/parts.ts +5 -0
  138. package/src/i18n/en-US/advanced.ts +1 -1
  139. package/src/i18n/en-US/apps.ts +6 -1
  140. package/src/i18n/en-US/cms.ts +5 -2
  141. package/src/i18n/en-US/common.ts +6 -4
  142. package/src/i18n/en-US/footer.ts +24 -0
  143. package/src/i18n/en-US/index.ts +2 -0
  144. package/src/i18n/en-US/parts.ts +8 -3
  145. package/src/menuChildren.ts +6 -14
  146. package/src/movedRoutes.mjs +12 -8
  147. package/src/movedRoutes.test.mjs +13 -10
  148. package/src/r2Caching.ts +1 -1
  149. package/src/registry.tsx +2 -2
  150. package/src/schema.ts +1 -1
  151. package/src/site.ts +2 -2
  152. package/src/statusReport.ts +24 -0
  153. package/src/store.ts +4 -8
  154. package/src/themeUi.ts +7 -7
  155. package/src/ui.tsx +4 -2
  156. package/src/useFooterClearance.ts +30 -0
  157. package/src/useStrayDividers.ts +24 -0
  158. package/theme/components/AppSlot.astro +4 -4
  159. package/theme/styles/global.css +10 -0
  160. package/theme/styles/ui.css +13 -0
  161. package/theme/ui/Badge.tsx +9 -3
  162. package/worker/cms-update.js +49 -12
  163. package/worker/index.js +10 -4
  164. package/worker/plan-status.js +1 -1
  165. package/worker/r2-status.js +1 -1
  166. package/worker/service-status.js +18 -0
package/site/apps.ts CHANGED
@@ -1,11 +1,17 @@
1
- // The apps on the site (src/apps/<app>/, packages/editor/src/apps.ts): which are installed under
2
- // "Indstillinger" › "Apps" in the editor (apps.json), and each app's settings (apps/<app>.json), as the
1
+ // The apps on the site (src/apps/<app>/, packages/editor/src/apps.ts): which are installed (and which of them are
2
+ // turned off) under "Indstillinger" › "Apps" in the editor (apps.json), and each app's settings (apps/<app>.json), as the
3
3
  // site's build reads them. What an app puts on every page (AppSlot.astro) is there only while it is
4
4
  // installed; what was made with it (a pop-up, a block on a page) stays when it is uninstalled.
5
5
  import data from '@data/apps.json';
6
+ import { activeOf, readAppsFile } from '../lib/appsFile.mjs';
6
7
 
7
- /** The ids of the apps installed. */
8
- export const installed: string[] = Array.isArray(data.installed) ? data.installed.map(String) : [];
8
+ const state = readAppsFile(JSON.stringify(data));
9
+
10
+ /** The ids of the apps installed, also those turned off. */
11
+ export const installed: string[] = state.installed;
12
+
13
+ /** The ids of the apps installed and not turned off: what runs, and puts something on the site. */
14
+ export const active: string[] = activeOf(state);
9
15
 
10
16
  // Each app's saved settings, by its id (the file's name).
11
17
  const saved = Object.fromEntries(
package/site/consent.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  // What the cookie banner asks about: the categories of cookies (@morphemecms/cms/lib/cookieCategories.mjs) of what the
2
- // installed apps load with their settings (`cookies` in their index.ts: Google Analytics statistics, Google Ads
2
+ // active apps (installed, not turned off) load with their settings (`cookies` in their index.ts: Google Analytics statistics, Google Ads
3
3
  // marketing, Google Tag Manager both), each a yes or no of its own; and whether the site has the banner (and
4
4
  // "Cookieindstillinger" in the footer) at all: when it is switched on ("Privatliv" › "Cookiebanner",
5
5
  // src/data/cookies.json) and there is something to ask about. For the theme (Base.astro, Footer.astro,
@@ -8,7 +8,7 @@ import type { AppDefinition } from '../src/apps';
8
8
  import { appIdOf } from '../src/apps';
9
9
  import { CONSENT_CATEGORIES, type ConsentCategory } from '../lib/cookieCategories.mjs';
10
10
  import cookies from '@data/cookies.json';
11
- import { installed, settingsOf } from './apps';
11
+ import { active, settingsOf } from './apps';
12
12
 
13
13
  // Only the apps' index.ts, without their blocks (as opens.ts).
14
14
  const appFiles = import.meta.glob<{ default: AppDefinition }>('/src/apps/*/index.ts', { eager: true });
@@ -16,7 +16,7 @@ const appFiles = import.meta.glob<{ default: AppDefinition }>('/src/apps/*/index
16
16
  const asked = new Set(
17
17
  Object.entries(appFiles).flatMap(([path, { default: app }]) => {
18
18
  const id = appIdOf(path);
19
- return installed.includes(id) ? (app.cookies?.(settingsOf(id, app.settings?.values)) ?? []) : [];
19
+ return active.includes(id) ? (app.cookies?.(settingsOf(id, app.settings?.values)) ?? []) : [];
20
20
  }),
21
21
  );
22
22
 
@@ -54,7 +54,7 @@ const CHOICES = {
54
54
 
55
55
  /** The same on every site. */
56
56
  const STANDARD = {
57
- /** The Worker's settings: the editor turns R2 caching on and off in them ("Indstillinger" › "Avanceret", src/r2Caching.ts). */
57
+ /** The Worker's settings: the editor turns R2 caching on and off in them (Settings › Developer › Caching and builds, src/r2Caching.ts). */
58
58
  wrangler: 'wrangler.jsonc',
59
59
  /** The folders the editor loads: the blog posts, the pages, the data files, the pictures and the PDFs, videos and zip
60
60
  * files (shown with the pictures in "Filer", at /filer/<name> on the site). */
package/site/icons.d.mts CHANGED
@@ -154,6 +154,7 @@ export const MATERIAL_SYMBOLS: {
154
154
  desktop: string;
155
155
  'device-mobile': string;
156
156
  'door-open': string;
157
+ 'dots-three-vertical': string;
157
158
  'download-simple': string;
158
159
  'envelope-simple': string;
159
160
  'envelope-simple-fill': string;
@@ -345,6 +346,7 @@ export const LUCIDE: {
345
346
  desktop: string;
346
347
  'device-mobile': string;
347
348
  'door-open': string;
349
+ 'dots-three-vertical': string;
348
350
  'download-simple': string;
349
351
  'envelope-simple': string;
350
352
  'envelope-simple-fill': string;
@@ -536,6 +538,7 @@ export const PACK_MAPS: {
536
538
  desktop: string;
537
539
  'device-mobile': string;
538
540
  'door-open': string;
541
+ 'dots-three-vertical': string;
539
542
  'download-simple': string;
540
543
  'envelope-simple': string;
541
544
  'envelope-simple-fill': string;
@@ -725,6 +728,7 @@ export const PACK_MAPS: {
725
728
  desktop: string;
726
729
  'device-mobile': string;
727
730
  'door-open': string;
731
+ 'dots-three-vertical': string;
728
732
  'download-simple': string;
729
733
  'envelope-simple': string;
730
734
  'envelope-simple-fill': string;
package/site/icons.mjs CHANGED
@@ -168,6 +168,7 @@ export const MATERIAL_SYMBOLS = {
168
168
  desktop: 'desktop_windows',
169
169
  'device-mobile': 'mobile',
170
170
  'door-open': 'door_open',
171
+ 'dots-three-vertical': 'more_vert',
171
172
  'download-simple': 'download',
172
173
  'envelope-simple': 'mail',
173
174
  'envelope-simple-fill': 'mail-fill',
@@ -360,6 +361,7 @@ export const LUCIDE = {
360
361
  desktop: 'monitor',
361
362
  'device-mobile': 'smartphone',
362
363
  'door-open': 'door-open',
364
+ 'dots-three-vertical': 'ellipsis-vertical',
363
365
  'download-simple': 'download',
364
366
  'envelope-simple': 'mail',
365
367
  'envelope-simple-fill': 'mail-fill',
@@ -1,5 +1,5 @@
1
1
  // The colours of one page, for the theme (Base.astro, which knows no app by name): the site's own (src/data/colors.json,
2
- // "Farver"), changed by the apps installed that change a page's colours (hooks/page-colors.ts, PageColorsHook in
2
+ // "Farver"), changed by the active apps (installed, not turned off) that change a page's colours (hooks/page-colors.ts, PageColorsHook in
3
3
  // @morphemecms/cms/lib/hooks.ts), in the order of their ids, each given the colours the one before left. An app gets the
4
4
  // page's address, its pictures, its own settings, the colours' roles, and the pixels of a picture read small, so it
5
5
  // never reads the site's files itself; what it gives is kept only when it is the site's colours, by their names, and
@@ -12,7 +12,7 @@ import path from 'node:path';
12
12
  import sharp from 'sharp';
13
13
  import colors from '@data/colors.json';
14
14
  import { colorFields, colorPairs, colorRoles } from '@site/colors';
15
- import { installed, settingsOf } from './apps';
15
+ import { active, settingsOf } from './apps';
16
16
  import { appIdOf, type AppDefinition } from '@morphemecms/cms/src/apps';
17
17
  import { colorProblems, lowContrast, MODES, type Colors } from '@morphemecms/cms/lib/colors';
18
18
  import { completeColors } from '@morphemecms/cms/lib/dynamic-colors';
@@ -31,7 +31,7 @@ const appFiles = import.meta.glob<{ default: AppDefinition }>('/src/apps/*/index
31
31
  const hookFiles = import.meta.glob<{ default: PageColorsHook }>('/src/apps/*/hooks/page-colors.ts', { eager: true });
32
32
  const hooks = Object.entries(hookFiles)
33
33
  .map(([file, { default: hook }]) => ({ id: appIdOf(file), hook }))
34
- .filter(({ id }) => installed.includes(id))
34
+ .filter(({ id }) => active.includes(id))
35
35
  .sort((a, b) => a.id.localeCompare(b.id))
36
36
  .map(({ id, hook }) => ({
37
37
  id,
@@ -1,4 +1,4 @@
1
- /** What the site's installed apps load from elsewhere, and the addresses allowed in the editor.
1
+ /** What the site's active apps (installed, not turned off) load from elsewhere, and the addresses allowed in the editor.
2
2
  * @returns {import('../lib/csp.mjs').Sources[]} */
3
3
  export function siteSources(): import("../lib/csp.mjs").Sources[];
4
4
  /** What is wrong with the addresses allowed in the editor (src/data/csp.json): not JSON, or not written as an app's
package/site/siteCsp.mjs CHANGED
@@ -6,6 +6,7 @@
6
6
  // Read by the build (packages/editor/astro.mjs) and by npm run check:content (appCspProblems), so no TypeScript here.
7
7
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
8
8
  import { pathToFileURL } from 'node:url';
9
+ import { activeOf, readAppsFile } from '../lib/appsFile.mjs';
9
10
  import { allows, sourcesProblems } from '../lib/csp.mjs';
10
11
 
11
12
  // The site's src/, from its root (the working directory).
@@ -22,10 +23,10 @@ const appSources = (app) => {
22
23
  const ALLOWED = new URL('data/csp.json', SRC);
23
24
  const allowedSources = () => (existsSync(ALLOWED) ? JSON.parse(readFileSync(ALLOWED, 'utf8')) : {});
24
25
 
25
- /** What the site's installed apps load from elsewhere, and the addresses allowed in the editor.
26
+ /** What the site's active apps (installed, not turned off) load from elsewhere, and the addresses allowed in the editor.
26
27
  * @returns {import('../lib/csp.mjs').Sources[]} */
27
28
  export function siteSources() {
28
- return [...(json('data/apps.json').installed ?? []).map(appSources), allowedSources()];
29
+ return [...activeOf(readAppsFile(readFileSync(new URL('data/apps.json', SRC), 'utf8'))).map(appSources), allowedSources()];
29
30
  }
30
31
 
31
32
  /** What is wrong with the addresses allowed in the editor (src/data/csp.json): not JSON, or not written as an app's
@@ -1,4 +1,4 @@
1
- // "Dele" › "UI-komponenter" (BlockParts.tsx), the group "Handlinger": the theme's buttons that do more than one thing
1
+ // "Parts" › "UI-komponenter" (BlockParts.tsx), the group "Handlinger": the theme's buttons that do more than one thing
2
2
  // (packages/editor/theme/ui/, through @site/editor) as the site draws them: button groups in both kinds with toggles on
3
3
  // and off, in every size and in both shapes, segmented buttons with one and with several chosen, split buttons in their
4
4
  // kinds and sizes, closed and with the menu open, and FAB menus in their colour sets, from FABs of each size, closed
package/src/Advanced.tsx CHANGED
@@ -1,4 +1,4 @@
1
- // "Avanceret" (#advanced), under "Indstillinger": R2 caching, the copy of the site's files in Cloudflare R2 the Worker
1
+ // "Caching and builds" (#developer/caching), under "Developer" (Library.tsx Developer): R2 caching, the copy of the site's files in Cloudflare R2 the Worker
2
2
  // answers the editor from (packages/editor/worker/files.js), turned on and off with "Aktiveret" in the Worker's settings (r2Caching.ts),
3
3
  // saved at once in its own commit. Off, the Worker asks GitHub for everything. It can only be turned on when Cloudflare
4
4
  // has the bucket (the Worker asks, /api/r2), else a deploy would fail; under the switch, why not, and, until the site is
@@ -13,7 +13,7 @@ import { EmptyNote, GroupTitle, LinkButton, MenuRow, ProblemNote, SupportingText
13
13
  import { ConflictError, type RepoFile } from './github';
14
14
  import type { Env } from './App';
15
15
  import { savedText } from './deploy';
16
- import { backFrom } from './site';
16
+ import { backToDeveloper } from './Library';
17
17
  import {
18
18
  bucketOf,
19
19
  JURISDICTIONS,
@@ -32,7 +32,7 @@ import { buildSettingsOf, withBuildSetting, type BuildSetting } from './buildSet
32
32
  import { BUILD_SETTINGS_PATH } from '../lib/build-settings.mjs';
33
33
  import { siteT, t } from './i18n';
34
34
 
35
- export const ADVANCED_ROUTE = 'advanced';
35
+ export const CACHING_ROUTE = 'developer/caching';
36
36
 
37
37
  // "Placering" when Cloudflare places the bucket (no jurisdiction).
38
38
  const AUTO = 'auto';
@@ -170,7 +170,7 @@ export default function Advanced({ env }: { env: Env }) {
170
170
  }
171
171
 
172
172
  return (
173
- <Screen title={t.common.parts.advanced} wide back={backFrom(ADVANCED_ROUTE)}>
173
+ <Screen title={t.common.parts.caching} wide back={backToDeveloper}>
174
174
  <section className="mb-8">
175
175
  <GroupTitle>{t.advanced.r2Caching}</GroupTitle>
176
176
  <div className="px-4">
package/src/App.tsx CHANGED
@@ -26,16 +26,17 @@
26
26
  // appearance/footer the footer's texts and links with its preview (FooterSettings.tsx; once at src/data/footer.json),
27
27
  // appearance/colors the colours (Colors.tsx;
28
28
  // appearance/colors/fallback-colors the site's own while an app changes the pages'),
29
- // appearance/parts the parts (Library.tsx PartsHome): appearance/parts/editor the editor's, every
30
- // one in every state (Parts.tsx), appearance/parts/ui the theme's UI components, appearance/parts/blocks the
31
- // blocks, the standard ones and the apps', and appearance/parts/list/<list> each kind of a list of kinds
32
- // ("Promovering"), its own and the apps' (BlockParts.tsx), and appearance/parts/values the
29
+ // developer the developer screens (Library.tsx Developer), under "Settings": developer/caching the copy of the site's files in R2 and the build settings (Advanced.tsx), and
30
+ // developer/parts the parts (Library.tsx PartsHome): developer/parts/editor the editor's, every
31
+ // one in every state (Parts.tsx), developer/parts/ui the theme's UI components, developer/parts/blocks the
32
+ // blocks, the standard ones and the apps', and developer/parts/list/<list> each kind of a list of kinds
33
+ // ("Promovering"), its own and the apps' (BlockParts.tsx), and developer/parts/values the
33
34
  // theme's design tokens, "Værdier" (Tokens.tsx)
34
35
  // block-settings the blocks that can be put in, "Blokindstillinger" (BlockSettings.tsx)
35
- // advanced "Avanceret", under "Indstillinger": the copy of the site's files in R2 (Advanced.tsx)
36
36
  // morpheme "Morpheme", under "Indstillinger": the CMS's version, a newer one and its notes, an update's progress (CmsPage.tsx)
37
37
  // licenses "Licenser", under "Indstillinger": the licences of what the site and the editor are made with, and
38
38
  // licenses/<id> one with its text (Licenses.tsx)
39
+ // status "System status", from the footer: GitHub's and Cloudflare's status (ServiceStatus.tsx)
39
40
  // apps the apps that come with the site, apps/<app> one with its button to install it and, while it
40
41
  // is installed, the tabs of its page (AppList.tsx AppPage): Settings at apps/<app>, with its settings
41
42
  // form (also on the screen with its category, analytics/<app>, AppList.tsx settingsRoute), and
@@ -97,9 +98,10 @@ import { BlocksParts, ComponentParts, KindParts } from './BlockParts';
97
98
  import Tokens from './Tokens';
98
99
  import BrandForm from './BrandForm';
99
100
  import BlockSettings from './BlockSettings';
100
- import Advanced, { ADVANCED_ROUTE } from './Advanced';
101
+ import Advanced, { CACHING_ROUTE } from './Advanced';
101
102
  import CmsPage, { CMS_ROUTE } from './CmsPage';
102
103
  import Licenses, { LICENSES_ROUTE, LicensePage } from './Licenses';
104
+ import ServiceStatus, { STATUS_ROUTE } from './ServiceStatus';
103
105
  import { StoreThemePage } from './ThemeStore';
104
106
  import { ThemeStoreScreen } from './ThemeStoreScreen';
105
107
  import {
@@ -114,7 +116,7 @@ import {
114
116
  StoreAppPage,
115
117
  StorePage,
116
118
  } from './AppList';
117
- import { installedApps } from './apps';
119
+ import { activeApps, installedApps } from './apps';
118
120
  import {
119
121
  DEVELOPER_ROUTE,
120
122
  readApp,
@@ -142,6 +144,8 @@ import {
142
144
  Account,
143
145
  Blocks,
144
146
  Company,
147
+ DEV_MENU_ROUTE,
148
+ Developer,
145
149
  List,
146
150
  Lists,
147
151
  PartsHome,
@@ -624,7 +628,7 @@ function Editor() {
624
628
 
625
629
  // What the installed apps have to say (AppNotifications in apps.ts): asked when the editor opens and every 15 minutes
626
630
  // while it is open; each comes once, at the bottom and under "Notifikationer", while its type is on (notificationData.ts).
627
- const notifying = installedApps(files ?? [])
631
+ const notifying = activeApps(files ?? [])
628
632
  .filter((id) => apps.some((a) => a.id === id && a.notifications))
629
633
  .join();
630
634
  useEffect(() => {
@@ -784,9 +788,11 @@ function Page({ route, files, env, user, builtAt, onLogout }: PageProps) {
784
788
  return <Colors env={env} files={files} fallback={route === FALLBACK_ROUTE} />;
785
789
  if (route === 'company/logo') return <BrandForm env={env} />;
786
790
  if (route === 'block-settings') return <BlockSettings env={env} files={files} />;
787
- if (route === ADVANCED_ROUTE) return <Advanced env={env} />;
791
+ if (route === DEV_MENU_ROUTE) return <Developer />;
792
+ if (route === CACHING_ROUTE) return <Advanced env={env} />;
788
793
  if (route === CMS_ROUTE) return <CmsPage />;
789
794
  if (route === LICENSES_ROUTE) return <Licenses />;
795
+ if (route === STATUS_ROUTE) return <ServiceStatus />;
790
796
  if (route.startsWith(`${LICENSES_ROUTE}/`)) return <LicensePage key={route} id={route.slice(LICENSES_ROUTE.length + 1)} />;
791
797
  if (route === 'apps') return <Apps env={env} files={files} />;
792
798
  if (route === STORE_ROUTE) return <StorePage env={env} files={files} />;
@@ -807,14 +813,13 @@ function Page({ route, files, env, user, builtAt, onLogout }: PageProps) {
807
813
  const [, screenApp, screenId, ...deeper] = route.split('/');
808
814
  const ownScreen =
809
815
  route.startsWith('apps/') && !deeper.length && apps.find((a) => a.id === screenApp && screenId && a.screens?.[screenId]);
810
- if (ownScreen && installedApps(files).includes(ownScreen.id))
811
- return <AppScreenPage key={route} app={ownScreen} id={screenId} env={env} />;
812
- if (route === 'appearance/parts') return <PartsHome />;
813
- if (route === 'appearance/parts/editor') return <Parts env={env} />;
814
- if (route === 'appearance/parts/ui') return <ComponentParts />;
815
- if (route === 'appearance/parts/blocks') return <BlocksParts />;
816
- if (route.startsWith('appearance/parts/list/')) return <KindParts key={route} id={route.slice('appearance/parts/list/'.length)} />;
817
- if (route === 'appearance/parts/values') return <Tokens />;
816
+ if (ownScreen && activeApps(files).includes(ownScreen.id)) return <AppScreenPage key={route} app={ownScreen} id={screenId} env={env} />;
817
+ if (route === 'developer/parts') return <PartsHome />;
818
+ if (route === 'developer/parts/editor') return <Parts env={env} />;
819
+ if (route === 'developer/parts/ui') return <ComponentParts />;
820
+ if (route === 'developer/parts/blocks') return <BlocksParts />;
821
+ if (route.startsWith('developer/parts/list/')) return <KindParts key={route} id={route.slice('developer/parts/list/'.length)} />;
822
+ if (route === 'developer/parts/values') return <Tokens />;
818
823
  if (route.startsWith('deleted/')) return <Deleted id={route.slice('deleted/'.length)} files={files} env={env} />;
819
824
  // company/<part>, or company/<part>/<its part> (Part.parts).
820
825
  const [, companyId, ownId, ...more] = route.split('/');
@@ -0,0 +1,21 @@
1
+ // The "Aktiveret" (Enabled) switch of an installed app, shown on its page's Settings tab directly below the app's description.
2
+ // Exposes AppEnabledSwitch (default). Depends on ./ui (SwitchRow) and ./i18n (t).
3
+ import { SwitchRow } from './ui';
4
+ import { t } from './i18n';
5
+
6
+ /** `enabled`: the app is not turned off; `busy`: something is saving; `onEnabled`: called with the new state. */
7
+ export default function AppEnabledSwitch({
8
+ enabled,
9
+ busy,
10
+ onEnabled,
11
+ }: {
12
+ enabled: boolean;
13
+ busy: boolean;
14
+ onEnabled: (on: boolean) => void;
15
+ }) {
16
+ return (
17
+ <div className="mb-8 px-4">
18
+ <SwitchRow label={t.apps.enabled} texts={[t.library.on, t.library.off]} selected={enabled} disabled={busy} onChange={onEnabled} />
19
+ </div>
20
+ );
21
+ }
package/src/AppList.tsx CHANGED
@@ -1,11 +1,12 @@
1
1
  // "Indstillinger" › "Apps" (#apps): the apps installed (src/apps/, apps.ts: the site's own and those from the app store,
2
2
  // with one from the store not yet built as "Installeres…"), each with who made it under its name and, from the store,
3
- // that it has a newer version, with search (by name, who made it and what it does), the order (A–Å, Å–A, kept in this
3
+ // that it has a newer version or is turned off (liveStatus, also in "Appstore"), with search (by name, who made it and what it does), the order (A–Å, Å–A, kept in this
4
4
  // browser) and headings by the first letter, as the lists under "Indhold" have. "Indstillinger" › "Appstore"
5
5
  // (#settings/appstore): all apps, those in the app store (store.ts) and the site's own, installed or not, with search (by name, what they
6
6
  // do, who made them and the words they are found by), the same order and a funnel by category (only apps: the themes are in "Theme store", ThemeStoreScreen.tsx; the search row and the
7
7
  // store notices both screens share are StoreSearchRow.tsx, StoreStatus.tsx and storeSort.tsx). An app's page
8
- // (#apps/<app>) is the same for every app and without examples: its name, who made it, whether it is installed, its
8
+ // (#apps/<app>) is the same for every app and without examples: its name, who made it, whether it is installed (or turned
9
+ // off, with the switch "Aktiveret" right below its description on the Settings tab, AppEnabledSwitch.tsx), its
9
10
  // version (from the store) and what it does, "Installer" (or "Opdater til …"); installed, it has two tabs: "Indstillinger"
10
11
  // (#apps/<app>: its settings form, saved with "Gem ændringer" in their file, config.apps.settings, also on the screen
11
12
  // under "Indstillinger" with its kind, "Analytics" or "Promovering"; AppFormPage.tsx; then what it shows itself, what it
@@ -32,7 +33,8 @@ import { ConflictError, type Change, type RepoFile } from './github';
32
33
  import { cookieChanges, inCookieList } from './cookieList';
33
34
  import type { Env } from './App';
34
35
  import { ask, choose } from './ask';
35
- import { installedApps, settingsFile, type SiteApp } from './apps';
36
+ import { appsState, installedApps, settingsFile, type SiteApp } from './apps';
37
+ import { appsFileText, withInstalled, withOff } from '../lib/appsFile.mjs';
36
38
  import { AnyIcon } from './icon';
37
39
  import {
38
40
  appCategories,
@@ -59,7 +61,7 @@ import { config } from '@site/editor';
59
61
  import { allThemes, ThemeRow, useThemeCatalog } from './ThemeStore';
60
62
  import { siteT, siteText, t } from './i18n';
61
63
  import { OwnerLicenses, StoreLicenseRows } from './Licenses';
62
- import { StoreFooter, StoreNotice, StoreRound, useStoreConnected, useStoreKey } from './StoreStatus';
64
+ import { StoreNotice, StoreRound, useStoreConnected, useStoreKey } from './StoreStatus';
63
65
  import { StoreSearchRow, useStateFilter } from './StoreSearchRow';
64
66
  import { sortChoice, useSort } from './storeSort';
65
67
  import { told } from './notificationData';
@@ -68,6 +70,7 @@ import AppInfoTab from './AppInfoTab';
68
70
  import AppAdds from './AppAdds';
69
71
  import AppFormPage from './AppFormPage';
70
72
  import AppSettingsTab from './AppSettingsTab';
73
+ import AppEnabledSwitch from './AppEnabledSwitch';
71
74
  import { INFO_KEY } from '../lib/appRoute.mjs';
72
75
  import { busyFor } from './busyAction.mjs';
73
76
 
@@ -185,6 +188,10 @@ export function kindsOf(app: SiteApp) {
185
188
 
186
189
  const statusOf = (installed: boolean) => (installed ? t.apps.installed : t.apps.notInstalled);
187
190
 
191
+ /** What an installed app's row and page head say instead of "Installed": "Deaktiveret" while it is turned off, else "Opdatering
192
+ * findes" when it has a newer version; undefined when neither. */
193
+ const liveStatus = (off: boolean, update: boolean) => (off ? t.apps.deactivated : update ? t.apps.updateFound : undefined);
194
+
188
195
  /** The store's catalog (store.ts) for a screen: null while it is read, 'failed' when it could not be; `skip`: not read
189
196
  * (the page of one of the site's own apps). */
190
197
  function useCatalog(env: Env, skip = false): ShownApp[] | null | 'failed' {
@@ -219,6 +226,7 @@ const storeIcon = (name?: string) => <LeadingIcon>{name && <AnyIcon name={name}
219
226
 
220
227
  export function Apps({ env, files }: { env: Env; files: RepoFile[] }) {
221
228
  const installed = installedApps(files);
229
+ const { off } = appsState(files);
222
230
  const catalog = useCatalog(env);
223
231
  const store = Array.isArray(catalog) ? catalog : [];
224
232
  const [query, setQuery] = useKept('apps-search', '');
@@ -236,7 +244,7 @@ export function Apps({ env, files }: { env: Env; files: RepoFile[] }) {
236
244
  publisher: a.publisher,
237
245
  hint: a.hint,
238
246
  icon: <LeadingIcon icon={a.icon} />,
239
- status: updateOf(a, catalog) ? t.apps.updateFound : '',
247
+ status: liveStatus(off.includes(a.id), !!updateOf(a, catalog)) ?? '',
240
248
  })),
241
249
  ...installed
242
250
  .filter((id) => !apps.some((a) => a.id === id))
@@ -320,14 +328,14 @@ const developerHref = (name: string) => `#${DEVELOPER_ROUTE}/${slugify(name)}`;
320
328
 
321
329
  /** An app's row in "Appstore" and on a developer's page: its icon, its name, what it does, and whether it is installed,
322
330
  * opening its page. */
323
- function AppRow({ app, installed }: { app: ReturnType<typeof allApps>[number]; installed: boolean }) {
331
+ function AppRow({ app, installed, off }: { app: ReturnType<typeof allApps>[number]; installed: boolean; off: boolean }) {
324
332
  return (
325
333
  <ListItem
326
334
  href={`#apps/${app.id}`}
327
335
  leading={app.glyph}
328
336
  headline={app.label}
329
337
  supporting={app.hint}
330
- count={installed && t.apps.installed}
338
+ count={installed && (liveStatus(off, false) ?? t.apps.installed)}
331
339
  arrow
332
340
  />
333
341
  );
@@ -337,6 +345,7 @@ function AppRow({ app, installed }: { app: ReturnType<typeof allApps>[number]; i
337
345
  * own), each opening its page. */
338
346
  export function DeveloperPage({ slug, env, files }: { slug: string; env: Env; files: RepoFile[] }) {
339
347
  const installed = installedApps(files);
348
+ const { off } = appsState(files);
340
349
  const catalog = useCatalog(env);
341
350
  const themeCatalog = useThemeCatalog(env);
342
351
  const theirs = allApps(catalog)
@@ -364,7 +373,7 @@ export function DeveloperPage({ slug, env, files }: { slug: string; env: Env; fi
364
373
  {theirs.length > 0 && (
365
374
  <Group title={t.apps.developerApps}>
366
375
  {theirs.map((a) => (
367
- <AppRow key={a.id} app={a} installed={installed.includes(a.id)} />
376
+ <AppRow key={a.id} app={a} installed={installed.includes(a.id)} off={off.includes(a.id)} />
368
377
  ))}
369
378
  </Group>
370
379
  )}
@@ -404,6 +413,7 @@ export function StorePage({ env, files }: { env: Env; files: RepoFile[] }) {
404
413
 
405
414
  function StoreScreen({ env, files, again }: { env: Env; files: RepoFile[]; again: () => void }) {
406
415
  const installed = installedApps(files);
416
+ const { off } = appsState(files);
407
417
  const key = useStoreKey();
408
418
  const catalog = useCatalog(env);
409
419
  const connected = useStoreConnected(env);
@@ -449,12 +459,11 @@ function StoreScreen({ env, files, again }: { env: Env; files: RepoFile[]; again
449
459
  appGroups.map((g) => (
450
460
  <Group key={g.key ?? 'all'} title={g.key ?? undefined}>
451
461
  {g.items.map((a) => (
452
- <AppRow key={a.id} app={a} installed={installed.includes(a.id)} />
462
+ <AppRow key={a.id} app={a} installed={installed.includes(a.id)} off={off.includes(a.id)} />
453
463
  ))}
454
464
  </Group>
455
465
  ))
456
466
  )}
457
- <StoreFooter keyInfo={key} again={again} />
458
467
  </Screen>
459
468
  );
460
469
  }
@@ -517,7 +526,7 @@ export async function autoUpdate(env: Env, files: RepoFile[]) {
517
526
  }
518
527
  if (changedFiles(app.id, record, current).length) continue;
519
528
  const wanted = { files: await env.store.files(next), record: recordOf(next) };
520
- const changes = [...appChanges(app.id, current, wanted, installed, file), ...cookieChanges(app.id, cookiesIn(wanted.files), files)];
529
+ const changes = [...appChanges(app.id, current, wanted, file), ...cookieChanges(app.id, cookiesIn(wanted.files), files)];
521
530
  const change = t.app.changes.updated(kindAndName(t.app.changes.app, next.label), next.version);
522
531
  await saveApp(env, siteT.apps.commit.update(next.written, next.version), change, changes, file);
523
532
  updated.set(app.id, next.version);
@@ -542,7 +551,6 @@ function useStoreActions(env: Env, files: RepoFile[]) {
542
551
  // Which action runs, on which app; `asking` while a dialog waits for the user (busyAction.mjs shows only the pressed button).
543
552
  const [running, setRunning] = useState<{ id: string; action: StoreAction; asking: boolean } | null>(null);
544
553
  const saving = !!running;
545
- const installed = installedApps(files);
546
554
  const file = files.find((f) => f.path === config.apps.installed);
547
555
 
548
556
  async function change(
@@ -566,7 +574,7 @@ function useStoreActions(env: Env, files: RepoFile[]) {
566
574
  setRunning({ id, action, asking: false });
567
575
  env.notify({ text: text.busy });
568
576
  const cookies = cookieChanges(id, wanted ? cookiesIn(wanted.files) : null, files);
569
- await saveApp(env, text.commit, text.change, [...appChanges(id, current, wanted, installed, file), ...cookies], file);
577
+ await saveApp(env, text.commit, text.change, [...appChanges(id, current, wanted, file), ...cookies], file);
570
578
  env.notify({ text: text.done, kind: text.kind ?? 'saved' });
571
579
  return true;
572
580
  } catch (e) {
@@ -854,7 +862,7 @@ export function AppScreenPage({ app, id, env }: { app: SiteApp; id: string; env:
854
862
  * not installed, with the pictures, what it needs, its cookies, who made it and where it is used). */
855
863
  function AppContent({ app, env, files, tab, fields }: AppProps & { tab: AppTab; fields?: ReactNode }) {
856
864
  const notify = env.notify;
857
- const [toggling, setToggling] = useState<'install' | 'uninstall' | null>(null);
865
+ const [toggling, setToggling] = useState<'install' | 'uninstall' | 'switch' | null>(null);
858
866
  const store = useStoreActions(env, files);
859
867
  const saving = !!toggling || store.saving;
860
868
  const catalog = useCatalog(env);
@@ -863,6 +871,7 @@ function AppContent({ app, env, files, tab, fields }: AppProps & { tab: AppTab;
863
871
  const inStore = Array.isArray(catalog) ? catalog.find((a) => a.id === app.id) : undefined;
864
872
  const installed = installedApps(files);
865
873
  const on = installed.includes(app.id);
874
+ const off = on && appsState(files).off.includes(app.id);
866
875
  // Where it is used: while it is, it cannot be uninstalled.
867
876
  const uses = on ? usesOf(app, files) : [];
868
877
  const stuck = uses.length > 0;
@@ -874,8 +883,7 @@ function AppContent({ app, env, files, tab, fields }: AppProps & { tab: AppTab;
874
883
  if (!yes && !(await ask(t.apps.askUninstall(app.label), t.apps.uninstall))) return;
875
884
  if (!yes && app.store) return store.uninstall(app.id, app.label, asWritten(app).label);
876
885
  const file = files.find((f) => f.path === config.apps.installed);
877
- const next = installed.filter((id) => id !== app.id).concat(yes ? [app.id] : []);
878
- const text = `${JSON.stringify({ installed: [...new Set(next)].sort() }, null, 2)}\n`;
886
+ const text = appsFileText(withInstalled(appsState(files), app.id, yes));
879
887
  if (text === file?.text) return;
880
888
  setToggling(yes ? 'install' : 'uninstall');
881
889
  notify({ text: yes ? t.apps.installing : t.apps.uninstalling });
@@ -895,14 +903,32 @@ function AppContent({ app, env, files, tab, fields }: AppProps & { tab: AppTab;
895
903
  }
896
904
  }
897
905
 
906
+ // Turned on or off, saved at once in its own commit: the app stays installed with its folder, settings and version.
907
+ async function turn(enabled: boolean) {
908
+ const file = files.find((f) => f.path === config.apps.installed);
909
+ const text = appsFileText(withOff(appsState(files), app.id, !enabled));
910
+ if (text === file?.text) return;
911
+ setToggling('switch');
912
+ try {
913
+ const change = t.app.changes[enabled ? 'turnedOn' : 'turnedOff'](kindAndName(t.app.changes.app, app.label));
914
+ const content = { path: config.apps.installed, content: text, oid: file?.oid || null };
915
+ await saveApp(env, siteT.apps.commit[enabled ? 'turnOn' : 'turnOff'](asWritten(app).label), change, [content], file);
916
+ notify({ text: change, kind: 'saved' });
917
+ } catch (e) {
918
+ notify({ text: e instanceof ConflictError ? e.message : t.common.notSaved((e as Error).message), error: true, kind: 'failed' });
919
+ } finally {
920
+ setToggling(null);
921
+ }
922
+ }
923
+
898
924
  return (
899
925
  <div>
900
926
  <AppHead
901
927
  icon={<LeadingIcon icon={app.icon} selected={on} />}
902
928
  label={app.label}
903
929
  publisher={app.publisher}
904
- status={on && update ? t.apps.updateFound : statusOf(on)}
905
- version={app.store && versionOf(app)}
930
+ status={(on && liveStatus(off, !!update)) || statusOf(on)}
931
+ version={versionOf(app) || app.version}
906
932
  hint={app.hint}
907
933
  />
908
934
  {(!on || (update && app.store)) && (
@@ -920,12 +946,14 @@ function AppContent({ app, env, files, tab, fields }: AppProps & { tab: AppTab;
920
946
  )}
921
947
  {on && tab === 'settings' ? (
922
948
  <>
949
+ <AppEnabledSwitch enabled={!off} busy={saving} onEnabled={turn} />
923
950
  {fields}
924
951
  <AppSettingsTab
925
952
  app={app}
926
953
  env={env}
927
954
  files={files}
928
955
  busy={saving}
956
+ enabled={!off}
929
957
  uninstalling={toggling === 'uninstall' || store.busy(app.id, 'uninstall')}
930
958
  stuck={stuck}
931
959
  onUninstall={() => install(false)}
@@ -946,7 +974,7 @@ function AppContent({ app, env, files, tab, fields }: AppProps & { tab: AppTab;
946
974
  id={`app/${app.id}`}
947
975
  name={app.label}
948
976
  author={app.publisher}
949
- version={app.store && versionOf(app)}
977
+ version={versionOf(app) || app.version}
950
978
  declared={app}
951
979
  />
952
980
  }
@@ -1,5 +1,6 @@
1
- // The Settings tab of an installed app's page, below its head and its settings form: what the app shows itself (`page`),
2
- // what it adds with its switches, its kinds of notifications with their switches, and "Afinstaller" with its text (it shows the loading indicator while it uninstalls).
1
+ // The Settings tab of an installed app's page, below its head and its settings form: what the app shows itself (`page`,
2
+ // not while it is off), what it adds with its switches, its kinds of notifications with their switches, and "Afinstaller"
3
+ // with its text (it shows the loading indicator while it uninstalls). The "Aktiveret" switch is AppEnabledSwitch.tsx, in AppList.tsx.
3
4
  // Exposes AppSettingsTab (default). Depends on AppAdds.tsx, AppNotificationSwitches.tsx, ./ui and notificationData.ts.
4
5
  import { Button, SupportingText } from './ui';
5
6
  import AppAdds from './AppAdds';
@@ -10,12 +11,14 @@ import type { SiteApp } from './apps';
10
11
  import { appType } from './notificationData';
11
12
  import { siteText, t } from './i18n';
12
13
 
13
- /** `busy`: something is saving; `uninstalling`: this app is uninstalled now; `stuck`: the app is in use, so it cannot be uninstalled; `onUninstall`: "Afinstaller". */
14
+ /** `busy`: something is saving; `enabled`: the app is not turned off (hides `app.page` while it is off); `uninstalling`: this app is
15
+ * uninstalled now; `stuck`: the app is in use, so it cannot be uninstalled; `onUninstall`: "Afinstaller". */
14
16
  export default function AppSettingsTab({
15
17
  app,
16
18
  env,
17
19
  files,
18
20
  busy,
21
+ enabled,
19
22
  uninstalling,
20
23
  stuck,
21
24
  onUninstall,
@@ -24,6 +27,7 @@ export default function AppSettingsTab({
24
27
  env: Env;
25
28
  files: RepoFile[];
26
29
  busy: boolean;
30
+ enabled: boolean;
27
31
  uninstalling: boolean;
28
32
  stuck: boolean;
29
33
  onUninstall: () => void;
@@ -34,8 +38,8 @@ export default function AppSettingsTab({
34
38
  }));
35
39
  return (
36
40
  <>
37
- {/* What the app shows itself (its `page`), while it is installed. */}
38
- {app.page && (
41
+ {/* What the app shows itself (its `page`), while it is installed and not off. */}
42
+ {app.page && enabled && (
39
43
  <div data-app-page={app.id}>
40
44
  <app.page api={(path, init) => env.appApi(app.id, path, init)} />
41
45
  </div>