From f28bc9ed7aa87591f9825c7aae63ab6171e0d37d Mon Sep 17 00:00:00 2001 From: Jaybe Date: Fri, 14 Nov 2025 22:26:59 +0900 Subject: [PATCH 01/10] =?UTF-8?q?chore(ci):=20auto=20pipeline=20=EC=B5=9C?= =?UTF-8?q?=EC=A2=85=20=EC=9E=AC=EA=B2=80=EC=A6=9D=20=EC=B5=9C=EC=A2=85=20?= =?UTF-8?q?ping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MAIN_PLAN.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/MAIN_PLAN.md b/MAIN_PLAN.md index c8477b5..bd4cc61 100644 --- a/MAIN_PLAN.md +++ b/MAIN_PLAN.md @@ -259,3 +259,5 @@ - [ci] full auto pipeline 재검증 ping (2025-11-14 22:09:02) - [ci] auto-label payload fix 재검증 ping (2025-11-14 22:15:14) + +- [ci] auto pipeline 최종 재검증 최종 ping (2025-11-14 22:26:59) From da91bca1bd61a61375b9c83e335fa07896ecccfc Mon Sep 17 00:00:00 2001 From: Jaybe Date: Fri, 14 Nov 2025 22:33:43 +0900 Subject: [PATCH 02/10] =?UTF-8?q?chore(ci):=20prevent=20duplicate=20'autom?= =?UTF-8?q?erge'=20creation=20=E2=80=94=20use=20existing=20label=20id=20on?= =?UTF-8?q?ly=20(auto-label/auto-pr)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/auto-label.yml | 19 ++++++------------- .gitea/workflows/auto-pr.yml | 19 ++++++------------- 2 files changed, 12 insertions(+), 26 deletions(-) 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..b9d12bd 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' (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" From 4f94e6538472e0542812dcc5638200688c1e831e Mon Sep 17 00:00:00 2001 From: Jaybe Date: Fri, 14 Nov 2025 22:54:34 +0900 Subject: [PATCH 03/10] =?UTF-8?q?feat(exporter):=20=EB=8B=A4=ED=81=AC?= =?UTF-8?q?=EB=AA=A8=EB=93=9C=20=EB=8C=80=EB=B9=84=20=EB=B3=B4=EA=B0=95(a?= =?UTF-8?q?=20=EB=A7=81=ED=81=AC/=ED=98=B8=EB=B2=84,=20=EB=B2=84=ED=8A=BC/?= =?UTF-8?q?CTA,=20FAQ=20=ED=85=8D=EC=8A=A4=ED=8A=B8)=20+=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../darkContrast.hover.tokens.test.ts | 30 +++++++++++++++ lib/exporter/darkContrast.tokens.test.ts | 37 +++++++++++++++++++ lib/exporter/html.ts | 2 +- 3 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 lib/exporter/darkContrast.hover.tokens.test.ts create mode 100644 lib/exporter/darkContrast.tokens.test.ts 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/html.ts b/lib/exporter/html.ts index 3eff894..8368979 100644 --- a/lib/exporter/html.ts +++ b/lib/exporter/html.ts @@ -273,7 +273,7 @@ function buildCss(page: Page) { .testimonial blockquote{ margin:0; font-style:italic; color:#1f2937 } .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 } } ` } From ae250f3eaac2d40e5b63b205442b10c4d4166a82 Mon Sep 17 00:00:00 2001 From: Jaybe Date: Fri, 14 Nov 2025 23:15:05 +0900 Subject: [PATCH 04/10] =?UTF-8?q?feat(exporter):=20=EC=84=B9=EC=85=98=20?= =?UTF-8?q?=EC=97=A3=EC=A7=80=20=EC=BC=80=EC=9D=B4=EC=8A=A4=20=EB=B3=B4?= =?UTF-8?q?=EA=B0=95(=EA=B3=B5=EB=B0=B1=20=EC=A0=9C=EA=B1=B0/=EC=A4=84?= =?UTF-8?q?=EB=B0=94=EA=BF=88=20=ED=86=A0=ED=81=B0/CTA)=20+=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/exporter/cta.edge-cases.test.ts | 32 +++++++++++++ lib/exporter/html.ts | 36 ++++++++++----- lib/exporter/sections.edge-cases.test.ts | 57 ++++++++++++++++++++++++ 3 files changed, 115 insertions(+), 10 deletions(-) create mode 100644 lib/exporter/cta.edge-cases.test.ts create mode 100644 lib/exporter/sections.edge-cases.test.ts 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/html.ts b/lib/exporter/html.ts index 8368979..adb110e 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)}”
+
— ${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 `
@@ -267,10 +281,12 @@ function buildCss(page: Page) { .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 } 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 } } 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) + }) +}) From 3145169188fec55c5bb9b2d79432982b3271aed3 Mon Sep 17 00:00:00 2001 From: Jaybe Date: Fri, 14 Nov 2025 23:36:25 +0900 Subject: [PATCH 05/10] =?UTF-8?q?feat(exporter):=20=EC=84=B9=EC=85=98=20?= =?UTF-8?q?=EC=97=A3=EC=A7=80=203=EC=B0=A8=20=EB=B3=B4=EA=B0=95(FAQ=20?= =?UTF-8?q?=EC=A4=84=EB=B0=94=EA=BF=88/Testimonials=20author=20=EC=83=9D?= =?UTF-8?q?=EB=9E=B5,=20Hero=20subheading=20=ED=8A=B8=EB=A6=BC)=20+=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/exporter/hero.edge-cases.test.ts | 31 ++++++++++++++++++ lib/exporter/html.ts | 12 ++++--- lib/exporter/sections.edge-phase3.test.ts | 40 +++++++++++++++++++++++ 3 files changed, 78 insertions(+), 5 deletions(-) create mode 100644 lib/exporter/hero.edge-cases.test.ts create mode 100644 lib/exporter/sections.edge-phase3.test.ts 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 adb110e..2c6cd0b 100644 --- a/lib/exporter/html.ts +++ b/lib/exporter/html.ts @@ -79,7 +79,7 @@ function renderTestimonials(section: { props: { items: Array<{ author: string; q (it) => `
    “${escapeHtml(it.quote)}”
    -
    — ${escapeHtml(it.author)}
    + ${it.author.length > 0 ? `
    — ${escapeHtml(it.author)}
    ` : ''}
    ` ) .join('')} @@ -110,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 @@ -119,7 +121,7 @@ function renderHero(section: { props: { heading: string; subheading?: string; im

    ${escapeHtml(heading)}

    - ${subheading ? `

    ${escapeHtml(subheading)}

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

    ${escapeHtml(subheading)}

    ` : ''}
    ${escapeHtml(heading)} @@ -276,8 +278,8 @@ 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 } 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) + }) +}) From 19d8575df99bf7b3350adf8693e9e1fe114c16ab Mon Sep 17 00:00:00 2001 From: Jaybe Date: Fri, 14 Nov 2025 23:39:05 +0900 Subject: [PATCH 06/10] =?UTF-8?q?chore(ci):=20=EB=9D=BC=EB=B2=A8=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EC=83=9D=EC=84=B1=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0(auto-label/auto-pr)=20=E2=80=94=20=EA=B8=B0?= =?UTF-8?q?=EC=A1=B4=20'automerge'=EB=A7=8C=20=EC=82=AC=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/auto-label.yml | 19 ++++++------------- .gitea/workflows/auto-pr.yml | 19 ++++++------------- 2 files changed, 12 insertions(+), 26 deletions(-) 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..33e3bb0 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' found; skip labeling" fi else echo "PR number could not be determined; skipping label add" From 0e70cee93c990ee64115626921da9620bc267484 Mon Sep 17 00:00:00 2001 From: Jaybe Date: Fri, 14 Nov 2025 23:45:08 +0900 Subject: [PATCH 07/10] =?UTF-8?q?test(zip):=20Export=20ZIP=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EA=B0=95=ED=99=94(assets=20=EC=9C=A0=EB=AC=B4/?= =?UTF-8?q?=EC=84=9C=EB=B8=8C=ED=8F=B4=EB=8D=94=20=ED=8F=AC=ED=95=A8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/exporter/zip.validation.test.ts | 44 +++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 lib/exporter/zip.validation.test.ts 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]) + }) +}) From 80e7586154531c5167cbc0d0f74fd8f19fa2a17e Mon Sep 17 00:00:00 2001 From: Jaybe Date: Fri, 14 Nov 2025 23:53:28 +0900 Subject: [PATCH 08/10] =?UTF-8?q?test(zip):=20=ED=86=B5=ED=95=A9/=EB=A9=94?= =?UTF-8?q?=ED=83=80=20=EC=8A=A4=EB=83=85=EC=83=B7=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80(exportPage+buildZip,=20SEO/OG/An?= =?UTF-8?q?alytics=20=EB=B3=B4=EC=A1=B4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../zip.validation.integrated.test.ts | 53 +++++++++++++++++++ lib/exporter/zip.validation.meta.test.ts | 28 ++++++++++ 2 files changed, 81 insertions(+) create mode 100644 lib/exporter/zip.validation.integrated.test.ts create mode 100644 lib/exporter/zip.validation.meta.test.ts 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(/ Date: Sat, 15 Nov 2025 00:01:55 +0900 Subject: [PATCH 09/10] =?UTF-8?q?docs(plan):=20CI=20=EB=9D=BC=EB=B2=A8=20?= =?UTF-8?q?=EC=A0=95=EC=B1=85/auto-pr=20=EC=B6=A9=EB=8F=8C=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0/ZIPP=20=EA=B2=80=EC=A6=9D=20TDD=20=EA=B2=B0=EA=B3=BC?= =?UTF-8?q?=20=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MAIN_PLAN.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/MAIN_PLAN.md b/MAIN_PLAN.md index bd4cc61..1a97684 100644 --- a/MAIN_PLAN.md +++ b/MAIN_PLAN.md @@ -243,6 +243,28 @@ - [2025-11-14 09:45] 프리뷰 텍스트 매칭 불안정으로 테스트 실패 → `data-testid` 기반 안정화(`preview-seo-title` 등) 및 텍스트 매칭 지점 `Title:`→`Locale:`로 조정, 전면 그린 - [2025-11-14 20:30] E2E 실패: 스킵링크 활성화 후 main 포커스 미이동 → Exporter JS에 스킵링크 클릭 핸들러 추가(`tabindex=-1` + `focus()`) → 로컬/CI 모두 그린 +- [2025-11-14 23:45] Gitea 라벨 중복 생성/페이로드 오류 → 워크플로 수정 + - auto-label.yml/auto-pr.yml에서 라벨 생성 로직 제거(기존 레이블만 사용) + - 라벨 지정 시 JSON 페이로드를 `{"labels":[id]}` 형태로 고정 + - 다중 동일 라벨 존재 시 가장 작은 ID를 선택하도록 정렬 적용 + - ci.yml 자동 머지 가드: PR 라벨 조회 후 `automerge` 존재시에만 머지 + +## CI 파이프라인 자동화 결과(automerge) +- 브랜치 푸시 → auto-pr로 PR 생성 → auto-label로 `automerge` 부여(기존 레이블만) → ci.yml에서 테스트 그린 시 자동 머지 +- 안전장치: `automerge` 라벨 없으면 머지 스킵, PR 닫힘/드래프트 시 머지 스킵 +- 진단 로그: API status/라벨 JSON 일부/머지 응답 코드 출력으로 디버그 용이성 확보 + +## 충돌 해결 기록 +- [2025-11-14 23:50] `.gitea/workflows/auto-pr.yml` main 병합 충돌 해결 + - 충돌 마커 제거, 메시지 통일: "기존 'automerge' 라벨만, 생성 금지" + - PR 번호 해석 재시도 루프 유지 및 라벨 ID 해석 로직 안정화 + +## ZIP 검증 TDD(자산/메타) +- 통합/메타 테스트 추가: `zip.validation.integrated.test.ts`, `zip.validation.meta.test.ts` + - exportPage + buildZip 통합: 자산 경로 재작성 및 포함, 하위 폴더 포함 검증 + - index.html 메타(SEO/OG/Analytics) 보존 스냅샷 검증 + - Hero 이미지 `loading="lazy"`, `decoding="async"`, canonical, 다크모드 토큰 존재 확인 + ## 의사결정 기록(ADR) - [YYYY-MM-DD] Next.js(App Router) 채택: 생태계/배포/서버리스/SEO 편의성. - [YYYY-MM-DD] Sheets 기본 방안: Apps Script 웹앱 템플릿(사용자 편의/트래픽 분산). From 9473b84b81b063d3a0de963f62c8e467d2c0e410 Mon Sep 17 00:00:00 2001 From: Jaybe Date: Sat, 15 Nov 2025 00:11:05 +0900 Subject: [PATCH 10/10] =?UTF-8?q?feat(zip):=20=EB=A3=A8=ED=8A=B8=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC(extras)=20=ED=8F=AC=ED=95=A8=20=EB=B0=8F=20s?= =?UTF-8?q?ite.webmanifest=20JSON=20=EA=B2=80=EC=A6=9D=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80\n\n-=20favicon.ico/site.webmanifest/robots.txt=20ZIP?= =?UTF-8?q?=20=ED=8F=AC=ED=95=A8=20=EC=A7=80=EC=9B=90\n-=20=EC=9E=98?= =?UTF-8?q?=EB=AA=BB=EB=90=9C=20manifest=20JSON=20=EC=8B=9C=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC\n-=20=ED=85=8C=EC=8A=A4=ED=8A=B8(phase4)=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=ED=86=B5=EA=B3=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/exporter/zip.ts | 18 ++++++++ lib/exporter/zip.validation.phase4.test.ts | 53 ++++++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 lib/exporter/zip.validation.phase4.test.ts 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.phase4.test.ts b/lib/exporter/zip.validation.phase4.test.ts new file mode 100644 index 0000000..15d26f5 --- /dev/null +++ b/lib/exporter/zip.validation.phase4.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest' +import JSZip from 'jszip' +import { buildZip } from '@/lib/exporter/zip' +import type { BuildZipInput } from '@/lib/exporter/zip' + +const TE = new TextEncoder() + +describe('Export ZIP 검증 강화 4차: 루트 파일(favicon/manifest/robots)', () => { + 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() + }) +})