@projektemacher/storymapjs 0.10.6 → 0.10.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/CHANGELOG +11 -0
  2. package/dist/assets/{demo-CVQ7kFek.js → demo-D6cTmzn6.js} +1 -1
  3. package/dist/assets/{harness-C-VZV-ou.js → harness-BPFlzutp.js} +1 -1
  4. package/dist/assets/main-Y-MYUSJ4.js +17 -0
  5. package/dist/css/fonts/font.oldstandard.css +18 -22
  6. package/dist/demo.html +2 -2
  7. package/dist/docs/migration.html +1 -1
  8. package/dist/embed/index.html +129 -4
  9. package/dist/harness.html +2 -2
  10. package/dist/index.html +17 -0
  11. package/dist/js/storymap.d.ts +126 -7
  12. package/dist/js/storymap.js +4518 -3613
  13. package/dist/js/storymap.js.map +1 -1
  14. package/docs/migration-from-knightlab.md +139 -0
  15. package/docs/storymap-as-iiif-manifest.md +510 -0
  16. package/package.json +6 -3
  17. package/src/animation/easings.ts +18 -0
  18. package/src/animation/morpheus.d.ts +41 -0
  19. package/src/core/Browser.ts +19 -0
  20. package/src/core/Load.ts +180 -0
  21. package/src/core/Util.ts +354 -0
  22. package/src/core/mixins.ts +171 -0
  23. package/src/dom/Dom.ts +31 -0
  24. package/src/dom/DomEvent.ts +61 -0
  25. package/src/globals.d.ts +16 -0
  26. package/src/language/Language.ts +107 -0
  27. package/src/language/locale/be.json +18 -0
  28. package/src/language/locale/bg.json +18 -0
  29. package/src/language/locale/cs.json +18 -0
  30. package/src/language/locale/de.json +20 -0
  31. package/src/language/locale/el.json +18 -0
  32. package/src/language/locale/en.json +28 -0
  33. package/src/language/locale/es.json +18 -0
  34. package/src/language/locale/et.json +18 -0
  35. package/src/language/locale/fr.json +18 -0
  36. package/src/language/locale/he.json +18 -0
  37. package/src/language/locale/hu.json +18 -0
  38. package/src/language/locale/is.json +18 -0
  39. package/src/language/locale/it.json +18 -0
  40. package/src/language/locale/jp.json +18 -0
  41. package/src/language/locale/ko.json +18 -0
  42. package/src/language/locale/nl.json +18 -0
  43. package/src/language/locale/nn.json +18 -0
  44. package/src/language/locale/no.json +18 -0
  45. package/src/language/locale/pl.json +18 -0
  46. package/src/language/locale/pt.json +18 -0
  47. package/src/language/locale/ru.json +18 -0
  48. package/src/language/locale/sk.json +18 -0
  49. package/src/language/locale/sr.json +17 -0
  50. package/src/language/locale/sv.json +18 -0
  51. package/src/language/locale/tr.json +18 -0
  52. package/src/language/locale/uk.json +18 -0
  53. package/src/language/locale/ur.json +18 -0
  54. package/src/language/locale/zh-cn.json +18 -0
  55. package/src/language/locale/zh-tw.json +18 -0
  56. package/src/main.ts +51 -0
  57. package/src/map/Map.ts +717 -0
  58. package/src/map/MapMarker.ts +149 -0
  59. package/src/map/openlayers/Map.OpenLayers.ts +1879 -0
  60. package/src/map/openlayers/MapMarker.OpenLayers.ts +176 -0
  61. package/src/map/openlayers/zoomifyTiles.ts +28 -0
  62. package/src/map/types.ts +17 -0
  63. package/src/media/EmbedUtil.ts +168 -0
  64. package/src/media/Media.ts +409 -0
  65. package/src/media/MediaType.ts +163 -0
  66. package/src/media/types/Audio.ts +87 -0
  67. package/src/media/types/Blockquote.ts +40 -0
  68. package/src/media/types/DailyMotion.ts +58 -0
  69. package/src/media/types/DocumentCloud.ts +41 -0
  70. package/src/media/types/Facebook.ts +41 -0
  71. package/src/media/types/Flickr.ts +115 -0
  72. package/src/media/types/GoogleDoc.ts +59 -0
  73. package/src/media/types/IFrame.ts +51 -0
  74. package/src/media/types/Image.ts +49 -0
  75. package/src/media/types/Juxtapose.ts +32 -0
  76. package/src/media/types/SoundCloud.ts +77 -0
  77. package/src/media/types/Text.ts +172 -0
  78. package/src/media/types/Twitter.ts +96 -0
  79. package/src/media/types/Video.ts +84 -0
  80. package/src/media/types/Vimeo.ts +65 -0
  81. package/src/media/types/Website.ts +36 -0
  82. package/src/media/types/Wikipedia.ts +124 -0
  83. package/src/media/types/YouTube.ts +192 -0
  84. package/src/scss/Typography.scss +237 -0
  85. package/src/scss/VCO.StoryMap.Dark.scss +138 -0
  86. package/src/scss/VCO.StoryMap.scss +164 -0
  87. package/src/scss/Variables.Dark.scss +56 -0
  88. package/src/scss/Variables.scss +47 -0
  89. package/src/scss/core/Mixins.scss +112 -0
  90. package/src/scss/core/Reset.scss +206 -0
  91. package/src/scss/fonts/_font.base.scss +113 -0
  92. package/src/scss/fonts/font.abril-droidsans.scss +75 -0
  93. package/src/scss/fonts/font.amatic-andika.scss +73 -0
  94. package/src/scss/fonts/font.bitter-raleway.scss +86 -0
  95. package/src/scss/fonts/font.clicker-garamond.scss +92 -0
  96. package/src/scss/fonts/font.dancing-ledger.scss +74 -0
  97. package/src/scss/fonts/font.default.scss +9 -0
  98. package/src/scss/fonts/font.fjalla-average.scss +80 -0
  99. package/src/scss/fonts/font.georgia-helvetica.scss +58 -0
  100. package/src/scss/fonts/font.knightlab.scss +97 -0
  101. package/src/scss/fonts/font.lustria-lato.scss +80 -0
  102. package/src/scss/fonts/font.medula-lato.scss +73 -0
  103. package/src/scss/fonts/font.oldstandard.scss +88 -0
  104. package/src/scss/fonts/font.opensans-gentiumbook.scss +80 -0
  105. package/src/scss/fonts/font.playfair-faunaone.scss +86 -0
  106. package/src/scss/fonts/font.playfair.scss +85 -0
  107. package/src/scss/fonts/font.pt.scss +90 -0
  108. package/src/scss/fonts/font.roboto-megrim.scss +73 -0
  109. package/src/scss/fonts/font.rufina-sintony.scss +73 -0
  110. package/src/scss/fonts/font.ubuntu.scss +78 -0
  111. package/src/scss/fonts/font.unicaone-vollkorn.scss +85 -0
  112. package/src/scss/icons/Icons.scss +239 -0
  113. package/src/scss/map/VCO.ImageMarker.scss +23 -0
  114. package/src/scss/map/VCO.Map.scss +21 -0
  115. package/src/scss/map/VCO.MapMarker.scss +147 -0
  116. package/src/scss/map/openlayers/VCO.Map.OpenLayers.scss +126 -0
  117. package/src/scss/media/VCO.Media.scss +222 -0
  118. package/src/scss/media/types/VCO.Media.Blockquote.scss +76 -0
  119. package/src/scss/media/types/VCO.Media.IFrame.scss +11 -0
  120. package/src/scss/media/types/VCO.Media.Image.scss +13 -0
  121. package/src/scss/media/types/VCO.Media.Instagram.scss +7 -0
  122. package/src/scss/media/types/VCO.Media.Profile.scss +5 -0
  123. package/src/scss/media/types/VCO.Media.Text.scss +90 -0
  124. package/src/scss/media/types/VCO.Media.Twitter.scss +87 -0
  125. package/src/scss/media/types/VCO.Media.Wikipedia.scss +64 -0
  126. package/src/scss/site/site.scss +748 -0
  127. package/src/scss/slider/VCO.Slide.scss +447 -0
  128. package/src/scss/slider/VCO.SlideNav.scss +338 -0
  129. package/src/scss/slider/VCO.StorySlider.scss +64 -0
  130. package/src/scss/snapmap/VCO.SnapMap.scss +103 -0
  131. package/src/scss/ui/VCO.Consent.scss +77 -0
  132. package/src/scss/ui/VCO.MenuBar.Button.scss +99 -0
  133. package/src/scss/ui/VCO.MenuBar.scss +20 -0
  134. package/src/scss/ui/VCO.Message.scss +104 -0
  135. package/src/site/site.ts +87 -0
  136. package/src/slider/Slide.ts +460 -0
  137. package/src/slider/SlideNav.ts +212 -0
  138. package/src/slider/StorySlider.ts +722 -0
  139. package/src/storymap/Consent.ts +146 -0
  140. package/src/storymap/StoryMap.ts +1108 -0
  141. package/src/storymap/iiif.ts +386 -0
  142. package/src/storymap/validate.ts +184 -0
  143. package/src/types.ts +297 -0
  144. package/src/ui/Draggable.ts +374 -0
  145. package/src/ui/MenuBar.ts +323 -0
  146. package/src/ui/Message.ts +118 -0
  147. package/src/ui/Swipable.ts +388 -0
  148. package/dist/assets/main-DwbXrrYn.js +0 -17
