Skip to content
Draft
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
18 changes: 10 additions & 8 deletions docs/booth-listing.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,13 +149,15 @@ node scripts/verify-release.mjs # ZIPを展開して件数・混入・実描画

- `build-mcp-bundle.mjs --all` は出力先が `mcp/data/components.json` 固定で、
リポジトリの無料版データを上書きしてしまう。退避 → 生成 → 復元している。
- 静的HTML と MCP が返す HTML に、Tailwind のトークン設定が入っているかを検める。
素の `cdn.tailwindcss.com` には `bg-card` 等が無く、**880件中430件**がこれを
使っているため、入っていないとその分の色が一切出ない。埋め込み自体は書き出しの
源流(`src/lib/vanilla.ts` + `scripts/build-vanilla-head.mjs`)が行うので、
ここでは足さずに検査だけする(同じ内容を2箇所に持たないため)。
- 静的HTML と MCP が返す HTML に実CSSが埋まっていて、外部を読む記述が残って
いないかを検める。埋め込み自体は `scripts/build-static-html.mjs` が
1件ぶんずつ Tailwind をコンパイルして行うので、ここでは足さずに検査だけする
(同じ内容を2箇所に持たないため)。**色が本当に出るか**は
`verify-release.mjs` が外部通信を遮断した実ブラウザで `getComputedStyle` を
読んで確かめる。
- Vite が `public/` を丸ごと `dist/` に写すので、そのままだと `studio/` の中に
`registry/` と `html/` が二重で入る(12MB の無駄)。除外している。
`registry/` が二重で入る(6MB の無駄)。除外している。`html/` は逆に残す
(スタジオのバニラHTML書き出しが `/html/<id>.html` を取りに行くため)。
- `node_modules` / `.git` / `.env` / `crawler/` などを混入させない。

