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:
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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' });
|
||||
}
|
||||
Reference in New Issue
Block a user