Skip to content
Merged
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
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -271,10 +271,11 @@ tooling and SSR imports.

## Website

`site/` holds a dependency-free landing page with an interactive appearance playground, plus a
full API reference at `site/docs.html`. Build and serve both with `npm run site`. The icon, the
generated `sitemap.xml` / `robots.txt` / `llms.txt`, the structured data, and the Cloudflare
deployment settings are covered in [docs/website.md](docs/website.md).
`site/` holds a dependency-free landing page with an interactive appearance playground, a live
demo page that runs every surface the library ships, and a full API reference — each published in
English and Vietnamese. Build and serve them with `npm run site`. The icon, the generated `sitemap.xml` / `robots.txt` / `llms.txt`, the
structured data, and the Cloudflare deployment settings are covered in
[docs/website.md](docs/website.md).

## Contributing

Expand Down
106 changes: 97 additions & 9 deletions docs/website.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,20 +21,23 @@ CDN, so what you see is always the build you have.
| File | Role |
| ----------------- | --------------------------------------------------------------------- |
| `site/index.html` | The landing page: demos, appearance playground, facts table, and FAQ. |
| `site/demo.html` | Live demos: every surface running, each beside its code. |
| `site/docs.html` | The API reference, written against the reviewed report in `etc/`. |
| `site/404.html` | Served for any unmatched path. Carries `noindex`. |
| `site/styles.css` | Site styling. Its dark mode reads the same `data-fd-theme` attribute |
| | that the library's `setTheme()` sets, so both switch together. |
| `site/common.js` | Shared chrome: version badge, theme toggle, copy buttons. |
| `site/site.js` | Landing-page demos and the appearance playground. |
| `site/docs.js` | Highlights the sidebar entry for the section being read. |
| `site/icon.svg` | The mark, and the favicon browsers actually load. |
| `site/demo.js` | Runs the demos. One delegated listener, no inline handlers. |
| `site/icon.svg` | The mark: the favicon, and the logo in every page header. |
| `wrangler.jsonc` | Cloudflare deployment config. Points at `site/`, not `dist/`. |
| `site/_headers` | Cloudflare response headers, including a strict CSP. |

### URLs

