@morphemecms/cms 0.13.0 → 0.14.0-next-20261107080444

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 (92) hide show
  1. package/CHANGELOG.md +14 -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/google-result.test.mjs +40 -9
  16. package/lib/menu.mjs +1 -1
  17. package/lib/menu.test.mjs +6 -5
  18. package/package.json +1 -1
  19. package/scripts/app-screenshots.mjs +4 -4
  20. package/scripts/build-settings-problems.mjs +1 -1
  21. package/scripts/built-site.mjs +1 -1
  22. package/scripts/check-content.mjs +1 -1
  23. package/scripts/check-worker.mjs +1 -1
  24. package/scripts/editor-screens.mjs +32 -16
  25. package/site/apps.ts +10 -4
  26. package/site/consent.ts +3 -3
  27. package/site/defineSite.mjs +1 -1
  28. package/site/icons.d.mts +4 -0
  29. package/site/icons.mjs +2 -0
  30. package/site/pageColors.ts +3 -3
  31. package/site/siteCsp.d.mts +1 -1
  32. package/site/siteCsp.mjs +3 -2
  33. package/src/ActionParts.tsx +1 -1
  34. package/src/Advanced.tsx +4 -4
  35. package/src/App.tsx +19 -17
  36. package/src/AppList.tsx +46 -19
  37. package/src/AppSettingsTab.tsx +15 -6
  38. package/src/BlockParts.tsx +7 -7
  39. package/src/BlockSettings.tsx +3 -3
  40. package/src/CmsPage.tsx +4 -1
  41. package/src/Colors.tsx +2 -2
  42. package/src/CommunicationParts.tsx +1 -1
  43. package/src/ContainmentParts.tsx +1 -1
  44. package/src/DataForm.tsx +1 -0
  45. package/src/EntryForm.tsx +1 -0
  46. package/src/Library.tsx +21 -10
  47. package/src/MainDrawerParts.tsx +1 -1
  48. package/src/MarkdownEditor.tsx +1 -1
  49. package/src/ModalSheetSample.tsx +1 -1
  50. package/src/MorphemeConnection.tsx +30 -0
  51. package/src/MorphemeLogo.tsx +9 -0
  52. package/src/NavigationParts.tsx +1 -1
  53. package/src/NavigationRestParts.tsx +1 -1
  54. package/src/Parts.tsx +9 -9
  55. package/src/PartsShown.tsx +1 -1
  56. package/src/PickerParts.tsx +1 -1
  57. package/src/SearchPreview.tsx +37 -14
  58. package/src/SelectionParts.tsx +1 -1
  59. package/src/SideColumn.tsx +2 -2
  60. package/src/StoreConnectNotice.tsx +1 -1
  61. package/src/StoreConnectedRow.tsx +20 -14
  62. package/src/StoreStatus.tsx +2 -10
  63. package/src/ThemeStoreScreen.tsx +1 -2
  64. package/src/Tokens.tsx +2 -2
  65. package/src/apps.ts +16 -14
  66. package/src/buildSettings.ts +1 -1
  67. package/src/editor.css +13 -2
  68. package/src/examples.ts +2 -2
  69. package/src/googleResult.d.mts +12 -1
  70. package/src/googleResult.mjs +19 -2
  71. package/src/i18n/da-DK/apps.ts +5 -1
  72. package/src/i18n/da-DK/cms.ts +1 -0
  73. package/src/i18n/da-DK/common.ts +3 -2
  74. package/src/i18n/en-US/advanced.ts +1 -1
  75. package/src/i18n/en-US/apps.ts +6 -1
  76. package/src/i18n/en-US/cms.ts +1 -0
  77. package/src/i18n/en-US/common.ts +6 -4
  78. package/src/i18n/en-US/parts.ts +3 -3
  79. package/src/menuChildren.ts +6 -14
  80. package/src/movedRoutes.mjs +12 -8
  81. package/src/movedRoutes.test.mjs +13 -10
  82. package/src/r2Caching.ts +1 -1
  83. package/src/registry.tsx +2 -2
  84. package/src/schema.ts +1 -1
  85. package/src/site.ts +2 -2
  86. package/src/store.ts +4 -8
  87. package/src/themeUi.ts +7 -7
  88. package/src/ui.tsx +2 -2
  89. package/theme/components/AppSlot.astro +4 -4
  90. package/worker/index.js +6 -3
  91. package/worker/plan-status.js +1 -1
  92. package/worker/r2-status.js +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # @morphemecms/cms
