AI LP Generator — Design Spec

Date: 2026-07-08
Status: Approved (pending spec review)
Author: Brainstorming session (Hung + reviewer)

1. Overview

A new merchant self-serve feature in Yoyacoo that produces publishable landing pages (LP) from AI-generated HTML. The merchant answers admin-configured questions, copies an assembled prompt to their own external AI tool (ChatGPT, Claude, etc.), pastes the returned HTML back into Yoyacoo, and Yoyacoo sanitizes + stores it as a standalone AI landing page that renders above an attached form on the public page.

Hard constraints (from client):

  • No Yoyacoo-side AI integration. Yoyacoo does not call any AI provider, store any API keys, or run any model. The merchant runs the AI externally and pastes the result.
  • No DraftJS. DraftJS is deprecated and is not used anywhere in this feature. AI LPs are stored as sanitized HTML blobs.
  • The AI LP is a standalone “page type” — not a mutation of the existing DraftJS pages system.
  • Admin must be able to edit the prompt text and the questions without a developer deployment (quality iteration loop).
  • Existing form / reservation / payment features continue to work; they are wired to the AI LP through a form picker, not regenerated by the AI.

Out of scope:

  • Structural block editing (add/move/delete sections). The merchant may edit text, images, and links only. Template structure is frozen.
  • Yoyacoo building or hosting the AI prompt execution. The external AI tool is the merchant’s own.
  • Consuming the admin page_templates library. The AI tool picks the template externally; Yoyacoo only stores the result.

2. Architecture

┌─────────────────────────────┐         ┌─────────────────────────────┐
│  Yoyacoo admin (existing)    │         │  Yoyacoo user (merchant)    │
│  ─ new module: AiLpConfig    │         │  ─ new module: AiLp          │
│    • manage questions        │         │   (wizard + store + render)  │
│    • manage prompt template  │         │   + dedicated editor         │
└─────────────────────────────┘         └─────────────────────────────┘
                                                  │
                                                  │ copy prompt → external AI
                                                  │ paste HTML ← external AI
                                                  ▼
                                          ┌──────────────────┐
                                          │ External AI tool  │
                                          │ (merchant's own)  │
                                          │  NOT our concern  │
                                          └──────────────────┘

┌─────────────────────────────┐         ┌─────────────────────────────┐
│  Yoyacoo common (shared)     │         │  Yoyacoo customer (public)   │
│  ─ new Eloquent: AiPage      │ ◀────── │  ─ new render branch:        │
│  ─ new migration: ai_pages   │  reads  │    render ai_html + form    │
│  ─ sanitization service      │         │    (no DraftJS involved)    │
└─────────────────────────────┘         └─────────────────────────────┘

Trust boundary: Yoyacoo treats AI as an opaque HTML source. We do not validate AI reasoning, store AI credentials, or call any AI endpoint. The only trust boundary is server-side HTML sanitization on write. Sanitized HTML is trusted on read; the public render path does not re-sanitize on every request.

Stack boundaries (sub-projects, built in order, each independently shippable):

  1. Phase 1 — PoC / foundation: store + sanitize + publish render path. This matches the client’s “数日で動作確認” request using their attached sample HTML.
  2. Phase 2 — Wizard: questions → answers → assembled prompt → paste box → save.
  3. Phase 3 — Admin config: admin UI to edit questions and prompt template without code.
  4. Phase 4 — Dedicated editor: scoped contentEditable editor for text/image/link changes.

Phase 1 has zero dependency on Phases 2–4 and can ship alone. Phase 2 works even before Phase 3 (questions and prompt are seeded by a migration, not admin-editable, until Phase 3 ships).

3. Data Model

3.1 ai_pages (new, isolated from pages)

Column Type Notes
id bigint PK
user_id bigint FK → users.id merchant owner; cascade delete
name string(255) merchant-facing label, e.g. “Spring campaign LP”
slug string(255) UNIQUE public URL segment, /ai-lp/{slug}
ai_html longText sanitized HTML blob (single block)
status tinyInteger 0=draft, 1=published, (2=archived, reserved)
form_id bigint NULL FK → forms.id form attached below the HTML; NULL = none yet
service_id bigint NULL FK → services.id optional tie to a service for listings/permissions
released_at datetime NULL when published
wizard_answers json NULL snapshot of answers used to generate this draft (audit/reuse)
original_prompt longText NULL the assembled prompt the merchant copied (audit/debug)
metadata json NULL reserved: generation source label, AI tool name, etc.
created_at, updated_at, soft_deletes

Indexes: (user_id, status), (slug), (form_id).

Field rationale:

  • ai_html is longText (not text) because LPs can be large and may eventually inline base64 images.
  • form_id is nullable to honor the “AI page and form are independent” decision. The dedicated AI LP screen has a form picker; the merchant picks an existing Yoyacoo form. NULL until chosen.
  • service_id is nullable. AI LPs may be standalone promo pages not tied to a service.
  • wizard_answers + original_prompt are non-functional but critical for audit: if quality is bad, the merchant/admin can see which prompt and answers produced which LP. Privacy review needed (free-text answers may contain PII — see §6).

Tables we do NOT touch:

  • pages — unchanged. The DraftJS path keeps working.
  • forms — referenced read-only from ai_pages.form_id.
  • page_templates (admin) — not consumed by the AI flow in this spec. A future “category-based prompt” could reuse them; out of scope now.

3.2 Admin-config tables (Phase 3)

ai_lp_configs (versioned singleton history):

Column Type Notes
id bigint PK
prompt_template longText the prompt string with {{answer_x}} placeholders
is_active boolean only one row active at a time
version integer monotonic; new save = new row, old deactivated
created_at, updated_at

ai_lp_questions (ordered list per active config):

Column Type Notes
id bigint PK
ai_lp_config_id bigint FK → ai_lp_configs.id
label string(255) the question text shown to the merchant
help_text string NULL optional guidance
question_type enum text, textarea, select, multi_select
options json NULL for select / multi_select
sort_order integer display order
is_required boolean
created_at, updated_at

The prompt template substitutes {{answer_x}} placeholders with the merchant’s answers to assemble the final prompt string. x is the 1-indexed position of the question in sort_order order (so {{answer_1}} = first question’s answer, {{answer_2}} = second, etc.). Because each admin save creates a new immutable ai_lp_configs row (see §3.2), reordering or deleting questions produces a new version and never breaks prompts already snapshot into saved ai_pages. Missing answers skip the placeholder line; no literal {{answer_x}} is left in the output.

4. User Flow (End-to-End)

4.1 Phase 1 — PoC (store + sanitize + publish)

Merchant (logged in)
  │
  │ 1) Opens "AI LP""New" → minimal form:
  │      name, slug, paste HTML <textarea>, pick form (optional)
  │
  │ 2) Pastes raw HTML returned from their own AI tool into the textarea
  │
  ▼
