@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.
- package/CHANGELOG.md +14 -0
- package/defaults/editor.ts +3 -14
- package/icons/lucide/dots-three-vertical.svg +1 -0
- package/icons/material-symbols/dots-three-vertical.svg +1 -0
- package/icons/phosphor/dots-three-vertical.svg +1 -0
- package/lib/appChanges.d.mts +16 -0
- package/lib/appChanges.mjs +20 -0
- package/lib/appChanges.test.mjs +30 -0
- package/lib/apps.d.mts +2 -2
- package/lib/apps.mjs +13 -7
- package/lib/appsFile.d.mts +20 -0
- package/lib/appsFile.mjs +57 -0
- package/lib/appsFile.test.mjs +53 -0
- package/lib/build-settings.mjs +1 -1
- package/lib/google-result.test.mjs +40 -9
- package/lib/menu.mjs +1 -1
- package/lib/menu.test.mjs +6 -5
- package/package.json +1 -1
- package/scripts/app-screenshots.mjs +4 -4
- package/scripts/build-settings-problems.mjs +1 -1
- package/scripts/built-site.mjs +1 -1
- package/scripts/check-content.mjs +1 -1
- package/scripts/check-worker.mjs +1 -1
- package/scripts/editor-screens.mjs +32 -16
- package/site/apps.ts +10 -4
- package/site/consent.ts +3 -3
- package/site/defineSite.mjs +1 -1
- package/site/icons.d.mts +4 -0
- package/site/icons.mjs +2 -0
- package/site/pageColors.ts +3 -3
- package/site/siteCsp.d.mts +1 -1
- package/site/siteCsp.mjs +3 -2
- package/src/ActionParts.tsx +1 -1
- package/src/Advanced.tsx +4 -4
- package/src/App.tsx +19 -17
- package/src/AppList.tsx +46 -19
- package/src/AppSettingsTab.tsx +15 -6
- package/src/BlockParts.tsx +7 -7
- package/src/BlockSettings.tsx +3 -3
- package/src/CmsPage.tsx +4 -1
- package/src/Colors.tsx +2 -2
- package/src/CommunicationParts.tsx +1 -1
- package/src/ContainmentParts.tsx +1 -1
- package/src/DataForm.tsx +1 -0
- package/src/EntryForm.tsx +1 -0
- package/src/Library.tsx +21 -10
- package/src/MainDrawerParts.tsx +1 -1
- package/src/MarkdownEditor.tsx +1 -1
- package/src/ModalSheetSample.tsx +1 -1
- package/src/MorphemeConnection.tsx +30 -0
- package/src/MorphemeLogo.tsx +9 -0
- package/src/NavigationParts.tsx +1 -1
- package/src/NavigationRestParts.tsx +1 -1
- package/src/Parts.tsx +9 -9
- package/src/PartsShown.tsx +1 -1
- package/src/PickerParts.tsx +1 -1
- package/src/SearchPreview.tsx +37 -14
- package/src/SelectionParts.tsx +1 -1
- package/src/SideColumn.tsx +2 -2
- package/src/StoreConnectNotice.tsx +1 -1
- package/src/StoreConnectedRow.tsx +20 -14
- package/src/StoreStatus.tsx +2 -10
- package/src/ThemeStoreScreen.tsx +1 -2
- package/src/Tokens.tsx +2 -2
- package/src/apps.ts +16 -14
- package/src/buildSettings.ts +1 -1
- package/src/editor.css +13 -2
- package/src/examples.ts +2 -2
- package/src/googleResult.d.mts +12 -1
- package/src/googleResult.mjs +19 -2
- package/src/i18n/da-DK/apps.ts +5 -1
- package/src/i18n/da-DK/cms.ts +1 -0
- package/src/i18n/da-DK/common.ts +3 -2
- package/src/i18n/en-US/advanced.ts +1 -1
- package/src/i18n/en-US/apps.ts +6 -1
- package/src/i18n/en-US/cms.ts +1 -0
- package/src/i18n/en-US/common.ts +6 -4
- package/src/i18n/en-US/parts.ts +3 -3
- package/src/menuChildren.ts +6 -14
- package/src/movedRoutes.mjs +12 -8
- package/src/movedRoutes.test.mjs +13 -10
- package/src/r2Caching.ts +1 -1
- package/src/registry.tsx +2 -2
- package/src/schema.ts +1 -1
- package/src/site.ts +2 -2
- package/src/store.ts +4 -8
- package/src/themeUi.ts +7 -7
- package/src/ui.tsx +2 -2
- package/theme/components/AppSlot.astro +4 -4
- package/worker/index.js +6 -3
- package/worker/plan-status.js +1 -1
- 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
|
package/defaults/editor.ts
CHANGED
|
@@ -129,7 +129,7 @@ export const icons: Record<string, Icon> = {
|
|
|
129
129
|
appstore: Storefront,
|
|
130
130
|
'theme-store': Storefront,
|
|
131
131
|
morpheme: Sparkle,
|
|
132
|
-
|
|
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', '
|
|
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
|
|
42
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
};
|
package/lib/appsFile.mjs
ADDED
|
@@ -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
|
+
});
|
package/lib/build-settings.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// The build settings in src/data/build.json (
|
|
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('
|
|
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: ['
|
|
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
|
-
|
|
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
|
|
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
|
-
['
|
|
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.
|
|
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 "
|
|
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 "
|
|
34
|
-
// "Blokke" or the list's screen (`parts`: #
|
|
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(`
|
|
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,
|
|
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'];
|
package/scripts/built-site.mjs
CHANGED
|
@@ -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 "
|
|
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 "
|
|
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
|
);
|
package/scripts/check-worker.mjs
CHANGED
|
@@ -472,7 +472,7 @@ try {
|
|
|
472
472
|
await run('r2-fejler', failing);
|
|
473
473
|
await settle();
|
|
474
474
|
|
|
475
|
-
// R2 caching under
|
|
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.
|