---
Expand Down Expand Up @@ -196,8 +198,8 @@ node scripts/verify-release.mjs # ZIPを展開して件数・混入・実描画
- [x] zip を実際に展開し、README の手順どおりに動くかを別ディレクトリで確認
→ `verify-release.mjs`。展開して 4555 ファイル・全件数一致を確認済み
- [x] 静的HTML を1つブラウザで開き、React 無しで表示されることを目視確認
実ブラウザで3件を実測。880件を走査して外部参照は
`cdn.tailwindcss.com` のみ、react も babel も含まれない
外部通信を遮断した実ブラウザで実測。880件を走査して外部参照は
**ゼロ**(CDN も Web フォントも無し)、react も babel も含まれない
- [x] MCP 全部入り版を入れ、880個返ることを確認
→ ZIP から展開したサーバに stdio で JSON-RPC を投げ、
`list_categories` が「収録 880 件 / 39 カテゴリ」を返すことを確認
Expand Down
17 changes: 9 additions & 8 deletions docs/dist-README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,15 @@
html/offers-value-stack-offer.html
```

React も Node も要りません。Tailwind を CDN から1行読み込み、その下に
このコンポーネント集のデザイントークン(`bg-card` などの色定義)を
埋め込んであるので、ファイル単体で意図した見た目になります。

**ネット接続が要ります。** Tailwind を CDN から読むためです。オフラインで
使う場合や本番に載せる場合は、`<script src="https://cdn.tailwindcss.com">`
を自分でビルドした Tailwind の CSS に差し替えてください。その際
`components/theme/tailwind.config.js` を使うと同じ色が出ます。
React も Node も要りません。**ネット接続も要りません。** その1枚に必要な
CSS だけをコンパイルして `<style>` に埋め込んであるので、外部への通信は
一切ありません(CDN も Web フォントも読みません)。オフラインでも、社内
ネットワークの中でも、同じ見た目になります。

`bg-card` などこのコンポーネント集のデザイントークンも、その CSS の中で
解決済みです。自分のビルドに載せ替えたい場合は
`components/theme/tailwind.config.js` と `components/theme/tokens.css` を
使うと同じ色が出ます。

### どれが React 不要か

Expand Down
10 changes: 5 additions & 5 deletions mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
| キーボード操作 | 対話部品がキーボードで到達できるか |
| 横スクロール | 実ブラウザ・幅375px でページが横に破れないか |

**React が要らない現場でも使える。** `get_component_html` は React も Babel も含まない静的 HTML を返します。Tailwind を読み込むだけで表示されるので、PHP・Rails・Hugo・WordPress にもそのまま貼れます。
**React が要らない現場でも使える。** `get_component_html` は React も Babel も含まない静的 HTML を返します。CSS もその1枚ぶんだけコンパイルして埋め込んであり、外部への通信が一切ないので、PHP・Rails・Hugo・WordPress にもそのまま貼れます。

## 導入

Expand Down Expand Up @@ -109,10 +109,10 @@ claude mcp add lp-section-studio -- npx -y lp-section-studio-mcp
`class-variance-authority` と、同梱の UI プリミティブのみです。外部画像も
使っていないので、ファイルを 1 つ置けば動きます。

静的 HTML 版は Tailwind だけで動きますが、`bg-card` `text-muted-foreground`
`bg-primary` などは**このコンポーネント集のデザイントークン**で、素の Tailwind
には存在しません。買い切り版の HTML はこの定義を `<head>` に埋め込んだ状態で
配布しているのでそのまま表示されます。自分でトークンを組み込む場合は
静的 HTML 版は CSS を `<head>` に埋め込んだ状態で返すので、Tailwind の
読み込みも要りません。`bg-card` `text-muted-foreground` `bg-primary` などは
**このコンポーネント集のデザイントークン**で素の Tailwind には存在しませんが、
埋め込み済みの CSS の中で解決済みです。自分のビルドに載せ替える場合は
買い切り版の `components/theme/` にある `tailwind.config.js` と `tokens.css`
を使ってください。

Expand Down
200 changes: 100 additions & 100 deletions mcp/data/components.json

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions mcp/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,8 @@ server.registerTool(
title: "React抜きの静的HTMLを取得",
description:
"React を使わずにそのまま貼れる HTML を返す。出力に react も babel も含まれず、" +
"Tailwind を読み込むだけで表示される。PHP・Rails・Hugo・WordPress など、" +
"React を持ち込めない現場向け。" +
"その1枚ぶんの CSS を <style> に埋め込んであるので外部への通信も無い。" +
"PHP・Rails・Hugo・WordPress など、React を持ち込めない現場向け。" +
"なお状態や操作を持つ部品(standalone が false のもの)は、" +
"静的版では見た目だけになり操作は動かない。",
inputSchema: {
Expand Down
61 changes: 40 additions & 21 deletions scripts/build-release.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -69,28 +69,42 @@ if (missing.length > 0) {
}

// ──────────────────────────────────────────────────────────────────────────
// デザイントークンが HTML に入っているかの確認
// 静的 HTML が本当に自己完結しているかの確認
//
// bg-card / text-muted-foreground のような**このプロジェクト固有のトークン**は
// 素の Tailwind には存在しない(880 件中 430 件が使っている)。そのため
// cdn.tailwindcss.com を1行読むだけの HTML では色が一切当たらない。
// 「1枚のHTMLで完結」と謳って売る以上、開くたびに外部へ取りに行く作りでは
// 通らない(オフライン・社内網・CDN障害で色が消える)。CSS は
// scripts/build-static-html.mjs が1件ぶんコンパイルして <style> に埋めるので、
// ここでは「埋まっているか」「外を読む記述が残っていないか」だけを検める。
// 埋め込み自体をここでやらないのは、同じ内容を2箇所に持つと片方だけ直したときに
// 配布物とスタジオの書き出しが食い違うため。
//
// この注入は書き出しの**源流**(src/lib/vanilla.ts の wrapDocument。設定は
// scripts/build-vanilla-head.mjs が生成する src/lib/vanillaHead.generated.ts)で
// 行うので、ここで配布時に足すことはしない。同じ内容を2箇所に持つと、
// 片方だけ直したときに配布物とスタジオの書き出しが食い違うため。
//
// 代わりに「入っているか」だけを検める。入っていなければ材料が古い。
// 実際に色が出るか(getComputedStyle での実測)は verify-release.mjs が見る。
// ──────────────────────────────────────────────────────────────────────────

/** トークン設定が入っていない HTML を洗い出し、あれば止める */
function assertTokensInjected(label, htmls, how) {
const broken = htmls.filter(([, html]) => html && !html.includes("tailwind.config"));
/** 外部からリソースを読む記述。<a href> や SVG の名前空間 URL は当たらない。 */
const EXTERNAL_RESOURCE =
/<script[^>]+\bsrc\s*=\s*["']\s*(?:https?:)?\/\/|<link[^>]+\bhref\s*=\s*["']\s*(?:https?:)?\/\/|<(?:img|iframe|video|audio|source|embed)[^>]+\bsrc\s*=\s*["']\s*(?:https?:)?\/\/|@import[^;]*(?:https?:)?\/\/|url\(\s*["']?(?:https?:)?\/\//i;

/**
* コンパイル済みの実CSSが埋まっているか。
* `--tw-border-spacing-x` は Tailwind の preflight が必ず出す変数なので、
* 部品が持つ独自の <style> と取り違えない。`--card:` は固有トークンの定義。
*/
const hasCompiledCss = (html) =>
html.includes("<style>") &&
html.includes("--tw-border-spacing-x") &&
html.includes("--card:");

/** CSS が埋まっていない/外部を読む HTML を洗い出し、あれば止める */
function assertSelfContained(label, htmls, how) {
const broken = htmls.filter(
([, html]) => html && (!hasCompiledCss(html) || EXTERNAL_RESOURCE.test(html))
);
if (broken.length > 0) {
console.error(
`${label}: デザイントークンの設定が入っていない HTML が ${broken.length} 件あります。\n` +
`${label}: 自己完結していない HTML が ${broken.length} 件あります。\n` +
` 例: ${broken.slice(0, 3).map(([name]) => name).join(", ")}\n` +
` bg-card / text-muted-foreground などの色が出ない状態です。${how} を実行して作り直してください。`
` CSS が埋め込まれていないか、外部を読む記述が残っています。${how} を実行して作り直してください。`
);
process.exit(1);
}
Expand Down Expand Up @@ -191,7 +205,7 @@ const htmlSrc = resolve(ROOT, "public/html");
const htmlDst = join(STAGE, "html");
mkdirSync(htmlDst, { recursive: true });
const htmlFiles = readdirSync(htmlSrc).filter((f) => f.endsWith(".html"));
assertTokensInjected(
assertSelfContained(
"html/",
htmlFiles.map((f) => [f, readFileSync(join(htmlSrc, f), "utf-8")]),
"npm run html"
Expand Down Expand Up @@ -226,7 +240,7 @@ if (fullBundle.edition !== "full") {
throw new Error(`MCP バンドルが full になっていません: ${fullBundle.edition}`);
}
// MCP が返す HTML も public/html/ から来るので、同じ検査を通す
assertTokensInjected(
assertSelfContained(
"mcp/data/components.json",
fullBundle.items.map((i) => [i.id, i.html]),
"npm run html"
Expand All @@ -243,11 +257,16 @@ writeFileSync(
copied++;

// --- studio/ : ビルド済み ---
// Vite は public/ をそのまま dist/ にコピーするので、dist/r と dist/html は
// registry/ と html/ の丸ごと重複になる(合わせて 12MB)。
// スタジオ本体はどちらも実行時に参照していないので外す。
// Vite は public/ をそのまま dist/ にコピーするので、dist/r は registry/ の
// 丸ごと重複になる(6MB)。スタジオ本体は実行時に参照していないので外す。
//
// dist/html は**外せない**。スタジオのバニラHTML書き出しは、その場で作らず
// /html/<id>.html を取りに行くようになったため(ブラウザでは Tailwind を
// コンパイルできない。src/components/PreviewCanvas.tsx を参照)。
// html/ と同じ中身が studio/html にも入るが、これで「スタジオでコピーした
// HTML」と「html/ に入っている HTML」が同一ファイルになる。
const DIST = resolve(ROOT, "dist");
const DIST_DUPES = new Set(["r", "html"]);
const DIST_DUPES = new Set(["r"]);
copyTree(DIST, join(STAGE, "studio"), {
filter: (p) => !DIST_DUPES.has(relative(DIST, p)),
});
Expand Down
95 changes: 93 additions & 2 deletions scripts/build-static-html.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,17 @@
*
* このリポジトリの売りの一つは「React が要らない現場でも使える」こと。
* renderToStaticMarkup で描画結果だけを取り出すので、出力に react も babel も
* 入らない。Tailwind の CDN を1行読むだけで、HTML を貼れば表示される。
* 入らない。HTML を貼れば表示される。
*
* **CSS も外部に頼らない。** 以前は `cdn.tailwindcss.com` を1行読ませていたが、
* 「1枚で完結」と言いながら開くたびに外部へ取りに行く作りで、オフライン・
* 社内網・CDN障害で色が消える(Tailwind 自身も Play CDN を本番非推奨としている)。
* そこで 1件ずつ、**その HTML だけを content にした Tailwind をコンパイル**して
* <style> に埋め込む。全880件の和集合は 339KB あって全件に埋めると 300MB 近くに
* なるが、個別なら 1件 8KB 前後で収まる(大半は preflight)。
*
* Tailwind は毎回プロセスを起こすと 880 回で数十分かかるので、postcss プラグインと
* して**この1プロセスの中から**回す。実測で全件 100 秒前後。
*
* ただし全部が静的で成立するわけではない。状態や操作を持つコンポーネントは
* 静的化すると見た目だけになる。そこで meta に interactive 判定を持たせ、
Expand All @@ -17,8 +27,12 @@
*/
import { createServer } from "vite";
import { chromium } from "playwright";
import postcss from "postcss";
import tailwindcss from "tailwindcss";
import autoprefixer from "autoprefixer";
import { existsSync, readdirSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";

const arg = (name, fallback) => {
const i = process.argv.indexOf(`--${name}`);
Expand All @@ -27,6 +41,60 @@ const arg = (name, fallback) => {
const LIMIT = arg("limit", Infinity);
const OUT = resolve(process.cwd(), "public/html");

// ──────────────────────────────────────────────────────────────────────────
// Tailwind のコンパイル(1件ぶん)
// ──────────────────────────────────────────────────────────────────────────

const TW_CONFIG = (
await import(pathToFileURL(resolve(process.cwd(), "tailwind.config.js")).href)
).default;
// 入力はスタジオ本体と同じ src/index.css。@tailwind の3層に加えて
// :root / .dark のトークンと body の既定スタイルが入っているので、
// 出力1本で「スタジオで見たとおり」になる。
const INPUT_CSS = readFileSync(resolve(process.cwd(), "src/index.css"), "utf-8");

/**
* PostCSS の AST 上で空白とコメントを落とす。
* cssnano を足せば済むが新規依存を増やしたくないので、stringifier の
* raws を潰すだけで済ませる。実測で 14.4KB → 8.3KB。
*/
const compact = () => ({
postcssPlugin: "compact",
OnceExit(root) {
root.walkComments((c) => c.remove());
root.walk((node) => {
node.raws.before = "";
if (node.type === "decl") {
node.raws.between = ":";
delete node.raws.value;
} else if (node.type === "rule") {
node.raws.between = "";
node.raws.after = "";
node.raws.semicolon = false;
delete node.raws.selector;
} else if (node.type === "atrule") {
node.raws.afterName = " ";
node.raws.between = "";
node.raws.after = "";
node.raws.semicolon = false;
delete node.raws.params;
}
});
root.raws.after = "";
},
});
compact.postcss = true;

/** この HTML 1枚だけを content にして Tailwind を通し、実CSSを返す */
async function compileCss(html) {
const result = await postcss([
tailwindcss({ ...TW_CONFIG, content: [{ raw: html, extension: "html" }] }),
autoprefixer(),
compact(),
]).process(INPUT_CSS, { from: undefined });
return result.css;
}

/** 状態や操作を持つか(=静的化すると見た目だけになるか)をソースから判定する */
const INTERACTIVE = /\buseState\b|\buseEffect\b|\buseReducer\b|\buseRef\b|\bonClick=|\bonChange=|\bonMouseEnter=|\bonSubmit=/;

Expand Down Expand Up @@ -70,13 +138,20 @@ const page = await browser.newPage();
mkdirSync(OUT, { recursive: true });
const index = [];
let failed = 0;
let cssSlot = "";
let cssMs = 0;
let cssBytes = 0;

try {
await page.goto(`http://localhost:${port}/overflow.html`, { waitUntil: "load" });
await page.waitForFunction(() => typeof window.__staticHtml === "function", {
timeout: 60_000,
});
const ids = (await page.evaluate(() => window.__ids)).slice(0, LIMIT);
// CSS の差し込み位置は src/lib/vanilla.ts が決める。文字列を二重に持つと
// 片方だけ直したときに黙って CSS 無しの HTML が出るので、実物を貰う。
cssSlot = await page.evaluate(() => window.__cssSlot);
if (!cssSlot) throw new Error("window.__cssSlot が取れませんでした");
console.log(`${ids.length} 件を静的 HTML に書き出します…`);