Yoyacoo user backend (POST /ai-lp)
  │  3a) Validate input (name, slug unique, ai_html non-empty, form_id exists+owned)
  │  3b) Sanitize ai_html server-side (HTMLPurifier allowlist — see §5)
  │  3c) Persist ai_pages row, status=draft
  ▼
Merchant → preview → publish → status=published, released_at=now
  │
  ▼
Public (customer app) visits /ai-lp/{slug}
  │  4a) Load ai_pages row by slug where status=published
  │  4b) If form_id set → render HTML, then render form below
  │      If form_id null → render HTML only

4.2 Phase 2 — Wizard (full self-serve flow)

Merchant → "AI LP""New from wizard"
  5) Server returns the active ai_lp_questions (ordered) to the wizard UI
  6) Merchant steps through questions (grouped screens), types/selects answers
  7) On finish: client assembles the prompt =
       template string (admin) with placeholders replaced by the answers
  8) Merchant sees two boxes side-by-side:
       (a) "Copy this prompt" (readonly textarea with copy-to-clipboard button)
       (b) "Paste your generated HTML here" (editable textarea)
  9) Merchant copies (a) → opens their own AI tool → runs → copies result → pastes into (b)
 10) Merchant clicks "Save draft" → server sanitizes + stores,
       keeps wizard_answers + original_prompt snapshots
 11) Merchant lands on the dedicated AI LP editor (Phase 4) to preview / adjust / attach form / publish

4.3 Phase 3 — Admin config

