Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .eslintignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
helix-importer-ui
*.min.js
*.min.js
blocks/_react/
build/
1 change: 1 addition & 0 deletions .stylelintignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
blocks/_react/react-runtime.css
142 changes: 142 additions & 0 deletions POC-GUIDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
# HAL × Adobe EDS / DA.live — Native Authoring POC Guide

**Start here.** This repo (`hal`, from `AdobeDevXSC/hal`) is a proof-of-concept answering one question:
**can we reuse HAL's `@hal-sbn-root/shared-ui` design system inside Adobe Edge Delivery Services (EDS)** —
where authors compose pages in **DA.live** (Document Authoring), **Adobe's edge renders the page**, and
authors can **drag / rearrange** our components?

Answer so far: **yes** — our shared-ui components run as **React islands** inside vanilla EDS blocks.

> A prior track — **headless** (a Next.js app that fetched DA.live JSON and rendered shared-ui itself) —
> was proven separately and is **archived** at `../hal-headless-poc/`. This repo is the **native**
> (EDS-rendered) track, plus the **Universal Editor** work on the roadmap below.

---

## The core idea — "React islands"

An EDS page is **mostly plain HTML** rendered by Adobe's edge (nav, text, images, footer — no React).
Each of our components is a **self-contained pocket of React** — an *island* — mounted into one block's
`<div>`, with its own React runtime bundled in. The rest of the page never loads React. (The term comes
from "islands architecture": the page is the static ocean; each interactive component is a React island.)

EDS is **no-build**, so we pre-bundle the island (React + shared-ui + providers) with esbuild and **commit
the output** — EDS serves it as-is.

---

## ★ How the React → block mapping works

When EDS renders a page, for each block named `<name>` it auto-imports `blocks/<name>/<name>.js`
and calls its default export with the block's DOM element (EDS's `decorate()` contract):

```
Authored block table (rows of: key | value)
▼ EDS auto-loads blocks/<name>/<name>.js (e.g. blocks/button/button.js)
renderBlock(block, 'Button') ← blocks/_react/block.js (orchestrator)
├─ parseBlock(block) ← blocks/_react/parse.js ★ the table → props mapping
│ reads the 2-col rows into a props object; supports dotted + [index] paths + booleans:
│ children -> "Book now"
│ fareCards[0].title -> array of objects
│ card1.isBestValue -> boolean
├─ ensureIslandCss() injects react-runtime.css once
▼ dynamic import of the esbuild bundle (react-runtime.js)
mountComponent(el, 'Button', props) ← blocks/_react/runtime.jsx (the island)
│ registry['Button'] → the shared-ui Button ← blocks/_react/registry.jsx
createRoot(el).render( <Providers> <Button {...props}/> </Providers> )
```

**Key files:**
- `blocks/<name>/<name>.js` — 2-line entry per block; EDS's naming convention auto-loads it.
Just calls `renderBlock(block, 'ComponentName')`.
- **`blocks/_react/block.js` — the orchestrator**: parse table → props, ensure CSS, import the
bundle, mount.
- **`blocks/_react/parse.js` — ★ the table → props mapping.** Generic: any component is
configurable from a key/value table with zero per-component code.
- `blocks/_react/registry.jsx` — name → shared-ui component map.
**Add a component here + a 2-line block folder = a new block.**
- `blocks/_react/runtime.jsx` — the React island: wraps the component in
Redux/Query/Router/ThemeProvider + imports design tokens. Bundled by esbuild.
- `build/esbuild.mjs` — the bundler (the "extra plumbing" EDS lacks). Externalizes shared-ui
barrel junk (`@ownid/react`, query-devtools, AEM fonts) so they tree-shake out.
- `blocks/_react/react-runtime.js` / `.css` — **committed build output** (EDS is no-build, so the
bundle must live in the repo).

---

## The DA.live picker config (JSON — can't be commented inline, so documented here)
- **`component-definition.json` — the picker.** The `hal-shared-ui` group lists our 5 blocks
(button, headline, savings-badge, fare-card, choose-your-fare). Each has a
`plugins.da.unsafeHTML` template (the block skeleton DA inserts) + `fields` (which cells are
editable). **This is what makes a block appear in DA.live's builder to drag.**
- `component-filters.json` — nesting rules: our 5 block ids are added to the `section` group so
they can be dropped into a section.
- `component-models.json` — field models that back the builder's edit forms.

---

## The last mile — what actually makes drag-drop work
The picker is a **cloud** feature: DA.live reads `component-definition.json` from the *deployed*
branch. So the blocks + configs must be pushed to a branch DA.live can read; only then do they
appear in the builder's block picker to drag. Everything up to that point is done + test-driven locally.

Two ways content maps to page order:
- **Reorder in the doc** → the page reorders 1:1 (EDS is document-driven; each block becomes a `<div>`
in the authored order, decorated top-to-bottom). Works today with just the committed block code.
- **Drag / reorder in the builder** → needs `component-definition.json` deployed so the palette lists
the blocks.

---

