@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.
- package/CHANGELOG.md +20 -0
- package/astro.mjs +2 -1
- package/lib/appId.d.mts +1 -0
- package/lib/appId.mjs +6 -0
- package/lib/apps.mjs +13 -3
- package/lib/apps.test.mjs +10 -1
- package/lib/contracts.d.mts +3 -2
- package/lib/contracts.mjs +3 -2
- package/lib/folderChanges.d.mts +29 -0
- package/lib/folderChanges.mjs +34 -0
- package/lib/section-ranges.d.mts +1 -1
- package/lib/section-ranges.mjs +8 -14
- package/lib/section-ranges.test.mjs +26 -5
- package/lib/storeEntries.d.mts +3 -1
- package/lib/storeEntries.mjs +22 -8
- package/lib/storeEntries.test.mjs +56 -7
- package/lib/text-sections.d.mts +11 -1
- package/lib/text-sections.mjs +18 -8
- package/lib/text-sections.test.mjs +34 -1
- package/lib/video-id.d.mts +6 -2
- package/lib/video-id.mjs +5 -1
- package/lib/video.d.mts +6 -2
- package/lib/video.mjs +5 -1
- package/lib/without-h1.d.mts +1 -0
- package/lib/without-h1.mjs +19 -4
- package/lib/without-h1.test.mjs +30 -1
- package/package.json +3 -2
- package/scripts/app-screenshots.mjs +2 -1
- package/scripts/check-content.mjs +2 -1
- package/scripts/check-site-types.mjs +26 -0
- package/scripts/site-types.mjs +14 -0
- package/scripts/site-types.test.mjs +21 -0
- package/site/activeTheme.test.mjs +52 -0
- package/site/appSelf.d.mts +2 -0
- package/site/appSelf.mjs +23 -0
- package/site/appSelf.test.mjs +35 -0
- package/site/content.ts +5 -2
- package/site/siteModules.mjs +2 -1
- package/site/theme.mjs +19 -8
- package/src/App.tsx +1 -1
- package/src/AppList.tsx +24 -9
- package/src/BlockDialog.tsx +38 -17
- package/src/CreateFab.tsx +8 -0
- package/src/DataForm.tsx +7 -17
- package/src/EntryForm.tsx +1 -1
- package/src/FormFrame.tsx +4 -3
- package/src/Library.tsx +4 -11
- package/src/ListFab.tsx +13 -0
- package/src/MarkdownEditor.tsx +88 -47
- package/src/Media.tsx +4 -13
- package/src/Redirects.tsx +6 -14
- package/src/Screen.tsx +13 -3
- package/src/StartFab.tsx +5 -7
- package/src/ThemeStore.tsx +33 -12
- package/src/apps.ts +2 -3
- package/src/drawerYield.d.mts +3 -0
- package/src/drawerYield.mjs +10 -0
- package/src/drawerYield.test.mjs +22 -0
- package/src/editor.css +55 -22
- package/src/fabScreens.test.mjs +28 -0
- package/src/fabScreens.ts +16 -0
- package/src/legacyMove.test.mjs +137 -0
- package/src/legacyMove.ts +131 -0
- package/src/markdown.mjs +10 -7
- package/src/markdown.test.mjs +16 -2
- package/src/menuOpen.ts +20 -4
- package/src/movedRoutes.mjs +2 -2
- package/src/movedRoutes.test.mjs +5 -0
- package/src/pageH1Panel.d.mts +4 -0
- package/src/pageH1Panel.mjs +31 -0
- package/src/pageH1Panel.test.mjs +56 -0
- package/src/pagePreview.tsx +22 -12
- package/src/sectionFrame.ts +3 -3
- package/src/sectionState.d.mts +3 -3
- package/src/sectionState.mjs +6 -6
- package/src/sectionState.test.mjs +14 -2
- package/src/sheetPlacement.d.mts +6 -4
- package/src/sheetPlacement.mjs +15 -10
- package/src/sheetPlacement.test.mjs +16 -9
- package/src/sidePanel.ts +38 -0
- package/src/store.ts +32 -27
- package/src/topBarSlot.tsx +7 -15
- package/src/ui.tsx +26 -15
- package/src/virtual.d.ts +6 -0
- package/starter/package.json +1 -1
- package/theme/components/PageHeading.astro +22 -0
- package/theme/layouts/PageLayout.astro +37 -20
- package/theme/styles/global.css +2 -1
- 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
|
-
|
|
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 {
|
package/lib/without-h1.d.mts
CHANGED
package/lib/without-h1.mjs
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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(
|
|
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
|
+
};
|
package/lib/without-h1.test.mjs
CHANGED
|
@@ -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.
|
|
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
|
+
});
|
package/site/appSelf.mjs
ADDED
|
@@ -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
|
-
|
|
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.
|
package/site/siteModules.mjs
CHANGED
|
@@ -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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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 (
|
|
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 ||
|
|
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
|
|
708
|
-
|
|
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
|
|
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
|
) : (
|