meno-core 1.0.52 → 1.0.53

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 (135) hide show
  1. package/build-astro.ts +183 -13
  2. package/build-next.ts +1361 -0
  3. package/build-static.ts +7 -5
  4. package/dist/bin/cli.js +2 -2
  5. package/dist/build-static.js +6 -6
  6. package/dist/chunks/{chunk-HNLUO36W.js → chunk-GZHGVVW3.js} +2 -2
  7. package/dist/chunks/chunk-GZHGVVW3.js.map +7 -0
  8. package/dist/chunks/{chunk-LPVETICS.js → chunk-H3GJ4H2U.js} +185 -1
  9. package/dist/chunks/chunk-H3GJ4H2U.js.map +7 -0
  10. package/dist/chunks/{chunk-CXCBV2M7.js → chunk-IGYR22T6.js} +76 -270
  11. package/dist/chunks/chunk-IGYR22T6.js.map +7 -0
  12. package/dist/chunks/{chunk-LHLHPYSP.js → chunk-JGP5A3Y5.js} +12 -11
  13. package/dist/chunks/chunk-JGP5A3Y5.js.map +7 -0
  14. package/dist/chunks/{chunk-7NIC4I3V.js → chunk-JGWFTO6P.js} +167 -21
  15. package/dist/chunks/chunk-JGWFTO6P.js.map +7 -0
  16. package/dist/chunks/{chunk-EDQSMAMP.js → chunk-O3NAGJP4.js} +85 -4
  17. package/dist/chunks/chunk-O3NAGJP4.js.map +7 -0
  18. package/dist/chunks/{chunk-H4JSCDNW.js → chunk-QB2LNO4W.js} +24 -1
  19. package/dist/chunks/chunk-QB2LNO4W.js.map +7 -0
  20. package/dist/chunks/{chunk-A725KYFK.js → chunk-R6XHAFBF.js} +561 -112
  21. package/dist/chunks/chunk-R6XHAFBF.js.map +7 -0
  22. package/dist/chunks/{chunk-J23ZX5AP.js → chunk-X754AHS5.js} +277 -1
  23. package/dist/chunks/chunk-X754AHS5.js.map +7 -0
  24. package/dist/chunks/{chunk-2QK6U5UK.js → chunk-YBLHKYFF.js} +12 -2
  25. package/dist/chunks/chunk-YBLHKYFF.js.map +7 -0
  26. package/dist/chunks/{constants-GWBAD66U.js → constants-STK2YBIW.js} +2 -2
  27. package/dist/entries/server-router.js +7 -7
  28. package/dist/lib/client/index.js +354 -59
  29. package/dist/lib/client/index.js.map +4 -4
  30. package/dist/lib/server/index.js +1458 -190
  31. package/dist/lib/server/index.js.map +4 -4
  32. package/dist/lib/shared/index.js +202 -34
  33. package/dist/lib/shared/index.js.map +4 -4
  34. package/dist/lib/test-utils/index.js +1 -1
  35. package/entries/client-router.tsx +5 -165
  36. package/lib/client/ErrorBoundary.test.tsx +27 -25
  37. package/lib/client/ErrorBoundary.tsx +34 -19
  38. package/lib/client/core/ComponentBuilder.ts +19 -2
  39. package/lib/client/core/builders/embedBuilder.ts +8 -4
  40. package/lib/client/core/builders/listBuilder.ts +23 -4
  41. package/lib/client/fontFamiliesService.test.ts +76 -0
  42. package/lib/client/fontFamiliesService.ts +69 -0
  43. package/lib/client/hmrCssReload.ts +160 -0
  44. package/lib/client/hooks/useColorVariables.ts +2 -0
  45. package/lib/client/index.ts +4 -0
  46. package/lib/client/meno-filter/ui.ts +2 -0
  47. package/lib/client/routing/RouteLoader.test.ts +2 -2
  48. package/lib/client/routing/RouteLoader.ts +8 -2
  49. package/lib/client/routing/Router.tsx +81 -15
  50. package/lib/client/scripts/ScriptExecutor.test.ts +143 -0
  51. package/lib/client/scripts/ScriptExecutor.ts +56 -2
  52. package/lib/client/styles/StyleInjector.ts +20 -5
  53. package/lib/client/styles/UtilityClassCollector.ts +7 -1
  54. package/lib/client/styles/cspNonce.test.ts +67 -0
  55. package/lib/client/styles/cspNonce.ts +63 -0
  56. package/lib/client/templateEngine.test.ts +80 -0
  57. package/lib/client/templateEngine.ts +5 -0
  58. package/lib/server/astro/cmsPageEmitter.ts +35 -5
  59. package/lib/server/astro/componentEmitter.ts +61 -5
  60. package/lib/server/astro/nodeToAstro.ts +149 -11
  61. package/lib/server/astro/normalizeOrphanTemplateProps.test.ts +264 -0
  62. package/lib/server/astro/normalizeOrphanTemplateProps.ts +184 -0
  63. package/lib/server/createServer.ts +11 -0
  64. package/lib/server/draftPageStore.ts +49 -0
  65. package/lib/server/fileWatcher.ts +62 -2
  66. package/lib/server/index.ts +13 -1
  67. package/lib/server/providers/fileSystemPageProvider.ts +8 -0
  68. package/lib/server/routes/api/components.ts +9 -4
  69. package/lib/server/routes/api/core-routes.ts +2 -2
  70. package/lib/server/routes/api/pages.ts +14 -22
  71. package/lib/server/routes/api/shared.ts +56 -0
  72. package/lib/server/routes/index.ts +90 -0
  73. package/lib/server/routes/pages.ts +13 -6
  74. package/lib/server/services/componentService.test.ts +199 -2
  75. package/lib/server/services/componentService.ts +354 -49
  76. package/lib/server/services/fileWatcherService.ts +4 -24
  77. package/lib/server/services/pageService.test.ts +23 -0
  78. package/lib/server/services/pageService.ts +124 -6
  79. package/lib/server/ssr/attributeBuilder.ts +8 -2
  80. package/lib/server/ssr/buildErrorOverlay.ts +1 -1
  81. package/lib/server/ssr/errorOverlay.test.ts +21 -2
  82. package/lib/server/ssr/errorOverlay.ts +38 -11
  83. package/lib/server/ssr/htmlGenerator.test.ts +53 -13
  84. package/lib/server/ssr/htmlGenerator.ts +71 -27
  85. package/lib/server/ssr/liveReloadIntegration.test.ts +123 -2
  86. package/lib/server/ssr/ssrRenderer.test.ts +67 -0
  87. package/lib/server/ssr/ssrRenderer.ts +94 -9
  88. package/lib/server/websocketManager.ts +0 -1
  89. package/lib/shared/componentRefs.ts +45 -0
  90. package/lib/shared/constants.ts +8 -0
  91. package/lib/shared/cssGeneration.ts +2 -0
  92. package/lib/shared/cssProperties.ts +184 -0
  93. package/lib/shared/expressionEvaluator.ts +54 -0
  94. package/lib/shared/fontCss.ts +101 -0
  95. package/lib/shared/fontLoader.ts +8 -86
  96. package/lib/shared/friendlyError.test.ts +87 -0
  97. package/lib/shared/friendlyError.ts +121 -0
  98. package/lib/shared/hrefRefs.test.ts +130 -0
  99. package/lib/shared/hrefRefs.ts +100 -0
  100. package/lib/shared/index.ts +52 -0
  101. package/lib/shared/inlineSvgStyleRules.test.ts +108 -0
  102. package/lib/shared/inlineSvgStyleRules.ts +134 -0
  103. package/lib/shared/interfaces/contentProvider.ts +13 -0
  104. package/lib/shared/itemTemplateUtils.test.ts +14 -0
  105. package/lib/shared/itemTemplateUtils.ts +4 -1
  106. package/lib/shared/registry/NodeTypeDefinition.ts +1 -1
  107. package/lib/shared/registry/nodeTypes/LinkNodeType.ts +1 -1
  108. package/lib/shared/slugTranslator.test.ts +24 -0
  109. package/lib/shared/slugTranslator.ts +24 -0
  110. package/lib/shared/styleNodeUtils.ts +4 -1
  111. package/lib/shared/tree/PathBuilder.test.ts +128 -1
  112. package/lib/shared/tree/PathBuilder.ts +83 -31
  113. package/lib/shared/types/comment.ts +99 -0
  114. package/lib/shared/types/index.ts +12 -0
  115. package/lib/shared/types/rendering.ts +8 -0
  116. package/lib/shared/utilityClassConfig.ts +4 -2
  117. package/lib/shared/utilityClassMapper.test.ts +24 -0
  118. package/lib/shared/validation/commentValidators.ts +69 -0
  119. package/lib/shared/validation/index.ts +1 -0
  120. package/lib/shared/viewportUnits.integration.test.ts +42 -0
  121. package/lib/shared/viewportUnits.test.ts +103 -0
  122. package/lib/shared/viewportUnits.ts +63 -0
  123. package/lib/test-utils/dom-setup.ts +6 -0
  124. package/package.json +1 -1
  125. package/dist/chunks/chunk-2QK6U5UK.js.map +0 -7
  126. package/dist/chunks/chunk-7NIC4I3V.js.map +0 -7
  127. package/dist/chunks/chunk-A725KYFK.js.map +0 -7
  128. package/dist/chunks/chunk-CXCBV2M7.js.map +0 -7
  129. package/dist/chunks/chunk-EDQSMAMP.js.map +0 -7
  130. package/dist/chunks/chunk-H4JSCDNW.js.map +0 -7
  131. package/dist/chunks/chunk-HNLUO36W.js.map +0 -7
  132. package/dist/chunks/chunk-J23ZX5AP.js.map +0 -7
  133. package/dist/chunks/chunk-LHLHPYSP.js.map +0 -7
  134. package/dist/chunks/chunk-LPVETICS.js.map +0 -7
  135. /package/dist/chunks/{constants-GWBAD66U.js.map → constants-STK2YBIW.js.map} +0 -0
