@texuli/blocks 0.0.0-stage → 0.1.0-next.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.
Files changed (257) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +77 -2
  3. package/dist/code/CodeBlockView.svelte +135 -0
  4. package/dist/code/CodeBlockView.svelte.d.ts +5 -0
  5. package/dist/code/check.d.ts +15 -0
  6. package/dist/code/check.js +14 -0
  7. package/dist/code/code.css +201 -0
  8. package/dist/code/code.css.d.ts +1 -0
  9. package/dist/code/engine-entry.d.ts +10 -0
  10. package/dist/code/engine-entry.js +10 -0
  11. package/dist/code/engine.d.ts +18 -0
  12. package/dist/code/engine.js +18 -0
  13. package/dist/code/family-engine.d.ts +23 -0
  14. package/dist/code/family-engine.js +4 -0
  15. package/dist/code/family.d.ts +7 -0
  16. package/dist/code/family.js +5 -0
  17. package/dist/code/grammars.d.ts +12 -0
  18. package/dist/code/grammars.js +83 -0
  19. package/dist/code/highlight.d.ts +15 -0
  20. package/dist/code/highlight.js +53 -0
  21. package/dist/code/index.d.ts +13 -0
  22. package/dist/code/index.js +12 -0
  23. package/dist/code/labels.d.ts +27 -0
  24. package/dist/code/labels.js +24 -0
  25. package/dist/code/languages/bash.d.ts +3 -0
  26. package/dist/code/languages/bash.js +48 -0
  27. package/dist/code/languages/css.d.ts +10 -0
  28. package/dist/code/languages/css.js +67 -0
  29. package/dist/code/languages/html.d.ts +6 -0
  30. package/dist/code/languages/html.js +35 -0
  31. package/dist/code/languages/javascript.d.ts +3 -0
  32. package/dist/code/languages/javascript.js +113 -0
  33. package/dist/code/languages/json.d.ts +3 -0
  34. package/dist/code/languages/json.js +24 -0
  35. package/dist/code/languages/python.d.ts +3 -0
  36. package/dist/code/languages/python.js +35 -0
  37. package/dist/code/languages/shared.d.ts +22 -0
  38. package/dist/code/languages/shared.js +37 -0
  39. package/dist/code/languages/sql.d.ts +3 -0
  40. package/dist/code/languages/sql.js +32 -0
  41. package/dist/code/languages/yaml.d.ts +7 -0
  42. package/dist/code/languages/yaml.js +36 -0
  43. package/dist/code/renderers.d.ts +5 -0
  44. package/dist/code/renderers.js +5 -0
  45. package/dist/code/token-styles.d.ts +25 -0
  46. package/dist/code/token-styles.js +51 -0
  47. package/dist/code/tokenize-code.d.ts +17 -0
  48. package/dist/code/tokenize-code.js +34 -0
  49. package/dist/code/tokenizer.d.ts +76 -0
  50. package/dist/code/tokenizer.js +171 -0
  51. package/dist/core/catalog/entries.d.ts +10 -0
  52. package/dist/core/catalog/entries.js +589 -0
  53. package/dist/core/catalog/index.d.ts +4 -0
  54. package/dist/core/catalog/index.js +2 -0
  55. package/dist/core/catalog/types.d.ts +36 -0
  56. package/dist/core/catalog/types.js +1 -0
  57. package/dist/core/catalog/validate.d.ts +15 -0
  58. package/dist/core/catalog/validate.js +46 -0
  59. package/dist/core/formula/evaluate.d.ts +14 -0
  60. package/dist/core/formula/evaluate.js +181 -0
  61. package/dist/core/formula/index.d.ts +8 -0
  62. package/dist/core/formula/index.js +6 -0
  63. package/dist/core/formula/parse.d.ts +19 -0
  64. package/dist/core/formula/parse.js +452 -0
  65. package/dist/core/formula/types.d.ts +76 -0
  66. package/dist/core/formula/types.js +7 -0
  67. package/dist/core/index.d.ts +14 -0
  68. package/dist/core/index.js +13 -0
  69. package/dist/core/limits.d.ts +27 -0
  70. package/dist/core/limits.js +26 -0
  71. package/dist/core/safety.d.ts +30 -0
  72. package/dist/core/safety.js +110 -0
  73. package/dist/core/schema/blocks.d.ts +138 -0
  74. package/dist/core/schema/blocks.js +181 -0
  75. package/dist/core/schema/code-family.d.ts +4 -0
  76. package/dist/core/schema/code-family.js +75 -0
  77. package/dist/core/schema/line-spec.d.ts +7 -0
  78. package/dist/core/schema/line-spec.js +19 -0
  79. package/dist/core/schema/media-family.d.ts +4 -0
  80. package/dist/core/schema/media-family.js +260 -0
  81. package/dist/core/schema/parse-family.d.ts +14 -0
  82. package/dist/core/schema/parse-family.js +37 -0
  83. package/dist/core/schema/primitives.d.ts +73 -0
  84. package/dist/core/schema/primitives.js +140 -0
  85. package/dist/core/schema/text-family.d.ts +4 -0
  86. package/dist/core/schema/text-family.js +121 -0
  87. package/dist/core/schema/vocabulary.d.ts +20 -0
  88. package/dist/core/schema/vocabulary.js +33 -0
  89. package/dist/core/text.d.ts +31 -0
  90. package/dist/core/text.js +139 -0
  91. package/dist/core/untrusted.d.ts +20 -0
  92. package/dist/core/untrusted.js +377 -0
  93. package/dist/css-modules.d.ts +1 -0
  94. package/dist/css.d.ts +1 -0
  95. package/dist/data/ChartBlockView.svelte +77 -0
  96. package/dist/data/ChartBlockView.svelte.d.ts +10 -0
  97. package/dist/data/DataBlockView.svelte +109 -0
  98. package/dist/data/DataBlockView.svelte.d.ts +5 -0
  99. package/dist/data/check.d.ts +10 -0
  100. package/dist/data/check.js +9 -0
  101. package/dist/data/engine.d.ts +18 -0
  102. package/dist/data/engine.js +10 -0
  103. package/dist/data/family.d.ts +6 -0
  104. package/dist/data/family.js +4 -0
  105. package/dist/data/index.d.ts +9 -0
  106. package/dist/data/index.js +8 -0
  107. package/dist/data/labels.d.ts +8 -0
  108. package/dist/data/labels.js +8 -0
  109. package/dist/data/renderers.d.ts +2 -0
  110. package/dist/data/renderers.js +2 -0
  111. package/dist/data/schema.d.ts +84 -0
  112. package/dist/data/schema.js +385 -0
  113. package/dist/diagram/BoardDiagram.svelte +48 -0
  114. package/dist/diagram/BoardDiagram.svelte.d.ts +8 -0
  115. package/dist/diagram/DiagramFamily.svelte +46 -0
  116. package/dist/diagram/DiagramFamily.svelte.d.ts +10 -0
  117. package/dist/diagram/FlowDiagram.svelte +108 -0
  118. package/dist/diagram/FlowDiagram.svelte.d.ts +8 -0
  119. package/dist/diagram/TerminalDiagram.svelte +38 -0
  120. package/dist/diagram/TerminalDiagram.svelte.d.ts +8 -0
  121. package/dist/diagram/TreeDiagram.svelte +149 -0
  122. package/dist/diagram/TreeDiagram.svelte.d.ts +8 -0
  123. package/dist/diagram/diagram.css +287 -0
  124. package/dist/diagram/diagram.css.d.ts +1 -0
  125. package/dist/diagram/engine.d.ts +16 -0
  126. package/dist/diagram/engine.js +14 -0
  127. package/dist/diagram/family.d.ts +7 -0
  128. package/dist/diagram/family.js +5 -0
  129. package/dist/diagram/index.d.ts +10 -0
  130. package/dist/diagram/index.js +9 -0
  131. package/dist/diagram/layout.d.ts +26 -0
  132. package/dist/diagram/layout.js +165 -0
  133. package/dist/diagram/schema.d.ts +74 -0
  134. package/dist/diagram/schema.js +358 -0
  135. package/dist/diagram/tones.d.ts +6 -0
  136. package/dist/diagram/tones.js +16 -0
  137. package/dist/embed/EmbedApp.svelte +138 -0
  138. package/dist/embed/EmbedApp.svelte.d.ts +17 -0
  139. package/dist/embed/EmbedBlockView.svelte +32 -0
  140. package/dist/embed/EmbedBlockView.svelte.d.ts +5 -0
  141. package/dist/embed/LinkPreview.svelte +48 -0
  142. package/dist/embed/LinkPreview.svelte.d.ts +10 -0
  143. package/dist/embed/engine.d.ts +30 -0
  144. package/dist/embed/engine.js +17 -0
  145. package/dist/embed/family.d.ts +7 -0
  146. package/dist/embed/family.js +5 -0
  147. package/dist/embed/index.d.ts +5 -0
  148. package/dist/embed/index.js +3 -0
  149. package/dist/embed/renderers.d.ts +3 -0
  150. package/dist/embed/renderers.js +3 -0
  151. package/dist/embed/sandbox.d.ts +86 -0
  152. package/dist/embed/sandbox.js +114 -0
  153. package/dist/embed/schema.d.ts +30 -0
  154. package/dist/embed/schema.js +217 -0
  155. package/dist/index.d.ts +16 -0
  156. package/dist/index.js +15 -0
  157. package/dist/interactive/CalculatorBlock.svelte +61 -0
  158. package/dist/interactive/CalculatorBlock.svelte.d.ts +10 -0
  159. package/dist/interactive/ChecklistBlock.svelte +44 -0
  160. package/dist/interactive/ChecklistBlock.svelte.d.ts +10 -0
  161. package/dist/interactive/InteractiveBlockView.svelte +31 -0
  162. package/dist/interactive/InteractiveBlockView.svelte.d.ts +5 -0
  163. package/dist/interactive/PollBlock.svelte +111 -0
  164. package/dist/interactive/PollBlock.svelte.d.ts +10 -0
  165. package/dist/interactive/QuizBlock.svelte +75 -0
  166. package/dist/interactive/QuizBlock.svelte.d.ts +10 -0
  167. package/dist/interactive/check.d.ts +10 -0
  168. package/dist/interactive/check.js +9 -0
  169. package/dist/interactive/engine.d.ts +19 -0
  170. package/dist/interactive/engine.js +12 -0
  171. package/dist/interactive/family.d.ts +6 -0
  172. package/dist/interactive/family.js +4 -0
  173. package/dist/interactive/index.d.ts +13 -0
  174. package/dist/interactive/index.js +13 -0
  175. package/dist/interactive/interactive.css +97 -0
  176. package/dist/interactive/interactive.css.d.ts +1 -0
  177. package/dist/interactive/labels.d.ts +16 -0
  178. package/dist/interactive/labels.js +16 -0
  179. package/dist/interactive/renderers.d.ts +9 -0
  180. package/dist/interactive/renderers.js +9 -0
  181. package/dist/interactive/schema.d.ts +76 -0
  182. package/dist/interactive/schema.js +324 -0
  183. package/dist/map/MapBlockView.svelte +465 -0
  184. package/dist/map/MapBlockView.svelte.d.ts +5 -0
  185. package/dist/map/check.d.ts +11 -0
  186. package/dist/map/check.js +6 -0
  187. package/dist/map/engine.d.ts +19 -0
  188. package/dist/map/engine.js +8 -0
  189. package/dist/map/family.d.ts +7 -0
  190. package/dist/map/family.js +5 -0
  191. package/dist/map/index.d.ts +11 -0
  192. package/dist/map/index.js +10 -0
  193. package/dist/map/labels.d.ts +27 -0
  194. package/dist/map/labels.js +21 -0
  195. package/dist/map/renderers.d.ts +2 -0
  196. package/dist/map/renderers.js +2 -0
  197. package/dist/map/schema.d.ts +41 -0
  198. package/dist/map/schema.js +176 -0
  199. package/dist/map/view-model.d.ts +55 -0
  200. package/dist/map/view-model.js +168 -0
  201. package/dist/media/MediaBlockView.svelte +258 -0
  202. package/dist/media/MediaBlockView.svelte.d.ts +5 -0
  203. package/dist/media/check.d.ts +14 -0
  204. package/dist/media/check.js +13 -0
  205. package/dist/media/engine.d.ts +18 -0
  206. package/dist/media/engine.js +10 -0
  207. package/dist/media/family.d.ts +7 -0
  208. package/dist/media/family.js +5 -0
  209. package/dist/media/index.d.ts +11 -0
  210. package/dist/media/index.js +10 -0
  211. package/dist/media/labels.d.ts +7 -0
  212. package/dist/media/labels.js +12 -0
  213. package/dist/media/renderers.d.ts +5 -0
  214. package/dist/media/renderers.js +5 -0
  215. package/dist/media/resolve.d.ts +6 -0
  216. package/dist/media/resolve.js +15 -0
  217. package/dist/structure/NestedBlocks.svelte +65 -0
  218. package/dist/structure/NestedBlocks.svelte.d.ts +11 -0
  219. package/dist/structure/SafeBody.svelte +13 -0
  220. package/dist/structure/SafeBody.svelte.d.ts +7 -0
  221. package/dist/structure/StructureFamily.svelte +157 -0
  222. package/dist/structure/StructureFamily.svelte.d.ts +5 -0
  223. package/dist/structure/check.d.ts +14 -0
  224. package/dist/structure/check.js +27 -0
  225. package/dist/structure/engine.d.ts +18 -0
  226. package/dist/structure/engine.js +18 -0
  227. package/dist/structure/family.d.ts +7 -0
  228. package/dist/structure/family.js +5 -0
  229. package/dist/structure/index.d.ts +11 -0
  230. package/dist/structure/index.js +10 -0
  231. package/dist/structure/labels.d.ts +11 -0
  232. package/dist/structure/labels.js +11 -0
  233. package/dist/structure/renderers.d.ts +5 -0
  234. package/dist/structure/renderers.js +5 -0
  235. package/dist/structure/schema.d.ts +71 -0
  236. package/dist/structure/schema.js +327 -0
  237. package/dist/structure/structure.css +182 -0
  238. package/dist/structure/structure.css.d.ts +1 -0
  239. package/dist/text/RichText.svelte +169 -0
  240. package/dist/text/RichText.svelte.d.ts +13 -0
  241. package/dist/text/TextBlockView.svelte +177 -0
  242. package/dist/text/TextBlockView.svelte.d.ts +5 -0
  243. package/dist/text/check.d.ts +14 -0
  244. package/dist/text/check.js +13 -0
  245. package/dist/text/engine.d.ts +17 -0
  246. package/dist/text/engine.js +10 -0
  247. package/dist/text/family.d.ts +7 -0
  248. package/dist/text/family.js +5 -0
  249. package/dist/text/index.d.ts +12 -0
  250. package/dist/text/index.js +11 -0
  251. package/dist/text/labels.d.ts +22 -0
  252. package/dist/text/labels.js +29 -0
  253. package/dist/text/markdown.d.ts +80 -0
  254. package/dist/text/markdown.js +361 -0
  255. package/dist/text/renderers.d.ts +6 -0
  256. package/dist/text/renderers.js +6 -0
  257. package/package.json +167 -4