The docs page is served at **`/docs`**, not `/docs.html`. Both Cloudflare's static-asset routing
The demo and docs pages are served at **`/demo`** and **`/docs`**, not `/demo.html` and
`/docs.html`, and their Vietnamese counterparts at `/vi/demo` and `/vi/docs`. Both Cloudflare's static-asset routing
(`html_handling: "auto-trailing-slash"`, the default) and the `serve` used by `npm run site` drop
the `.html` and redirect the extension form, so the extensionless URL is the one that answers with
a `200`. Every link, canonical tag, sitemap entry, and `llms.txt` link uses it — a canonical or a
Expand All @@ -53,15 +56,37 @@ refers to it. The npm package (`forgedialog`), the browser global (`ForgeDialog`
element (`forge-dialog`), and the repository name stay as they are: they are identifiers, not
prose.

## The demo page

`site/demo.html` runs every surface the library ships — the four dialog entry points, `open()`,
stacking, animations, drawers, bottom sheets, toasts, the lightbox, loading states, the command
palette, the notification centre, `form()`, `wizard()`, and `formWizard()` — against the same
vendored build the rest of the site uses.

Each card pairs a control that really runs with the snippet of the call being made, so the page
cannot drift from what it documents: if a snippet were wrong, the button beside it would misbehave.
A plugin registered on the page logs `afterOpen` and `afterClose`, and every demo prints its return
value, so the result log doubles as evidence that the hooks fire.

`demo.js` uses one delegated listener on `document` keyed by `data-demo`, because the site's CSP is
`script-src 'self'` and inline `onclick` attributes would be blocked. A demo that throws prints the
failure into the log rather than only the console.

This is separate from `demo/index.html` at the repository root, which is the manual verification
page the visual regression test drives. That one loads `../dist/` directly and is not deployed.

## Icons

`site/icon.svg` is the source of truth and is what browsers load as the favicon. Two bitmaps are
committed beside it because SVG is not enough everywhere:
`site/icon.svg` is the source of truth. It is what browsers load as the favicon and what every page
header shows beside the wordmark — with `alt=""`, since the adjacent text already names the product
and a screen reader should not announce it twice. Two bitmaps are committed beside it because SVG
is not enough everywhere:

| File | Size | Used by |
| --------------------------- | -------- | ------------------------------ |
| `site/apple-touch-icon.png` | 180×180 | iOS home screens |
| `site/og-image.png` | 1200×630 | Link previews and social cards |
| File | Size | Used by |
| --------------------------- | -------- | ---------------------------------------------- |
| `site/favicon.ico` | 32×32 | Clients that request `/favicon.ico` regardless |
| `site/apple-touch-icon.png` | 180×180 | iOS home screens |
| `site/og-image.png` | 1200×630 | Link previews and social cards |

Both are rendered from `icon.svg` through Chromium, which is already a dev dependency:

Expand All @@ -71,7 +96,70 @@ npm run site:icons

Run it only when the icon changes; neither the site build nor a deploy runs it. The apple-touch
variant squares off the mark's ground (via `#ground { rx: 0 }`) because iOS applies its own rounded
mask and would otherwise round it twice.
mask and would otherwise round it twice. The `.ico` is a 32×32 PNG wrapped in an ICO container,
which every browser since Vista accepts and which needs no bitmap encoder — the whole file is about
1.2 KB.

## Vietnamese

All three pages are published in Vietnamese under `/vi/`, as translations of the English pages
rather than different content. `site/vi/index.html`, `site/vi/demo.html`, and `site/vi/docs.html`
mirror their English counterparts section for section, keeping the same element ids so
`site.js`, `demo.js`, and `docs.js` drive both languages without a second copy.

### How the strings work

Those three scripts are shared, so nothing user-visible is hard-coded in them. Each page carries a
JSON data block, and the script reads it with English as the fallback:

```html
<script type="application/json" id="fd-strings">
{ "alertTitle": "Lưu ý", "labels": { "cancel": "Huỷ", "submit": "Gửi" } }
</script>
```

A data block is not an executable script, so `script-src 'self'` allows it — an inline `<script>`
of real code would be blocked. The id is prefixed because `i18n` alone is already a section id on
the docs page, and `getElementById` would find that section instead.

`labels` is passed to the library's own `setLabels()` by `common.js`, so the buttons inside the
dialogs are in the same language as the page around them. The theme toggle and copy buttons read
their four strings from `data-` attributes for the same reason.

The code shown beside each demo is translated too: the snippet has to be the call that is actually
made, or the page stops documenting itself.

### hreflang

Each page declares `en`, `vi`, and `x-default`, and the sitemap repeats the same set as
`xhtml:link` alternates on every entry. `x-default` points at the English page, since this is a
static site with no language negotiation.

Both sides have to name each other. `npm run site:check` fails the build on a one-way annotation,
on a missing `x-default`, and on a page whose hreflang set leaves out its own canonical — those
are the three ways this goes wrong, and none of them is visible in a browser.

Assets on a `/vi/` page are linked root-relative (`/styles.css`, not `./styles.css`), because the
page sits a directory down and a relative path would resolve inside `/vi/`.

## Checking the site

`npm run site:build` ends with `node scripts/check-site.mjs`, so a broken site fails the Cloudflare
deploy rather than reaching the domain. Run it alone with `npm run site:check`.

It fails the build on:

- a link or asset that resolves to nothing, following the same rules the server does (a directory
means its `index.html`, an extensionless path means the `.html` file of that name);
- a `#fragment` naming an id that does not exist on the page it lands on;
- a one-way, incomplete, or self-omitting hreflang set;
- a link written in the `.html` form, which the server answers with a redirect;
- JSON-LD that does not parse, and an `FAQPage` whose questions have drifted from the visible ones;
- a page with no canonical, title, description, or `og:image`, a `404.html` that declares a
canonical or forgets `noindex`, and any disagreement between pages about the canonical origin.

Every one of those is a failure this site shipped or nearly shipped. They are checked rather than
eyeballed because a page can look right in a browser and still be wrong in all of them.

## SEO and answer engines

Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -226,10 +226,11 @@
"validate": "npm run typecheck && npm run lint && npm run test:coverage && npm run size && npm run test:treeshake && npm run check:package && npm run api:check && npm run api:subpaths && npm run test:consumers && npm run test:capabilities && npm run test:csp && npm run package:size",
"validate:all": "npm run validate && npm run test:e2e",
"demo": "npm run build && serve .",
"site:build": "npm run build && node scripts/build-site.mjs",
"site:build": "npm run build && node scripts/build-site.mjs && node scripts/check-site.mjs",
"site:icons": "node scripts/build-icons.mjs",
"site": "npm run site:build && serve site",
"prepublishOnly": "npm run build"
"prepublishOnly": "npm run build",
"site:check": "node scripts/check-site.mjs"
},
"keywords": [
"dialog",
Expand Down
41 changes: 34 additions & 7 deletions scripts/build-icons.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ import { readFile, writeFile } from 'node:fs/promises';
/**
* Rasterizes `site/icon.svg` into the bitmap icons the web still needs.
*
* The SVG is the source of truth and is what browsers actually load as the
* favicon; only iOS home screens and link-preview scrapers need PNGs. Those
* PNGs are committed, so neither the site build nor a Cloudflare deploy runs
* this — run it by hand (`npm run site:icons`) whenever the icon changes.
* The SVG is the source of truth and is what modern browsers load as the
* favicon; iOS home screens, link-preview scrapers, and clients that request
* `/favicon.ico` regardless of the `<link rel="icon">` need bitmaps. Those are
* committed, so neither the site build nor a Cloudflare deploy runs this — run
* it by hand (`npm run site:icons`) whenever the icon changes.
*
* Rendering goes through Chromium, which is already a dev dependency for the
* end-to-end tests, rather than a hand-rolled rasterizer.
Expand All @@ -19,17 +20,18 @@ const icon = await readFile(new URL('icon.svg', site), 'utf8');
const browser = await chromium.launch();

/** Renders `html` at exactly `width`×`height` and writes it out as a PNG. */
async function render(name, width, height, html) {
async function render(name, width, height, html, { transparent = false } = {}) {
const page = await browser.newPage({ viewport: { width, height } });
await page.setContent(
`<!doctype html><meta charset="utf-8">
<style>html,body{margin:0;padding:0}</style>${html}`,
{ waitUntil: 'load' },
);
const buffer = await page.screenshot({ type: 'png' });
const buffer = await page.screenshot({ type: 'png', omitBackground: transparent });
await page.close();
await writeFile(new URL(name, site), buffer);
if (name.endsWith('.png')) await writeFile(new URL(name, site), buffer);
console.log(`icons: ${name} (${width}×${height}, ${buffer.length} bytes)`);
return buffer;
}

// iOS applies its own rounded mask to home-screen icons, so this variant
Expand Down Expand Up @@ -70,4 +72,29 @@ await render(
<code>npm install forgedialog</code>`,
);

// Some clients ask for /favicon.ico whatever the <link rel="icon"> says, and a
// 404 for it shows up in logs and in crawlers' reports.
//
// An .ico is a 6-byte header, one 16-byte directory entry per image, then the
// payloads — and since Vista a payload may be a PNG verbatim. Writing it that
// way needs no bitmap encoder and keeps the file near a kilobyte.
const ico = await render(
'favicon.ico',
32,
32,
`<style>svg{display:block;width:32px;height:32px}</style>${icon}`,
{ transparent: true },
);
const header = Buffer.alloc(6);
header.writeUInt16LE(1, 2); // type 1 = icon
header.writeUInt16LE(1, 4); // one image in the file
const entry = Buffer.alloc(16);
entry[0] = 32; // width
entry[1] = 32; // height
entry.writeUInt16LE(1, 4); // colour planes
entry.writeUInt16LE(32, 6); // bits per pixel
entry.writeUInt32LE(ico.length, 8); // payload size
entry.writeUInt32LE(header.length + entry.length, 12); // payload offset
await writeFile(new URL('favicon.ico', site), Buffer.concat([header, entry, ico]));

await browser.close();
86 changes: 83 additions & 3 deletions scripts/build-site.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -31,24 +31,85 @@ const canonical = indexHtml.match(/<link rel="canonical" href="([^"]+)"/)?.[1];
if (!canonical) throw new Error('site/index.html has no <link rel="canonical">, cannot build');
const origin = new URL(canonical).origin;

/**
* Every page, in both languages. `group` pairs the translations of one page:
* it is what the sitemap's alternates and the pages' own hreflang tags are
* built from, so a page can never be listed in one and missing from the other.
*/
const pages = [
{
path: '/',
file: 'site/index.html',
locale: 'en',
group: 'home',
priority: '1.0',
title: 'Forge Dialog',
summary: 'What it is, live demos, and an interactive appearance playground.',
},
{
path: '/demo',
file: 'site/demo.html',
locale: 'en',
group: 'demo',
priority: '0.9',
title: 'Live demos',
summary:
'Every surface running in the browser — dialogs, drawers, sheets, lightboxes, loading states, toasts, the command palette, forms, and wizards — each beside the code that produced it.',
},
{
path: '/docs',
file: 'site/docs.html',
locale: 'en',
group: 'docs',
priority: '0.9',
title: 'API reference',
summary:
'Every option, method, and entry point: dialogs, appearance, dragging, forms, wizards, toasts, theming, plugins, and framework adapters.',
},
{
path: '/vi/',
file: 'site/vi/index.html',
locale: 'vi',
group: 'home',
priority: '0.9',
title: 'Forge Dialog (tiếng Việt)',
summary: 'Giới thiệu, demo trực tiếp, và bảng điều khiển tuỳ biến giao diện.',
},
{
path: '/vi/demo',
file: 'site/vi/demo.html',
locale: 'vi',
group: 'demo',
priority: '0.8',
title: 'Demo trực tiếp (tiếng Việt)',
summary:
'Mọi thành phần chạy thật trong trình duyệt — hộp thoại, drawer, bottom sheet, lightbox, trạng thái tải, toast, bảng lệnh, biểu mẫu, và wizard — kèm đúng đoạn mã tạo ra nó.',
},
{
path: '/vi/docs',
file: 'site/vi/docs.html',
locale: 'vi',
group: 'docs',
priority: '0.8',
title: 'Tài liệu API (tiếng Việt)',
summary:
'Mọi tuỳ chọn, phương thức, và điểm vào: hộp thoại, giao diện, kéo thả, biểu mẫu, wizard, toast, chủ đề, plugin, và adapter cho framework.',
},
];

/** The alternates for one page, as hreflang code to absolute URL. */
function alternates(group) {
const entries = pages.filter((page) => page.group === group);
const english = entries.find((page) => page.locale === 'en');
return [
...entries.map((page) => [page.locale, `${origin}${page.path}`]),
// x-default is what a crawler serves when it has no better match for the
// visitor's language, so it points at the English page rather than a
// language-negotiating redirect this static site does not have.
['x-default', `${origin}${english.path}`],
];
}

/**
* `lastmod` has to be the day the page's content actually changed. Stamping
* every build with today's date tells a crawler both pages change daily, which
Expand All @@ -74,11 +135,18 @@ function lastModified(file) {
await writeFile(
new URL('sitemap.xml', site),
`<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">
${pages
.map((page) => {
const lastmod = lastModified(page.file);
return ` <url>\n <loc>${origin}${page.path}</loc>\n${
// Every entry carries the alternates for its whole group. A sitemap that
// annotates only one direction is the usual way hreflang goes wrong.
const links = alternates(page.group)
.map(
([code, href]) => ` <xhtml:link rel="alternate" hreflang="${code}" href="${href}"/>\n`,
)
.join('');
return ` <url>\n <loc>${origin}${page.path}</loc>\n${links}${
lastmod ? ` <lastmod>${lastmod}</lastmod>\n` : ''
} <priority>${page.priority}</priority>\n </url>`;
})
Expand Down Expand Up @@ -137,7 +205,19 @@ The product is named **Forge Dialog** (two words). The npm package is \`forgedia

## Documentation

${pages.map((page) => `- [${page.title}](${origin}${page.path}): ${page.summary}`).join('\n')}
${pages
.filter((page) => page.locale === 'en')
.map((page) => `- [${page.title}](${origin}${page.path}): ${page.summary}`)
.join('\n')}

## Vietnamese

The same three pages are published in Vietnamese. They are translations of the English pages above, not different content.

${pages
.filter((page) => page.locale === 'vi')
.map((page) => `- [${page.title}](${origin}${page.path}): ${page.summary}`)
.join('\n')}

## Source

Expand Down
Loading
Loading