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
pagessystem. - 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_templateslibrary. 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):
- Phase 1 — PoC / foundation: store + sanitize + publish render path. This matches the client’s “数日で動作確認” request using their attached sample HTML.
- Phase 2 — Wizard: questions → answers → assembled prompt → paste box → save.
- Phase 3 — Admin config: admin UI to edit questions and prompt template without code.
- 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_htmlislongText(nottext) because LPs can be large and may eventually inline base64 images.form_idis 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_idis nullable. AI LPs may be standalone promo pages not tied to a service.wizard_answers+original_promptare 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 fromai_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_htmlstorage 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. Forcerel="noopener noreferrer"whentarget=_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 forimg 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_pagesrows they own (user_id = Auth::id()). - Public page: only
status = publishedrows 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 clearwizard_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_promptretention policy (§5.4). - Image hosting: if the AI returns
img srcpointing 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_htmlstorage + 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 |