## One-time engine vs per-component cost
The engine (esbuild + island + providers + the generic parser) is built **once**. Adding
component #6, #7… is ~3 lines each: a `registry.jsx` line + a 2-line block file + a picker entry
in `component-definition.json` + a filter line. The bundle cost is front-loaded — the first
component pulls in React + shared-ui; the rest share it.

---

## Roadmap — what's next
The renderer stays `@hal-sbn-root/shared-ui` throughout; we only change **who authors** and **who renders**.

- **Now — Native drag-drop (this repo).** Blocks render on the EDS page; authors rearrange by reordering.
Last mile: push → deploy → the DA builder lists the blocks to drag.
- **Next — Universal Editor (the "live editor").** In-context WYSIWYG editing, two flavors: **native**
(xwalk on EDS) and **headless** (instrument our own app). Both need **`data-aue-*` instrumentation per
component** + an **AEM** connection. The editing *overlay* is demoable locally; **saving** edits needs
the AEM backend (a DevOps/access item).
- **Future — Content Fragments.** Structured, schema-defined headless content in AEM via GraphQL. Same
shared-ui renderer, different source — proves the design system is source-agnostic.

---

## Build & test
- **Build the island:** `node build/esbuild.mjs` (Node ≥ 20 + the React/shared-ui deps installed locally
over corp VPN — deps are intentionally **not** in `package.json`, since the committed bundle is what
EDS serves).
- **Test rendering, no deploy:** serve the repo statically and open `blocks/_react/gallery.html` (renders
all 5 components through the block code).
- **Test on real EDS (your machine):** `npx @adobe/aem-cli up`, then hand-author a block table in a
DA.live doc → preview → it renders through the real EDS pipeline; reorder the tables → the page reorders.
- **CI (`npm ci` + `npm run lint`) must pass:** the island (`blocks/_react/`, `build/`) is in
`.eslintignore` and the bundled CSS is in `.stylelintignore`; the private `shared-ui` dep is kept out of
`package.json` (CI can't reach HAL's Artifactory).

---

## Repo layout cheat-sheet
```
hal/
├─ POC-GUIDE.md ← you are here
├─ blocks/
│ ├─ _react/ ← the island engine (block.js, parse.js, registry, runtime, bundle, gallery)
│ └─ button|headline|savings-badge|fare-card|choose-your-fare/ ← 2-line block entries
├─ build/esbuild.mjs ← island bundler
├─ component-{definition,filters,models}.json ← DA.live picker config
└─ scripts | styles | head.html | fstab.yaml … ← EDS boilerplate (base project)
```
22 changes: 22 additions & 0 deletions blocks/_react/block.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// The one helper every component block calls: parse the authored table → props,
// then mount the named shared-ui component via the shared runtime bundle.
import { parseBlock } from './parse.js';

function ensureIslandCss() {
if (document.querySelector('link[data-react-island]')) return;
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = '/blocks/_react/react-runtime.css';
link.setAttribute('data-react-island', '');
document.head.append(link);
}

