@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/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # @morphemecms/cms
2
2
 
3
+ ## 0.14.0-next-20261107093528
4
+
5
+ ### Minor Changes
6
+
7
+ - a70c310: Apps can be turned off without uninstalling them; the app page shows the app's version.
8
+ - 26e7adc: The editor footer has links to support, system status, security reports, the privacy policy and the licenses, and a copyright line. System status shows whether GitHub and Cloudflare work for your site.
9
+ - 4c30155: The Morpheme page shows a friendly note for each change: why, how and what.
10
+
11
+ ### Patch Changes
12
+
13
+ - da37368: The Enabled switch on an app's page sits right below the app's description.
14
+ - dd625db: Editor menu: Advanced is now Developer, with Parts and Caching and builds in it. Parts moved from Appearance; old links still work.
15
+ - 5cd4149: Editor menu: "Lists" is now "Dynamic lists", one row that opens the page with all lists (no fold-out).
16
+ - 1fe1daa: The text toolbar is no taller than its tools on a phone, and it stays as far from the top of the screen as from the sides while you scroll.
17
+ - b47a580: Google preview looks more like Google: a flat result on a phone, no card on a computer, a ringed site icon, https:// in the address, the ⋮ menu, a larger phone title, and the primary keyword in bold in the description.
18
+ - 006c78b: The connection to Morpheme moves from the store screens to the Morpheme page under Settings, with the Morpheme logo.
19
+ - a4da24e: System status checks only the Cloudflare services your site uses, so a site without R2 or D1 is not marked down by them.
20
+
3
21
  ## 0.13.0
4
22
 
5
23
  ### Minor Changes
