Add initial project structure with essential files

- Created .gitignore to exclude build artifacts and dependencies.
- Added index.html as the main entry point for the application.
- Included LICENSE file with Apache 2.0 terms.
- Initialized package.json and package-lock.json for project dependencies.
- Added pnpm-lock.yaml for package management.
- Created QUICKSTART.md for setup instructions.
- Added README.md and README.zh-CN.md for project documentation in English and Chinese.
This commit is contained in:
pftom
2026-04-28 12:25:59 +08:00
commit a98096a042
258 changed files with 67862 additions and 0 deletions
+143
View File
@@ -0,0 +1,143 @@
// Client-side export helpers used by the Share menu in the HTML viewer.
// Three of the four formats run entirely in the browser:
// - PDF : open the artifact in a popup window and trigger window.print().
// The user picks "Save as PDF" from the system print dialog.
// - HTML : download the artifact as a single .html file via a Blob URL.
// - ZIP : pack the artifact into a stored-mode ZIP (see ./zip.ts).
// PPTX export is fundamentally different — it asks the agent to convert the
// artifact server-side, so it lives in ProjectView.tsx (not here).
import { buildSrcdoc } from './srcdoc';
import { buildZip } from './zip';
function safeFilename(name: string, fallback: string): string {
const slug = (name || fallback)
.replace(/[^\w.\-]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 60);
return slug || fallback;
}
function triggerDownload(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
// Revoke later — Safari sometimes hasn't finished reading the blob yet
// when the click handler returns.
setTimeout(() => URL.revokeObjectURL(url), 60_000);
}
export function exportAsHtml(html: string, title: string): void {
const doc = buildSrcdoc(html);
const blob = new Blob([doc], { type: 'text/html;charset=utf-8' });
triggerDownload(blob, `${safeFilename(title, 'artifact')}.html`);
}
export function exportAsZip(html: string, title: string): void {
const doc = buildSrcdoc(html);
const slug = safeFilename(title, 'artifact');
const blob = buildZip([
{ path: `${slug}/index.html`, content: doc },
{
path: `${slug}/README.md`,
content: `# ${title || slug}\n\nGenerated by Open Claude Design.\nOpen index.html in a browser to view.\n`,
},
]);
triggerDownload(blob, `${slug}.zip`);
}
// Open the artifact in a new tab via a Blob URL with a self-printing
// script injected. Going through a Blob URL (rather than `window.open('')`
// + `document.write`) avoids two failure modes we hit before:
// - `noopener` makes `window.open` return null, leaving an empty popup
// and triggering a duplicate fallback tab.
// - Cross-document writes are flaky in some browsers and don't always
// fire load events the way an inline script tied to the document does.
// The injected script also sets the document title so "Save as PDF" picks
// a sensible default filename.
//
// `deck: true` injects an extra print stylesheet that lays every `.slide`
// section out one-per-page top-to-bottom. The deck framework already ships
// equivalent print rules; this is a safety net for older / partially
// regenerated decks where the framework was stripped — without it,
// horizontal-snap decks print only the visible slide.
export function exportAsPdf(
html: string,
title: string,
opts?: { deck?: boolean },
): void {
let doc = buildSrcdoc(html);
if (opts?.deck) doc = injectDeckPrintStylesheet(doc);
doc = injectPrintScript(doc, title);
const blob = new Blob([doc], { type: 'text/html;charset=utf-8' });
const url = URL.createObjectURL(blob);
const win = window.open(url, '_blank');
if (!win) {
// Popup blocked — at least the tab navigation may have happened above.
// Nothing else we can do without a fresh user gesture.
}
// Revoke later — the loaded document keeps a reference until the tab
// closes; revoking the URL string only removes the lookup name.
setTimeout(() => URL.revokeObjectURL(url), 60_000);
}
function injectPrintScript(doc: string, title: string): string {
const safeTitle = JSON.stringify(title || 'artifact');
// setTimeout gives stylesheets and images one tick to settle before the
// print dialog measures the page; without it some print previews come
// out blank in Chrome.
const script = `<script>try{document.title=${safeTitle}}catch(e){}window.addEventListener('load',function(){setTimeout(function(){try{window.focus();window.print()}catch(e){}},300)})</script>`;
if (/<\/head>/i.test(doc)) return doc.replace(/<\/head>/i, `${script}</head>`);
if (/<\/body>/i.test(doc)) return doc.replace(/<\/body>/i, `${script}</body>`);
return doc + script;
}
// Stitches every .slide into a vertical multi-page PDF: 1920×1080 per page,
// no margins, scroll-snap and horizontal flex disabled. `!important` guards
// override skill-specific styles that pin the deck to `display: flex` /
// `overflow: hidden` for on-screen swiping.
const DECK_PRINT_CSS = `
@media print {
@page { size: 1920px 1080px; margin: 0; }
html, body {
width: 1920px !important;
height: auto !important;
overflow: visible !important;
background: #fff !important;
}
body {
display: block !important;
scroll-snap-type: none !important;
transform: none !important;
}
.slide, [data-screen-label], section.slide, .deck-slide, .ppt-slide {
flex: none !important;
width: 1920px !important;
height: 1080px !important;
min-height: 1080px !important;
max-height: 1080px !important;
page-break-after: always;
break-after: page;
scroll-snap-align: none !important;
transform: none !important;
position: relative !important;
overflow: hidden !important;
}
.slide:last-child, [data-screen-label]:last-child { page-break-after: auto; break-after: auto; }
.deck-counter, .deck-hint, .deck-nav,
[aria-label="Previous slide"], [aria-label="Next slide"] {
display: none !important;
}
}
`;
function injectDeckPrintStylesheet(doc: string): string {
const tag = `<style data-deck-print="injected">${DECK_PRINT_CSS}</style>`;
if (/<\/head>/i.test(doc)) return doc.replace(/<\/head>/i, `${tag}</head>`);
if (/<head[^>]*>/i.test(doc)) return doc.replace(/<head[^>]*>/i, (m) => `${m}${tag}`);
return tag + doc;
}
+243
View File
@@ -0,0 +1,243 @@
/**
* A pocket-sized markdown renderer for assistant chat messages.
*
* We deliberately avoid a full parser library — chat output rarely uses
* the long tail of markdown features and a hand-rolled walker keeps the
* bundle slim. Block-level: ATX headings (# … ###), fenced code (```),
* ordered (1.) and unordered (- / *) lists, paragraphs, blank-line
* separation. Inline: backtick code spans, **bold**, *italic* / _italic_,
* and bare links (autolinked URLs).
*
* Output is a React fragment of typed elements — no dangerouslySetInnerHTML,
* so untrusted text can't smuggle markup through.
*/
import { Fragment, type ReactNode } from 'react';
export function renderMarkdown(input: string): ReactNode {
const blocks = parseBlocks(input);
return (
<>
{blocks.map((b, i) => renderBlock(b, i))}
</>
);
}
type Block =
| { kind: 'p'; text: string }
| { kind: 'h'; level: 1 | 2 | 3 | 4; text: string }
| { kind: 'ul'; items: string[] }
| { kind: 'ol'; items: string[] }
| { kind: 'code'; lang: string | null; body: string }
| { kind: 'hr' };
function parseBlocks(input: string): Block[] {
const lines = input.replace(/\r\n/g, '\n').split('\n');
const out: Block[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i] ?? '';
if (line.trim() === '') {
i++;
continue;
}
// Fenced code block.
const fence = /^```(\w[\w+-]*)?\s*$/.exec(line);
if (fence) {
const lang = fence[1] ?? null;
const buf: string[] = [];
i++;
while (i < lines.length && !/^```\s*$/.test(lines[i] ?? '')) {
buf.push(lines[i] ?? '');
i++;
}
// Skip the closing fence (if present).
if (i < lines.length) i++;
out.push({ kind: 'code', lang, body: buf.join('\n') });
continue;
}
// ATX heading.
const heading = /^(#{1,4})\s+(.*\S)\s*$/.exec(line);
if (heading) {
const level = heading[1]!.length as 1 | 2 | 3 | 4;
out.push({ kind: 'h', level, text: heading[2]! });
i++;
continue;
}
// Horizontal rule.
if (/^\s*(-{3,}|_{3,}|\*{3,})\s*$/.test(line)) {
out.push({ kind: 'hr' });
i++;
continue;
}
// Unordered list. Group consecutive items.
if (/^\s*[-*+]\s+/.test(line)) {
const items: string[] = [];
while (i < lines.length && /^\s*[-*+]\s+/.test(lines[i] ?? '')) {
items.push((lines[i] ?? '').replace(/^\s*[-*+]\s+/, ''));
i++;
}
out.push({ kind: 'ul', items });
continue;
}
// Ordered list.
if (/^\s*\d+\.\s+/.test(line)) {
const items: string[] = [];
while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i] ?? '')) {
items.push((lines[i] ?? '').replace(/^\s*\d+\.\s+/, ''));
i++;
}
out.push({ kind: 'ol', items });
continue;
}
// Paragraph: greedy until a blank line or another block-starter.
const buf: string[] = [line];
i++;
while (i < lines.length) {
const next = lines[i] ?? '';
if (next.trim() === '') break;
if (/^```/.test(next)) break;
if (/^#{1,4}\s+/.test(next)) break;
if (/^\s*[-*+]\s+/.test(next)) break;
if (/^\s*\d+\.\s+/.test(next)) break;
buf.push(next);
i++;
}
out.push({ kind: 'p', text: buf.join('\n') });
}
return out;
}
function renderBlock(block: Block, key: number): ReactNode {
if (block.kind === 'p') {
return <p key={key} className="md-p">{renderInline(block.text)}</p>;
}
if (block.kind === 'h') {
const Tag = (`h${block.level}` as 'h1' | 'h2' | 'h3' | 'h4');
return <Tag key={key} className={`md-h md-h${block.level}`}>{renderInline(block.text)}</Tag>;
}
if (block.kind === 'ul') {
return (
<ul key={key} className="md-ul">
{block.items.map((item, i) => (
<li key={i}>{renderInline(item)}</li>
))}
</ul>
);
}
if (block.kind === 'ol') {
return (
<ol key={key} className="md-ol">
{block.items.map((item, i) => (
<li key={i}>{renderInline(item)}</li>
))}
</ol>
);
}
if (block.kind === 'code') {
return (
<pre key={key} className="md-code">
<code data-lang={block.lang ?? undefined}>{block.body}</code>
</pre>
);
}
if (block.kind === 'hr') {
return <hr key={key} className="md-hr" />;
}
return null;
}
// Inline pass: tokenize into runs of `code`, **bold**, *italic*, links,
// and plain text. We walk the string with a regex that matches whichever
// delimiter shows up next; everything between delimiters becomes a text
// span (which itself still gets autolink scanning).
function renderInline(text: string): ReactNode {
const out: ReactNode[] = [];
// Order matters: inline code first so its contents are not re-tokenized
// as bold/italic.
const re =
/(`[^`]+`)|(\*\*[^*]+\*\*)|(__[^_]+__)|(\*[^*\n]+\*)|(_[^_\n]+_)|\[([^\]]+)\]\(([^)\s]+)\)/g;
let lastIndex = 0;
let m: RegExpExecArray | null;
let key = 0;
while ((m = re.exec(text))) {
if (m.index > lastIndex) {
pushText(out, text.slice(lastIndex, m.index), key++);
}
if (m[1]) {
out.push(
<code key={key++} className="md-inline-code">
{m[1].slice(1, -1)}
</code>,
);
} else if (m[2]) {
out.push(<strong key={key++}>{m[2].slice(2, -2)}</strong>);
} else if (m[3]) {
out.push(<strong key={key++}>{m[3].slice(2, -2)}</strong>);
} else if (m[4]) {
out.push(<em key={key++}>{m[4].slice(1, -1)}</em>);
} else if (m[5]) {
out.push(<em key={key++}>{m[5].slice(1, -1)}</em>);
} else if (m[6] && m[7]) {
out.push(
<a
key={key++}
className="md-link"
href={m[7]}
target="_blank"
rel="noreferrer noopener"
>
{m[6]}
</a>,
);
}
lastIndex = re.lastIndex;
}
if (lastIndex < text.length) {
pushText(out, text.slice(lastIndex), key++);
}
return <Fragment>{out}</Fragment>;
}
// Walk a plain text run, autolinking bare URLs and preserving the rest as
// text nodes. Newlines inside a paragraph become explicit <br />s — the
// upstream parser has already left them in place because chat output
// often relies on hard line breaks rather than blank-line separation.
function pushText(out: ReactNode[], text: string, baseKey: number): void {
if (!text) return;
const urlRe = /(https?:\/\/[^\s)]+)/g;
const segments: ReactNode[] = [];
let lastIndex = 0;
let m: RegExpExecArray | null;
let k = 0;
while ((m = urlRe.exec(text))) {
if (m.index > lastIndex) {
segments.push(...withBreaks(text.slice(lastIndex, m.index), `${baseKey}-${k++}`));
}
segments.push(
<a
key={`${baseKey}-${k++}`}
className="md-link"
href={m[1]}
target="_blank"
rel="noreferrer noopener"
>
{m[1]}
</a>,
);
lastIndex = urlRe.lastIndex;
}
if (lastIndex < text.length) {
segments.push(...withBreaks(text.slice(lastIndex), `${baseKey}-${k++}`));
}
out.push(<Fragment key={baseKey}>{segments}</Fragment>);
}
function withBreaks(text: string, baseKey: string): ReactNode[] {
const parts = text.split('\n');
const out: ReactNode[] = [];
parts.forEach((part, i) => {
if (i > 0) out.push(<br key={`${baseKey}-br-${i}`} />);
if (part) out.push(<Fragment key={`${baseKey}-t-${i}`}>{part}</Fragment>);
});
return out;
}
+81
View File
@@ -0,0 +1,81 @@
/**
* Wrap an artifact's HTML for a sandboxed iframe. Corresponds to
* buildSrcdoc in packages/runtime/src/index.ts — the reference version also
* injects an edit-mode overlay and tweak bridge, which this starter omits.
*
* If the model returned a full document, pass it through unchanged; otherwise
* wrap the fragment in a minimal doctype shell.
*
* When `options.deck` is set we also inject a `postMessage` listener that
* lets the host advance / rewind slides without relying on the iframe
* having keyboard focus. The host posts:
* { type: 'ocd:slide', action: 'next' | 'prev' | 'first' | 'last' | 'go', index?: number }
* and the iframe responds with:
* { type: 'ocd:slide-state', active: number, count: number }
* after every navigation so the host can render its own counter / dots.
*/
export function buildSrcdoc(
html: string,
options: { deck?: boolean } = {},
): string {
const head = html.trimStart().slice(0, 64).toLowerCase();
const isFullDoc = head.startsWith('<!doctype') || head.startsWith('<html');
const wrapped = isFullDoc
? html
: `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>${html}</body>
</html>`;
if (!options.deck) return wrapped;
return injectDeckBridge(wrapped);
}
function injectDeckBridge(doc: string): string {
const script = `<script>(function(){
function slides(){ return document.querySelectorAll('.slide'); }
function scroller(){
if (document.body.scrollWidth > document.body.clientWidth + 1) return document.body;
return document.scrollingElement || document.documentElement;
}
function activeIndex(){
return Math.round(scroller().scrollLeft / window.innerWidth);
}
function go(i){
var list = slides();
if (!list.length) return;
var next = Math.max(0, Math.min(list.length - 1, i));
scroller().scrollTo({ left: next * window.innerWidth, behavior: 'smooth' });
setTimeout(report, 360);
}
function report(){
try {
var list = slides();
window.parent.postMessage({
type: 'ocd:slide-state',
active: activeIndex(),
count: list.length,
}, '*');
} catch (e) {}
}
window.addEventListener('message', function(ev){
var data = ev && ev.data;
if (!data || data.type !== 'ocd:slide') return;
var list = slides();
var i = activeIndex();
if (data.action === 'next') go(i + 1);
else if (data.action === 'prev') go(i - 1);
else if (data.action === 'first') go(0);
else if (data.action === 'last') go(list.length - 1);
else if (data.action === 'go' && typeof data.index === 'number') go(data.index);
});
// Report once on load and on every scroll-end so the host stays in sync.
window.addEventListener('load', function(){ setTimeout(report, 200); });
document.addEventListener('scroll', function(){ clearTimeout(window.__ocdReportT); window.__ocdReportT = setTimeout(report, 120); }, { passive: true, capture: true });
})();</script>`;
if (/<\/body>/i.test(doc)) return doc.replace(/<\/body>/i, `${script}</body>`);
return doc + script;
}
+127
View File
@@ -0,0 +1,127 @@
// Minimal ZIP encoder, stored mode (no compression). Big enough for the
// "Download as ZIP" button — we only ever pack a handful of UTF-8 text files
// (HTML/CSS/JS/Markdown) totalling well under a few MB, so skipping deflate
// keeps the implementation small and dependency-free.
export interface ZipEntry {
path: string;
content: string;
}
const CRC_TABLE: number[] = (() => {
const t: number[] = new Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) {
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
}
t[n] = c >>> 0;
}
return t;
})();
function crc32(bytes: Uint8Array): number {
let c = 0xffffffff;
for (let i = 0; i < bytes.length; i++) {
c = CRC_TABLE[(c ^ bytes[i]!) & 0xff]! ^ (c >>> 8);
}
return (c ^ 0xffffffff) >>> 0;
}
function dosTime(d: Date): { time: number; date: number } {
const time =
((d.getHours() & 0x1f) << 11) |
((d.getMinutes() & 0x3f) << 5) |
((Math.floor(d.getSeconds() / 2)) & 0x1f);
const date =
(((d.getFullYear() - 1980) & 0x7f) << 9) |
(((d.getMonth() + 1) & 0xf) << 5) |
(d.getDate() & 0x1f);
return { time, date };
}
export function buildZip(entries: ZipEntry[]): Blob {
const enc = new TextEncoder();
const now = dosTime(new Date());
const localChunks: Uint8Array[] = [];
const centralChunks: Uint8Array[] = [];
let offset = 0;
let centralSize = 0;
for (const entry of entries) {
const nameBytes = enc.encode(entry.path);
const dataBytes = enc.encode(entry.content);
const crc = crc32(dataBytes);
const size = dataBytes.length;
// Local file header (30 bytes + name).
const local = new Uint8Array(30 + nameBytes.length);
const lv = new DataView(local.buffer);
lv.setUint32(0, 0x04034b50, true); // signature
lv.setUint16(4, 20, true); // version needed
lv.setUint16(6, 0, true); // flags
lv.setUint16(8, 0, true); // method: stored
lv.setUint16(10, now.time, true); // mod time
lv.setUint16(12, now.date, true); // mod date
lv.setUint32(14, crc, true); // crc-32
lv.setUint32(18, size, true); // compressed size
lv.setUint32(22, size, true); // uncompressed size
lv.setUint16(26, nameBytes.length, true);
lv.setUint16(28, 0, true);
local.set(nameBytes, 30);
localChunks.push(local, dataBytes);
// Central directory header (46 bytes + name).
const central = new Uint8Array(46 + nameBytes.length);
const cv = new DataView(central.buffer);
cv.setUint32(0, 0x02014b50, true); // signature
cv.setUint16(4, 20, true); // version made by
cv.setUint16(6, 20, true); // version needed
cv.setUint16(8, 0, true); // flags
cv.setUint16(10, 0, true); // method
cv.setUint16(12, now.time, true);
cv.setUint16(14, now.date, true);
cv.setUint32(16, crc, true);
cv.setUint32(20, size, true);
cv.setUint32(24, size, true);
cv.setUint16(28, nameBytes.length, true);
cv.setUint16(30, 0, true); // extra len
cv.setUint16(32, 0, true); // comment len
cv.setUint16(34, 0, true); // disk number
cv.setUint16(36, 0, true); // internal attrs
cv.setUint32(38, 0, true); // external attrs
cv.setUint32(42, offset, true); // relative offset of local header
central.set(nameBytes, 46);
centralChunks.push(central);
offset += local.length + dataBytes.length;
centralSize += central.length;
}
// End of central directory record.
const eocd = new Uint8Array(22);
const ev = new DataView(eocd.buffer);
ev.setUint32(0, 0x06054b50, true);
ev.setUint16(4, 0, true);
ev.setUint16(6, 0, true);
ev.setUint16(8, entries.length, true);
ev.setUint16(10, entries.length, true);
ev.setUint32(12, centralSize, true);
ev.setUint32(16, offset, true);
ev.setUint16(20, 0, true);
// Concatenate into one buffer rather than passing Uint8Arrays straight to
// the Blob constructor — the Blob lib types now reject Uint8Array<...>
// in some TS configurations.
const totalSize =
localChunks.reduce((n, c) => n + c.length, 0) +
centralChunks.reduce((n, c) => n + c.length, 0) +
eocd.length;
const out = new Uint8Array(totalSize);
let p = 0;
for (const c of localChunks) { out.set(c, p); p += c.length; }
for (const c of centralChunks) { out.set(c, p); p += c.length; }
out.set(eocd, p);
return new Blob([out.buffer], { type: 'application/zip' });
}