@morphemecms/cms 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/astro.mjs +2 -1
  3. package/lib/appId.d.mts +1 -0
  4. package/lib/appId.mjs +6 -0
  5. package/lib/apps.mjs +13 -3
  6. package/lib/apps.test.mjs +10 -1
  7. package/lib/contracts.d.mts +3 -2
  8. package/lib/contracts.mjs +3 -2
  9. package/lib/folderChanges.d.mts +29 -0
  10. package/lib/folderChanges.mjs +34 -0
  11. package/lib/section-ranges.d.mts +1 -1
  12. package/lib/section-ranges.mjs +8 -14
  13. package/lib/section-ranges.test.mjs +26 -5
  14. package/lib/storeEntries.d.mts +3 -1
  15. package/lib/storeEntries.mjs +22 -8
  16. package/lib/storeEntries.test.mjs +56 -7
  17. package/lib/text-sections.d.mts +11 -1
  18. package/lib/text-sections.mjs +18 -8
  19. package/lib/text-sections.test.mjs +34 -1
  20. package/lib/video-id.d.mts +6 -2
  21. package/lib/video-id.mjs +5 -1
  22. package/lib/video.d.mts +6 -2
  23. package/lib/video.mjs +5 -1
  24. package/lib/without-h1.d.mts +1 -0
  25. package/lib/without-h1.mjs +19 -4
  26. package/lib/without-h1.test.mjs +30 -1
  27. package/package.json +3 -2
  28. package/scripts/app-screenshots.mjs +2 -1
  29. package/scripts/check-content.mjs +2 -1
  30. package/scripts/check-site-types.mjs +26 -0
  31. package/scripts/site-types.mjs +14 -0
  32. package/scripts/site-types.test.mjs +21 -0
  33. package/site/activeTheme.test.mjs +52 -0
  34. package/site/appSelf.d.mts +2 -0
  35. package/site/appSelf.mjs +23 -0
  36. package/site/appSelf.test.mjs +35 -0
  37. package/site/content.ts +5 -2
  38. package/site/siteModules.mjs +2 -1
  39. package/site/theme.mjs +19 -8
  40. package/src/App.tsx +1 -1
  41. package/src/AppList.tsx +24 -9
  42. package/src/BlockDialog.tsx +38 -17
  43. package/src/CreateFab.tsx +8 -0
  44. package/src/DataForm.tsx +7 -17
  45. package/src/EntryForm.tsx +1 -1
  46. package/src/FormFrame.tsx +4 -3
  47. package/src/Library.tsx +4 -11
  48. package/src/ListFab.tsx +13 -0
  49. package/src/MarkdownEditor.tsx +88 -47
  50. package/src/Media.tsx +4 -13
  51. package/src/Redirects.tsx +6 -14
  52. package/src/Screen.tsx +13 -3
  53. package/src/StartFab.tsx +5 -7
  54. package/src/ThemeStore.tsx +33 -12
  55. package/src/apps.ts +2 -3
  56. package/src/drawerYield.d.mts +3 -0
  57. package/src/drawerYield.mjs +10 -0
  58. package/src/drawerYield.test.mjs +22 -0
  59. package/src/editor.css +55 -22
  60. package/src/fabScreens.test.mjs +28 -0
  61. package/src/fabScreens.ts +16 -0
  62. package/src/legacyMove.test.mjs +137 -0
  63. package/src/legacyMove.ts +131 -0
  64. package/src/markdown.mjs +10 -7
  65. package/src/markdown.test.mjs +16 -2
  66. package/src/menuOpen.ts +20 -4
  67. package/src/movedRoutes.mjs +2 -2
  68. package/src/movedRoutes.test.mjs +5 -0
  69. package/src/pageH1Panel.d.mts +4 -0
  70. package/src/pageH1Panel.mjs +31 -0
  71. package/src/pageH1Panel.test.mjs +56 -0
  72. package/src/pagePreview.tsx +22 -12
  73. package/src/sectionFrame.ts +3 -3
  74. package/src/sectionState.d.mts +3 -3
  75. package/src/sectionState.mjs +6 -6
  76. package/src/sectionState.test.mjs +14 -2
  77. package/src/sheetPlacement.d.mts +6 -4
  78. package/src/sheetPlacement.mjs +15 -10
  79. package/src/sheetPlacement.test.mjs +16 -9
  80. package/src/sidePanel.ts +38 -0
  81. package/src/store.ts +32 -27
  82. package/src/topBarSlot.tsx +7 -15
  83. package/src/ui.tsx +26 -15
  84. package/src/virtual.d.ts +6 -0
  85. package/starter/package.json +1 -1
  86. package/theme/components/PageHeading.astro +22 -0
  87. package/theme/layouts/PageLayout.astro +37 -20
  88. package/theme/styles/global.css +2 -1
  89. package/theme/styles/ui.css +40 -10