@@ -0,0 +1,386 @@
1
+ // Conversion of IIIF Presentation API 3.0 manifests into legacy StoryMapJS
2
+ // data, following the mapping proposed in docs/storymap-as-iiif-manifest.md.
3
+ //
4
+ // The converter is intentionally defensive: every property is read through
5
+ // narrowing helpers and malformed shapes are skipped, never thrown.
6
+
7
+ import type {
8
+ StorymapData,
9
+ StorymapSlide,
10
+ StorymapSlideBackground,
11
+ StorymapSlideLocation,
12
+ StorymapSlideMedia,
13
+ } from "../types";
14
+
15
+ const PRESENTATION_3_CONTEXT = "iiif.io/api/presentation/3/context.json";
16
+ const MAPCONFIG_PROFILE = "mapconfig";
17
+ const STORYMAP_PREFIX = "storymap:";
18
+
19
+ /** Keys copied verbatim from navPlace Feature properties onto the location. */
20
+ const LOCATION_PROPERTIES = [
21
+ "name",
22
+ "zoom",
23
+ "line",
24
+ "icon",
25
+ "iconSize",
26
+ "image",
27
+ "use_custom_marker",
28
+ ] as const;
29
+
30
+ function asRecord(value: unknown): Record<string, unknown> | null {
31
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
32
+ return null;
33
+ }
34
+ return value as Record<string, unknown>;
35
+ }
36
+
37
+ function asString(value: unknown): string | null {
38
+ return typeof value === "string" && value !== "" ? value : null;
39
+ }
40
+
41
+ function asNumber(value: unknown): number | null {
42
+ return typeof value === "number" && !isNaN(value) ? value : null;
43
+ }
44
+
45
+ function asBoolean(value: unknown): boolean | null {
46
+ return typeof value === "boolean" ? value : null;
47
+ }
48
+
49
+ function asStringArray(value: unknown): string[] {
50
+ if (typeof value === "string") return [value];
51
+ if (Array.isArray(value)) {
52
+ return value.filter((entry): entry is string => typeof entry === "string");
53
+ }
54
+ return [];
55
+ }
56
+
57
+ /**
58
+ * Flattens a IIIF language map (`{"none": ["text"]}`, `{"en": ["Hello"]}`) to a
59
+ * plain string. The `none` language is preferred; entries are joined with a
60
+ * space. Also accepts a plain string and a TextualBody (`{value: "..."}`),
61
+ * which the proposal allows for Canvas summaries.
62
+ */
63
+ export function flattenLanguageMap(value: unknown): string {
64
+ if (typeof value === "string") return value;
65
+ const record = asRecord(value);
66
+ if (!record) return "";
67
+ const textual = record.value;
68
+ if (typeof textual === "string") return textual;
69
+ if (Array.isArray(textual)) {
70
+ return asStringArray(textual).join(" ").trim();
71
+ }
72
+ // Language map: prefer the "none" key, fall back to every language in order.
73
+ const keys = "none" in record ? ["none"] : Object.keys(record);
74
+ const parts: string[] = [];
75
+ for (const key of keys) {
76
+ parts.push(...asStringArray(record[key]));
77
+ }
78
+ return parts.join(" ").trim();
79
+ }
80
+
81
+ /**
82
+ * Reads a StoryMap extension term from a Canvas or service object. Manifests
83
+ * use the `storymap:`-prefixed form, but JSON-LD processors may emit the bare
84
+ * term, so both are accepted. The bare `type` key is never read because it
85
+ * collides with the IIIF resource type (`"Canvas"`).
86
+ */
87
+ function readTerm(record: Record<string, unknown>, term: string): unknown {
88
+ const prefixed = record[STORYMAP_PREFIX + term];
89
+ if (prefixed !== undefined) {
90
+ return prefixed;
91
+ }
92
+ return term === "type" ? undefined : record[term];
93
+ }
94
+
95
+ /** True when `data` looks like a Presentation API 3.0 manifest. */
96
+ export function isPresentation3Manifest(data: unknown): boolean {
97
+ const record = asRecord(data);
98
+ if (!record) return false;
99
+ if (record.type === "Manifest") return true;
100
+ const context = record["@context"];
101
+ const candidates = Array.isArray(context) ? context : [context];
102
+ return candidates.some(
103
+ (entry) => typeof entry === "string" && entry.includes(PRESENTATION_3_CONTEXT),
104
+ );
105
+ }
106
+
107
+ /**
108
+ * Finds the manifest-level map configuration service (profile containing
109
+ * "mapconfig") and returns its properties, or null when absent.
110
+ */
111
+ function readMapConfig(manifest: Record<string, unknown>): Record<string, unknown> | null {
112
+ const services = Array.isArray(manifest.service) ? manifest.service : [manifest.service];
113
+ for (const service of services) {
114
+ const record = asRecord(service);
115
+ if (!record) continue;
116
+ const profile = asString(record.profile);
117
+ if (profile !== null && profile.includes(MAPCONFIG_PROFILE)) {
118
+ return record;
119
+ }
120
+ }
121
+ return null;
122
+ }
123
+
124
+ /**
125
+ * Reads the slide media URL from the canvas's painting annotation body:
126
+ * typed bodies contribute their `id`, TextualBody (HTML) content its `value`
127
+ * - the legacy format stores both in `media.url`.
128
+ */
129
+ function readPaintingBodyUrl(canvas: Record<string, unknown>): string | null {
130
+ const annotationPages = Array.isArray(canvas.items) ? canvas.items : [];
131
+ for (const page of annotationPages) {
132
+ const pageRecord = asRecord(page);
133
+ if (!pageRecord) continue;
134
+ const annotations = Array.isArray(pageRecord.items) ? pageRecord.items : [];
135
+ for (const annotation of annotations) {
136
+ const annotationRecord = asRecord(annotation);
137
+ if (!annotationRecord) continue;
138
+ const motivation = asString(annotationRecord.motivation);
139
+ if (motivation !== null && motivation !== "painting") continue;
140
+ const url = readBodyUrl(annotationRecord.body);
141
+ if (url !== null) return url;
142
+ }
143
+ }
144
+ return null;
145
+ }
146
+
147
+ function readBodyUrl(body: unknown): string | null {
148
+ if (Array.isArray(body)) {
149
+ for (const entry of body) {
150
+ const url = readBodyUrl(entry);
151
+ if (url !== null) return url;
152
+ }
153
+ return null;
154
+ }
155
+ const record = asRecord(body);
156
+ if (!record) return null;
157
+ const id = asString(record.id);
158
+ if (id !== null) return id;
159
+ const value = asString(record.value);
160
+ if (value !== null) return value;
161
+ return null;
162
+ }
163
+
164
+ /**
165
+ * Reads the slide location from a navPlace FeatureCollection: the first
166
+ * Feature's Point geometry becomes `{lat, lon}` and the Feature properties
167
+ * carry the marker data.
168
+ */
169
+ function readLocation(navPlace: unknown): StorymapSlideLocation | null {
170
+ const collection = asRecord(navPlace);
171
+ if (!collection) return null;
172
+ const features = Array.isArray(collection.features) ? collection.features : [];
173
+ for (const feature of features) {
174
+ const location = readFeatureLocation(feature);
175
+ if (location !== null) return location;
176
+ }
177
+ return null;
178
+ }
179
+
180
+ function readFeatureLocation(feature: unknown): StorymapSlideLocation | null {
181
+ const featureRecord = asRecord(feature);
182
+ if (!featureRecord) return null;
183
+ const geometry = asRecord(featureRecord.geometry);
184
+ if (!geometry || asString(geometry.type) !== "Point") return null;
185
+ const coordinates = Array.isArray(geometry.coordinates) ? geometry.coordinates : [];
186
+ const lon = asNumber(coordinates[0]);
187
+ const lat = asNumber(coordinates[1]);
188
+ if (lon === null || lat === null) return null;
189
+ const location: StorymapSlideLocation = { lat, lon };
190
+ const properties = asRecord(featureRecord.properties);
191
+ if (properties) {
192
+ const locationProps = location as Record<string, unknown>;
193
+ for (const key of LOCATION_PROPERTIES) {
194
+ const value = properties[key];
195
+ if (value === undefined || value === null || value === "") continue;
196
+ locationProps[key] = value;
197
+ }
198
+ }
199
+ return location;
200
+ }
201
+
202
+ function readBackground(value: unknown): StorymapSlideBackground | string | null {
203
+ const record = asRecord(value);
204
+ if (record) {
205
+ const background: StorymapSlideBackground = {};
206
+ const url = asString(record.url);
207
+ const color = asString(record.color);
208
+ if (url !== null) background.url = url;
209
+ if (color !== null) background.color = color;
210
+ // slide background opacity is accepted but never read by the viewer
211
+ return Object.keys(background).length > 0 ? background : null;
212
+ }
213
+ if (typeof value === "string" && value !== "") {
214
+ return value;
215
+ }
216
+ return null;
217
+ }
218
+
219
+ function canvasToSlide(canvas: unknown, manifestFeature: unknown): StorymapSlide | null {
220
+ const record = asRecord(canvas);
221
+ if (!record) return null;
222
+
223
+ const slide: StorymapSlide = {};
224
+
225
+ // text: Canvas label → headline, Canvas summary → body text
226
+ const headline = flattenLanguageMap(record.label);
227
+ const text = flattenLanguageMap(record.summary);
228
+ if (headline !== "" || text !== "") {
229
+ slide.text = {};
230
+ if (headline !== "") slide.text.headline = headline;
231
+ if (text !== "") slide.text.text = text;
232
+ }
233
+
234
+ // media: painting annotation body plus the caption/credit extension terms
235
+ const mediaUrl = readPaintingBodyUrl(record);
236
+ const caption = asString(readTerm(record, "mediaCaption"));
237
+ const credit = asString(readTerm(record, "mediaCredit"));
238
+ if (mediaUrl !== null || caption !== null || credit !== null) {
239
+ const media: StorymapSlideMedia = {};
240
+ if (mediaUrl !== null) media.url = mediaUrl;
241
+ if (caption !== null) media.caption = caption;
242
+ if (credit !== null) media.credit = credit;
243
+ slide.media = media;
244
+ }
245
+
246
+ // location: canvas navPlace, falling back to a manifest-level navPlace
247
+ // aggregated in items order (both are allowed by the proposal)
248
+ const location = readLocation(record.navPlace) ?? readFeatureLocation(manifestFeature);
249
+ if (location !== null) slide.location = location;
250
+
251
+ // StoryMap extension terms
252
+ const slideType = asString(record[STORYMAP_PREFIX + "type"]);
253
+ if (slideType !== null) slide.type = slideType;
254
+ const date = readTerm(record, "date");
255
+ const dateString = asString(date);
256
+ const dateRecord = asRecord(date);
257
+ if (dateString !== null) slide.date = dateString;
258
+ else if (dateRecord !== null) slide.date = dateRecord;
259
+ const background = readBackground(readTerm(record, "background"));
260
+ if (background !== null) slide.background = background;
261
+
262
+ return slide;
263
+ }
264
+
265
+ /**
266
+ * Converts a IIIF Presentation API 3.0 manifest into legacy StoryMapJS data
267
+ * (the `storymap` object). Malformed or missing pieces are skipped; the
268
+ * result always contains at least `{slides: []}`.
269
+ */
270
+ export function manifestToStorymapData(manifest: unknown): StorymapData {
271
+ const data: StorymapData = { slides: [] };
272
+ const record = asRecord(manifest);
273
+ if (!record) return data;
274
+
275
+ const title = flattenLanguageMap(record.label);
276
+ if (title !== "") data.title = title;
277
+
278
+ // Manifest-level map configuration service → storymap options fields.
279
+ const config = readMapConfig(record);
280
+ if (config) {
281
+ applyMapConfig(data, config);
282
+ }
283
+
284
+ // requiredStatement → iiif.attribution
285
+ const requiredStatement = asRecord(record.requiredStatement);
286
+ const attribution = requiredStatement ? flattenLanguageMap(requiredStatement.value) : "";
287
+ if (attribution !== "") {
288
+ const iiif = (data.iiif as { url?: string; attribution?: string } | undefined) ?? {};
289
+ iiif.attribution = attribution;
290
+ if (iiif.url === undefined) iiif.url = "";
291
+ data.iiif = iiif;
292
+ }
293
+
294
+ // items[] (Canvases) → slides[], in order
295
+ const items = Array.isArray(record.items) ? record.items : [];
296
+ const manifestNavPlace = asRecord(record.navPlace);
297
+ const manifestFeatures =
298
+ manifestNavPlace && Array.isArray(manifestNavPlace.features)
299
+ ? manifestNavPlace.features
300
+ : [];
301
+ for (let index = 0; index < items.length; index++) {
302
+ const slide = canvasToSlide(items[index], manifestFeatures[index]);
303
+ if (slide !== null) data.slides.push(slide);
304
+ }
305
+
306
+ return data;
307
+ }
308
+
309
+ /** Copies the mapconfig service terms onto the storymap data root (legacy keys). */
310
+ function applyMapConfig(data: StorymapData, config: Record<string, unknown>): void {
311
+ const mapType = asString(readTerm(config, "mapType"));
312
+ if (mapType !== null && mapType !== "") data.map_type = mapType;
313
+
314
+ const mapAsImage = asBoolean(readTerm(config, "mapAsImage"));
315
+ if (mapAsImage !== null) data.map_as_image = mapAsImage;
316
+
317
+ const mapAccessToken = asString(readTerm(config, "mapAccessToken"));
318
+ if (mapAccessToken !== null) data.map_access_token = mapAccessToken;
319
+
320
+ const mapBackgroundColor = asString(readTerm(config, "mapBackgroundColor"));
321
+ if (mapBackgroundColor !== null) data.map_background_color = mapBackgroundColor;
322
+
323
+ const mapCenterOffset = asRecord(readTerm(config, "mapCenterOffset"));
324
+ if (mapCenterOffset) {
325
+ const left = asNumber(mapCenterOffset.left);
326
+ const top = asNumber(mapCenterOffset.top);
327
+ if (left !== null && top !== null) data.map_center_offset = { left, top };
328
+ }
329
+
330
+ const mapSubdomains = asString(readTerm(config, "mapSubdomains"));
331
+ if (mapSubdomains !== null) data.map_subdomains = mapSubdomains;
332
+
333
+ const iiifUrl = asString(readTerm(config, "iiifUrl"));
334
+ if (iiifUrl !== null) data.iiif = { url: iiifUrl, attribution: "" };
335
+
336
+ const fontCss = asString(readTerm(config, "fontCss"));
337
+ if (fontCss !== null) data.font_css = fontCss;
338
+
339
+ const callToAction = asBoolean(readTerm(config, "callToAction"));
340
+ if (callToAction !== null) data.call_to_action = callToAction;
341
+
342
+ const callToActionText = asString(readTerm(config, "callToActionText"));
343
+ if (callToActionText !== null) data.call_to_action_text = callToActionText;
344
+
345
+ const startAtSlide = asNumber(readTerm(config, "startAtSlide"));
346
+ if (startAtSlide !== null) data.start_at_slide = startAtSlide;
347
+
348
+ const language = asString(readTerm(config, "language"));
349
+ if (language !== null) data.language = language;
350
+
351
+ const calculateZoom = asBoolean(readTerm(config, "calculateZoom"));
352
+ if (calculateZoom !== null) data.calculate_zoom = calculateZoom;
353
+
354
+ const lessBounce = asBoolean(readTerm(config, "lessBounce"));
355
+ if (lessBounce !== null) data.less_bounce = lessBounce;
356
+
357
+ const lineFollowsPath = asBoolean(readTerm(config, "lineFollowsPath"));
358
+ if (lineFollowsPath !== null) data.line_follows_path = lineFollowsPath;
359
+
360
+ const showLines = asBoolean(readTerm(config, "showLines"));
361
+ if (showLines !== null) data.show_lines = showLines;
362
+
363
+ const showHistoryLine = asBoolean(readTerm(config, "showHistoryLine"));
364
+ if (showHistoryLine !== null) data.show_history_line = showHistoryLine;
365
+
366
+ const lineColor = asString(readTerm(config, "lineColor"));
367
+ if (lineColor !== null) data.line_color = lineColor;
368
+
369
+ const lineColorInactive = asString(readTerm(config, "lineColorInactive"));
370
+ if (lineColorInactive !== null) data.line_color_inactive = lineColorInactive;
371
+
372
+ const lineWeight = asNumber(readTerm(config, "lineWeight"));
373
+ if (lineWeight !== null) data.line_weight = lineWeight;
374
+
375
+ const lineOpacity = asNumber(readTerm(config, "lineOpacity"));
376
+ if (lineOpacity !== null) data.line_opacity = lineOpacity;
377
+
378
+ const lineDash = asString(readTerm(config, "lineDash"));
379
+ if (lineDash !== null) data.line_dash = lineDash;
380
+
381
+ const lineJoin = asString(readTerm(config, "lineJoin"));
382
+ if (lineJoin !== null) data.line_join = lineJoin;
383
+
384
+ const useCustomMarkers = asBoolean(readTerm(config, "useCustomMarkers"));
385
+ if (useCustomMarkers !== null) data.use_custom_markers = useCustomMarkers;
386
+ }
@@ -0,0 +1,184 @@
1
+ // storymap schema import (json module)
2
+ import schema from "../../schema/storymap.schema.json" with { type: "json" };
3
+
4
+ /* StoryMap data validation
5
+ Validates storymap JSON against schema/storymap.schema.json and
6
+ reports every error found. Runs at load time and in the
7
+ scripts/validate-storymap.mjs CLI.
8
+ ================================================== */
9
+
10
+ export interface StorymapError {
11
+ path: string;
12
+ message: string;
13
+ }
14
+
15
+ /** Minimal structural type for a JSON Schema node. */
16
+ interface SchemaNode {
17
+ $ref?: string;
18
+ type?: string | string[];
19
+ enum?: unknown[];
20
+ minimum?: number;
21
+ maximum?: number;
22
+ minLength?: number;
23
+ maxLength?: number;
24
+ pattern?: string;
25
+ minItems?: number;
26
+ maxItems?: number;
27
+ items?: SchemaNode;
28
+ required?: string[];
29
+ properties?: Record<string, SchemaNode>;
30
+ [key: string]: unknown;
31
+ }
32
+
33
+ const SCHEMA: SchemaNode = schema;
34
+
35
+ function typeMatches(value: unknown, type: string | string[]): boolean {
36
+ const types = Array.isArray(type) ? type : [type];
37
+ return types.some((t) => {
38
+ switch (t) {
39
+ case "object":
40
+ return typeof value === "object" && value !== null && !Array.isArray(value);
41
+ case "array":
42
+ return Array.isArray(value);
43
+ case "string":
44
+ return typeof value === "string";
45
+ case "number":
46
+ return typeof value === "number" && !isNaN(value);
47
+ case "integer":
48
+ return typeof value === "number" && Number.isInteger(value);
49
+ case "boolean":
50
+ return typeof value === "boolean";
51
+ case "null":
52
+ return value === null;
53
+ case "uri":
54
+ return typeof value === "string";
55
+ default:
56
+ return true;
57
+ }
58
+ });
59
+ }
60
+
61
+ function validateAgainstSchema(
62
+ value: unknown,
63
+ schemaNode: SchemaNode,
64
+ path: string,
65
+ errors: StorymapError[],
66
+ ) {
67
+ if (!schemaNode || typeof schemaNode !== "object") return;
68
+
69
+ // $ref / $defs
70
+ if (schemaNode.$ref) {
71
+ const refPath = schemaNode.$ref
72
+ .replace(/^#\//, "")
73
+ .replaceAll("~1", "/")
74
+ .replaceAll("~0", "~");
75
+ let node: SchemaNode = SCHEMA;
76
+ for (const part of refPath.split("/")) node = node[part] as SchemaNode;
77
+ validateAgainstSchema(value, node, path, errors);
78
+ return;
79
+ }
80
+
81
+ // type
82
+ if (schemaNode.type) {
83
+ if (!typeMatches(value, schemaNode.type)) {
84
+ const expected = Array.isArray(schemaNode.type)
85
+ ? schemaNode.type.join(" or ")
86
+ : schemaNode.type;
87
+ errors.push({
88
+ path,
89
+ message: `expected ${expected}, got ${value === null ? "null" : typeof value}`,
90
+ });
91
+ return;
92
+ }
93
+ }
94
+
95
+ // enum
96
+ if (schemaNode.enum && !schemaNode.enum.some((v) => v === value)) {
97
+ errors.push({
98
+ path,
99
+ message: `must be one of ${schemaNode.enum.map((v) => JSON.stringify(v)).join(", ")}`,
100
+ });
101
+ }
102
+
103
+ // numeric constraints
104
+ if (typeof value === "number") {
105
+ if (schemaNode.minimum !== undefined && value < schemaNode.minimum) {
106
+ errors.push({ path, message: `must be >= ${schemaNode.minimum}` });
107
+ }
108
+ if (schemaNode.maximum !== undefined && value > schemaNode.maximum) {
109
+ errors.push({ path, message: `must be <= ${schemaNode.maximum}` });
110
+ }
111
+ }
112
+
113
+ // string constraints
114
+ if (typeof value === "string") {
115
+ if (schemaNode.minLength !== undefined && value.length < schemaNode.minLength) {
116
+ errors.push({ path, message: `must be at least ${schemaNode.minLength} characters` });
117
+ }
118
+ if (schemaNode.pattern !== undefined && !new RegExp(schemaNode.pattern).test(value)) {
119
+ errors.push({ path, message: `must match ${schemaNode.pattern}` });
120
+ }
121
+ }
122
+
123
+ // array constraints
124
+ if (Array.isArray(value)) {
125
+ if (schemaNode.minItems !== undefined && value.length < schemaNode.minItems) {
126
+ errors.push({ path, message: `must have at least ${schemaNode.minItems} items` });
127
+ }
128
+ if (schemaNode.maxItems !== undefined && value.length > schemaNode.maxItems) {
129
+ errors.push({ path, message: `must have at most ${schemaNode.maxItems} items` });
130
+ }
131
+ if (schemaNode.items) {
132
+ value.forEach((item, i) =>
133
+ validateAgainstSchema(item, schemaNode.items, `${path}[${i}]`, errors),
134
+ );
135
+ }
136
+ }
137
+
138
+ // object constraints
139
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
140
+ const record = value as Record<string, unknown>;
141
+ if (schemaNode.required) {
142
+ for (const key of schemaNode.required) {
143
+ if (!(key in record)) {
144
+ errors.push({ path, message: `missing required property "${key}"` });
145
+ }
146
+ }
147
+ }
148
+ if (schemaNode.properties) {
149
+ for (const [key, propSchema] of Object.entries(schemaNode.properties)) {
150
+ if (key in record) {
151
+ validateAgainstSchema(record[key], propSchema, `${path}.${key}`, errors);
152
+ }
153
+ }
154
+ }
155
+ }
156
+ }
157
+
158
+ /** Validate storymap JSON (the object containing a "storymap" property). */
159
+ export function validateStorymap(data: unknown): StorymapError[] {
160
+ const errors: StorymapError[] = [];
161
+ if (data === null || typeof data !== "object" || Array.isArray(data)) {
162
+ return [{ path: "", message: "storymap data must be a JSON object" }];
163
+ }
164
+ if (!("storymap" in data)) {
165
+ return [{ path: "", message: 'missing required property "storymap"' }];
166
+ }
167
+ validateAgainstSchema(data, SCHEMA, "", errors);
168
+ return errors;
169
+ }
170
+
171
+ /** Validate and report all errors to the JavaScript console. Returns true when valid. */
172
+ export function validateStorymapAndReport(data: unknown, source?: string): boolean {
173
+ const errors = validateStorymap(data);
174
+ if (errors.length === 0) {
175
+ return true;
176
+ }
177
+ console.error(
178
+ `StoryMapJS: invalid storymap data${source ? ` (${source})` : ""} - ${errors.length} error${errors.length > 1 ? "s" : ""} found:`,
179
+ );
180
+ for (const err of errors) {
181
+ console.error(` ${err.path || "(root)"}: ${err.message}`);
182
+ }
183
+ return false;
184
+ }