@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 +11 -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/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/package.json +1 -1
- 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 +15 -11
- package/src/SearchPreview.tsx +75 -0
- package/src/editor.css +19 -1
- 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/en-US/forms.ts +9 -1
- package/src/i18n/en-US/parts.ts +1 -1
- 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
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
|
package/defaults/popup.tsx
CHANGED
|
@@ -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):
|
|
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
|
-
<
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
<
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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: '
|
|
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
|
+
});
|
package/lib/heading-levels.d.mts
CHANGED
package/lib/heading-levels.mjs
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
-
|
|
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
|
@@ -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 {
|
|
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
|
|
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
|
-
//
|
|
599
|
-
off:
|
|
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
|
-
//
|
|
861
|
-
const [
|
|
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
|
|
888
|
-
|
|
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
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
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
|
+
}
|
package/src/i18n/da-DK/forms.ts
CHANGED
|
@@ -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',
|
package/src/i18n/en-US/forms.ts
CHANGED
|
@@ -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: '
|
|
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: {
|
package/src/i18n/en-US/parts.ts
CHANGED
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
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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.
|
|
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.
|
|
9
|
-
// (
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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
|
-
<
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
)}
|
package/theme/layouts/Base.astro
CHANGED
|
@@ -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
|
-
|
|
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
|
|
113
|
-
(.
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
/*
|
|
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
|
-
|
|
156
|
+
.theme-action-bar-row {
|
|
144
157
|
flex-wrap: wrap;
|
|
145
158
|
}
|
|
146
|
-
|
|
159
|
+
.theme-action-bar-row > .button {
|
|
147
160
|
flex: 1 1 0;
|
|
148
161
|
}
|
|
149
|
-
|
|
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 (
|
|
161
|
-
|
|
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
|
|
package/theme/styles/global.css
CHANGED
|
@@ -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
|
-
|
|
1118
|
+
.theme-action-bar-row > .button-filled {
|
|
1125
1119
|
order: 2;
|
|
1126
1120
|
}
|
|
1127
1121
|
|
package/theme/ui/BottomSheet.tsx
CHANGED
|
@@ -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
|
-
} &
|
|
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}
|