@morphemecms/cms 0.9.1 → 0.10.0-next-20261105153927

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 CHANGED
@@ -1,5 +1,16 @@
1
1
  # @morphemecms/cms
2
2
 
3
+ ## 0.10.0-next-20261105153927
4
+
5
+ ### Minor Changes
6
+
7
+ - 999142f: The action bar at the bottom of a phone's screen is a standard bottom sheet, on the site and in the editor.
8
+ - 358c778: The page and post forms show a Google result preview for phone and computer; the shared-link card keeps its shared width.
9
+
10
+ ### Patch Changes
11
+
12
+ - 1478765: On a page, the heading menus offer only H1 until the text has one, then H2 and H3.
13
+
3
14
  ## 0.9.1
4
15
 
5
16
  ### Patch Changes
@@ -8,6 +8,7 @@ import type { KindPart } from '../src/schema';
8
8
  import { buttonsOf } from '../src/standard/buttons/view';
9
9
  import { shownPicture } from '../src/standard/view';
10
10
  import PopupView from '@theme/components/PopupView';
11
+ import BottomSheet from '@theme/ui/BottomSheet';
11
12
  import Button from '@theme/ui/Button';
12
13
  import Toolbar from '@theme/ui/Toolbar';
13
14
  import List, { ListItem } from '@theme/ui/List';
@@ -43,7 +44,8 @@ export function drawPopup(p: Record<string, any>, data: BlockData, part?: KindPa
43
44
  }
44
45
 
45
46
  // An action bar ("Promovering", the kind "Handlingslinje") as it looks on a phone (ActionBar.astro in the
46
- // theme): its one or two items side by side, each a text (a list row of two lines: its heading over its text) or a button, the first button filled, the buttons with their own texts or those of their target
47
+ // theme): the same standard bottom sheet (BottomSheet, .theme-action-bar-drawn in styles/components.css: in its place,
48
+ // a phone wide) with the same toolbar row in it, its one or two items side by side, each a text (a list row of two lines: its heading over its text) or a button, the first button filled, the buttons with their own texts or those of their target
47
49
  // ("Book en tid", "Ring"), of what they open (a pop-up's heading, an app's booking sheet: "Book en tid") or of their
48
50
  // page; the calendar for what books, the phone for "Ring", the arrow after a page or a pop-up.