@@ -85,6 +85,35 @@ export const CSS_PROPERTIES_DEFINITION: Record<string, CSSPropertyDefinition> =
85
85
  values: ['repeat', 'repeat-x', 'repeat-y', 'no-repeat'],
86
86
  type: 'select',
87
87
  },
88
+ backgroundAttachment: {
89
+ values: ['scroll', 'fixed', 'local'],
90
+ type: 'select',
91
+ },
92
+ backgroundOrigin: {
93
+ values: ['border-box', 'padding-box', 'content-box'],
94
+ type: 'select',
95
+ },
96
+ backgroundBlendMode: {
97
+ values: ['normal', 'multiply', 'screen', 'overlay', 'darken', 'lighten', 'color-dodge', 'color-burn', 'hard-light', 'soft-light', 'difference', 'exclusion', 'hue', 'saturation', 'color', 'luminosity'],
98
+ type: 'select',
99
+ },
100
+ // Gradient-text recipe (`background: linear-gradient(...); background-clip:
101
+ // text; -webkit-background-clip: text; -webkit-text-fill-color: transparent`).
102
+ // The `Webkit*` capitalization is intentional — utilityClassMapper's
103
+ // camel→kebab emitter only prepends `-` when the first letter is uppercase,
104
+ // so `WebkitBackgroundClip` round-trips back to `-webkit-background-clip`.
105
+ backgroundClip: {
106
+ values: ['border-box', 'padding-box', 'content-box', 'text'],
107
+ type: 'select',
108
+ },
109
+ WebkitBackgroundClip: {
110
+ values: ['border-box', 'padding-box', 'content-box', 'text'],
111
+ type: 'select',
112
+ },
113
+ WebkitTextFillColor: { type: 'string' },
114
+ WebkitTextStroke: { type: 'string' },
115
+ WebkitTextStrokeWidth: { type: 'string' },
116
+ WebkitTextStrokeColor: { type: 'string' },
88
117
  color: { type: 'string' },
