@407dev/cms-astro 1.0.1 → 1.1.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.
@@ -1,4 +1,10 @@
1
- import { isValidLinkHref } from '@407dev/field-types';
1
+ import type { CmsAsset } from '@407dev/blocks';
2
+ import {
3
+ isAssetUuid,
4
+ isSafeMediaSrc,
5
+ isValidLinkHref,
6
+ stripUrlControlChars,
7
+ } from '@407dev/field-types';
2
8
 
3
9
  export interface RichTextRenderOptions {
4
10
  components?: Record<string, unknown>;
@@ -47,6 +53,62 @@ export interface LinkMarkAttrs {
47
53
  title?: string;
48
54
  }
49
55
 
56
+ /**
57
+ * Props for an image node. `src` is always a renderable URL string; `asset` is set when the stored
58
+ * asset UUID was resolved into a CmsAsset (published snapshots and draft previews), so components
59
+ * can use focal point and dimensions, e.g. `<CmsImage src={asset ?? src} alt={alt} />`.
60
+ */
61
+ export interface RichTextImageProps {
62
+ src: string;
63
+ alt: string;
64
+ title?: string;
65
+ width?: number;
66
+ height?: number;
67
+ asset?: CmsAsset;
68
+ }
69
+
70
+ function toImageUrl(raw: unknown): string | null {
71
+ if (typeof raw !== 'string') return null;
72
+ const cleaned = stripUrlControlChars(raw);
73
+ // A bare UUID here was never resolved (e.g. the asset was deleted), so there is nothing to load.
74
+ if (isAssetUuid(cleaned) || cleaned.startsWith('#')) return null;
75
+ return isSafeMediaSrc(cleaned) ? cleaned : null;
76
+ }
77
+
78
+ function isImageAsset(obj: Record<string, unknown>): boolean {
79
+ if (typeof obj.id !== 'string' || typeof obj.url !== 'string') return false;
80
+ if (typeof obj.kind === 'string') return obj.kind === 'image';
81
+ return typeof obj.mime_type === 'string' && obj.mime_type.startsWith('image/');
82
+ }
83
+
84
+ /** Normalizes an image node's `src` (URL string or resolved CmsAsset), or null if it has no safe URL. */
85
+ export function getImageNodeProps(
86
+ attrs: Record<string, unknown> | undefined,
87
+ ): RichTextImageProps | null {
88
+ let rawSrc = attrs?.src;
89
+ let asset: CmsAsset | undefined;
90
+ if (rawSrc && typeof rawSrc === 'object' && !Array.isArray(rawSrc)) {
91
+ const obj = rawSrc as Record<string, unknown>;
92
+ // Unresolved wire shapes: `{ kind: 'url', src }` carries a URL; `{ kind: 'asset', id }` has none.
93
+ if (obj.kind === 'url') rawSrc = obj.src;
94
+ else if (obj.kind === 'asset') return null;
95
+ else {
96
+ if (!isImageAsset(obj)) return null;
97
+ asset = rawSrc as CmsAsset;
98
+ rawSrc = asset.url;
99
+ }
100
+ }
101
+ const src = toImageUrl(rawSrc);
102
+ if (!src) return null;
103
+
104
+ const alt = typeof attrs?.alt === 'string' && attrs.alt ? attrs.alt : (asset?.alt ?? '');
105
+ const title = typeof attrs?.title === 'string' && attrs.title ? attrs.title : undefined;
106
+ const width = typeof attrs?.width === 'number' ? attrs.width : (asset?.width ?? undefined);
107
+ const height = typeof attrs?.height === 'number' ? attrs.height : (asset?.height ?? undefined);
108
+
109
+ return { src, alt, title, width, height, asset };
110
+ }
111
+
50
112
  /** Parses a richtext value (doc object or JSON string) into a doc root, or null if invalid/empty. */
51
113
  export function parseRichTextDoc(value: unknown): { type?: string; content?: unknown[] } | null {
52
114
  if (!value) return null;
@@ -232,12 +294,12 @@ export function renderRichTextHtml(doc: unknown, options: RichTextRenderOptions
232
294
  case 'horizontalRule':
233
295
  return '<hr />';
234
296
  case 'image': {
235
- if (options.components?.image) {
236
- const src = typeof n.attrs?.src === 'string' ? escapeHtml(n.attrs.src) : '';
237
- const alt = typeof n.attrs?.alt === 'string' ? escapeHtml(n.attrs.alt) : '';
238
- return `<img src="${src}" alt="${alt}" />`;
239
- }
240
- return '';
297
+ const img = getImageNodeProps(n.attrs);
298
+ if (!img) return '';
299
+ const title = img.title ? ` title="${escapeHtml(img.title)}"` : '';
300
+ const width = img.width ? ` width="${img.width}"` : '';
301
+ const height = img.height ? ` height="${img.height}"` : '';
302
+ return `<img src="${escapeHtml(img.src)}" alt="${escapeHtml(img.alt)}"${title}${width}${height} loading="lazy" />`;
241
303
  }
242
304
  default:
243
305
  return children;
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
- import { renderRichTextHtml } from './richtext/render';
2
+ import { getImageNodeProps, renderRichTextHtml } from './richtext/render';
3
3
 
4
4
  describe('RichText Defensive Rendering', () => {
5
5
  it('renders standard ProseMirror nodes and marks to valid HTML', () => {
@@ -137,3 +137,77 @@ describe('RichText Defensive Rendering', () => {
137
137
  expect(html).not.toContain('Deep text');
138
138
  });
139
139
  });
140
+
141
+ describe('RichText image nodes', () => {
142
+ const imageDoc = (attrs: Record<string, unknown>) => ({
143
+ type: 'doc',
144
+ content: [{ type: 'paragraph', content: [{ type: 'image', attrs }] }],
145
+ });
146
+
147
+ it('renders a URL src without an image component', () => {
148
+ const html = renderRichTextHtml(
149
+ imageDoc({ src: 'https://cdn.example.com/a.jpg', alt: 'A', title: 'Caption' }),
150
+ );
151
+ expect(html).toBe(
152
+ '<p><img src="https://cdn.example.com/a.jpg" alt="A" title="Caption" loading="lazy" /></p>',
153
+ );
154
+ });
155
+
156
+ it('renders a src resolved into a CmsAsset, falling back to its alt and dimensions', () => {
157
+ const asset = {
158
+ id: '0b8f6c1e-2d3a-4b5c-8d9e-0f1a2b3c4d5e',
159
+ url: 'https://assets.example.com/sites/x/assets/y/original.jpg',
160
+ alt: 'Asset alt',
161
+ width: 800,
162
+ height: 600,
163
+ focal_x: 0.5,
164
+ focal_y: 0.5,
165
+ mime_type: 'image/jpeg',
166
+ filename: 'original.jpg',
167
+ };
168
+ expect(getImageNodeProps({ src: asset })).toEqual({
169
+ src: asset.url,
170
+ alt: 'Asset alt',
171
+ title: undefined,
172
+ width: 800,
173
+ height: 600,
174
+ asset,
175
+ });
176
+ expect(renderRichTextHtml(imageDoc({ src: asset, alt: 'Node alt' }))).toBe(
177
+ `<p><img src="${asset.url}" alt="Node alt" width="800" height="600" loading="lazy" /></p>`,
178
+ );
179
+ });
180
+
181
+ it('skips unresolved UUIDs, private or non-image assets, and unsafe schemes', () => {
182
+ for (const src of [
183
+ '0b8f6c1e-2d3a-4b5c-8d9e-0f1a2b3c4d5e',
184
+ { id: 'x', url: null },
185
+ 'javascript:alert(1)',
186
+ '//evil.example.com/a.jpg',
187
+ 'data:image/png;base64,AAAA',
188
+ { kind: 'asset', id: '0b8f6c1e-2d3a-4b5c-8d9e-0f1a2b3c4d5e' },
189
+ { url: 'https://cdn.example.com/a.jpg' },
190
+ { id: 'v', url: 'https://cdn.example.com/a.mp4', kind: 'video', mime_type: 'video/mp4' },
191
+ { id: 'f', url: 'https://cdn.example.com/a.pdf', mime_type: 'application/pdf' },
192
+ { kind: 'url', src: 'javascript:alert(1)' },
193
+ ]) {
194
+ expect(getImageNodeProps({ src })).toBeNull();
195
+ expect(renderRichTextHtml(imageDoc({ src }))).toBe('<p></p>');
196
+ }
197
+ });
198
+
199
+ it('accepts the unresolved { kind: "url", src } wire shape', () => {
200
+ expect(
201
+ getImageNodeProps({ src: { kind: 'url', src: 'https://cdn.example.com/a.jpg' } }),
202
+ ).toEqual({ src: 'https://cdn.example.com/a.jpg', alt: '', asset: undefined });
203
+ });
204
+
205
+ it('escapes alt and title', () => {
206
+ const html = renderRichTextHtml(
207
+ imageDoc({ src: 'https://cdn.example.com/a.jpg', alt: '"><script>', title: '<b>' }),
208
+ );
209
+ expect(html).not.toContain('<script>');
210
+ expect(html).toContain('alt="&quot;&gt;&lt;script&gt;"');
211
+ expect(html).toContain('title="&lt;b&gt;"');
212
+ });
213
+ });
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../../design-tokens/src/tokens.ts"],"names":[],"mappings":";AA+IO,IAAM,UAAA,GAA+B;AAAA;AAAA,EAE1C,UAAA,EAAY,SAAA;AAAA;AAAA,EACZ,IAAA,EAAM,SAAA;AAAA;AAAA,EACN,cAAA,EAAgB,SAAA;AAAA;AAAA,EAChB,OAAA,EAAS,SAAA;AAAA,EACT,iBAAA,EAAmB,SAAA;AAAA,EACnB,KAAA,EAAO,SAAA;AAAA;AAAA,EACP,eAAA,EAAiB,SAAA;AAAA;AAAA,EACjB,WAAA,EAAa,SAAA;AAAA;AAAA,EACb,MAAA,EAAQ,SAAA;AAAA;AAAA,EACR,SAAA,EAAW,SAAA;AAAA,EACX,mBAAA,EAAqB,SAAA;AAAA,EACrB,MAAA,EAAQ,SAAA;AAAA,EACR,gBAAA,EAAkB,SAAA;AAAA;AAAA,EAGlB,UAAA,EAAY,SAAA;AAAA;AAAA,EACZ,mBAAA,EAAqB,SAAA;AAAA;AAAA,EACrB,gBAAA,EAAkB,SAAA;AAAA;AAAA;AAAA,EAGlB,MAAA,EAAQ,SAAA;AAAA;AAAA,EACR,YAAA,EAAc,SAAA;AAAA;AAAA,EACd,KAAA,EAAO,SAAA;AAAA;AAAA,EACP,WAAA,EAAa,SAAA;AAAA,EACb,IAAA,EAAM,SAAA;AAAA;AAAA;AAAA,EAGN,OAAA,EAAS,SAAA;AAAA;AAAA,EACT,iBAAA,EAAmB,SAAA;AAAA,EACnB,WAAA,EAAa,SAAA;AAAA;AAAA,EACb,YAAA,EAAc,SAAA;AAAA;AAAA,EACd,aAAA,EAAe,SAAA;AAAA;AAAA;AAAA,EAGf,OAAA,EAAS,SAAA;AAAA;AAAA,EACT,iBAAA,EAAmB,SAAA;AAAA,EACnB,WAAA,EAAa,SAAA;AAAA;AAAA,EACb,YAAA,EAAc,SAAA;AAAA;AAAA,EACd,aAAA,EAAe,SAAA;AAAA;AAAA;AAAA,EAGf,OAAA,EAAS,SAAA;AAAA;AAAA,EACT,iBAAA,EAAmB,SAAA;AAAA,EACnB,WAAA,EAAa,SAAA;AAAA;AAAA,EACb,YAAA,EAAc,SAAA;AAAA;AAAA,EACd,aAAA,EAAe,SAAA;AAAA;AAAA;AAAA,EAGf,WAAA,EAAa,SAAA;AAAA;AAAA,EACb,qBAAA,EAAuB,SAAA;AAAA,EACvB,eAAA,EAAiB,SAAA;AAAA;AAAA,EACjB,gBAAA,EAAkB,SAAA;AAAA;AAAA,EAClB,iBAAA,EAAmB,SAAA;AAAA;AAAA;AAAA,EAGnB,IAAA,EAAM,SAAA;AAAA;AAAA,EACN,cAAA,EAAgB,SAAA;AAAA,EAChB,QAAA,EAAU,SAAA;AAAA;AAAA,EACV,SAAA,EAAW,SAAA;AAAA;AAAA,EACX,UAAA,EAAY,SAAA;AAAA;AAAA;AAAA,EAGZ,OAAA,EAAS,SAAA;AAAA,EACT,iBAAA,EAAmB,SAAA;AAAA,EACnB,cAAA,EAAgB,SAAA;AAAA,EAChB,wBAAA,EAA0B,SAAA;AAAA,EAC1B,aAAA,EAAe,SAAA;AAAA,EACf,uBAAA,EAAyB,SAAA;AAAA,EACzB,aAAA,EAAe,SAAA;AAAA,EACf,WAAA,EAAa,SAAA;AAAA;AAAA,EAGb,MAAA,EAAQ,SAAA;AAAA;AAAA,EACR,MAAA,EAAQ,SAAA;AAAA;AAAA,EACR,MAAA,EAAQ,SAAA;AAAA;AAAA,EACR,MAAA,EAAQ,SAAA;AAAA;AAAA,EACR,MAAA,EAAQ;AAAA;AACV,CAAA;AAMO,IAAM,YAAA,GAAiC;AAAA,EAC5C,GAAG;AACL","file":"chunk-2TKIQIOE.js","sourcesContent":["export interface ThemeColorTokens {\n background: string;\n card: string;\n cardForeground: string;\n popover: string;\n popoverForeground: string;\n muted: string;\n mutedForeground: string;\n mutedStrong: string;\n sunken: string;\n secondary: string;\n secondaryForeground: string;\n accent: string;\n accentForeground: string;\n foreground: string;\n foregroundSecondary: string;\n foregroundSubtle: string;\n border: string;\n borderStrong: string;\n input: string;\n outlineHalo: string;\n ring: string;\n primary: string;\n primaryForeground: string;\n primaryText: string;\n primaryMuted: string;\n primaryBorder: string;\n success: string;\n successForeground: string;\n successText: string;\n successMuted: string;\n successBorder: string;\n warning: string;\n warningForeground: string;\n warningText: string;\n warningMuted: string;\n warningBorder: string;\n destructive: string;\n destructiveForeground: string;\n destructiveText: string;\n destructiveMuted: string;\n destructiveBorder: string;\n info: string;\n infoForeground: string;\n infoText: string;\n infoMuted: string;\n infoBorder: string;\n sidebar: string;\n sidebarForeground: string;\n sidebarPrimary: string;\n sidebarPrimaryForeground: string;\n sidebarAccent: string;\n sidebarAccentForeground: string;\n sidebarBorder: string;\n sidebarRing: string;\n chart1: string;\n chart2: string;\n chart3: string;\n chart4: string;\n chart5: string;\n}\n\nexport const lightTokens: ThemeColorTokens = {\n // Surfaces\n background: '#f6f6f6',\n card: '#ffffff',\n cardForeground: '#171717',\n popover: '#ffffff',\n popoverForeground: '#171717',\n muted: '#f0f0f0',\n mutedForeground: '#6b6b6b',\n mutedStrong: '#e4e4e4',\n sunken: '#f0f0f0',\n secondary: '#f0f0f0',\n secondaryForeground: '#171717',\n accent: '#f0f0f0',\n accentForeground: '#171717',\n\n // Text\n foreground: '#171717',\n foregroundSecondary: '#4a4a4a',\n foregroundSubtle: '#8a8a8a',\n\n // Borders\n border: '#e4e4e4',\n borderStrong: '#bfbfbf',\n input: '#bfbfbf',\n outlineHalo: '#f0f0f0',\n ring: '#0091ff',\n\n // Brand: Primary\n primary: '#0091ff',\n primaryForeground: '#ffffff',\n primaryText: '#006ec2',\n primaryMuted: '#ebf6ff',\n primaryBorder: '#b3e0ff',\n\n // Status: Success\n success: '#059669',\n successForeground: '#ffffff',\n successText: '#047857',\n successMuted: '#ecfdf5',\n successBorder: '#a7f3d0',\n\n // Status: Warning\n warning: '#d97706',\n warningForeground: '#ffffff',\n warningText: '#b45309',\n warningMuted: '#fffbeb',\n warningBorder: '#fde68a',\n\n // Status: Destructive\n destructive: '#dc2626',\n destructiveForeground: '#ffffff',\n destructiveText: '#b91c1c',\n destructiveMuted: '#fef2f2',\n destructiveBorder: '#fecaca',\n\n // Status: Info\n info: '#2563eb',\n infoForeground: '#ffffff',\n infoText: '#1d4ed8',\n infoMuted: '#eff6ff',\n infoBorder: '#bfdbfe',\n\n // Sidebar\n sidebar: '#ffffff',\n sidebarForeground: '#171717',\n sidebarPrimary: '#0091ff',\n sidebarPrimaryForeground: '#ffffff',\n sidebarAccent: '#f0f0f0',\n sidebarAccentForeground: '#171717',\n sidebarBorder: '#e4e4e4',\n sidebarRing: '#0091ff',\n\n // Charts\n chart1: '#0091ff',\n chart2: '#10b981',\n chart3: '#f59e0b',\n chart4: '#8b5cf6',\n chart5: '#f43f5e',\n};\n\nexport const darkTokens: ThemeColorTokens = {\n // Surfaces\n background: '#0a0a0a', // neutral-950\n card: '#171717', // neutral-900\n cardForeground: '#f5f5f5', // neutral-100\n popover: '#171717',\n popoverForeground: '#f5f5f5',\n muted: '#262626', // neutral-800\n mutedForeground: '#a3a3a3', // neutral-400\n mutedStrong: '#404040', // neutral-700\n sunken: '#0a0a0a', // neutral-950\n secondary: '#262626',\n secondaryForeground: '#f5f5f5',\n accent: '#262626',\n accentForeground: '#f5f5f5',\n\n // Text\n foreground: '#f5f5f5', // neutral-100\n foregroundSecondary: '#d4d4d4', // neutral-300\n foregroundSubtle: '#737373', // neutral-500\n\n // Borders\n border: '#262626', // neutral-800\n borderStrong: '#404040', // neutral-700\n input: '#404040', // neutral-700\n outlineHalo: '#262626',\n ring: '#6366f1', // indigo-500\n\n // Brand: Primary\n primary: '#6366f1', // indigo-500\n primaryForeground: '#ffffff',\n primaryText: '#a5b4fc', // indigo-300\n primaryMuted: '#1e1b4b', // indigo-950\n primaryBorder: '#3730a3', // indigo-800\n\n // Status: Success\n success: '#34d399', // emerald-400\n successForeground: '#022c22',\n successText: '#6ee7b7', // emerald-300\n successMuted: '#022c22', // emerald-950\n successBorder: '#065f46', // emerald-800\n\n // Status: Warning\n warning: '#fbbf24', // amber-400\n warningForeground: '#451a03',\n warningText: '#fcd34d', // amber-300\n warningMuted: '#451a03', // amber-950\n warningBorder: '#92400e', // amber-800\n\n // Status: Destructive\n destructive: '#ef4444', // red-500\n destructiveForeground: '#ffffff',\n destructiveText: '#fca5a5', // red-300\n destructiveMuted: '#450a0a', // red-950\n destructiveBorder: '#991b1b', // red-800\n\n // Status: Info\n info: '#60a5fa', // blue-400\n infoForeground: '#ffffff',\n infoText: '#93c5fd', // blue-300\n infoMuted: '#172554', // blue-950\n infoBorder: '#1e40af', // blue-800\n\n // Sidebar\n sidebar: '#171717',\n sidebarForeground: '#f5f5f5',\n sidebarPrimary: '#6366f1',\n sidebarPrimaryForeground: '#ffffff',\n sidebarAccent: '#262626',\n sidebarAccentForeground: '#f5f5f5',\n sidebarBorder: '#262626',\n sidebarRing: '#6366f1',\n\n // Charts\n chart1: '#6366f1', // Brand Indigo\n chart2: '#34d399', // Emerald\n chart3: '#fbbf24', // Amber\n chart4: '#a78bfa', // Violet\n chart5: '#38bdf8', // Sky\n};\n\n/**\n * Chrome tokens match dark tokens for tool surfaces (site editor shell & preview canvas)\n * so that editor chrome stays dark regardless of active page theme.\n */\nexport const chromeTokens: ThemeColorTokens = {\n ...darkTokens,\n};\n\nexport const tokens = {\n light: lightTokens,\n dark: darkTokens,\n chrome: chromeTokens,\n} as const;\n\nexport const typography = {\n text3xs: { fontSize: '0.625rem', lineHeight: '0.875rem' }, // 10px / 14px\n text2xs: { fontSize: '0.6875rem', lineHeight: '1rem' }, // 11px / 16px\n textXs: { fontSize: '0.75rem', lineHeight: '1rem' }, // 12px / 16px\n textSm: { fontSize: '0.875rem', lineHeight: '1.25rem' }, // 14px / 20px\n} as const;\n\nexport const radii = {\n radius: '0.625rem',\n sm: 'calc(0.625rem * 0.6)',\n md: 'calc(0.625rem * 0.8)',\n lg: '0.625rem',\n xl: 'calc(0.625rem * 1.4)',\n '2xl': 'calc(0.625rem * 1.8)',\n '3xl': 'calc(0.625rem * 2.2)',\n '4xl': 'calc(0.625rem * 2.6)',\n} as const;\n"]}
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../../config/src/index.ts"],"names":[],"mappings":";AAsBO,SAAS,aAAa,GAAA,EAA4B;AACvD,EAAA,MAAM,SAAS,GAAA,EAAK,UAAA,IAAe,OAAO,OAAA,KAAY,WAAA,IAAe,QAAQ,GAAA,EAAK,UAAA;AAClF,EAAA,IAAI,CAAC,MAAA,EAAQ;AACX,IAAA,MAAM,IAAI,MAAM,wBAAwB,CAAA;AAAA,EAC1C;AACA,EAAA,OAAO,MAAA;AACT;AA+BO,IAAM,mBAAA,GAAsB;AAC5B,IAAM,uBAAA,GAA0B","file":"chunk-B4YYLS7Y.js","sourcesContent":["export const configVersion = '1.0.0';\n\nexport * from './preview-token.js';\n\nexport interface JwtSecretEnv {\n JWT_SECRET?: string;\n}\n\nexport interface PreviewTokenSecretEnv {\n PREVIEW_TOKEN_SECRET?: string;\n}\n\n// Local dev JWT secret used consistently across dev environments (.dev.vars, demo-site)\nexport const DEFAULT_DEV_JWT_SECRET =\n 'f44b0b9bf64e28c3b12e3c7ec5cec7b79f139d50f43655138caafe6b890565ce';\n\nexport const DEFAULT_DEV_ORCHESTRATOR_SECRET =\n 'dev-orchestrator-secret-f44b0b9bf64e28c3b12e3c7ec5cec7b79';\n\nexport const DEFAULT_DEV_ANALYTICS_QUERY_INTERNAL_SECRET =\n 'dev-analytics-query-internal-secret-b12e3c7ec5cec7b79f139d5';\n\nexport function getJwtSecret(env?: JwtSecretEnv): string {\n const secret = env?.JWT_SECRET || (typeof process !== 'undefined' && process.env?.JWT_SECRET);\n if (!secret) {\n throw new Error('JWT_SECRET is required');\n }\n return secret;\n}\n\nexport function getPreviewTokenSecret(env?: PreviewTokenSecretEnv): string {\n const secret =\n env?.PREVIEW_TOKEN_SECRET ||\n (typeof process !== 'undefined' && process.env?.PREVIEW_TOKEN_SECRET);\n if (!secret) {\n throw new Error('PREVIEW_TOKEN_SECRET is required');\n }\n return secret;\n}\n\n// Leading slash, no trailing slash (except root '/'), lowercase, duplicate slashes collapsed\nexport function normalizePath(rawPath?: string): string {\n if (!rawPath || rawPath.trim() === '') {\n return '/';\n }\n let p = rawPath.trim().toLowerCase();\n if (!p.startsWith('/')) {\n p = `/${p}`;\n }\n p = p.replace(/\\/{2,}/g, '/');\n if (p.length > 1 && p.endsWith('/')) {\n p = p.replace(/\\/+$/, '');\n }\n return p;\n}\n\n// Local Supabase URL fallback\nexport const DEFAULT_SUPABASE_URL = 'http://127.0.0.1:54321';\n\nexport const DEFAULT_DEV_API_URL = 'http://127.0.0.1:8787';\nexport const DEFAULT_DEV_CONTENT_URL = 'http://127.0.0.1:8788';\nexport const DEFAULT_DEV_ANALYTICS_QUERY_URL = 'http://127.0.0.1:8791';\nexport const DEFAULT_DEV_WEB_URL = 'https://web.localhost';\nexport const DEFAULT_DEV_FALLBACK_WEB_URL = 'http://localhost:5173';\nexport const DEFAULT_DEV_DEMO_SITE_URL = 'https://demo.localhost';\nexport const DEFAULT_DEV_FALLBACK_DEMO_SITE_URL = 'http://localhost:4321';\n\n// Canonical local development origins allowlist across both portless names and raw loopback ports\nexport const LOCAL_DEV_ORIGINS: string[] = [\n 'http://localhost:4321',\n 'http://localhost:3000',\n 'http://localhost:5173',\n 'http://localhost:8787',\n 'http://localhost:8788',\n 'http://localhost:8789',\n 'http://localhost:8790',\n 'http://localhost:8791',\n 'http://127.0.0.1:4321',\n 'http://127.0.0.1:3000',\n 'http://127.0.0.1:5173',\n 'http://127.0.0.1:8787',\n 'http://127.0.0.1:8788',\n 'http://127.0.0.1:8789',\n 'http://127.0.0.1:8790',\n 'http://127.0.0.1:8791',\n 'https://web.localhost',\n 'https://api.localhost',\n 'https://content.localhost',\n 'https://builds.localhost',\n 'https://analytics.localhost',\n 'https://analytics-query.localhost',\n 'https://demo.localhost',\n];\n\n// Local Supabase demo anon key, used only when SUPABASE_ANON_KEY is unset\nexport const DEFAULT_SUPABASE_ANON_KEY =\n 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0';\n\nexport const DEFAULT_SUPABASE_SERVICE_ROLE_KEY =\n 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU';\n\nexport function normalizeOrigin(raw?: string | null): string | null {\n if (!raw || typeof raw !== 'string') return null;\n const trimmed = raw.trim();\n if (!trimmed) return null;\n\n try {\n const withScheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:\\/\\//.test(trimmed)\n ? trimmed\n : `https://${trimmed}`;\n const url = new URL(withScheme);\n if (!url.protocol || !url.host) return null;\n return `${url.protocol}//${url.host}`.toLowerCase();\n } catch {\n return null;\n }\n}\n\nexport function deriveSiteOrigins(\n domain?: string | null,\n customDomain?: string | null,\n opts?: { includeLocalhost?: boolean },\n): string[] {\n const origins = new Set<string>();\n\n const normDomain = normalizeOrigin(domain);\n if (normDomain) origins.add(normDomain);\n\n const normCustom = normalizeOrigin(customDomain);\n if (normCustom) origins.add(normCustom);\n\n if (opts?.includeLocalhost) {\n for (const origin of LOCAL_DEV_ORIGINS) {\n origins.add(origin);\n }\n }\n\n return Array.from(origins);\n}\n\nexport const PAGES_PROJECT_NAME_REGEX = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;\n// 58 = Cloudflare's project-name cap; reserve 8 for the `-preview` twin name.\nexport const PAGES_PROJECT_NAME_MAX = 50;\n\nexport function validatePagesProjectName(name: string): string | null {\n if (!name || typeof name !== 'string' || name.trim() === '') {\n return 'Project name cannot be empty';\n }\n if (name.length > PAGES_PROJECT_NAME_MAX) {\n return `Project name must be ${PAGES_PROJECT_NAME_MAX} characters or fewer`;\n }\n if (!PAGES_PROJECT_NAME_REGEX.test(name)) {\n return 'Project name can only contain lowercase letters, numbers, and hyphens, and cannot start or end with a hyphen';\n }\n return null;\n}\n"]}