2
2
 
3
+ ## 0.14.0-next-20261107080444
4
+
5
+ ### Minor Changes
6
+
7
+ - a70c310: Apps can be turned off without uninstalling them; the app page shows the app's version.
8
+
9
+ ### Patch Changes
10
+
11
+ - dd625db: Editor menu: Advanced is now Developer, with Parts and Caching and builds in it. Parts moved from Appearance; old links still work.
12
+ - 5cd4149: Editor menu: "Lists" is now "Dynamic lists", one row that opens the page with all lists (no fold-out).
13
+ - 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.
14
+ - 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.
15
+ - 006c78b: The connection to Morpheme moves from the store screens to the Morpheme page under Settings, with the Morpheme logo.
16
+
3
17
  ## 0.13.0
4
18
 
5
19
  ### 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
 
@@ -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
 
package/lib/menu.test.mjs CHANGED
@@ -83,7 +83,8 @@ test('the app store is Find apps', () => {
83
83
  });
84
84
 
85
85
  test('a screen of its own is itself, and the start screen is none', () => {
86
- assert.equal(currentPart('advanced', known), 'advanced');
86
+ assert.equal(currentPart('developer', known), 'developer');
87
+ assert.equal(currentPart('developer/parts/editor', known), 'developer');
87
88
  assert.equal(currentPart('licenses', known), 'licenses');
88
89
  assert.equal(currentPart('', known), '');
89
90
  });
@@ -159,7 +160,7 @@ const menuGroups = [
159
160
  { id: 'company', title: 'Business', ids: ['logo', 'company'] },
160
161
  { id: 'appearance', title: 'Appearance', ids: ['theme-store', 'header', 'footer', 'colors'] },
161
162
  { id: 'privacy', title: 'Privacy', ids: ['cookie-banner', 'cookies'] },
162
- { id: 'settings', title: 'Settings', ids: ['advanced'] },
163
+ { id: 'settings', title: 'Settings', ids: ['developer'] },
163
164
  ];
