skills/eds-website-builder/SKILL.md
Build a complete website with AEM Edge Delivery Services from scratch. Guides through project setup (from aem-boilerplate), block development, styling, content authoring, and deployment. Use this skill when the user wants to create an EDS site, set up an AEM Edge Delivery project, build EDS blocks, or start a new aem.live website. Also useful for "create a website", "new EDS project", or "AEM site from scratch".
npx skillsauth add paolomoz/skills eds-website-builderInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Build a complete AEM Edge Delivery Services website from project creation to production deployment. This skill captures battle-tested patterns from real EDS projects.
If the user already knows what they want, jump straight to the relevant section. Otherwise, walk them through the full workflow below.
1. Project Setup → Clone boilerplate, connect to GitHub + content source
2. Brand Analysis → Screenshot reference site, extract brand voice + visual patterns
3. Design Foundation → Brand tokens, fonts, color system, block-level design tokens
4. Content Planning → Product briefing, page structure, block content models
5. Block Development → Build blocks (JS decoration + CSS styling)
6. Image Generation → AI-generated site images (Gemini / FLUX / Firefly)
7. Content Authoring → Create content in CMS or local drafts
7b. Parallel Page Gen → Spawn parallel agents to generate all pages concurrently
8. DA Integration → Programmatic content upload via service token (optional)
9. Universal Editor → Component definitions, models, filters, instrumentation (optional)
10. Three-Phase Loading → Eager/lazy/delayed resource loading strategy
11. Testing & QA → Lint, CI/CD, responsive check, accessibility, PageSpeed
12. Deployment → Push to GitHub, preview on aem.page, go live on aem.live
Hard-won lessons from real site builds. Read these before starting any phase.
about:errorDA cannot resolve repo-relative paths like /icons/logo.svg or /images/hero.jpeg. When DA encounters these, it silently converts them to src="about:error" — producing broken images on the live site with no obvious error message.
Every <img> in DA-uploaded content must use a public URL — this includes page drafts, nav.plain.html, and footer.plain.html. The fix: deploy all assets (images, icons, logos) to a public CDN (e.g. Cloudflare Pages) first, then reference the public URL in your HTML.
<!-- WRONG — DA converts this to about:error -->
<img src="/icons/logo.svg" alt="Logo">
<!-- RIGHT — DA fetches and ingests from public URL -->
<img src="https://my-site-images.pages.dev/logo.svg" alt="Logo">
This is especially easy to miss for nav and footer icons (logos, search icons, social icons) because they're authored as separate fragments — you won't see the breakage until the header/footer loads on the live site.
Checklist: Before every DA upload, grep your drafts for local asset paths:
grep -rn 'src="/\|src="\.\.' drafts/ --include='*.html' && echo "BLOCKED: fix local paths before uploading" || echo "OK: no local paths"
If your site has one product with multiple pages (efficacy, safety, dosing, etc.), put pages at the root level with a flat nav — not under a subfolder with a single dropdown. A dropdown that contains all pages adds a click with no informational benefit.
Hero blocks that place white text over a background image have three layered problems that must all be addressed:
1. No gradient overlay — reducing image opacity alone isn't enough for busy images. Add a directional gradient:
.hero-teaser::before {
content: '';
position: absolute;
inset: 0;
z-index: 1;
background: linear-gradient(to right, rgb(0 0 0 / 60%) 0%, rgb(0 0 0 / 30%) 50%, transparent 100%);
pointer-events: none;
}
2. Gradient covers the text — if the overlay and the content row share the same z-index, the gradient renders on top of the text. The content row must have a higher z-index than the overlay.
3. Image paints on top of text (the subtle one) — in hero-teaser's DOM, the <picture> is a child of the content row but positioned absolutely to cover the whole hero. Because positioned elements with z-index: 0 paint after non-positioned flow content in CSS stacking order, the semi-transparent image sits on top of the text — making it look permanently washed out. The text cell must be explicitly positioned above the image:
/* Text cell must be above the absolutely-positioned picture */
.hero-teaser > div:last-child > div:last-child {
position: relative;
z-index: 1;
}
Complete stacking order (bottom to top):
z-index: 0 — <picture> (position: absolute, covers hero, opacity 0.5–0.6)
z-index: 1 — ::before gradient overlay (position: absolute)
z-index: 2 — content row container
z-index: 0 — <picture> again (resolved within row's stacking context)
z-index: 1 — text cell (position: relative, sits above picture)
All three fixes are required. Missing any one produces text that looks transparent or washed out.
The AEM dev server with --html-folder drafts does not register new page paths from local files alone. It proxies every request to the remote AEM server (https://main--{repo}--{owner}.aem.page/) first. If the remote server returns 404 (because the content hasn't been uploaded to DA yet), the dev server returns 404 too — even though the .plain.html file exists locally.
The --html-folder flag replaces content for paths the remote server already knows about; it does not create new paths. For a brand-new site subfolder (e.g. /mysite/), you must:
./tools/upload-to-da.sh {sitename}./tools/preview-all.sh {sitename}http://localhost:3000/{sitename}/Do not waste time debugging 404s on the dev server for new sites — upload to DA first.
When subagents generate .plain.html files, they frequently place block content outside the class div instead of inside it, producing empty block elements that render nothing:
<!-- BROKEN — agent puts content outside the class div -->
<div>
<div><picture>...</picture></div>
</div>
<div>
<div>
<h2>Heading</h2>
<p>Body text</p>
</div>
</div>
<div class="columns-teaser">
</div>
<!-- CORRECT — content is INSIDE the class div -->
<div class="columns-teaser">
<div>
<div><picture>...</picture></div>
<div>
<h2>Heading</h2>
<p>Body text</p>
</div>
</div>
</div>
Agents may also put page metadata in <head> meta tags instead of a <div class="metadata"> block. DA requires the metadata block to set page title and description — <head> meta tags are ignored.
Note: <html><body><main> wrappers are fine — DA requires them for upload. The upload tool adds them if missing. The problem is purely about block nesting and metadata placement.
Mitigations:
examples/reference-page.plain.html) in every agent prompt — not just block snippets# Option A: Use the GitHub template
# Go to https://github.com/adobe/aem-boilerplate and click "Use this template"
# Option B: Clone directly
git clone https://github.com/adobe/aem-boilerplate.git my-site
cd my-site
rm -rf .git
git init
git add -A && git commit -m "Initial commit from aem-boilerplate"
gh repo create owner/my-site --public --source=. --push
The AEM Code Sync bot will automatically detect the new repo and start syncing.
EDS supports multiple content sources:
da.liveConfigure fstab.yaml in the project root:
mountpoints:
/: https://content.da.live/org/repo/
Or for Google Drive:
mountpoints:
/: https://drive.google.com/drive/folders/YOUR_FOLDER_ID
npm install
npx @adobe/aem-cli up --no-open
# Dev server at http://localhost:3000
For local-only content (no CMS), create a drafts/ folder with .plain.html files:
mkdir drafts
npx @adobe/aem-cli up --no-open --html-folder drafts
├── blocks/ # Reusable content blocks
│ └── {blockname}/
│ ├── {blockname}.js # Block decoration logic
│ └── {blockname}.css # Block styles
├── styles/
│ ├── styles.css # Critical-path CSS (loaded eagerly for LCP)
│ ├── lazy-styles.css # Below-fold CSS (loaded lazily)
│ └── fonts.css # @font-face definitions
├── scripts/
│ ├── aem.js # Core AEM library — NEVER MODIFY
│ ├── scripts.js # Page decoration entry point
│ └── delayed.js # Martech, analytics (loaded 3s after page)
├── icons/ # SVG icons (referenced as :icon-name: in content)
├── fonts/ # Web font files
├── head.html # Global <head> content (meta, scripts)
├── 404.html # Custom 404 page
└── fstab.yaml # Content source mount configuration
For detailed setup instructions and troubleshooting, read references/project-setup.md.
Before writing code, analyze a reference site to extract structured brand guidelines. This produces two key artifacts that drive all design and content decisions.
Use Playwright to capture full-page screenshots of 10-20 representative pages:
mkdir -p analysis/screenshots
node tools/screenshot-reference.js
Capture: homepage, category pages, product/detail pages, about page, contact page.
Analyze screenshots systematically to document:
End with a block mapping table connecting visual patterns to EDS blocks:
| Site Pattern | EDS Block | Notes |
|---|---|---|
| Hero with image + heading | hero-teaser | Product-branded heroes |
| 3-column image cards | cards-teaser | Homepage category cards |
| Expandable sections | accordion | FAQ, focus areas |
Extract text from reference pages and analyze for:
Add to your project's CLAUDE.md or AGENTS.md:
### Brand Guidelines
- Visual analysis: `analysis/visual-analysis.md`
- Brand voice & imagery: `brand-voice.md`
- Consult these when writing copy, selecting imagery, or making design decisions.
For the complete brand analysis methodology, templates, and examples, read references/brand-analysis.md.
Define your brand's design tokens in styles/styles.css under :root:
:root {
/* Colors */
--background-color: #fff;
--light-color: #f5f5f5;
--dark-color: #1a1a2e;
--text-color: #333;
--link-color: #0066cc;
--link-hover-color: #004499;
/* Typography */
--body-font-family: 'Inter', 'Helvetica Neue', helvetica, arial, sans-serif;
--heading-font-family: 'Georgia', serif;
--fixed-font-family: 'Roboto Mono', menlo, consolas, monospace;
/* Font Sizes */
--heading-font-size-xxl: 36px;
--heading-font-size-xl: 28px;
--heading-font-size-l: 24px;
--heading-font-size-m: 20px;
--heading-font-size-s: 18px;
--heading-font-size-xs: 16px;
--body-font-size-m: 16px;
--body-font-size-s: 14px;
--body-font-size-xs: 12px;
/* Layout */
--max-width-site: 1200px;
--nav-height: 64px;
}
/* Scale up headings on desktop */
@media (width >= 900px) {
:root {
--heading-font-size-xxl: 48px;
--heading-font-size-xl: 36px;
--heading-font-size-l: 28px;
--heading-font-size-m: 24px;
}
}
Define fonts in styles/fonts.css (loaded conditionally for performance):
@font-face {
font-family: 'MyFont';
src: url('/fonts/myfont.woff2') format('woff2');
font-weight: 400;
font-display: swap;
}
Fonts are loaded conditionally by scripts.js — only on desktop (>= 900px) or when cached in sessionStorage. This prevents font-loading from blocking LCP on mobile.
Links are auto-converted to buttons based on author formatting:
**[Link text](url)** → .button.primary (strong wrapping)*[Link text](url)* → .button.secondary (em wrapping)***[Link text](url)*** → .button.accent (strong + em wrapping)Style all three variants in styles/styles.css.
Each block defines its own token file that centralizes all customizable values — colors, typography, spacing, borders, effects. This makes it easy to re-skin blocks without touching structural CSS.
blocks/{blockname}/
├── {blockname}.js # Decoration logic
├── {blockname}.css # Structural styles (uses tokens)
└── {blockname}-tokens.css # Design tokens (all customizable values)
Token naming convention: --{blockname}-{element}-{property}
/* cards-teaser-tokens.css */
:root {
--cards-teaser-heading-font-size: 26px;
--cards-teaser-heading-color: #4f0031;
--cards-teaser-button-background: #d0006f;
--cards-teaser-button-hover-background: #a80059;
/* ... all visual values for the block */
}
Import tokens at the top of the structural CSS:
/* cards-teaser.css */
@import url('./cards-teaser-tokens.css');
main .cards-teaser h2 {
font-size: var(--cards-teaser-heading-font-size);
color: var(--cards-teaser-heading-color);
}
Optionally create {blockname}-measurements.txt files to capture exact Figma values as a bridge between design and implementation.
For the complete token architecture, naming conventions, and examples, read references/design-tokens.md.
Before creating content, write a structured product/project briefing that defines the scope and subject matter. This becomes the single source of truth for all page content, driving page structure decisions, content for each page, brand voice application, and block selection.
The briefing should cover: product overview, clinical/technical data, key messages, and website scope (which pages to create and what each contains).
Every EDS page is composed of:
--- in authoring)The aem.live backend delivers clean HTML. Inspect it to understand the DOM:
# See the full decorated HTML
curl http://localhost:3000/path/to/page
# See the raw markdown
curl http://localhost:3000/path/to/page.md
# See the plain HTML before decoration
curl http://localhost:3000/path/to/page.plain.html
Section structure:
<main>
<div class="section">
<div class="default-content-wrapper">
<h1>Page Title</h1>
<p>Introductory text</p>
</div>
</div>
<div class="section">
<div class="hero-wrapper">
<div class="hero block" data-block-name="hero" data-block-status="loaded">
<!-- Block content -->
</div>
</div>
</div>
</main>
Before building a block, define its content model — the table structure authors will create:
| Column 1 | Column 2 | |-----------|----------| | Image | Text content | | ... | ... |
The block name is the table header. Each row/cell maps to block > div > div in the DOM. Design your content model to be author-friendly and handle missing fields gracefully.
Maintain a blocks/BLOCK-REFERENCE.md file documenting the expected .plain.html markup structure for every block in the project. This serves as the authoritative reference for content authoring — both for human authors and for parallel page generation agents. Include: class name, HTML snippet showing row/cell structure, and key notes per block.
For detailed content planning patterns, read references/content-patterns.md.
Each block is self-contained:
blocks/my-block/
├── my-block.js # Decoration logic
└── my-block.css # Scoped styles
Blocks are auto-loaded when their content appears on a page — no manual imports needed.
/**
* loads and decorates the block
* @param {Element} block The block element
*/
export default async function decorate(block) {
// 1. Read the DOM structure delivered by the backend
const rows = [...block.children];
// 2. Transform the DOM in place
rows.forEach((row) => {
const [imageCell, textCell] = [...row.children];
// ... transform cells
});
// 3. Add interactivity
block.addEventListener('click', handleClick);
}
Key principles:
decorate function receives the block <div> element<picture>, headings, etc.) rather than recreatingconsole.log(block.innerHTML) to inspect what the backend sends.js extensions in imports/* All selectors MUST be scoped to the block */
main .my-block {
/* Mobile-first base styles */
padding: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
main .my-block h2 {
font-family: var(--heading-font-family);
font-size: var(--heading-font-size-m);
}
/* Tablet+ */
@media (width >= 600px) {
main .my-block {
padding: 2rem;
}
}
/* Desktop+ */
@media (width >= 900px) {
main .my-block {
flex-direction: row;
padding: 4rem;
}
}
CSS rules:
.my-block .item, never just .itemmin-width media queries for largervar(--token) for all colors, fonts, sizes-container / -wrapper class names — those conflict with section wrappersBlock variants are CSS classes added to the block element by authors (e.g., Hero (dark) → .hero.dark):
/* CSS-only variant — no JS needed */
main .hero.dark {
background: var(--dark-color);
color: white;
}
/* JS-variant — when DOM structure changes */
if (block.classList.contains('carousel')) {
setupCarousel(block);
}
Create blocks automatically from content patterns in scripts.js:
function buildAutoBlocks(main) {
// Example: auto-create hero from first H1 + picture
const h1 = main.querySelector('h1');
const picture = main.querySelector('picture');
if (h1 && picture && h1.closest('div') === picture.closest('div')) {
const section = h1.closest('div.section > div');
const heroBlock = buildBlock('hero', { elems: [picture, h1] });
section.prepend(heroBlock);
}
}
For complete block development patterns, read references/block-patterns.md.
Replace stock photography placeholders with AI-generated images tailored to your brand.
Ask the user which provider to use:
| Provider | When to Choose | |----------|---------------| | Gemini 3 Pro | Fast iteration, simple setup (single API key), good general quality | | FLUX 2 Pro | Highest photorealism, explicit size control, async generation | | Adobe Firefly | Brand-safe imagery, Adobe ecosystem, OAuth credentials |
tools/generate-images.sh) with prompts for each imageimages/ directorychmod +x tools/generate-images.sh
./tools/generate-images.sh
CRITICAL: Never reference local paths (/images/, /icons/) in DA content. DA converts unresolvable paths to about:error. See "Common Pitfalls" above.
Deploy generated images to a CDN so DA can ingest them. Cloudflare Pages is the simplest option:
# Create a Pages project (one-time)
npx wrangler pages project create my-site-images --production-branch main
# Deploy images
npx wrangler pages deploy ./images --project-name my-site-images
# → Images live at https://my-site-images.pages.dev/hero-example.jpeg
Replace placeholder URLs in draft HTML with the public Cloudflare URLs, then upload to DA. DA automatically ingests images from public URLs.
# Replace all placeholder URLs with public Cloudflare URLs
find drafts -name '*.plain.html' -exec sed -i '' 's|/images/|https://my-site-images.pages.dev/|g' {} \;
# Upload to DA — DA will ingest the images automatically
./tools/upload-to-da.sh
./tools/preview-all.sh
For complete API documentation, script templates, and prompt writing tips for all three providers, read references/image-generation.md.
Create page content using your chosen content source:
da.live, using tables for blocks and formatted text for default content.plain.html files in drafts/ for development without a CMSFor local drafts, follow the markup structure documented in references/content-patterns.md — sections as <div> children of <main>, blocks as <div class="blockname"> with nested row/cell <div> elements.
CRITICAL: All <img> tags in DA content must use public URLs, not local paths. This applies to page images, nav logos, footer icons — everything. Local paths like /icons/logo.svg become about:error in DA. See "Common Pitfalls" above.
Choose navigation depth based on the site scope:
/efficacy, /safety, /dosing)/product-a/efficacy)Key rule: if the nav would have only one top-level dropdown, flatten it. A single dropdown adds a click with no informational benefit.
Every time you create or delete a page, update nav.plain.html and footer.plain.html to match. Navigation is authored as a separate fragment — broken nav links (pointing to non-existent pages) are easy to miss.
Quick audit:
# Compare page inventory against nav links
find drafts -name '*.plain.html' ! -name 'nav.*' ! -name 'footer.*' | sort
grep -oP 'href="\K[^"]+' drafts/nav.plain.html | grep -v '^#' | sort
For navigation structure patterns and consistency checklists, see references/content-patterns.md.
When the main agent generates all pages itself (no subagents), follow the same shared context approach as Phase 7b but generate pages one at a time. This is the recommended path for sites with 6 or fewer pages.
<head> tags instead of metadata blocks.Same as Phase 7b.1 — generate images, deploy to CDN, verify CDN is live, then create nav and footer.
Before generating any page, prepare the shared context bundle (same as Phase 7b.2):
blocks/BLOCK-REFERENCE.md)<picture> patternFor each page in the briefing, write the .plain.html file using the briefing's copy verbatim. Copy the accordion HTML identically into every page. Use the block reference for correct nesting. Generate pages in any order.
Same as Phase 7b.4 — run all validation checks (CDN reachability, local paths, structural validation), then upload to DA, preview, and open.
When a site has a complete briefing with final copy for every page and more than 6 pages, all pages can be generated concurrently using parallel agents. Each page is independent — same accordion, same CDN base, same block patterns — so no agent needs another agent's output.
Complete these steps sequentially before spawning parallel agents. Every step is mandatory — do not skip any step. If a step fails, stop and fix it before proceeding.
1. Generate images → ./tools/generate-images.sh {sitename}
2. Deploy to CDN → ./tools/deploy-images.sh {sitename}
3. Verify CDN is live → curl -sI https://{sitename}-images.pages.dev/astrazeneca-logo.png | head -1
Must return HTTP 200. If not, the CDN deployment failed — do not proceed.
4. Create nav + footer → Write drafts/{sitename}/nav.plain.html & footer.plain.html
- Extract the nav page list from the briefing's NAVIGATION SECTION, not from the full page inventory.
If the briefing lists fewer pages in nav than exist in the site, respect that — some pages may be
intentionally discoverable only via inline CTAs, not top-level navigation.
- Extract top-bar link URLs from briefing nav section (Contact Us, Login)
- Extract footer site links from the briefing's FOOTER SECTION — use the same page list as specified
there, which may differ from the full page inventory.
- Extract footer link URLs from briefing footer section (Privacy, Terms, Accessibility, AE reporting)
- Extract copyright line verbatim from briefing footer section
- Extract approval code and DOP from briefing
- Do NOT use placeholder /{sitename}/ URLs for links that the briefing specifies as external
These produce the shared context that all pages need.
CRITICAL: Steps 1–3 (image generation, CDN deployment, CDN verification) MUST complete before creating any draft files. All draft HTML references CDN image URLs. If the CDN doesn't exist, DA will fail to ingest images and they will appear broken on the live site. This is the single most common cause of broken images in production.
Before spawning agents, assemble a context bundle containing everything each agent needs. This avoids each agent redundantly reading the same files.
Shared context bundle:
├── CDN base URL → https://{sitename}-images.pages.dev/
├── Site prefix → /{sitename}/
├── Accordion content → The prescribing info / AE reporting / references HTML
│ (identical on every page — generate once from briefing
│ sections 5/6/7, then include verbatim in every page agent's
│ context. Do NOT let individual agents re-generate this.)
├── Metadata block → HTML pattern for page metadata
├── Block reference → blocks/BLOCK-REFERENCE.md (markup patterns for all blocks)
├── Reference example → A complete, known-good .plain.html file showing correct
│ nesting for ALL block types (see examples/reference-page.plain.html).
│ Include the FULL file content in each agent's prompt.
│ This is the single most effective way to prevent broken nesting.
└── Image <picture> pattern → <picture><source type="image/webp" srcset="CDN_URL"><img src="CDN_URL" alt="..."></picture>
Slice the briefing into per-page sections. Each agent receives:
drafts/{sitename}/efficacy.plain.html)┌─────────────────────────────────────────────────────┐
│ Main Agent │
│ 1. Sequential setup (images, CDN, nav, footer) │
│ 2. Extract shared context bundle │
│ 3. Spawn N page agents in parallel │
│ 4. Wait for all to complete │
│ 5. Upload to DA + preview │
└──────────┬──────┬──────┬──────┬──────┬──────┬───────┘
│ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼
Page 1 Page 2 Page 3 Page 4 Page 5 Page 6
Agent Agent Agent Agent Agent Agent
Use the Agent tool with subagent_type: "general-purpose" for each page. Launch all agents in a single message with multiple tool calls so they run concurrently:
// Pseudocode — launch all page agents in one message
for (const page of pages) {
Agent({
description: `Generate ${page.name} page`,
prompt: `
Create the file: drafts/${sitename}/${page.filename}
## CRITICAL STRUCTURAL RULES
1. ALL block content MUST be INSIDE the block's class div. NEVER put content outside it.
WRONG: <div><h2>Text</h2></div><div class="introduction"></div>
RIGHT: <div class="introduction"><div><div><h2>Text</h2></div></div></div>
2. Page metadata MUST use a <div class="metadata"> block, NOT <head><meta> tags.
DA ignores <head> meta tags — only the metadata block sets page title and description.
3. Use the reference example below as your structural template — match its nesting exactly.
## Reference Example (correct .plain.html structure)
${referenceExampleFileContent}
## Shared Context
${sharedContextBundle}
## Page Content (from briefing)
${page.briefingSection}
## Block Reference
${blockReference}
Write the complete .plain.html file using the exact copy from the briefing.
Map content to the specified block types using the markup patterns from the block reference.
Use CDN URLs for all images: https://${sitename}-images.pages.dev/${imageName}
Do not modify any copy text — it is final approved content.
`,
subagent_type: "general-purpose"
});
}
IMPORTANT: Include the full content of examples/reference-page.plain.html in each agent's prompt (read it once, embed the text). Block reference snippets alone are insufficient — agents misinterpret the nesting. A complete example file showing correct structure for hero-teaser, cards-teaser, columns-teaser, tabs-large, introduction, title, table-data, accordion, section-metadata, and metadata blocks eliminates the most common structural errors.
IMPORTANT: Execute all of these steps automatically once all page agents complete. Do not wait for the user to ask — the whole point of parallel generation is an end-to-end pipeline that delivers a viewable site.
Step 0 — Verify CDN images are reachable (blocks upload if any fail):
This is the most critical pre-upload check. If the CDN is not live, every image on the site will be broken.
# Sample 3 image URLs from drafts and verify they return HTTP 200
echo "--- CDN reachability check ---"
FAILED=0
for url in $(grep -oh 'https://[^"]*\.jpeg' drafts/{sitename}/*.plain.html | sort -u | head -3); do
code=$(curl -sI -o /dev/null -w '%{http_code}' "$url")
if [ "$code" = "200" ]; then
echo "OK ($code): $url"
else
echo "FAILED ($code): $url"
FAILED=1
fi
done
[ "$FAILED" = "1" ] && echo "BLOCKED: CDN images not reachable — run generate-images.sh and deploy-images.sh first" || echo "OK: CDN images verified"
If this check fails, stop immediately. Run ./tools/generate-images.sh {sitename} and ./tools/deploy-images.sh {sitename} before proceeding. Uploading to DA with unreachable image URLs produces broken images that persist until the content is re-uploaded after fixing.
Step 1 — Verify no local image paths (blocks upload if any are found):
grep -rn 'src="/' drafts/{sitename}/ --include='*.html' && echo "BLOCKED: fix local paths before uploading" || echo "OK: no local paths"
Step 1b — Verify no placeholder links in nav/footer (blocks upload if any are found):
grep -Pn 'href="/{sitename}/"' drafts/{sitename}/nav.plain.html drafts/{sitename}/footer.plain.html | grep -v 'img src' | grep -v 'Login' && echo "BLOCKED: placeholder links found — replace with briefing URLs" || echo "OK: no placeholder links"
Nav/footer links that equal /{sitename}/ (other than the logo home link and Login CTA) are likely unfilled placeholders. Cross-reference against the briefing's nav/footer sections and replace with the specified URLs.
Step 1c — Structural validation (blocks upload if any issues are found):
This step catches the most common agent generation failures. Run all three checks:
# 1. Every content page has accordion and metadata blocks
echo "--- Accordion count (expect 1 per content page, 0 for nav/footer) ---"
grep -c 'class="accordion"' drafts/{sitename}/*.plain.html
echo "--- Metadata count (expect 1 per content page, 0 for nav/footer) ---"
grep -c 'class="metadata"' drafts/{sitename}/*.plain.html
# 2. No empty block class divs (broken nesting — content ended up outside the block)
grep -n 'class="\(hero-teaser\|columns-teaser\|cards-teaser\|tabs-large\|introduction\|title\|table-data\|accordion\)">' drafts/{sitename}/*.plain.html | grep '>\s*$' && echo "BLOCKED: empty block divs found — content is outside the block" || echo "OK: no empty block divs"
# 3. No metadata in <head> tags (DA ignores these — must be a metadata block div)
grep -rn '<head>' drafts/{sitename}/*.plain.html | grep -v nav | grep -v footer && echo "WARNING: check that metadata uses <div class='metadata'>, not <head><meta> tags" || echo "OK"
If any check fails, read the broken file, compare against examples/reference-page.plain.html, and rewrite it with correct nesting. Do not attempt surgical fixes on structurally broken files — rewriting is faster and more reliable.
Step 2 — Upload all drafts to DA (including nav and footer):
./tools/upload-to-da.sh {sitename}
Step 3 — Preview all pages on AEM CDN (including nav and footer — they must be previewed or the header/footer won't render on the live preview):
./tools/preview-all.sh {sitename}
Step 4 — Open the previewed homepage in the browser:
open "https://main--{repo}--{owner}.aem.page/{sitename}/"
This closes the feedback loop — the user sees the fully rendered site with header, footer, and all blocks decorated without needing to start a local dev server or run any commands themselves.
When using Document Authoring (DA) as your content source, you can programmatically upload and manage content using Adobe IMS service tokens. This enables automated workflows: AI-generated content, bulk uploads, CI/CD pipelines.
DA uses a two-step OAuth flow:
# Exchange service token for access token
ACCESS_TOKEN=$(curl -s -X POST "https://ims-na1.adobelogin.com/ims/token/v3" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code&client_id=$DA_CLIENT_ID&client_secret=$DA_CLIENT_SECRET&code=$DA_SERVICE_TOKEN" \
| python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])")
Store in .env (never commit):
DA_ORG=my-org
DA_REPO=my-site
DA_CLIENT_ID=my-project-da-prod
DA_CLIENT_SECRET="your-secret"
DA_SERVICE_TOKEN="eyJhbGciOiJSUzI1NiIs..."
Before uploading: verify no local asset paths remain in drafts (DA converts these to about:error):
grep -rn 'src="/\|src="\.\.' drafts/ --include='*.html' && echo "BLOCKED: fix local paths" || echo "OK"
# 1. Create local draft content (drafts/*.plain.html)
# 2. Convert plain HTML → DA table format
python3 tools/plain-to-da.py drafts/page.plain.html > /tmp/page.html
# 3. Upload to DA
curl -X POST -H "Authorization: Bearer $ACCESS_TOKEN" \
-F "data=@/tmp/page.html;type=text/html" \
"https://admin.da.live/source/$DA_ORG/$DA_REPO/page.html"
# 4. Trigger CDN preview refresh
curl -X POST -H "Authorization: Bearer $ACCESS_TOKEN" \
"https://admin.hlx.page/preview/$DA_ORG/$DA_REPO/main/page"
The skill includes ready-to-use scripts:
| Script | Purpose |
|--------|---------|
| tools/plain-to-da.py | Convert .plain.html (div-based) to DA format (table-based) |
| tools/upload-to-da.sh | Authenticate + bulk upload all drafts to DA |
| tools/preview-all.sh | Trigger CDN preview refresh for all uploaded pages |
For complete DA integration guide including format conversion details, API reference, and troubleshooting, read references/da-integration.md.
When using Document Authoring (DA), enable visual in-context editing with the Universal Editor. Authors can click on blocks and content elements to edit them directly rather than through table structures.
Create four root-level JSON files:
| File | Purpose |
|------|---------|
| component-definition.json | Declares all available components (text, image, section, blocks) |
| component-models.json | Defines editable fields for each component |
| component-filters.json | Controls what can be nested inside what |
| paths.json | Maps DA content paths to website routes |
Blocks that transform their DOM during decoration (e.g., converting <div> rows to <ul>/<li> lists) need MutationObserver scripts that move data-aue-* attributes to the correct decorated elements.
// ue/scripts/ue.js — watches for DOM changes and remaps editor attributes
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.nodeName === 'UL') {
moveInstrumentation(block.firstElementChild, node);
}
});
});
});
observer.observe(block, { childList: true, subtree: true });
Only load UE scripts in the editor context:
if (window.location.hostname.includes('adobeaemcloud.com')
|| new URLSearchParams(window.location.search).has('aue')) {
await import('../ue/scripts/ue.js');
}
For the complete UE integration guide including component definitions, models, filters, DA plugin fields, and instrumentation patterns, read references/universal-editor.md.
EDS uses progressive loading for maximum performance:
appear class to body (reveals content)lazy-styles.cssdelayed.js for martech, analytics, third-party scriptsNever load non-critical resources in the eager phase. This is the single most important performance rule.
npm run lint # ESLint (Airbnb) + Stylelint
npm run lint:fix # Auto-fix issues
Set up GitHub Actions to run lint on every push. Create a PR template that requires preview URLs. See references/project-setup.md for the CI/CD workflow and template.
https://developers.google.com/speed/pagespeed/insights/?url=YOUR_URLstyles.css minimal (critical path only)curl http://localhost:3000/page # Full HTML
curl http://localhost:3000/page.md # Markdown
curl http://localhost:3000/page.plain.html # Plain HTML
For detailed testing and performance guidance, read references/testing-performance.md.
With your GitHub {owner}/{repo} and {branch}:
| Environment | URL |
|-------------|-----|
| Local dev | http://localhost:3000/ |
| Branch preview | https://{branch}--{repo}--{owner}.aem.page/ |
| Production preview | https://main--{repo}--{owner}.aem.page/ |
| Production live | https://main--{repo}--{owner}.aem.live/ |
https://{branch}--{repo}--{owner}.aem.page/main — include a link to the preview URL showing your changesgh pr create --title "Add hero block" --body "$(cat <<'EOF'
## Summary
- Added hero block with image + heading + CTA layout
- Supports dark variant for inverted color scheme
## Preview
https://feat-hero--my-site--my-org.aem.page/
## Test plan
- [ ] Hero renders correctly on desktop and mobile
- [ ] Dark variant applies correct colors
- [ ] PageSpeed score remains 100
EOF
)"
Search the full aem.live documentation:
# Search by keyword
curl -s https://www.aem.live/docpages-index.json | jq -r '.data[] | select(.content | test("KEYWORD"; "i")) | "\(.path): \(.title)"'
# Web search restricted to docs
# Use: site:www.aem.live <query>
Key documentation:
Read these for detailed guidance on specific topics:
| File | When to Read |
|------|-------------|
| references/project-setup.md | Setting up a new project, configuring content sources, CI/CD pipeline, troubleshooting dev server |
| references/brand-analysis.md | Screenshotting reference sites, extracting brand voice, visual analysis methodology |
| references/design-tokens.md | Block-level CSS custom properties, token naming conventions, Figma-to-tokens workflow |
| references/block-patterns.md | Building new blocks, JS decoration patterns, advanced DOM manipulation |
| references/image-generation.md | AI image generation with Gemini 3 Pro, FLUX 2 Pro, or Adobe Firefly |
| references/content-patterns.md | Product briefing, content modeling, section metadata, fragment patterns, draft content |
| references/da-integration.md | DA service token auth, programmatic content upload, format conversion, preview API |
| references/universal-editor.md | UE component definitions, models, filters, DA plugin fields, MutationObserver instrumentation |
| references/testing-performance.md | Linting, PageSpeed optimization, accessibility testing, responsive QA |
| examples/reference-page.plain.html | Complete, known-good .plain.html showing correct nesting for all block types. Include in parallel agent prompts to prevent structural errors. |
development
Generate artistic infographics from any topic. Runs the Sumi pipeline (analyze → structure → craft prompt → generate image) entirely within Claude Code. Use when "generate infographic", "create infographic", "sumi", "make an infographic about", or "visualize topic".
tools
Implement Server-Sent Events streaming from Cloudflare Workers to browser clients with reconnection, state persistence, and progress tracking. Use when building "SSE streaming", "real-time updates", "server push", or "event streaming".
development
Audit websites by cross-referencing query indexes, sitemaps, and navigation to identify content gaps, stale pages, missing metadata, and quality issues. Use when "auditing a website", "finding content gaps", "site quality audit", or "content inventory analysis".
data-ai
Track user session context across multi-turn interactions using browser sessionStorage and server-side KV caching with TTL. Use when implementing "session tracking", "conversation context", "multi-turn sessions", or "user journey tracking".