89
118
  opacity: { type: 'number' },
90
119
 
@@ -167,10 +196,63 @@ export const CSS_PROPERTIES_DEFINITION: Record<string, CSSPropertyDefinition> =
167
196
  values: ['none', 'underline', 'overline', 'line-through'],
168
197
  type: 'select',
169
198
  },
199
+ textDecorationLine: {
200
+ values: ['none', 'underline', 'overline', 'line-through'],
201
+ type: 'select',
202
+ },
203
+ textDecorationStyle: {
204
+ values: ['solid', 'double', 'dotted', 'dashed', 'wavy'],
205
+ type: 'select',
206
+ },
207
+ textDecorationColor: { type: 'string' },
208
+ textDecorationThickness: { type: 'string' },
209
+ textUnderlineOffset: { type: 'string' },
210
+ textUnderlinePosition: { type: 'string' },
170
211
  textTransform: {
171
212
  values: ['none', 'capitalize', 'uppercase', 'lowercase'],
172
213
  type: 'select',
173
214
  },
215
+ textRendering: {
216
+ values: ['auto', 'optimizeSpeed', 'optimizeLegibility', 'geometricPrecision'],
217
+ type: 'select',
218
+ },
219
+ fontVariant: { type: 'string' },
220
+ fontStretch: { type: 'string' },
221
+ fontFeatureSettings: { type: 'string' },
222
+ fontVariationSettings: { type: 'string' },
223
+ fontKerning: {
224
+ values: ['auto', 'normal', 'none'],
225
+ type: 'select',
226
+ },
227
+ WebkitFontSmoothing: {
228
+ values: ['auto', 'none', 'antialiased', 'subpixel-antialiased'],
229
+ type: 'select',
230
+ },
231
+ MozOsxFontSmoothing: {
232
+ values: ['auto', 'grayscale'],
233
+ type: 'select',
234
+ },
235
+ hyphens: {
236
+ values: ['none', 'manual', 'auto'],
237
+ type: 'select',
238
+ },
239
+ WebkitHyphens: {
240
+ values: ['none', 'manual', 'auto'],
241
+ type: 'select',
242
+ },
243
+ writingMode: {
244
+ values: ['horizontal-tb', 'vertical-rl', 'vertical-lr'],
245
+ type: 'select',
246
+ },
247
+ direction: {
248
+ values: ['ltr', 'rtl'],
249
+ type: 'select',
250
+ },
251
+ tabSize: { type: 'string' },
252
+ wordWrap: {
253
+ values: ['normal', 'break-word'],
254
+ type: 'select',
255
+ },
174
256
  letterSpacing: { type: 'string' },