@@ -129,7 +129,7 @@ export const icons: Record<string, Icon> = {
129
129
  appstore: Storefront,
130
130
  'theme-store': Storefront,
131
131
  morpheme: Sparkle,
132
- advanced: Wrench,
132
+ developer: Wrench,
133
133
  licenses: Certificate,
134
134
  // The screens under "Appearance" (Library.tsx Theme), the first the store's themes.
135
135
  themes: Swatches,
@@ -168,7 +168,7 @@ export const appearance: Part[] = [
168
168
  // Each part is a collection (collections.ts), except those named above, "privacy"'s (partScreens) and the editor's own
169
169
  // "company" (companyParts), "blocks" (blockParts), id files (the pictures, PDFs, videos and zip files), "header", "lists"
170
170
  // and "block-settings" (BlockSettings.tsx).
171
- const settingsIds = ['morpheme', 'advanced'];
171
+ const settingsIds = ['morpheme', 'developer'];
172
172
  export const groups: { id: string; title: string; href?: string; ids: string[] }[] = [
173
173
  { id: 'home', title: 'Dashboard', href: '#', ids: [] },
174
174
  {
@@ -182,18 +182,7 @@ export const groups: { id: string; title: string; href?: string; ids: string[] }
182
182
  {
183
183
  id: 'appearance',
184
184
  title: 'Appearance',
185
- ids: [
186
- 'theme-store',
187
- 'header',
188
- 'footer',
189
- 'themes',
190
- 'icon-packs',
191
- 'motion',
192
- 'side-column',
193
- 'colors',
194
- ...appearance.map((p) => p.id),
195
- 'parts',
196
- ],
185
+ ids: ['theme-store', 'header', 'footer', 'themes', 'icon-packs', 'motion', 'side-column', 'colors', ...appearance.map((p) => p.id)],
197
186
  },
198
187
  { id: 'privacy', title: 'Privacy', ids: privacy.parts.map((p) => p.id) },
199
188
  { id: 'settings', title: 'Settings', ids: settingsIds },
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="1" /> <circle cx="12" cy="5" r="1" /> <circle cx="12" cy="19" r="1" /></g></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="M479.86-160Q460-160 446-174.14t-14-34Q432-228 446.14-242t34-14Q500-256 514-241.86t14 34Q528-188 513.86-174t-34 14Zm0-272Q460-432 446-446.14t-14-34Q432-500 446.14-514t34-14Q500-528 514-513.86t14 34Q528-460 513.86-446t-34 14Zm0-272Q460-704 446-718.14t-14-34Q432-772 446.14-786t34-14Q500-800 514-785.86t14 34Q528-732 513.86-718t-34 14Z"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M140,128a12,12,0,1,1-12-12A12,12,0,0,1,140,128ZM128,72a12,12,0,1,0-12-12A12,12,0,0,0,128,72Zm0,112a12,12,0,1,0,12,12A12,12,0,0,0,128,184Z"/></svg>
@@ -0,0 +1,16 @@
1
+ /** The changes that make an app's folder (`folder`, its files now `current`) the store's version, or remove it (`next`
2
+ * null: uninstalling); and the site's file of apps (`appsPath`, `file`: it now, if there is one) with the app among those
3
+ * installed or not. An update keeps the app turned off; uninstalling takes it out of "off". In one commit.
4
+ * @param {string} folder @param {string} appsPath @param {string} id
5
+ * @param {{ path: string, oid: string }[]} current
6
+ * @param {Parameters<typeof folderChanges>[2]} next
7
+ * @param {{ text: string, oid: string } | undefined} file
8
+ * @returns {ReturnType<typeof folderChanges>} */
9
+ export function appChanges(folder: string, appsPath: string, id: string, current: {
10
+ path: string;
11
+ oid: string;
12
+ }[], next: Parameters<typeof folderChanges>[2], file: {
13
+ text: string;
14
+ oid: string;
15
+ } | undefined): ReturnType<typeof folderChanges>;
16
+ import { folderChanges } from './folderChanges.mjs';
@@ -0,0 +1,20 @@
1
+ // The changes that install, update or uninstall an app from the store: its folder (folderChanges) and the site's file of
2
+ // apps (apps.json), where the app is added or removed and its place in "off" is kept. Exposes appChanges (src/store.ts).
3
+ // Depends on ./folderChanges.mjs and ./appsFile.mjs.
4
+ import { appsFileText, readAppsFile, withInstalled } from './appsFile.mjs';
5
+ import { folderChanges } from './folderChanges.mjs';
6
+
7
+ /** The changes that make an app's folder (`folder`, its files now `current`) the store's version, or remove it (`next`
8
+ * null: uninstalling); and the site's file of apps (`appsPath`, `file`: it now, if there is one) with the app among those
9
+ * installed or not. An update keeps the app turned off; uninstalling takes it out of "off". In one commit.
10
+ * @param {string} folder @param {string} appsPath @param {string} id
11
+ * @param {{ path: string, oid: string }[]} current
12
+ * @param {Parameters<typeof folderChanges>[2]} next
13
+ * @param {{ text: string, oid: string } | undefined} file
14
+ * @returns {ReturnType<typeof folderChanges>} */
15
+ export function appChanges(folder, appsPath, id, current, next, file) {
16
+ const changes = folderChanges(folder, current, next);
17
+ const text = appsFileText(withInstalled(readAppsFile(file?.text), id, !!next));
18
+ if (text !== file?.text) changes.push({ path: appsPath, content: text, oid: file?.oid || null });
19
+ return changes;
20
+ }
@@ -0,0 +1,30 @@
1
+ // Tests for lib/appChanges.mjs: the site's file of apps changes with an app's install, update and uninstall.
2
+ import { describe, it } from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import { appChanges } from './appChanges.mjs';
5
+
6
+ const PATH = 'src/data/apps.json';
7
+ const file = (text) => ({ text, oid: 'o1' });
8
+ const store = { files: [], record: { files: [] } };
9
+ const textOf = (changes) => changes.find((c) => c.path === PATH)?.content;
10
+
11
+ describe('appChanges', () => {
12
+ it('installs an app into the file of apps', () => {
13
+ const changes = appChanges('src/apps/b', PATH, 'b', [], store, file('{\n "installed": [\n "a"\n ]\n}\n'));
14
+ assert.deepEqual(JSON.parse(textOf(changes)), { installed: ['a', 'b'] });
15
+ });
16
+ it('an update keeps the app turned off', () => {
17
+ const before = '{\n "installed": [\n "a",\n "b"\n ],\n "off": [\n "b"\n ]\n}\n';
18
+ const changes = appChanges('src/apps/b', PATH, 'b', [], store, file(before));
19
+ assert.equal(textOf(changes), undefined);
20
+ });
21
+ it('an uninstall removes the id from installed and off', () => {
22
+ const before = '{\n "installed": [\n "a",\n "b"\n ],\n "off": [\n "b"\n ]\n}\n';
23
+ const changes = appChanges('src/apps/b', PATH, 'b', [], null, file(before));
24
+ assert.deepEqual(JSON.parse(textOf(changes)), { installed: ['a'] });
25
+ });
26
+ it('writes the file when there is none', () => {
27
+ const changes = appChanges('src/apps/b', PATH, 'b', [], store, undefined);
28
+ assert.equal(changes.find((c) => c.path === PATH)?.oid, null);
29
+ });
30
+ });
package/lib/apps.d.mts CHANGED
@@ -1,5 +1,3 @@
1
- /** The ids of the apps installed. @param {string} root @returns {string[]} */
2
- export function installedApps(root: string): string[];
3
1
  /** worker/apps.js as it must be: an import of each app's worker.js, installed or not (the Worker answers only for
4
2
  * those installed), and of its texts.ts, for its notifications on the phone in the editor's other languages
5
3
  * (packages/editor/worker/push.js). @param {string} root */
@@ -23,6 +21,8 @@ export function appFoldersPlugin(root: string): import("vite").Plugin;
23
21
  /** Where an app's files from the build are, in the build (dist/) and so on the site (served only to the Worker). */
24
22
  export const APP_FILES: "_apps";
25
23
  export function appIds(root: string): string[];
24
+ export function installedApps(root: string): string[];
25
+ export function activeApps(root: string): string[];
26
26
  /** The module with what is in each app's folder (appFoldersPlugin): its files, by their paths in it, and its csp.json,
27
27
  * so the editor can say what an app needs (needsOf in src/store.ts) without loading its code. */
28
28
  export const APP_FOLDERS: "virtual:app-folders";
package/lib/apps.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  // A site folder in src/apps/ named app_ or theme_ that is not a store id stops the build (appIds, strayStoreId).
2
2
  // What an app (src/apps/<app>/, packages/editor/src/apps.ts) can have outside the editor and the pages, each only while
3
- // it is installed (src/data/apps.json):
3
+ // it is installed and not turned off (src/data/apps.json, "off"):
4
4
  // worker.js its part of the site's Worker: its own addresses, /api/<app>/… (`fetch`), and what it does each hour
5
5
  // (`scheduled`). The Worker cannot find files itself, so worker/apps.js imports each one; this file writes
6
6
  // it (writeAppWorkers, npx morpheme app-workers), and npm run check:content says when it is out of date.
@@ -12,6 +12,7 @@
12
12
  import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
13
13
  import { join, sep } from 'node:path';
14
14
  import { fileURLToPath, pathToFileURL } from 'node:url';
15
+ import { activeOf, readAppsFile } from './appsFile.mjs';
15
16
  import { strayStoreId } from './storeEntries.mjs';
16
17
 
17
18
  /** Where an app's files from the build are, in the build (dist/) and so on the site (served only to the Worker). */
@@ -38,16 +39,21 @@ export const appIds = (root) => {
38
39
  return ids;
39
40
  };
40
41
 
41
- /** The ids of the apps installed. @param {string} root @returns {string[]} */
42
- export function installedApps(root) {
42
+ /** The state of src/data/apps.json: the apps installed and those turned off. @param {string} root */
43
+ function appsState(root) {
43
44
  try {
44
- const { installed } = JSON.parse(readFileSync(join(root, INSTALLED), 'utf8'));
45
- return Array.isArray(installed) ? installed.map(String) : [];
45
+ return readAppsFile(readFileSync(join(root, INSTALLED), 'utf8'));
46
46
  } catch {
47
- return [];
47
+ return readAppsFile(undefined);
48
48
  }
49
49
  }
50
50
 
51
+ /** The ids of the apps installed, also those turned off. @param {string} root @returns {string[]} */
52
+ export const installedApps = (root) => appsState(root).installed;
53
+
54
+ /** The ids of the apps installed and not turned off: those that run. @param {string} root @returns {string[]} */
55
+ export const activeApps = (root) => activeOf(appsState(root));
56
+
51
57
  /** worker/apps.js as it must be: an import of each app's worker.js, installed or not (the Worker answers only for
52
58
  * those installed), and of its texts.ts, for its notifications on the phone in the editor's other languages
53
59
  * (packages/editor/worker/push.js). @param {string} root */
@@ -99,7 +105,7 @@ export function appBuilds(root) {
99
105
  hooks: {
100
106
  'astro:build:done': async ({ dir, logger }) => {
101
107
  const built = fileURLToPath(dir);
102
- for (const id of installedApps(root)) {
108
+ for (const id of activeApps(root)) {
103
109
  const step = join(root, APPS, id, 'build.mjs');
104
110
  if (!existsSync(step)) continue;
105
111
  const out = join(built, APP_FILES, id);
@@ -0,0 +1,20 @@
1
+ /** Reads the text of src/data/apps.json. Bad JSON, undefined, or wrong types give empty lists.
2
+ * Keeps only string ids. Sorts and removes duplicates. Drops ids in `off` that are not in `installed`.
3
+ * @param {string | undefined} text @returns {AppsState} */
4
+ export function readAppsFile(text: string | undefined): AppsState;
5
+ /** The active apps: `installed` minus `off`, sorted. @param {AppsState} state @returns {string[]} */
6
+ export function activeOf(state: AppsState): string[];
7
+ /** The file text: JSON.stringify with 2-space indentation plus a final "\n". Sorted lists, no duplicates.
8
+ * `off` keeps only ids in `installed`. No `off` key when `off` is empty. Key order: installed, off.
9
+ * @param {AppsState} state @returns {string} */
10
+ export function appsFileText(state: AppsState): string;
11
+ /** The state with `id` installed (`yes` true) or uninstalled (`yes` false). Uninstall also removes `id` from `off`.
12
+ * @param {AppsState} state @param {string} id @param {boolean} yes @returns {AppsState} */
13
+ export function withInstalled(state: AppsState, id: string, yes: boolean): AppsState;
14
+ /** The state with `id` deactivated (`off` true) or active (`off` false). No change when `id` is not installed.
15
+ * @param {AppsState} state @param {string} id @param {boolean} off @returns {AppsState} */
16
+ export function withOff(state: AppsState, id: string, off: boolean): AppsState;
17
+ export type AppsState = {
18
+ installed: string[];
19
+ off: string[];
20
+ };
@@ -0,0 +1,57 @@
1
+ // Reads and writes the site's file of apps (src/data/apps.json): the apps installed and those turned off ("off").
2
+ // Exposes readAppsFile, activeOf, appsFileText, withInstalled, withOff. No dependencies; lib/apps.mjs, src/apps.ts,
3
+ // site/apps.ts, src/store.ts, src/AppList.tsx and worker/index.js use it.
4
+
5
+ /** @typedef {{ installed: string[], off: string[] }} AppsState */
6
+
7
+ /** Sorted ids without duplicates; only strings are kept. @param {unknown} list @returns {string[]} */
8
+ const ids = (list) => [...new Set(Array.isArray(list) ? list.filter((id) => typeof id === 'string') : [])].sort();
9
+
10
+ /** The state with clean lists: `off` holds only ids that are installed. @param {AppsState} state @returns {AppsState} */
11
+ const clean = (state) => {
12
+ const installed = ids(state.installed);
13
+ return { installed, off: ids(state.off).filter((id) => installed.includes(id)) };
14
+ };
15
+
16
+ /** Reads the text of src/data/apps.json. Bad JSON, undefined, or wrong types give empty lists.
17
+ * Keeps only string ids. Sorts and removes duplicates. Drops ids in `off` that are not in `installed`.
18
+ * @param {string | undefined} text @returns {AppsState} */
19
+ export function readAppsFile(text) {
20
+ try {
21
+ const data = JSON.parse(text ?? '');
22
+ return clean({ installed: data?.installed, off: data?.off });
23
+ } catch {
24
+ return { installed: [], off: [] };
25
+ }
26
+ }
27
+
28
+ /** The active apps: `installed` minus `off`, sorted. @param {AppsState} state @returns {string[]} */
29
+ export function activeOf(state) {
30
+ const { installed, off } = clean(state);
31
+ return installed.filter((id) => !off.includes(id));
32
+ }
33
+
34
+ /** The file text: JSON.stringify with 2-space indentation plus a final "\n". Sorted lists, no duplicates.
35
+ * `off` keeps only ids in `installed`. No `off` key when `off` is empty. Key order: installed, off.
36
+ * @param {AppsState} state @returns {string} */
37
+ export function appsFileText(state) {
38
+ const { installed, off } = clean(state);
39
+ return JSON.stringify(off.length ? { installed, off } : { installed }, null, 2) + '\n';
40
+ }
41
+
42
+ /** The state with `id` installed (`yes` true) or uninstalled (`yes` false). Uninstall also removes `id` from `off`.
43
+ * @param {AppsState} state @param {string} id @param {boolean} yes @returns {AppsState} */
44
+ export function withInstalled(state, id, yes) {
45
+ const { installed, off } = clean(state);
46
+ return yes
47
+ ? { installed: ids([...installed, id]), off }
48
+ : { installed: installed.filter((x) => x !== id), off: off.filter((x) => x !== id) };
49
+ }
50
+
51
+ /** The state with `id` deactivated (`off` true) or active (`off` false). No change when `id` is not installed.
52
+ * @param {AppsState} state @param {string} id @param {boolean} off @returns {AppsState} */
53
+ export function withOff(state, id, off) {
54
+ const now = clean(state);
55
+ if (!now.installed.includes(id)) return now;
56
+ return { installed: now.installed, off: off ? ids([...now.off, id]) : now.off.filter((x) => x !== id) };
57
+ }
@@ -0,0 +1,53 @@
1
+ // Tests for lib/appsFile.mjs: reading, writing and changing the site's file of apps.
2
+ import { describe, it } from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import { activeOf, appsFileText, readAppsFile, withInstalled, withOff } from './appsFile.mjs';
5
+
6
+ const second = '{\n "installed": [\n "a",\n "b"\n ],\n "off": [\n "b"\n ]\n}\n';
7
+
8
+ describe('readAppsFile', () => {
9
+ it('gives empty lists for undefined', () => assert.deepEqual(readAppsFile(undefined), { installed: [], off: [] }));
10
+ it('gives empty lists for bad JSON', () => assert.deepEqual(readAppsFile('not json'), { installed: [], off: [] }));
11
+ it('sorts and removes duplicates', () =>
12
+ assert.deepEqual(readAppsFile('{"installed":["b","a","a"]}'), { installed: ['a', 'b'], off: [] }));
13
+ it('drops off ids that are not installed', () =>
14
+ assert.deepEqual(readAppsFile('{"installed":["a","b"],"off":["b","x"]}'), { installed: ['a', 'b'], off: ['b'] }));
15
+ it('gives empty lists for wrong types', () => assert.deepEqual(readAppsFile('{"installed":"a","off":3}'), { installed: [], off: [] }));
16
+ });
17
+
18
+ describe('activeOf', () => {
19
+ it('is installed minus off', () => assert.deepEqual(activeOf({ installed: ['a', 'b', 'c'], off: ['b'] }), ['a', 'c']));
20
+ });
21
+
22
+ describe('appsFileText', () => {
23
+ it('writes sorted installed without off', () =>
24
+ assert.equal(appsFileText({ installed: ['b', 'a'], off: [] }), '{\n "installed": [\n "a",\n "b"\n ]\n}\n'));
25
+ it('writes off when set, keeping only installed ids', () =>
26
+ assert.equal(appsFileText({ installed: ['a', 'b'], off: ['b', 'x'] }), second));
27
+ it('round trips', () => assert.equal(appsFileText(readAppsFile(second)), second));
28
+ });
29
+
30
+ describe('withInstalled', () => {
31
+ it('installs', () => assert.deepEqual(withInstalled({ installed: ['a'], off: [] }, 'b', true), { installed: ['a', 'b'], off: [] }));
32
+ it('uninstall removes the id from off', () =>
33
+ assert.deepEqual(withInstalled({ installed: ['a', 'b'], off: ['b'] }, 'b', false), { installed: ['a'], off: [] }));
34
+ });
35
+
36
+ describe('withOff', () => {
37
+ it('turns an app off', () =>
38
+ assert.deepEqual(withOff({ installed: ['a', 'b'], off: [] }, 'b', true), { installed: ['a', 'b'], off: ['b'] }));
39
+ it('turns an app on', () =>
40
+ assert.deepEqual(withOff({ installed: ['a', 'b'], off: ['b'] }, 'b', false), { installed: ['a', 'b'], off: [] }));
41
+ it('ignores an app that is not installed', () =>
42
+ assert.deepEqual(withOff({ installed: ['a'], off: [] }, 'x', true), { installed: ['a'], off: [] }));
43
+ });
44
+
45
+ describe('no mutation', () => {
46
+ it('does not change a frozen state', () => {
47
+ const state = Object.freeze({ installed: Object.freeze(['a', 'b']), off: Object.freeze([]) });
48
+ assert.doesNotThrow(() => {
49
+ withOff(state, 'b', true);
50
+ withInstalled(state, 'c', true);
51
+ });
52
+ });
53
+ });
@@ -1,4 +1,4 @@
1
- // The build settings in src/data/build.json (Indstillinger › Avanceret): readBuildSettings fills in the defaults, and
1
+ // The build settings in src/data/build.json (Settings › Developer › Caching and builds): readBuildSettings fills in the defaults, and
2
2
  // giveUpAfter is how long the editor waits for a save's build. Exposes BUILD_SETTINGS_PATH, readBuildSettings,
3
3
  // giveUpAfter. No dependencies.
4
4
 
@@ -4,6 +4,13 @@
4
4
  * @returns {Promise<Uint8Array>}
5
5
  */
6
6
  export function gunzip(bytes: Uint8Array): Promise<Uint8Array>;
7
+ /**
8
+ * The bytes of the entry named exactly `path` in a tar, or null when there is none.
9
+ * @param {Uint8Array} tar
10
+ * @param {string} path
11
+ * @returns {Uint8Array | null}
12
+ */
13
+ export function bytesFromTar(tar: Uint8Array, path: string): Uint8Array | null;
7
14
  /**
8
15
  * The UTF-8 text of the entry named exactly `path` in a tar, or null when there is none.
9
16
  * @param {Uint8Array} tar
package/lib/changelog.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  // A version's release notes, read from its npm tarball: unzips the tarball, finds CHANGELOG.md in the tar and cuts out the
2
- // sections between two versions. Exposes gunzip, fileFromTar and sectionsBetween. Depends on ./cms-version.mjs; no node: imports
2
+ // sections between two versions. Exposes gunzip, bytesFromTar, fileFromTar and sectionsBetween. Depends on ./cms-version.mjs; no node: imports
3
3
  // (runs in a Cloudflare Worker and in Node 18+).
4
4
  import { compareVersions } from './cms-version.mjs';
5
5
 
@@ -21,12 +21,12 @@ function field(header, start, end) {
21
21
  }
22
22
 
23
23
  /**
24
- * The UTF-8 text of the entry named exactly `path` in a tar, or null when there is none.
24
+ * The bytes of the entry named exactly `path` in a tar, or null when there is none.
25
25
  * @param {Uint8Array} tar
26
26
  * @param {string} path
27
- * @returns {string | null}
27
+ * @returns {Uint8Array | null}
28
28
  */
29
- export function fileFromTar(tar, path) {
29
+ export function bytesFromTar(tar, path) {
30
30
  let offset = 0;
31
31
  while (offset + 512 <= tar.length) {
32
32
  const header = tar.subarray(offset, offset + 512);
@@ -35,12 +35,23 @@ export function fileFromTar(tar, path) {
35
35
  const prefix = field(header, 345, 500);
36
36
  const size = parseInt(field(header, 124, 136).trim(), 8) || 0;
37
37
  const data = tar.subarray(offset + 512, offset + 512 + size);
38
- if ((prefix ? prefix + '/' + name : name) === path) return new TextDecoder().decode(data);
38
+ if ((prefix ? prefix + '/' + name : name) === path) return data;
39
39
  offset += 512 + Math.ceil(size / 512) * 512;
40
40
  }
41
41
  return null;
42
42
  }
43
43
 
44
+ /**
45
+ * The UTF-8 text of the entry named exactly `path` in a tar, or null when there is none.
46
+ * @param {Uint8Array} tar
47
+ * @param {string} path
48
+ * @returns {string | null}
49
+ */
50
+ export function fileFromTar(tar, path) {
51
+ const bytes = bytesFromTar(tar, path);
52
+ return bytes === null ? null : new TextDecoder().decode(bytes);
53
+ }
54
+
44
55
  /**
45
56
  * The changelog's sections for versions after `from` up to and including `to`, newest first, without empty ones.
46
57
  * @param {string} changelog
@@ -0,0 +1 @@
1
+ export function needsClearance(pageHeight: number, windowHeight: number, hasFab: boolean): boolean;
@@ -0,0 +1,9 @@
1
+ // Whether a screen needs clearance under its footer for the floating action button: only when it has one and its content
2
+ // pushes the footer below the window, so a short screen has its footer at the very bottom. Exposes needsClearance. No imports.
3
+
4
+ /**
5
+ * @param {number} pageHeight The page's height with no clearance added.
6
+ * @param {number} windowHeight The window's height.
7
+ * @param {boolean} hasFab Whether the screen shows a FAB.
8
+ */
9
+ export const needsClearance = (pageHeight, windowHeight, hasFab) => hasFab && pageHeight > windowHeight;
@@ -0,0 +1,24 @@
1
+ // Tests for lib/footerClearance.mjs: clearance under the footer only for a FAB, and only when the page is taller than the window.
2
+ import test from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import { needsClearance } from './footerClearance.mjs';
5
+
6
+ test('a page that fits the window needs no clearance', () => {
7
+ assert.equal(needsClearance(600, 800, true), false);
8
+ });
9
+
10
+ test('a page exactly as tall as the window needs none', () => {
11
+ assert.equal(needsClearance(800, 800, true), false);
12
+ });
13
+
14
+ test('a tall page with a FAB needs clearance', () => {
15
+ assert.equal(needsClearance(801, 800, true), true);
16
+ });
17
+
18
+ test('a tall page without a FAB needs none', () => {
19
+ assert.equal(needsClearance(2000, 800, false), false);
20
+ });
21
+
22
+ test('a short page without a FAB needs none', () => {
23
+ assert.equal(needsClearance(600, 800, false), false);
24
+ });
@@ -0,0 +1,12 @@
1
+ /** @typedef {{ id: 'support' | 'status' | 'terms' | 'security' | 'privacy' | 'licenses', href: string | null }} FooterLink */
2
+ /** @type {FooterLink[]} A link with `href: null` is not shown yet. */
3
+ export const FOOTER_LINKS: FooterLink[];
4
+ export function copyright(year: number): string;
5
+ export function visibleLinks(links: FooterLink[]): (FooterLink & {
6
+ href: string;
7
+ })[];
8
+ export function isExternal(href: string): boolean;
9
+ export type FooterLink = {
10
+ id: "support" | "status" | "terms" | "security" | "privacy" | "licenses";
11
+ href: string | null;
12
+ };
@@ -0,0 +1,26 @@
1
+ // The links in the editor's footer, in the order they are shown, and its copyright line (EditorFooter.tsx).
2
+ // Exposes FOOTER_LINKS, copyright, visibleLinks and isExternal. No imports.
3
+
4
+ /** @typedef {{ id: 'support' | 'status' | 'terms' | 'security' | 'privacy' | 'licenses', href: string | null }} FooterLink */
5
+
6
+ /** @type {FooterLink[]} A link with `href: null` is not shown yet. */
7
+ export const FOOTER_LINKS = [
8
+ { id: 'support', href: 'https://github.com/larsadolfsen/morpheme-cms/issues' },
9
+ { id: 'status', href: '#status' },
10
+ { id: 'terms', href: null },
11
+ { id: 'security', href: 'https://github.com/larsadolfsen/morpheme-cms/security' },
12
+ { id: 'privacy', href: 'https://getmorpheme.com/privacy-policy/' },
13
+ { id: 'licenses', href: '#licenses' },
14
+ ];
15
+
16
+ /** The copyright line for a year (the caller passes the current one: this has no Date). @param {number} year */
17
+ export const copyright = (year) => `© ${year} Lars Adolfsen`;
18
+
19
+ /**
20
+ * @param {FooterLink[]} links
21
+ * @returns {(FooterLink & { href: string })[]}
22
+ */
23
+ export const visibleLinks = (links) => /** @type {(FooterLink & { href: string })[]} */ (links.filter((link) => link.href !== null));
24
+
25
+ /** Whether a link leaves the editor (opens in a new tab). @param {string} href */
26
+ export const isExternal = (href) => href.startsWith('https://');
@@ -0,0 +1,32 @@
1
+ // Tests for lib/footerLinks.mjs: which links the footer shows, and which of them leave the editor.
2
+ import test from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import { copyright, FOOTER_LINKS, isExternal, visibleLinks } from './footerLinks.mjs';
5
+
6
+ test('while terms has no URL, the footer shows 5 links and no terms', () => {
7
+ const shown = visibleLinks(FOOTER_LINKS);
8
+ assert.equal(shown.length, 5);
9
+ assert.equal(
10
+ shown.some((l) => l.id === 'terms'),
11
+ false,
12
+ );
13
+ });
14
+
15
+ test('with a terms URL, the footer shows 6 links and terms is third', () => {
16
+ const links = FOOTER_LINKS.map((l) => (l.id === 'terms' ? { ...l, href: 'https://example.com/terms' } : l));
17
+ const shown = visibleLinks(links);
18
+ assert.equal(shown.length, 6);
19
+ assert.equal(shown[2].id, 'terms');
20
+ });
21
+
22
+ test('each external href starts with https://, and the others stay in the editor', () => {
23
+ for (const { href } of visibleLinks(FOOTER_LINKS)) {
24
+ assert.equal(isExternal(href), href.startsWith('https://'));
25
+ assert.equal(isExternal(href), !href.startsWith('#'));
26
+ }
27
+ });
28
+
29
+ test('the copyright line has the year it is given', () => {
30
+ assert.equal(copyright(2026), '© 2026 Lars Adolfsen');
31
+ assert.equal(copyright(2027), '© 2027 Lars Adolfsen');
32
+ });
@@ -1,9 +1,9 @@
1
1
  // Run: node --test lib/google-result.test.mjs
2
- // Tests src/googleResult.mjs: the title, description and address line of a page's Google result.
2
+ // Tests src/googleResult.mjs: the title, description and address line of a page's Google result and the bold keyword parts.
3
3
  // Depends on node:test, node:assert/strict and ../src/googleResult.mjs.
4
4
  import { test } from 'node:test';
5
5
  import assert from 'node:assert/strict';
6
- import { pageTitle, searchedAs, searchCrumbs } from '../src/googleResult.mjs';
6
+ import { boldParts, pageTitle, searchedAs, searchCrumbs } from '../src/googleResult.mjs';
7
7
 
8
8
  const site = { name: 'Morpheme', description: 'The site.' };
9
9
 
@@ -53,18 +53,49 @@ test('the site description is used when the page has no description', () => {
53
53
  assert.equal(searchedAs({ title: 'A', site }).description, 'The site.');
54
54
  });
55
55
 
56
- test('the address line is the domain and the path parts', () => {
57
- assert.equal(searchCrumbs('morpheme.example', '/blog/my-post/'), 'morpheme.example › blog › my-post');
56
+ test('the address line is https, the domain and the path parts', () => {
57
+ assert.equal(searchCrumbs('morpheme.example', '/blog/my-post/'), 'https://morpheme.example › blog › my-post');
58
+ assert.equal(searchCrumbs('morpheme.example', '/app-store/'), 'https://morpheme.example › app-store');
58
59
  });
59
60
 
60
- test('the front page address line is only the domain', () => {
61
- assert.equal(searchCrumbs('morpheme.example', '/'), 'morpheme.example');
61
+ test('the front page address line is only https and the domain', () => {
62
+ assert.equal(searchCrumbs('morpheme.example', '/'), 'https://morpheme.example');
62
63
  });
63
64
 
64
- test('an empty path gives only the domain', () => {
65
- assert.equal(searchCrumbs('morpheme.example', ''), 'morpheme.example');
65
+ test('an empty path gives only https and the domain', () => {
66
+ assert.equal(searchCrumbs('morpheme.example', ''), 'https://morpheme.example');
67
+ });
68
+
69
+ test('the keyword is bold in the text, case-insensitive', () => {
70
+ assert.deepEqual(boldParts('The App Store gives', 'app store'), [
71
+ { text: 'The ', bold: false },
72
+ { text: 'App Store', bold: true },
73
+ { text: ' gives', bold: false },
74
+ ]);
75
+ });
76
+
77
+ test('every occurrence is bold and keeps its own case', () => {
78
+ assert.deepEqual(boldParts('app store and APP STORE', 'App Store'), [
79
+ { text: 'app store', bold: true },
80
+ { text: ' and ', bold: false },
81
+ { text: 'APP STORE', bold: true },
82
+ ]);
83
+ });
84
+
85
+ test('an empty or blank keyword gives one plain part', () => {
86
+ assert.deepEqual(boldParts('anything', ''), [{ text: 'anything', bold: false }]);
87
+ assert.deepEqual(boldParts('anything', ' '), [{ text: 'anything', bold: false }]);
88
+ assert.deepEqual(boldParts('anything'), [{ text: 'anything', bold: false }]);
89
+ });
90
+
91
+ test('regex characters in the keyword match literally', () => {
92
+ assert.deepEqual(boldParts('a.b', '.'), [
93
+ { text: 'a', bold: false },
94
+ { text: '.', bold: true },
95
+ { text: 'b', bold: false },
96
+ ]);
66
97
  });
67
98
 
68
99
  test('a path without a leading slash still gives its parts', () => {
69
- assert.equal(searchCrumbs('morpheme.example', 'about'), 'morpheme.example › about');
100
+ assert.equal(searchCrumbs('morpheme.example', 'about'), 'https://morpheme.example › about');
70
101
  });
package/lib/menu.mjs CHANGED
@@ -38,7 +38,7 @@ export function menuTree(groups, partOf, childrenOf) {
38
38
  });
39
39
  }
40
40
 
41
- const PARENTS = ['company', 'blocks', 'appearance', 'seo', 'apps'];
41
+ const PARENTS = ['company', 'blocks', 'appearance', 'seo', 'apps', 'developer'];
42
42
 
43
43
  const under = (path, prefix) => path === prefix || path.startsWith(prefix + '/');
44
44