for (const [i, id] of ids.entries()) {
Expand All @@ -95,6 +170,17 @@ try {
failed++;
continue;
}
if (!html.includes(cssSlot)) {
throw new Error(
`${id}: CSS の差し込み位置 ${cssSlot} が HTML にありません(src/lib/vanilla.ts を確認)`
);
}
// ここで初めて外部依存が消える。content はこの HTML 自身。
const t = Date.now();
const css = await compileCss(html);
cssMs += Date.now() - t;
cssBytes += css.length;
html = html.replace(cssSlot, `<style>\n${css}\n </style>`);
writeFileSync(join(OUT, `${id}.html`), html);
const source = readFileSync(
resolve(process.cwd(), "src/registry", meta.path.replace("./", "")),
Expand Down Expand Up @@ -126,9 +212,14 @@ writeFileSync(
) + "\n"
);

const kb = (n) => `${(n / 1024).toFixed(1)} KB`;
console.log(
`\n静的 HTML: ${index.length} 件を書き出しました(失敗 ${failed} 件)。\n` +
` うち ${standalone} 件は React 無しでそのまま動きます。\n` +
` 残り ${index.length - standalone} 件は状態や操作を持つため、静的版は見た目のみです。`
` 残り ${index.length - standalone} 件は状態や操作を持つため、静的版は見た目のみです。\n` +
` CSS の埋め込み: ${(cssMs / 1000).toFixed(0)} 秒 / 1件あたり CSS ${kb(
cssBytes / Math.max(1, index.length)
)}・HTML ${kb(index.reduce((a, e) => a + e.bytes, 0) / Math.max(1, index.length))}\n` +
` 外部への参照はありません(CDN も Web フォントも読みません)。`
);
if (failed) process.exit(1);
Loading
Loading