175
257
  wordSpacing: { type: 'string' },
176
258
  wordBreak: {
@@ -181,6 +263,10 @@ export const CSS_PROPERTIES_DEFINITION: Record<string, CSSPropertyDefinition> =
181
263
  values: ['normal', 'break-word', 'anywhere'],
182
264
  type: 'select',
183
265
  },
266
+ textWrap: {
267
+ values: ['wrap', 'nowrap', 'balance', 'pretty', 'stable'],
268
+ type: 'select',
269
+ },
184
270
  textIndent: { type: 'string' },
185
271
  verticalAlign: {
186
272
  values: ['baseline', 'top', 'middle', 'bottom', 'text-top', 'text-bottom', 'sub', 'super'],
@@ -192,9 +278,20 @@ export const CSS_PROPERTIES_DEFINITION: Record<string, CSSPropertyDefinition> =
192
278
  textShadow: { type: 'string' },
193
279
  filter: { type: 'string' },
194
280
  backdropFilter: { type: 'string' },
281
+ WebkitBackdropFilter: { type: 'string' },
195
282
  transform: { type: 'string' },
196
283
  transformOrigin: { type: 'string' },
284
+ transformStyle: {
285
+ values: ['flat', 'preserve-3d'],
286
+ type: 'select',
287
+ },
288
+ perspective: { type: 'string' },
289
+ perspectiveOrigin: { type: 'string' },
197
290
  transition: { type: 'string' },
291
+ transitionProperty: { type: 'string' },
292
+ transitionDuration: { type: 'string' },
293
+ transitionTimingFunction: { type: 'string' },
294
+ transitionDelay: { type: 'string' },
198
295
  animation: { type: 'string' },
199
296
  backfaceVisibility: {
200
297
  values: ['visible', 'hidden'],
@@ -204,7 +301,38 @@ export const CSS_PROPERTIES_DEFINITION: Record<string, CSSPropertyDefinition> =
204
301
  values: ['normal', 'multiply', 'screen', 'overlay', 'darken', 'lighten', 'color-dodge', 'color-burn', 'hard-light', 'soft-light', 'difference', 'exclusion', 'hue', 'saturation', 'color', 'luminosity'],
205
302
  type: 'select',
206
303
  },
304
+ isolation: {
305
+ values: ['auto', 'isolate'],
306
+ type: 'select',
307
+ },
308
+ willChange: { type: 'string' },
309
+ imageRendering: {
310
+ values: ['auto', 'crisp-edges', 'pixelated', 'smooth'],
311
+ type: 'select',
312
+ },
207
313
  clipPath: { type: 'string' },
314
+ WebkitClipPath: { type: 'string' },
315
+ // Mask family — gradient/SVG masks. Emit both the standard and `-webkit-`
316
+ // form because Safari still needs the prefix.
317
+ maskImage: { type: 'string' },
318
+ WebkitMaskImage: { type: 'string' },
319
+ maskSize: { type: 'string' },
320
+ WebkitMaskSize: { type: 'string' },
321
+ maskPosition: { type: 'string' },
322
+ WebkitMaskPosition: { type: 'string' },
323
+ maskRepeat: { type: 'string' },
324
+ WebkitMaskRepeat: { type: 'string' },
325
+ maskOrigin: { type: 'string' },
326
+ WebkitMaskOrigin: { type: 'string' },
327
+ maskClip: { type: 'string' },
328
+ WebkitMaskClip: { type: 'string' },
329
+ maskComposite: { type: 'string' },
330
+ WebkitMaskComposite: { type: 'string' },
331
+ maskMode: { type: 'string' },
332
+ maskType: {
333
+ values: ['luminance', 'alpha'],
334
+ type: 'select',
335
+ },
208
336
 
209
337
  // Overflow & Content
210
338
  overflow: {
@@ -246,6 +374,27 @@ export const CSS_PROPERTIES_DEFINITION: Record<string, CSSPropertyDefinition> =
246
374
  values: ['auto', 'none', 'text', 'all'],
247
375
  type: 'select',
248
376
  },
377
+ WebkitUserSelect: {
378
+ values: ['auto', 'none', 'text', 'all'],
379
+ type: 'select',
380
+ },
381
+ MozUserSelect: {
382
+ values: ['auto', 'none', 'text', 'all'],
383
+ type: 'select',
384
+ },
385
+ caretColor: { type: 'string' },
386
+ appearance: {
387
+ values: ['none', 'auto', 'menulist-button', 'textfield'],
388
+ type: 'select',
389
+ },
390
+ WebkitAppearance: {
391
+ values: ['none', 'auto', 'menulist-button', 'textfield'],
392
+ type: 'select',
393
+ },
394
+ MozAppearance: {
395
+ values: ['none', 'auto', 'menulist-button', 'textfield'],
396
+ type: 'select',
397
+ },
249
398
 
250
399
  // Outline
251
400
  outline: { type: 'string' },
@@ -297,6 +446,41 @@ export const CSS_PROPERTIES_DEFINITION: Record<string, CSSPropertyDefinition> =
297
446
  values: ['auto', 'smooth'],
298
447
  type: 'select',
299
448
  },
