@morphemecms/cms 0.9.1 → 0.10.0-next-20261105172247
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 +13 -0
- package/defaults/popup.tsx +44 -33
- package/defaults/texts.ts +1 -1
- package/icons/lucide/desktop.svg +1 -0
- package/icons/material-symbols/desktop.svg +1 -0
- package/icons/phosphor/desktop.svg +1 -0
- package/lib/danish.d.mts +24 -0
- package/lib/danish.mjs +134 -0
- package/lib/danish.test.mjs +102 -0
- package/lib/google-result.test.mjs +70 -0
- package/lib/heading-levels.d.mts +1 -0
- package/lib/heading-levels.mjs +6 -1
- package/lib/heading-levels.test.mjs +15 -1
- package/lib/translate.d.mts +8 -0
- package/lib/translate.mjs +15 -0
- package/lib/translate.test.mjs +38 -0
- package/package.json +9 -1
- package/scripts/check-language.mjs +105 -0
- package/scripts/editor-roundtrip.mjs +9 -14
- package/scripts/template-round-trip.mjs +22 -0
- package/scripts/template-round-trip.test.mjs +26 -0
- package/site/icons.d.mts +4 -0
- package/site/icons.mjs +2 -0
- package/src/DataForm.tsx +31 -11
- package/src/EntryForm.tsx +22 -3
- package/src/Library.tsx +2 -8
- package/src/MarkdownEditor.tsx +17 -11
- package/src/SearchPreview.tsx +75 -0
- package/src/editor.css +36 -6
- package/src/googleResult.d.mts +20 -0
- package/src/googleResult.mjs +30 -0
- package/src/i18n/da-DK/forms.ts +6 -0
- package/src/i18n/da-DK/text.ts +1 -0
- package/src/i18n/en-US/forms.ts +9 -1
- package/src/i18n/en-US/parts.ts +1 -1
- package/src/i18n/en-US/text.ts +2 -0
- package/src/i18n/index.ts +16 -2
- package/src/seo.ts +11 -7
- package/src/ui.tsx +3 -2
- package/src/when.ts +6 -0
- package/theme/components/ActionBar.astro +26 -15
- package/theme/layouts/Base.astro +3 -1
- package/theme/styles/components.css +33 -17
- package/theme/styles/global.css +1 -7
- package/theme/ui/BottomSheet.tsx +6 -1
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Checks that tracked files outside the language files hold no Danish text (a Danish letter, or a Danish dictionary value).
|
|
2
|
+
// Run: npm run check:language, or node scripts/check-language.mjs; --all ignores PENDING.
|
|
3
|
+
// Depends on: lib/danish.mjs, Vite (loads the dictionaries), git (lists the files).
|
|
4
|
+
import { execFileSync } from 'node:child_process';
|
|
5
|
+
import { closeSync, existsSync, openSync, readFileSync, readSync } from 'node:fs';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { createServer } from 'vite';
|
|
8
|
+
import { danishValuesOf, findDanish, isLanguagePath } from '../lib/danish.mjs';
|
|
9
|
+
|
|
10
|
+
// Path prefixes not converted to English yet. Each batch of docs/superpowers/plans/2026-11-04-english-source.md
|
|
11
|
+
// removes its own. The last batch deletes the list.
|
|
12
|
+
const PENDING = [
|
|
13
|
+
'AGENTS.md',
|
|
14
|
+
'README.md',
|
|
15
|
+
'astro.mjs',
|
|
16
|
+
'defaults/',
|
|
17
|
+
'demo/',
|
|
18
|
+
'lib/',
|
|
19
|
+
'pages/',
|
|
20
|
+
'scripts/',
|
|
21
|
+
'site/',
|
|
22
|
+
'src/',
|
|
23
|
+
'starter/',
|
|
24
|
+
'theme/',
|
|
25
|
+
'worker/',
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
const root = fileURLToPath(new URL('..', import.meta.url));
|
|
29
|
+
const all = process.argv.includes('--all');
|
|
30
|
+
|
|
31
|
+
/** Whether the first 8000 bytes of a file hold a zero byte. @param {string} file */
|
|
32
|
+
function isBinary(file) {
|
|
33
|
+
const fd = openSync(file, 'r');
|
|
34
|
+
try {
|
|
35
|
+
const buffer = Buffer.alloc(8000);
|
|
36
|
+
const n = readSync(fd, buffer, 0, 8000, 0);
|
|
37
|
+
return buffer.subarray(0, n).includes(0);
|
|
38
|
+
} finally {
|
|
39
|
+
closeSync(fd);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Whether a path is left out of the check. @param {string} path */
|
|
44
|
+
function isSkipped(path) {
|
|
45
|
+
if (isLanguagePath(path)) return true;
|
|
46
|
+
if (path === 'CHANGELOG.md' || path === 'package-lock.json') return true;
|
|
47
|
+
if (path.startsWith('docs/superpowers/')) return true;
|
|
48
|
+
if (!all && PENDING.some((prefix) => path.startsWith(prefix))) return true;
|
|
49
|
+
if (!existsSync(`${root}${path}`)) return true;
|
|
50
|
+
return isBinary(`${root}${path}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const names = execFileSync('git', ['ls-files', '-z'], { cwd: root, encoding: 'utf8', maxBuffer: 1 << 28 })
|
|
54
|
+
.split('\0')
|
|
55
|
+
.filter(Boolean);
|
|
56
|
+
const paths = names.filter((path) => !isSkipped(path));
|
|
57
|
+
|
|
58
|
+
const server = await createServer({
|
|
59
|
+
root,
|
|
60
|
+
configFile: false,
|
|
61
|
+
logLevel: 'silent',
|
|
62
|
+
appType: 'custom',
|
|
63
|
+
server: { middlewareMode: true, hmr: false },
|
|
64
|
+
optimizeDeps: { noDiscovery: true },
|
|
65
|
+
});
|
|
66
|
+
let danishValues;
|
|
67
|
+
try {
|
|
68
|
+
const danish = [(await server.ssrLoadModule('/src/i18n/da-DK/index.ts')).daDK];
|
|
69
|
+
if (existsSync(`${root}defaults/i18n/da-DK/index.ts`)) {
|
|
70
|
+
const defaults = await server.ssrLoadModule('/defaults/i18n/da-DK/index.ts');
|
|
71
|
+
for (const value of Object.values(defaults)) {
|
|
72
|
+
if (value && typeof value === 'object') danish.push(value);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const english = [(await server.ssrLoadModule('/src/i18n/en-US/index.ts')).enUS];
|
|
76
|
+
danishValues = danishValuesOf(danish, english);
|
|
77
|
+
} finally {
|
|
78
|
+
await server.close();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let count = 0;
|
|
82
|
+
const filesWithMatches = new Set();
|
|
83
|
+
/** @param {string} path @param {{ line: number, match: string }[]} matches */
|
|
84
|
+
function report(path, matches) {
|
|
85
|
+
for (const { line, match } of matches) {
|
|
86
|
+
console.log(`${path}:${line} ${match}`);
|
|
87
|
+
count++;
|
|
88
|
+
filesWithMatches.add(path);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
for (const path of paths) {
|
|
93
|
+
report(
|
|
94
|
+
path,
|
|
95
|
+
findDanish(path, new Set()).map((m) => ({ ...m, line: 0 })),
|
|
96
|
+
);
|
|
97
|
+
report(path, findDanish(readFileSync(`${root}${path}`, 'utf8'), danishValues));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (count > 0) {
|
|
101
|
+
console.log(`${count} Danish texts in ${filesWithMatches.size} files outside the language files.`);
|
|
102
|
+
process.exitCode = 1;
|
|
103
|
+
} else {
|
|
104
|
+
console.log('No Danish outside the language files.');
|
|
105
|
+
}
|
|
@@ -5,12 +5,13 @@
|
|
|
5
5
|
// sections as blocks (packages/editor/src/sections.mjs) and read back: the sections must be the same, a section
|
|
6
6
|
// of text with the same heading and the same HTML. Every page is also read into sections as the front page (as the
|
|
7
7
|
// site reads the page that is the front page without the editor, partsOfMarkdown in sections.mjs, packages/editor/site/home.ts):
|
|
8
|
-
// they must be those the editor reads. A page whose template is changed and changed back has the same front matter (the default leaves no key). Run: npm run check:editor [files...]
|
|
8
|
+
// they must be those the editor reads. A page whose template is changed and changed back has the same front matter (the default leaves no key; template-round-trip.mjs). Run: npm run check:editor [files...]
|
|
9
9
|
// A built-in sample with section colours checks that the editor and the site give the front page's sections the same colours.
|
|
10
10
|
// Milkdown needs a DOM; jsdom stands in for the browser.
|
|
11
11
|
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
12
12
|
import { join } from 'node:path';
|
|
13
13
|
import { JSDOM } from 'jsdom';
|
|
14
|
+
import { templateRoundTripProblem } from './template-round-trip.mjs';
|
|
14
15
|
|
|
15
16
|
const dom = new JSDOM('<!doctype html><html><body></body></html>', { pretendToBeVisual: true });
|
|
16
17
|
globalThis.window = dom.window;
|
|
@@ -141,6 +142,7 @@ const frontSections = async (body, parts) => [
|
|
|
141
142
|
}
|
|
142
143
|
|
|
143
144
|
for (const file of files) {
|
|
145
|
+
const isPage = file.replaceAll('\\', '/').includes('src/content/pages/');
|
|
144
146
|
if (file.endsWith('.json')) {
|
|
145
147
|
const { sections, intro } = JSON.parse(readFileSync(file, 'utf8'));
|
|
146
148
|
const field = textOf[file];
|
|
@@ -161,20 +163,13 @@ for (const file of files) {
|
|
|
161
163
|
}
|
|
162
164
|
// The template (templateWritten): the theme's default leaves no key; another one is written; changing back gives the
|
|
163
165
|
// front matter as it was (without the key).
|
|
164
|
-
if (
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
const chosen = writeFields(plain, { template: templateWritten('sidebar-left', STANDARD_TEMPLATES) });
|
|
168
|
-
const back = writeFields(chosen, { template: templateWritten(fallback, STANDARD_TEMPLATES) });
|
|
169
|
-
if (
|
|
170
|
-
!/^template: sidebar-left$/m.test(chosen) ||
|
|
171
|
-
back !== plain ||
|
|
172
|
-
writeFields(plain, { template: templateWritten(fallback, STANDARD_TEMPLATES) }) !== plain
|
|
173
|
-
) {
|
|
166
|
+
if (isPage) {
|
|
167
|
+
const problem = templateRoundTripProblem(yaml, { writeFields, templateWritten, templates: STANDARD_TEMPLATES });
|
|
168
|
+
if (problem) {
|
|
174
169
|
failed++;
|
|
175
170
|
console.log(`
|
|
176
171
|
✗ ${file}
|
|
177
|
-
Skabelonen skiftet og skiftet tilbage giver ikke det samme front matter`);
|
|
172
|
+
Skabelonen skiftet og skiftet tilbage giver ikke det samme front matter (${problem})`);
|
|
178
173
|
continue;
|
|
179
174
|
}
|
|
180
175
|
}
|
|
@@ -182,12 +177,12 @@ for (const file of files) {
|
|
|
182
177
|
// As in the editor: with the free lines between the blocks, written without them.
|
|
183
178
|
// A page also has the first-heading rule: opening and saving it must not change it.
|
|
184
179
|
const made = makeEditor(root, body).use(spacing);
|
|
185
|
-
const editor = await (
|
|
180
|
+
const editor = await (isPage ? made.use(firstHeading) : made).create();
|
|
186
181
|
editor.action((ctx) => ctx.get(editorViewCtx).dispatch(ctx.get(editorViewCtx).state.tr));
|
|
187
182
|
const saved = editor.action(markdownOf);
|
|
188
183
|
const parts = editor.action(partsOf);
|
|
189
184
|
await editor.destroy();
|
|
190
|
-
if (
|
|
185
|
+
if (isPage) {
|
|
191
186
|
const [editors, site] = [
|
|
192
187
|
await comparable('', sectionsFromParts(parts, frontLeads)),
|
|
193
188
|
await comparable('', sectionsFromParts(partsOfMarkdown(body), frontLeads)),
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Checks that a page's front matter survives a template change and change back (used by editor-roundtrip.mjs).
|
|
2
|
+
// Exports: templateRoundTripProblem(yaml, { writeFields, templateWritten, templates }). No imports; the callers pass the functions.
|
|
3
|
+
const TEMPLATE_LINE = /^template: ("sidebar-left"|sidebar-left)$/m;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Change the template of the front matter to sidebar-left and back to the default; the result must be the front matter as it was.
|
|
7
|
+
*
|
|
8
|
+
* @param {string} yaml Front matter text without the --- lines.
|
|
9
|
+
* @param {{ writeFields: (yaml: string, fields: object) => string, templateWritten: (id: string, templates: string[]) => string, templates: string[] }} deps
|
|
10
|
+
* @returns {string | null} A problem text, or null when the round trip holds.
|
|
11
|
+
*/
|
|
12
|
+
export function templateRoundTripProblem(yaml, { writeFields, templateWritten, templates }) {
|
|
13
|
+
const [fallback] = templates;
|
|
14
|
+
const plain = writeFields(yaml, { template: '' });
|
|
15
|
+
const chosen = writeFields(plain, { template: templateWritten('sidebar-left', templates) });
|
|
16
|
+
const back = writeFields(chosen, { template: templateWritten(fallback, templates) });
|
|
17
|
+
if (!TEMPLATE_LINE.test(chosen)) return 'the template line is not written';
|
|
18
|
+
if (back !== plain) return 'changing back does not give the front matter as it was';
|
|
19
|
+
if (writeFields(plain, { template: templateWritten(fallback, templates) }) !== plain)
|
|
20
|
+
return 'the default template changes the front matter';
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Tests templateRoundTripProblem (template-round-trip.mjs) with the real writeFields and templateWritten.
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { test } from 'node:test';
|
|
4
|
+
import { writeFields } from '../src/frontmatter.mjs';
|
|
5
|
+
import { STANDARD_TEMPLATES, templateWritten } from '../lib/page-template.mjs';
|
|
6
|
+
import { templateRoundTripProblem } from './template-round-trip.mjs';
|
|
7
|
+
|
|
8
|
+
const deps = { writeFields, templateWritten, templates: STANDARD_TEMPLATES };
|
|
9
|
+
|
|
10
|
+
test('a page with no template round-trips', () => {
|
|
11
|
+
assert.equal(templateRoundTripProblem('title: "X"', deps), null);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('a page with a template round-trips', () => {
|
|
15
|
+
assert.equal(templateRoundTripProblem('title: "X"\ntemplate: "sidebar-right"', deps), null);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('the chosen front matter has the template line', () => {
|
|
19
|
+
const chosen = writeFields('title: "X"', { template: templateWritten('sidebar-left', STANDARD_TEMPLATES) });
|
|
20
|
+
assert.match(chosen, /^template: "?sidebar-left"?$/m);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('a writer that drops other keys is reported', () => {
|
|
24
|
+
const broken = (yaml, fields) => (fields.template ? `template: "${fields.template}"` : writeFields(yaml, fields));
|
|
25
|
+
assert.notEqual(templateRoundTripProblem('title: "X"', { ...deps, writeFields: broken }), null);
|
|
26
|
+
});
|
package/site/icons.d.mts
CHANGED
|
@@ -151,6 +151,7 @@ export const MATERIAL_SYMBOLS: {
|
|
|
151
151
|
'currency-circle-dollar': string;
|
|
152
152
|
'cursor-click': string;
|
|
153
153
|
database: string;
|
|
154
|
+
desktop: string;
|
|
154
155
|
'device-mobile': string;
|
|
155
156
|
'door-open': string;
|
|
156
157
|
'download-simple': string;
|
|
@@ -341,6 +342,7 @@ export const LUCIDE: {
|
|
|
341
342
|
'currency-circle-dollar': string;
|
|
342
343
|
'cursor-click': string;
|
|
343
344
|
database: string;
|
|
345
|
+
desktop: string;
|
|
344
346
|
'device-mobile': string;
|
|
345
347
|
'door-open': string;
|
|
346
348
|
'download-simple': string;
|
|
@@ -531,6 +533,7 @@ export const PACK_MAPS: {
|
|
|
531
533
|
'currency-circle-dollar': string;
|
|
532
534
|
'cursor-click': string;
|
|
533
535
|
database: string;
|
|
536
|
+
desktop: string;
|
|
534
537
|
'device-mobile': string;
|
|
535
538
|
'door-open': string;
|
|
536
539
|
'download-simple': string;
|
|
@@ -719,6 +722,7 @@ export const PACK_MAPS: {
|
|
|
719
722
|
'currency-circle-dollar': string;
|
|
720
723
|
'cursor-click': string;
|
|
721
724
|
database: string;
|
|
725
|
+
desktop: string;
|
|
722
726
|
'device-mobile': string;
|
|
723
727
|
'door-open': string;
|
|
724
728
|
'download-simple': string;
|
package/site/icons.mjs
CHANGED
|
@@ -165,6 +165,7 @@ export const MATERIAL_SYMBOLS = {
|
|
|
165
165
|
'currency-circle-dollar': 'paid',
|
|
166
166
|
'cursor-click': 'ads_click',
|
|
167
167
|
database: 'database',
|
|
168
|
+
desktop: 'desktop_windows',
|
|
168
169
|
'device-mobile': 'mobile',
|
|
169
170
|
'door-open': 'door_open',
|
|
170
171
|
'download-simple': 'download',
|
|
@@ -356,6 +357,7 @@ export const LUCIDE = {
|
|
|
356
357
|
'currency-circle-dollar': 'circle-dollar-sign',
|
|
357
358
|
'cursor-click': 'mouse-pointer-click',
|
|
358
359
|
database: 'database',
|
|
360
|
+
desktop: 'monitor',
|
|
359
361
|
'device-mobile': 'smartphone',
|
|
360
362
|
'door-open': 'door-open',
|
|
361
363
|
'download-simple': 'download',
|
package/src/DataForm.tsx
CHANGED
|
@@ -22,6 +22,8 @@ import { useMediaPicker } from './Media';
|
|
|
22
22
|
import FormFrame, { savedStatus, useSavedAt } from './FormFrame';
|
|
23
23
|
import { savedOn, VersionNotice, VersionsButton, WithVersions, type ShownVersion } from './Versions';
|
|
24
24
|
import { Button, CheckList, FoldSection, FormColumns, Group, IconButton, Preview, FormHeading, SharePreview, Notice, ListItem } from './ui';
|
|
25
|
+
import { SearchPreview } from './SearchPreview';
|
|
26
|
+
import { searchedAs } from './googleResult.mjs';
|
|
25
27
|
import { plain, SEO_FIELDS, seoRating, SHARE_FIELDS, sharedAs } from './seo';
|
|
26
28
|
import type { Env } from './App';
|
|
27
29
|
import { kindAndName, lower, nameOf, savedText } from './deploy';
|
|
@@ -215,28 +217,46 @@ function Form({
|
|
|
215
217
|
relations: env.relations,
|
|
216
218
|
});
|
|
217
219
|
}, [seoNow, seoFields.length]);
|
|
218
|
-
|
|
219
|
-
<FoldSection title={t.forms.seo} open={seoOpen} onToggle={() => setSeoOpen(!seoOpen)}>
|
|
220
|
-
{rating.length > 0 && <CheckList title={t.forms.seoRating.title} items={rating} />}
|
|
221
|
-
<FieldList fields={seoFields} value={values} onChange={(v) => setValues(v as Record<string, unknown>)} />
|
|
222
|
-
</FoldSection>
|
|
223
|
-
);
|
|
224
|
-
// The link as shared (BlogIndex.astro): the heading or the tag's name with the site's name after it, and the
|
|
220
|
+
// The page as the site writes it (BlogIndex.astro): the heading or the tag's name with the site's name after it, and the
|
|
225
221
|
// introduction or, on a tag, the number of its posts on the blog.
|
|
226
|
-
const
|
|
222
|
+
const pageNow = () => {
|
|
227
223
|
const name = String(values.heading ?? values.name ?? '');
|
|
228
224
|
const intro = text?.intro ? plain(String(values[text.intro] ?? '')) : '';
|
|
229
225
|
const tag = collection.kind === 'folder' && base.path ? slugOf(base.path, collection.folder) : '';
|
|
230
226
|
const count = env.site.posts.filter((p) => p.categories.includes(tag)).length;
|
|
231
|
-
return
|
|
227
|
+
return {
|
|
232
228
|
title: name,
|
|
233
229
|
seoTitle: String(values.seoTitle ?? ''),
|
|
234
230
|
seoDescription: String(values.seoDescription ?? ''),
|
|
235
|
-
shareImage: String(values.shareImage ?? ''),
|
|
236
231
|
description: intro || (collection.kind === 'folder' ? siteT.forms.tagShared(count, name) : ''),
|
|
237
232
|
site: sharing,
|
|
238
|
-
}
|
|
233
|
+
};
|
|
239
234
|
};
|
|
235
|
+
// The link as shared.
|
|
236
|
+
const card = () => sharedAs({ ...pageNow(), shareImage: String(values.shareImage ?? '') });
|
|
237
|
+
// The Google result (SearchPreview) at the page's address on the site: a fixed page's own, a tag's /blog/kategori/<file
|
|
238
|
+
// name>/ (its name for one not saved yet).
|
|
239
|
+
const resultPath = () => {
|
|
240
|
+
if (fixed) return dataFile!.view;
|
|
241
|
+
if (collection.kind !== 'folder') return '/';
|
|
242
|
+
return `/blog/kategori/${base.path ? slugOf(base.path, collection.folder) : slugify(String(values.name ?? ''))}/`;
|
|
243
|
+
};
|
|
244
|
+
const result = seoFields.length > 0 && seoOpen ? searchedAs(pageNow()) : null;
|
|
245
|
+
const seoSection = seoFields.length > 0 && (
|
|
246
|
+
<FoldSection title={t.forms.seo} open={seoOpen} onToggle={() => setSeoOpen(!seoOpen)}>
|
|
247
|
+
{rating.length > 0 && <CheckList title={t.forms.seoRating.title} items={rating} />}
|
|
248
|
+
{result && (
|
|
249
|
+
<SearchPreview
|
|
250
|
+
domain={config.domain}
|
|
251
|
+
path={resultPath()}
|
|
252
|
+
siteName={sharing.name}
|
|
253
|
+
title={result.title}
|
|
254
|
+
description={result.description}
|
|
255
|
+
/>
|
|
256
|
+
)}
|
|
257
|
+
<FieldList fields={seoFields} value={values} onChange={(v) => setValues(v as Record<string, unknown>)} />
|
|
258
|
+
</FoldSection>
|
|
259
|
+
);
|
|
240
260
|
const shared = shareFields.length > 0 && shareOpen ? card() : null;
|
|
241
261
|
const shareSection = shareFields.length > 0 && (
|
|
242
262
|
<FoldSection title={t.forms.share} open={shareOpen} onToggle={() => setShareOpen(!shareOpen)}>
|
package/src/EntryForm.tsx
CHANGED
|
@@ -34,6 +34,8 @@ import { isOver } from '../lib/notice.mjs';
|
|
|
34
34
|
import { splitPicture } from '../lib/crop.mjs';
|
|
35
35
|
import { asStandardTemplate, blockProblems, notOffered, siteBlocks } from './registry';
|
|
36
36
|
import { FieldInput, Fields, useWrongField, WrongField, type FieldContext } from './fields';
|
|
37
|
+
import { SearchPreview } from './SearchPreview';
|
|
38
|
+
import { searchedAs } from './googleResult.mjs';
|
|
37
39
|
import { CheckList, FoldSection, FormColumns, type NavigationItem, SectionList, SectionPane, SharePreview, useInView } from './ui';
|
|
38
40
|
import FormFrame, { savedStatus, useSavedAt } from './FormFrame';
|
|
39
41
|
import { usePagePreview } from './pagePreview';
|
|
@@ -286,18 +288,27 @@ function Form({
|
|
|
286
288
|
// The link as it is shared: as Base.astro writes it, from a page's H1 (pageH1) or a post's title, and its picture
|
|
287
289
|
// (a post's "Billede øverst", else the first in the text; a page has its pictures in its text, and the front page shows
|
|
288
290
|
// the site's picture, as Home.astro).
|
|
289
|
-
const
|
|
291
|
+
const shared = {
|
|
290
292
|
title: (!post && pageH1(body).heading) || String(values.title ?? ''),
|
|
291
293
|
front,
|
|
292
294
|
seoTitle: String(values.seoTitle ?? ''),
|
|
293
295
|
seoDescription: String(values.seoDescription ?? ''),
|
|
296
|
+
description: String(values.description ?? ''),
|
|
297
|
+
site: sharing,
|
|
298
|
+
};
|
|
299
|
+
const card = sharedAs({
|
|
300
|
+
...shared,
|
|
294
301
|
shareTitle: String(values.shareTitle ?? ''),
|
|
295
302
|
shareDescription: String(values.shareDescription ?? ''),
|
|
296
303
|
shareImage: String(values.shareImage ?? ''),
|
|
297
|
-
description: String(values.description ?? ''),
|
|
298
304
|
image: front ? undefined : String(values.image ?? '') || firstPicture(body),
|
|
299
|
-
site: sharing,
|
|
300
305
|
});
|
|
306
|
+
// The Google result (SearchPreview), at the address the page or post has now: the one typed in "Adresse" or, for a new
|
|
307
|
+
// one without, /<title>/, as "Gem" works it out; the front page is at /.
|
|
308
|
+
const result = searchedAs(shared);
|
|
309
|
+
const resultPath = front
|
|
310
|
+
? '/'
|
|
311
|
+
: addressOf(String(values.path ?? ''), post ? 'post' : 'page') || (isNew ? `/${slugify(String(values.title ?? ''))}/` : url);
|
|
301
312
|
|
|
302
313
|
// A picture is kept in the browser until "Gem", which uploads it with the page. A file in Filer the page refers to
|
|
303
314
|
// now and did not in its last saved version (a picture, a PDF field, a link) gets the page's tags at "Gem" (autoTags.mjs).
|
|
@@ -663,6 +674,14 @@ function Form({
|
|
|
663
674
|
<FoldSection {...sectionProps('seo')}>
|
|
664
675
|
<div ref={seoBox} className="space-y-6">
|
|
665
676
|
{rating.length > 0 && <CheckList title={t.forms.seoRating.title} items={rating} />}
|
|
677
|
+
<SearchPreview
|
|
678
|
+
domain={config.domain}
|
|
679
|
+
path={resultPath}
|
|
680
|
+
siteName={sharing.name}
|
|
681
|
+
title={result.title}
|
|
682
|
+
description={result.description}
|
|
683
|
+
date={post ? String(values.date ?? '') : undefined}
|
|
684
|
+
/>
|
|
666
685
|
{collection.fields.filter(inSection('seo')).map(field)}
|
|
667
686
|
</div>
|
|
668
687
|
</FoldSection>
|
package/src/Library.tsx
CHANGED
|
@@ -83,7 +83,7 @@ import { fileRating } from './seo';
|
|
|
83
83
|
import { dateName, headed, letterOf } from './Sheet';
|
|
84
84
|
import type { Env } from './App';
|
|
85
85
|
import { kindAndName, savedText } from './deploy';
|
|
86
|
-
import { when } from './when';
|
|
86
|
+
import { shortDate, when } from './when';
|
|
87
87
|
import { untilTime } from '../lib/notice.mjs';
|
|
88
88
|
import { savedTheme, setTheme, shownMode, type Theme } from './theme';
|
|
89
89
|
import { browserLocale, locale, localeName, locales, savedLocale, setLocale, siteLocale, siteT, siteText, t, type Locale } from './i18n';
|
|
@@ -1035,12 +1035,6 @@ function savedSort(c: Collection, sorts: Sort[], first: Sort): Sort {
|
|
|
1035
1035
|
// "i dag kl. 13.42" as "I dag kl. 13.42", alone on its line.
|
|
1036
1036
|
const capital = (s: string) => s.charAt(0).toLocaleUpperCase(locale) + s.slice(1);
|
|
1037
1037
|
|
|
1038
|
-
// "2023-10-01" as "1. okt. 2023" (in the editor's language).
|
|
1039
|
-
const date = (iso: string) =>
|
|
1040
|
-
/^\d{4}-\d{2}-\d{2}$/.test(iso)
|
|
1041
|
-
? new Date(`${iso}T12:00`).toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric' })
|
|
1042
|
-
: iso;
|
|
1043
|
-
|
|
1044
1038
|
export function List({ collection, tab, files, env }: { collection: Collection; tab?: string; files: RepoFile[]; env: Env }) {
|
|
1045
1039
|
// The kinds a new entry can be: the list's own and those of the apps installed and not turned off (apps.ts).
|
|
1046
1040
|
const kinds = collection.kind === 'folder' ? kindsOffered(collection, apps, installedApps(files), offBlocks(files)) : [];
|
|
@@ -1309,7 +1303,7 @@ export function List({ collection, tab, files, env }: { collection: Collection;
|
|
|
1309
1303
|
) : (
|
|
1310
1304
|
<>
|
|
1311
1305
|
<Status item={item} />
|
|
1312
|
-
{collection.id === 'blog' ?
|
|
1306
|
+
{collection.id === 'blog' ? shortDate(item.detail) : item.detail}
|
|
1313
1307
|
</>
|
|
1314
1308
|
)}
|
|
1315
1309
|
</Under>
|
package/src/MarkdownEditor.tsx
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// two cards to write in. A fixed page's sections are such a text too (SectionsField.tsx), so both look
|
|
6
6
|
// and work the same. The last tool, "Section settings", opens the section's sheet (BlockDialog.tsx).
|
|
7
7
|
import {
|
|
8
|
+
type CSSProperties,
|
|
8
9
|
forwardRef,
|
|
9
10
|
Fragment as ReactFragment,
|
|
10
11
|
type MouseEvent as ReactMouseEvent,
|
|
@@ -55,7 +56,7 @@ import {
|
|
|
55
56
|
} from '@phosphor-icons/react';
|
|
56
57
|
import { HTML, makeEditor, markdownOf, partsOf, siteBlockSchema, spacing } from './markdown.mjs';
|
|
57
58
|
import { firstHeading, topHeadings } from './firstHeading.mjs';
|
|
58
|
-
import {
|
|
59
|
+
import { headingChoices } from '../lib/heading-levels.mjs';
|
|
59
60
|
import { TEXT_COLORS, colorClass } from '../lib/text-colors.mjs';
|
|
60
61
|
import { SECTION_COLORS } from '../lib/section-marker.mjs';
|
|
61
62
|
import { sectionHeading, setSectionColor } from './sectionState.mjs';
|
|
@@ -540,7 +541,7 @@ const inLabel = (state: EditorView['state']) => state.selection.$from.parent.typ
|
|
|
540
541
|
|
|
541
542
|
// When a word or more is selected, or the cursor has stood still in a word (STILL_MS, stillCursor.mjs): a row of icons
|
|
542
543
|
// over it, to make it bold, italic, a link, one of the site's colours (a menu under the button, opened by `colors`), the
|
|
543
|
-
// page's
|
|
544
|
+
// page's headings (`h1`: only the levels headingChoices gives for the text), a heading or a subheading, a label above the heading; the ones that apply are marked. At a still
|
|
544
545
|
// cursor, bold, italic, link and colour act on the whole word, the headings on the line; a move or a keypress hides the
|
|
545
546
|
// row. On a phone, where the phone's own menu is over the selection and its handles under it, the row is at the bottom
|
|
546
547
|
// of the screen, over the keyboard. sections: no colour in a heading (H2), the name of a section.
|
|
@@ -595,8 +596,8 @@ function selectionMenu(ctx: Ctx, h1: boolean, sections: boolean, colors: (button
|
|
|
595
596
|
label: [t.text.pageHeading, t.text.heading, t.text.subheading][level - 1],
|
|
596
597
|
on: (s: EditorView['state']) => heading(s, level),
|
|
597
598
|
action: () => (heading(view.state, level) ? run(turnIntoTextCommand) : run(wrapInHeadingCommand, level)),
|
|
598
|
-
//
|
|
599
|
-
off:
|
|
599
|
+
// On a page only the levels headingChoices gives are offered: H1 until the text has one, then H2 and H3.
|
|
600
|
+
off: h1 ? (s: EditorView['state']) => !headingChoices(levelsOf(s.doc)).includes(level) : undefined,
|
|
600
601
|
})),
|
|
601
602
|
// A label above the heading. Not on a fixed page's H1 or H2, whose headings are its sections (sections.mjs).
|
|
602
603
|
{
|
|
@@ -857,8 +858,8 @@ export default forwardRef<MarkdownHandle, Props>(function MarkdownEditor(
|
|
|
857
858
|
looked.current = looks;
|
|
858
859
|
// What applies where the cursor is (activeOf), marked in the toolbar.
|
|
859
860
|
const [active, setActive] = useState<string[]>([]);
|
|
860
|
-
//
|
|
861
|
-
const [
|
|
861
|
+
// The heading levels the toolbar offers on a page (headingChoices): [1] while the text has no H1, then [2, 3].
|
|
862
|
+
const [choices, setChoices] = useState<number[]>([1]);
|
|
862
863
|
// The colour button over the selection, while its menu with the colours is open.
|
|
863
864
|
const [colorsAt, setColorsAt] = useState<HTMLElement | null>(null);
|
|
864
865
|
useEffect(() => {
|
|
@@ -884,8 +885,8 @@ export default forwardRef<MarkdownHandle, Props>(function MarkdownEditor(
|
|
|
884
885
|
new Plugin({
|
|
885
886
|
view: () => ({
|
|
886
887
|
update: (view) => {
|
|
887
|
-
const
|
|
888
|
-
|
|
888
|
+
const next = headingChoices(levelsOf(view.state.doc));
|
|
889
|
+
setChoices((was) => (was.join() === next.join() ? was : next));
|
|
889
890
|
// On a page the first heading is the H1 by itself (firstHeading.mjs); the Heading button stands for it.
|
|
890
891
|
const now = [...activeOf(view.state)].map((name) => (h1 && name === 'h1' ? 'h2' : name)).sort();
|
|
891
892
|
setActive((was) => (was.join() === now.join() ? was : now));
|
|
@@ -1092,9 +1093,13 @@ export default forwardRef<MarkdownHandle, Props>(function MarkdownEditor(
|
|
|
1092
1093
|
{ label: t.text.bold, icon: TextB, action: () => run(toggleStrongCommand), on: 'strong' },
|
|
1093
1094
|
{ label: t.text.italic, icon: TextItalic, action: () => run(toggleEmphasisCommand), on: 'emphasis' },
|
|
1094
1095
|
{ label: t.text.link, icon: LinkSimple, action: link, on: 'link' },
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1096
|
+
// On a page the levels in choices (H1 until the text has one); a blog post has H2 and H3.
|
|
1097
|
+
...(h1 ? choices : [2, 3]).map((level) => ({
|
|
1098
|
+
label: [t.text.pageHeading, t.text.heading, t.text.subheading][level - 1],
|
|
1099
|
+
icon: [TextHOne, TextHTwo, TextHThree][level - 1],
|
|
1100
|
+
action: () => setKind(`h${level}`),
|
|
1101
|
+
on: `h${level}`,
|
|
1102
|
+
})),
|
|
1098
1103
|
{ label: t.text.paragraph, icon: TextT, action: () => setKind('text'), on: 'text' },
|
|
1099
1104
|
{ label: t.text.bulletList, icon: ListBullets, action: () => setKind('bullet'), on: 'bullet' },
|
|
1100
1105
|
{ label: t.text.orderedList, icon: ListNumbers, action: () => setKind('ordered'), on: 'ordered' },
|
|
@@ -1194,6 +1199,7 @@ export default forwardRef<MarkdownHandle, Props>(function MarkdownEditor(
|
|
|
1194
1199
|
<div
|
|
1195
1200
|
ref={root}
|
|
1196
1201
|
className={`editor-text prose max-w-none px-5 py-4 medium:px-8 ${short ? 'editor-text-short' : ''} ${spaced ? 'editor-spaced' : ''}`}
|
|
1202
|
+
style={{ '--editor-gap-text': JSON.stringify(t.text.typeHere) } as CSSProperties}
|
|
1197
1203
|
/>
|
|
1198
1204
|
</div>
|
|
1199
1205
|
);
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// How a page looks as a Google result, on a phone or a computer (tabs): the site's name and address, the title and the description.
|
|
2
|
+
// Exposes SearchPreview. Depends on googleResult.mjs (the address line), TabRow and TabPanel in ui.tsx, shortDate (when.ts) and i18n.
|
|
3
|
+
import { useId, useState } from 'react';
|
|
4
|
+
import { DeviceMobile, Desktop } from '@phosphor-icons/react';
|
|
5
|
+
import { searchCrumbs } from './googleResult.mjs';
|
|
6
|
+
import { TabPanel, TabRow } from './ui';
|
|
7
|
+
import { shortDate } from './when';
|
|
8
|
+
import { t } from './i18n';
|
|
9
|
+
|
|
10
|
+
type Device = 'mobile' | 'desktop';
|
|
11
|
+
|
|
12
|
+
/** How a page looks in Google's results, drawn as Google draws one, in Google's own colours (--google-* in editor.css,
|
|
13
|
+
* light and dark with the editor): a circle with the site's first letter, the site's name over the address line, the title
|
|
14
|
+
* in blue and the description, with a post's `date` before it. Tabs switch between a phone (opens first: the title cut off
|
|
15
|
+
* after two lines, the description after three, on a card at most 360 px wide) and a computer (one line of title, two of
|
|
16
|
+
* description, at most 600 px wide, Google's results column). `path`: the page's address on the site. */
|
|
17
|
+
export function SearchPreview({
|
|
18
|
+
domain,
|
|
19
|
+
path,
|
|
20
|
+
siteName,
|
|
21
|
+
title,
|
|
22
|
+
description,
|
|
23
|
+
date,
|
|
24
|
+
}: {
|
|
25
|
+
domain: string;
|
|
26
|
+
path: string;
|
|
27
|
+
siteName: string;
|
|
28
|
+
title: string;
|
|
29
|
+
description: string;
|
|
30
|
+
date?: string;
|
|
31
|
+
}) {
|
|
32
|
+
const id = useId();
|
|
33
|
+
const [device, setDevice] = useState<Device>('mobile');
|
|
34
|
+
const phone = device === 'mobile';
|
|
35
|
+
return (
|
|
36
|
+
<div className="space-y-3">
|
|
37
|
+
<TabRow
|
|
38
|
+
id={id}
|
|
39
|
+
label={t.forms.searchPreview.on}
|
|
40
|
+
value={device}
|
|
41
|
+
options={[
|
|
42
|
+
{ value: 'mobile', label: t.forms.searchPreview.mobile, icon: DeviceMobile },
|
|
43
|
+
{ value: 'desktop', label: t.forms.searchPreview.desktop, icon: Desktop },
|
|
44
|
+
]}
|
|
45
|
+
onChange={setDevice}
|
|
46
|
+
/>
|
|
47
|
+
<TabPanel id={id} value={device}>
|
|
48
|
+
<figure
|
|
49
|
+
aria-label={t.forms.searchPreview.result}
|
|
50
|
+
className={`space-y-1 border border-line bg-(--google-background) p-4 font-[arial,sans-serif] ${
|
|
51
|
+
phone ? 'max-w-[360px] rounded-3xl' : 'max-w-[600px] rounded-lg'
|
|
52
|
+
}`}
|
|
53
|
+
>
|
|
54
|
+
<div className="flex items-center gap-3">
|
|
55
|
+
<span
|
|
56
|
+
aria-hidden="true"
|
|
57
|
+
className="flex size-[26px] shrink-0 items-center justify-center rounded-full bg-[color-mix(in_srgb,var(--google-text)_15%,transparent)] text-xs text-(--google-site)"
|
|
58
|
+
>
|
|
59
|
+
{[...siteName][0]?.toLocaleUpperCase()}
|
|
60
|
+
</span>
|
|
61
|
+
<span className="min-w-0">
|
|
62
|
+
<span className="block truncate text-sm text-(--google-site)">{siteName}</span>
|
|
63
|
+
<span className="block truncate text-xs text-(--google-text)">{searchCrumbs(domain, path)}</span>
|
|
64
|
+
</span>
|
|
65
|
+
</div>
|
|
66
|
+
<span className={`block text-(--google-title) ${phone ? 'line-clamp-2 text-lg' : 'truncate text-xl'}`}>{title}</span>
|
|
67
|
+
<p className={`text-sm leading-[22px] text-(--google-text) ${phone ? 'line-clamp-3' : 'line-clamp-2'}`}>
|
|
68
|
+
{date && <span className="text-(--google-date)">{shortDate(date)} — </span>}
|
|
69
|
+
{description}
|
|
70
|
+
</p>
|
|
71
|
+
</figure>
|
|
72
|
+
</TabPanel>
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
}
|