// We do NOT wipe the block. The authored source rows stay in the DOM (hidden) so
// Universal Editor can edit them + re-run decorate on change; React mounts into a
// child island, so an in-place re-render on edit never clobbers the source.
export async function renderBlock(block, componentName) {
ensureIslandCss();
const props = parseBlock(block);
const { mountComponent } = await import('/blocks/_react/react-runtime.js');
mountComponent(block, componentName, props);
}
104 changes: 104 additions & 0 deletions blocks/_react/gallery.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>shared-ui components as EDS blocks — gallery</title>
<style>
body { margin: 0; background: #fff; font-family: system-ui, sans-serif; color: #030f1f; }
.banner { padding: 12px 20px; background: #022658; color: #fff; font-size: 13px; }
.banner b { color: #ffd7a8; }
main { max-width: 900px; margin: 0 auto; padding: 20px; }
.demo { margin: 26px 0; padding: 18px; border: 1px solid #d9dbde; border-radius: 12px; }
.demo > h2 { margin: 0 0 12px; font-size: 13px; text-transform: uppercase; letter-spacing: .05em; color: #e46c24; }
/* the authored tables are consumed by decorate(); hide the raw markup */
.block { display: block; }
</style>
</head>
<body>
<div class="banner">
<b>shared-ui components as EDS blocks</b> — each card below is a real shared-ui React component,
mounted by its EDS block's <i>decorate()</i>, configured from an authored key/value table.
Adding a new component = one line in the registry + a 2-line block file.
</div>
<main>

<div class="demo">
<h2>Atom · Headline</h2>
<div class="headline block" data-block="headline">
<div><div>children</div><div>Welcome aboard — plan your voyage</div></div>
</div>
</div>

<div class="demo">
<h2>Atom · Button</h2>
<div class="button block" data-block="button">
<div><div>children</div><div>Book your cruise</div></div>
<div><div>variant</div><div>primary</div></div>
</div>
</div>

<div class="demo">
<h2>Molecule · SavingsBadge</h2>
<div class="savings-badge block" data-block="savings-badge">
<div><div>text</div><div>Save $200 per guest</div></div>
</div>
</div>

<div class="demo">
<h2>Organism · FareCardDetails (Card)</h2>
<div class="fare-card block" data-block="fare-card">
<div><div>title</div><div>Have It All</div></div>
<div><div>badgeLabel</div><div>Best value</div></div>
<div><div>isBestValue</div><div>true</div></div>
<div><div>currency</div><div>$</div></div>
<div><div>price</div><div>1,299</div></div>
<div><div>perPersonLabel</div><div>Per person</div></div>
<div><div>strikePrice</div><div>$1,499*</div></div>
<div><div>isShowSavingsBadge</div><div>true</div></div>
<div><div>savingsText</div><div>Save $200!</div></div>
<div><div>ctaLabel</div><div>Select</div></div>
<div><div>secondaryLabel</div><div>See details</div></div>
</div>
</div>

<div class="demo">
<h2>Template · ChooseYourFare</h2>
<div class="choose-your-fare block" data-block="choose-your-fare">
<div><div>heading</div><div>How do you like to cruise?</div></div>
<div><div>description</div><div>Pick the fare that fits.</div></div>
<div><div>pageSavingsBadge.text</div><div>You've saved $100 per guest so far</div></div>
<div><div>fareCards[0].id</div><div>have-it-all</div></div>
<div><div>fareCards[0].title</div><div>Have It All</div></div>
<div><div>fareCards[0].badgeLabel</div><div>Best value</div></div>
<div><div>fareCards[0].isBestValue</div><div>true</div></div>
<div><div>fareCards[0].currency</div><div>$</div></div>
<div><div>fareCards[0].price</div><div>1,299</div></div>
<div><div>fareCards[0].perPersonLabel</div><div>Per person</div></div>
<div><div>fareCards[0].ctaLabel</div><div>Select Have It All</div></div>
<div><div>fareCards[0].secondaryLabel</div><div>See details</div></div>
<div><div>fareCards[1].id</div><div>cruise-fare</div></div>
<div><div>fareCards[1].title</div><div>Cruise Fare</div></div>
<div><div>fareCards[1].currency</div><div>$</div></div>
<div><div>fareCards[1].price</div><div>999</div></div>
<div><div>fareCards[1].perPersonLabel</div><div>Per person</div></div>
<div><div>fareCards[1].ctaLabel</div><div>Select Cruise Fare</div></div>
<div><div>fareCards[1].secondaryLabel</div><div>See details</div></div>
</div>
</div>

</main>
<script type="module">
// Mimics EDS block loading: for each block, import /blocks/<name>/<name>.js and decorate.
document.querySelectorAll('[data-block]').forEach(async (el) => {
const name = el.dataset.block;
try {
const mod = await import(`/blocks/${name}/${name}.js`);
await mod.default(el);
} catch (e) {
el.innerHTML = `<pre style="color:#bb0000">${name} failed: ${e}</pre>`;
}
});
</script>
</body>
</html>
39 changes: 39 additions & 0 deletions blocks/_react/parse.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Generic authored-table → props parser (plain JS, loaded by each block).
// Supports dotted + [index] paths and boolean coercion, so ANY shared-ui
// component can be configured from a key/value table:
// children -> "Book now"
// pageSavingsBadge.text -> nested object
// fareCards[0].title -> array of objects
// card1.isBestValue=true -> boolean
function coerce(v) {
if (v === 'true') return true;
if (v === 'false') return false;
return v;
}

function setPath(obj, path, value) {
const parts = path.replace(/\[(\d+)\]/g, '.$1').split('.').filter(Boolean);
let cur = obj;
for (let i = 0; i < parts.length; i += 1) {
const p = parts[i];
if (i === parts.length - 1) {
cur[p] = coerce(value);
return;
}
const nextIsIndex = /^\d+$/.test(parts[i + 1]);
if (cur[p] == null) cur[p] = nextIsIndex ? [] : {};
cur = cur[p];
}
}

export function parseBlock(block) {
const props = {};
[...block.children].forEach((row) => {
if (row.hasAttribute('data-island-root')) return; // skip our React render target
const cells = row.children;
if (cells.length >= 2) {
setPath(props, cells[0].textContent.trim(), cells[1].textContent.trim());
}
});
return props;
}
1 change: 1 addition & 0 deletions blocks/_react/react-runtime.css

Large diffs are not rendered by default.

103 changes: 103 additions & 0 deletions blocks/_react/react-runtime.js

Large diffs are not rendered by default.

17 changes: 17 additions & 0 deletions blocks/_react/registry.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Which shared-ui components are exposed as EDS blocks.
// Add a component here + a 2-line block folder → it's authorable as a block.
import {
Button,
Headline,
SavingsBadge,
FareCardDetails,
ChooseYourFare,
} from '@hal-sbn-root/shared-ui';

export const registry = {
Button, // atom
Headline, // atom
SavingsBadge, // molecule
FareCardDetails, // organism (card)
ChooseYourFare, // template
};
Loading