449
+ scrollSnapType: { type: 'string' },
450
+ scrollSnapAlign: {
451
+ values: ['none', 'start', 'end', 'center'],
452
+ type: 'select',
453
+ },
454
+ scrollSnapStop: {
455
+ values: ['normal', 'always'],
456
+ type: 'select',
457
+ },
458
+ scrollMarginTop: { type: 'string' },
459
+ scrollMarginRight: { type: 'string' },
460
+ scrollMarginBottom: { type: 'string' },
461
+ scrollMarginLeft: { type: 'string' },
462
+ scrollPaddingTop: { type: 'string' },
463
+ scrollPaddingRight: { type: 'string' },
464
+ scrollPaddingBottom: { type: 'string' },
465
+ scrollPaddingLeft: { type: 'string' },
466
+ overscrollBehavior: {
467
+ values: ['auto', 'contain', 'none'],
468
+ type: 'select',
469
+ },
470
+ overscrollBehaviorX: {
471
+ values: ['auto', 'contain', 'none'],
472
+ type: 'select',
473
+ },
474
+ overscrollBehaviorY: {
475
+ values: ['auto', 'contain', 'none'],
476
+ type: 'select',
477
+ },
478
+ // CSS Containment — container queries depend on these.
479
+ containerType: {
480
+ values: ['normal', 'size', 'inline-size'],
481
+ type: 'select',
482
+ },
483
+ containerName: { type: 'string' },
300
484
  accentColor: { type: 'string' },
301
485
 
302
486
  // SVG
@@ -159,3 +159,57 @@ export function isComplexExpression(expression: string): boolean {
159
159
  // Excludes dots (.) which are used for simple field access
160
160
  return /[?:+\-*/%<>=!&|]/.test(expression);
161
161
  }
