@aranova/tracking-next 0.20.0 → 0.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/blog.mjs ADDED
@@ -0,0 +1,965 @@
1
+ // ../tracking-core/src/resources/blog/schema.ts
2
+ import { z as z2 } from "zod";
3
+
4
+ // ../tracking-core/src/resources/blog/theme.ts
5
+ import { z } from "zod";
6
+ var BLOG_BREAKPOINTS = ["sm", "md", "lg", "xl", "2xl", "3xl"];
7
+ var BLOG_BREAKPOINT_MIN_WIDTHS_PX = {
8
+ sm: 640,
9
+ md: 768,
10
+ lg: 1024,
11
+ xl: 1280,
12
+ "2xl": 1536,
13
+ "3xl": 1920
14
+ };
15
+ function toBlogBreakpointMap(value) {
16
+ return typeof value === "object" ? value : { base: value };
17
+ }
18
+ var HEX_COLOR = /^#[0-9a-fA-F]{3,8}$/;
19
+ var FUNCTIONAL_COLOR = /^(rgb|rgba|hsl|hsla|oklch)\([0-9a-zA-Z ,.%/-]*\)$/;
20
+ var LENGTH = /^-?\d+(\.\d+)?(px|rem|em|%|ch|vw)$/;
21
+ var LINE_HEIGHT = /^-?\d+(\.\d+)?(px|rem|em|%|ch|vw)?$/;
22
+ var FONT_FAMILY_TOKEN = /^['"]?[a-zA-Z0-9 -]+['"]?$/;
23
+ var FONT_FAMILY_VAR_TOKEN = /^var\(--[a-zA-Z0-9-]+\)$/;
24
+ var colorValueSchema = z.string().refine((v) => HEX_COLOR.test(v) || FUNCTIONAL_COLOR.test(v), {
25
+ message: "must be a #hex or rgb()/rgba()/hsl()/hsla()/oklch() color"
26
+ });
27
+ var lengthValueSchema = z.string().regex(LENGTH, {
28
+ message: "must be a CSS length (px|rem|em|%|ch|vw)"
29
+ });
30
+ var lineHeightValueSchema = z.string().regex(LINE_HEIGHT, {
31
+ message: "must be a CSS length or unitless line-height"
32
+ });
33
+ var fontWeightSchema = z.number().int().min(100).max(1e3);
34
+ var alignValueSchema = z.enum(["left", "center", "right"]);
35
+ var fontFamilySchema = z.string().refine(
36
+ (v) => {
37
+ const tokens = v.split(",").map((t) => t.trim());
38
+ return tokens.length > 0 && tokens.every((t) => FONT_FAMILY_TOKEN.test(t) || FONT_FAMILY_VAR_TOKEN.test(t));
39
+ },
40
+ { message: "must be a comma-separated list of font-family or var(--custom-property) tokens" }
41
+ );
42
+ function nullishField(schema) {
43
+ return schema.nullish().transform((v) => v ?? void 0);
44
+ }
45
+ function breakpointMapSchema(valueSchema) {
46
+ return z.object({
47
+ base: valueSchema,
48
+ sm: nullishField(valueSchema).catch(void 0),
49
+ md: nullishField(valueSchema).catch(void 0),
50
+ lg: nullishField(valueSchema).catch(void 0),
51
+ xl: nullishField(valueSchema).catch(void 0),
52
+ "2xl": nullishField(valueSchema).catch(void 0),
53
+ "3xl": nullishField(valueSchema).catch(void 0)
54
+ });
55
+ }
56
+ function responsive(valueSchema) {
57
+ return z.union([valueSchema, breakpointMapSchema(valueSchema)]);
58
+ }
59
+ var blogThemeColorsShape = {
60
+ background: colorValueSchema,
61
+ surface: colorValueSchema,
62
+ text: colorValueSchema,
63
+ text_muted: colorValueSchema,
64
+ primary: colorValueSchema,
65
+ primary_foreground: colorValueSchema,
66
+ link: colorValueSchema,
67
+ border: colorValueSchema,
68
+ code_background: colorValueSchema,
69
+ heading: colorValueSchema
70
+ };
71
+ var blogThemeColorsSchema = z.object({
72
+ ...blogThemeColorsShape,
73
+ // The ONE optional color in a resolved theme: when unset, headings fall back
74
+ // to `text` in the stylesheet (`--_heading-color`), so `blogThemeToCssVars`
75
+ // only emits `--ara-blog-color-heading` when a theme actually sets it.
76
+ heading: nullishField(colorValueSchema)
77
+ });
78
+ var blogThemeTypographySchema = z.object({
79
+ heading_family: fontFamilySchema,
80
+ body_family: fontFamilySchema,
81
+ heading_weight: fontWeightSchema,
82
+ body_weight: fontWeightSchema,
83
+ h1_size: responsive(lengthValueSchema),
84
+ h2_size: responsive(lengthValueSchema),
85
+ h3_size: responsive(lengthValueSchema),
86
+ h4_size: responsive(lengthValueSchema),
87
+ body_size: responsive(lengthValueSchema),
88
+ body_line_height: responsive(lineHeightValueSchema),
89
+ small_size: responsive(lengthValueSchema),
90
+ // Scalar (non-responsive) length; the grammar allows negative em values.
91
+ // h1/h2 consume it directly, h3/h4 at half strength (calc in the base CSS).
92
+ heading_letter_spacing: lengthValueSchema,
93
+ // Figure caption text alignment (left/center/right). Site-owner override;
94
+ // default center in the base CSS if unset.
95
+ caption_align: alignValueSchema
96
+ });
97
+ var blogThemeSpacingSchema = z.object({
98
+ content_max_width: responsive(lengthValueSchema),
99
+ block_gap: responsive(lengthValueSchema),
100
+ section_gap: responsive(lengthValueSchema)
101
+ });
102
+ var blogThemeColorsPartialSchema = z.object(
103
+ Object.fromEntries(
104
+ Object.keys(blogThemeColorsShape).map((key) => [key, nullishField(colorValueSchema)])
105
+ )
106
+ );
107
+ var blogThemeSchema = z.object({
108
+ colors: blogThemeColorsSchema,
109
+ colors_dark: nullishField(blogThemeColorsPartialSchema),
110
+ typography: blogThemeTypographySchema,
111
+ spacing: blogThemeSpacingSchema,
112
+ radius: responsive(lengthValueSchema)
113
+ });
114
+ function dropInvalidFields(shape) {
115
+ const out = {};
116
+ for (const [key, schema] of Object.entries(shape)) {
117
+ out[key] = nullTolerant(schema).catch(void 0);
118
+ }
119
+ return out;
120
+ }
121
+ function nullTolerant(schema) {
122
+ if (schema instanceof z.ZodUnion) {
123
+ const options = schema.options;
124
+ return z.union([...options, z.null()]).optional().transform((v) => v ?? void 0);
125
+ }
126
+ return nullishField(schema);
127
+ }
128
+ var blogThemeColorsOverrideSchema = z.object(dropInvalidFields(blogThemeColorsShape));
129
+ var blogThemeTypographyOverrideSchema = z.object(
130
+ dropInvalidFields(blogThemeTypographySchema.shape)
131
+ );
132
+ var blogThemeSpacingOverrideSchema = z.object(dropInvalidFields(blogThemeSpacingSchema.shape));
133
+ var blogThemeOverrideSchema = z.object(
134
+ dropInvalidFields({
135
+ colors: blogThemeColorsOverrideSchema,
136
+ colors_dark: blogThemeColorsOverrideSchema,
137
+ typography: blogThemeTypographyOverrideSchema,
138
+ spacing: blogThemeSpacingOverrideSchema,
139
+ radius: responsive(lengthValueSchema)
140
+ })
141
+ );
142
+ var BASE_BLOG_THEME = {
143
+ colors: {
144
+ background: "#ffffff",
145
+ surface: "#f5f5f5",
146
+ text: "#171717",
147
+ text_muted: "#6b6b6b",
148
+ primary: "#171717",
149
+ primary_foreground: "#ffffff",
150
+ link: "#1d4ed8",
151
+ border: "#e5e5e5",
152
+ code_background: "#f5f5f5"
153
+ },
154
+ typography: {
155
+ heading_family: "system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif",
156
+ body_family: "system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif",
157
+ heading_weight: 700,
158
+ body_weight: 400,
159
+ // Responsive defaults: comfortable mobile sizes, larger from `md` up.
160
+ // This replaces the hardcoded mobile media query the base CSS used to need.
161
+ h1_size: { base: "1.9rem", md: "2.25rem" },
162
+ h2_size: { base: "1.5rem", md: "1.75rem" },
163
+ h3_size: "1.25rem",
164
+ h4_size: "1.125rem",
165
+ body_size: "1rem",
166
+ body_line_height: "1.7",
167
+ small_size: "0.875rem",
168
+ heading_letter_spacing: "-0.02em",
169
+ caption_align: "center"
170
+ },
171
+ spacing: {
172
+ content_max_width: "42rem",
173
+ block_gap: "1.25rem",
174
+ section_gap: "3rem"
175
+ },
176
+ radius: "8px"
177
+ };
178
+ function sanitizeBlogTheme(raw) {
179
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
180
+ const parsed = blogThemeOverrideSchema.safeParse(raw);
181
+ return parsed.success ? parsed.data : {};
182
+ }
183
+ function mergeGroup(base, over) {
184
+ if (!over) return base;
185
+ const out = { ...base };
186
+ for (const [key, value] of Object.entries(over)) {
187
+ if (value !== void 0) out[key] = value;
188
+ }
189
+ return out;
190
+ }
191
+ function resolveBlogTheme(...layers) {
192
+ let resolved = {
193
+ colors: { ...BASE_BLOG_THEME.colors },
194
+ colors_dark: BASE_BLOG_THEME.colors_dark ? { ...BASE_BLOG_THEME.colors_dark } : void 0,
195
+ typography: { ...BASE_BLOG_THEME.typography },
196
+ spacing: { ...BASE_BLOG_THEME.spacing },
197
+ radius: BASE_BLOG_THEME.radius
198
+ };
199
+ for (const layer of layers) {
200
+ if (!layer) continue;
201
+ resolved = {
202
+ colors: mergeGroup(resolved.colors, layer.colors),
203
+ colors_dark: layer.colors_dark || resolved.colors_dark ? mergeGroup(resolved.colors_dark ?? {}, layer.colors_dark) : void 0,
204
+ typography: mergeGroup(resolved.typography, layer.typography),
205
+ spacing: mergeGroup(resolved.spacing, layer.spacing),
206
+ radius: layer.radius ?? resolved.radius
207
+ };
208
+ }
209
+ return resolved;
210
+ }
211
+ function kebab(key) {
212
+ return key.split("_").join("-");
213
+ }
214
+ function emitResponsiveVar(vars, name, value) {
215
+ const map = toBlogBreakpointMap(value);
216
+ vars[name] = String(map.base);
217
+ for (const bp of BLOG_BREAKPOINTS) {
218
+ const bpValue = map[bp];
219
+ if (bpValue !== void 0) vars[`${name}-${bp}`] = String(bpValue);
220
+ }
221
+ }
222
+ function blogThemeToCssVars(theme) {
223
+ const vars = {};
224
+ const dark = theme.colors_dark;
225
+ const hasDark = !!dark && Object.values(dark).some((v) => v !== void 0);
226
+ const colorKeys = /* @__PURE__ */ new Set([...Object.keys(theme.colors), ...dark ? Object.keys(dark) : []]);
227
+ for (const key of colorKeys) {
228
+ const light = theme.colors[key];
229
+ const darkValue = hasDark ? dark?.[key] : void 0;
230
+ const lightValue = light ?? (darkValue !== void 0 ? theme.colors.text : void 0);
231
+ if (lightValue === void 0) continue;
232
+ vars[`--ara-blog-color-${kebab(key)}`] = darkValue ? `light-dark(${lightValue}, ${darkValue})` : lightValue;
233
+ }
234
+ if (hasDark) vars.colorScheme = "light dark";
235
+ for (const [key, value] of Object.entries(theme.typography)) {
236
+ emitResponsiveVar(vars, `--ara-blog-type-${kebab(key)}`, value);
237
+ }
238
+ for (const [key, value] of Object.entries(theme.spacing)) {
239
+ emitResponsiveVar(vars, `--ara-blog-space-${kebab(key)}`, value);
240
+ }
241
+ emitResponsiveVar(vars, "--ara-blog-radius", theme.radius);
242
+ return vars;
243
+ }
244
+
245
+ // ../tracking-core/src/resources/blog/schema.ts
246
+ var schemaVersionSchema = z2.literal(1);
247
+ var blogHeroImageSchema = z2.object({
248
+ url: z2.string(),
249
+ alt: z2.string().catch(""),
250
+ width: z2.number().int(),
251
+ height: z2.number().int()
252
+ });
253
+ var blogManifestEntrySchema = z2.object({
254
+ slug: z2.string(),
255
+ title: z2.string(),
256
+ description: z2.string().catch(""),
257
+ content_hash: z2.string(),
258
+ content_key: z2.string(),
259
+ published_at: z2.string(),
260
+ updated_at: z2.string(),
261
+ // Estimated read time in whole minutes (computed at publish from the post
262
+ // body). 0 when unknown; index cards render "N min read".
263
+ reading_minutes: z2.number().int().catch(0),
264
+ // A malformed tags array / hero_image degrades gracefully instead of
265
+ // dropping the whole entry.
266
+ tags: z2.array(z2.string()).catch([]),
267
+ hero_image: blogHeroImageSchema.nullable().catch(null)
268
+ });
269
+ var blogManifestWireSchema = z2.object({
270
+ schema_version: schemaVersionSchema,
271
+ namespace: z2.string(),
272
+ generated_at: z2.string(),
273
+ posts: z2.array(blogManifestEntrySchema).catch([])
274
+ });
275
+ var blogManifestSchema = blogManifestWireSchema.extend({
276
+ // A single malformed entry is dropped; the rest of the manifest survives.
277
+ posts: z2.array(blogManifestEntrySchema.nullable().catch(null)).transform((entries) => entries.filter((e) => e !== null)).catch([])
278
+ });
279
+ var blogImageSchema = z2.object({
280
+ id: z2.string(),
281
+ url: z2.string(),
282
+ alt: z2.string().catch(""),
283
+ caption: z2.string().nullable().catch(null),
284
+ role: z2.enum(["hero", "inline"]).catch("inline"),
285
+ width: z2.number().int(),
286
+ height: z2.number().int(),
287
+ mime: z2.string().catch("")
288
+ });
289
+ var blogSeoSchema = z2.object({
290
+ meta_title: z2.string().catch(""),
291
+ meta_description: z2.string().catch(""),
292
+ canonical_path: z2.string().catch(""),
293
+ og_image: z2.string().nullable().catch(null)
294
+ });
295
+ var blogPostContentWireSchema = z2.object({
296
+ schema_version: schemaVersionSchema,
297
+ slug: z2.string(),
298
+ title: z2.string(),
299
+ description: z2.string().catch(""),
300
+ mdx: z2.string(),
301
+ images: z2.array(blogImageSchema).catch([]),
302
+ // Already schema-validated at publish time, but re-sanitized on read (and
303
+ // again before rendering) — the override schema itself drops invalid values.
304
+ // Nullish: the backend serializes an unset override as an explicit `null`
305
+ // (Pydantic `model_dump()`); it folds into `undefined` for TS consumers.
306
+ theme_overrides: nullishField(blogThemeOverrideSchema).catch(void 0),
307
+ seo: blogSeoSchema.catch({
308
+ meta_title: "",
309
+ meta_description: "",
310
+ canonical_path: "",
311
+ og_image: null
312
+ }),
313
+ jsonld: z2.record(z2.unknown()).catch({}),
314
+ published_at: z2.string(),
315
+ // The applied_at of the version this content was published from; `null`
316
+ // when the backend has no separate modified date. Nullish on the wire
317
+ // (Pydantic `model_dump()` emits explicit `null`s), folded to `null` for a
318
+ // single TS sentinel; a malformed value degrades to `null` instead of
319
+ // dropping the post.
320
+ updated_at: z2.string().nullish().transform((v) => v ?? null).catch(null),
321
+ version_number: z2.number().int().catch(0),
322
+ content_hash: z2.string()
323
+ });
324
+ var blogPostContentSchema = blogPostContentWireSchema.extend({
325
+ // A single malformed image record is dropped; the post survives.
326
+ images: z2.array(blogImageSchema.nullable().catch(null)).transform((images) => images.filter((i) => i !== null)).catch([])
327
+ });
328
+ function parseBlogManifest(raw) {
329
+ const parsed = blogManifestSchema.safeParse(raw);
330
+ return parsed.success ? parsed.data : null;
331
+ }
332
+ function parseBlogPostContent(raw) {
333
+ const parsed = blogPostContentSchema.safeParse(raw);
334
+ return parsed.success ? parsed.data : null;
335
+ }
336
+
337
+ // ../tracking-core/src/resources/blog/client.ts
338
+ function createBlogClient(config) {
339
+ const base = config.cdnBaseUrl.replace(/\/+$/, "");
340
+ const doFetch = (...args) => (config.fetchImpl ?? fetch)(...args);
341
+ async function fetchJson(url, init) {
342
+ try {
343
+ const response = await doFetch(url, init);
344
+ if (!response.ok) return null;
345
+ return await response.json();
346
+ } catch {
347
+ return null;
348
+ }
349
+ }
350
+ async function getManifest() {
351
+ const init = {
352
+ next: { revalidate: config.manifestRevalidateSeconds ?? 300 }
353
+ };
354
+ const raw = await fetchJson(`${base}/blogs/v1/${config.namespace}/manifest.json`, init);
355
+ return raw === null ? null : parseBlogManifest(raw);
356
+ }
357
+ async function listPosts() {
358
+ const manifest = await getManifest();
359
+ if (!manifest) return [];
360
+ return [...manifest.posts].sort((a, b) => b.published_at.localeCompare(a.published_at));
361
+ }
362
+ async function getPost(slug) {
363
+ const manifest = await getManifest();
364
+ const entry = manifest?.posts.find((post) => post.slug === slug);
365
+ if (!entry) return null;
366
+ const init = { cache: "force-cache" };
367
+ const raw = await fetchJson(`${base}/${entry.content_key.replace(/^\/+/, "")}`, init);
368
+ return raw === null ? null : parseBlogPostContent(raw);
369
+ }
370
+ async function getStaticParams() {
371
+ const posts = await listPosts();
372
+ return posts.map((post) => ({ slug: post.slug }));
373
+ }
374
+ return { getManifest, listPosts, getPost, getStaticParams };
375
+ }
376
+
377
+ // ../tracking-core/src/resources/blog/presets.ts
378
+ var BLOG_THEME_PRESETS = {
379
+ /** Serif display headings over a humanist sans body — long-form magazine feel. */
380
+ editorial: {
381
+ typography: {
382
+ heading_family: "'Source Serif 4', 'Iowan Old Style', 'Palatino Linotype', Georgia, serif",
383
+ body_family: "'Source Sans 3', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif",
384
+ heading_weight: 600,
385
+ body_weight: 400,
386
+ h1_size: { base: "2rem", md: "2.5rem" },
387
+ h2_size: { base: "1.5rem", md: "1.875rem" },
388
+ h3_size: "1.3rem",
389
+ h4_size: "1.125rem",
390
+ body_size: "1.0625rem",
391
+ body_line_height: "1.75",
392
+ heading_letter_spacing: "-0.015em"
393
+ },
394
+ spacing: {
395
+ block_gap: "1.35rem",
396
+ section_gap: "3.25rem"
397
+ },
398
+ radius: "6px"
399
+ },
400
+ /** Geometric sans pairing (Inter-first) — tight, contemporary product voice. */
401
+ modern: {
402
+ typography: {
403
+ heading_family: "'Inter', 'SF Pro Display', 'Segoe UI', Roboto, sans-serif",
404
+ body_family: "'Inter', 'SF Pro Text', 'Segoe UI', Roboto, sans-serif",
405
+ heading_weight: 700,
406
+ body_weight: 400,
407
+ h1_size: { base: "2.1rem", md: "2.75rem" },
408
+ h2_size: { base: "1.5rem", md: "1.75rem" },
409
+ h3_size: "1.25rem",
410
+ h4_size: "1.125rem",
411
+ body_size: "1rem",
412
+ body_line_height: "1.7",
413
+ heading_letter_spacing: "-0.03em"
414
+ },
415
+ spacing: {
416
+ block_gap: "1.25rem",
417
+ section_gap: "3rem"
418
+ },
419
+ radius: "12px"
420
+ },
421
+ /** Rounded-friendly pairing with a softer scale — approachable, human tone. */
422
+ warm: {
423
+ typography: {
424
+ heading_family: "'Nunito', 'Avenir Next', 'Trebuchet MS', Verdana, sans-serif",
425
+ body_family: "'Nunito Sans', 'Avenir Next', 'Trebuchet MS', Verdana, sans-serif",
426
+ heading_weight: 700,
427
+ body_weight: 400,
428
+ h1_size: { base: "1.9rem", md: "2.35rem" },
429
+ h2_size: { base: "1.45rem", md: "1.7rem" },
430
+ h3_size: "1.25rem",
431
+ h4_size: "1.125rem",
432
+ body_size: "1.0625rem",
433
+ body_line_height: "1.75",
434
+ heading_letter_spacing: "-0.01em"
435
+ },
436
+ spacing: {
437
+ block_gap: "1.35rem",
438
+ section_gap: "3.25rem"
439
+ },
440
+ radius: "16px"
441
+ },
442
+ /** Workmanlike sans with a prominent code surface — docs/engineering feel. */
443
+ technical: {
444
+ colors: {
445
+ // The one preset that ships a color: a cool code surface is part of the
446
+ // "technical" identity. Everything else stays on the site palette.
447
+ code_background: "#eef1f5"
448
+ },
449
+ colors_dark: {
450
+ code_background: "#1b1f27"
451
+ },
452
+ typography: {
453
+ heading_family: "'IBM Plex Sans', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif",
454
+ body_family: "'IBM Plex Sans', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif",
455
+ heading_weight: 600,
456
+ body_weight: 400,
457
+ h1_size: { base: "1.85rem", md: "2.25rem" },
458
+ h2_size: { base: "1.45rem", md: "1.65rem" },
459
+ h3_size: "1.2rem",
460
+ h4_size: "1.0625rem",
461
+ body_size: "1rem",
462
+ body_line_height: "1.65",
463
+ heading_letter_spacing: "-0.01em"
464
+ },
465
+ spacing: {
466
+ block_gap: "1.15rem",
467
+ section_gap: "2.75rem"
468
+ },
469
+ radius: "6px"
470
+ }
471
+ };
472
+ function prependFamily(front, stack) {
473
+ if (!front) return stack;
474
+ return stack ? `${front}, ${stack}` : front;
475
+ }
476
+ function createBlogFontTheme(preset, fonts) {
477
+ const heading_family = prependFamily(fonts.heading, preset.typography?.heading_family);
478
+ const body_family = prependFamily(fonts.body, preset.typography?.body_family);
479
+ return {
480
+ ...preset,
481
+ typography: {
482
+ ...preset.typography,
483
+ ...heading_family !== void 0 ? { heading_family } : {},
484
+ ...body_family !== void 0 ? { body_family } : {}
485
+ }
486
+ };
487
+ }
488
+
489
+ // ../tracking-core/src/resources/blog/view/class-names.ts
490
+ function joinClassNames(base, extra) {
491
+ return extra ? `${base} ${extra}` : base;
492
+ }
493
+
494
+ // ../tracking-core/src/resources/blog/seo.ts
495
+ function joinUrl(base, ...paths) {
496
+ let out = base.replace(/\/+$/, "");
497
+ for (const path of paths) {
498
+ const segment = path?.replace(/^\/+/, "").replace(/\/+$/, "");
499
+ if (segment) out += `/${segment}`;
500
+ }
501
+ return out;
502
+ }
503
+ function canonicalUrl(post, opts) {
504
+ return joinUrl(opts.siteUrl, opts.basePath ?? "/blog", post.slug);
505
+ }
506
+ function ogImages(post) {
507
+ if (post.seo.og_image) return [{ url: post.seo.og_image }];
508
+ const hero = post.images.find((image) => image.role === "hero");
509
+ if (!hero) return void 0;
510
+ return [{ url: hero.url, width: hero.width, height: hero.height, alt: hero.alt }];
511
+ }
512
+ function buildBlogPostMetadata(post, opts) {
513
+ const url = canonicalUrl(post, opts);
514
+ const title = post.seo.meta_title || post.title;
515
+ const description = post.seo.meta_description || post.description;
516
+ const images = ogImages(post);
517
+ return {
518
+ title,
519
+ description,
520
+ alternates: { canonical: url },
521
+ openGraph: {
522
+ title,
523
+ description,
524
+ type: "article",
525
+ url,
526
+ ...images ? { images } : {},
527
+ ...post.published_at ? { publishedTime: post.published_at } : {}
528
+ }
529
+ };
530
+ }
531
+ function buildBlogPostingJsonLd(post, opts) {
532
+ const url = canonicalUrl(post, opts);
533
+ const images = post.images.map((image) => image.url);
534
+ const doc = {
535
+ headline: post.title,
536
+ description: post.seo.meta_description || post.description,
537
+ datePublished: post.published_at,
538
+ dateModified: post.updated_at ?? post.published_at,
539
+ ...images.length > 0 ? { image: images } : {},
540
+ ...opts.publisherName ? { publisher: { "@type": "Organization", name: opts.publisherName } } : {}
541
+ };
542
+ const backend = post.jsonld;
543
+ const merged = backend && typeof backend === "object" && Object.keys(backend).length > 0 ? { ...doc, ...backend } : doc;
544
+ return {
545
+ ...merged,
546
+ "@context": "https://schema.org",
547
+ "@type": "BlogPosting",
548
+ url,
549
+ mainEntityOfPage: { "@type": "WebPage", "@id": url }
550
+ };
551
+ }
552
+ var FAQ_ITEM_RE = /<FaqItem\s+question=(?:"([^"]*)"|'([^']*)')\s*>([\s\S]*?)<\/FaqItem>/g;
553
+ var CODE_FENCE_RE = /^ {0,3}(`{3,}|~{3,})[^\n]*\n[\s\S]*?(?:^ {0,3}\1`* *$|(?![\s\S]))/gm;
554
+ var ANSWER_TAG_RE = /<[^>]*>/g;
555
+ function extractFaqPairs(mdx) {
556
+ const withoutCode = mdx.replaceAll(CODE_FENCE_RE, "");
557
+ const pairs = [];
558
+ for (const match of withoutCode.matchAll(FAQ_ITEM_RE)) {
559
+ const question = (match[1] ?? match[2] ?? "").trim();
560
+ const answer = match[3].replaceAll(ANSWER_TAG_RE, " ").replaceAll(/\s+/g, " ").trim();
561
+ if (question && answer) pairs.push({ question, answer });
562
+ }
563
+ return pairs;
564
+ }
565
+ function buildFaqJsonLd(pairs) {
566
+ if (pairs.length === 0) return null;
567
+ return {
568
+ "@context": "https://schema.org",
569
+ "@type": "FAQPage",
570
+ mainEntity: pairs.map((pair) => ({
571
+ "@type": "Question",
572
+ name: pair.question,
573
+ acceptedAnswer: { "@type": "Answer", text: pair.answer }
574
+ }))
575
+ };
576
+ }
577
+ var JSONLD_ESCAPES = {
578
+ "<": "\\u003c",
579
+ ">": "\\u003e",
580
+ "&": "\\u0026",
581
+ "\u2028": "\\u2028",
582
+ "\u2029": "\\u2029"
583
+ };
584
+ function serializeJsonLd(data) {
585
+ return JSON.stringify(data).replaceAll(/[<>&\u2028\u2029]/g, (ch) => JSONLD_ESCAPES[ch] ?? ch);
586
+ }
587
+ function buildBlogSitemapEntries(posts, opts) {
588
+ return posts.map((post) => ({
589
+ url: joinUrl(opts.siteUrl, opts.basePath ?? "/blog", post.slug),
590
+ lastModified: post.updated_at
591
+ }));
592
+ }
593
+
594
+ // ../tracking-core/src/resources/blog/toc.ts
595
+ import GithubSlugger from "github-slugger";
596
+ import remarkMdx from "remark-mdx";
597
+ import remarkParse from "remark-parse";
598
+ import { unified } from "unified";
599
+ var TEXT_LEAF_TYPES = /* @__PURE__ */ new Set(["text", "inlineCode"]);
600
+ function headingText(node) {
601
+ let text = "";
602
+ const stack = [];
603
+ const roots = node.children ?? [];
604
+ for (let i = roots.length - 1; i >= 0; i--) {
605
+ stack.push(roots[i]);
606
+ }
607
+ while (stack.length > 0) {
608
+ const current = stack.pop();
609
+ if (!current) break;
610
+ if (TEXT_LEAF_TYPES.has(current.type) && typeof current.value === "string") {
611
+ text += current.value;
612
+ }
613
+ if (Array.isArray(current.children)) {
614
+ for (let i = current.children.length - 1; i >= 0; i--) {
615
+ stack.push(current.children[i]);
616
+ }
617
+ }
618
+ }
619
+ return text;
620
+ }
621
+ function extractBlogHeadings(mdx) {
622
+ let tree;
623
+ try {
624
+ tree = unified().use(remarkParse).use(remarkMdx).parse(mdx);
625
+ } catch {
626
+ return [];
627
+ }
628
+ const slugger = new GithubSlugger();
629
+ const headings = [];
630
+ const stack = [tree];
631
+ while (stack.length > 0) {
632
+ const node = stack.pop();
633
+ if (!node) break;
634
+ if (node.type === "heading" && typeof node.depth === "number") {
635
+ const text = headingText(node);
636
+ const id = slugger.slug(text);
637
+ if (node.depth === 2 || node.depth === 3) {
638
+ headings.push({ depth: node.depth, text, id });
639
+ }
640
+ }
641
+ if (Array.isArray(node.children)) {
642
+ for (let i = node.children.length - 1; i >= 0; i--) {
643
+ stack.push(node.children[i]);
644
+ }
645
+ }
646
+ }
647
+ return headings;
648
+ }
649
+ var WORDS_PER_MINUTE = 220;
650
+ var MDX_SYNTAX_RE = /<[^>]*>|```[^`]*```|`|^[#>\-*+\s]+|\]\([^)]*\)|[[\]!]/gm;
651
+ function estimateReadingTime(mdx) {
652
+ const words = mdx.replaceAll(MDX_SYNTAX_RE, " ").split(/\s+/).filter((w) => w.length > 0).length;
653
+ return Math.max(1, Math.round(words / WORDS_PER_MINUTE));
654
+ }
655
+
656
+ // ../tracking-core/src/resources/blog/validate-mdx.ts
657
+ import remarkMdx2 from "remark-mdx";
658
+ import remarkParse2 from "remark-parse";
659
+ import { unified as unified2 } from "unified";
660
+ var BLOG_MDX_MAX_SIZE = 5e5;
661
+ var BLOG_MDX_MAX_DEPTH = 64;
662
+ var BLOG_MDX_MAX_NODES = 1e5;
663
+ var MAX_VIOLATIONS = 50;
664
+ var BLOG_MDX_COMPONENT_GRAMMAR = [
665
+ {
666
+ name: "Callout",
667
+ attrs: [],
668
+ urlAttrs: [],
669
+ selfClosing: false,
670
+ cssClass: "aranova-blog-callout",
671
+ promptUsage: "<Callout>...</Callout>",
672
+ promptDescription: "a highlighted tip/note block; no attributes."
673
+ },
674
+ {
675
+ name: "Cta",
676
+ attrs: ["href", "label"],
677
+ urlAttrs: ["href"],
678
+ selfClosing: true,
679
+ cssClass: "aranova-blog-cta",
680
+ promptUsage: '<Cta href="..." label="..." />',
681
+ promptDescription: "a call-to-action button (self-closing)."
682
+ },
683
+ {
684
+ name: "BlogImage",
685
+ attrs: ["id"],
686
+ urlAttrs: [],
687
+ selfClosing: true,
688
+ promptUsage: '<BlogImage id="..." />',
689
+ promptDescription: "a generated image placeholder (self-closing). The `id` references an image brief id from the plan; the renderer resolves it."
690
+ },
691
+ {
692
+ name: "FaqItem",
693
+ attrs: ["question"],
694
+ urlAttrs: [],
695
+ selfClosing: false,
696
+ cssClass: "aranova-blog-faq-item",
697
+ promptUsage: '<FaqItem question="...">...</FaqItem>',
698
+ promptDescription: "one FAQ entry: the required `question` attribute carries the question, the children are the prose answer. Group consecutive `<FaqItem>`s under an FAQ heading near the end of the post when the topic suits a Q&A section."
699
+ },
700
+ {
701
+ name: "KeyTakeaways",
702
+ attrs: [],
703
+ urlAttrs: [],
704
+ selfClosing: false,
705
+ cssClass: "aranova-blog-key-takeaways",
706
+ promptUsage: "<KeyTakeaways>...</KeyTakeaways>",
707
+ promptDescription: "a TL;DR box containing a standard markdown bullet list of 3-5 takeaways; place it right after the H1 (and hero image)."
708
+ }
709
+ ];
710
+ var BLOG_MDX_ALLOWED_COMPONENTS = BLOG_MDX_COMPONENT_GRAMMAR.map((entry) => entry.name);
711
+ var ALLOWED_NODE_TYPES = /* @__PURE__ */ new Set([
712
+ "root",
713
+ "heading",
714
+ "paragraph",
715
+ "text",
716
+ "emphasis",
717
+ "strong",
718
+ "delete",
719
+ "link",
720
+ "image",
721
+ "linkReference",
722
+ "imageReference",
723
+ "definition",
724
+ "list",
725
+ "listItem",
726
+ "blockquote",
727
+ "code",
728
+ "inlineCode",
729
+ "table",
730
+ "tableRow",
731
+ "tableCell",
732
+ "thematicBreak",
733
+ "break"
734
+ ]);
735
+ var FORBIDDEN_NODE_TYPES = {
736
+ html: { rule: "raw-html", message: "Raw HTML is not allowed" },
737
+ mdxjsEsm: { rule: "esm", message: "import/export statements are not allowed" },
738
+ mdxFlowExpression: { rule: "expression", message: "JSX expressions ({\u2026}) are not allowed" },
739
+ mdxTextExpression: { rule: "expression", message: "JSX expressions ({\u2026}) are not allowed" }
740
+ };
741
+ var COMPONENT_SPECS = Object.fromEntries(
742
+ BLOG_MDX_COMPONENT_GRAMMAR.map((entry) => [
743
+ entry.name,
744
+ { attrs: entry.attrs, urlAttrs: entry.urlAttrs, allowChildren: !entry.selfClosing }
745
+ ])
746
+ );
747
+ var URL_SCHEME_RE = /^([A-Za-z][A-Za-z0-9+.-]*):/;
748
+ var ALLOWED_URL_SCHEMES = /* @__PURE__ */ new Set(["http", "https", "mailto"]);
749
+ function forbiddenUrlScheme(rawUrl) {
750
+ const cleaned = rawUrl.replaceAll(/[\u0000-\u0020\u007F]/g, "");
751
+ const match = URL_SCHEME_RE.exec(cleaned);
752
+ if (!match) return null;
753
+ const scheme = match[1].toLowerCase();
754
+ return ALLOWED_URL_SCHEMES.has(scheme) ? null : scheme;
755
+ }
756
+ function nodeLine(node) {
757
+ return node.position?.start?.line;
758
+ }
759
+ function checkComponent(node, violations) {
760
+ const line = nodeLine(node);
761
+ const name = node.name;
762
+ if (!name) {
763
+ violations.push({
764
+ rule: "unknown-component",
765
+ message: "JSX fragments (<>\u2026</>) are not allowed",
766
+ line
767
+ });
768
+ return;
769
+ }
770
+ const spec = COMPONENT_SPECS[name];
771
+ if (!spec) {
772
+ violations.push({
773
+ rule: "unknown-component",
774
+ message: `<${name}> is not an allowed element (allowed: ${BLOG_MDX_ALLOWED_COMPONENTS.join(", ")})`,
775
+ line
776
+ });
777
+ return;
778
+ }
779
+ const seen = /* @__PURE__ */ new Set();
780
+ for (const attr of node.attributes ?? []) {
781
+ if (attr.type !== "mdxJsxAttribute" || typeof attr.name !== "string") {
782
+ violations.push({
783
+ rule: "component-attribute",
784
+ message: `<${name}> has a non-literal attribute`,
785
+ line
786
+ });
787
+ continue;
788
+ }
789
+ if (!spec.attrs.includes(attr.name)) {
790
+ violations.push({
791
+ rule: "component-attribute",
792
+ message: `<${name}> does not accept attribute "${attr.name}"`,
793
+ line
794
+ });
795
+ continue;
796
+ }
797
+ if (typeof attr.value !== "string") {
798
+ violations.push({
799
+ rule: "component-attribute",
800
+ message: `<${name} ${attr.name}> must be a quoted string literal`,
801
+ line
802
+ });
803
+ continue;
804
+ }
805
+ seen.add(attr.name);
806
+ if (spec.urlAttrs.includes(attr.name)) {
807
+ const scheme = forbiddenUrlScheme(attr.value);
808
+ if (scheme !== null) {
809
+ violations.push({
810
+ rule: "url-scheme",
811
+ message: `<${name} ${attr.name}> uses forbidden URL scheme "${scheme}:"`,
812
+ line
813
+ });
814
+ }
815
+ }
816
+ }
817
+ for (const required of spec.attrs) {
818
+ if (!seen.has(required)) {
819
+ violations.push({
820
+ rule: "component-attribute",
821
+ message: `<${name}> requires attribute "${required}"`,
822
+ line
823
+ });
824
+ }
825
+ }
826
+ if (!spec.allowChildren && (node.children?.length ?? 0) > 0) {
827
+ violations.push({
828
+ rule: "component-attribute",
829
+ message: `<${name}> must be self-closing (no children)`,
830
+ line
831
+ });
832
+ }
833
+ }
834
+ function validateBlogMdx(mdx, options) {
835
+ const violations = [];
836
+ if (typeof mdx !== "string" || mdx.length > BLOG_MDX_MAX_SIZE) {
837
+ return {
838
+ ok: false,
839
+ violations: [
840
+ {
841
+ rule: "size-limit",
842
+ message: `Document exceeds the ${BLOG_MDX_MAX_SIZE}-character limit`
843
+ }
844
+ ]
845
+ };
846
+ }
847
+ let tree;
848
+ try {
849
+ tree = unified2().use(remarkParse2).use(remarkMdx2).parse(mdx);
850
+ } catch (error) {
851
+ const line = error.line;
852
+ return {
853
+ ok: false,
854
+ violations: [
855
+ {
856
+ rule: "parse-error",
857
+ message: "Document is not valid MDX",
858
+ ...typeof line === "number" ? { line } : {}
859
+ }
860
+ ]
861
+ };
862
+ }
863
+ if (options?.requireLeadingH1) {
864
+ const firstChild = tree.children?.[0];
865
+ if (!firstChild || firstChild.type !== "heading" || firstChild.depth !== 1) {
866
+ violations.push({
867
+ rule: "leading-h1",
868
+ message: "Document must start with the H1 title (`# ...`) \u2014 no frontmatter or metadata block before it",
869
+ line: nodeLine(firstChild ?? tree)
870
+ });
871
+ }
872
+ }
873
+ let nodeCount = 0;
874
+ const stack = [{ node: tree, depth: 0 }];
875
+ while (stack.length > 0 && violations.length < MAX_VIOLATIONS) {
876
+ const item = stack.pop();
877
+ if (!item) break;
878
+ const { node, depth } = item;
879
+ nodeCount += 1;
880
+ if (nodeCount > BLOG_MDX_MAX_NODES) {
881
+ violations.push({ rule: "size-limit", message: "Document has too many nodes" });
882
+ break;
883
+ }
884
+ if (depth > BLOG_MDX_MAX_DEPTH) {
885
+ violations.push({ rule: "depth-limit", message: "Document nests too deeply" });
886
+ break;
887
+ }
888
+ const forbidden = FORBIDDEN_NODE_TYPES[node.type];
889
+ if (forbidden) {
890
+ violations.push({ ...forbidden, line: nodeLine(node) });
891
+ continue;
892
+ }
893
+ if (node.type === "mdxJsxFlowElement" || node.type === "mdxJsxTextElement") {
894
+ checkComponent(node, violations);
895
+ } else if (!ALLOWED_NODE_TYPES.has(node.type)) {
896
+ violations.push({
897
+ rule: "disallowed-node",
898
+ message: `Markdown construct "${node.type}" is not allowed`,
899
+ line: nodeLine(node)
900
+ });
901
+ continue;
902
+ }
903
+ if ((node.type === "link" || node.type === "image" || node.type === "definition") && typeof node.url === "string") {
904
+ const scheme = forbiddenUrlScheme(node.url);
905
+ if (scheme !== null) {
906
+ violations.push({
907
+ rule: "url-scheme",
908
+ message: `${node.type} uses forbidden URL scheme "${scheme}:"`,
909
+ line: nodeLine(node)
910
+ });
911
+ }
912
+ }
913
+ if (Array.isArray(node.children)) {
914
+ for (const child of node.children) {
915
+ stack.push({ node: child, depth: depth + 1 });
916
+ }
917
+ }
918
+ }
919
+ return violations.length === 0 ? { ok: true } : { ok: false, violations };
920
+ }
921
+ export {
922
+ BASE_BLOG_THEME,
923
+ BLOG_BREAKPOINTS,
924
+ BLOG_BREAKPOINT_MIN_WIDTHS_PX,
925
+ BLOG_MDX_ALLOWED_COMPONENTS,
926
+ BLOG_MDX_COMPONENT_GRAMMAR,
927
+ BLOG_MDX_MAX_DEPTH,
928
+ BLOG_MDX_MAX_NODES,
929
+ BLOG_MDX_MAX_SIZE,
930
+ BLOG_THEME_PRESETS,
931
+ blogHeroImageSchema,
932
+ blogImageSchema,
933
+ blogManifestEntrySchema,
934
+ blogManifestSchema,
935
+ blogManifestWireSchema,
936
+ blogPostContentSchema,
937
+ blogPostContentWireSchema,
938
+ blogSeoSchema,
939
+ blogThemeColorsSchema,
940
+ blogThemeOverrideSchema,
941
+ blogThemeSchema,
942
+ blogThemeSpacingSchema,
943
+ blogThemeToCssVars,
944
+ blogThemeTypographySchema,
945
+ buildBlogPostMetadata,
946
+ buildBlogPostingJsonLd,
947
+ buildBlogSitemapEntries,
948
+ buildFaqJsonLd,
949
+ createBlogClient,
950
+ createBlogFontTheme,
951
+ estimateReadingTime,
952
+ extractBlogHeadings,
953
+ extractFaqPairs,
954
+ joinClassNames,
955
+ nullishField,
956
+ parseBlogManifest,
957
+ parseBlogPostContent,
958
+ resolveBlogTheme,
959
+ responsive,
960
+ sanitizeBlogTheme,
961
+ serializeJsonLd,
962
+ toBlogBreakpointMap,
963
+ validateBlogMdx
964
+ };
965
+ //# sourceMappingURL=blog.mjs.map