diff --git a/.gitea/workflows/auto-label.yml b/.gitea/workflows/auto-label.yml index 723c48d..f66113a 100644 --- a/.gitea/workflows/auto-label.yml +++ b/.gitea/workflows/auto-label.yml @@ -20,22 +20,15 @@ jobs: echo "CI_TOKEN not set; skipping labeling"; exit 0; fi OWNER=$(echo "$REPO" | cut -d'/' -f1) NAME=$(echo "$REPO" | cut -d'/' -f2) - echo "Resolving label id for 'automerge' in $OWNER/$NAME" + echo "Resolving existing label id for 'automerge' in $OWNER/$NAME (no creation)" curl -sS -o /tmp/labels.json -H "Authorization: token ${CI_TOKEN}" \ "${GITEA_BASE_URL}/api/v1/repos/${OWNER}/${NAME}/labels?limit=1000" || true - # Robust parse without jq: find the id preceding name "automerge" - LID=$(tr '\n' ' ' < /tmp/labels.json | grep -o '"id":[0-9][0-9]*[^\}]*"name":"automerge"' | head -n1 | sed -E 's/.*"id":([0-9]+).*/\1/' || true) + # Extract all ids whose name == automerge, prefer the smallest id (oldest) + LID=$(tr '\n' ' ' < /tmp/labels.json | \ + grep -o '"id":[0-9][0-9]*[^\}]*"name":"automerge"' | \ + sed -E 's/.*"id":([0-9]+).*/\1/' | sort -n | head -n1 || true) if [ -z "${LID:-}" ]; then - echo "Label 'automerge' not found. Creating..." - curl -sS -o /tmp/create_label.json -X POST \ - -H "Content-Type: application/json" \ - -H "Authorization: token ${CI_TOKEN}" \ - "${GITEA_BASE_URL}/api/v1/repos/${OWNER}/${NAME}/labels" \ - -d '{"name":"automerge","color":"#0ea5e9"}' - LID=$(tr '\n' ' ' < /tmp/create_label.json | sed -E 's/.*"id":([0-9]+).*/\1/' | head -n1 || true) - fi - if [ -z "${LID:-}" ]; then - echo "Failed to resolve or create label id; skipping"; exit 0; fi + echo "No existing 'automerge' label found; skipping labeling"; exit 0; fi echo "Labeling PR #$PR_NUMBER with id $LID" curl -sS -X POST \ -H "Content-Type: application/json" \ diff --git a/.gitea/workflows/auto-pr.yml b/.gitea/workflows/auto-pr.yml index 87d8fe2..43bd478 100644 --- a/.gitea/workflows/auto-pr.yml +++ b/.gitea/workflows/auto-pr.yml @@ -55,19 +55,12 @@ jobs: done if [ -n "${PR_NUMBER:-}" ]; then - echo "Resolving label id for 'automerge' in $OWNER/$NAME" + echo "Resolving existing label id for 'automerge' in $OWNER/$NAME (no creation)" curl -sS -o /tmp/labels.json -H "Authorization: token ${CI_TOKEN}" \ - "${GITEA_BASE_URL}/api/v1/repos/${OWNER}/${NAME}/labels" - LID=$(awk 'BEGIN{RS=","} /"name":"automerge"/ {if (match($0,/"id":([0-9]+)/,m)) {print m[1]; exit}}' /tmp/labels.json || true) - if [ -z "${LID:-}" ]; then - echo "Label 'automerge' not found. Creating..." - curl -sS -o /tmp/create_label.json -X POST \ - -H "Content-Type: application/json" \ - -H "Authorization: token ${CI_TOKEN}" \ - "${GITEA_BASE_URL}/api/v1/repos/${OWNER}/${NAME}/labels" \ - -d '{"name":"automerge","color":"#0ea5e9"}' - LID=$(sed -n 's/.*"id":\([0-9]*\).*/\1/p' /tmp/create_label.json | head -n1 || true) - fi + "${GITEA_BASE_URL}/api/v1/repos/${OWNER}/${NAME}/labels?limit=1000" || true + LID=$(tr '\n' ' ' < /tmp/labels.json | \ + grep -o '"id":[0-9][0-9]*[^\}]*"name":"automerge"' | \ + sed -E 's/.*"id":([0-9]+).*/\1/' | sort -n | head -n1 || true) if [ -n "${LID:-}" ]; then echo "Adding 'automerge' (id=$LID) label to PR #$PR_NUMBER" curl -sS -X POST \ @@ -76,7 +69,7 @@ jobs: "${GITEA_BASE_URL}/api/v1/repos/${OWNER}/${NAME}/issues/${PR_NUMBER}/labels" \ -d '{"labels":['"${LID}"']}' || true else - echo "Failed to resolve label id; skipping label add" + echo "No existing 'automerge' label found; skipping label add" fi else echo "PR number could not be determined; skipping label add" diff --git a/lib/exporter/cta.edge-cases.test.ts b/lib/exporter/cta.edge-cases.test.ts new file mode 100644 index 0000000..c019200 --- /dev/null +++ b/lib/exporter/cta.edge-cases.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest' +import { exportPage } from '@/lib/exporter/html' +import { pageSchema, type Page } from '@/lib/schema/page' + +function makePage(overrides: Partial = {}): Page { + const base: Page = { + title: 'CTA Edge', + locale: 'en', + theme: { primaryColor: '#2563eb', fontFamily: 'Inter' }, + sections: [ + { type: 'cta', props: { text: ' ', href: '#' } }, + { type: 'features', props: { items: ['One'] } }, + ] as Page['sections'], + form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } }, + analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' }, + seo: { title: 't', description: 'd' }, + } + return pageSchema.parse({ ...base, ...overrides }) +} + +describe('Exporter - CTA edge cases', () => { + it('drops CTA section when text is whitespace-only', () => { + const out = exportPage(makePage()) + expect(out.html).not.toContain('
{ + const out = exportPage(makePage({ sections: [ { type: 'cta', props: { text: ' Buy Now ', href: '#' } } ] as Page['sections'] })) + expect(out.html).toContain('Buy Now') + }) +}) diff --git a/lib/exporter/darkContrast.hover.tokens.test.ts b/lib/exporter/darkContrast.hover.tokens.test.ts new file mode 100644 index 0000000..34f92a8 --- /dev/null +++ b/lib/exporter/darkContrast.hover.tokens.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from 'vitest' +import { exportPage } from '@/lib/exporter/html' +import { pageSchema, type Page } from '@/lib/schema/page' + +function makePage(): Page { + return pageSchema.parse({ + title: 'Dark Contrast Hover', + locale: 'en', + theme: { primaryColor: '#2563eb', fontFamily: 'Inter' }, + sections: [ + { type: 'hero', props: { heading: 'Welcome', subheading: 'Sub', imageUrl: 'https://img.example/h.png' } }, + { type: 'cta', props: { text: 'Call', href: '#' } }, + ], + form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } }, + seo: { title: 't', description: 'd' }, + }) +} + +describe('Exporter - dark mode hover/cta tokens', () => { + it('adds link hover underline and ensures btn-cta readable in dark', () => { + const out = exportPage(makePage()) + const css = out.css + + // underline on hover for links in dark for clearer affordance + expect(css).toMatch(/@media \(prefers-color-scheme: dark\)[\s\S]*a:hover\{[^}]*text-decoration:underline/i) + + // btn-cta has readable foreground/background in dark + expect(css).toMatch(/@media \(prefers-color-scheme: dark\)[\s\S]*\.btn-cta\{[^}]*background:[^;]+;[^}]*color:#fff/i) + }) +}) diff --git a/lib/exporter/darkContrast.tokens.test.ts b/lib/exporter/darkContrast.tokens.test.ts new file mode 100644 index 0000000..29d20d7 --- /dev/null +++ b/lib/exporter/darkContrast.tokens.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest' +import { exportPage } from '@/lib/exporter/html' +import { pageSchema, type Page } from '@/lib/schema/page' + +function makePage(): Page { + return pageSchema.parse({ + title: 'Dark Contrast', + locale: 'en', + theme: { primaryColor: '#2563eb', fontFamily: 'Inter' }, + sections: [ + { type: 'hero', props: { heading: 'Welcome', subheading: 'Sub', imageUrl: 'https://img.example/h.png' } }, + { type: 'faq', props: { items: [{ q: 'Q', a: 'A' }] } }, + { type: 'cta', props: { text: 'Go', href: '#' } }, + ], + form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } }, + seo: { title: 't', description: 'd' }, + }) +} + +describe('Exporter - dark mode contrast tokens', () => { + it('includes dark mode tokens that improve contrast for links/buttons/faq text', () => { + const out = exportPage(makePage()) + const css = out.css + + // dark mode block exists + expect(css).toMatch(/@media \(prefers-color-scheme: dark\)/) + + // link color token in dark + expect(css).toMatch(/@media \(prefers-color-scheme: dark\)[\s\S]*a\{[^}]*color:#93c5fd/i) + + // button keeps readable foreground/background in dark + expect(css).toMatch(/@media \(prefers-color-scheme: dark\)[\s\S]*button\{[^}]*background:[^;]+;[^}]*color:#fff/i) + + // faq answer text brighter in dark + expect(css).toMatch(/@media \(prefers-color-scheme: dark\)[\s\S]*\.faq-a\{[^}]*color:#cbd5e1/i) + }) +}) diff --git a/lib/exporter/hero.edge-cases.test.ts b/lib/exporter/hero.edge-cases.test.ts new file mode 100644 index 0000000..9606abe --- /dev/null +++ b/lib/exporter/hero.edge-cases.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'vitest' +import { exportPage } from '@/lib/exporter/html' +import { pageSchema, type Page } from '@/lib/schema/page' + +function makePage(overrides: Partial = {}): Page { + const base: Page = { + title: 'Hero Edge', + locale: 'en', + theme: { primaryColor: '#2563eb', fontFamily: 'Inter' }, + sections: [ + { type: 'hero', props: { heading: 'Welcome', subheading: ' ', imageUrl: 'https://img.example/h.png' } }, + ] as Page['sections'], + form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } }, + analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' }, + seo: { title: 't', description: 'd' }, + } + return pageSchema.parse({ ...base, ...overrides }) +} + +describe('Exporter - hero edge cases', () => { + it('omits subheading when whitespace-only', () => { + const out = exportPage(makePage()) + expect(out.html).toContain('

Welcome

') + expect(out.html).not.toMatch(/
[\s\S]*

\s*<\/p>/) + }) + + it('trims subheading content', () => { + const out = exportPage(makePage({ sections: [ { type: 'hero', props: { heading: 'Welcome', subheading: ' Hello world ', imageUrl: 'https://img.example/h.png' } } ] as Page['sections'] })) + expect(out.html).toContain('

Hello world

') + }) +}) diff --git a/lib/exporter/html.ts b/lib/exporter/html.ts index 3eff894..2c6cd0b 100644 --- a/lib/exporter/html.ts +++ b/lib/exporter/html.ts @@ -5,7 +5,12 @@ export type Exported = { html: string; css: string; js: string } export type ExportOptions = { assetManager?: AssetManager } function renderFaq(section: { props: { items: Array<{ q: string; a: string }> } }) { - const items: Array<{ q: string; a: string }> = Array.isArray(section.props.items) ? section.props.items : [] + const itemsRaw: Array<{ q: string; a: string }> = Array.isArray(section.props.items) ? section.props.items : [] + const isBlank = (s: string | undefined | null) => !s || s.trim().length === 0 + const items = itemsRaw + .map((it) => ({ q: (it.q || '').trim(), a: (it.a || '').trim() })) + // drop when both question and answer are blank + .filter((it) => !(isBlank(it.q) && isBlank(it.a))) return `
@@ -27,7 +32,9 @@ function renderFaq(section: { props: { items: Array<{ q: string; a: string }> } } function renderFeatures(section: { props: { items: string[] } }) { - const items: string[] = Array.isArray(section.props?.items) ? section.props.items : [] + const items: string[] = (Array.isArray(section.props?.items) ? section.props.items : []) + .map((s) => String(s ?? '').trim()) + .filter((s) => s.length > 0) return `
@@ -41,7 +48,9 @@ function renderFeatures(section: { props: { items: string[] } }) { } function renderBenefits(section: { props: { items: string[] } }) { - const items: string[] = Array.isArray(section.props?.items) ? section.props.items : [] + const items: string[] = (Array.isArray(section.props?.items) ? section.props.items : []) + .map((s) => String(s ?? '').trim()) + .filter((s) => s.length > 0) return `
@@ -55,9 +64,12 @@ function renderBenefits(section: { props: { items: string[] } }) { } function renderTestimonials(section: { props: { items: Array<{ author: string; quote: string }> } }) { - const items: Array<{ author: string; quote: string }> = Array.isArray(section.props?.items) + const items: Array<{ author: string; quote: string }> = (Array.isArray(section.props?.items) ? section.props.items - : [] + : []) + .map((it) => ({ author: (it.author || '').trim(), quote: (it.quote || '').trim() })) + // drop when both are blank + .filter((it) => !(it.author.length === 0 && it.quote.length === 0)) return `
@@ -66,8 +78,8 @@ function renderTestimonials(section: { props: { items: Array<{ author: string; q .map( (it) => `
-
“${escapeHtml(it.quote || '')}”
-
— ${escapeHtml(it.author || '')}
+
“${escapeHtml(it.quote)}”
+ ${it.author.length > 0 ? `
— ${escapeHtml(it.author)}
` : ''}
` ) .join('')} @@ -77,7 +89,9 @@ function renderTestimonials(section: { props: { items: Array<{ author: string; q } function renderCta(section: { props: { text: string; href?: string } }) { - const { text, href } = section.props + const href = section.props.href + const text = (section.props.text || '').trim() + if (text.length === 0) return '' return `
@@ -96,7 +110,9 @@ const escapeHtml = (s: string) => .replaceAll('"', '"') function renderHero(section: { props: { heading: string; subheading?: string; imageUrl: string } }, opts?: ExportOptions) { - const { heading, subheading } = section.props + const { heading } = section.props + const subheadingRaw = section.props.subheading + const subheading = subheadingRaw ? subheadingRaw.trim() : '' const am = opts?.assetManager const imageUrlRaw: string = section.props.imageUrl const imageUrl = am ? am.rewriteUrl(imageUrlRaw) : imageUrlRaw @@ -105,7 +121,7 @@ function renderHero(section: { props: { heading: string; subheading?: string; im

${escapeHtml(heading)}

- ${subheading ? `

${escapeHtml(subheading)}

` : ''} + ${subheading.length > 0 ? `

${escapeHtml(subheading)}

` : ''}
${escapeHtml(heading)} @@ -262,18 +278,20 @@ function buildCss(page: Page) { button:focus, a:focus, input:focus, select:focus, textarea:focus{ outline: 3px solid #2563eb; outline-offset:2px } .faq-list{ list-style:none; padding:0; margin:0 } .faq-item{ margin: .75rem 0 } - .faq-q{ font-size: 1rem; font-weight:600; } - .faq-a{ color:#334155 } + .faq-q{ font-size: 1rem; font-weight:600; overflow-wrap:anywhere } + .faq-a{ color:#334155; overflow-wrap:anywhere } .btn-cta{ background: var(--primary); color:#fff; padding:.75rem 1rem; border-radius:.5rem; display:inline-block; text-decoration:none } .features{ background:#f8fafc } .features-list{ list-style:none; padding:0; margin:0; display:grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: .75rem } - .feature-item{ background:#fff; border:1px solid #e2e8f0; border-radius:.5rem; padding:.75rem } + .feature-item{ background:#fff; border:1px solid #e2e8f0; border-radius:.5rem; padding:.75rem; overflow-wrap:anywhere } + .benefits-list{ list-style:none; padding:0; margin:0 } + .benefit-item{ overflow-wrap:anywhere } .testimonials{ background:#ffffff } .testimonial{ border-left:4px solid var(--primary); padding:.5rem 1rem; margin: .75rem 0; background:#fefefe } - .testimonial blockquote{ margin:0; font-style:italic; color:#1f2937 } + .testimonial blockquote{ margin:0; font-style:italic; color:#1f2937; overflow-wrap:anywhere } .testimonial figcaption{ color:#475569; margin-top:.25rem } @media (max-width: 640px){ .container{ padding: 1rem } h1{ font-size: 1.5rem } } - @media (prefers-color-scheme: dark){ body{ color:#e5e7eb; background:#0b1020 } .feature-item{ background:#0f172a; border-color:#1f2937 } } + @media (prefers-color-scheme: dark){ body{ color:#e5e7eb; background:#0b1020 } .feature-item{ background:#0f172a; border-color:#1f2937 } a{ color:#93c5fd } a:hover{ text-decoration:underline } button{ background: var(--primary); color:#fff } .btn-cta{ background: var(--primary); color:#fff } .faq-a{ color:#cbd5e1 } } @media (prefers-reduced-motion: reduce){ *{ transition: none !important; animation: none !important } } ` } diff --git a/lib/exporter/sections.edge-cases.test.ts b/lib/exporter/sections.edge-cases.test.ts new file mode 100644 index 0000000..7410fd6 --- /dev/null +++ b/lib/exporter/sections.edge-cases.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from 'vitest' +import { exportPage } from '@/lib/exporter/html' +import { pageSchema, type Page } from '@/lib/schema/page' + +function makePage(overrides: Partial = {}): Page { + const base: Page = { + title: 'Edge', + locale: 'en', + theme: { primaryColor: '#2563eb', fontFamily: 'Inter' }, + sections: [ + { type: 'features', props: { items: ['One', ' ', ' ', 'Two'] } }, + { type: 'benefits', props: { items: ['A', ' ', ' ', 'B'] } }, + { type: 'faq', props: { items: [ + { q: ' ', a: ' ' }, + { q: 'Valid Q', a: ' ' }, + { q: 'Another', a: 'Answer' }, + ] } }, + { type: 'testimonials', props: { items: [ + { author: ' ', quote: ' ' }, + { author: 'Jane', quote: ' Great product ' }, + ] } }, + ] as Page['sections'], + form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } }, + analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' }, + seo: { title: 't', description: 'd' }, + } + return pageSchema.parse({ ...base, ...overrides }) +} + +describe('Exporter - sections edge cases', () => { + it('filters out whitespace-only items and keeps valid ones', () => { + const out = exportPage(makePage()) + const html = out.html + + // features: only One, Two + expect(html).toContain('
  • One
  • ') + expect(html).toContain('
  • Two
  • ') + expect(html).not.toMatch(/
  • <\/li>/) + + // benefits: only A, B + expect(html).toContain('
  • A
  • ') + expect(html).toContain('
  • B
  • ') + + // faq: drop fully empty, keep items even if answer empty when question is valid + expect(html).toContain('

    Valid Q

    ') + // testimonials: drop fully empty, keep trimmed quote + expect(html).toContain('
    “Great product”
    ') + }) + + it('adds word-break tokens to avoid overflow for long words', () => { + const out = exportPage(makePage()) + const css = out.css + expect(css).toMatch(/\.feature-item\{[\s\S]*(?:word-break:break-word|overflow-wrap:anywhere)/i) + expect(css).toMatch(/\.benefit-item\{[\s\S]*(?:word-break:break-word|overflow-wrap:anywhere)/i) + expect(css).toMatch(/\.testimonial blockquote\{[\s\S]*(?:word-break:break-word|overflow-wrap:anywhere)/i) + }) +}) diff --git a/lib/exporter/sections.edge-phase3.test.ts b/lib/exporter/sections.edge-phase3.test.ts new file mode 100644 index 0000000..41a80a5 --- /dev/null +++ b/lib/exporter/sections.edge-phase3.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from 'vitest' +import { exportPage } from '@/lib/exporter/html' +import { pageSchema, type Page } from '@/lib/schema/page' + +function makePage(overrides: Partial = {}): Page { + const base: Page = { + title: 'Edge P3', + locale: 'en', + theme: { primaryColor: '#2563eb', fontFamily: 'Inter' }, + sections: [ + { type: 'faq', props: { items: [ { q: 'VeryVeryVeryVeryVeryLongWordWithoutSpaces', a: 'AnotherSuperLongUnbreakableWord' } ] } }, + { type: 'testimonials', props: { items: [ + { author: ' ', quote: 'Great!' }, + { author: 'Alice', quote: 'Nice' }, + ] } }, + ] as Page['sections'], + form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } }, + analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' }, + seo: { title: 't', description: 'd' }, + } + return pageSchema.parse({ ...base, ...overrides }) +} + +describe('Exporter - phase3 edge cases', () => { + it('omits testimonial author line when author is blank', () => { + const out = exportPage(makePage()) + const html = out.html + // First item author blank -> no figcaption for that blockquote + expect(html).toMatch(/
    “Great!\”<\/blockquote>[\s\S]*?<\/figure>/) + // Ensure the next item with author renders figcaption + expect(html).toMatch(/
    — Alice<\/figcaption>/) + }) + + it('adds word-break/overflow-wrap for FAQ q/a to avoid overflow', () => { + const out = exportPage(makePage()) + const css = out.css + expect(css).toMatch(/\.faq-q\{[\s\S]*(?:overflow-wrap:anywhere|word-break:break-word)/i) + expect(css).toMatch(/\.faq-a\{[\s\S]*(?:overflow-wrap:anywhere|word-break:break-word)/i) + }) +}) diff --git a/lib/exporter/zip.ts b/lib/exporter/zip.ts index 6a0ded6..09b7382 100644 --- a/lib/exporter/zip.ts +++ b/lib/exporter/zip.ts @@ -5,6 +5,8 @@ export type BuildZipInput = { css: string js: string assets?: Record + // root-level extra files like favicon.ico, site.webmanifest, robots.txt + extras?: Record } export async function buildZip(input: BuildZipInput): Promise { @@ -19,6 +21,22 @@ export async function buildZip(input: BuildZipInput): Promise { zip.file(path, buf as unknown as Buffer) } } + if (input.extras) { + // validate manifest if present + if (Object.prototype.hasOwnProperty.call(input.extras, 'site.webmanifest')) { + try { + const manifestBytes = input.extras['site.webmanifest'] + const manifestStr = Buffer.from(manifestBytes).toString('utf8') + JSON.parse(manifestStr) + } catch { + throw new Error('Invalid site.webmanifest JSON') + } + } + for (const [path, bytes] of Object.entries(input.extras)) { + const buf = Buffer.from(bytes) + zip.file(path, buf as unknown as Buffer) + } + } const content = await zip.generateAsync({ type: 'uint8array' }) return content } diff --git a/lib/exporter/zip.validation.integrated.test.ts b/lib/exporter/zip.validation.integrated.test.ts new file mode 100644 index 0000000..0244912 --- /dev/null +++ b/lib/exporter/zip.validation.integrated.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest' +import JSZip from 'jszip' +import { exportPage } from '@/lib/exporter/html' +import { buildZip } from '@/lib/exporter/zip' +import { AssetManager } from '@/lib/assets/assetManager' +import { pageSchema, type Page } from '@/lib/schema/page' + +function makePageWithLocalAsset(): { page: Page; am: AssetManager } { + const am = new AssetManager({ maxBytes: 1024 * 1024, allowedExts: ['png','svg','jpg','jpeg','webp'] }) + // simulate adding a local image and referencing it by original name via local: prefix + const bytes = new Uint8Array([7,8,9]) + // add as "hero.png" + am.add({ name: 'hero.png', type: 'image/png', bytes }) + + const base: Page = pageSchema.parse({ + title: 'Integrated ZIP', + locale: 'en', + theme: { primaryColor: '#2563eb', fontFamily: 'Inter' }, + sections: [ + { type: 'hero', props: { heading: 'Hello', subheading: 'World', imageUrl: 'local:hero.png' } }, + { type: 'cta', props: { text: 'Go', href: '#' } }, + ], + form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } }, + analytics: { ga4MeasurementId: 'G-TEST', metaPixelId: '', customHeadHtml: '' }, + seo: { title: 't', description: 'd' }, + }) + return { page: base, am } +} + +describe('Export ZIP 통합 검증', () => { + it('exportPage 출력 + buildZip 결과에서 assets 경로/메타가 보존된다', async () => { + const { page, am } = makePageWithLocalAsset() + const out = exportPage(page, { assetManager: am }) + const assets = am.toZipStructure() + const zipBytes = await buildZip({ html: out.html, css: out.css, js: out.js, assets }) + + const zip = await JSZip.loadAsync(zipBytes) + const names = Object.keys(zip.files) + // assets 파일이 존재해야 함 + const assetKey = Object.keys(assets)[0] + expect(names).toContain(assetKey) + + // index.html에 canonical/meta/ga4 스니펫 보존 + const html = await zip.files['index.html'].async('string') + expect(html).toMatch(/]*src="([^"]+)"/) + expect(match && match[1]).toBe(assetKey) + }) +}) diff --git a/lib/exporter/zip.validation.meta.test.ts b/lib/exporter/zip.validation.meta.test.ts new file mode 100644 index 0000000..5934287 --- /dev/null +++ b/lib/exporter/zip.validation.meta.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest' +import JSZip from 'jszip' +import { buildZip } from '@/lib/exporter/zip' + +describe('Export ZIP 메타 보존 검증', () => { + it('index.html의 SEO/OG/Analytics 메타가 그대로 보존된다', async () => { + const html = ` + + + + + + + ok` + const css = 'body{color:#222}' + const js = 'console.log("ok")' + + const zipBytes = await buildZip({ html, css, js, assets: {} }) + const zip = await JSZip.loadAsync(zipBytes) + const htmlContent = await zip.files['index.html'].async('string') + + expect(htmlContent).toMatch(/ { + it('루트 파일들을 포함한다: favicon.ico / site.webmanifest / robots.txt', async () => { + const manifest = { + name: 'Landing Builder', + short_name: 'LB', + start_url: '/', + display: 'standalone', + icons: [{ src: '/favicon.png', sizes: '512x512', type: 'image/png' }], + } + const input: BuildZipInput = { + html: 'aok', + css: 'body{color:#111}', + js: 'console.log("ok")', + assets: {}, + extras: { + 'favicon.ico': new Uint8Array([0, 1, 2, 3]), + 'site.webmanifest': TE.encode(JSON.stringify(manifest)), + 'robots.txt': TE.encode('User-agent: *\nDisallow:'), + }, + } + const zipBytes = await buildZip(input) + + const zip = await JSZip.loadAsync(zipBytes) + const names = Object.keys(zip.files) + expect(names).toContain('favicon.ico') + expect(names).toContain('site.webmanifest') + expect(names).toContain('robots.txt') + + const robots = await zip.files['robots.txt'].async('string') + expect(robots).toMatch(/User-agent: \*/) + + const parsed = JSON.parse(await zip.files['site.webmanifest'].async('string')) + expect(parsed.name).toBe('Landing Builder') + expect(parsed.icons[0].type).toBe('image/png') + }) + + it('잘못된 manifest JSON이면 실패한다', async () => { + const badInput: BuildZipInput = { + html: 'aok', + css: 'body{color:#111}', + js: 'console.log("ok")', + extras: { 'site.webmanifest': TE.encode('{ invalid json') }, + } + await expect(buildZip(badInput)).rejects.toThrow() + }) +}) diff --git a/lib/exporter/zip.validation.test.ts b/lib/exporter/zip.validation.test.ts new file mode 100644 index 0000000..c6ee0dc --- /dev/null +++ b/lib/exporter/zip.validation.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest' +import JSZip from 'jszip' +import { buildZip } from '@/lib/exporter/zip' + +const TE = new TextEncoder() + +describe('Export ZIP 검증 강화', () => { + it('assets가 비어있을 때 index.html/styles.css/app.js만 포함한다', async () => { + const zipBytes = await buildZip({ + html: 'aok', + css: 'body{color:#111}', + js: 'console.log("ok")', + assets: {}, + }) + const zip = await JSZip.loadAsync(zipBytes) + const names = Object.keys(zip.files).sort() + expect(names).toEqual(['app.js','index.html','styles.css']) + }) + + it('assets가 있으면 모든 파일을 그대로 포함한다(서브폴더 포함)', async () => { + const assets = { + 'assets/img/logo.png': new Uint8Array([0,1,2,3]), + 'assets/svg/icon.svg': TE.encode(''), + 'assets/fonts/inter.woff2': new Uint8Array([4,5,6]), + } + const zipBytes = await buildZip({ + html: 'aok', + css: 'body{color:#111}', + js: 'console.log("ok")', + assets, + }) + const zip = await JSZip.loadAsync(zipBytes) + const names = Object.keys(zip.files) + expect(names).toContain('index.html') + expect(names).toContain('styles.css') + expect(names).toContain('app.js') + expect(names).toContain('assets/img/logo.png') + expect(names).toContain('assets/svg/icon.svg') + expect(names).toContain('assets/fonts/inter.woff2') + + const logo = await zip.files['assets/img/logo.png'].async('uint8array') + expect(Array.from(logo)).toEqual([0,1,2,3]) + }) +})