Admin → "AI LP settings"
  12) Edit prompt_template (big text field, supports {{answer_x}} placeholders, live preview pane)
  13) Add/edit/reorder questions (label, type, options, required, help text)
  14) Activate a version (sets is_active=1, old version is_active=0)
  — No code deploy. Changes affect new wizard sessions immediately.
  — Already-saved ai_pages keep their stored wizard_answers/original_prompt snapshot (immutable).

4.4 Phase 4 — Dedicated editor (scoped contentEditable)

Merchant → "AI LP" → opens an existing ai_pages row → "Edit"
  15) Server renders the saved ai_html inside <div class="ai-lp-content"> with edit affordances on:
       (a) Text-bearing elements (p, h1–h6, li, span, strong, em, blockquote,
           figcaption, td): click → contentEditable. Type inline. No new elements,
           no drag/drop, no structure change.
       (b) <img>: click → small toolbar: "Replace image" (opens Yoyacoo's existing
           file picker → upload/crop → new src) + "Alt text" field. Width/height preserved.
       (c) <a>: click → toolbar: "Edit URL", "Edit label" (visible text),
           "Open in new tab?" toggle (forces rel="noopener noreferrer" if _blank).
  16) Everything else (sections, layout, classes, IDs): frozen. The merchant
      cannot add/move/delete blocks. Template structure and quality are preserved.
  17) Merchant clicks "Save":
       - browser serializes the wrapper's innerHTML
       - POST /ai-lp/{id} with the new HTML
       - server re-sanitizes via the same AiHtmlSanitizer (idempotent on already-clean HTML)
       - stored back into ai_html

Why scoped contentEditable (Approach A) over a drop-in WYSIWYG or a parsed-block editor:

  • Matches the client’s stated edit scope exactly (text + image + link). The client confirmed “編集(テキスト・画像・リンク)は専用画面” and has already mocked this.
  • No new editor library, no new runtime dependency to maintain — directly addresses the DraftJS deprecation concern.
  • Structure is locked → merchant cannot break the template; QA stays predictable; the AI’s quality is preserved.
  • Re-sanitization on save is the only trust boundary (same path as Phase 1).
  • If structural editing (add/reorder blocks) is requested later, the editor frontend can swap to TipTap/Quill reusing the same ai_html storage and sanitization, with no schema change.

5. Sanitization & Security

5.1 HTML sanitization (the trust boundary)

Library: HTMLPurifier (ezyang/htmlpurifier), optionally via stevegrunwell/laravel-purifier. Final wrapper choice at implementation time.

Allowlist (config-driven, not blocklist):

  • Allowed tags: p, br, hr, h1, h2, h3, h4, h5, h6, ul, ol, li, strong, em, b, i, u, span, div, section, header, footer, main, article, aside, figure, figcaption, blockquote, pre, code, a, img, table, thead, tbody, tfoot, tr, td, th, dl, dt, dd, sup, sub, mark
  • Allowed global attributes: class, id, style (style scrubbed via property allowlist — see §5.2)
  • a: href, title, target, rel. Force rel="noopener noreferrer" when target=_blank.
  • img: src, alt, width, height, loading.
  • Rejected outright: <script>, <iframe>, <object>, <embed>, <form> (the existing form is wired separately — no nested forms), <input>, <button> (outside the attached form), <style> element, <link>, any event handler (on*), javascript: URLs, data: URLs except for img src (with size limit).

Per-merchant scoping: the saved HTML is rendered inside <div class="ai-lp-content"> on the public page. Optionally prefix all id= values with ai-lp- during sanitization to avoid clashing with existing page chrome IDs.

5.2 Inline CSS policy

Allow inline style with a strict property allowlist. AI tools frequently emit inline styles; blocking them entirely would make the merchant’s HTML render unstyled.

HTMLPurifier’s style attribute with CSS.AllowedProperties permits:

  • colors, background-color
  • font-family, font-size, font-weight, font-style
  • text-align, text-decoration, line-height
  • margin, padding (spacing)
  • width, height, max-width
  • border, border-radius

Forbidden:

  • position: fixed/absolute/sticky (prevents overlay/escape attacks)
  • behavior, expression(), -moz-binding
  • anything that loads external resources or executes script

