@407dev/cms-astro 1.0.2 → 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,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":["../../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"]}