164
165
  const hrefs = {
165
166
  pages: '#list/pages',
@@ -182,7 +183,7 @@ const hrefs = {
182
183
  logo: '#company/logo',
183
184
  'cookie-banner': '#privacy/cookie-banner',
184
185
  cookies: '#list/cookies',
185
- advanced: '#advanced',
186
+ developer: '#developer',
186
187
  };
187
188
  const menu = menuTree(
188
189
  menuGroups,
@@ -228,14 +229,14 @@ test('Redirects has All and Not found as children', () => {
228
229
  );
229
230
  });
230
231
 
231
- test('Find apps is the first item under Apps, then the apps installed, and Advanced the only one under Settings', () => {
232
+ test('Find apps is the first item under Apps, then the apps installed, and Developer the only one under Settings', () => {
232
233
  assert.deepEqual(
233
234
  menu[3].items.map((i) => i.id),
234
235
  ['appstore', 'calendly'],
235
236
  );
236
237
  assert.deepEqual(
237
238
  menu[7].items.map((i) => i.id),
238
- ['advanced'],
239
+ ['developer'],
239
240
  );
240
241
  });
241
242
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morphemecms/cms",
3
- "version": "0.13.0",
3
+ "version": "0.14.0-next-20261107080444",
4
4
  "description": "Morpheme CMS: the editor for a site built with Astro, its screens, its blocks and the rules a site gives it. See README.md.",
5
5
  "license": "MIT",
6
6
  "author": "Lars Adolfsen",
@@ -1,7 +1,7 @@
1
1
  // Takes the pictures each app is shown with on its page and in the app store (AppAbout in
2
2
  // packages/editor/src/AppList.tsx): the few its index.ts picks to give an idea of it (`screenshots`, AppShot in
3
3
  // packages/editor/src/store.ts), as a theme's are, on a phone, in the standard theme with blue colours and with made-up
4
- // content only, never the site's: its blocks and kinds as "Udseende" › "Dele" › "Apps" draws them with the examples,
4
+ // content only, never the site's: its blocks and kinds as "Developer" › "Parts" › "Apps" draws them with the examples,
5
5
  // its lists, its settings (with the made-up `values` of the picture) and its own page in the editor, into
6
6
  // src/apps/<app>/screenshots/<n>-<what>.jpg (shotFile). The site is built in a folder of its own with the theme and the
7
7
  // colours (src/data/theme.json and colors.json as they were after), and the editor has every app installed, with GitHub
@@ -30,8 +30,8 @@ const BLUE = { source: '#1a73e8', variant: 'tonalSpot', contrast: 'standard' };
30
30
 
31
31
  const ids = process.argv.length > 2 ? process.argv.slice(2) : readdirSync(APPS).filter((id) => existsSync(join(APPS, id, 'index.ts')));
32
32
 
33
- // The pictures each app picks, read as store-release.mjs does, in their order. `part`: its drawing under "Dele" ›
34
- // "Blokke" or the list's screen (`parts`: #appearance/parts/blocks, #appearance/parts/list/<list>) by the part's name; `editor`: a screen of the editor (`at`: from the top of what is
33
+ // The pictures each app picks, read as store-release.mjs does, in their order. `part`: its drawing under "Parts" ›
34
+ // "Blokke" or the list's screen (`parts`: #developer/parts/blocks, #developer/parts/list/<list>) by the part's name; `editor`: a screen of the editor (`at`: from the top of what is
35
35
  // there). And each app's settings as the pictures show them: those it starts with, and the made-up ones of its picture.
36
36
  const vite = await createServer({
37
37
  configFile: false,
@@ -136,7 +136,7 @@ try {
136
136
  for (const [i, shot] of shots[id].entries()) {
137
137
  const options = { path: join(folder, shot.name), type: 'jpeg', quality: 80 };
138
138
  if (shot.part) {
139
- await go(`appearance/parts/${shot.parts}`);
139
+ await go(`developer/parts/${shot.parts}`);
140
140
  // Tall enough for the whole drawing, under the screen's top bar.
141
141
  await editor.setViewportSize({ width: phone.width, height: 1600 });
142
142
  const drawing = editor.locator(`section[data-part="${shot.part}"] > :last-child`).first();
@@ -1,4 +1,4 @@
1
- // The check of the build settings file (src/data/build.json, Indstillinger › Avanceret): an object with only the
1
+ // The check of the build settings file (src/data/build.json, Settings › Developer › Caching and builds): an object with only the
2
2
  // boolean keys contentLane and paidBuilds. Exposes buildSettingsProblems; used by check-content.mjs. No dependencies.
3
3
 
4
4
  const KEYS = ['contentLane', 'paidBuilds'];
@@ -138,7 +138,7 @@ export const STORE_STATES = {
138
138
  * store (a key of STORE_STATES). */
139
139
  export async function openEditor(page, site, texts = {}, store = 'button') {
140
140
  let state = STORE_STATES[store];
141
- // R2 caching under "Avanceret": Cloudflare has the bucket (packages/editor/worker/r2-status.js).
141
+ // R2 caching under "Caching and builds": Cloudflare has the bucket (packages/editor/worker/r2-status.js).
142
142
  await page.route(`${site}/api/r2?**`, (r) =>
143
143
  r.fulfill({ contentType: 'application/json', body: JSON.stringify({ bound: false, bucket: 'ready' }) }),
144
144
  );
@@ -185,7 +185,7 @@ try {
185
185
  tracked = execFileSync('git', ['ls-files'], { cwd: root, encoding: 'utf8' }).split('\n').filter(Boolean);
186
186
  } catch {}
187
187
  result.problems.push(...casingProblems(tracked));
188
- // The build settings the editor writes under "Avanceret": only boolean contentLane and paidBuilds; a missing file passes.
188
+ // The build settings the editor writes under "Caching and builds": only boolean contentLane and paidBuilds; a missing file passes.
189
189
  result.problems.push(
190
190
  ...buildSettingsProblems(BUILD_SETTINGS_PATH, existsSync(BUILD_SETTINGS_PATH) ? readFileSync(BUILD_SETTINGS_PATH, 'utf8') : undefined),
191
191
  );
@@ -472,7 +472,7 @@ try {
472
472
  await run('r2-fejler', failing);
473
473
  await settle();
474
474
 
475
- // R2 caching under "Indstillinger" › "Avanceret" (r2Caching.ts): the Worker's settings have the line and its bucket, and
475
+ // R2 caching under Settings › Developer › Caching and builds (r2Caching.ts): the Worker's settings have the line and its bucket, and
476
476
  // with it on or off they are settings wrangler reads, with the bucket bound as FILES or no bucket; on and off again,
477
477
  // they are as they were. And /api/r2 (packages/editor/worker/r2-status.js) says what Cloudflare says of the bucket, only to someone who
478
478
  // can save, so the switch is turned on only when Cloudflare has it.