5.3 Sanitization timing

  • On save (write path): always sanitize; store only the cleaned HTML. Never store raw.
  • On render (read path): do not re-sanitize on every request (perf). Trust what is in the DB (write-time guarantee). Render with {!! $aiPage->ai_html !!} inside the scoped wrapper.
  • Re-edit round-trip (Phase 4): when the merchant edits and saves, sanitize again. Safe because HTMLPurifier is idempotent on already-clean HTML.

5.4 Security

  • AuthN/Z: every admin and merchant endpoint behind existing Yoyacoo auth middleware. A merchant can only see/edit ai_pages rows they own (user_id = Auth::id()).
  • Public page: only status = published rows are served. Draft/archived return 404.
  • Rate limit / cache: public page render cached (e.g. 60s) to absorb traffic spikes.
  • PII in wizard_answers: free-text answers may contain names/contact info. Storage is acceptable for the merchant’s own LP generation; no admin cross-tenant visibility. Purge policy (retain for X days then clear wizard_answers + original_prompt) is deferred to a privacy review — flagged here, not decided.

5.5 Error handling

Failure Behavior
HTML fails sanitization (HTMLPurifier throws) Save rejected; merchant shown “HTML format not supported — please paste only the AI tool’s output”
HTML empty after sanitization (all tags stripped) Save rejected; “Sanitized HTML was empty — likely the paste was not HTML”
slug collision Suggest slug+suffix; auto-append -2, -3, …
form_id does not exist or is not owned by the merchant 422 “Form not found”; do not silently attach
Public render: form_id set but form row gone Render HTML only + log soft alert; do not 500
Prompt assembly: placeholder has no answer Skip the placeholder line; do not render literal {{answer_x}}
Admin saves oversized prompt (>500 KB) Validate max length; reject

6. Implementation Components

6.1 Backend (Yoyacoo domain layout)

yoyacoo_be/common/src/Packages/Models/
  AiPage.php                          — Eloquent model (ai_pages table)

yoyacoo_be/common/src/Packages/Services/AiLp/
  AiHtmlSanitizer.php                 — wraps HTMLPurifier, allowlist config
  AiLpConfigRepository.php            — cached reader for the active ai_lp_configs row
  AiPromptAssembler.php               — template + answers → final prompt string

yoyacoo_be/common/database/migrations/
  2026_07_08_000001_create_ai_pages_table.php
  2026_07_08_000002_create_ai_lp_configs_table.php
  2026_07_08_000003_create_ai_lp_questions_table.php

yoyacoo_be/user/app/Domains/AiLp/
  Controller/
    AiLpListAction.php                — GET /ai-lp
    AiLpCreateAction.php              — GET /ai-lp/create (wizard UI)
    AiLpStoreAction.php               — POST /ai-lp (PoC paste + wizard)
    AiLpEditAction.php                — GET /ai-lp/{id}/edit (Phase 4)
    AiLpUpdateAction.php              — POST /ai-lp/{id} (Phase 4 save)
    AiLpPublishAction.php             — POST /ai-lp/{id}/publish
    Request/AiLpStoreRequest.php      — validation incl. size cap
    Request/AiLpUpdateRequest.php
    Resource/AiLpResource.php
  Usecase/
    StoreAiLpInteractor.php           — orchestrate sanitize → persist
    UpdateAiLpInteractor.php          — Phase 4: re-sanitize edited HTML
    AssemblePromptInteractor.php      — wizard preview step

yoyacoo_be/admin/app/Domains/AiLpConfig/
  Controller/ (Index, Update) + Usecase + Request
  — view-friendly editor for prompt template + ordered questions

yoyacoo_be/customer/app/Domains/AiLp/
  Controller/ShowAiLpAction.php        — GET /ai-lp/{slug}
  — renders ai_html + (form if form_id set); 404 otherwise

6.2 Frontend

  • User app: new screen under /ai-lp (existing user-app stack):
    • List page (paginated card list of the merchant’s AI LPs)
    • Wizard (Phase 1 ships a paste-only store UI — name/slug/HTML/form, no questions yet; Phase 2 adds the full question flow)
    • Editor (Phase 4): render saved HTML with scoped contentEditable + img/link toolbars + form picker + status toggle. No new editor library.
  • Admin app: one settings screen with prompt template (textarea + live {{answer_x}} highlighting) and a sortable question list editor.
  • Customer (public): tiny new route /ai-lp/{slug} → view that emits <div class="ai-lp-content">{!! $html !!}</div> followed by the attached form component.