@@ -0,0 +1,176 @@
1
+ import { fail, failMany, ok, parseBlockBase, parseBlockId, parsePlainText, } from "../core/schema/primitives.js";
2
+ import { isBlockTone } from "../core/schema/vocabulary.js";
3
+ /**
4
+ * Map block (T-blocks-map): points on an offline, fictional basemap. A map
5
+ * block never loads tiles, geocodes or touches the network; coordinates are
6
+ * plain numbers on a simulated map. Owned validator: strict keys, finite
7
+ * numbers, unique ids, bounded sizes.
8
+ */
9
+ /** Fictional basemaps that ship with `@texuli/data/geo`. */
10
+ export const MAP_BASEMAPS = ['port-meridian'];
11
+ export const MAP_LIMITS = Object.freeze({
12
+ points: 50,
13
+ route: 20,
14
+ label: 120,
15
+ pointLabel: 80,
16
+ description: 200,
17
+ /** Web Mercator stops being finite at the poles. */
18
+ maxLatitude: 85.06,
19
+ });
20
+ const BLOCK_KEYS = new Set([
21
+ 'type',
22
+ 'id',
23
+ 'step',
24
+ 'reveal',
25
+ 'tone',
26
+ 'label',
27
+ 'title',
28
+ 'basemap',
29
+ 'points',
30
+ 'route',
31
+ ]);
32
+ const POINT_KEYS = new Set(['id', 'label', 'lng', 'lat', 'description', 'tone']);
33
+ function plainRecord(value, path, what) {
34
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
35
+ return fail(path, `Expected a ${what} object`);
36
+ }
37
+ const prototype = Object.getPrototypeOf(value);
38
+ if (prototype !== Object.prototype && prototype !== null) {
39
+ return fail(path, `${what} must be plain JSON data`);
40
+ }
41
+ return ok(value);
42
+ }
43
+ function unknownKeys(record, allowed, path) {
44
+ return Object.keys(record)
45
+ .filter((key) => !allowed.has(key))
46
+ .map((key) => ({ path: [...path, key], message: `Unknown field "${key}"` }));
47
+ }
48
+ function coordinate(value, path, limit) {
49
+ if (typeof value !== 'number' || !Number.isFinite(value))
50
+ return fail(path, 'Expected a finite number');
51
+ if (value < -limit || value > limit)
52
+ return fail(path, `Must be between -${limit} and ${limit}`);
53
+ return ok(value);
54
+ }
55
+ function parsePoint(value, path) {
56
+ const record = plainRecord(value, path, 'point');
57
+ if (!record.success)
58
+ return record;
59
+ const point = record.data;
60
+ const extra = unknownKeys(point, POINT_KEYS, path);
61
+ if (extra.length > 0)
62
+ return failMany(extra);
63
+ if (point.id === undefined)
64
+ return fail([...path, 'id'], 'point id is required');
65
+ const id = parseBlockId(point.id, [...path, 'id']);
66
+ if (!id.success)
67
+ return id;
68
+ const label = parsePlainText(point.label, [...path, 'label'], MAP_LIMITS.pointLabel);
69
+ if (!label.success)
70
+ return label;
71
+ const lng = coordinate(point.lng, [...path, 'lng'], 180);
72
+ if (!lng.success)
73
+ return lng;
74
+ const lat = coordinate(point.lat, [...path, 'lat'], MAP_LIMITS.maxLatitude);
75
+ if (!lat.success)
76
+ return lat;
77
+ const parsed = {
78
+ id: id.data,
79
+ label: label.data,
80
+ lng: lng.data,
81
+ lat: lat.data,
82
+ };
83
+ if (point.description !== undefined) {
84
+ const description = parsePlainText(point.description, [...path, 'description'], MAP_LIMITS.description);
85
+ if (!description.success)
86
+ return description;
87
+ parsed.description = description.data;
88
+ }
89
+ if (point.tone !== undefined) {
90
+ if (!isBlockTone(point.tone))
91
+ return fail([...path, 'tone'], 'Unknown tone');
92
+ parsed.tone = point.tone;
93
+ }
94
+ return ok(parsed);
95
+ }
96
+ /** Validates untrusted data as a map block. */
97
+ export function parseMapBlock(value, path = []) {
98
+ const record = plainRecord(value, path, 'block');
99
+ if (!record.success)
100
+ return record;
101
+ const block = record.data;
102
+ if (block.type !== 'map')
103
+ return fail([...path, 'type'], 'Expected a map block');
104
+ const extra = unknownKeys(block, BLOCK_KEYS, path);
105
+ if (extra.length > 0)
106
+ return failMany(extra);
107
+ const base = parseBlockBase(block, path);
108
+ if (!base.success)
109
+ return base;
110
+ const label = parsePlainText(block.label, [...path, 'label'], MAP_LIMITS.label);
111
+ if (!label.success)
112
+ return label;
113
+ const parsed = { ...base.data, type: 'map', label: label.data, points: [] };
114
+ if (block.title !== undefined) {
115
+ const title = parsePlainText(block.title, [...path, 'title'], MAP_LIMITS.label);
116
+ if (!title.success)
117
+ return title;
118
+ parsed.title = title.data;
119
+ }
120
+ if (block.basemap !== undefined) {
121
+ if (!MAP_BASEMAPS.includes(block.basemap)) {
122
+ return fail([...path, 'basemap'], `basemap must be one of: ${MAP_BASEMAPS.join(', ')}`);
123
+ }
124
+ parsed.basemap = block.basemap;
125
+ }
126
+ if (!Array.isArray(block.points) || block.points.length === 0) {
127
+ return fail([...path, 'points'], 'map needs at least one point');
128
+ }
129
+ if (block.points.length > MAP_LIMITS.points) {
130
+ return fail([...path, 'points'], `map may contain at most ${MAP_LIMITS.points} points`);
131
+ }
132
+ const ids = new Set();
133
+ const issues = [];
134
+ block.points.forEach((raw, index) => {
135
+ const point = parsePoint(raw, [...path, 'points', index]);
136
+ if (!point.success) {
137
+ issues.push(...point.issues);
138
+ return;
139
+ }
140
+ if (ids.has(point.data.id)) {
141
+ issues.push({
142
+ path: [...path, 'points', index, 'id'],
143
+ message: `Repeated point id "${point.data.id}"`,
144
+ });
145
+ return;
146
+ }
147
+ ids.add(point.data.id);
148
+ parsed.points.push(point.data);
149
+ });
150
+ if (issues.length > 0)
151
+ return failMany(issues);
152
+ if (block.route !== undefined) {
153
+ if (!Array.isArray(block.route) || block.route.length < 2) {
154
+ return fail([...path, 'route'], 'route needs at least two point ids');
155
+ }
156
+ if (block.route.length > MAP_LIMITS.route) {
157
+ return fail([...path, 'route'], `route may contain at most ${MAP_LIMITS.route} stops`);
158
+ }
159
+ const route = [];
160
+ for (let index = 0; index < block.route.length; index += 1) {
161
+ const stop = block.route[index];
162
+ if (typeof stop !== 'string' || !ids.has(stop)) {
163
+ return fail([...path, 'route', index], 'Name one of the point ids');
164
+ }
165
+ if (route[route.length - 1] === stop) {
166
+ return fail([...path, 'route', index], 'A stop cannot repeat the previous one');
167
+ }
168
+ route.push(stop);
169
+ }
170
+ parsed.route = route;
171
+ }
172
+ return ok(parsed);
173
+ }
174
+ export function isMapBlock(value) {
175
+ return parseMapBlock(value).success;
176
+ }
@@ -0,0 +1,55 @@
1
+ import { type Basemap, type BasemapPath, type MapSize, type MapView } from '@texuli/data/geo';
2
+ import type { BlockTone } from '../core/schema/vocabulary.js';
3
+ import type { MapBlock, MapPoint } from './schema.js';
4
+ /**
5
+ * Pure geometry for the map block renderer, over the offline geo engine of
6
+ * `@texuli/data/geo`. Only the Svelte view imports this module, so the
7
+ * package root never pulls in the engine.
8
+ */
9
+ export declare const MAP_BLOCK_SIZE: MapSize;
10
+ /**
11
+ * Drawing size for a measured canvas width: 1:1 pixels (so map text keeps
12
+ * its size on a phone), 16:9 with a taller minimum on narrow screens. An
13
+ * unmeasured canvas (server render, tests) uses `MAP_BLOCK_SIZE`.
14
+ */
15
+ export declare function mapSizeFor(width: number): MapSize;
16
+ /** Screen pixels one arrow-key press pans. */
17
+ export declare const MAP_PAN_STEP = 64;
18
+ export declare function basemapFor(block: Pick<MapBlock, 'basemap'>): Basemap;
19
+ /**
20
+ * The view that frames every point. A lone point (or a tight cluster) gets
21
+ * at least a quarter of the basemap around it, so it is never zoomed to a dot.
22
+ */
23
+ export declare function fitPoints(points: readonly MapPoint[], basemap: Basemap, size?: MapSize): MapView;
24
+ export interface PlacedPoint extends MapPoint {
25
+ x: number;
26
+ y: number;
27
+ /** Inside the visible map (with a small margin for the marker). */
28
+ visible: boolean;
29
+ }
30
+ export declare function placePoints(points: readonly MapPoint[], view: MapView, size?: MapSize): PlacedPoint[];
31
+ /** Ordered stops of the route (ids already validated to exist). */
32
+ export declare function routeStops(block: Pick<MapBlock, 'points' | 'route'>): MapPoint[];
33
+ /** SVG path data for the route (numbers and M/L only), or `undefined`. */
34
+ export declare function routePath(stops: readonly MapPoint[], view: MapView, size?: MapSize): string | undefined;
35
+ /** Route length in meters along the stops (great-circle legs). */
36
+ export declare function routeMeters(stops: readonly MapPoint[]): number;
37
+ export declare function basemapFeatures(basemap: Basemap, view: MapView, size?: MapSize): BasemapPath[];
38
+ /** A marker label sits at the inline end of its dot, or flips when it would overflow. */
39
+ export declare function markerLabelFlips(point: Pick<PlacedPoint, 'x' | 'label'>, size?: MapSize): boolean;
40
+ /**
41
+ * Basemap features whose labels stay clear of the marker labels: a feature
42
+ * label inside a marker label's box (on whichever side the label sits) is
43
+ * dropped, so place names never print over each other.
44
+ */
45
+ export declare function clearFeatureLabels(features: readonly BasemapPath[], markers: readonly Pick<PlacedPoint, 'x' | 'y' | 'visible' | 'label'>[], size?: MapSize): BasemapPath[];
46
+ export type MapKeyAction = 'pan-up' | 'pan-down' | 'pan-left' | 'pan-right' | 'zoom-in' | 'zoom-out' | 'reset';
47
+ /**
48
+ * Keyboard map: arrows pan (geography, so not mirrored in RTL), `+`/`=` and
49
+ * `-` zoom, `0` and Home reset.
50
+ */
51
+ export declare function mapKeyAction(key: string): MapKeyAction | undefined;
52
+ /** Applies a keyboard or button action to a view. */
53
+ export declare function applyMapAction(view: MapView, action: MapKeyAction, home: MapView, size?: MapSize): MapView;
54
+ /** Marker class per tone; untoned points use the brand emphasis. */
55
+ export declare function toneClass(tone: BlockTone | undefined): string;
@@ -0,0 +1,168 @@
1
+ import { basemapPaths, fitBounds, PORT_MERIDIAN, panBy, project, routeLength, zoomAround, } from '@texuli/data/geo';
2
+ /**
3
+ * Pure geometry for the map block renderer, over the offline geo engine of
4
+ * `@texuli/data/geo`. Only the Svelte view imports this module, so the
5
+ * package root never pulls in the engine.
6
+ */
7
+ export const MAP_BLOCK_SIZE = Object.freeze({ width: 800, height: 450 });
8
+ /**
9
+ * Drawing size for a measured canvas width: 1:1 pixels (so map text keeps
10
+ * its size on a phone), 16:9 with a taller minimum on narrow screens. An
11
+ * unmeasured canvas (server render, tests) uses `MAP_BLOCK_SIZE`.
12
+ */
13
+ export function mapSizeFor(width) {
14
+ if (!Number.isFinite(width) || width <= 0)
15
+ return MAP_BLOCK_SIZE;
16
+ const w = Math.round(Math.min(width, 2_000));
17
+ return { width: w, height: Math.round(Math.max(240, (w * 9) / 16)) };
18
+ }
19
+ /** Screen pixels one arrow-key press pans. */
20
+ export const MAP_PAN_STEP = 64;
21
+ const BASEMAPS = { 'port-meridian': PORT_MERIDIAN };
22
+ export function basemapFor(block) {
23
+ return BASEMAPS[block.basemap ?? 'port-meridian'];
24
+ }
25
+ /**
26
+ * The view that frames every point. A lone point (or a tight cluster) gets
27
+ * at least a quarter of the basemap around it, so it is never zoomed to a dot.
28
+ */
29
+ export function fitPoints(points, basemap, size = MAP_BLOCK_SIZE) {
30
+ let [minLng, minLat, maxLng, maxLat] = [Infinity, Infinity, -Infinity, -Infinity];
31
+ for (const point of points) {
32
+ minLng = Math.min(minLng, point.lng);
33
+ maxLng = Math.max(maxLng, point.lng);
34
+ minLat = Math.min(minLat, point.lat);
35
+ maxLat = Math.max(maxLat, point.lat);
36
+ }
37
+ if (!Number.isFinite(minLng))
38
+ return fitBounds(basemap.bbox, size, 24);
39
+ const [bMinLng, bMinLat, bMaxLng, bMaxLat] = basemap.bbox;
40
+ const minSpanLng = (bMaxLng - bMinLng) / 4;
41
+ const minSpanLat = (bMaxLat - bMinLat) / 4;
42
+ const padLng = Math.max((maxLng - minLng) * 0.15, (minSpanLng - (maxLng - minLng)) / 2, 0);
43
+ const padLat = Math.max((maxLat - minLat) * 0.15, (minSpanLat - (maxLat - minLat)) / 2, 0);
44
+ const box = [minLng - padLng, minLat - padLat, maxLng + padLng, maxLat + padLat];
45
+ return fitBounds(box, size, 32);
46
+ }
47
+ export function placePoints(points, view, size = MAP_BLOCK_SIZE) {
48
+ return points.map((point) => {
49
+ const { x, y } = project(point, view, size);
50
+ const visible = x >= -12 && x <= size.width + 12 && y >= -12 && y <= size.height + 12;
51
+ return { ...point, x: round(x), y: round(y), visible };
52
+ });
53
+ }
54
+ function round(value) {
55
+ return Math.round(value * 10) / 10;
56
+ }
57
+ /** Ordered stops of the route (ids already validated to exist). */
58
+ export function routeStops(block) {
59
+ if (!block.route)
60
+ return [];
61
+ const byId = new Map(block.points.map((point) => [point.id, point]));
62
+ return block.route
63
+ .map((id) => byId.get(id))
64
+ .filter((point) => point !== undefined);
65
+ }
66
+ /** SVG path data for the route (numbers and M/L only), or `undefined`. */
67
+ export function routePath(stops, view, size = MAP_BLOCK_SIZE) {
68
+ if (stops.length < 2)
69
+ return undefined;
70
+ return stops
71
+ .map((stop, index) => {
72
+ const { x, y } = project(stop, view, size);
73
+ return `${index === 0 ? 'M' : 'L'}${round(x)} ${round(y)}`;
74
+ })
75
+ .join(' ');
76
+ }
77
+ /** Route length in meters along the stops (great-circle legs). */
78
+ export function routeMeters(stops) {
79
+ return stops.length < 2 ? 0 : routeLength(stops);
80
+ }
81
+ export function basemapFeatures(basemap, view, size = MAP_BLOCK_SIZE) {
82
+ return basemapPaths(basemap, view, size);
83
+ }
84
+ /** Approximate width of one marker-label character, in px (13px semibold). */
85
+ const LABEL_CHAR = 7.5;
86
+ /** A marker label sits at the inline end of its dot, or flips when it would overflow. */
87
+ export function markerLabelFlips(point, size = MAP_BLOCK_SIZE) {
88
+ return point.x + 16 + point.label.length * LABEL_CHAR > size.width;
89
+ }
90
+ /**
91
+ * Basemap features whose labels stay clear of the marker labels: a feature
92
+ * label inside a marker label's box (on whichever side the label sits) is
93
+ * dropped, so place names never print over each other.
94
+ */
95
+ export function clearFeatureLabels(features, markers, size = MAP_BLOCK_SIZE) {
96
+ const boxes = markers
97
+ .filter((marker) => marker.visible)
98
+ .map((marker) => {
99
+ const width = 16 + marker.label.length * LABEL_CHAR;
100
+ const flips = markerLabelFlips(marker, size);
101
+ return {
102
+ left: flips ? marker.x - width : marker.x - 16,
103
+ right: flips ? marker.x + 16 : marker.x + width,
104
+ top: marker.y - 16,
105
+ bottom: marker.y + 16,
106
+ };
107
+ });
108
+ return features.map((feature) => {
109
+ const label = feature.label;
110
+ if (!label)
111
+ return feature;
112
+ const hit = boxes.some((box) => label.x >= box.left && label.x <= box.right && label.y >= box.top && label.y <= box.bottom);
113
+ if (!hit)
114
+ return feature;
115
+ const { label: _dropped, ...rest } = feature;
116
+ return rest;
117
+ });
118
+ }
119
+ /**
120
+ * Keyboard map: arrows pan (geography, so not mirrored in RTL), `+`/`=` and
121
+ * `-` zoom, `0` and Home reset.
122
+ */
123
+ export function mapKeyAction(key) {
124
+ switch (key) {
125
+ case 'ArrowUp':
126
+ return 'pan-up';
127
+ case 'ArrowDown':
128
+ return 'pan-down';
129
+ case 'ArrowLeft':
130
+ return 'pan-left';
131
+ case 'ArrowRight':
132
+ return 'pan-right';
133
+ case '+':
134
+ case '=':
135
+ return 'zoom-in';
136
+ case '-':
137
+ case '_':
138
+ return 'zoom-out';
139
+ case '0':
140
+ case 'Home':
141
+ return 'reset';
142
+ default:
143
+ return undefined;
144
+ }
145
+ }
146
+ /** Applies a keyboard or button action to a view. */
147
+ export function applyMapAction(view, action, home, size = MAP_BLOCK_SIZE) {
148
+ switch (action) {
149
+ case 'pan-up':
150
+ return panBy(view, 0, MAP_PAN_STEP, size);
151
+ case 'pan-down':
152
+ return panBy(view, 0, -MAP_PAN_STEP, size);
153
+ case 'pan-left':
154
+ return panBy(view, MAP_PAN_STEP, 0, size);
155
+ case 'pan-right':
156
+ return panBy(view, -MAP_PAN_STEP, 0, size);
157
+ case 'zoom-in':
158
+ return zoomAround(view, 1, size);
159
+ case 'zoom-out':
160
+ return zoomAround(view, -1, size);
161
+ default:
162
+ return home;
163
+ }
164
+ }
165
+ /** Marker class per tone; untoned points use the brand emphasis. */
166
+ export function toneClass(tone) {
167
+ return `tone-${tone ?? 'accent'}`;
168
+ }
@@ -0,0 +1,258 @@
1
+ <script lang="ts">
2
+ import { onMount } from 'svelte';
3
+ import { checkMediaBlock } from './check.js';
4
+ import type { MediaBlockViewProps } from './family.js';
5
+ import { resolveMediaBlockLabels } from './labels.js';
6
+ import { resolveBlockMediaSrc } from './resolve.js';
7
+
8
+ /**
9
+ * Media family renderer: image, figure, video and gallery. Untrusted data
10
+ * passes `checkMediaBlock` before anything renders; rejected blocks become an
11
+ * inert notice. Sources use the block media classifier (no remote URLs).
12
+ */
13
+ let { block, labels, resolveAvatar, oninvalid }: MediaBlockViewProps = $props();
14
+
15
+ const check = $derived(checkMediaBlock(block));
16
+ const text = $derived(resolveMediaBlockLabels(labels));
17
+
18
+ let reducedMotion = $state(false);
19
+ let motionKnown = $state(false);
20
+
21
+ onMount(() => {
22
+ const query = window.matchMedia('(prefers-reduced-motion: reduce)');
23
+ const update = () => {
24
+ reducedMotion = query.matches;
25
+ };
26
+ update();
27
+ motionKnown = true;
28
+ query.addEventListener('change', update);
29
+ return () => query.removeEventListener('change', update);
30
+ });
31
+
32
+ $effect(() => {
33
+ if (!check.ok) oninvalid?.(check.issues);
34
+ });
35
+
36
+ function imageAlt(decorative: boolean | undefined, alt: string | undefined): string {
37
+ return decorative === true ? '' : (alt ?? '');
38
+ }
39
+ </script>
40
+
41
+ {#if !check.ok}
42
+ <p class="tx-block tx-block-invalid" role="note" data-tx-block="invalid">{text.invalid}</p>
43
+ {:else}
44
+ {@const b = check.block}
45
+ {#if b.type === 'image' || b.type === 'figure'}
46
+ {@const src = resolveBlockMediaSrc(b.src, 'image', resolveAvatar)}
47
+ <figure
48
+ class="tx-block tx-media-figure"
49
+ data-tx-block={b.type}
50
+ data-tone={b.tone}
51
+ data-fit={b.fit ?? 'cover'}
52
+ >
53
+ {#if src}
54
+ <img
55
+ {src}
56
+ alt={imageAlt(b.decorative, b.alt)}
57
+ width={b.width}
58
+ height={b.height}
59
+ loading="lazy"
60
+ decoding="async"
61
+ referrerpolicy="no-referrer"
62
+ class="tx-media-img"
63
+ data-tx-media-img
64
+ />
65
+ {:else}
66
+ <span class="tx-media-fallback" role="img" aria-label={imageAlt(b.decorative, b.alt) || text.galleryFallback}
67
+ >{text.galleryFallback}</span
68
+ >
69
+ {/if}
70
+ {#if b.caption || b.credit}
71
+ <figcaption class="tx-media-caption" data-tx-media-caption>
72
+ {#if b.caption}<span class="tx-media-caption-text">{b.caption}</span>{/if}
73
+ {#if b.credit}
74
+ <span class="tx-media-credit">{b.caption ? ` · ${b.credit}` : b.credit}</span>
75
+ {/if}
76
+ </figcaption>
77
+ {/if}
78
+ </figure>
79
+ {:else if b.type === 'video'}
80
+ {@const src = resolveBlockMediaSrc(b.src, 'video')}
81
+ {@const poster = b.poster ? resolveBlockMediaSrc(b.poster, 'image', resolveAvatar) : undefined}
82
+ {@const canAutoplay = b.autoplay === true && b.muted === true && motionKnown && !reducedMotion}
83
+ <figure class="tx-block tx-media-figure" data-tx-block="video" data-tone={b.tone}>
84
+ {#if src}
85
+ <video
86
+ {src}
87
+ {poster}
88
+ width={b.width}
89
+ height={b.height}
90
+ aria-label={b.label}
91
+ controls={b.controls !== false}
92
+ playsinline
93
+ muted={b.muted === true || canAutoplay}
94
+ loop={b.loop === true}
95
+ autoplay={canAutoplay}
96
+ preload="metadata"
97
+ class="tx-media-video"
98
+ data-tx-media-video
99
+ ></video>
100
+ {:else}
101
+ <span class="tx-media-fallback" role="img" aria-label={b.label}>{text.galleryFallback}</span>
102
+ {/if}
103
+ {#if b.caption}
104
+ <figcaption class="tx-media-caption" data-tx-media-caption>{b.caption}</figcaption>
105
+ {/if}
106
+ </figure>
107
+ {:else}
108
+ {@const columns = b.columns ?? 3}
109
+ <section
110
+ class="tx-block tx-media-gallery"
111
+ data-tx-block="gallery"
112
+ data-tone={b.tone}
113
+ data-columns={columns}
114
+ aria-label={b.title}
115
+ style={`--tx-gallery-cols: ${columns}`}
116
+ >
117
+ {#if b.title}
118
+ <h3 class="tx-media-gallery-title" data-tx-media-gallery-title>{b.title}</h3>
119
+ {/if}
120
+ <ul class="tx-media-gallery-list" data-tx-media-gallery-list>
121
+ {#each b.items as item, i (i)}
122
+ {@const src = resolveBlockMediaSrc(item.src, 'image', resolveAvatar)}
123
+ <li class="tx-media-gallery-item">
124
+ <figure class="tx-media-gallery-figure">
125
+ {#if src}
126
+ <img
127
+ {src}
128
+ alt={imageAlt(item.decorative, item.alt)}
129
+ width={item.width}
130
+ height={item.height}
131
+ loading="lazy"
132
+ decoding="async"
133
+ referrerpolicy="no-referrer"
134
+ class="tx-media-img"
135
+ data-tx-media-img
136
+ />
137
+ {:else}
138
+ <span
139
+ class="tx-media-fallback"
140
+ role="img"
141
+ aria-label={imageAlt(item.decorative, item.alt) || text.galleryFallback}
142
+ >{text.galleryFallback}</span
143
+ >
144
+ {/if}
145
+ {#if item.caption}
146
+ <figcaption class="tx-media-caption" data-tx-media-caption>{item.caption}</figcaption>
147
+ {/if}
148
+ </figure>
149
+ </li>
150
+ {/each}
151
+ </ul>
152
+ </section>
153
+ {/if}
154
+ {/if}
155
+
156
+ <style>
157
+ .tx-block {
158
+ margin: 0;
159
+ overflow-wrap: anywhere;
160
+ color: var(--tx-foreground, CanvasText);
161
+ }
162
+
163
+ .tx-media-figure,
164
+ .tx-media-gallery-figure {
165
+ display: grid;
166
+ gap: 0.5rem;
167
+ margin: 0;
168
+ }
169
+
170
+ .tx-media-img,
171
+ .tx-media-video {
172
+ display: block;
173
+ max-inline-size: 100%;
174
+ block-size: auto;
175
+ border-radius: var(--tx-radius-md, 0.375rem);
176
+ border: 1px solid var(--tx-border, currentColor);
177
+ background-color: var(--tx-muted, Canvas);
178
+ }
179
+
180
+ .tx-media-figure[data-fit='contain'] .tx-media-img {
181
+ object-fit: contain;
182
+ }
183
+
184
+ .tx-media-figure[data-fit='cover'] .tx-media-img,
185
+ .tx-media-gallery-figure .tx-media-img {
186
+ object-fit: cover;
187
+ }
188
+
189
+ .tx-media-video {
190
+ inline-size: 100%;
191
+ }
192
+
193
+ .tx-media-caption {
194
+ margin: 0;
195
+ font-size: var(--tx-font-size-sm, 0.875rem);
196
+ color: var(--tx-muted-foreground, GrayText);
197
+ }
198
+
199
+ .tx-media-credit {
200
+ font-style: normal;
201
+ }
202
+
203
+ .tx-media-fallback {
204
+ display: grid;
205
+ place-items: center;
206
+ min-block-size: 6rem;
207
+ padding: 0.75rem;
208
+ border: 1px dashed var(--tx-border, currentColor);
209
+ border-radius: var(--tx-radius-md, 0.375rem);
210
+ background-color: var(--tx-muted, Canvas);
211
+ color: var(--tx-muted-foreground, GrayText);
212
+ font-size: 0.875rem;
213
+ text-align: center;
214
+ }
215
+
216
+ .tx-media-gallery {
217
+ display: grid;
218
+ gap: 0.75rem;
219
+ }
220
+
221
+ .tx-media-gallery-title {
222
+ margin: 0;
223
+ font-size: var(--tx-font-size-base, 1rem);
224
+ font-weight: var(--tx-font-weight-heading, 600);
225
+ }
226
+
227
+ .tx-media-gallery-list {
228
+ display: grid;
229
+ gap: 0.75rem;
230
+ margin: 0;
231
+ padding: 0;
232
+ list-style: none;
233
+ grid-template-columns: repeat(
234
+ auto-fit,
235
+ minmax(min(100%, calc(100% / var(--tx-gallery-cols, 3) - 0.5rem)), 1fr)
236
+ );
237
+ }
238
+
239
+ .tx-media-gallery-item {
240
+ margin: 0;
241
+ min-inline-size: 0;
242
+ }
243
+
244
+ .tx-block-invalid {
245
+ padding: 0.5em 0.75em;
246
+ border: 1px dashed currentColor;
247
+ border-radius: 0.375em;
248
+ font-size: 0.875em;
249
+ }
250
+
251
+ @media (forced-colors: active) {
252
+ .tx-media-img,
253
+ .tx-media-video,
254
+ .tx-media-fallback {
255
+ border-color: ButtonText;
256
+ }
257
+ }
258
+ </style>
@@ -0,0 +1,5 @@
1
+ /// <reference path="../css-modules.d.ts" />
2
+ import type { MediaBlockViewProps } from './family.js';
3
+ declare const MediaBlockView: import("svelte").Component<MediaBlockViewProps, {}, "">;
4
+ type MediaBlockView = ReturnType<typeof MediaBlockView>;
5
+ export default MediaBlockView;
@@ -0,0 +1,14 @@
1
+ import type { FieldIssue } from '../core/schema/primitives.js';
2
+ import type { MediaFamilyBlock } from './engine.js';
3
+ export type MediaBlockCheck = {
4
+ ok: true;
5
+ block: MediaFamilyBlock;
6
+ } | {
7
+ ok: false;
8
+ issues: FieldIssue[];
9
+ };
10
+ /**
11
+ * The only way data reaches a media renderer: untrusted input runs through the
12
+ * block schemas, then must be one of the media family types.
13
+ */
14
+ export declare function checkMediaBlock(value: unknown): MediaBlockCheck;
@@ -0,0 +1,13 @@
1
+ import { parseMediaFamilyBlock } from "../core/schema/media-family.js";
2
+ import { parseFamilyBlock } from "../core/schema/parse-family.js";
3
+ /**
4
+ * The only way data reaches a media renderer: untrusted input runs through the
5
+ * block schemas, then must be one of the media family types.
6
+ */
7
+ export function checkMediaBlock(value) {
8
+ // Only this family's parser: the renderer does not bundle every family's rules.
9
+ const parsed = parseFamilyBlock(value, [parseMediaFamilyBlock], 'media');
10
+ if (!parsed.success)
11
+ return { ok: false, issues: parsed.issues };
12
+ return { ok: true, block: parsed.data };
13
+ }