scavold 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +73 -1
- package/COMPONENTS.md +270 -0
- package/components/ScavoldCard.vue +51 -0
- package/components/ScavoldDetails.vue +23 -0
- package/components/ScavoldGrid.vue +53 -0
- package/components/ScavoldImage.vue +13 -4
- package/components/ScavoldMenuItems.vue +3 -1
- package/composables/hierarchy.ts +4 -1
- package/composables/useCard.js +45 -0
- package/composables/useDetails.js +43 -0
- package/composables/useGrid.js +40 -0
- package/composables/useImageSizes.js +42 -0
- package/icons/float-left.svg +1 -0
- package/icons/float-right.svg +1 -0
- package/icons/split-left.svg +1 -0
- package/icons/split-right.svg +1 -0
- package/icons/stacked.svg +1 -0
- package/l10n/de.json +6 -0
- package/l10n/en.json +6 -0
- package/lib/config.js +15 -22
- package/lib/containers.js +6 -0
- package/lib/figures.js +99 -0
- package/lib/icons.js +197 -0
- package/lib/index.d.ts +11 -1
- package/lib/index.js +10 -1
- package/lib/sectionManifest.js +488 -74
- package/package.json +4 -1
- package/scripts/build-icons.js +27 -0
- package/scripts/check-fixture.js +119 -4
- package/styles/layouts.css +64 -0
package/lib/sectionManifest.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { mkdir, writeFile, readFile, readdir } from "node:fs/promises";
|
|
4
|
+
import { GRID_COLUMNS, SECTIONING_ELEMENTS } from "./containers.js";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Canonical URL of the section-type grammar this manifest conforms to. The
|
|
@@ -15,188 +16,479 @@ export const SECTION_SPEC_VERSION = 0;
|
|
|
15
16
|
export const SECTION_MANIFEST_DIR = ".cratly";
|
|
16
17
|
export const SECTION_MANIFEST_FILE = "sections.json";
|
|
17
18
|
|
|
19
|
+
/**
|
|
20
|
+
* The one folder a site keeps the icons of its section controls in, one SVG file
|
|
21
|
+
* each, named by the file's base name. See ADR 0010 on cratly.io for why there is
|
|
22
|
+
* no other place an icon can come from.
|
|
23
|
+
*/
|
|
24
|
+
export const ICON_DIR = "icons";
|
|
25
|
+
|
|
26
|
+
/** Names an icon may have: a base name, never a path. Mirrors the grammar's `iconName`. */
|
|
27
|
+
const ICON_NAME = /^[a-z0-9][a-z0-9-]*$/;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A text for the editor, in English as the fallback and in German. Further languages
|
|
31
|
+
* are further keys; see "Localized texts" on https://cratly.io/reference/section-types.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} en
|
|
34
|
+
* @param {string} de
|
|
35
|
+
* @returns {{"*": string, de: string}}
|
|
36
|
+
*/
|
|
37
|
+
function text( en, de ) {
|
|
38
|
+
return { "*": en, de };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The choice of one value among several, each with a label.
|
|
43
|
+
*
|
|
44
|
+
* @param {...[string, string, string]} entries value, English and German label of each
|
|
45
|
+
* @returns {Array<{value: string, label: object}>}
|
|
46
|
+
*/
|
|
47
|
+
function choices( ...entries ) {
|
|
48
|
+
return entries.map( ( [ value, en, de ] ) => ( { value, label: text( en, de ) } ) );
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Pages explaining the section kinds to authors, in English and German, linked by the
|
|
53
|
+
* editor beside their hints. Both carry the same anchor per kind; header, footer, main
|
|
54
|
+
* and nav share one.
|
|
55
|
+
*/
|
|
56
|
+
const AUTHOR_DOCS = {
|
|
57
|
+
"*": "https://scavold.io/authors/sections.html",
|
|
58
|
+
de: "https://scavold.io/de/authors/sections.html",
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @param {string} anchor id of the kind's heading on the pages for authors
|
|
63
|
+
* @returns {{"*": string, de: string}}
|
|
64
|
+
*/
|
|
65
|
+
function docs( anchor ) {
|
|
66
|
+
return Object.fromEntries( Object.entries( AUTHOR_DOCS ).map( ( [ locale, page ] ) => [ locale, `${page}#${anchor}` ] ) );
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Names of the sectioning wrappers in the editor. */
|
|
70
|
+
const SECTIONING_LABELS = {
|
|
71
|
+
article: text( "Article", "Artikel" ),
|
|
72
|
+
aside: text( "Aside", "Randbereich" ),
|
|
73
|
+
footer: text( "Footer", "Fußbereich" ),
|
|
74
|
+
header: text( "Header", "Kopfbereich" ),
|
|
75
|
+
main: text( "Main", "Hauptinhalt" ),
|
|
76
|
+
nav: text( "Nav", "Navigation" ),
|
|
77
|
+
section: text( "Section", "Abschnitt" ),
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** What each sectioning wrapper is for, in the editor's type picker. */
|
|
81
|
+
const SECTIONING_HINTS = {
|
|
82
|
+
article: text(
|
|
83
|
+
"A piece that stands on its own — a post, a news item — and would make sense elsewhere too.",
|
|
84
|
+
"Ein Beitrag, der für sich steht – ein Artikel, eine Meldung – und auch anderswo verständlich wäre.",
|
|
85
|
+
),
|
|
86
|
+
aside: text(
|
|
87
|
+
"Content beside the main text — a note, a box of related links — that it could do without.",
|
|
88
|
+
"Inhalt neben dem eigentlichen Text – ein Hinweis, eine Box mit Links –, auf den er auch verzichten könnte.",
|
|
89
|
+
),
|
|
90
|
+
footer: text(
|
|
91
|
+
"Closing information of the page or of the section around it, like an author or related links.",
|
|
92
|
+
"Abschließende Angaben zur Seite oder zum umgebenden Abschnitt, etwa Autor oder weiterführende Links.",
|
|
93
|
+
),
|
|
94
|
+
header: text(
|
|
95
|
+
"Introductory content of the page or of the section around it, like a title with a lead.",
|
|
96
|
+
"Einleitender Inhalt der Seite oder des umgebenden Abschnitts, etwa Titel mit Vorspann.",
|
|
97
|
+
),
|
|
98
|
+
main: text(
|
|
99
|
+
"The page's main content. A page has one at most, and the site's design usually provides it already.",
|
|
100
|
+
"Der Hauptinhalt der Seite. Eine Seite hat höchstens einen, und meist stellt ihn die Gestaltung schon bereit.",
|
|
101
|
+
),
|
|
102
|
+
nav: text(
|
|
103
|
+
"A group of links for finding one's way, like a table of contents.",
|
|
104
|
+
"Eine Gruppe von Links zur Orientierung, etwa ein Inhaltsverzeichnis.",
|
|
105
|
+
),
|
|
106
|
+
section: text(
|
|
107
|
+
"A part of the page with a topic of its own, usually opened by a heading.",
|
|
108
|
+
"Ein Teil der Seite mit eigenem Thema, meist mit einer Überschrift am Anfang.",
|
|
109
|
+
),
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Sectioning wrappers offering a layout of their first block — an image, usually —
|
|
114
|
+
* and the text after it. The variants are implemented in styles/layouts.css.
|
|
115
|
+
*/
|
|
116
|
+
const LAYOUT_SECTIONS = new Set( [ "section", "article", "aside" ] );
|
|
117
|
+
|
|
118
|
+
const LAYOUT_PROP = {
|
|
119
|
+
type: "enum",
|
|
120
|
+
label: text( "Layout", "Anordnung" ),
|
|
121
|
+
hint: text(
|
|
122
|
+
"How the first block — an image, say — and the text after it are arranged. Left unset, the block stands above the text.",
|
|
123
|
+
"Wie der erste Block – etwa ein Bild – und der Text danach angeordnet sind. Ohne Angabe steht der Block über dem Text.",
|
|
124
|
+
),
|
|
125
|
+
// shown for the choice of no layout, which stacks the block above the text
|
|
126
|
+
icon: "stacked",
|
|
127
|
+
values: [
|
|
128
|
+
{ value: "float-left", label: text( "Text flowing around it, block on the left", "Text umfließt, Block links" ), icon: "float-left" },
|
|
129
|
+
{ value: "float-right", label: text( "Text flowing around it, block on the right", "Text umfließt, Block rechts" ), icon: "float-right" },
|
|
130
|
+
{ value: "split-left", label: text( "Side by side, block on the left", "Nebeneinander, Block links" ), icon: "split-left" },
|
|
131
|
+
{ value: "split-right", label: text( "Side by side, block on the right", "Nebeneinander, Block rechts" ), icon: "split-right" },
|
|
132
|
+
],
|
|
133
|
+
};
|
|
134
|
+
|
|
18
135
|
/**
|
|
19
136
|
* Editor-facing schema for Scavold's built-in section kinds. Emitted into each
|
|
20
137
|
* website's `.cratly/sections.json` so the cratly editor can render the right
|
|
21
|
-
* controls when an author inserts or edits a section.
|
|
138
|
+
* controls when an author inserts or edits a section. Every text comes in English
|
|
139
|
+
* and German; a test keeps both complete.
|
|
22
140
|
*
|
|
23
141
|
* Keep the `video` props in sync with `videoProps` in
|
|
24
142
|
* composables/useVideo.js — that composable defines the runtime (data-*) props;
|
|
25
143
|
* this object describes the same options in the editor's typed vocabulary. The
|
|
26
|
-
* same goes for `cover` and `coverProps` in composables/useCover.js
|
|
144
|
+
* same goes for `cover` and `coverProps` in composables/useCover.js, for
|
|
145
|
+
* `details` and `detailsProps` in composables/useDetails.js, for `grid` and
|
|
146
|
+
* `gridProps` in composables/useGrid.js, and for `card` and `cardProps` in
|
|
147
|
+
* composables/useCard.js.
|
|
27
148
|
*/
|
|
28
149
|
export const BUILTIN_SECTIONS = {
|
|
29
|
-
// The seven sectioning wrappers are generic content containers
|
|
30
|
-
//
|
|
31
|
-
//
|
|
150
|
+
// The seven sectioning wrappers are generic content containers; authors may
|
|
151
|
+
// still add ad-hoc flags / key-value arguments, which the editor surfaces only
|
|
152
|
+
// when declared in config. Those holding running content offer a layout.
|
|
32
153
|
...Object.fromEntries(
|
|
33
154
|
[...SECTIONING_ELEMENTS].map( name => [ name, {
|
|
34
|
-
label: name
|
|
155
|
+
label: SECTIONING_LABELS[name],
|
|
156
|
+
hint: SECTIONING_HINTS[name],
|
|
157
|
+
docs: docs( LAYOUT_SECTIONS.has( name ) ? name : "landmarks" ),
|
|
158
|
+
...LAYOUT_SECTIONS.has( name ) && { props: { layout: LAYOUT_PROP } },
|
|
35
159
|
} ] ),
|
|
36
160
|
),
|
|
37
161
|
|
|
38
162
|
video: {
|
|
39
|
-
label: "Video",
|
|
40
|
-
hint: "Embedded video with playback controls.",
|
|
163
|
+
label: text( "Video", "Video" ),
|
|
164
|
+
hint: text( "Embedded video with playback controls.", "Eingebettetes Video mit Bedienelementen." ),
|
|
165
|
+
docs: docs( "video" ),
|
|
41
166
|
props: {
|
|
42
|
-
src: { type: "media-file", label: "Video file", required: true },
|
|
43
|
-
poster: { type: "media-file", label: "Poster image" },
|
|
167
|
+
src: { type: "media-file", label: text( "Video file", "Videodatei" ), required: true },
|
|
168
|
+
poster: { type: "media-file", label: text( "Poster image", "Vorschaubild" ) },
|
|
44
169
|
autoplay: {
|
|
45
170
|
type: "boolean",
|
|
46
|
-
label: "Autoplay (muted)",
|
|
47
|
-
hint:
|
|
171
|
+
label: text( "Autoplay (muted)", "Automatisch abspielen (stumm)" ),
|
|
172
|
+
hint: text(
|
|
173
|
+
"Not for visitors who asked for reduced motion. For a video across the whole window, use a cover instead.",
|
|
174
|
+
"Nicht für Besucher, die weniger Bewegung wünschen. Für ein Video über das ganze Fenster eignet sich ein Cover.",
|
|
175
|
+
),
|
|
48
176
|
default: false,
|
|
49
177
|
},
|
|
50
|
-
loop: { type: "boolean", label: "Loop" },
|
|
51
|
-
muted: { type: "boolean", label: "Muted" },
|
|
178
|
+
loop: { type: "boolean", label: text( "Loop", "Endlos wiederholen" ) },
|
|
179
|
+
muted: { type: "boolean", label: text( "Muted", "Stumm" ) },
|
|
52
180
|
preload: {
|
|
53
181
|
type: "enum",
|
|
54
|
-
label: "Preload",
|
|
55
|
-
values:
|
|
182
|
+
label: text( "Preload", "Vorab laden" ),
|
|
183
|
+
values: choices(
|
|
184
|
+
[ "none", "Nothing", "Nichts" ],
|
|
185
|
+
[ "metadata", "Metadata only", "Nur Metadaten" ],
|
|
186
|
+
[ "auto", "Whole video", "Ganzes Video" ],
|
|
187
|
+
),
|
|
56
188
|
default: "metadata",
|
|
57
189
|
},
|
|
58
190
|
label: {
|
|
59
191
|
type: "text",
|
|
60
|
-
label: "Accessible label",
|
|
61
|
-
hint:
|
|
192
|
+
label: text( "Accessible label", "Zugängliche Bezeichnung" ),
|
|
193
|
+
hint: text(
|
|
194
|
+
"Announced by screen readers when the surrounding text does not describe the video.",
|
|
195
|
+
"Wird von Screenreadern angesagt, wenn der umgebende Text das Video nicht beschreibt.",
|
|
196
|
+
),
|
|
62
197
|
},
|
|
63
198
|
},
|
|
64
199
|
},
|
|
65
200
|
|
|
66
201
|
cover: {
|
|
67
|
-
label: "Cover",
|
|
68
|
-
hint:
|
|
202
|
+
label: text( "Cover", "Cover" ),
|
|
203
|
+
hint: text(
|
|
204
|
+
"An image or a video across the full width and height of the window. Anything written inside the section is laid on top of it.",
|
|
205
|
+
"Ein Bild oder Video über die volle Breite und Höhe des Fensters. Was im Abschnitt steht, liegt darüber.",
|
|
206
|
+
),
|
|
207
|
+
docs: docs( "cover" ),
|
|
69
208
|
props: {
|
|
70
209
|
src: {
|
|
71
210
|
type: "media-file",
|
|
72
|
-
label: "Image or video",
|
|
73
|
-
hint:
|
|
211
|
+
label: text( "Image or video", "Bild oder Video" ),
|
|
212
|
+
hint: text(
|
|
213
|
+
"A video plays muted in a loop, with a button to pause it.",
|
|
214
|
+
"Ein Video läuft stumm in Schleife, mit einem Knopf zum Anhalten.",
|
|
215
|
+
),
|
|
74
216
|
required: true,
|
|
75
217
|
},
|
|
76
218
|
poster: {
|
|
77
219
|
type: "media-file",
|
|
78
|
-
label: "Still image",
|
|
79
|
-
hint:
|
|
220
|
+
label: text( "Still image", "Standbild" ),
|
|
221
|
+
hint: text(
|
|
222
|
+
"Only for a video: shown until it plays, and instead of it for visitors who asked for reduced motion.",
|
|
223
|
+
"Nur bei einem Video: zu sehen, bis es läuft, und an seiner Stelle für Besucher, die weniger Bewegung wünschen.",
|
|
224
|
+
),
|
|
80
225
|
},
|
|
81
226
|
focus: {
|
|
82
227
|
type: "text",
|
|
83
|
-
label: "Focal point",
|
|
84
|
-
hint:
|
|
228
|
+
label: text( "Focal point", "Fokuspunkt" ),
|
|
229
|
+
hint: text(
|
|
230
|
+
"Part of the picture that stays visible when the window crops it, e.g. \"center top\" or \"30% 60%\".",
|
|
231
|
+
"Bildteil, der sichtbar bleibt, wenn das Fenster beschneidet, z. B. \"center top\" oder \"30% 60%\".",
|
|
232
|
+
),
|
|
85
233
|
},
|
|
86
234
|
label: {
|
|
87
235
|
type: "text",
|
|
88
|
-
label: "Description",
|
|
89
|
-
hint:
|
|
236
|
+
label: text( "Description", "Beschreibung" ),
|
|
237
|
+
hint: text(
|
|
238
|
+
"Alternative text of the image, or what the video shows. Leave empty when it is decoration only.",
|
|
239
|
+
"Alternativtext des Bildes oder was das Video zeigt. Leer lassen, wenn es nur Dekoration ist.",
|
|
240
|
+
),
|
|
90
241
|
},
|
|
91
242
|
tone: {
|
|
92
243
|
type: "enum",
|
|
93
|
-
label: "Content on top",
|
|
94
|
-
values:
|
|
95
|
-
|
|
244
|
+
label: text( "Content on top", "Inhalt darüber" ),
|
|
245
|
+
values: choices(
|
|
246
|
+
[ "light", "Light, over a dark picture", "Hell, über dunklem Bild" ],
|
|
247
|
+
[ "dark", "Dark, over a bright picture", "Dunkel, über hellem Bild" ],
|
|
248
|
+
),
|
|
249
|
+
hint: text( "Left unset, the site's design decides.", "Ohne Angabe entscheidet die Gestaltung der Website." ),
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
|
|
254
|
+
card: {
|
|
255
|
+
label: text( "Card", "Karte" ),
|
|
256
|
+
hint: text(
|
|
257
|
+
"A unit of its own — an image, a heading and a few lines, say — set apart from the text around it. Cards side by side go into a grid.",
|
|
258
|
+
"Eine Einheit für sich – etwa Bild, Überschrift und ein paar Zeilen –, abgesetzt vom Text drumherum. Karten nebeneinander kommen in ein Raster.",
|
|
259
|
+
),
|
|
260
|
+
docs: docs( "card" ),
|
|
261
|
+
props: {
|
|
262
|
+
link: {
|
|
263
|
+
type: "page-ref",
|
|
264
|
+
label: text( "Links to", "Verlinkt auf" ),
|
|
265
|
+
hint: text(
|
|
266
|
+
"Makes the whole card a link to this page, with a \"Read more\" naming it for screen readers.",
|
|
267
|
+
"Macht die ganze Karte zum Link auf diese Seite, mit einem „Weiterlesen“, das sie für Screenreader benennt.",
|
|
268
|
+
),
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
|
|
273
|
+
details: {
|
|
274
|
+
label: text( "Details", "Ausklappbar" ),
|
|
275
|
+
hint: text(
|
|
276
|
+
"Content that is collapsed under a title until the reader expands it — for answers in a FAQ, say, or information only some readers need.",
|
|
277
|
+
"Inhalt, der unter einem Titel zugeklappt ist, bis man ihn aufklappt – etwa Antworten in einer FAQ oder Hinweise, die nur manche brauchen.",
|
|
278
|
+
),
|
|
279
|
+
docs: docs( "details" ),
|
|
280
|
+
props: {
|
|
281
|
+
summary: {
|
|
282
|
+
type: "text",
|
|
283
|
+
label: text( "Title", "Titel" ),
|
|
284
|
+
hint: text(
|
|
285
|
+
"Always visible; clicking it expands or collapses the content.",
|
|
286
|
+
"Immer sichtbar; ein Klick darauf klappt den Inhalt auf oder zu.",
|
|
287
|
+
),
|
|
288
|
+
required: true,
|
|
289
|
+
},
|
|
290
|
+
open: { type: "boolean", label: text( "Expanded at first", "Anfangs aufgeklappt" ) },
|
|
291
|
+
group: {
|
|
292
|
+
type: "text",
|
|
293
|
+
label: text( "Group", "Gruppe" ),
|
|
294
|
+
hint: text(
|
|
295
|
+
"Blocks with the same group name close each other, so only one of them is expanded at a time.",
|
|
296
|
+
"Blöcke mit demselben Gruppennamen schließen einander, sodass immer nur einer aufgeklappt ist.",
|
|
297
|
+
),
|
|
298
|
+
},
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
|
|
302
|
+
grid: {
|
|
303
|
+
label: text( "Grid", "Raster" ),
|
|
304
|
+
hint: text(
|
|
305
|
+
"Puts the blocks written inside side by side in columns, starting a new row when a row is full — a paragraph, an image or an embedded section each fill one cell. On narrow screens the grid drops columns by itself.",
|
|
306
|
+
"Stellt die Blöcke darin in Spalten nebeneinander und beginnt eine neue Zeile, wenn eine voll ist – ein Absatz, ein Bild oder ein eingebetteter Abschnitt füllt je eine Zelle. Auf schmalen Bildschirmen werden es von selbst weniger Spalten.",
|
|
307
|
+
),
|
|
308
|
+
docs: docs( "grid" ),
|
|
309
|
+
props: {
|
|
310
|
+
columns: {
|
|
311
|
+
type: "enum",
|
|
312
|
+
label: text( "Columns", "Spalten" ),
|
|
313
|
+
values: GRID_COLUMNS.map( String ),
|
|
314
|
+
default: String( GRID_COLUMNS[0] ),
|
|
315
|
+
hint: text(
|
|
316
|
+
"Most cells side by side, where the screen is wide enough.",
|
|
317
|
+
"Höchstens so viele Zellen nebeneinander, wo der Bildschirm breit genug ist.",
|
|
318
|
+
),
|
|
96
319
|
},
|
|
97
320
|
},
|
|
98
321
|
},
|
|
99
322
|
|
|
100
323
|
pagelist: {
|
|
101
|
-
label: "Page list",
|
|
102
|
-
hint:
|
|
324
|
+
label: text( "Page list", "Seitenliste" ),
|
|
325
|
+
hint: text(
|
|
326
|
+
"Links a configurable number of the site's own pages — the latest posts of a blog, for example. Anything written inside the section is rendered above the list.",
|
|
327
|
+
"Verlinkt eine einstellbare Zahl eigener Seiten – etwa die neuesten Beiträge eines Blogs. Was im Abschnitt steht, erscheint über der Liste.",
|
|
328
|
+
),
|
|
329
|
+
docs: docs( "pagelist" ),
|
|
103
330
|
props: {
|
|
104
331
|
from: {
|
|
105
332
|
type: "number",
|
|
106
|
-
label: "Pages to list",
|
|
107
|
-
hint:
|
|
333
|
+
label: text( "Pages to list", "Welche Seiten" ),
|
|
334
|
+
hint: text(
|
|
335
|
+
"Relative to this page: 1 = its sub-pages (the usual choice), 0 = its siblings.",
|
|
336
|
+
"Ausgehend von dieser Seite: 1 = ihre Unterseiten (der übliche Fall), 0 = ihre Nachbarseiten.",
|
|
337
|
+
),
|
|
108
338
|
default: 1,
|
|
109
339
|
},
|
|
110
340
|
"from-root": {
|
|
111
341
|
type: "number",
|
|
112
|
-
label: "Level from the site root",
|
|
113
|
-
hint:
|
|
342
|
+
label: text( "Level from the site root", "Ebene ab Website-Start" ),
|
|
343
|
+
hint: text(
|
|
344
|
+
"Alternative to the relative choice: 1 = top-level pages, 2 = second level. Takes precedence over it.",
|
|
345
|
+
"Statt der relativen Wahl: 1 = oberste Ebene, 2 = zweite Ebene. Hat Vorrang davor.",
|
|
346
|
+
),
|
|
114
347
|
},
|
|
115
348
|
"from-path": {
|
|
116
349
|
type: "page-ref",
|
|
117
|
-
label: "Pages below this page",
|
|
118
|
-
hint:
|
|
350
|
+
label: text( "Pages below this page", "Unterseiten dieser Seite" ),
|
|
351
|
+
hint: text(
|
|
352
|
+
"Lists the sub-pages of the page you pick, wherever this section sits. Takes precedence over both other choices.",
|
|
353
|
+
"Listet die Unterseiten der gewählten Seite, egal wo dieser Abschnitt steht. Hat Vorrang vor beiden anderen Angaben.",
|
|
354
|
+
),
|
|
119
355
|
},
|
|
120
356
|
deep: {
|
|
121
357
|
type: "boolean",
|
|
122
|
-
label: "Include deeper levels",
|
|
123
|
-
hint:
|
|
358
|
+
label: text( "Include deeper levels", "Tiefere Ebenen einbeziehen" ),
|
|
359
|
+
hint: text(
|
|
360
|
+
"Also lists pages further down, flattened into one list — for posts filed in year or month folders.",
|
|
361
|
+
"Listet auch Seiten weiter unten, in einer flachen Liste – für Beiträge in Jahres- oder Monatsordnern.",
|
|
362
|
+
),
|
|
124
363
|
},
|
|
125
364
|
limit: {
|
|
126
365
|
type: "number",
|
|
127
|
-
label: "Number of entries",
|
|
128
|
-
hint:
|
|
366
|
+
label: text( "Number of entries", "Anzahl Einträge" ),
|
|
367
|
+
hint: text(
|
|
368
|
+
"0 lists every page found. Left unset on a list with entries per page, every page found is paged through.",
|
|
369
|
+
"0 listet alle gefundenen Seiten. Ohne Angabe, aber mit Einträgen pro Seite, wird durch alle geblättert.",
|
|
370
|
+
),
|
|
129
371
|
default: 5,
|
|
130
372
|
},
|
|
131
373
|
offset: {
|
|
132
374
|
type: "number",
|
|
133
|
-
label: "Skip entries",
|
|
134
|
-
hint:
|
|
375
|
+
label: text( "Skip entries", "Einträge überspringen" ),
|
|
376
|
+
hint: text(
|
|
377
|
+
"Leaves out this many entries at the start — a second list can continue where the first one ended.",
|
|
378
|
+
"Lässt so viele Einträge am Anfang weg – eine zweite Liste kann dort weitermachen, wo die erste endet.",
|
|
379
|
+
),
|
|
135
380
|
default: 0,
|
|
136
381
|
},
|
|
137
382
|
sort: {
|
|
138
383
|
type: "enum",
|
|
139
|
-
label: "Order",
|
|
140
|
-
values:
|
|
384
|
+
label: text( "Order", "Reihenfolge" ),
|
|
385
|
+
values: choices(
|
|
386
|
+
[ "order", "As in the site structure", "Wie in der Seitenstruktur" ],
|
|
387
|
+
[ "date", "Newest first", "Neueste zuerst" ],
|
|
388
|
+
[ "date-asc", "Oldest first", "Älteste zuerst" ],
|
|
389
|
+
[ "title", "By title", "Nach Titel" ],
|
|
390
|
+
),
|
|
141
391
|
default: "order",
|
|
142
|
-
hint:
|
|
392
|
+
hint: text(
|
|
393
|
+
"Ordering by date needs a date in the listed pages.",
|
|
394
|
+
"Die Sortierung nach Datum braucht ein Datum in den gelisteten Seiten.",
|
|
395
|
+
),
|
|
143
396
|
},
|
|
144
|
-
reverse: { type: "boolean", label: "Reverse the order" },
|
|
397
|
+
reverse: { type: "boolean", label: text( "Reverse the order", "Reihenfolge umkehren" ) },
|
|
145
398
|
teaser: {
|
|
146
399
|
type: "boolean",
|
|
147
|
-
label: "Show introductory texts",
|
|
148
|
-
hint:
|
|
400
|
+
label: text( "Show introductory texts", "Einleitungstexte zeigen" ),
|
|
401
|
+
hint: text(
|
|
402
|
+
"Each target page contributes its excerpt — from its own excerpt field, or its opening lines.",
|
|
403
|
+
"Jede Zielseite steuert ihren Auszug bei – aus ihrem Auszugsfeld oder ihren ersten Zeilen.",
|
|
404
|
+
),
|
|
149
405
|
},
|
|
150
406
|
images: {
|
|
151
407
|
type: "boolean",
|
|
152
|
-
label: "Show teaser images",
|
|
153
|
-
hint:
|
|
408
|
+
label: text( "Show teaser images", "Teaserbilder zeigen" ),
|
|
409
|
+
hint: text(
|
|
410
|
+
"Each target page contributes its image field, or the first image in the page.",
|
|
411
|
+
"Jede Zielseite steuert ihr Bildfeld bei oder das erste Bild der Seite.",
|
|
412
|
+
),
|
|
154
413
|
},
|
|
155
|
-
dates: { type: "boolean", label: "Show dates" },
|
|
414
|
+
dates: { type: "boolean", label: text( "Show dates", "Datum zeigen" ) },
|
|
156
415
|
"date-style": {
|
|
157
416
|
type: "enum",
|
|
158
|
-
label: "Date format",
|
|
159
|
-
values:
|
|
417
|
+
label: text( "Date format", "Datumsformat" ),
|
|
418
|
+
values: choices(
|
|
419
|
+
[ "short", "Short", "Kurz" ],
|
|
420
|
+
[ "medium", "Medium", "Mittel" ],
|
|
421
|
+
[ "long", "Long", "Lang" ],
|
|
422
|
+
[ "full", "Full, with weekday", "Voll, mit Wochentag" ],
|
|
423
|
+
[ "iso", "ISO (2020-10-20)", "ISO (2020-10-20)" ],
|
|
424
|
+
),
|
|
160
425
|
default: "long",
|
|
161
|
-
hint:
|
|
426
|
+
hint: text(
|
|
427
|
+
"ISO is the same in every language; the others follow the page's language.",
|
|
428
|
+
"ISO ist in jeder Sprache gleich; die anderen folgen der Sprache der Seite.",
|
|
429
|
+
),
|
|
162
430
|
},
|
|
163
431
|
"time-style": {
|
|
164
432
|
type: "enum",
|
|
165
|
-
label: "Time format",
|
|
166
|
-
values:
|
|
167
|
-
|
|
433
|
+
label: text( "Time format", "Uhrzeitformat" ),
|
|
434
|
+
values: choices(
|
|
435
|
+
[ "short", "Short", "Kurz" ],
|
|
436
|
+
[ "medium", "Medium", "Mittel" ],
|
|
437
|
+
[ "long", "Long", "Lang" ],
|
|
438
|
+
[ "full", "Full", "Voll" ],
|
|
439
|
+
[ "iso", "ISO (12:16)", "ISO (12:16)" ],
|
|
440
|
+
),
|
|
441
|
+
hint: text( "Left unset, entries show their date only.", "Ohne Angabe zeigen Einträge nur ihr Datum." ),
|
|
168
442
|
},
|
|
169
443
|
timezone: {
|
|
170
444
|
type: "text",
|
|
171
|
-
label: "Time zone",
|
|
172
|
-
hint:
|
|
445
|
+
label: text( "Time zone", "Zeitzone" ),
|
|
446
|
+
hint: text(
|
|
447
|
+
"Zone the dates are read in, e.g. Europe/Berlin. Defaults to UTC, which is what a date without a time of day should be shown in.",
|
|
448
|
+
"Zone, in der Datumsangaben gelesen werden, z. B. Europe/Berlin. Standard ist UTC – richtig für ein Datum ohne Uhrzeit.",
|
|
449
|
+
),
|
|
173
450
|
},
|
|
174
451
|
"heading-level": {
|
|
175
452
|
type: "number",
|
|
176
|
-
label: "Heading level of the titles",
|
|
177
|
-
hint:
|
|
453
|
+
label: text( "Heading level of the titles", "Überschriftenebene der Titel" ),
|
|
454
|
+
hint: text(
|
|
455
|
+
"0 keeps the entries a plain list of links. Use 2–6 for teaser cards, one level below the heading above the list.",
|
|
456
|
+
"0 belässt es bei einer einfachen Linkliste. 2–6 für Teaserkarten, eine Ebene unter der Überschrift über der Liste.",
|
|
457
|
+
),
|
|
178
458
|
default: 0,
|
|
179
459
|
},
|
|
180
460
|
"per-page": {
|
|
181
461
|
type: "number",
|
|
182
|
-
label: "Entries per page",
|
|
183
|
-
hint:
|
|
462
|
+
label: text( "Entries per page", "Einträge pro Seite" ),
|
|
463
|
+
hint: text(
|
|
464
|
+
"0 shows all of them; anything higher lets the reader page through the list.",
|
|
465
|
+
"0 zeigt alle; ein höherer Wert lässt durch die Liste blättern.",
|
|
466
|
+
),
|
|
184
467
|
default: 0,
|
|
185
468
|
},
|
|
186
469
|
param: {
|
|
187
470
|
type: "text",
|
|
188
|
-
label: "Name of the page parameter",
|
|
189
|
-
hint:
|
|
471
|
+
label: text( "Name of the page parameter", "Name des Seitenparameters" ),
|
|
472
|
+
hint: text(
|
|
473
|
+
"Only needed when one page carries several paginated lists, so each remembers its own page.",
|
|
474
|
+
"Nur nötig, wenn eine Seite mehrere blätterbare Listen hat, damit sich jede ihre Seite merkt.",
|
|
475
|
+
),
|
|
190
476
|
},
|
|
191
477
|
"image-sizes": {
|
|
192
478
|
type: "text",
|
|
193
|
-
label: "Image widths (sizes)",
|
|
194
|
-
hint:
|
|
479
|
+
label: text( "Image widths (sizes)", "Bildbreiten (sizes)" ),
|
|
480
|
+
hint: text(
|
|
481
|
+
"CSS sizes descriptor for the teaser images, matching the column they are shown in.",
|
|
482
|
+
"CSS-sizes-Angabe für die Teaserbilder, passend zur Spalte, in der sie stehen.",
|
|
483
|
+
),
|
|
195
484
|
},
|
|
196
485
|
label: {
|
|
197
486
|
type: "text",
|
|
198
|
-
label: "Accessible label",
|
|
199
|
-
hint:
|
|
487
|
+
label: text( "Accessible label", "Zugängliche Bezeichnung" ),
|
|
488
|
+
hint: text(
|
|
489
|
+
"Announced by screen readers to tell this list apart from other lists on the page.",
|
|
490
|
+
"Wird von Screenreadern angesagt, um diese Liste von anderen auf der Seite zu unterscheiden.",
|
|
491
|
+
),
|
|
200
492
|
},
|
|
201
493
|
},
|
|
202
494
|
},
|
|
@@ -278,6 +570,10 @@ export function buildSectionManifest( declaredContainers = {}, { adapterVersion
|
|
|
278
570
|
entry.hint = decl.hint;
|
|
279
571
|
}
|
|
280
572
|
|
|
573
|
+
if ( decl.docs != null ) {
|
|
574
|
+
entry.docs = decl.docs;
|
|
575
|
+
}
|
|
576
|
+
|
|
281
577
|
const props = propsFromDeclaration( decl );
|
|
282
578
|
if ( Object.keys( props ).length > 0 ) {
|
|
283
579
|
entry.props = { ...entry.props ?? {} , ...props };
|
|
@@ -302,6 +598,124 @@ export function buildSectionManifest( declaredContainers = {}, { adapterVersion
|
|
|
302
598
|
};
|
|
303
599
|
}
|
|
304
600
|
|
|
601
|
+
/**
|
|
602
|
+
* Lists the icons a manifest refers to, from `boolean` props and from the choices
|
|
603
|
+
* of `enum` props.
|
|
604
|
+
*
|
|
605
|
+
* @param {object} manifest manifest object from buildSectionManifest()
|
|
606
|
+
* @returns {Set<string>} referenced icon names
|
|
607
|
+
*/
|
|
608
|
+
export function iconReferencesOf( manifest ) {
|
|
609
|
+
const names = new Set();
|
|
610
|
+
|
|
611
|
+
for ( const section of Object.values( manifest?.sections ?? {} ) ) {
|
|
612
|
+
for ( const prop of Object.values( section?.props ?? {} ) ) {
|
|
613
|
+
if ( typeof prop?.icon === "string" ) {
|
|
614
|
+
names.add( prop.icon );
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
for ( const choice of Array.isArray( prop?.values ) ? prop.values : [] ) {
|
|
618
|
+
if ( typeof choice?.icon === "string" ) {
|
|
619
|
+
names.add( choice.icon );
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
return names;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Folder of the icons Scavold brings for its own section kinds, written from
|
|
630
|
+
* design/icons.svg by scripts/build-icons.js.
|
|
631
|
+
*/
|
|
632
|
+
export const BUILTIN_ICON_DIR = fileURLToPath( new URL( "../icons/", import.meta.url ) );
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* Reads the icons available to a site: Scavold's own, and those the site keeps in
|
|
636
|
+
* `{cwd}/.cratly/icons/`, which replace Scavold's of the same name. A file whose name
|
|
637
|
+
* is not a valid icon name is skipped and reported, since nothing could refer to it.
|
|
638
|
+
*
|
|
639
|
+
* @param {string} cwd project root
|
|
640
|
+
* @param {object} [options]
|
|
641
|
+
* @param {Function} [options.warn] sink for reports
|
|
642
|
+
* @param {string|null} [options.builtin] folder of Scavold's own icons, null for none
|
|
643
|
+
* @returns {Promise<Map<string,string>>} icon name → SVG markup
|
|
644
|
+
*/
|
|
645
|
+
export async function readIcons( cwd, { warn = console.warn, builtin = BUILTIN_ICON_DIR } = {} ) {
|
|
646
|
+
const own = builtin ? await readIconDir( builtin, warn ) : new Map();
|
|
647
|
+
const site = await readIconDir( join( cwd, SECTION_MANIFEST_DIR, ICON_DIR ), warn );
|
|
648
|
+
|
|
649
|
+
return new Map( [ ...own, ...site ] );
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Reads the SVG files in one folder as icons named by their base names.
|
|
654
|
+
*
|
|
655
|
+
* @param {string} dir
|
|
656
|
+
* @param {Function} warn sink for reports
|
|
657
|
+
* @returns {Promise<Map<string,string>>} icon name → SVG markup
|
|
658
|
+
*/
|
|
659
|
+
async function readIconDir( dir, warn ) {
|
|
660
|
+
let entries;
|
|
661
|
+
try {
|
|
662
|
+
entries = await readdir( dir, { withFileTypes: true } );
|
|
663
|
+
} catch {
|
|
664
|
+
return new Map();
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
const names = [];
|
|
668
|
+
|
|
669
|
+
for ( const entry of entries ) {
|
|
670
|
+
if ( !entry.isFile() || !entry.name.endsWith( ".svg" ) ) {
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
const name = entry.name.slice( 0, -4 );
|
|
675
|
+
|
|
676
|
+
if ( ICON_NAME.test( name ) ) {
|
|
677
|
+
names.push( name );
|
|
678
|
+
} else {
|
|
679
|
+
warn( "[scavold] icon %s is ignored: a name has lower-case letters, digits and dashes only",
|
|
680
|
+
join( dir, entry.name ) );
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
const markup = await Promise.all( names.map( name => readFile( join( dir, `${name}.svg` ), "utf-8" ) ) );
|
|
685
|
+
|
|
686
|
+
return new Map( names.map( ( name, index ) => [ name, markup[index].trim() ] ) );
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Embeds the icons a manifest refers to, so the editor gets them with the manifest.
|
|
691
|
+
* Icons nothing refers to stay out; a reference to an icon that does not exist is
|
|
692
|
+
* reported and left for the editor to fall back on its plain control.
|
|
693
|
+
*
|
|
694
|
+
* @param {object} manifest manifest object from buildSectionManifest(), adjusted in place
|
|
695
|
+
* @param {Map<string,string>} available icons from readIcons()
|
|
696
|
+
* @param {object} [options]
|
|
697
|
+
* @param {Function} [options.warn] sink for reports
|
|
698
|
+
* @returns {object} the manifest
|
|
699
|
+
*/
|
|
700
|
+
export function embedIcons( manifest, available, { warn = console.warn } = {} ) {
|
|
701
|
+
const icons = {};
|
|
702
|
+
|
|
703
|
+
for ( const name of [...iconReferencesOf( manifest )].sort() ) {
|
|
704
|
+
if ( available.has( name ) ) {
|
|
705
|
+
icons[name] = available.get( name );
|
|
706
|
+
} else {
|
|
707
|
+
warn( "[scavold] icon '%s' is used in .cratly.config.yaml, but there is no %s/%s/%s.svg",
|
|
708
|
+
name, SECTION_MANIFEST_DIR, ICON_DIR, name );
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
if ( Object.keys( icons ).length > 0 ) {
|
|
713
|
+
manifest.icons = icons;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
return manifest;
|
|
717
|
+
}
|
|
718
|
+
|
|
305
719
|
/**
|
|
306
720
|
* Writes a section-type manifest to `{cwd}/.cratly/sections.json`, creating the
|
|
307
721
|
* folder as needed. Skips the write when the on-disk content is already
|