diff --git a/README.md b/README.md index e215bc4..2efff98 100644 --- a/README.md +++ b/README.md @@ -34,3 +34,61 @@ You can check out [the Next.js GitHub repository](https://github.com/vercel/next The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. + +## Export Bundle (Landing ZIP) + +The Builder can export a production-ready ZIP that contains: + +- index.html, styles.css, app.js +- assets/ hashed assets (optionally grouped by type) +- site.webmanifest, robots.txt +- Optional: sheets/ (Google Apps Script template) +- Metadata files: assets.json, assets.index.json + +### Export Options UI + +- Manifest + - Name, Short Name, Start URL, Display (dropdown), Theme Color (HEX only) +- Robots + - Disallow lines (textarea). Each line becomes a `Disallow:` rule. +- Google Sheets Template Mode + - Auto: include when `form.actionUrl` is empty + - Include: always include + - Exclude: never include + +### site.webmanifest and robots.txt + +- `site.webmanifest` is derived from page data; Export Options can override fields. +- `robots.txt` defaults to a permissive header and adds `Disallow` lines from UI. + +### Asset paths and grouping + +- AssetManager writes files to `assets/` with stable hash-based filenames. +- Path strategy can be configured: + - `flat` (default): `assets/.` + - `grouped`: `assets/images/...`, `assets/fonts/...`, `assets/other/...` + +### assets.json + +Structured metadata for all assets. Example fields per entry: + +- originalName, mimeType, size +- hash8, ext, zipPath +- integrity: `sha256-` (placeholder, base64 of bytes) +- group: `images|fonts|other` +- referencedAt: array of `local:` when referenced by the page + +### assets.index.json + +Simple index to quickly find assets by group: + +- images: string[] of zip paths +- fonts: string[] of zip paths +- other: string[] of zip paths + +### Google Sheets template (optional) + +- When included, ZIP contains: + - `sheets/Code.gs`: minimal Apps Script handler (Web App) + - `sheets/README.txt`: setup instructions +- Inclusion is controlled by `sheetsMode` (Auto/Include/Exclude) and `form.actionUrl` in Auto mode. diff --git a/components/BuilderClientPage.tsx b/components/BuilderClientPage.tsx index 66a27d3..7d3bfb8 100644 --- a/components/BuilderClientPage.tsx +++ b/components/BuilderClientPage.tsx @@ -8,6 +8,7 @@ import { AssetManager } from '@/lib/assets/assetManager' import { buildZip } from '@/lib/exporter/zip' import { buildExtrasForPage } from '@/lib/exporter/extras' import { buildAssetsMetadata } from '@/lib/exporter/assets.meta' +import { buildAssetsIndex } from '@/lib/exporter/assets.index' import type { Section } from '@/lib/state/store' import { createFormStore, type FormState } from '@/lib/state/formStore' import FormBuilderPanel from '@/components/FormBuilderPanel' @@ -56,6 +57,10 @@ export default function BuilderClientPage() { const [manifestDisplay, setManifestDisplay] = useState('') const [manifestThemeColor, setManifestThemeColor] = useState('') const [robotsDisallow, setRobotsDisallow] = useState('') + const [sheetsMode, setSheetsMode] = useState<'auto' | 'include' | 'exclude'>('auto') + const [assetPathStrategy, setAssetPathStrategy] = useState<'flat' | 'grouped'>('flat') + // Font optimization: allow toggling Google Fonts preconnect links in exported HTML + const [fontPreconnect, setFontPreconnect] = useState(true) const [viewport, setViewport] = useState<'desktop' | 'mobile'>(() => { if (typeof window !== 'undefined') { try { @@ -174,9 +179,22 @@ export default function BuilderClientPage() { try { if (typeof window !== 'undefined') { const w = window as unknown as { __exportPreview?: () => { html: string; css: string; js: string } } + // Build an effective AssetManager based on selected path strategy + const buildEffectiveAm = (): AssetManager | undefined => { + const base = am ?? undefined + if (!base) return undefined + if (assetPathStrategy === 'flat') return base + const clone = new AssetManager({ maxBytes: 10 * 1024 * 1024, allowedExts: ['png', 'jpg', 'jpeg', 'webp', 'svg'], pathStrategy: assetPathStrategy }) + for (const ref of base.list()) { + // re-add to apply new path strategy and recreate byOriginal mapping + clone.add({ name: ref.originalName, type: ref.mimeType, bytes: ref.bytes }) + } + return clone + } w.__exportPreview = () => { try { - return exportPage(pageSchema.parse(pageData), { assetManager: am ?? undefined }) + // Export preview with current optimization options + return exportPage(pageSchema.parse(pageData), { assetManager: buildEffectiveAm(), fontPreconnect }) } catch { const fixed: Page = { ...pageData, @@ -186,12 +204,12 @@ export default function BuilderClientPage() { spamProtection: pageData.form?.spamProtection ?? { honeypotFieldName: '_hp', minSubmitSeconds: 2 }, }, } - return exportPage(pageSchema.parse(fixed), { assetManager: am ?? undefined }) + return exportPage(pageSchema.parse(fixed), { assetManager: buildEffectiveAm(), fontPreconnect }) } } } } catch {} - }, [pageData, am]) + }, [pageData, am, fontPreconnect, assetPathStrategy]) const doExport = useCallback(async () => { let valid = pageData as Page @@ -208,7 +226,19 @@ export default function BuilderClientPage() { } as Page valid = pageSchema.parse(fixed) } - const exported = exportPage(valid, { assetManager: am ?? undefined }) + // Build an effective AssetManager based on selected path strategy + const buildEffectiveAm = (): AssetManager | undefined => { + const base = am ?? undefined + if (!base) return undefined + if (assetPathStrategy === 'flat') return base + const clone = new AssetManager({ maxBytes: 10 * 1024 * 1024, allowedExts: ['png', 'jpg', 'jpeg', 'webp', 'svg'], pathStrategy: assetPathStrategy }) + for (const ref of base.list()) { + clone.add({ name: ref.originalName, type: ref.mimeType, bytes: ref.bytes }) + } + return clone + } + // Respect font optimization toggle when exporting + const exported = exportPage(valid, { assetManager: buildEffectiveAm(), fontPreconnect }) try { if (typeof window !== 'undefined') { const w = window as unknown as { __captureExport?: boolean; __lastExport?: { html: string; css: string; js: string } } @@ -217,7 +247,17 @@ export default function BuilderClientPage() { } } } catch {} - const assets = am ? am.toZipStructure() : {} + const effectiveAm = (() => { + const base = am ?? undefined + if (!base) return undefined + if (assetPathStrategy === 'flat') return base + const clone = new AssetManager({ maxBytes: 10 * 1024 * 1024, allowedExts: ['png', 'jpg', 'jpeg', 'webp', 'svg'], pathStrategy: assetPathStrategy }) + for (const ref of base.list()) { + clone.add({ name: ref.originalName, type: ref.mimeType, bytes: ref.bytes }) + } + return clone + })() + const assets = effectiveAm ? effectiveAm.toZipStructure() : {} // Build overrides from UI options const manifestOverrideBase: Record = {} if (manifestName && manifestName.trim().length > 0) manifestOverrideBase.name = manifestName.trim() @@ -238,14 +278,14 @@ export default function BuilderClientPage() { const disallowLines = lines.map((l) => (l.startsWith('Disallow:') ? l : `Disallow: ${l}`)) return `User-agent: *\n${disallowLines.join('\n')}` })() - const baseExtras = buildExtrasForPage(valid, { robotsText: robotsTextOverride, manifest: manifestOverride }) + const baseExtras = buildExtrasForPage(valid, { robotsText: robotsTextOverride, manifest: manifestOverride, sheetsMode }) const extras: Record = { ...baseExtras, - ...(am ? { 'assets.json': buildAssetsMetadata(am) } : {}), + ...(am ? { 'assets.json': buildAssetsMetadata(am), 'assets.index.json': buildAssetsIndex(am) } : {}), } const zipBytes = await buildZip({ html: exported.html, css: exported.css, js: exported.js, assets, extras }) download('landing.zip', zipBytes) - }, [pageData, am, manifestName, manifestShortName, manifestStartUrl, manifestDisplay, manifestThemeColor, robotsDisallow]) + }, [pageData, am, manifestName, manifestShortName, manifestStartUrl, manifestDisplay, manifestThemeColor, robotsDisallow, sheetsMode, fontPreconnect, assetPathStrategy]) return (
@@ -310,6 +350,31 @@ export default function BuilderClientPage() {

Export Options

+ {/* Asset path strategy: flat vs grouped */} + + {/* Font optimization toggle for Google Fonts preconnect */} + +
} diff --git a/lib/assets/assetManager.ts b/lib/assets/assetManager.ts index 3d5525b..18e1387 100644 --- a/lib/assets/assetManager.ts +++ b/lib/assets/assetManager.ts @@ -16,6 +16,7 @@ export type AssetRef = { export type AssetManagerOptions = { maxBytes: number allowedExts: string[] // e.g., ['png','jpg','jpeg','webp','svg'] + pathStrategy?: 'flat' | 'grouped' } function extOf(name: string): string { @@ -36,6 +37,7 @@ function hash8(bytes: Uint8Array): string { export class AssetManager { private readonly maxBytes: number private readonly allowed: Set + private readonly pathStrategy: 'flat' | 'grouped' private byHash = new Map() private byOriginal = new Map() // originalName -> path private referenced = new Set() // originalName referenced via rewrite @@ -43,6 +45,7 @@ export class AssetManager { constructor(opts: AssetManagerOptions) { this.maxBytes = opts.maxBytes this.allowed = new Set(opts.allowedExts.map((e) => e.toLowerCase())) + this.pathStrategy = opts.pathStrategy ?? 'flat' } async add(file: AssetInput): Promise { @@ -61,7 +64,7 @@ export class AssetManager { return existing } - const path = `assets/${h}.${ext}` + const path = this.makePath(h, ext, file.type) const ref: AssetRef = { originalName: file.name, mimeType: file.type, @@ -101,4 +104,14 @@ export class AssetManager { referencedList(): string[] { return Array.from(this.referenced) } + + private makePath(hash8: string, ext: string, mime: string): string { + if (this.pathStrategy === 'grouped') { + let group = 'other' + if (mime.startsWith('image/')) group = 'images' + else if (mime.startsWith('font/')) group = 'fonts' + return `assets/${group}/${hash8}.${ext}` + } + return `assets/${hash8}.${ext}` + } } diff --git a/lib/exporter/assets.index.test.ts b/lib/exporter/assets.index.test.ts new file mode 100644 index 0000000..fc278f9 --- /dev/null +++ b/lib/exporter/assets.index.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest' +import { AssetManager } from '@/lib/assets/assetManager' +import { buildAssetsIndex } from '@/lib/exporter/assets.index' + +function u8(n: number): Uint8Array { const a = new Uint8Array(n); for (let i=0;i { + it('groups assets by mime into images/fonts/other and lists zipPath', async () => { + const am = new AssetManager({ maxBytes: 1024*1024, allowedExts: ['png','woff2','txt'] }) + await am.add({ name: 'a.png', type: 'image/png', bytes: u8(4) }) + await am.add({ name: 'b.woff2', type: 'font/woff2', bytes: u8(6) }) + await am.add({ name: 'c.txt', type: 'text/plain', bytes: u8(2) }) + const bytes = buildAssetsIndex(am) + const obj = JSON.parse(new TextDecoder().decode(bytes)) as { images: string[]; fonts: string[]; other: string[] } + expect(obj.images.length).toBe(1) + expect(obj.fonts.length).toBe(1) + expect(obj.other.length).toBe(1) + expect(obj.images[0]).toMatch(/^assets\/[0-9a-f]{8}\.png$/) + expect(obj.fonts[0]).toMatch(/^assets\/[0-9a-f]{8}\.woff2$/) + expect(obj.other[0]).toMatch(/^assets\/[0-9a-f]{8}\.txt$/) + }) +}) diff --git a/lib/exporter/assets.index.ts b/lib/exporter/assets.index.ts new file mode 100644 index 0000000..fb82246 --- /dev/null +++ b/lib/exporter/assets.index.ts @@ -0,0 +1,21 @@ +import type { AssetManager } from '@/lib/assets/assetManager' + +export type AssetsIndex = { + images: string[] + fonts: string[] + other: string[] +} + +export function buildAssetsIndex(am: AssetManager): Uint8Array { + const images: string[] = [] + const fonts: string[] = [] + const other: string[] = [] + for (const ref of am.list()) { + if (ref.mimeType.startsWith('image/')) images.push(ref.path) + else if (ref.mimeType.startsWith('font/')) fonts.push(ref.path) + else other.push(ref.path) + } + const obj: AssetsIndex = { images, fonts, other } + const json = JSON.stringify(obj, null, 2) + return new TextEncoder().encode(json) +} diff --git a/lib/exporter/assets.pathStrategy.integration.test.ts b/lib/exporter/assets.pathStrategy.integration.test.ts new file mode 100644 index 0000000..9026322 --- /dev/null +++ b/lib/exporter/assets.pathStrategy.integration.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest' +import { exportPage } from '@/lib/exporter/html' +import { pageSchema, type Page } from '@/lib/schema/page' +import { AssetManager } from '@/lib/assets/assetManager' + +function makePage(): Page { + return pageSchema.parse({ + title: 'Path Strategy', + locale: 'en', + theme: { primaryColor: '#2563eb', fontFamily: 'Inter' }, + sections: [ { type: 'hero', props: { heading: 'Hello', subheading: 'World', imageUrl: 'local:hero.png' } } ], + form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } }, + seo: { title: 't', description: 'd' }, + }) +} + +describe('자산 경로 구조 옵션', () => { + it('grouped 전략일 때 이미지가 assets/images/ 경로로 렌더된다', async () => { + const page = makePage() + const am = new AssetManager({ maxBytes: 1024 * 1024, allowedExts: ['png'], pathStrategy: 'grouped' }) + // add hero image asset + const bytes = new Uint8Array([1,2,3,4,5]) + await am.add({ name: 'hero.png', type: 'image/png', bytes }) + const out = exportPage(page, { assetManager: am }) + expect(out.html).toMatch(/assets\/images\/[a-f0-9]{8}\.png/i) + }) +}) diff --git a/lib/exporter/extras.autoEnrich.test.ts b/lib/exporter/extras.autoEnrich.test.ts new file mode 100644 index 0000000..b15cf14 --- /dev/null +++ b/lib/exporter/extras.autoEnrich.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest' +import { buildExtrasForPage } from '@/lib/exporter/extras' +import { pageSchema, type Page } from '@/lib/schema/page' + +function makePage(): Page { + return pageSchema.parse({ + title: 'My Product', + locale: 'en', + theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' }, + sections: [ { type: 'hero', props: { heading: 'Hello', subheading: '', imageUrl: 'https://example.com/hero.png' } } ], + form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } }, + seo: { title: 't', description: 'd' }, + }) +} + +function toStr(bytes: Uint8Array) { + return new TextDecoder().decode(bytes) +} + +describe('extras 자동 보강', () => { + it('manifest: 잘못된 theme_color는 무시하고 기본(primaryColor) 사용', () => { + const page = makePage() + const out = buildExtrasForPage(page, { manifest: { theme_color: 'not-a-hex' } }) + const manifest = JSON.parse(toStr(out['site.webmanifest'])) + expect(manifest.theme_color).toBe('#0ea5e9') + }) + + it('manifest: short_name은 12자 이내로 자동 절단', () => { + const page = makePage() + const out = buildExtrasForPage(page, { manifest: { name: 'Super Long Product Name' } }) + const manifest = JSON.parse(toStr(out['site.webmanifest'])) + expect(manifest.short_name.length).toBeLessThanOrEqual(12) + }) + + it('robots: 주어진 plain 경로 목록을 자동으로 User-agent 헤더와 Disallow 라인으로 보강', () => { + const page = makePage() + const robotsPlain = "/admin\n/private" + const out = buildExtrasForPage(page, { robotsText: robotsPlain }) + const robots = toStr(out['robots.txt']) + expect(robots).toMatch(/^User-agent: \*/) + expect(robots).toMatch(/Disallow: \/admin/) + expect(robots).toMatch(/Disallow: \/private/) + }) +}) diff --git a/lib/exporter/extras.ts b/lib/exporter/extras.ts index 84cd4dd..65bb975 100644 --- a/lib/exporter/extras.ts +++ b/lib/exporter/extras.ts @@ -9,6 +9,7 @@ export type ExtrasOverrides = { display: string theme_color: string }> + sheetsMode?: 'auto' | 'include' | 'exclude' } function toBytes(s: string): Uint8Array { @@ -16,7 +17,14 @@ function toBytes(s: string): Uint8Array { } function buildRobots(base?: string): string { - if (base && base.trim().length > 0) return base + if (base && base.trim().length > 0) { + const s = base.trim() + if (/^User-agent:/i.test(s)) return s + const lines = s.split(/\n+/).map((l) => l.trim()).filter((l) => l.length > 0) + if (lines.length === 0) return 'User-agent: *\n' + const disallowLines = lines.map((l) => (l.startsWith('Disallow:') ? l : `Disallow: ${l}`)) + return `User-agent: *\n${disallowLines.join('\n')}` + } return 'User-agent: *\n' } @@ -36,16 +44,27 @@ export function buildExtrasForPage(page: Page, overrides?: ExtrasOverrides): Rec const robotsText = buildRobots(overrides?.robotsText) out['robots.txt'] = toBytes(robotsText) - // site.webmanifest + // site.webmanifest (auto-enrich and safe-merge) const manifestBase = deriveManifest(page) - const manifestMerged = { ...manifestBase, ...(overrides?.manifest || {}) } + const ov = overrides?.manifest || {} + const name = ov.name && ov.name.trim().length > 0 ? ov.name : manifestBase.name + const short_name = ov.short_name && ov.short_name.trim().length > 0 + ? ov.short_name + : (name.length > 12 ? name.slice(0, 12) : name) + const start_url = ov.start_url && ov.start_url.trim().length > 0 ? ov.start_url : manifestBase.start_url + const display = ov.display && ov.display.trim().length > 0 ? ov.display : manifestBase.display + const hexOk = ov.theme_color ? /^#([0-9a-fA-F]{3}){1,2}$/.test(ov.theme_color) : false + const theme_color = hexOk ? (ov.theme_color as string) : manifestBase.theme_color + const manifestMerged = { name, short_name, start_url, display, theme_color } const manifestJson = JSON.stringify(manifestMerged, null, 2) out['site.webmanifest'] = toBytes(manifestJson) - // Google Sheets 템플릿: actionUrl 미지정 시 포함 + // Google Sheets 템플릿: 기본 auto. overrides.sheetsMode로 강제 포함/제외 가능 + const mode = overrides?.sheetsMode ?? 'auto' const action = page.form?.actionUrl?.trim() const hasAction = !!(action && action.length > 0) - if (!hasAction) { + const shouldInclude = mode === 'include' || (mode === 'auto' && !hasAction) + if (shouldInclude) { const code = `/** * Google Apps Script Web App for form submissions * - Deploy as Web App and set URL as form action if needed. diff --git a/lib/exporter/html.ts b/lib/exporter/html.ts index 3b5655f..14c1932 100644 --- a/lib/exporter/html.ts +++ b/lib/exporter/html.ts @@ -2,7 +2,7 @@ import type { Page, FormField } from '@/lib/schema/page' import type { AssetManager } from '@/lib/assets/assetManager' export type Exported = { html: string; css: string; js: string } -export type ExportOptions = { assetManager?: AssetManager } +export type ExportOptions = { assetManager?: AssetManager; fontPreconnect?: boolean } function renderFaq(section: { props: { items: Array<{ q: string; a: string }> } }) { const itemsRaw: Array<{ q: string; a: string }> = Array.isArray(section.props.items) ? section.props.items : [] @@ -222,7 +222,7 @@ function renderForm(page: Page) { ` } -function buildHead(page: Page) { +function buildHead(page: Page, opts?: ExportOptions) { const ogImage = page.seo.ogImage const analyticsParts: string[] = [] const ga = page.analytics?.ga4MeasurementId @@ -250,11 +250,13 @@ function buildHead(page: Page) { `) } const custom = page.analytics?.customHeadHtml || '' + const preconnect = opts?.fontPreconnect !== false return ` - + ${preconnect ? '' : ''} + ${preconnect ? '' : ''} ${escapeHtml(page.seo.title)} @@ -338,9 +340,7 @@ export function exportPage(page: Page, opts?: ExportOptions): Exported { const html = ` -${buildHead(page)} - - +${buildHead(page, opts)}