package/lib/video-id.mjs CHANGED
@@ -43,7 +43,11 @@ export function videoOf(text) {
43
43
  return null;
44
44
  }
45
45
 
46
- /** The address that plays the video, with the settings in the address of the iframe it came from. */
46
+ /** The address that plays the video, with the settings in the address of the iframe it came from.
47
+ * @param {any} video
48
+ * @param {string | null} [frame]
49
+ * @returns {string}
50
+ */
47
51
  export function player(video, frame) {
48
52
  let params;
49
53
  try {
package/lib/video.d.mts CHANGED
@@ -1,7 +1,11 @@
1
1
  /** What stands in for a video (videoOf) on the site: its page, the address that plays it (`frame`: the address of the
2
2
  * iframe it replaces, if any), its title, and its still from the site, when it could be fetched. The video block's
3
- * view too (src/standard/video/load.ts). */
4
- export function facadeOf(video: any, title: any, frame: any): {
3
+ * view too (src/standard/video/load.ts).
4
+ * @param {any} video
5
+ * @param {any} [title]
6
+ * @param {string | null} [frame]
7
+ */
8
+ export function facadeOf(video: any, title?: any, frame?: string | null): {
5
9
  href: string;
6
10
  player: string;
7
11
  title: any;
package/lib/video.mjs CHANGED
@@ -39,7 +39,11 @@ function facade(video, title, frame, play) {
39
39
 
40
40
  /** What stands in for a video (videoOf) on the site: its page, the address that plays it (`frame`: the address of the
41
41
  * iframe it replaces, if any), its title, and its still from the site, when it could be fetched. The video block's
42
- * view too (src/standard/video/load.ts). */
42
+ * view too (src/standard/video/load.ts).
43
+ * @param {any} video
44
+ * @param {any} [title]
45
+ * @param {string | null} [frame]
46
+ */
43
47
  export function facadeOf(video, title, frame) {
44
48
  const still = `/images/_video/${stillName(video)}`;
45
49
  return {
@@ -1 +1,2 @@
1
1
  export function withoutH1(html: string): string;
2
+ export function h1SectionColor(html: string): string | null;
@@ -1,7 +1,22 @@
1
- // A page's text as HTML without its H1, which the layout draws in its own place (above the picture, PageLayout.astro).
2
- // Exposes withoutH1 (pure); the H1 is the first one wherever it stands, as pageH1 (src/sections.mjs) reads it. No dependencies.
1
+ // A page's text as HTML without its H1, which the layout draws in its own place (above the picture, PageLayout.astro), and the
2
+ // colour of the H1's section (the marker before it, lib/section-marker.mjs). Exposes withoutH1 and h1SectionColor (pure); the H1
3
+ // is the first one wherever it stands, as pageH1 (src/sections.mjs) reads it. Depends on text-sections.mjs and section-marker.mjs.
4
+ import { LABEL, MARKER } from './text-sections.mjs';
5
+ import { SECTION_COLORS } from './section-marker.mjs';
3
6
 
4
- /** The html without its first <h1>…</h1> and the white space after it.
7
+ // The first <h1>…</h1> and the white space after it, with the marker before it and the label between them, if any.
8
+ const H1 = new RegExp(`(?:${MARKER})?(${LABEL})<h1\\b[^>]*>[\\s\\S]*?<\\/h1>\\s*`);
9
+
10
+ /** The html without its first <h1>…</h1> and the white space after it, and without the marker before it (a label above it
11
+ * stays in the text).
5
12
  * @param {string} html
6
13
  * @returns {string} */
7
- export const withoutH1 = (html) => html.replace(/<h1\b[^>]*>[\s\S]*?<\/h1>\s*/, '');
14
+ export const withoutH1 = (html) => html.replace(H1, (_, __, label) => label);
15
+
16
+ /** The colour of the section the first H1 starts (the marker before it, or before the label above it), or null.
17
+ * @param {string} html
18
+ * @returns {string | null} */
19
+ export const h1SectionColor = (html) => {
20
+ const color = html.match(H1)?.[1];
21
+ return SECTION_COLORS.includes(color) ? color : null;
22
+ };
@@ -2,7 +2,7 @@
2
2
  // Run with `node --test packages/editor/lib/*.test.mjs` (part of npm run check).
3
3
  import { test } from 'node:test';
4
4
  import assert from 'node:assert/strict';
5
- import { withoutH1 } from './without-h1.mjs';
5
+ import { h1SectionColor, withoutH1 } from './without-h1.mjs';
6
6
 
7
7
  test('an H1 at the top is taken out, with the space after it', () => {
8
8
  assert.equal(withoutH1('<h1 id="title">Title</h1>\n<p>Text</p>'), '<p>Text</p>');
@@ -20,6 +20,35 @@ test('only the first H1 goes', () => {
20
20
  assert.equal(withoutH1('<h1>A</h1><p>x</p><h1>B</h1>'), '<p>x</p><h1>B</h1>');
21
21
  });
22
22
 
23
+ const M = (color) => `<div data-section="${color}"></div>`;
24
+
25
+ test('the marker before the H1 is taken out with it', () => {
26
+ assert.equal(withoutH1(`${M('tint')}\n<h1 id="t">Title</h1>\n<p>Text</p>`), '<p>Text</p>');
27
+ assert.equal(withoutH1(`<p>Intro</p>\n${M('tint')}\n<h1>Title</h1>\n<h2>A</h2>`), '<p>Intro</p>\n<h2>A</h2>');
28
+ });
29
+
30
+ test('a label between the marker and the H1 stays in the text', () => {
31
+ const label = '<p class="block-eyebrow">Hello</p>';
32
+ assert.equal(withoutH1(`${M('accent')}\n${label}\n<h1>Title</h1>\n<p>Text</p>`), `${label}\n<p>Text</p>`);
33
+ });
34
+
35
+ test('the marker of an H2 is not taken out', () => {
36
+ const html = `<h1>Title</h1>\n${M('tint')}\n<h2>A</h2>`;
37
+ assert.equal(withoutH1(html), `${M('tint')}\n<h2>A</h2>`);
38
+ });
39
+
40
+ test('the colour of the H1 section is the marker before the H1', () => {
41
+ assert.equal(h1SectionColor(`${M('tint')}\n<h1>Title</h1>\n<p>x</p>`), 'tint');
42
+ assert.equal(h1SectionColor(`<p>a</p>${M('accent')}<p class="block-eyebrow">L</p><h1>T</h1>`), 'accent');
43
+ });
44
+
45
+ test('without a marker before the H1 there is no colour', () => {
46
+ assert.equal(h1SectionColor('<h1>Title</h1><p>x</p>'), null);
47
+ assert.equal(h1SectionColor(`<h1>Title</h1>${M('tint')}<h2>A</h2>`), null);
48
+ assert.equal(h1SectionColor(`${M('red')}<h1>Title</h1>`), null);
49
+ assert.equal(h1SectionColor('<p>x</p>'), null);
50
+ });
51
+
23
52
  test('without an H1 the html is as it was', () => {
24
53
  assert.equal(withoutH1('<p>Text</p><h2>Section</h2>'), '<p>Text</p><h2>Section</h2>');
25
54
  assert.equal(withoutH1('<header>x</header>'), '<header>x</header>');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morphemecms/cms",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
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",
@@ -40,7 +40,8 @@
40
40
  },
41
41
  "scripts": {
42
42
  "build": "node scripts/demo-build.mjs",
43
- "check": "npm run format:check && npm run check:language && npm test && npm run check:types && npm run build && npm run check:editor",
43
+ "check": "npm run format:check && npm run check:language && npm test && npm run check:types && npm run build && npm run check:site-types && npm run check:editor",
44
+ "check:site-types": "node scripts/check-site-types.mjs",
44
45
  "check:types": "tsc -p tsconfig.types.json --noEmit",
45
46
  "check:editor": "cd .morpheme/demo/site && node ../../../bin/morpheme.mjs check:editor",
46
47
  "check:screens": "cd .morpheme/demo/site && node ../../../bin/morpheme.mjs check:screens",
@@ -17,6 +17,7 @@ import { chromium } from 'playwright';
17
17
  import { createServer } from 'vite';
18
18
  import { aliases, themePlugin } from '@morphemecms/cms/site/theme.mjs';
19
19
  import { appFoldersPlugin } from '@morphemecms/cms/lib/apps.mjs';
20
+ import { appSelf } from '@morphemecms/cms/site/appSelf.mjs';
20
21
  import { binOf } from './bin.mjs';
21
22
  import { openEditor, serveBuilt } from './built-site.mjs';
22
23
 
@@ -36,7 +37,7 @@ const vite = await createServer({
36
37
  configFile: false,
37
38
  logLevel: 'error',
38
39
  appType: 'custom',
39
- plugins: [themePlugin(), appFoldersPlugin(ROOT)],
40
+ plugins: [themePlugin(), appFoldersPlugin(ROOT), appSelf()],
40
41
  resolve: { alias: aliases() },
41
42
  server: { middlewareMode: true, hmr: false, ws: false },
42
43
  });
@@ -25,6 +25,7 @@ import {
25
25
  themeProblems,
26
26
  } from '@morphemecms/cms/site/theme.mjs';
27
27
  import { appFoldersPlugin, appIds, appWorkersProblems } from '@morphemecms/cms/lib/apps.mjs';
28
+ import { appSelf } from '@morphemecms/cms/site/appSelf.mjs';
28
29
  import { caseCollisionProblems } from '@morphemecms/cms/lib/caseCollisions.mjs';
29
30
  import { codePackageProblems, declarationProblems, ownerId } from '@morphemecms/cms/lib/licenses.mjs';
30
31
  import { breakpointProblems } from '@morphemecms/cms/site/breakpoints.mjs';
@@ -70,7 +71,7 @@ const server = await createServer({
70
71
  logLevel: 'error',
71
72
  appType: 'custom',
72
73
  // As in packages/editor/astro.mjs: the editor takes the site from src/site/ as @site, and the theme in use as @theme.
73
- plugins: [themePlugin(), appFoldersPlugin(process.cwd())],
74
+ plugins: [themePlugin(), appFoldersPlugin(process.cwd()), appSelf()],
74
75
  resolve: { alias: aliases() },
75
76
  server: { middlewareMode: true, hmr: false, ws: false },
76
77
  });
@@ -0,0 +1,26 @@
1
+ // Type-checks the package's whole TS source in the demo site, with the site's own tsc, as a site that has it from npm does.
2
+ // Run by `npm run check:site-types`, after the build (.morpheme/demo/site, node_modules/@morphemecms/cms). Wiring only.
3
+ import { execFileSync } from 'node:child_process';
4
+ import { rmSync, writeFileSync } from 'node:fs';
5
+ import { join, relative } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { binOf } from './bin.mjs';
8
+ import { siteTypesConfig } from './site-types.mjs';
9
+
10
+ const root = fileURLToPath(new URL('../', import.meta.url));
11
+ const site = join(root, '.morpheme/demo/site');
12
+ const config = join(site, 'tsconfig.package.json');
13
+ const packageDir = relative(site, join(root, 'node_modules/@morphemecms/cms')).replaceAll('\\', '/');
14
+
15
+ writeFileSync(config, JSON.stringify(siteTypesConfig(packageDir), null, 2));
16
+ try {
17
+ execFileSync(process.execPath, [binOf('typescript', 'tsc', site), '--noEmit', '-p', 'tsconfig.package.json'], {
18
+ cwd: site,
19
+ stdio: 'inherit',
20
+ });
21
+ } catch (error) {
22
+ console.error('The package source does not type-check as a site type-checks it: sites will fail the same way.');
23
+ process.exitCode = error.status ?? 1;
24
+ } finally {
25
+ rmSync(config, { force: true });
26
+ }
@@ -0,0 +1,14 @@
1
+ // The tsconfig of the site-style type check (check-site-types.mjs): the demo site's own, plus the package's whole TS source.
2
+ // Exposes siteTypesConfig(packageDir). No dependencies.
3
+
4
+ const DIRS = ['src', 'site', 'lib', 'defaults', 'theme'];
5
+
6
+ /**
7
+ * @param {string} packageDir the package's folder, relative to the site, with forward slashes
8
+ * @returns {{ extends: string, include: string[], exclude: string[] }}
9
+ */
10
+ export const siteTypesConfig = (packageDir) => ({
11
+ extends: './tsconfig.json',
12
+ include: ['.astro/types.d.ts', '**/*', ...DIRS.flatMap((dir) => [`${packageDir}/${dir}/**/*.ts`, `${packageDir}/${dir}/**/*.tsx`])],
13
+ exclude: ['dist', `${packageDir}/**/*.test.*`],
14
+ });
@@ -0,0 +1,21 @@
1
+ // Tests siteTypesConfig (site-types.mjs): the tsconfig that type-checks the package's source as a site does.
2
+ import assert from 'node:assert/strict';
3
+ import { test } from 'node:test';
4
+ import { siteTypesConfig } from './site-types.mjs';
5
+
6
+ const PACKAGE = '../../../node_modules/@morphemecms/cms';
7
+ const config = siteTypesConfig(PACKAGE);
8
+
9
+ test('extends the site tsconfig', () => {
10
+ assert.equal(config.extends, './tsconfig.json');
11
+ });
12
+
13
+ test('includes the site first, then the package source', () => {
14
+ assert.deepEqual(config.include.slice(0, 2), ['.astro/types.d.ts', '**/*']);
15
+ assert.ok(config.include.includes(`${PACKAGE}/src/**/*.tsx`));
16
+ assert.ok(config.include.includes(`${PACKAGE}/lib/**/*.ts`));
17
+ });
18
+
19
+ test('excludes dist and the package tests', () => {
20
+ assert.deepEqual(config.exclude, ['dist', `${PACKAGE}/**/*.test.*`]);
21
+ });
@@ -0,0 +1,52 @@
1
+ // Tests of activeTheme and installedThemes (site/theme.mjs) with permanent theme ids.
2
+ // Run with `node --test site/activeTheme.test.mjs`; the site's root is the working directory, set before the import.
3
+ import { strict as assert } from 'node:assert';
4
+ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
5
+ import { registerHooks } from 'node:module';
6
+ import { tmpdir } from 'node:os';
7
+ import { join } from 'node:path';
8
+ import { test } from 'node:test';
9
+
10
+ const PACKAGE = '@morphemecms/cms/';
11
+ const pkg = new URL('../', import.meta.url);
12
+ registerHooks({
13
+ resolve: (specifier, context, next) =>
14
+ specifier.startsWith(PACKAGE) ? next(new URL(specifier.slice(PACKAGE.length), pkg).href, context) : next(specifier, context),
15
+ });
16
+
17
+ const site = mkdtempSync(join(tmpdir(), 'site-'));
18
+ const theme = (name) => {
19
+ mkdirSync(join(site, 'src/themes', name), { recursive: true });
20
+ writeFileSync(join(site, 'src/themes', name, 'theme.json'), '{}');
21
+ };
22
+ const choose = (value) => {
23
+ mkdirSync(join(site, 'src/data'), { recursive: true });
24
+ writeFileSync(join(site, 'src/data/theme.json'), JSON.stringify({ theme: value }));
25
+ };
26
+ theme('theme_fa395ad84690acbd');
27
+ theme('own-theme');
28
+ process.chdir(site);
29
+ const { activeTheme, installedThemes } = await import('./theme.mjs');
30
+
31
+ test('a permanent theme id is accepted', () => {
32
+ choose('theme_fa395ad84690acbd');
33
+ assert.equal(activeTheme(), 'theme_fa395ad84690acbd');
34
+ });
35
+
36
+ test('the standard theme and a site-own theme are accepted', () => {
37
+ choose('saturn');
38
+ assert.equal(activeTheme(), 'saturn');
39
+ choose('own-theme');
40
+ assert.equal(activeTheme(), 'own-theme');
41
+ });
42
+
43
+ test('a theme id that is not a store id stops the build', () => {
44
+ choose('theme_xyz');
45
+ assert.throws(() => activeTheme(), /theme_xyz/);
46
+ });
47
+
48
+ test('a theme folder that looks like a store id but is not one stops the build, naming the folder', () => {
49
+ assert.deepEqual(installedThemes(), ['saturn', 'own-theme', 'theme_fa395ad84690acbd']);
50
+ theme('theme_xyz');
51
+ assert.throws(() => installedThemes(), /src\/themes\/theme_xyz/);
52
+ });
@@ -0,0 +1,2 @@
1
+ /** @returns {import('vite').Plugin} */
2
+ export function appSelf(): import("vite").Plugin;
@@ -0,0 +1,23 @@
1
+ // The Vite plugin behind `import self from '@app/self'` (app contract 3): the id of the app whose folder the importing
2
+ // file is in. Exposes appSelf. Depends on appIdOf (lib/appId.mjs).
3
+ import { appIdOf } from '../lib/appId.mjs';
4
+
5
+ const SELF = '@app/self';
6
+ const PREFIX = '\0app-self:';
7
+
8
+ /** @returns {import('vite').Plugin} */
9
+ export function appSelf() {
10
+ return {
11
+ name: 'app-self',
12
+ enforce: 'pre',
13
+ resolveId(source, importer) {
14
+ if (source !== SELF) return null;
15
+ const path = (importer ?? '').split('?')[0].replaceAll('\\', '/');
16
+ if (!/\/src\/apps\/[^/]+\//.test(path)) throw new Error(`${SELF} is only for app files: ${path}`);
17
+ return PREFIX + appIdOf(path);
18
+ },
19
+ load(id) {
20
+ return id.startsWith(PREFIX) ? `export default { id: ${JSON.stringify(id.slice(PREFIX.length)).replaceAll('"', "'")} };` : null;
21
+ },
22
+ };
23
+ }
@@ -0,0 +1,35 @@
1
+ // Tests for the @app/self Vite plugin (site/appSelf.mjs): the id an app file gets, and the error outside apps.
2
+ import test from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import { appSelf } from './appSelf.mjs';
5
+
6
+ /** The module text the plugin makes for `@app/self` imported from `importer`. */
7
+ const moduleFor = (importer) => {
8
+ const plugin = appSelf();
9
+ const id = plugin.resolveId('@app/self', importer);
10
+ return plugin.load(id);
11
+ };
12
+
13
+ test('gives a store id', () => {
14
+ assert.equal(moduleFor('/x/src/apps/app_a030dfba69793996/link.ts'), "export default { id: 'app_a030dfba69793996' };");
15
+ });
16
+
17
+ test('gives a plain folder name', () => {
18
+ assert.equal(moduleFor('/x/src/apps/cookiescanner/worker.js'), "export default { id: 'cookiescanner' };");
19
+ });
20
+
21
+ test('gives the app of a nested file', () => {
22
+ assert.equal(moduleFor('/x/src/apps/mobilepay/blocks/mobilepay/load.ts'), "export default { id: 'mobilepay' };");
23
+ });
24
+
25
+ test('throws outside an app, with the path', () => {
26
+ assert.throws(() => moduleFor('/x/src/themes/saturn/a.ts'), /@app\/self is only for app files: \/x\/src\/themes\/saturn\/a\.ts/);
27
+ });
28
+
29
+ test('gives the same id for a Windows path', () => {
30
+ assert.equal(moduleFor(String.raw`C:\x\src\apps\app_a030dfba69793996\link.ts`), moduleFor('/x/src/apps/app_a030dfba69793996/link.ts'));
31
+ });
32
+
33
+ test('ignores other imports', () => {
34
+ assert.equal(appSelf().resolveId('react', '/x/src/apps/a/b.ts'), null);
35
+ });
package/site/content.ts CHANGED
@@ -1,14 +1,17 @@
1
+ // The site's content collections (pages and blog posts): their status, paths, headings, and a page's text without its H1.
2
+ // Exposes getPages, getPosts, pageHeading, pageHeadingHtml, withoutH1 and h1SectionColor. Depends on astro:content and lib/.
1
3
  import { getCollection, type CollectionEntry } from 'astro:content';
2
4
  import { categories, isBlogCategory } from './categories';
3
5
  import { displayTitle } from './text';
4
6
  import { isPreview } from './noindex.mjs';
5
7
  import { stateOf } from '@morphemecms/cms/lib/status.mjs';
6
8
  import { pageH1 } from '@morphemecms/cms/src/sections.mjs';
7
- import { withoutH1 } from '@morphemecms/cms/lib/without-h1.mjs';
9
+ import { h1SectionColor, withoutH1 } from '@morphemecms/cms/lib/without-h1.mjs';
8
10
 
9
11
  // From text.ts, which the editor uses too (it has no content collections).
10
12
  export { displayTitle, openingText } from './text';
11
- export { stateOf, withoutH1 };
13
+ // withoutH1 and h1SectionColor: a page's text without its H1 (and its marker), and the colour of the H1's section (PageLayout.astro).
14
+ export { h1SectionColor, stateOf, withoutH1 };
12
15
 
13
16
  export type Post = CollectionEntry<'blog'>;
14
17
  // Every page has a path: its own, or /<file name>/ for pages created in the editor.
@@ -3,6 +3,7 @@
3
3
  import { createServer } from 'vite';
4
4
  import { appFoldersPlugin } from '../lib/apps.mjs';
5
5
  import { aliases, themePlugin } from './theme.mjs';
6
+ import { appSelf } from './appSelf.mjs';
6
7
 
7
8
  /**
8
9
  * Runs `use` with a loader of the site's modules (`@site/collections`, `/src/apps/<id>/index.ts`), then closes it.
@@ -15,7 +16,7 @@ export async function withSiteModules(use) {
15
16
  configFile: false,
16
17
  logLevel: 'error',
17
18
  appType: 'custom',
18
- plugins: [themePlugin(), appFoldersPlugin(`${process.cwd()}/`)],
19
+ plugins: [themePlugin(), appFoldersPlugin(`${process.cwd()}/`), appSelf()],
19
20
  resolve: { alias: aliases() },
20
21
  server: { middlewareMode: true, hmr: false, ws: false },
21
22
  });
package/site/theme.mjs CHANGED
@@ -4,7 +4,7 @@
4
4
  // theme or the look of a block, is the standard theme's, styled by the theme in use's CSS. The site and the editor
5
5
  // reach the theme as @theme (themePlugin: the theme in use's file, else the standard theme's), and the site's own
6
6
  // parts (src/site/) as @site (aliases): for packages/editor/astro.mjs and packages/editor/scripts/check-content.mjs. Stops with what is
7
- // wrong, when the theme named is not installed. Also the virtual modules with all Phosphor's icons (allIconsModule) and with the theme's coverage of Material's parts
7
+ // wrong, when the theme named is not installed or a folder in src/themes/ is a malformed store id (a store id is theme_ + 16 hex characters). Also the virtual modules with all Phosphor's icons (allIconsModule) and with the theme's coverage of Material's parts
8
8
  // (coverageModule, site/themeCoverageFiles.mjs).
9
9
  import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
10
10
  import { join, relative } from 'node:path';
@@ -16,6 +16,7 @@ import { MATERIAL_ALL, MATERIAL_SSR, materialModule, materialSsrModule } from '.
16
16
  import { ICON_PACK_FILE, ICON_PACK_LICENSE, SYSTEM_PACKS, THEME_PACKS_FOLDER, packInUse, packTheme, themePackId } from './iconPacks.mjs';
17
17
  import { MOTION_SCHEMES } from '@morphemecms/cms/lib/motion-schemes.mjs';
18
18
  import { templateProblems } from '@morphemecms/cms/lib/page-template.mjs';
19
+ import { strayStoreId } from '@morphemecms/cms/lib/storeEntries.mjs';
19
20
  import { storeImportProblems } from './storeTheme.mjs';
20
21
  import { coverageModule } from './themeCoverageFiles.mjs';
21
22
 
@@ -52,13 +53,19 @@ export const THEME_FILES = [
52
53
  'editor/looks.mjs',
53
54
  ];
54
55
 
55
- /** The themes installed: the standard theme and the folders in src/themes/ with a theme.json. */
56
- export const installedThemes = () => [
57
- STANDARD_THEME,
58
- ...(existsSync(new URL('src/themes/', root)) ? readdirSync(new URL('src/themes/', root), { withFileTypes: true }) : [])
56
+ /** The themes installed: the standard theme and the folders in src/themes/ with a theme.json. Stops on a folder that
57
+ * starts with `app_` or `theme_` but is not a store id of a theme. */
58
+ export const installedThemes = () => {
59
+ const names = (existsSync(new URL('src/themes/', root)) ? readdirSync(new URL('src/themes/', root), { withFileTypes: true }) : [])
59
60
  .filter((e) => e.isDirectory() && existsSync(new URL(`src/themes/${e.name}/theme.json`, root)))
60
- .map((e) => e.name),
61
- ];
61
+ .map((e) => e.name);
62
+ const stray = names.find((name) => strayStoreId(name, 'theme'));
63
+ if (stray)
64
+ throw new Error(
65
+ `src/themes/${stray}: the folder name looks like a store id but is not one. A store theme id is theme_ and 16 lowercase hex characters; rename the folder.`,
66
+ );
67
+ return [STANDARD_THEME, ...names];
68
+ };
62
69
 
63
70
  /** What every theme has itself. */
64
71
  export const OWN_FILES = ['theme.json', 'styles/global.css'];
@@ -455,7 +462,11 @@ export function siteFile(file) {
455
462
  /** The folder of the theme in use, under src/themes/. */
456
463
  export function activeTheme() {
457
464
  const { theme } = JSON.parse(readFileSync(new URL('src/data/theme.json', root), 'utf8'));
458
- if (typeof theme !== 'string' || !/^[a-z0-9-]+$/.test(theme) || !existsSync(new URL(`${themeFolder(theme)}/theme.json`, root)))
465
+ if (
466
+ typeof theme !== 'string' ||
467
+ !/^([a-z0-9-]+|theme_[0-9a-f]{16})$/.test(theme) ||
468
+ !existsSync(new URL(`${themeFolder(theme)}/theme.json`, root))
469
+ )
459
470
  throw new Error(`src/data/theme.json: temaet ${JSON.stringify(theme)} er ikke installeret (src/themes/<tema>/theme.json).`);
460
471
  return theme;
461
472
  }
package/src/App.tsx CHANGED
@@ -668,7 +668,7 @@ function Editor() {
668
668
  }
669
669
  >
670
670
  <>
671
- <div className="editor-content min-w-0 flex-1">
671
+ <div className="editor-content relative min-w-0 flex-1">
672
672
  {files ? (
673
673
  <Page key={visit} route={route} files={files} env={env} user={user} builtAt={builtAt} onLogout={signOut} />
674
674
  ) : (
package/src/AppList.tsx CHANGED
@@ -49,6 +49,11 @@ import { AnyIcon } from './icon';
49
49
  import {
50
50
  appCategories,
51
51
  appChanges,
52
+ appliedTo,
53
+ installedHere,
54
+ legacyOf,
55
+ mergeChanges,
56
+ moveChanges,
52
57
  appFolder,
53
58
  breaking,
54
59
  changedFiles,
@@ -64,7 +69,7 @@ import {
64
69
  type StoreAdd,
65
70
  type StoreRecord,
66
71
  } from './store';
67
- import { apps, siteBlocks } from './registry';
72
+ import { apps, apps as siteApps, siteBlocks } from './registry';
68
73
  import { asWritten, backFrom, collections, companyParts, partScreens } from './site';
69
74
  import { byText, slugify, type Part } from './schema';
70
75
  import { headed, letterOf } from './Sheet';
@@ -273,7 +278,7 @@ const versionOf = (app: SiteApp) => updated.get(app.id) ?? app.store?.version ??
273
278
 
274
279
  /** The newer version of an app from the store, when the store has one. */
275
280
  const updateOf = (app: SiteApp, catalog: ShownApp[] | null | 'failed') => {
276
- const found = app.store && Array.isArray(catalog) ? catalog.find((a) => a.id === app.id) : undefined;
281
+ const found = app.store && Array.isArray(catalog) ? catalog.find((a) => a.id === app.id || a.legacy === app.id) : undefined;
277
282
  return found && newer(found.version, versionOf(app)) ? found : undefined;
278
283
  };
279
284
 
@@ -491,7 +496,7 @@ function StoreScreen({ env, files, tab, again }: { env: Env; files: RepoFile[];
491
496
  allApps(catalog)
492
497
  .filter((a) => words.every((w) => a.words.includes(w)))
493
498
  .filter((a) => !category || a.category === category)
494
- .filter((a) => !state || installed.includes(a.id) === (state === 'installed'))
499
+ .filter((a) => !state || installedHere(a, installed, siteApps) === (state === 'installed'))
495
500
  .sort((a, b) => order(a.label, b.label)),
496
501
  (a) => letterOf(a.label),
497
502
  );
@@ -692,11 +697,13 @@ function useStoreActions(env: Env, files: RepoFile[]) {
692
697
  next: () => Promise<Parameters<typeof appChanges>[2]>,
693
698
  // Whether to go on, with the app's files in the site now (an update asks, when some were changed).
694
699
  go: (current: RepoFile[]) => Promise<boolean> = async () => true,
700
+ // The id the app is installed under, when it is a legacy copy that moves to `id` (moveChanges).
701
+ legacy: string | null = null,
695
702
  ): Promise<boolean> {
696
703
  setRunning({ id, action, asking: false });
697
704
  env.notify({ text: text.busy });
698
705
  try {
699
- const [current, wanted] = await Promise.all([env.folder(appFolder(id)), next()]);
706
+ const [current, wanted] = await Promise.all([env.folder(appFolder(legacy ?? id)), next()]);
700
707
  if (!(await go(current))) {
701
708
  env.notify(null);
702
709
  return false;
@@ -704,8 +711,11 @@ function useStoreActions(env: Env, files: RepoFile[]) {
704
711
  // `go` cleared the message (and its dialog stopped the button being busy): saving goes on.
705
712
  setRunning({ id, action, asking: false });
706
713
  env.notify({ text: text.busy });
707
- const cookies = cookieChanges(id, wanted ? cookiesIn(wanted.files) : null, files);
708
- await saveApp(env, text.commit, text.change, [...appChanges(id, current, wanted, installed, file), ...cookies], file);
714
+ const moved = legacy && wanted ? moveChanges({ id }, legacy, wanted, current, files) : null;
715
+ // The cookie list as the move leaves it (the legacy id rewritten), then the new release's cookies on it: one set of changes.
716
+ const cookies = cookieChanges(id, wanted ? cookiesIn(wanted.files) : null, moved ? appliedTo(files, moved) : files);
717
+ const changes = moved ? mergeChanges(moved, cookies) : [...appChanges(id, current, wanted, installed, file), ...cookies];
718
+ await saveApp(env, text.commit, text.change, changes, file);
709
719
  env.notify({ text: text.done, kind: text.kind ?? 'saved' });
710
720
  return true;
711
721
  } catch (e) {
@@ -731,7 +741,7 @@ function useStoreActions(env: Env, files: RepoFile[]) {
731
741
  },
732
742
  async () => ({ files: await env.store.files(app), record: recordOf(app) }),
733
743
  ),
734
- async update(app: ShownApp, record: StoreRecord) {
744
+ async update(app: ShownApp, record: StoreRecord, legacy: string | null = null) {
735
745
  const done = await change(
736
746
  app.id,
737
747
  'update',
@@ -751,9 +761,10 @@ function useStoreActions(env: Env, files: RepoFile[]) {
751
761
  record.version,
752
762
  app.version,
753
763
  notesSince(app.releaseNotes, record.version, app.version),
754
- changedFiles(app.id, record, current),
764
+ changedFiles(app.id, record, current, appFolder(legacy ?? app.id)),
755
765
  );
756
766
  },
767
+ legacy,
757
768
  );
758
769
  if (done) updated.set(app.id, app.version);
759
770
  return done;
@@ -1047,7 +1058,11 @@ function AppContent({ app, env, files, tab, fields }: AppProps & { tab: AppTab;
1047
1058
  {(!on || (update && app.store)) && (
1048
1059
  <div className="mb-8">
1049
1060
  {on ? (
1050
- <Button disabled={saving} busy={store.busy(app.id, 'update')} onClick={() => store.update(update!, app.store!)}>
1061
+ <Button
1062
+ disabled={saving}
1063
+ busy={store.busy(update!.id, 'update')}
1064
+ onClick={() => store.update(update!, app.store!, legacyOf(update!, apps))}
1065
+ >
1051
1066
  {t.apps.update(update!.version)}
1052
1067
  </Button>
1053
1068
  ) : (