@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.
Files changed (45) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/defaults/popup.tsx +44 -33
  3. package/defaults/texts.ts +1 -1
  4. package/icons/lucide/desktop.svg +1 -0
  5. package/icons/material-symbols/desktop.svg +1 -0
  6. package/icons/phosphor/desktop.svg +1 -0
  7. package/lib/danish.d.mts +24 -0
  8. package/lib/danish.mjs +134 -0
  9. package/lib/danish.test.mjs +102 -0
  10. package/lib/google-result.test.mjs +70 -0
  11. package/lib/heading-levels.d.mts +1 -0
  12. package/lib/heading-levels.mjs +6 -1
  13. package/lib/heading-levels.test.mjs +15 -1
  14. package/lib/translate.d.mts +8 -0
  15. package/lib/translate.mjs +15 -0
  16. package/lib/translate.test.mjs +38 -0
  17. package/package.json +9 -1
  18. package/scripts/check-language.mjs +105 -0
  19. package/scripts/editor-roundtrip.mjs +9 -14
  20. package/scripts/template-round-trip.mjs +22 -0
  21. package/scripts/template-round-trip.test.mjs +26 -0
  22. package/site/icons.d.mts +4 -0
  23. package/site/icons.mjs +2 -0
  24. package/src/DataForm.tsx +31 -11
  25. package/src/EntryForm.tsx +22 -3
  26. package/src/Library.tsx +2 -8
  27. package/src/MarkdownEditor.tsx +17 -11
  28. package/src/SearchPreview.tsx +75 -0
  29. package/src/editor.css +36 -6
  30. package/src/googleResult.d.mts +20 -0
  31. package/src/googleResult.mjs +30 -0
  32. package/src/i18n/da-DK/forms.ts +6 -0
  33. package/src/i18n/da-DK/text.ts +1 -0
  34. package/src/i18n/en-US/forms.ts +9 -1
  35. package/src/i18n/en-US/parts.ts +1 -1
  36. package/src/i18n/en-US/text.ts +2 -0
  37. package/src/i18n/index.ts +16 -2
  38. package/src/seo.ts +11 -7
  39. package/src/ui.tsx +3 -2
  40. package/src/when.ts +6 -0
  41. package/theme/components/ActionBar.astro +26 -15
  42. package/theme/layouts/Base.astro +3 -1
  43. package/theme/styles/components.css +33 -17
  44. package/theme/styles/global.css +1 -7
  45. 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 (file.includes('src/content/pages/')) {
165
- const [fallback] = STANDARD_TEMPLATES;
166
- const plain = writeFields(yaml, { template: '' });
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 (file.includes('src/content/pages/') ? made.use(firstHeading) : made).create();
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 (file.includes('src/content/pages/')) {
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
- const seoSection = seoFields.length > 0 && (
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 card = () => {
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 sharedAs({
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 card = sharedAs({
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' ? date(item.detail) : item.detail}
1306
+ {collection.id === 'blog' ? shortDate(item.detail) : item.detail}
1313
1307
  </>
1314
1308
  )}
1315
1309
  </Under>
@@ -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 { showsH1Choice } from '../lib/heading-levels.mjs';
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 H1 (`h1`, only while the text has none), a heading or a subheading, a label above the heading; the ones that apply are marked. At a still
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
- // The H1 choice is gone as soon as the page's text has an H1.
599
- off: level === 1 ? (s: EditorView['state']) => !showsH1Choice(levelsOf(s.doc)) : undefined,
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
- // Whether the page's text has no H1, so the H1 choice is shown (showsH1Choice).
861
- const [h1Choice, setH1Choice] = useState(true);
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 choice = showsH1Choice(levelsOf(view.state.doc));
888
- setH1Choice((was) => (was === choice ? was : choice));
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
- ...(h1 && h1Choice ? [{ label: t.text.pageHeading, icon: TextHOne, action: () => setKind('h1'), on: 'h1' }] : []),
1096
- { label: t.text.heading, icon: TextHTwo, action: () => setKind('h2'), on: 'h2' },
1097
- { label: t.text.subheading, icon: TextHThree, action: () => setKind('h3'), on: 'h3' },
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
+ }