162
+
163
+ /** jsep node types `evaluateNode` (above) can evaluate — the rest throw at runtime. */
164
+ const SUPPORTED_NODE_TYPES = new Set([
165
+ 'Identifier',
166
+ 'Literal',
167
+ 'MemberExpression',
168
+ 'BinaryExpression',
169
+ 'LogicalExpression',
170
+ 'ConditionalExpression',
171
+ 'UnaryExpression',
172
+ 'ArrayExpression',
173
+ ]);
174
+
175
+ /** Child-expression slots jsep produces on the supported node types. */
176
+ const CHILD_KEYS = ['object', 'property', 'left', 'right', 'test', 'consequent', 'alternate', 'argument'];
177
+
178
+ /** Recursively verify every node in a jsep AST is a type this evaluator supports. */
179
+ function isSupportedNode(node: Expression | null | undefined): boolean {
180
+ if (!node) return true;
181
+ if (!SUPPORTED_NODE_TYPES.has(node.type)) return false; // CallExpression, Compound, …
182
+ const n = node as unknown as Record<string, unknown>;
183
+ for (const key of CHILD_KEYS) {
184
+ const child = n[key];
185
+ if (child && typeof child === 'object' && 'type' in child) {
186
+ if (!isSupportedNode(child as Expression)) return false;
187
+ }
188
+ }
189
+ if (Array.isArray(n.elements)) {
190
+ for (const el of n.elements) {
191
+ if (el && !isSupportedNode(el as Expression)) return false;
192
+ }
193
+ }
194
+ return true;
195
+ }
196
+
197
+ /**
198
+ * True when `expression` is one the template engine can actually evaluate (see
199
+ * {@link safeEvaluate}): jsep parses it and every node is a whitelisted type —
200
+ * identifier, member access, literal, the supported operators, ternary, or array.
201
+ *
202
+ * Returns false for anything with a function/method call (`x.toFixed(2)`), a compound
203
+ * (`a, b`), or a syntax error. Those are arbitrary JS that the Meno model cannot
204
+ * represent as a `{{template}}` binding — the meno-astro codec preserves them verbatim
205
+ * instead of coercing them into a fake binding.
206
+ */
207
+ export function isSupportedTemplateExpression(expression: string): boolean {
208
+ const expr = expression.trim();
209
+ if (!expr) return false;
210
+ try {
211
+ return isSupportedNode(jsep(expr));
212
+ } catch {
213
+ return false;
214
+ }
215
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Pure `@font-face` / preload generation from a list of font configs.
3
+ *
4
+ * No filesystem or project-context coupling — the caller passes the `fonts`
5
+ * array (read however it likes). This is the single source of truth for font
6
+ * CSS formatting, shared by every renderer:
7
+ * - meno-core SSR → via {@link ../shared/fontLoader} (cached config)
8
+ * - meno-core JSON→Astro → build-astro.ts
9
+ * - meno-astro dialect twin → meno-astro/server `loadFontCss` → BaseLayout.astro
10
+ *
11
+ * Keeping it server-free means the Astro `BaseLayout` can import it without
12
+ * dragging meno-core's server runtime into the build.
13
+ */
14
+
15
+ export interface FontConfig {
16
+ path: string;
17
+ family?: string;
18
+ weight?: number;
19
+ weightMax?: number; // If set, font is variable with weight range [weight, weightMax]
20
+ style?: 'normal' | 'italic';
21
+ fontDisplay?: 'auto' | 'block' | 'swap' | 'fallback' | 'optional';
22
+ unicodeRange?: string;
23
+ /** Legacy alias for `path` (emitted by the website-import flow). */
24
+ src?: string;
25
+ }
26
+
27
+ /** Detect the `@font-face` `format()` value from a file extension. */
28
+ function getFontFormat(path: string): string {
29
+ if (path.endsWith('.woff2')) return 'woff2';
30
+ if (path.endsWith('.woff')) return 'woff';
31
+ if (path.endsWith('.ttf')) return 'truetype';
32
+ if (path.endsWith('.otf')) return 'opentype';
33
+ return 'truetype';
34
+ }
35
+
36
+ /** Font `type` attribute for a `<link rel="preload">`. */
37
+ function getFontMimeType(path: string): string {
38
+ if (path.endsWith('.woff2')) return 'font/woff2';
39
+ if (path.endsWith('.woff')) return 'font/woff';
40
+ if (path.endsWith('.ttf')) return 'font/ttf';
41
+ if (path.endsWith('.otf')) return 'font/otf';
42
+ return 'font/ttf';
43
+ }
44
+
45
+ /**
46
+ * Derive a family name from a path when none is configured.
47
+ * Example: "/fonts/geomanist-regular.ttf" -> "Geomanist Regular".
48
+ */
49
+ export function extractFamilyName(path: string): string {
50
+ const filename = path.split('/').pop() || 'Font';
51
+ const name = filename.replace(/\.(ttf|woff2?|otf)$/i, '');
52
+ return name
53
+ .split('-')
54
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
55
+ .join(' ');
56
+ }
57
+
58
+ /** Build the `@font-face` rules for the given fonts (empty string for none). */
59
+ export function fontFaceCss(fonts: FontConfig[]): string {
60
+ return (fonts || [])
61
+ .filter((font) => font.path || font.src)
62
+ .map((font) => {
63
+ // Support both "path" (standard) and "src" (legacy from website import)
64
+ const fontPath = (font.path || font.src) as string;
65
+ const format = getFontFormat(fontPath);
66
+ const family = font.family || extractFamilyName(fontPath);
67
+ const weight = font.weight ?? 400;
68
+ const weightMax = font.weightMax;
69
+ const style = font.style ?? 'normal';
70
+ const fontDisplay = font.fontDisplay;
71
+
72
+ // Variable fonts use weight range syntax: "100 900"
73
+ const fontWeight = weightMax != null ? `${weight} ${weightMax}` : weight;
74
+ const unicodeRange = font.unicodeRange;
75
+
76
+ return `@font-face {
77
+ font-family: '${family}';
78
+ src: url('${fontPath}') format('${format}');
79
+ font-weight: ${fontWeight};
80
+ font-style: ${style};${fontDisplay ? `\n font-display: ${fontDisplay};` : ''}${unicodeRange ? `\n unicode-range: ${unicodeRange};` : ''}
81
+ }`;
82
+ })
83
+ .join('\n\n');
84
+ }
85
+
86
+ /**
87
+ * Build `<link rel="preload">` tags for the given fonts (empty string for none).
88
+ * `crossorigin` is required for the preloaded font to be reused by the CSS.
89
+ */
90
+ export function fontPreloadLinks(fonts: FontConfig[]): string {
91
+ if (!fonts || fonts.length === 0) return '';
92
+
93
+ return fonts
94
+ .filter((font) => font.path || font.src)
95
+ .map((font) => {
96
+ const fontPath = (font.path || font.src) as string;
97
+ const mimeType = getFontMimeType(fontPath);
98
+ return `<link rel="preload" href="${fontPath}" as="font" type="${mimeType}" crossorigin>`;
99
+ })
100
+ .join('\n ');
101
+ }
@@ -1,15 +1,8 @@
1
1
  import { projectPaths } from '../server/projectContext';
2
2
  import { readJsonFile, fileExists } from '../server/runtime';
3
+ import { fontFaceCss, fontPreloadLinks, extractFamilyName, type FontConfig } from './fontCss';
3
4
 
4
- export interface FontConfig {
5
- path: string;
6
- family?: string;
7
- weight?: number;
8
- weightMax?: number; // If set, font is variable with weight range [weight, weightMax]
9
- style?: 'normal' | 'italic';
10
- fontDisplay?: 'auto' | 'block' | 'swap' | 'fallback' | 'optional';
11
- unicodeRange?: string;
12
- }
5
+ export type { FontConfig };
13
6
 
14
7
  interface ProjectConfig {
15
8
  fonts?: FontConfig[];
@@ -39,92 +32,21 @@ export function getProjectConfig(): ProjectConfig {
39
32
  }
40
33
 
41
34
  /**
42
- * Detect font format from file extension
43
- */
44
- function getFontFormat(path: string): string {
45
- if (path.endsWith('.woff2')) return 'woff2';
46
- if (path.endsWith('.woff')) return 'woff';
47
- if (path.endsWith('.ttf')) return 'truetype';
48
- if (path.endsWith('.otf')) return 'opentype';
49
- return 'truetype';
50
- }
51
-
52
- /**
53
- * Extract font family name from path if not provided
54
- * Example: "/fonts/geomanist-regular.ttf" -> "Geomanist"
35
+ * Generate `@font-face` CSS for the project's configured fonts.
36
+ * Thin wrapper over the pure {@link fontFaceCss} (formatting lives in `./fontCss`
37
+ * so renderers without a project-context cache — e.g. the Astro twin — can reuse it).
55
38
  */
56
- function extractFamilyName(path: string): string {
57
- const filename = path.split('/').pop() || 'Font';
58
- const name = filename.replace(/\.(ttf|woff2?|otf)$/i, '');
59
- // Capitalize and replace hyphens with spaces
60
- return name
61
- .split('-')
62
- .map(part => part.charAt(0).toUpperCase() + part.slice(1))
63
- .join(' ');
64
- }
65
-
66
39
  export function generateFontCSS(): string {
67
- const config = getProjectConfig();
68
- const fonts = config.fonts || [];
69
-
70
- return fonts
71
- .filter((font: any) => font.path || font.src)
72
- .map((font: any) => {
73
- // Support both "path" (standard) and "src" (legacy from website import)
74
- const fontPath: string = font.path || font.src;
75
- const format = getFontFormat(fontPath);
76
- const family = font.family || extractFamilyName(fontPath);
77
- const weight = font.weight ?? 400;
78
- const weightMax = font.weightMax;
79
- const style = font.style ?? 'normal';
80
- const fontDisplay = font.fontDisplay;
81
-
82
- // Variable fonts use weight range syntax: "100 900"
83
- const fontWeight = weightMax != null ? `${weight} ${weightMax}` : weight;
84
-
85
- const unicodeRange = font.unicodeRange;
86
-
87
- return `@font-face {
88
- font-family: '${family}';
89
- src: url('${fontPath}') format('${format}');
90
- font-weight: ${fontWeight};
91
- font-style: ${style};${fontDisplay ? `\n font-display: ${fontDisplay};` : ''}${unicodeRange ? `\n unicode-range: ${unicodeRange};` : ''}
92
- }`;
93
- })
94
- .join('\n\n');
95
- }
96
-
97
- /**
98
- * Get font MIME type for preload "type" attribute
99
- */
100
- function getFontMimeType(path: string): string {
101
- if (path.endsWith('.woff2')) return 'font/woff2';
102
- if (path.endsWith('.woff')) return 'font/woff';
103
- if (path.endsWith('.ttf')) return 'font/ttf';
104
- if (path.endsWith('.otf')) return 'font/otf';
105
- return 'font/ttf';
40
+ return fontFaceCss(getProjectConfig().fonts || []);
106
41
  }
107
42
 
108
43
  /**
109
- * Generate font preload link tags for early font loading
44
+ * Generate font preload link tags for early font loading.
110
45
  * This prevents font swap flash on SPA navigation by ensuring fonts are
111
46
  * loaded and cached before they're needed.
112
47
  */
113
48
  export function generateFontPreloadTags(): string {
114
- const config = getProjectConfig();
115
- const fonts = config.fonts || [];
116
-
117
- if (fonts.length === 0) return '';
118
-
119
- return fonts
120
- .filter((font: any) => font.path || font.src)
121
- .map((font: any) => {
122
- const fontPath: string = font.path || font.src;
123
- const mimeType = getFontMimeType(fontPath);
124
- // crossorigin is required for fonts to be cached properly
125
- return `<link rel="preload" href="${fontPath}" as="font" type="${mimeType}" crossorigin>`;
126
- })
127
- .join('\n ');
49
+ return fontPreloadLinks(getProjectConfig().fonts || []);
128
50
  }
129
51
 
130
52
  export function getFontFamilies(): Record<string, number[]> {
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Tests for the friendly-error mapper.
3
+ */
4
+
5
+ import { describe, it, expect } from 'bun:test';
6
+ import { toFriendlyError } from './friendlyError';
7
+
8
+ describe('toFriendlyError', () => {
9
+ const cases: Array<{ name: string; input: string; expectTitle: string; expectHint?: boolean }> = [
10
+ {
11
+ name: 'null property read (the reported crash)',
12
+ input: "Cannot read properties of null (reading 'length')",
13
+ expectTitle: "A section couldn't load its content",
14
+ expectHint: true,
15
+ },
16
+ {
17
+ name: 'undefined property read (older phrasing)',
18
+ input: "Cannot read property 'map' of undefined",
19
+ expectTitle: "A section couldn't load its content",
20
+ expectHint: true,
21
+ },
22
+ {
23
+ name: 'not a function',
24
+ input: 'foo.bar is not a function',
25
+ expectTitle: "A piece of code didn't run as expected",
26
+ expectHint: true,
27
+ },
28
+ {
29
+ name: 'not defined',
30
+ input: 'myVar is not defined',
31
+ expectTitle: 'A piece of code referenced something missing',
32
+ expectHint: true,
33
+ },
34
+ {
35
+ name: 'network failure',
36
+ input: 'Failed to fetch',
37
+ expectTitle: "Couldn't reach the network",
38
+ expectHint: true,
39
+ },
40
+ {
41
+ name: 'module load failure',
42
+ input: 'Cannot find module ./missing',
43
+ expectTitle: "A required file couldn't be loaded",
44
+ expectHint: true,
45
+ },
46
+ {
47
+ name: 'malformed JSON',
48
+ input: 'Unexpected token < in JSON at position 0',
49
+ expectTitle: 'Some data was malformed',
50
+ expectHint: true,
51
+ },
52
+ ];
53
+
54
+ for (const c of cases) {
55
+ it(`maps ${c.name}`, () => {
56
+ const friendly = toFriendlyError(c.input);
57
+ expect(friendly.title).toBe(c.expectTitle);
58
+ expect(friendly.friendlyMessage.length).toBeGreaterThan(0);
59
+ expect(friendly.raw).toBe(c.input);
60
+ if (c.expectHint) expect(friendly.hint).toBeTruthy();
61
+ });
62
+ }
63
+
64
+ it('includes the offending property name when present', () => {
65
+ const friendly = toFriendlyError("Cannot read properties of null (reading 'length')");
66
+ expect(friendly.friendlyMessage).toContain('length');
67
+ });
68
+
69
+ it('falls back reassuringly for unknown errors but keeps the raw text', () => {
70
+ const friendly = toFriendlyError('Some totally unexpected gibberish');
71
+ expect(friendly.title).toBe('This section ran into a problem');
72
+ expect(friendly.raw).toBe('Some totally unexpected gibberish');
73
+ expect(friendly.hint).toBeUndefined();
74
+ });
75
+
76
+ it('accepts an Error instance', () => {
77
+ const friendly = toFriendlyError(new Error('boom is not a function'));
78
+ expect(friendly.title).toBe("A piece of code didn't run as expected");
79
+ expect(friendly.raw).toBe('boom is not a function');
80
+ });
81
+
82
+ it('accepts non-error values without throwing', () => {
83
+ const friendly = toFriendlyError({ weird: true });
84
+ expect(friendly.raw).toBe('[object Object]');
85
+ expect(friendly.title.length).toBeGreaterThan(0);
86
+ });
87
+ });