6.3 URLs

Surface URL
Merchant: list GET /ai-lp
Merchant: new wizard GET /ai-lp/create
Merchant: save POST /ai-lp
Merchant: editor GET /ai-lp/{id}/edit
Merchant: update POST /ai-lp/{id}
Merchant: publish POST /ai-lp/{id}/publish
Public GET /ai-lp/{slug}
Admin: settings GET /admin/ai-lp-config

6.4 Background jobs

None for MVP. Sanitization is synchronous (HTML typically 10–100 KB). Background regeneration of prompt previews is YAGNI for now.

7. Testing Strategy

Use the existing Pest/PHPUnit setup already in yoyacoo_be/user/tests/. No new test framework.

Layer Tests
Unit — AiHtmlSanitizer <script> stripped; <iframe> stripped; javascript: URL stripped; allowed tags preserved; inline style allowlist enforced; idempotency (sanitize sanitized = same bytes)
Unit — AiPromptAssembler placeholders substituted; missing answers skipped; no literal {{}} left behind
Feature — merchant store auth enforced; cannot write to another user’s ai_pages; slug uniqueness; oversized HTML rejected; sanitization runs before DB write
Feature — merchant update (Phase 4) re-sanitize on edit; locked structure preserved (no new tags introduced by the editor path)
Feature — public render draft returns 404; published renders HTML+form; missing form renders HTML-only + log
Feature — admin config only admin role can update; new version activates and deactivates old; question reordering persists
Integration — PoC path end-to-end: paste client’s attached sample HTML → record created → public URL returns HTML+form, no <script> present in DOM

8. Phased Roll-out

Phase Deliverables Rationale
1 — PoC (days) Migration, AiPage model, AiHtmlSanitizer, AiLpStoreAction, public render. Paste-only store UI. Client’s “数日で動作確認” using their attached HTML — proves the ingest→sanitize→render invariant.
2 — Wizard ai_lp_configs + ai_lp_questions tables; question flow UI; AiPromptAssembler; two-box final screen. The actual product value.
3 — Admin config UI Admin screen to edit prompts and questions. The “品質改善を開発なしで回す” requirement.
4 — Dedicated editor Scoped contentEditable editor: text/image/link in-place edits + form picker + publish. The “編集(テキスト・画像・リンク)は専用画面” requirement; replaces the “replace whole blob” stopgap.

Phase 1 has zero dependency on Phases 2–4 and can ship alone. Phase 2 works before Phase 3 (questions and prompt are seeded by a migration, not admin-editable, until Phase 3 ships).

9. Open Items / Future Considerations

  • Privacy review for wizard_answers / original_prompt retention policy (§5.4).
  • Image hosting: if the AI returns img src pointing to external URLs, decide whether Yoyacoo re-hosts (download → store) for reliability, or leaves external refs. Default: leave external in Phase 1; revisit if uptime becomes an issue.
  • Category-based prompts: Phase 3 currently ships one prompt template. Future: one prompt per template category, reusing admin page_templates. Out of scope for this spec.
  • Structural editing: if requested after Phase 4, swap the editor frontend to TipTap/Quill reusing the same ai_html storage + sanitization. No schema change.

10. Decisions Log

Decision Choice Rationale
Primary user flow Merchant self-serve (in-app wizard) Client’s stated UX
AI integration direction Browser → merchant’s own external AI; manual copy-paste Client clarified they use their own AI; admin only configures prompt + questions
AI output shape Free-form sanitized HTML blob Client’s “サニタイズ済みHTMLのひとかたまりとして保存” requirement
Data model New ai_pages table, isolated from pages Keeps DraftJS storage path untouched; isolated risk
Form binding form_id nullable; merchant attaches a form in the AI LP editor “AI page and form are independent” decision
Inline CSS policy Allow inline style with strict property allowlist AI tools emit inline styles; blocking them renders output unstyled
Editor approach Scoped contentEditable (Approach A) Matches client’s text/image/link scope; no new library; reuses sanitization