49
51
  export function drawActionBar(p: Record<string, any>, data: BlockData) {
@@ -55,39 +57,48 @@ export function drawActionBar(p: Record<string, any>, data: BlockData) {
55
57
  if (!buttons.length) return null;
56
58
  const first = buttons.findIndex((b) => b.type !== 'text');
57
59
  return (
58
- <Toolbar label={siteT.theme.actions} className="theme-action-bar-drawn not-prose">
59
- {buttons.map((b, i) => {
60
- if (b.type === 'text')
60
+ <BottomSheet
61
+ kind="standard"
62
+ wide="sheet"
63
+ dragHandle={false}
64
+ states={['expanded']}
65
+ label={siteT.theme.actions}
66
+ className="theme-action-bar-drawn not-prose"
67
+ >
68
+ <Toolbar label={siteT.theme.actions} className="theme-action-bar-row">
69
+ {buttons.map((b, i) => {
70
+ if (b.type === 'text')
71
+ return (
72
+ <List key={i} dividers="none" className="theme-action-bar-text">
73
+ <ListItem headline={String(b.heading ?? '').trim()} supporting={String(b.text ?? '').trim() || undefined} />
74
+ </List>
75
+ );
76
+ const own = String(b.label ?? '').trim();
77
+ const opens = b.to === 'promotion' ? data.relations.promotions?.find((o) => o.value === b.open) : undefined;
78
+ // What it does: "Book en tid" also for a promotion that stands for it (an app's booking sheet).
79
+ const to = opens?.as ?? b.to;
80
+ const text =
81
+ to === 'phone'
82
+ ? own || data.site.labels.call
83
+ : to === 'page'
84
+ ? own || pageName(b.href ?? '') || ''
85
+ : to === 'promotion'
86
+ ? own || opens?.text || ''
87
+ : own || data.site.labels.book;
88
+ const arrow = to === 'page' || to === 'promotion';
61
89
  return (
62
- <List key={i} dividers="none" className="theme-action-bar-text">
63
- <ListItem headline={String(b.heading ?? '').trim()} supporting={String(b.text ?? '').trim() || undefined} />
64
- </List>
90
+ <Button
91
+ key={i}
92
+ href=""
93
+ kind={i === first ? 'filled' : 'outlined'}
94
+ icon={arrow ? undefined : to === 'phone' ? 'phone' : 'calendar-blank'}
95
+ trailingIcon={arrow ? 'arrow-right' : undefined}
96
+ >
97
+ {text}
98
+ </Button>
65
99
  );
66
- const own = String(b.label ?? '').trim();
67
- const opens = b.to === 'promotion' ? data.relations.promotions?.find((o) => o.value === b.open) : undefined;
68
- // What it does: "Book en tid" also for a promotion that stands for it (an app's booking sheet).
69
- const to = opens?.as ?? b.to;
70
- const text =
71
- to === 'phone'
72
- ? own || data.site.labels.call
73
- : to === 'page'
74
- ? own || pageName(b.href ?? '') || ''
75
- : to === 'promotion'
76
- ? own || opens?.text || ''
77
- : own || data.site.labels.book;
78
- const arrow = to === 'page' || to === 'promotion';
79
- return (
80
- <Button
81
- key={i}
82
- href=""
83
- kind={i === first ? 'filled' : 'outlined'}
84
- icon={arrow ? undefined : to === 'phone' ? 'phone' : 'calendar-blank'}
85
- trailingIcon={arrow ? 'arrow-right' : undefined}
86
- >
87
- {text}
88
- </Button>
89
- );
90
- })}
91
- </Toolbar>
100
+ })}
101
+ </Toolbar>
102
+ </BottomSheet>
92
103
  );
93
104
  }
package/defaults/texts.ts CHANGED
@@ -15,7 +15,7 @@ export const texts: Record<string, Record<string, string>> = {
15
15
  'Nyt blogindlæg': 'New blog post',
16
16
  Titel: 'Title',
17
17
  Dato: 'Date',
18
- Adresse: 'Address',
18
+ Adresse: 'URL',
19
19
  'Kort beskrivelse': 'Short description',
20
20
  Tags: 'Tags',
21
21
  'Opret et tag': 'Create a tag',
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="14" x="2" y="3" rx="2" /> <line x1="8" x2="16" y1="21" y2="21" /> <line x1="12" x2="12" y1="17" y2="21" /></g></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="M334-120v-60h86v-100H140q-24 0-42-18t-18-42v-440q0-24 18-42t42-18h680q24 0 42 18t18 42v440q0 24-18 42t-42 18H540v100h86v60H334ZM140-340h680v-440H140v440Zm0 0v-440 440Z"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M208,40H48A24,24,0,0,0,24,64V176a24,24,0,0,0,24,24h72v16H96a8,8,0,0,0,0,16h64a8,8,0,0,0,0-16H136V200h72a24,24,0,0,0,24-24V64A24,24,0,0,0,208,40ZM48,56H208a8,8,0,0,1,8,8v80H40V64A8,8,0,0,1,48,56ZM208,184H48a8,8,0,0,1-8-8V160H216v16A8,8,0,0,1,208,184Z"/></svg>
@@ -0,0 +1,70 @@
1
+ // Run: node --test lib/google-result.test.mjs
2
+ // Tests src/googleResult.mjs: the title, description and address line of a page's Google result.
3
+ // Depends on node:test, node:assert/strict and ../src/googleResult.mjs.
4
+ import { test } from 'node:test';
5
+ import assert from 'node:assert/strict';
6
+ import { pageTitle, searchedAs, searchCrumbs } from '../src/googleResult.mjs';
7
+
8
+ const site = { name: 'Morpheme', description: 'The site.' };
9
+
10
+ test('a page title gets the site name after it', () => {
11
+ assert.equal(pageTitle({ title: 'About', front: false, siteName: 'Morpheme' }), 'About | Morpheme');
12
+ });
13
+
14
+ test('the page title is trimmed before the site name is added', () => {
15
+ assert.equal(pageTitle({ title: ' About ', front: false, siteName: 'Morpheme' }), 'About | Morpheme');
16
+ });
17
+
18
+ test('the front page title has no site name', () => {
19
+ assert.equal(pageTitle({ title: 'Welcome', front: true, siteName: 'Morpheme' }), 'Welcome');
20
+ });
21
+
22
+ test('without a title the site name is the title', () => {
23
+ assert.equal(pageTitle({ title: '', front: false, siteName: 'Morpheme' }), 'Morpheme');
24
+ });
25
+
26
+ test('a blank title on the front page is the site name', () => {
27
+ assert.equal(pageTitle({ title: ' ', front: true, siteName: 'Morpheme' }), 'Morpheme');
28
+ });
29
+
30
+ test('the SEO title is the result title when it is set', () => {
31
+ assert.equal(searchedAs({ title: 'About', seoTitle: 'Our story', site }).title, 'Our story');
32
+ });
33
+
34
+ test('a blank SEO title falls back to the page title', () => {
35
+ assert.equal(searchedAs({ title: 'About', seoTitle: ' ', site }).title, 'About | Morpheme');
36
+ });
37
+
38
+ test('without any title the result title is the site name', () => {
39
+ assert.equal(searchedAs({ title: '', site }).title, 'Morpheme');
40
+ });
41
+
42
+ test('the SEO description is the result description when it is set', () => {
43
+ const result = searchedAs({ title: 'A', seoDescription: 'Google text', description: 'Short', site });
44
+ assert.equal(result.description, 'Google text');
45
+ });
46
+
47
+ test('the page description is used when the SEO description is blank', () => {
48
+ const result = searchedAs({ title: 'A', seoDescription: '', description: ' Short ', site });
49
+ assert.equal(result.description, 'Short');
50
+ });
51
+
52
+ test('the site description is used when the page has no description', () => {
53
+ assert.equal(searchedAs({ title: 'A', site }).description, 'The site.');
54
+ });
55
+
56
+ test('the address line is the domain and the path parts', () => {
57
+ assert.equal(searchCrumbs('morpheme.example', '/blog/my-post/'), 'morpheme.example › blog › my-post');
58
+ });
59
+
60
+ test('the front page address line is only the domain', () => {
61
+ assert.equal(searchCrumbs('morpheme.example', '/'), 'morpheme.example');
62
+ });
63
+
64
+ test('an empty path gives only the domain', () => {
65
+ assert.equal(searchCrumbs('morpheme.example', ''), 'morpheme.example');
66
+ });
67
+
68
+ test('a path without a leading slash still gives its parts', () => {
69
+ assert.equal(searchCrumbs('morpheme.example', 'about'), 'morpheme.example › about');
70
+ });
@@ -5,3 +5,4 @@
5
5
  */
6
6
  export function headingLevels(levels: number[]): number[];
7
7
  export function showsH1Choice(levels: number[]): boolean;
8
+ export function headingChoices(levels: number[]): number[];
@@ -1,5 +1,5 @@
1
1
  // The levels of a page's headings as the editor keeps them: the first heading is the page's H1, and no later one is.
2
- // Exposes headingLevels and showsH1Choice (pure); the editor's plugin that applies it is src/firstHeading.mjs. No dependencies.
2
+ // Exposes headingLevels, showsH1Choice and headingChoices (pure); the editor's plugin that applies it is src/firstHeading.mjs. No dependencies.
3
3
 
4
4
  /**
5
5
  * The levels with the first set to 1 and every later 1 set to 2 (the others as they are).
@@ -14,3 +14,8 @@ export function headingLevels(levels) {
14
14
  * @param {number[]} levels The levels (1 to 6) of the headings of a text, in order.
15
15
  * @returns {boolean} */
16
16
  export const showsH1Choice = (levels) => !levels.includes(1);
17
+
18
+ /** The heading levels the editor offers on a page: only the H1 while the text has no H1, after that H2 and H3.
19
+ * @param {number[]} levels The levels (1 to 6) of the headings of a text, in order.
20
+ * @returns {number[]} */
21
+ export const headingChoices = (levels) => (showsH1Choice(levels) ? [1] : [2, 3]);
@@ -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 { headingLevels, showsH1Choice } from './heading-levels.mjs';
5
+ import { headingLevels, showsH1Choice, headingChoices } from './heading-levels.mjs';
6
6
 
7
7
  test('no headings, no levels', () => {
8
8
  assert.deepEqual(headingLevels([]), []);
@@ -35,3 +35,17 @@ test('the H1 choice is shown only while no heading is an H1', () => {
35
35
  assert.equal(showsH1Choice([1]), false);
36
36
  assert.equal(showsH1Choice([2, 1, 3]), false);
37
37
  });
38
+
39
+ test('only H1 is offered until the page has one', () => {
40
+ assert.deepEqual(headingChoices([]), [1]);
41
+ assert.deepEqual(headingChoices([2, 3]), [1]);
42
+ assert.deepEqual(headingChoices([1]), [2, 3]);
43
+ assert.deepEqual(headingChoices([1, 2, 3]), [2, 3]);
44
+ assert.deepEqual(headingChoices([2, 1]), [2, 3]);
45
+ });
46
+
47
+ test('the list given to headingChoices is not changed', () => {
48
+ const given = [2, 1];
49
+ headingChoices(given);
50
+ assert.deepEqual(given, [2, 1]);
51
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morphemecms/cms",
3
- "version": "0.9.1",
3
+ "version": "0.10.0-next-20261105153927",
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",
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>
@@ -55,7 +55,7 @@ import {
55
55
  } from '@phosphor-icons/react';
56
56
  import { HTML, makeEditor, markdownOf, partsOf, siteBlockSchema, spacing } from './markdown.mjs';
57
57
  import { firstHeading, topHeadings } from './firstHeading.mjs';
58
- import { showsH1Choice } from '../lib/heading-levels.mjs';
58
+ import { headingChoices } from '../lib/heading-levels.mjs';
59
59
  import { TEXT_COLORS, colorClass } from '../lib/text-colors.mjs';
60
60
  import { SECTION_COLORS } from '../lib/section-marker.mjs';
61
61
  import { sectionHeading, setSectionColor } from './sectionState.mjs';
@@ -540,7 +540,7 @@ const inLabel = (state: EditorView['state']) => state.selection.$from.parent.typ
540
540
 
541
541
  // 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
542
  // 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
543
+ // 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
544
  // cursor, bold, italic, link and colour act on the whole word, the headings on the line; a move or a keypress hides the
545
545
  // 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
546
  // of the screen, over the keyboard. sections: no colour in a heading (H2), the name of a section.
@@ -595,8 +595,8 @@ function selectionMenu(ctx: Ctx, h1: boolean, sections: boolean, colors: (button
595
595
  label: [t.text.pageHeading, t.text.heading, t.text.subheading][level - 1],
596
596
  on: (s: EditorView['state']) => heading(s, level),
597
597
  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,
598
+ // On a page only the levels headingChoices gives are offered: H1 until the text has one, then H2 and H3.
599
+ off: h1 ? (s: EditorView['state']) => !headingChoices(levelsOf(s.doc)).includes(level) : undefined,
600
600
  })),
601
601
  // A label above the heading. Not on a fixed page's H1 or H2, whose headings are its sections (sections.mjs).
602
602
  {
@@ -857,8 +857,8 @@ export default forwardRef<MarkdownHandle, Props>(function MarkdownEditor(
857
857
  looked.current = looks;
858
858
  // What applies where the cursor is (activeOf), marked in the toolbar.
859
859
  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);
860
+ // The heading levels the toolbar offers on a page (headingChoices): [1] while the text has no H1, then [2, 3].
861
+ const [choices, setChoices] = useState<number[]>([1]);
862
862
  // The colour button over the selection, while its menu with the colours is open.
863
863
  const [colorsAt, setColorsAt] = useState<HTMLElement | null>(null);
864
864
  useEffect(() => {
@@ -884,8 +884,8 @@ export default forwardRef<MarkdownHandle, Props>(function MarkdownEditor(
884
884
  new Plugin({
885
885
  view: () => ({
886
886
  update: (view) => {
887
- const choice = showsH1Choice(levelsOf(view.state.doc));
888
- setH1Choice((was) => (was === choice ? was : choice));
887
+ const next = headingChoices(levelsOf(view.state.doc));
888
+ setChoices((was) => (was.join() === next.join() ? was : next));
889
889
  // On a page the first heading is the H1 by itself (firstHeading.mjs); the Heading button stands for it.
890
890
  const now = [...activeOf(view.state)].map((name) => (h1 && name === 'h1' ? 'h2' : name)).sort();
891
891
  setActive((was) => (was.join() === now.join() ? was : now));
@@ -1092,9 +1092,13 @@ export default forwardRef<MarkdownHandle, Props>(function MarkdownEditor(
1092
1092
  { label: t.text.bold, icon: TextB, action: () => run(toggleStrongCommand), on: 'strong' },
1093
1093
  { label: t.text.italic, icon: TextItalic, action: () => run(toggleEmphasisCommand), on: 'emphasis' },
1094
1094
  { 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' },
1095
+ // On a page the levels in choices (H1 until the text has one); a blog post has H2 and H3.
1096
+ ...(h1 ? choices : [2, 3]).map((level) => ({
1097
+ label: [t.text.pageHeading, t.text.heading, t.text.subheading][level - 1],
1098
+ icon: [TextHOne, TextHTwo, TextHThree][level - 1],
1099
+ action: () => setKind(`h${level}`),
1100
+ on: `h${level}`,
1101
+ })),
1098
1102
  { label: t.text.paragraph, icon: TextT, action: () => setKind('text'), on: 'text' },
1099
1103
  { label: t.text.bulletList, icon: ListBullets, action: () => setKind('bullet'), on: 'bullet' },
1100
1104
  { label: t.text.orderedList, icon: ListNumbers, action: () => setKind('ordered'), on: 'ordered' },
@@ -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
+ }
package/src/editor.css CHANGED
@@ -17,6 +17,14 @@
17
17
  --editor-status-bad-text: #cc0000;
18
18
  --editor-status-bad-surface: color-mix(in srgb, #ff4e42 10%, var(--surface));
19
19
 
20
+ /* A Google result (SearchPreview.tsx) in Google's own colours, light: its background, the title, the site's name, the
21
+ address and the text, and the date before the text. */
22
+ --google-background: #fff;
23
+ --google-title: #1a0dab;
24
+ --google-site: #202124;
25
+ --google-text: #4d5156;
26
+ --google-date: #70757a;
27
+
20
28
  /* The dotted frame round an H2 section of a page or blog post (sectionFrame.ts): its line, the space between the line and
21
29
  the text, the space above a section, and the space between two parts inside it (em: it scales with the part's text). */
22
30
  --editor-section-frame: 2px dotted var(--block-line);
@@ -34,15 +42,25 @@
34
42
  --side-panel-width: calc((100vw - var(--column-start)) / 2);
35
43
  }
36
44
 
37
- /* In dark mode the score's number in the light colours, which are read on the dark. */
45
+ /* In dark mode the score's number in the light colours, which are read on the dark, and Google's dark colours. */
38
46
  @media (prefers-color-scheme: dark) {
39
47
  :where(:root:not([data-theme='light'])) {
48
+ --google-background: #1f1f1f;
49
+ --google-title: #99c3ff;
50
+ --google-site: #dadce0;
51
+ --google-text: #bfbfbf;
52
+ --google-date: #9aa0a6;
40
53
  --editor-status-good-text: var(--editor-status-good);
41
54
  --editor-status-average-text: var(--editor-status-average);
42
55
  --editor-status-bad-text: var(--editor-status-bad);
43
56
  }
44
57
  }
45
58
  :where(:root[data-theme='dark']) {
59
+ --google-background: #1f1f1f;
60
+ --google-title: #99c3ff;
61
+ --google-site: #dadce0;
62
+ --google-text: #bfbfbf;
63
+ --google-date: #9aa0a6;
46
64
  --editor-status-good-text: var(--editor-status-good);
47
65
  --editor-status-average-text: var(--editor-status-average);
48
66
  --editor-status-bad-text: var(--editor-status-bad);
@@ -0,0 +1,20 @@
1
+ /** The page's own title as the site writes it in <title> when it has no Google title. */
2
+ export function pageTitle({ title, front, siteName }: {
3
+ title: any;
4
+ front: any;
5
+ siteName: any;
6
+ }): string;
7
+ /** The title and description a Google result shows for the page. `site`: { name, description }. */
8
+ export function searchedAs({ title, front, seoTitle, seoDescription, description, site }: {
9
+ title: any;
10
+ front: any;
11
+ seoTitle: any;
12
+ seoDescription: any;
13
+ description: any;
14
+ site: any;
15
+ }): {
16
+ title: string;
17
+ description: string;
18
+ };
19
+ /** The address line of a Google result: the domain and the path's parts, joined by " › ". */
20
+ export function searchCrumbs(domain: any, path: any): string;
@@ -0,0 +1,30 @@
1
+ // The texts of a page's Google result: its title and description as the site writes them, and the address line.
2
+ // Exposes pageTitle, searchedAs and searchCrumbs. Mirrors theme/layouts/Base.astro; no dependencies.
3
+ // Missing (undefined or null) string inputs count as empty strings.
4
+
5
+ const SEPARATOR = ' › ';
6
+
7
+ const text = (s) => String(s ?? '').trim();
8
+
9
+ /** The page's own title as the site writes it in <title> when it has no Google title. */
10
+ export function pageTitle({ title, front, siteName }) {
11
+ const own = text(title);
12
+ if (!own) return String(siteName ?? '');
13
+ if (front) return own;
14
+ return `${own} | ${siteName ?? ''}`;
15
+ }
16
+
17
+ /** The title and description a Google result shows for the page. `site`: { name, description }. */
18
+ export function searchedAs({ title, front, seoTitle, seoDescription, description, site }) {
19
+ const resultTitle = text(seoTitle) || pageTitle({ title, front, siteName: site.name });
20
+ const resultDescription = text(seoDescription) || text(description) || String(site.description ?? '');
21
+ return { title: resultTitle, description: resultDescription };
22
+ }
23
+
24
+ /** The address line of a Google result: the domain and the path's parts, joined by " › ". */
25
+ export function searchCrumbs(domain, path) {
26
+ const parts = String(path ?? '')
27
+ .split('/')
28
+ .filter((part) => part !== '');
29
+ return [domain, ...parts].join(SEPARATOR);
30
+ }
@@ -10,6 +10,12 @@ export const forms: Messages['forms'] = {
10
10
  seo: 'Søgemaskiner',
11
11
  share: 'Sociale medier',
12
12
  sharePreview: 'Sådan ser et link til siden ud, når det deles',
13
+ searchPreview: {
14
+ on: 'Google-resultat på',
15
+ mobile: 'Mobil',
16
+ desktop: 'Computer',
17
+ result: 'Forhåndsvisning af Google-resultatet',
18
+ },
13
19
  tagShared: (n, name) => `${n} indlæg om ${name.toLowerCase()}.`,
14
20
  seoRating: {
15
21
  title: 'SEO-vurdering',
@@ -12,6 +12,14 @@ export const forms = {
12
12
  * to it looks (SharePreview in ui.tsx). */
13
13
  share: 'Social media',
14
14
  sharePreview: 'How a link to the page looks when it is shared',
15
+ /** The preview of the Google result at the top of "Søgemaskiner" (SearchPreview.tsx): the name of its tabs, the tabs and
16
+ * what a screen reader says of the result. */
17
+ searchPreview: {
18
+ on: 'Google result on',
19
+ mobile: 'Mobile',
20
+ desktop: 'Desktop',
21
+ result: 'Preview of the Google result',
22
+ },
15
23
  /** What a tag's page says about itself when it is shared without a Google description (the site's text, Category.astro). */
16
24
  tagShared: (n: number, name: string) => `${n} ${n === 1 ? 'post' : 'posts'} about ${name.toLowerCase()}.`,
17
25
  /** The SEO rating at the top of "Søgemaskiner" (seo.ts): its heading and what each check says; `post`: a
@@ -62,7 +70,7 @@ export const forms = {
62
70
  },
63
71
  /** Over the places that use an entry (a tag), DataForm.tsx. */
64
72
  usedOn: 'Used on',
65
- address: 'Address',
73
+ address: 'URL',
66
74
  writeAddress: 'Write the address with letters or digits.',
67
75
  /** The commits; `what` is a name or a file name in quotation marks. */
68
76
  commit: {
@@ -86,7 +86,7 @@ export const parts = {
86
86
  bold: 'Bold',
87
87
  insert: 'Insert',
88
88
  cookieBanner: 'The cookie banner',
89
- address: 'Address',
89
+ address: 'URL',
90
90
  addressHint: 'For example, /about/.',
91
91
  sendsTo: 'Sends to the page "Second page."',
92
92
  color: 'Accent color',
package/src/seo.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  import { t } from './i18n';
8
8
  import { decorativeOf } from '../lib/crop.mjs';
9
9
  import { readFields, splitFrontmatter } from './frontmatter.mjs';
10
+ import { searchedAs } from './googleResult.mjs';
10
11
 
11
12
  /** What the rating looks at: a blog post (whose title is its H1) or a page (whose H1 is in its text). */
12
13
  export type SeoInput = {
@@ -71,8 +72,8 @@ export const SHARE_FIELDS = ['shareImage', 'shareTitle', 'shareDescription'];
71
72
 
72
73
  /** What a page shows when a link to it is shared (Facebook, LinkedIn, a message), worked out as Base.astro writes its
73
74
  * og: tags: the title when shared, else the Google title, else the page's own with the site's name after it (the front
74
- * page's without); the description when shared, else the Google description, else the page's own (`description`: its
75
- * "Kort beskrivelse", a fixed page's introduction), else the site's; the picture when shared, else the page's own
75
+ * page's without; a page without a title: only the site's name, as `searchedAs` has it); the description when shared,
76
+ * else the Google description, else the page's own (`description`: its "Kort beskrivelse", a fixed page's introduction), else the site's; the picture when shared, else the page's own
76
77
  * (`image`: "Billede øverst", else the first picture in the text), else the site's. */
77
78
  export type ShareInput = {
78
79
  /** The page's own title as the site has it: a page's H1, a post's title, a fixed page's heading, a tag's name. */
@@ -89,11 +90,14 @@ export type ShareInput = {
89
90
  site: { name: string; description: string; picture: string };
90
91
  };
91
92
 
92
- export const sharedAs = (input: ShareInput) => ({
93
- title: input.shareTitle?.trim() || input.seoTitle?.trim() || (input.front ? input.title : `${input.title} | ${input.site.name}`),
94
- description: input.shareDescription?.trim() || input.seoDescription?.trim() || input.description?.trim() || input.site.description,
95
- image: input.shareImage || input.image || input.site.picture,
96
- });
93
+ export const sharedAs = (input: ShareInput) => {
94
+ const searched = searchedAs(input);
95
+ return {
96
+ title: input.shareTitle?.trim() || searched.title,
97
+ description: input.shareDescription?.trim() || searched.description,
98
+ image: input.shareImage || input.image || input.site.picture,
99
+ };
100
+ };
97
101
 
98
102
  /** The first picture in a text (its address), not one in the text of a block in it (a "Banner"'s, on its line of HTML),
99
103
  * as the site picks it (firstImage in packages/editor/site/images.mjs). */
package/src/ui.tsx CHANGED
@@ -2466,11 +2466,12 @@ export function CheckList({ title, items }: { title?: string; items: Check[] })
2466
2466
 
2467
2467
  /** How a link to a page looks when it is shared on social media (Facebook, LinkedIn, a message), as a card: its picture
2468
2468
  * cut to 1.91:1 (one on its side or square keeps its upper part, as the site cuts it), the site's domain in small
2469
- * capitals, the title and the description, each cut off after two lines. Material 3 has none, so it has its own name. */
2469
+ * capitals, the title and the description, each cut off after two lines. It is never wider than 500 px, the width of a link
2470
+ * card in the Facebook feed, and sits at the start of its column. Material 3 has none, so it has its own name. */
2470
2471
  export function SharePreview({ image, domain, title, description }: { image: string; domain: string; title: string; description: string }) {
2471
2472
  const [tall, setTall] = useState(false);
2472
2473
  return (
2473
- <figure aria-label={t.forms.sharePreview} className={`${siteCardClass('outlined')} overflow-hidden p-0`}>
2474
+ <figure aria-label={t.forms.sharePreview} className={`${siteCardClass('outlined')} max-w-[500px] overflow-hidden p-0`}>
2474
2475
  <img
2475
2476
  src={image}
2476
2477
  alt=""
package/src/when.ts CHANGED
@@ -20,3 +20,9 @@ export function when(date: Date, { short = false } = {}) {
20
20
  if (short && shown === t.common.today) return time;
21
21
  return t.common.at(shown, time);
22
22
  }
23
+
24
+ // "2023-10-01" as "1. okt. 2023" (in the editor's language); anything else as it is.
25
+ export const shortDate = (iso: string) =>
26
+ /^\d{4}-\d{2}-\d{2}$/.test(iso)
27
+ ? new Date(`${iso}T12:00`).toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric' })
28
+ : iso;
@@ -5,12 +5,14 @@
5
5
  // its text (the theme's List and ListItem, .theme-action-bar-text: beside the button, in the room it leaves; one button alone takes the whole width, two share it, styles/components.css). Each button goes
6
6
  // where a button of "Knapper" can, and is drawn by the same component (Buttons.tsx, with `bar`): "Book en tid" opens
7
7
  // what books on the page (`data-open="booking"`: an app's booking sheet) or goes to the booking page, "Ring" calls, "Åbn
8
- // promovering" opens a pop-up or an app's booking sheet, and a page is a link. Built of the theme's docked toolbar
9
- // (toolbarClass, ui/Toolbar.tsx), fixed at the bottom (.theme-action-bar in styles/components.css), and its buttons
10
- // (ui/Button.tsx); only the first bar on a page is shown, and it moves away while the buttons at the top of the front
11
- // page are on screen (data-action-bar, the script in Base.astro). Not on a page one of its buttons goes to (the booking
8
+ // promovering" opens a pop-up or an app's booking sheet, and a page is a link. A standard bottom
9
+ // sheet (ui/BottomSheet.tsx: fixed at the bottom, its surface, top corners and shadow, without handle, one state,
10
+ // .theme-action-bar in styles/components.css) with the theme's docked toolbar in it (toolbarClass, ui/Toolbar.tsx, the
11
+ // row .theme-action-bar-row, without a surface of its own) and its buttons (ui/Button.tsx); only the first bar on a page is
12
+ // shown, and it moves away while the buttons at the top of the front page are on screen (data-action-bar, the script in Base.astro). Not on a page one of its buttons goes to (the booking
12
13
  // page for "Book en tid") or the thank-you page.
13
14
  import Buttons from '@theme/blocks/buttons/Buttons';
15
+ import BottomSheet from '@theme/ui/BottomSheet';
14
16
  import List, { ListItem } from '@theme/ui/List';
15
17
  import { toolbarClass } from '@theme/ui/Toolbar';
16
18
  import { loadButtons } from '@morphemecms/cms/src/standard/buttons/load';
@@ -49,15 +51,24 @@ const shown = await Promise.all(
49
51
  ---
50
52
 
51
53
  {shown.some((s) => s.heading || s.button) && (
52
- <div class={`${toolbarClass()} theme-action-bar`} role="toolbar" aria-label={siteT.theme.actions} data-action-bar>
53
- {shown.map((s) =>
54
- s.button ? (
55
- <Buttons buttons={[s.button]} />
56
- ) : s.heading ? (
57
- <List dividers="none" className="theme-action-bar-text">
58
- <ListItem headline={s.heading} supporting={s.text} />
59
- </List>
60
- ) : null,
61
- )}
62
- </div>
54
+ <BottomSheet
55
+ kind="standard"
56
+ dragHandle={false}
57
+ states={['expanded']}
58
+ label={siteT.theme.actions}
59
+ className="theme-action-bar"
60
+ data-action-bar=""
61
+ >
62
+ <div class={`${toolbarClass()} theme-action-bar-row`} role="toolbar" aria-label={siteT.theme.actions}>
63
+ {shown.map((s) =>
64
+ s.button ? (
65
+ <Buttons buttons={[s.button]} />
66
+ ) : s.heading ? (
67
+ <List dividers="none" className="theme-action-bar-text">
68
+ <ListItem headline={s.heading} supporting={s.text} />
69
+ </List>
70
+ ) : null,
71
+ )}
72
+ </div>
73
+ </BottomSheet>
63
74
  )}
@@ -64,7 +64,9 @@ const { title, image, type = 'website', crumbs, questions, schema = [] } = Astro
64
64
  const entry = seo[Astro.url.pathname];
65
65
  const noindex = Astro.props.noindex || entry?.noindex || isNoindex(Astro.url.pathname);
66
66
  // `||`, not `??`: an empty field (the content schemas default to '') falls through to the next.
67
- const fullTitle = Astro.props.seoTitle || entry?.title || (Astro.url.pathname === '/' ? title : `${title} | ${site.name}`);
67
+ // A page without a title shows only the site's name, without " | " (pageTitle in src/googleResult.mjs).
68
+ const fullTitle =
69
+ Astro.props.seoTitle || entry?.title || (!title ? site.name : Astro.url.pathname === '/' ? title : `${title} | ${site.name}`);
68
70
  const description = Astro.props.seoDescription || entry?.description || Astro.props.description || site.description;
69
71
  // When the page is shared: its own title and description for that ("Titel ved deling", "Beskrivelse ved deling"), else
70
72
  // the Google ones.
@@ -109,20 +109,19 @@
109
109
  --theme-section-space: var(--theme-blog-first-space);
110
110
  --theme-section-space-large: var(--theme-blog-first-space-large);
111
111
  }
112
- /* The bar at the bottom of a phone's screen (a promotion: ActionBar.astro), a docked toolbar
113
- (.toolbar-docked, ui.css) fixed there: the page gets room under its footer, and only the first bar on a page is
114
- shown. data-hidden: moved down out of sight (while the buttons at the top of the front page are on screen, the script
115
- in Base.astro). Not from the medium width (a tablet in portrait, a computer). */
112
+ /* The bar at the bottom of a phone's screen (a promotion: ActionBar.astro), a standard bottom sheet
113
+ (.bottom-sheet-standard, ui.css: fixed at the bottom, its surface, corners and shadow) with a docked toolbar in it
114
+ (.theme-action-bar-row): the page gets room under its footer, and only the first bar on a page is shown.
115
+ data-hidden: moved down out of sight (while the buttons at the top of the front page are on screen, the script in
116
+ Base.astro). Not from the medium width (a tablet in portrait, a computer): the display rules name both classes, so
117
+ they win over the sheet's own display and its data-wide="side-sheet" whatever the order of the files. */
116
118
  .theme-action-bar {
117
- position: fixed;
118
- inset: auto 0 0;
119
- z-index: 30;
120
119
  transition: transform var(--motion-spatial-slow);
121
120
  }
122
121
  .theme-action-bar[data-hidden] {
123
122
  transform: translateY(100%);
124
123
  }
125
- .theme-action-bar ~ .theme-action-bar {
124
+ .bottom-sheet-standard.theme-action-bar ~ .bottom-sheet-standard.theme-action-bar {
126
125
  display: none;
127
126
  }
128
127
  @media (width < theme(--breakpoint-medium)) {
@@ -131,22 +130,36 @@
131
130
  }
132
131
  }
133
132
  @media (width >= theme(--breakpoint-medium)) {
134
- .theme-action-bar {
133
+ .bottom-sheet-standard.theme-action-bar {
135
134
  display: none;
136
135
  }
137
136
  }
138
- /* Its buttons and its text, on the site and as the editor draws it: one button alone the whole width; two side by side
137
+ /* The sheet's room round what is in it is the row's (the toolbar's), which has no surface of its own (the sheet's shows),
138
+ no line over it and no blur behind it. */
139
+ .bottom-sheet-standard:is(.theme-action-bar, .theme-action-bar-drawn) {
140
+ padding-bottom: 0;
141
+ }
142
+ .bottom-sheet-standard:is(.theme-action-bar, .theme-action-bar-drawn) > .bottom-sheet-content {
143
+ overflow: visible;
144
+ padding: 0;
145
+ }
146
+ .toolbar.theme-action-bar-row {
147
+ --toolbar-docked-line-width: 0px;
148
+ --toolbar-docked-backdrop: none;
149
+ background: none;
150
+ }
151
+ /* Its buttons and its text, in the row, on the site and as the editor draws it: one button alone the whole width; two side by side
139
152
  as wide as each other, unless one's text needs more (a button's text stays on one line), and under each other, each
140
153
  the whole width, when they cannot stand side by side; a text (a list row of two lines, the theme's List) in the room
141
154
  the button beside it leaves, without the row's own room round it, the button as wide as its text (under the text,
142
155
  the whole width, when they cannot stand side by side). */
143
- :is(.theme-action-bar, .theme-action-bar-drawn) {
156
+ .theme-action-bar-row {
144
157
  flex-wrap: wrap;
145
158
  }
146
- :is(.theme-action-bar, .theme-action-bar-drawn) > .button {
159
+ .theme-action-bar-row > .button {
147
160
  flex: 1 1 0;
148
161
  }
149
- :is(.theme-action-bar, .theme-action-bar-drawn):has(> .theme-action-bar-text) > .button {
162
+ .theme-action-bar-row:has(> .theme-action-bar-text) > .button {
150
163
  flex: 1 0 auto;
151
164
  }
152
165
  .theme-action-bar-text {
@@ -157,12 +170,15 @@
157
170
  flex: 1000 1 0;
158
171
  min-width: 8rem;
159
172
  }
160
- /* The editor's drawing of one (packages/editor/defaults/popup.tsx): in its place, as wide as a phone, in a frame. */
161
- .theme-action-bar-drawn {
173
+ /* The editor's drawing of one (defaults/popup.tsx): the same sheet, in its place in the editor's frame
174
+ instead of fixed at the bottom of the screen, as wide as a phone, with the sheet's own corners and shadow. It is a
175
+ wide="sheet" there (the editor is wider than the medium width), the selector names data-wide to win over that. */
176
+ .bottom-sheet-standard.theme-action-bar-drawn[data-wide] {
177
+ position: static;
178
+ width: 100%;
162
179
  max-width: 24rem;
180
+ max-height: none;
163
181
  margin-inline: auto;
164
- border: 1px solid var(--block-line);
165
- border-radius: var(--block-radius);
166
182
  }
167
183
  }
168
184
 
@@ -928,12 +928,6 @@
928
928
  }
929
929
  }
930
930
 
931
- /* The editor's drawing of the bar at the bottom of a phone's screen (components.css): without a frame, rounded. */
932
- .theme-action-bar-drawn {
933
- border: 0;
934
- border-radius: var(--md-shape-medium);
935
- }
936
-
937
931
  /* The carousel's page indicator (its variables above): the dots half seen through, the chosen one wholly. */
938
932
  .carousel-indicator-dot > span {
939
933
  opacity: 0.5;
@@ -1121,7 +1115,7 @@
1121
1115
  display: none;
1122
1116
  }
1123
1117
  }
1124
- :is(.theme-action-bar, .theme-action-bar-drawn) > .button-filled {
1118
+ .theme-action-bar-row > .button-filled {
1125
1119
  order: 2;
1126
1120
  }
1127
1121
 
@@ -42,6 +42,8 @@
42
42
  // them (bottomSheetFullClass: as high as the screen, a whole page in it), with the container and its header
43
43
  // (BottomSheetHeader, whose handle is a button there, `onHandle`, named `handleLabel`, and whose close button does
44
44
  // `onClose`).
45
+ // Other attributes (data-…, as the action bar's data-action-bar) go on the container, as Toolbar's do on it, and `className`
46
+ // after its own classes.
45
47
  // How it looks is the theme's (--bottom-sheet-*).
46
48
  import { useEffect, useId, useRef, type ReactNode } from 'react';
47
49
  import { useOnSite } from '@morphemecms/cms/src/standard/view';
@@ -183,6 +185,7 @@ export default function BottomSheet({
183
185
  height,
184
186
  className,
185
187
  children,
188
+ ...rest
186
189
  }: {
187
190
  state?: BottomSheetState;
188
191
  states?: [BottomSheetState, ...BottomSheetState[]];
@@ -195,7 +198,8 @@ export default function BottomSheet({
195
198
  height?: string;
196
199
  className?: string;
197
200
  children: ReactNode;
198
- } & BottomSheetKindWide &
201
+ } & Record<`data-${string}`, unknown> &
202
+ BottomSheetKindWide &
199
203
  BottomSheetName &
200
204
  BottomSheetResize) {
201
205
  const shownState = state ?? startState(states);
@@ -223,6 +227,7 @@ export default function BottomSheet({
223
227
  data-state={standard ? shownState : undefined}
224
228
  data-states={standard && states ? states.join(' ') : undefined}
225
229
  style={height ? { height } : undefined}
230
+ {...rest}
226
231
  >
227
232
  <BottomSheetHeader
228
233
  headline={headline}