视觉画布设计
canvas-design
基于设计原则创作静态视觉作品,用于海报与品牌图形。
figma-to-content【文档与办公】Use this to turn a Figma design into an AEM Edge Delivery Services (EDS / AEM / Franklin / Helix) content page in Document Authoring (DA, da.live). Triggers: "build this Figma frame in EDS", "turn this Figma design into a DA page", "publish this design to da.live", or providing a figma.com URL for a page. Reads the frame (and any annotations) via a F…
独立 GitHub 快照 · 自动分类 · 以原作者文档为准
Read a Figma frame, assemble a page from EDS blocks and default content, and publish it to Document Authoring. Runs with a Figma MCP (to read the design) and a DA IMS token (to write content) — no proprietary tooling required.
This skill orchestrates existing skills; it does not reimplement DA rules, block knowledge, or block-building. Invoke those skills — do not inline them. The condensed rules quoted in this file are pointers to jog the right skill, never a substitute for loading it: when a phase names a skill, invoke it and work from its actual guidance. Running this file as a self-contained procedure — hand-writing blocks, authoring DA HTML from memory, skipping the browser/visual check — is the single most common way a run goes wrong. Phase 2.3 turns the confirmed plan into an explicit manifest of the sub-skills you must invoke.
Classify each section of the design, then follow the matching path:
A single design usually mixes all three (known blocks + default content + one or two new blocks).
| For | Use skill |
|---|---|
DA IMS token (DA_TOKEN) | da-auth |
| DA body-fragment HTML rules, Source API, preview/publish, media | da-content |
| Whether a block exists + its authoring model & examples | block-collection-and-party |
| Surveying the whole available block palette | block-inventory |
| Designing a content model for a new block | content-modeling |
| Building a new block (full dev workflow) | content-driven-development (invokes building-blocks, testing-blocks) |
| Rendering a block + visual comparison to the design (the reuse gate) | testing-blocks (browser/Playwright screenshot + "compare implementation to design") |
The DA-write contract in Phase 5 is the same one da-content documents
(see its references/html-content.md and references/platform.md).
blocks/, and required for the content+code path to
add block code). Its GitHub {owner}/{repo} and the deploy {branch}.daOrg, daRepo (the DA namespace), page PATH (no
extension, lowercase/dash only — see da-content platform rules). In the
standard EDS+DA setup daOrg/daRepo equal the GitHub {owner}/{repo};
confirm, because Phase 5 writes to daOrg/daRepo but previews/renders on
the GitHub {owner}/{repo}/{branch}.DA_TOKEN — via da-auth, which exports $DA_TOKEN and caches it at
~/.aem/da-token.json (valid ~1h). Prefer the $DA_TOKEN da-auth already set
in this session; read the cache file only if that's unset. Two distinct
failures: a 401 with an empty body means the token expired → re-auth; a
cache file that can't be read because the execution sandbox has no $HOME
access means the token is unreachable, not expired (see Phase 0 step 3) —
don't conflate them.Verify the run can actually complete before reading the design or writing to DA — a missing prerequisite caught here is one actionable message; caught mid-run it is a confusing, half-built page. Run these checks in order and, on the first that fails, stop with the specific remediation below — do not proceed on a guess or a partial capability.
whoami, or listing its tools). If no Figma MCP tool is
available at all, stop: "No Figma MCP is connected. Connect one (Claude
desktop Dev Mode, an IDE Figma integration, or a remote Figma MCP) and
re-run." Record the authenticated identity (whoami) for the next check.fileKey (e.g. get_metadata scoped to the frame, or get_design_context
on the node). A permission / not-found error (403/404/"no access")
means the file is not shared with the authenticated account → stop: "Figma
reports no access to <fileKey> as <whoami>. Share the file with that
account, switch accounts, or provide a file you can open." Distinguish this
from a transport cap — a truncated, garbled, or JSON-parse-error response is
the size cap (see Phase 1), not an access failure: retry narrower, do not
report it as no access.DA_TOKEN is obtainable via da-auth
— prefer the $DA_TOKEN it exports into the session, else its cache at
~/.aem/da-token.json, else freshly minted. If the cache exists but can't be
read because this execution sandbox has no $HOME access, the token is not
missing — it is unreachable; do not loop re-minting. Stop with that
distinction spelled out: "A DA token exists but this sandbox can't read
~/.aem/da-token.json — run where the cache is readable, or provide the token
as $DA_TOKEN (or a readable path)." If no token can be obtained at all,
stop: "Can't obtain a DA token (da-auth) — authenticate to DA and re-run."
Either way, don't spend a full Figma read only to fail at the deploy step.blocks/ and to add new-block code) and the skills
this one orchestrates (da-auth, da-content, the block skills) are
available. If the checkout path is unknown, ask for it.On all-pass, print a one-line preflight summary — Figma identity, the file/frame,
and the DA org/repo + branch you will write to — then proceed to Phase 1.
Use a Figma MCP (Claude desktop / IDE / external). Introspect the actual tool
schemas — signatures differ between MCP implementations (the local Dev Mode
server often works off the current selection and may not take a fileKey; the
remote/desktop server takes fileKey + optional nodeId). The tools you need,
by capability:
get_metadata) — the frame's section/layer tree; node
ids, names, positions, sizes. Derive the section list from the content
groups in visual order (sort by y) — not the raw child list: full-
bleed background rectangles, overlays, and decorative shapes are part of a
section (its background), not sections of their own, and a single visual
section is often split across sibling nodes (e.g. a background rect + a tab
strip + a text group). Ignore the decorative layers and group the rest into
sections by position. Usually fileKey required, nodeId optional. Some
MCP servers cap response size — even a single frame's structure dump can
exceed it; scope the call to the frame or, if that still fails, one section
at a time. A truncated, garbled, or JSON parse-error response is the cap
being hit — retry narrower; do not read it as "no structure."get_screenshot) — a per-section reference image to
sanity-check the block/content mapping.get_design_context) — text, links, and image
asset download URLs for a node. For the content+code path this also provides
the layout/structure a new block must reproduce. Request the lean form —
exclude the screenshot from the context call (fetch visuals separately with
the screenshot tool) and disable any Code Connect lookup (e.g.
excludeScreenshot / disableCodeConnect-style options) unless you are
mapping to a real component library; both add payload and round-trips and can
push a large response over the transport cap. Icons are usually component
instances, not raster fills — obtain their SVG (export/copy as SVG),
never a PNG, for the /icons or DA /media reference in Phase 4.get_variable_defs) — colors, spacing, type. Read
annotation values and, for new blocks, source token values.Call budget & order — Figma MCP calls are rate-limited and payload-capped, so spend them deliberately rather than re-fetching:
get_metadata first (scoped to the frame) — the structure/section tree.
The cheapest orienting call; every later call keys off the node ids it returns.get_screenshot of the whole frame early — one full-frame reference image
up front is the anchor you reconcile the section count against (segmentation
heuristic) and, later, compare the rendered page to (Phase 5 Stage B). Take
per-section crops afterwards, only for the sections you actually build.get_design_context targeted and lean, per section — request the lean
form (exclude the screenshot, disable Code Connect) and scope it to one
section's node at a time. A whole-frame context dump is the single call most
likely to blow the transport cap.download_assets / export-as-SVG) — only for the
assets the confirmed plan references, after Phase 2. Don't pull binaries
for sections that end up reusing an existing block or being cut.A 429/rate-limit or a truncated/garbled response is a budget/cap signal, not
"no data": back off, narrow the scope (frame → section), and retry — never read
it as an empty design or as missing access (Phase 0 step 2 draws the same line).
Produce an ordered section inventory: { sectionNodeId, annotation, screenshot, content, background } — capture each section's background /
theme (e.g. alternating light and dark sections), because the global token
retheme (Guardrails) recolors blocks but does not switch a section's
background: that carries via a section-metadata Style class or a block's
own defined dark/light variant (Phase 4). Read annotations per
references/annotation-contract.md.
Segmentation heuristic — when the frame has no explicit grouping, derive the section list like this, not from the raw child order:
- Sort the frame's direct children by
y(top to bottom).- Drop pure-decoration layers from the section list — full-bleed background rectangles, gradients, blurs, absolutely-positioned shapes with no text or interactive child. Record each as the background of the content it sits behind (→ Phase 4
section-metadata); don't emit it as a section of its own.- Merge siblings that form one visual band — nodes whose vertical extents overlap or sit within ~one line-height of each other (a background rect + a heading group + a button row are one section, not three).
- Reconcile the count against the screenshot before resolving: the eye sees the real sections; a mismatch means you over- or under-split — fix it before Phase 2.
Placeholder content is common — don't ship it. Designs routinely contain dummy copy (
Lorem ipsum, a CTA literally labelled "Button" or "Lorem Ipsum", the same card title repeated across every card) and unfilled slots (empty or transparent image cells, blank stat boxes). Author from the real text and media in the design context — not from the placeholder and not from invented filler. Where it's clearly placeholder, flag it in the plan and confirm the real copy/media with the user rather than publishing "Lorem Ipsum" to a live page. Distinct items (cards, tabs, news entries) need distinct copy and images — repeated-identical content is itself a placeholder smell. If the design itself carries only placeholder, you cannot manufacture the real content: stop and get it from the user before publish.
Site chrome (nav bar, footer) is usually not page body — in EDS it is sourced from separate
/navand/footerdocuments via the header/footer blocks. Don't author it into the page unless the user asks.
Every section resolves to exactly one of: existing block (→ 3A), default content (→ 3C), or new block (→ 3B). How that decision is reached depends on whether the section is annotated.
Before resolving anything, enumerate what the project already has:
ls -d blocks/*/ plus block-inventory / block-collection-and-party for
each block's authoring model (row/cell structure, variants) and a
rendered example — the block's liveExampleUrl when it comes from the Block
Collection, or the project's own block rendered at localhost:3000. That
rendered example is the "block side" of the 2.1 / Phase 3A visual check. This
is the reuse-candidate set — essential when the customer is already on EDS with
their own blocks.
If the section is annotated (see
references/annotation-contract.md), the
annotation is authoritative: named block that exists → existing block (3A);
marked new (or absent-and-user-confirmed) → new block (3B); plain prose/media
→ default content (3C).
If it is not annotated (e.g. "just migrate this page"), infer the mapping — do not dump it as unresolved:
high (clear reuse match, or
clearly novel) or low (structure fits but styling is borderline; two
blocks plausibly fit; new-variant-vs-new-block; content model ambiguous).Present a resolution plan — one line per section: decision (reuse X /
default content / new block Y), confidence, a one-clause rationale, and a
content flag on any section whose copy or media is placeholder (Phase 1)
and needs real content before publish.
low-confidence section or genuine
ambiguity, offering the concrete choice (reuse this block vs. new block;
which block; new variant vs. new block). Wait for the answer.content/<PATH>.html already exists in DA (a cheap Source-API GET, Phase 5);
if it does, deploying overwrites it — say so in the plan and get explicit
overwrite confirmation. Never silently clobber a page you didn't create, even
on an otherwise pre-authorized unattended run. Record two facts per path for
Phase 5 to enforce: PLANNED_STATE (new if the check returned 404, exists
if 200) and OVERWRITE_OK (yes only when the user confirmed overwriting an
existing page). Phase 5 re-checks existence right before writing and refuses
if the state changed since planning (a page appeared in the gap) or overwrite
was never confirmed — the plan-time check alone is not a license to clobber.Never silently drop a section, and never deploy an inferred mapping the user has not seen.
Worked example — an unannotated 4-section frame; this is the plan you present in 2.2 (one line per section):
| # | Section | Decision | Conf. | Why | Content |
|---|---|---|---|---|---|
| 1 | Hero band — heading + 2 CTAs over a photo | reuse hero | high | model fits; heading and CTAs stay legible on the media under the theme | ok |
| 2 | 3 feature blurbs — icon + title + text | reuse cards | high | content model and rendered look both fit | ok |
| 3 | Metric strip — 3 big numbers + labels | new block stat-cards | high | bespoke panel look no existing block produces (3B) | ok |
| 4 | Newsletter row — heading + email field + button | new block / confirm | low | carries an interactive control (input) — ask keep vs. flatten (G5) | ⚠ placeholder copy |
Then act on it: sections 1–2 build without blocking; #3 builds (high-confidence new block); #4 stops for a decision (low-confidence + interactive control); and because the plan contains inferred mappings, the whole thing gets one pre-deploy confirmation before the da.live write. Section #4's ⚠ flag means its real copy must be supplied before publish, not shipped as placeholder.
Turn the confirmed plan into an explicit manifest of the sub-skills it requires and invoke each one — this is where the intro's orchestrate, don't inline rule becomes a concrete, ticked list. This file's summaries never substitute for loading the named skill.
Derive the manifest from the plan:
| The plan contains… | You MUST invoke |
|---|---|
| Any section (always) | da-auth (token) and da-content — load its real references/html-content.md, platform.md, and media.md, not the condensed rules in this file — before authoring (Phase 4) and deploying (Phase 5). |
| An existing-block reuse (3A) | block-collection-and-party (authoring model + a rendered example) and testing-blocks for the visual reuse gate (rendered block vs. the Figma section screenshot). |
| A new block (3B) | content-modeling (design the authoring model), then content-driven-development (which runs building-blocks and testing-blocks). Do not hand-write block JS/CSS from this file. |
| Default content (3C) | da-content only (no block skills). |
Record the manifest as an evidence-bearing checklist and tick each item only after you actually invoked the skill — "I know what it does" is not invocation, and an un-invoked required skill means this phase is not complete:
html-content.md / platform.md / media.md)If the environment genuinely cannot run a required skill (e.g. no browser for testing-blocks), say so explicitly in the report and mark the affected checks unverified — never silently substitute this file's summary and call it passed.
Reuse gate — structure AND visual. An existing block is a valid target
only when the section both (a) fits the block's authoring model (its
row/cell structure and field types) and (b) matches the block's rendered
appearance under the project theme, using only tokens and variants the block
already defines. Structural fit alone is not enough: if the section's
visual identity — bespoke layout, corner radius, shadow, decorative treatment —
lives in that block's own CSS, you cannot reproduce it without editing the
block (forbidden), so route the section to Phase 3B (new block). Global,
token-level differences (palette, fonts, type scale) do not break reuse —
they are absorbed once by retargeting the project's design tokens (see
Guardrails). How to run the visual check — reuse testing-blocks, don't invent
one: get a rendered example of the candidate block — its liveExampleUrl
(block-collection-and-party / block-inventory) or the project's own block
rendered at localhost:3000 with the section's actual content — including
secondary text, captions, and CTAs over whatever background or media the block
places them on, not just placeholder cells — then follow testing-blocks'
browser/Playwright-MCP screenshot pass (mobile/tablet/desktop) and its "compare
implementation to design" step, comparing that screenshot against the Figma
section screenshot from Phase 1. Watch for treatments a block applies to
only its primary element: one that (say) whitens a heading over dark media but
leaves the supporting text and buttons at body color passes a structural check
yet renders that text illegibly — a divergence the token retheme cannot fix.
Divergence beyond what the token retheme explains ⇒ new block (or a new
variant), not reuse. This outcome is blocking: the section is not resolved
until its rendered look — that text included — is faithful, and the fix is a new
isolated block/variant, never an edit to the shared block. Recording the gap in
the plan and reusing the block anyway is a plan note, not a fix — the Phase 5
pre-publish gate treats such a box as failed.
Once the gate passes, invoke block-collection-and-party to learn the block's authoring model (its examples show the row/cell structure and variants) — read the block from the skill, don't guess its model from its CSS source. Then pour the Figma content into that structure:
cards highlight).
Only apply a variant the block actually defines. (Adding a new variant =
modifying an existing block = Phase 3B, not 3A.)<strong> for a primary
button, <em> for secondary. Do not add target="_blank" (decoration
handles external links). Validate the href's URL scheme and escape the
link text/attributes before emitting — see Phase 4, Sanitize everything
derived from the design. (da-content html-content.md §8)Only for sections Phase 2 routed here (a needed block is missing, or an existing block's look diverges) — the 3B case. Guardrails (strict):
blocks/<new-name>/).scripts.js, or
head.html, or by adding block-specific rules to global CSS — keep it
self-contained under blocks/<new-name>/. (Retargeting the project's
global design tokens in styles/styles.css — the :root custom properties
and base typography — is a separate, allowed project-theming step, not part
of building this block; see Guardrails.)pricing-table ✓, pricing_table / 2col / promo--wide ✗). Names must
be unique and not collide with existing blocks.Build route — invoke content-driven-development (don't hand-write the block).
Build every new block by invoking content-driven-development — not by writing
block JS/CSS from scratch off this file's summary. It invokes content-modeling
(design the authoring model from the Figma structure/tokens) then
building-blocks and testing-blocks, and produces a self-contained
blocks/<name>/ — no source URL, no installed substrate, no page chrome, and no
global styles. That is the route that honors the 3B guardrails above, and its
testing-blocks pass is the block's Stage B verification (Phase 5). Build a bespoke, one-off section
the same way — it is still an ordinary isolated block, and "one-off" changes
nothing about how it is generated.
Do not use snowflake here. Snowflake converts an already-rendered page: it requires a reachable Source URL, installs an overlay substrate into the repo, and in block mode emits header/footer fragments and global styles/tokens — each of which violates this skill's constraints (isolated new block, don't touch globals, work from the Figma frame, not a live URL). Snowflake is the right tool for a different entry point — converting an existing static/rendered site — as noted under "When NOT to use".
Use the Figma design context/tokens from Phase 1 as the source of truth for
layout and styling. New-block CSS must target structure, not authored
classes — inline wrappers like <span class="…"> are stripped inside block
cells at delivery (da-content html-content.md §3.9), so a class you emit in a
cell will not survive.
Make the block responsive. A Figma page frame is almost always a single desktop width, but EDS pages are responsive. Author the block mobile-first (or with explicit breakpoints) so a multi-column layout collapses to one column on narrow viewports, and verify at mobile / tablet / desktop via testing-blocks — don't ship a fixed desktop-width block. If the design provides a separate mobile frame, use it to derive the breakpoint behavior (what stacks, what hides, how type scales) — it's the same page, so it feeds one responsive block, not a second page (see Inputs on frame variants).
The new block's code must be committed and pushed to the deploy branch on GitHub and built by Code Sync before the page can render it — see Phase 5 (content+code).
For sections Phase 2 routed to default content — the 3C case — emit standard
document elements directly inside the section <div> (see Phase 4 skeleton) — no
block wrapper:
<h1>–<h6> (preserve levels), paragraphs, lists, images.<p> becomes a button (<strong>/<em>
for primary/secondary) — same rule as 3A.class, id, or style — decoration adds them at delivery.(da-content html-content.md §6)
Emit a body fragment (not a full HTML document) per da-content. Invoke
da-content and load its references/html-content.md, platform.md, and
media.md now — the rules quoted throughout this phase are reminders to jog the
right skill, not the source of truth. Subtle authoring rules (block-cell inline-
tag normalization, media MIME/extension derivation, metadata keys) live in those
docs; authoring from this summary alone is how they get missed. Write one file per
page to content/<PATH>.html.
Mandatory skeleton (da-content html-content.md §1–§2): wrap everything in
<body> with an (empty) <header>/<footer> and a <main>; each section
is exactly one <div> directly inside <main> — the <div> is the
section boundary (no <hr>). Do NOT emit <!DOCTYPE>, <html>, <head>,
<script>, <style>, style=, or class= on default content.
<body>
<header></header>
<main>
<div>
<!-- section: default content and/or a block, in visual order -->
<h1>Heading</h1>
<p>Intro paragraph.</p>
<div class="block-name variant">
<div><div>cell</div><div>cell</div></div>
</div>
</div>
<div>
<!-- next section -->
</div>
</main>
<footer></footer>
</body>
&→&, <→<, >→>, and inside attribute values also
"→" and '→'. A heading Tips & Tricks <Beta> must serialize
as Tips & Tricks <Beta>, never as raw markup that can break the
document or inject an element.http, https,
mailto, tel, or a root-relative (/…) path. Reject javascript:,
data:, vbscript:, and any other scheme (a prototype link can carry
anything): drop the href or ask the user — never emit it.<div class="block-name variant">, each
direct child <div> a row, each grandchild <div> a cell. The first class
token is the block name (resolves to blocks/<name>/<name>.{js,css}).
Multi-word variants hyphenate; multiple variants are separate class tokens.
Max 4 cells per row; blocks cannot nest. (html-content.md §3)<div>, outside any block. (html-content.md §6)<span class="icon icon-<name>"></span> convention resolves only to the
project's Code Bus /icons/<name>.svg, so that SVG must be committed to the
repo /icons/ folder and pushed on the deploy branch (content+code path,
same as block code) and return 200 on the branch host — uploading it to DA
/media does not satisfy the span (it 404s and the icon silently vanishes).
A DA-/media SVG must instead be referenced by full URL on an <img>, not
an icon span. Get the real SVG in Phase 1; never emit an emoji or Unicode
glyph in place of a designed icon. (html-content.md §7)/img/…) render as about:error. So: download the
image bytes from Figma (Phase 1 asset URLs), upload each binary to DA
(PUT admin.da.live/source/{daOrg}/{daRepo}/<media-path>), and reference
https://content.da.live/{daOrg}/{daRepo}/<media-path>. External image URLs
are also accepted (the preview sideloads them). Author a bare <img alt="…">
and let the pipeline build the <picture>.
format), then make all three agree: the multipart
type= MIME, the <media-path> file extension you PUT to, and the extension
in the content.da.live URL you author. Design tools routinely export JPEG
bytes under a .png-named asset; trusting the suffix gives you a .png path
served as image/jpeg (or the reverse) — a latent corruption bug. A layer
that looks vector (an icon, a logo, a shape) often comes back rasterized
— download_assets returns it under rawImages with svgAssets empty — so a
design that implies .svg can hand you PNG/JPEG bytes. Author each <img>
extension from the bytes you actually downloaded, never from the layer's
apparent type or name. Canonical
mapping: JPEG→.jpg/image/jpeg, PNG→.png/image/png, WebP→.webp/
image/webp, GIF→.gif/image/gif, SVG→.svg/image/svg+xml. If bytes
and asset-reported format disagree, trust the bytes.
(html-content.md §9 + media.md)section-metadata block inside the section
(Style → CSS classes; other rows → data-*). (html-content.md §4)metadata block (exact class), placed as the
last element of the last section inside <main> (never after </main>
or in <footer>); keys like title, description, image, template,
theme. Author it from the design — don't leave it empty or a bare
comment. Derive title from the frame name or the page <h1>, description
from the hero/intro copy (a concise real sentence, never lorem), and image
from the primary/hero image's uploaded DA URL when the design has one. This
block is required — the Phase 5 pre-publish gate blocks on its absence — so
populate it rather than deferring it. If the design offers no usable
title/description text, ask the user rather than inventing marketing copy.
(html-content.md §5)Inside block cells the pipeline runs a stricter inline-tag normalization than
for default content — <span class> is unwrapped (class lost), <b>→<strong>,
<mark>→<em>, etc. Restrict cell content to the html-content.md §3.9 preserve
list. A wrong metadata key or block field silently corrupts output;
when unsure, read da-content.
If a DA MCP server is available in the session, use its tools for auth and source writes (da-auth and da-content both defer to it when present). Otherwise use the Source API directly, below.
# Two identities — keep them separate. DA (Document Authoring) and GitHub are the
# same org/repo in the standard EDS setup, but nothing guarantees it, so never
# assume one from the other. DA endpoints (admin.da.live/source, content.da.live,
# da.live/edit) use the DA pair; the render host and admin.hlx.page (code, preview,
# live) use the GitHub pair.
DA_ORG=<da-org> # Document Authoring org
DA_REPO=<da-repo> # Document Authoring repo/site
GH_OWNER=<gh-owner> # GitHub owner
GH_REPO=<gh-repo> # GitHub repo
# In the standard setup all four match: DA_ORG=GH_OWNER=<owner>, DA_REPO=GH_REPO=<repo>.
BRANCH=<branch> # git deploy ref (usually main). For content+code this MUST be
# the branch the new-block code was pushed to and Code Sync built.
BRANCH_HOST=${BRANCH//\//-} # host label: slashes → dashes ('feature/x' → 'feature-x').
# Used BOTH for the aem.page/aem.live hostname AND as the ref
# segment in every admin.hlx.page path (code/preview/live): that
# ref is a SINGLE path segment, so a slashed branch ('figma/x')
# splits it and 404s — pass the dashed label ('figma-x'), which is
# what AEM actually resolves. Only git itself (push/checkout) uses
# the literal slashed $BRANCH. For a slash-free branch the two
# forms are identical, so $BRANCH_HOST is always the safe choice
# for admin.hlx.page.
P=<path-without-extension>
TOKEN="$DA_TOKEN" # from da-auth; 401 w/ empty body ⇒ expired, re-auth
# Fail fast if the branch host would be unresolvable (>63 chars won't resolve).
host="$BRANCH_HOST--$GH_REPO--$GH_OWNER"
[ "${#host}" -le 63 ] || { echo "❌ branch host '$host' is ${#host} chars (>63) — won't resolve; use a shorter branch/repo/org"; exit 1; }
# --- checked-request helper: every call asserts its status; a bare `curl -sS`
# exits 0 on 401/403/409/5xx, so an unchecked curl silently "succeeds" on a
# failed write. req <expected-codes> <curl-args…>: prints the body, retries a
# few times on network/429/5xx, and aborts (non-zero) on any other mismatch.
# Use it for every PUT/POST below; if a DA MCP server is used instead, apply
# the same rule — assert the returned status, don't assume success. ---
req() {
local expect="$1"; shift
local attempt out code body
for attempt in 1 2 3 4 5; do
if out=$(curl -sS -w #x27;\n%{http_code}' "$@"); then code="${out##*#x27;\n'}"; else code="000"; fi
body="${out%#x27;\n'*}"
case ",$expect," in *",$code,"*) printf '%s' "$body"; return 0;; esac
case "$code" in
000|429|5??) sleep $((attempt * 2)); continue;; # transient — bounded retry
401) echo "❌ 401 (empty body ⇒ token expired) — re-auth (da-auth) and retry" >&2; return 1;;
*) echo "❌ HTTP $code (expected $expect) — $*" >&2; return 1;; # 4xx: do not retry
esac
done
echo "❌ giving up after retries (last status $code) — $*" >&2; return 1
}
# --- content+code path ONLY: block code must be LIVE before the page renders ---
# (skip this whole block for content-only — the code is already deployed)
# 1. commit the new block(s) and push to the deploy branch (open a PR if the
# project protects $BRANCH; the branch that renders the page must contain
# the block code):
# git add blocks/<new-block> && git commit -m "feat: <new-block> block" && git push origin "$BRANCH"
# 2. Code Sync builds automatically on push. Optionally force it (non-2xx here
# isn't fatal if the push already synced, so don't abort on it):
req 200,202 -X POST -H "Authorization: Bearer $TOKEN" \
"https://admin.hlx.page/code/$GH_OWNER/$GH_REPO/$BRANCH_HOST/*" >/dev/null || true
# 3. poll until the new block's JS is live on the branch host (bounded — don't hang):
BH="https://$BRANCH_HOST--$GH_REPO--$GH_OWNER.aem.page"
for i in $(seq 1 24); do
code=$(curl -s -o /dev/null -w '%{http_code}' --compressed "$BH/blocks/<new-block>/<new-block>.js")
[ "$code" = "200" ] && break
[ "$i" = "24" ] && { echo "❌ block JS not live after ~2min — check push/branch/Code Sync"; exit 1; }
sleep 5
done
# also confirm the block's CSS is live — a block whose JS loads but CSS 404s
# renders unstyled:
csscode=$(curl -s -o /dev/null -w '%{http_code}' --compressed "$BH/blocks/<new-block>/<new-block>.css")
[ "$csscode" = "200" ] || echo "⚠ block CSS not live ($csscode) — block will render unstyled"
# --- both paths ---
# 1) Upload referenced media FIRST — every authored <img> must resolve at PREVIEW
# time. For each image downloaded in Phase 1, PUT the binary to DA (field name
# MUST be "data"). Detect the format from the BYTES and keep <image/mime>, the
# <media-path> extension, and the authored content.da.live URL extension all in
# agreement (see Phase 4 "Normalize format, extension, and MIME together") — the
# filename/URL suffix is not authoritative.
# Skip images that use a stable external URL the preview can sideload.
req 200,201 -X PUT -H "Authorization: Bearer $TOKEN" \
-F "data=@<local-image>;type=<image/mime>" \
"https://admin.da.live/source/$DA_ORG/$DA_REPO/<media-path>" >/dev/null
# then reference it in the HTML as https://content.da.live/$DA_ORG/$DA_REPO/<media-path>
# 2) Overwrite guard — the Source-API PUT clobbers an existing page and still
# returns 200, so the abort MUST be bound to an explicit decision, not to a
# warning. Two facts come from the Phase 2.2 plan (record them there, per path):
# PLANNED_STATE = new | exists — what the 2.2 existence check saw
# OVERWRITE_OK = yes — set ONLY when the user confirmed overwriting
# an existing page; unset/no otherwise
# Re-GET now (auth is checked BEFORE existence, so a 401 empty body = expired
# token, NOT "new"). Refuse to write if the state changed since planning or the
# overwrite was never confirmed — a page can be created in the gap between
# planning and this write.
exists=$(curl -sS -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $TOKEN" \
"https://admin.da.live/source/$DA_ORG/$DA_REPO/$P.html")
case "$exists" in
404) # absent now
[ "$PLANNED_STATE" = "exists" ] && { echo "❌ plan expected an existing page but it's gone (404) — STOP and reconfirm"; exit 1; }
;; # planned-new and still absent → proceed
200) # present now
if [ "$OVERWRITE_OK" != "yes" ]; then
echo "❌ $P.html exists in DA but overwrite was NOT confirmed in the plan — STOP and reconfirm with the user"; exit 1
fi
if [ "$PLANNED_STATE" != "exists" ]; then
echo "❌ plan saw a NEW path (404) but it exists now (200) — a page was created since planning;"
echo " do NOT overwrite on the stale confirmation — STOP and reconfirm"; exit 1
fi
echo "→ overwriting $P.html (existing page, confirmed in the plan)"
;;
401) echo "❌ 401 on existence check — token expired; re-auth (da-auth) and retry"; exit 1;;
*) echo "❌ unexpected $exists on existence check — resolve before writing"; exit 1;;
esac
# 3) Write content — multipart, field name MUST be "data", type text/html
req 200,201 -X PUT -H "Authorization: Bearer $TOKEN" \
-F "data=@content/$P.html;type=text/html" \
"https://admin.da.live/source/$DA_ORG/$DA_REPO/$P.html" >/dev/null # 201 (new) or 200 (update)
# Preview — separate, required. Path WITHOUT .html; ref = $BRANCH_HOST (the dashed
# label, NOT the slashed git branch — see the BRANCH_HOST note above).
# The deploy sequence STOPS here, at preview. Publishing to the live host is a
# SEPARATE, FINAL step (see "Publish to the live host" after the pre-publish
# gate) that runs ONLY after every gate box passes AND only if the user asked to
# publish — never inline here, before verification.
req 200 -X POST -H "Authorization: Bearer $TOKEN" \
"https://admin.hlx.page/preview/$GH_OWNER/$GH_REPO/$BRANCH_HOST/$P" >/dev/null
Verify (do not skip). Two stages — a fragment curl is not enough for a new block, and it can never stand in for the browser stage:
Stage A — server-side (curl the plain fragment; fast, no JS):
BASE="https://$BRANCH_HOST--$GH_REPO--$GH_OWNER.aem.page/$P.plain.html"
curl -s --compressed "$BASE" | grep -c about:error # expect 0 (no broken images)
curl -s --compressed "$BASE" | grep -o '<img' | wc -l # expect = authored image count
curl -s --compressed "$BASE" | grep -o 'class="[a-z][a-z-]*"' | sort -u # every authored block class present
Also confirm the section count matches the plan — top-level sections, not
every <div> (blocks and rows are divs too, so a raw <div> count runs several
times high). Do not count this on .plain.html: the fragment has no
<main> (nor <section>) wrappers — see the metadata scope-note below — so a
<main> > div count there is always 0. Count it on an artifact that has them:
# rendered (post-preview): EDS wraps each top-level section as <div class="section">
curl -s --compressed "https://$BRANCH_HOST--$GH_REPO--$GH_OWNER.aem.page/$P" \
| grep -oE 'class="section[ "]' | wc -l # expect = planned section count
or count the top-level <main> > div in the local source content/$P.html
before upload — it carries the Phase 4 <body>/<main> skeleton, so its direct
<main> children are the sections. Rich default content (heading / list / link
counts) does survive in .plain.html, so spot-check that there.
Stage B — browser (testing-blocks); mandatory, and curl is not a substitute.
.plain.html shows blocks UNDECORATED (<div class="name"> with raw rows);
a block's JS runs in the browser, so the fragment — and a curl of the block's
CSS/JS, which only proves the files return 200 — can never tell you whether the
block decorated or whether it looks like the design. For a new block
(3B) that decoration and visual fit are the deploy's payoff, so this stage runs
through testing-blocks: render https://$BRANCH_HOST--$GH_REPO--$GH_OWNER.aem.page/$P
in a browser, confirm the block got data-block-status="loaded" with its
transformed DOM and applied CSS, and compare the rendered block against the
Figma section screenshot (get_screenshot from Phase 1). If no browser is
available, Stage B is UNVERIFIED — the page is preview-only, never
"done" (see the pre-publish gate). (Reused existing blocks are already
known-good, so Stage A suffices for them; Stage B still applies to their visual
fit if the token retheme changed their look.)
Non-obvious rules (da-content / EDS):
data — other names silently 200 with
nothing written.…aem.page/$P.plain.html or the page),
not by GETting content.da.live/… directly — a direct GET returns 401
by design even when the upload succeeded and the pipeline internalizes it.<branch-host>--<gh-repo>--<gh-owner> must be ≤ 63 chars or it
won't resolve (asserted by the host length check in the deploy block above;
<branch-host> is the deploy ref with slashes replaced by dashes).For many pages, drive PUT → preview (publish stays a gated, post-verify step —
see below) with a concurrency pool + retry (429/5xx) rather than a
hand-rolled loop. An unattended multi-page run can outlast a single ~1h token, so
refresh the token before long batches and on any 401-with-empty-body, then
resume — don't abort the whole run. Each page still clears the pre-publish gate
before it is eligible to publish; a gate failure on one page blocks that page's
publish, not the batch.
The verify steps above only help if you act on a failure. An autonomous run
tends to note a problem in the plan and ship anyway — a plan note is not a
check. The boxes below are split into two stages: Stage A is server-side
and can be cleared by curl of the fragment plus the referenced block CSS/JS and
assets; Stage B requires a real browser (via testing-blocks) and curl
is not an accepted substitute — a 200 on a block's CSS/JS proves the file
exists, never that the block decorated or matches the design. Fix-and-redeploy
any box that fails. Any box you cannot positively verify counts as failed, not
passed — an un-run check is a blocker, not a green light, and narrowing your
attention to this list must not drop a check the phases above already require.
If no browser is available, the Stage B boxes are UNVERIFIED: report the page
preview-only and never call it "done."
Stage A — server-side (curl the fragment + referenced assets):
h1/h2/h3, p, a, .button, li). With a
browser, read the rendered contrast; with curl only it is still
mandatory — fetch the section's block CSS on the branch host and confirm
each of those elements is given an explicit contrasting color (the
colors-only-the-heading failure mode is the Phase 3A reuse gate). The fix is
a dark variant as a new isolated block/variant, never an edit to the
shared block; flagging it in the plan does not satisfy this box, and if
you cannot verify legibility by either route the box is FAILED — block,
never publish on an unchecked assumption.lorem, CTA labels like "Button"/"Lorem Ipsum", and repeated-identical
items; every item that should be distinct has distinct copy and a
distinct image.<span class="icon icon-x">
returns 200 at /icons/x.svg on the branch host (or is a full DA-/media
URL on an <img>); no emoji or Unicode glyph standing in for a designed
icon.200 on the
branch host. (A 200 on the file proves it exists, not that the block
decorated — that is the Stage B decoration box below.)about:error and the <img> count matches what you authored (both on
.plain.html), and the top-level section count matches the plan —
counted on the rendered page (class="section" under <main>) or the
local source, never as <main> > div on .plain.html (no <main>
there ⇒ always 0). See the Stage A verify block above.metadata block is present — a <div class="metadata"> (exact
class) is the last element of the last section per Phase 4, carrying the
keys the plan calls for (title, description, image, …). An
intended-but-unauthored block — a bare <!-- Metadata block --> comment
with no <div class="metadata"> — is a failed box, not a passed one.
(Scope note: the .plain.html fragment legitimately has no
<body>/<header>/<main>/<footer> wrappers — it is a body fragment, so their
absence there is not a defect. Check only for the metadata block.)Stage B — browser (testing-blocks); mandatory. curl cannot clear these:
…aem.page/$P, not the fragment) the block element carries
data-block-status="loaded", shows its expected transformed DOM, and its
CSS applied. The Stage A "code is live" 200 does not satisfy this — a
block whose JS 500s on load still serves its JS file with a 200.If no browser is available, both Stage B boxes are UNVERIFIED — do not
tick them and do not call the page "done"; report it preview-only and say
which checks could not run (Phase 6). Never substitute a Stage A curl for a
Stage B box.
If an item can't be fixed unattended (e.g. the design itself only contains placeholder copy, or real icon SVGs aren't available), stop and get the real content/decision from the user — don't publish the failing page and don't fabricate the missing piece.
Publishing (POST admin.hlx.page/live/...) is not part of the deploy
sequence in Phase 5 — it is the last action, and it runs only when both
hold:
# Preconditions asserted by the caller: gate fully passed AND publish requested.
# ref = $BRANCH_HOST (dashed label, not the slashed git branch — see BRANCH_HOST note).
req 200 -X POST -H "Authorization: Bearer $TOKEN" \
"https://admin.hlx.page/live/$GH_OWNER/$GH_REPO/$BRANCH_HOST/$P" >/dev/null \
|| { echo "❌ publish failed — page stays preview-only"; exit 1; }
https://da.live/edit#/$DA_ORG/$DA_REPO/$Phttps://$BRANCH_HOST--$GH_REPO--$GH_OWNER.aem.page/$Phttps://$BRANCH_HOST--$GH_REPO--$GH_OWNER.aem.live/$Pblocks/<existing>/*), scripts.js, or
head.html to make it match a design, and never add per-section or
block-specific rules to global CSS — build a new isolated block instead.
Allowed, and expected once per project: retargeting the global design
tokens — the :root custom properties and base typography/button styling
in styles/styles.css — to the design system. That token retheme is how a
reused block picks up the design's palette/type; restyling a specific
existing block is not (→ new block).content.da.live) is the
working default (it internalizes to the media bus at preview and supports
cross-page reuse); external sideloaded URLs remain supported. Confirm the
default for v1.0.0.Optional enhancement (not required for v1.0.0):