@xiaobuyu/nodesign 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/README.md +140 -0
- package/bin/nodesign.js +176 -0
- package/package.json +104 -0
- package/server/api/README.md +45 -0
- package/server/api/_guard.js +75 -0
- package/server/api/admin.js +187 -0
- package/server/api/assets/docx-page.js +103 -0
- package/server/api/assets/helpers.js +22 -0
- package/server/api/assets/notes.js +144 -0
- package/server/api/assets.js +896 -0
- package/server/api/board.js +57 -0
- package/server/api/browse.js +138 -0
- package/server/api/canvas.js +444 -0
- package/server/api/chatai.js +324 -0
- package/server/api/exports/build-standalone.js +980 -0
- package/server/api/exports/cards.js +118 -0
- package/server/api/exports/docx-pdf.js +46 -0
- package/server/api/exports/export-page.js +142 -0
- package/server/api/exports/handoff.js +147 -0
- package/server/api/exports.js +690 -0
- package/server/api/instruction.js +75 -0
- package/server/api/local.js +115 -0
- package/server/api/me.js +116 -0
- package/server/api/memory.js +185 -0
- package/server/api/pending-changes.js +366 -0
- package/server/api/plugins.js +164 -0
- package/server/api/projects.js +220 -0
- package/server/api/publish.js +64 -0
- package/server/api/recent.js +73 -0
- package/server/api/sessions.js +589 -0
- package/server/api/skills.js +77 -0
- package/server/api/standalone-fit.js +168 -0
- package/server/api/turn-compose.js +180 -0
- package/server/api/turn-inflight.js +44 -0
- package/server/api/turn.js +669 -0
- package/server/auth/README.md +17 -0
- package/server/auth/middleware.js +138 -0
- package/server/auth/origin-guard.js +71 -0
- package/server/auth/session.js +108 -0
- package/server/auth/tier.js +113 -0
- package/server/auth/users-store.js +348 -0
- package/server/edit/README.md +29 -0
- package/server/engine/README.md +113 -0
- package/server/engine/agent/agent-shared.js +550 -0
- package/server/engine/agent/auto-mode-default-hard-deny.txt +4 -0
- package/server/engine/agent/auto-mode-rules.js +78 -0
- package/server/engine/agent/context.js +418 -0
- package/server/engine/agent/events.js +464 -0
- package/server/engine/agent/hooks/canvas-validate.js +199 -0
- package/server/engine/agent/hooks/failure.js +120 -0
- package/server/engine/agent/hooks/file-events.js +75 -0
- package/server/engine/agent/hooks/lifecycle.js +233 -0
- package/server/engine/agent/hooks/post-canvas-focus.js +80 -0
- package/server/engine/agent/hooks/post-guidance.js +206 -0
- package/server/engine/agent/hooks/post-subagent-report.js +104 -0
- package/server/engine/agent/hooks/post-trim.js +58 -0
- package/server/engine/agent/hooks/pre-board-neighborhood.js +34 -0
- package/server/engine/agent/hooks/pre-defaults.js +84 -0
- package/server/engine/agent/hooks/pre-injectors.js +277 -0
- package/server/engine/agent/hooks/pre-performance-log-guard.js +78 -0
- package/server/engine/agent/hooks/pre-starter-files.js +61 -0
- package/server/engine/agent/hooks/pre-workspace-scope-guard.js +78 -0
- package/server/engine/agent/hooks/site-validate.js +94 -0
- package/server/engine/agent/hooks/tool-prompts.js +31 -0
- package/server/engine/agent/hooks/turn-state-memory.js +56 -0
- package/server/engine/agent/hooks/user-prompt-submit.js +215 -0
- package/server/engine/agent/hooks.js +354 -0
- package/server/engine/agent/init-contract.js +69 -0
- package/server/engine/agent/isolation.js +159 -0
- package/server/engine/agent/model-context.js +359 -0
- package/server/engine/agent/model-table.js +292 -0
- package/server/engine/agent/plugin-loader.js +266 -0
- package/server/engine/agent/prompts/nodesign-prelude.md +336 -0
- package/server/engine/agent/prompts/tools/ask-user-question-protocol.md +71 -0
- package/server/engine/agent/prompts/tools/direct-edit-protocol.md +98 -0
- package/server/engine/agent/prompts/tools/generate-image-cookbook.gemini-gateway.md +583 -0
- package/server/engine/agent/prompts/tools/generate-image-cookbook.md +483 -0
- package/server/engine/agent/prompts/tools/hybrid-reference.md +100 -0
- package/server/engine/agent/prompts/tools/paint-still-cookbook.md +270 -0
- package/server/engine/agent/prompts/tools/roll-film-cookbook.md +70 -0
- package/server/engine/agent/prompts/tools/site-reference.md +106 -0
- package/server/engine/agent/prompts/tools/tweaks-syntax.md +125 -0
- package/server/engine/agent/prompts/tools/vision-checker-dispatch.md +48 -0
- package/server/engine/agent/session-loop.js +1024 -0
- package/server/engine/agent/session-model.js +164 -0
- package/server/engine/agent/skill.js +204 -0
- package/server/engine/agent/system-prompts.js +86 -0
- package/server/engine/agent/task-events.js +78 -0
- package/server/engine/agents/ds-extractor.md +132 -0
- package/server/engine/agents/explorer.md +190 -0
- package/server/engine/agents/index.js +230 -0
- package/server/engine/agents/schemas/design-system.json +156 -0
- package/server/engine/agents/schemas/tweak-schema.json +113 -0
- package/server/engine/agents/tweak-proposer.md +127 -0
- package/server/engine/agents/vision-checker.md +254 -0
- package/server/engine/browse/capture.js +422 -0
- package/server/engine/browse/card.js +137 -0
- package/server/engine/browse/handover.js +59 -0
- package/server/engine/browse/page-digest.js +119 -0
- package/server/engine/browse/refs.js +196 -0
- package/server/engine/browse/registry.js +307 -0
- package/server/engine/browse/screencast.js +170 -0
- package/server/engine/browse/state.js +121 -0
- package/server/engine/chatai/chat-log.js +79 -0
- package/server/engine/chatai/index.js +193 -0
- package/server/engine/chatai/openai-compat.js +152 -0
- package/server/engine/chatai/orchestrate.js +309 -0
- package/server/engine/chatai/perform.js +40 -0
- package/server/engine/chatai/summarize.js +79 -0
- package/server/engine/mcp/capability-gate.js +52 -0
- package/server/engine/mcp/index.js +339 -0
- package/server/engine/mcp/param-sanitizer.js +142 -0
- package/server/engine/mcp/tools/arrange-on-board.js +124 -0
- package/server/engine/mcp/tools/artifact-session.js +278 -0
- package/server/engine/mcp/tools/browse-computer.js +421 -0
- package/server/engine/mcp/tools/browse-find-batch.js +183 -0
- package/server/engine/mcp/tools/browse-screenshot.js +157 -0
- package/server/engine/mcp/tools/browse.js +480 -0
- package/server/engine/mcp/tools/build-docx.js +101 -0
- package/server/engine/mcp/tools/clear-pending-changes.js +99 -0
- package/server/engine/mcp/tools/create-on-board.js +155 -0
- package/server/engine/mcp/tools/crystallize-skill.js +168 -0
- package/server/engine/mcp/tools/deliver-files.js +0 -0
- package/server/engine/mcp/tools/explain-style.js +237 -0
- package/server/engine/mcp/tools/export-handoff.js +133 -0
- package/server/engine/mcp/tools/expose-tweaks.js +149 -0
- package/server/engine/mcp/tools/generate-image.js +842 -0
- package/server/engine/mcp/tools/get-computed-styles.js +164 -0
- package/server/engine/mcp/tools/get-pending-changes.js +205 -0
- package/server/engine/mcp/tools/h3box-ssh.js +84 -0
- package/server/engine/mcp/tools/helpers/acquire-page.js +82 -0
- package/server/engine/mcp/tools/helpers/motion-lab.js +554 -0
- package/server/engine/mcp/tools/helpers/motion-scroll.js +112 -0
- package/server/engine/mcp/tools/helpers/perception-page.js +215 -0
- package/server/engine/mcp/tools/helpers/reference-download.js +103 -0
- package/server/engine/mcp/tools/helpers/rembg-bridge.py +80 -0
- package/server/engine/mcp/tools/helpers/rembg.js +273 -0
- package/server/engine/mcp/tools/helpers/shot-pipeline.js +280 -0
- package/server/engine/mcp/tools/highlight.js +68 -0
- package/server/engine/mcp/tools/list-pages.js +222 -0
- package/server/engine/mcp/tools/lookup-tags.js +59 -0
- package/server/engine/mcp/tools/navigate-to-page.js +60 -0
- package/server/engine/mcp/tools/organize-board.js +64 -0
- package/server/engine/mcp/tools/paint-still.js +434 -0
- package/server/engine/mcp/tools/pin-to-board.js +130 -0
- package/server/engine/mcp/tools/preview-deck.js +130 -0
- package/server/engine/mcp/tools/profile-scroll.js +305 -0
- package/server/engine/mcp/tools/publish-site.js +102 -0
- package/server/engine/mcp/tools/query-elements.js +195 -0
- package/server/engine/mcp/tools/read-board.js +103 -0
- package/server/engine/mcp/tools/read-document.js +93 -0
- package/server/engine/mcp/tools/read-page.js +264 -0
- package/server/engine/mcp/tools/read-tavern-json.js +143 -0
- package/server/engine/mcp/tools/record-decision.js +140 -0
- package/server/engine/mcp/tools/relate-on-board.js +110 -0
- package/server/engine/mcp/tools/remove-background.js +391 -0
- package/server/engine/mcp/tools/report-issue.js +115 -0
- package/server/engine/mcp/tools/roll-film.js +266 -0
- package/server/engine/mcp/tools/screenshot-docx.js +138 -0
- package/server/engine/mcp/tools/screenshot-url.js +195 -0
- package/server/engine/mcp/tools/screenshot.js +583 -0
- package/server/engine/mcp/tools/tier-gate.js +96 -0
- package/server/engine/mcp/tools/trace-motion.js +215 -0
- package/server/engine/mcp/tools/web-search.js +548 -0
- package/server/engine/motion/inventory.js +349 -0
- package/server/engine/perception/session.js +235 -0
- package/server/engine/plugins/nodesign/.claude-plugin/plugin.json +5 -0
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/SKILL.md +192 -0
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/canvas.template.html +667 -0
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/hybrid-grid.md +22 -0
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/image-led-cover.md +25 -0
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/portrait.md +23 -0
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/quote-backdrop.md +21 -0
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/section-divider.md +23 -0
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/text-led.md +21 -0
- package/server/engine/plugins/nodesign/skills/docx-craft/SKILL.md +162 -0
- package/server/engine/plugins/nodesign/skills/docx-craft/references/token-schema.md +407 -0
- package/server/engine/plugins/nodesign/skills/docx-craft//346/226/207/346/241/243.template.json +80 -0
- package/server/engine/plugins/nodesign/skills/rp-craft/SKILL.md +228 -0
- package/server/engine/plugins/nodesign/skills/rp-craft/patterns//346/274/224/345/207/272/351/241/265-/346/234/200/345/260/217/345/256/236/347/216/260.html +78 -0
- package/server/engine/plugins/nodesign/skills/rp-craft//346/274/224/345/207/272.template.js +268 -0
- package/server/engine/plugins/nodesign/skills/site-craft/SKILL.md +323 -0
- package/server/engine/plugins/nodesign/skills/site-craft/patterns/build-lane.md +134 -0
- package/server/engine/plugins/nodesign/skills/site-craft/patterns/cutout-collage.md +61 -0
- package/server/engine/plugins/nodesign/skills/site-craft/patterns/mock-app.md +163 -0
- package/server/engine/plugins/nodesign/skills/site-craft/patterns/page-transitions.md +86 -0
- package/server/engine/runs/active-runs.js +698 -0
- package/server/engine/runs/live-turn.js +252 -0
- package/server/engine/runs/store.js +364 -0
- package/server/engine/runs/turn-relay.js +217 -0
- package/server/engine/runtime/workspace.js +164 -0
- package/server/index.js +235 -0
- package/server/lib/artifact-file-path.js +80 -0
- package/server/lib/artifact-target.js +284 -0
- package/server/lib/asset-refs.js +239 -0
- package/server/lib/async-queue.js +104 -0
- package/server/lib/auto-relations.js +130 -0
- package/server/lib/binding-types.js +60 -0
- package/server/lib/board-kind-sizes.js +62 -0
- package/server/lib/board-relations.js +133 -0
- package/server/lib/browse-proxy.js +180 -0
- package/server/lib/canvas-id.js +36 -0
- package/server/lib/cover.js +227 -0
- package/server/lib/danbooru-tags.js +291 -0
- package/server/lib/doc-extract.js +156 -0
- package/server/lib/docx/build-from-source.js +260 -0
- package/server/lib/docx/build.js +490 -0
- package/server/lib/docx/dump-styles.js +287 -0
- package/server/lib/docx/fonts/nodesign-cjk.conf +164 -0
- package/server/lib/docx/merge-runs.js +129 -0
- package/server/lib/docx/numbering.js +218 -0
- package/server/lib/docx/order.js +152 -0
- package/server/lib/docx/rawzip.js +172 -0
- package/server/lib/docx/render.js +92 -0
- package/server/lib/docx/text-lint.js +225 -0
- package/server/lib/docx/tokens.js +335 -0
- package/server/lib/docx/units.js +29 -0
- package/server/lib/docx/xml.js +291 -0
- package/server/lib/docx-pages.js +187 -0
- package/server/lib/export-collect.js +289 -0
- package/server/lib/export-package.js +210 -0
- package/server/lib/html-srcset.js +145 -0
- package/server/lib/image-variant.js +460 -0
- package/server/lib/ingress/forward-openai-chat.js +305 -0
- package/server/lib/ingress/openai-chat.js +491 -0
- package/server/lib/ingress/session-notice.js +66 -0
- package/server/lib/ingress/session-routes.js +68 -0
- package/server/lib/ingress/slot-probe.js +130 -0
- package/server/lib/ingress/upstream-billing.js +52 -0
- package/server/lib/ingress/upstream-fail-streak.js +72 -0
- package/server/lib/ingress/upstream-truncation.js +48 -0
- package/server/lib/issues-store.js +182 -0
- package/server/lib/kinds/deck.js +72 -0
- package/server/lib/kinds/docx.js +283 -0
- package/server/lib/kinds/file-kinds.js +113 -0
- package/server/lib/kinds/index.js +202 -0
- package/server/lib/kinds/site.js +161 -0
- package/server/lib/model-ingress.js +543 -0
- package/server/lib/moderation.js +255 -0
- package/server/lib/notice-store.js +103 -0
- package/server/lib/plugin-install.js +104 -0
- package/server/lib/plugin-validator.js +721 -0
- package/server/lib/publish-store.js +115 -0
- package/server/lib/quick-summary.js +52 -0
- package/server/lib/quota.js +270 -0
- package/server/lib/rate-window.js +29 -0
- package/server/lib/reference-assets.js +92 -0
- package/server/lib/region-shot.js +135 -0
- package/server/lib/safe-path.js +73 -0
- package/server/lib/sdk-session.js +33 -0
- package/server/lib/showcase-store.js +92 -0
- package/server/lib/site-publish.js +465 -0
- package/server/lib/ssrf-guard.js +432 -0
- package/server/lib/task-scan.js +158 -0
- package/server/lib/tavern-json.js +154 -0
- package/server/lib/video-variant.js +237 -0
- package/server/lib/workspace-path.js +33 -0
- package/server/ops/fix-sdk-musl.mjs +40 -0
- package/server/ops/install-macos-fonts.sh +114 -0
- package/server/ops/macos-fonts.conf +336 -0
- package/server/ops/sandbox-shim/bwrap +57 -0
- package/server/projects/assets-summary.js +119 -0
- package/server/projects/auto-name.js +53 -0
- package/server/projects/board-store.js +640 -0
- package/server/projects/move-entry.js +103 -0
- package/server/projects/store.js +270 -0
- package/server/projects/ui-config.js +54 -0
- package/server/projects/workspace-templates.js +53 -0
- package/server/projects/workspace.js +1025 -0
- package/server/runtime/capabilities.js +153 -0
- package/server/runtime/local-config.js +177 -0
- package/server/runtime/local-env.js +88 -0
- package/server/runtime/platform.js +342 -0
- package/server/runtime/profile.js +72 -0
- package/server/services/rembg-launcher.js +313 -0
- package/server/services/rembg-service.py +334 -0
- package/server/shared/README.md +25 -0
- package/server/shared/deck.js +54 -0
- package/server/shared/time.js +49 -0
- package/server/style-pipeline/README.md +53 -0
- package/server/ws/broker.js +36 -0
- package/server/ws/browse-channel.js +177 -0
- package/server/ws/index.js +386 -0
- package/web/README.md +89 -0
- package/web/dist/assets/BrowserWindow-Dc7J7CYc.js +11 -0
- package/web/dist/assets/DeckWindow-4G0xX7P3.js +16 -0
- package/web/dist/assets/DocxWindow-BXQsoPkh.js +16 -0
- package/web/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/web/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/web/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/web/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/web/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/web/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/web/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/web/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/web/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/web/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/web/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/web/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/web/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/web/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/web/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/web/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/web/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/web/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/web/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/web/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/web/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/web/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/web/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/web/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/web/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/web/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/web/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/web/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/web/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/web/dist/assets/SiteWindow-D6eS-2gM.js +18 -0
- package/web/dist/assets/again-D8tfgKKO.webp +0 -0
- package/web/dist/assets/caveat-nd-CP6HlsNg.woff2 +0 -0
- package/web/dist/assets/clock-xJhvfYpS.webp +0 -0
- package/web/dist/assets/film-cut-BBKMwH7q.webp +0 -0
- package/web/dist/assets/film-still-BZ_DL9Tt.webp +0 -0
- package/web/dist/assets/index-C0KUf6Wd.css +1 -0
- package/web/dist/assets/index-Cbl3ATvp.js +2125 -0
- package/web/dist/assets/ink-desk-BRbxeDsR.webp +0 -0
- package/web/dist/assets/ink-night-Bz4BT3XH.webp +0 -0
- package/web/dist/assets/ink-portrait-C0DM4V8Q.webp +0 -0
- package/web/dist/assets/longcang-regular-C0uvTuKa.woff2 +0 -0
- package/web/dist/assets/lxgw-nd-bold-DbRfiTgo.woff2 +0 -0
- package/web/dist/assets/lxgw-nd-regular-CpMw9Ehg.woff2 +0 -0
- package/web/dist/assets/pending-edit-apply-DTjo2geW.js +31 -0
- package/web/dist/assets/question-8H4HSqxh.webp +0 -0
- package/web/dist/assets/reject-BaPJqElj.webp +0 -0
- package/web/dist/assets/rotate-cw-LMT3x1Zj.js +11 -0
- package/web/dist/assets/rp-portrait-CbrGpew2.webp +0 -0
- package/web/dist/assets/rp-street-C4jVKjxx.webp +0 -0
- package/web/dist/assets/square-dashed-mouse-pointer-CBoAkfhi.js +11 -0
- package/web/dist/assets/tangle-DIxHVNuT.webp +0 -0
- package/web/dist/assets/thisone-BylZKglV.webp +0 -0
- package/web/dist/assets/thumb-DO1wfK1I.webp +0 -0
- package/web/dist/index.html +18 -0
|
@@ -0,0 +1,980 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* server/api/exports/build-standalone.js
|
|
3
|
+
*
|
|
4
|
+
* 把 hybrid canvas.html 打包成"自包含离线 HTML"——CDN 全 inline,用户拿到能离线双击打开。
|
|
5
|
+
*
|
|
6
|
+
* 触发条件:检测到 <script type="text/babel"> 存在 = hybrid 文件 → 走管道
|
|
7
|
+
* 老 deck(无 babel script)→ 不走管道,调用方降级到 injectViewportFit 文本替换
|
|
8
|
+
*
|
|
9
|
+
* 7 步管道:
|
|
10
|
+
* 1. parse:抽 importmap / babel scripts / Tailwind CDN script tag
|
|
11
|
+
* 2. esbuild bundle:babel script 内容当 entry,import 走 esm.sh HTTP plugin(in-memory LRU cache)
|
|
12
|
+
* 3. tailwind extract:跑 tailwindcss CLI on stdin,--content 指 raw html,--output 拿 used CSS
|
|
13
|
+
* 4. replace tags:删 importmap / Babel CDN / Tailwind CDN script,替换 babel scripts 为 type=module 的 bundled JS
|
|
14
|
+
* 5. inline tailwind CSS:插入 <style> 到 <head>
|
|
15
|
+
* 6. inject viewport fit(复用 injectViewportFit)—— 跟 commit 1 / 2 / 3 已有的 fit script 兼容
|
|
16
|
+
* 7. return assembled HTML
|
|
17
|
+
*
|
|
18
|
+
* 性能预期:首次导出 5-10s(CDN fetch 耗时);缓存命中后 1-2s(仅 esbuild bundle + tailwind extract)
|
|
19
|
+
*
|
|
20
|
+
* 错误降级:管道任一步失败 → throw,调用方应回落到原 injectViewportFit(保证导出至少能拿到能用的 HTML)
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { build } from 'esbuild';
|
|
24
|
+
import { promisify } from 'node:util';
|
|
25
|
+
import { execFile } from 'node:child_process';
|
|
26
|
+
import { promises as fs } from 'node:fs';
|
|
27
|
+
import path from 'node:path';
|
|
28
|
+
import os from 'node:os';
|
|
29
|
+
import { fileURLToPath } from 'node:url';
|
|
30
|
+
import { runInNewContext } from 'node:vm';
|
|
31
|
+
import { fitInjectionBlock } from '../standalone-fit.js';
|
|
32
|
+
|
|
33
|
+
const execFileAsync = promisify(execFile);
|
|
34
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
35
|
+
|
|
36
|
+
/** Tailwind CLI 路径 — 项目本地 install */
|
|
37
|
+
const TAILWIND_BIN = path.resolve(__dirname, '../../../node_modules/.bin/tailwindcss');
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 去掉 HTML 注释——避免 regex 把注释里的 `<script type="text/babel">` 字面
|
|
41
|
+
* 字符当成真 script tag 抓(template 顶部就有这种注释提示)。
|
|
42
|
+
*
|
|
43
|
+
* 仅用于 parser 内部 regex 扫描;assemble 阶段仍用原始 rawHtml(保留注释作为 doc)。
|
|
44
|
+
*
|
|
45
|
+
* @param {string} html
|
|
46
|
+
* @returns {string}
|
|
47
|
+
*/
|
|
48
|
+
function stripHtmlComments(html) {
|
|
49
|
+
return html.replace(/<!--[\s\S]*?-->/g, '');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Hybrid 文件检测(heuristic)——任意一个 CDN 依赖都算"需要打包"。
|
|
54
|
+
*
|
|
55
|
+
* 检测信号(OR):
|
|
56
|
+
* - <script type="text/babel">(agent 用了 React mount)
|
|
57
|
+
* - <script src="cdn.tailwindcss.com">
|
|
58
|
+
* - <script src="...@babel/standalone...">
|
|
59
|
+
* - <script type="importmap">(依赖 esm.sh 远程模块)
|
|
60
|
+
*
|
|
61
|
+
* 任意一个出现都说明 raw HTML **离线打开会失败**——必须走自包含管道。
|
|
62
|
+
* 即使 agent 没用 React mount(纯静态 + Tailwind),Tailwind CDN 离线也挂。
|
|
63
|
+
*
|
|
64
|
+
* @param {string} html
|
|
65
|
+
* @returns {boolean}
|
|
66
|
+
*/
|
|
67
|
+
export function isHybridHtml(html) {
|
|
68
|
+
const scan = stripHtmlComments(html);
|
|
69
|
+
return (
|
|
70
|
+
/<script[^>]*type=["']text\/babel["']/i.test(scan) ||
|
|
71
|
+
/<script[^>]*src=["']https?:\/\/cdn\.tailwindcss\.com/i.test(scan) ||
|
|
72
|
+
/<script[^>]*src=["'][^"']*@babel\/standalone/i.test(scan) ||
|
|
73
|
+
/<script[^>]*type=["']importmap["']/i.test(scan)
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 主入口:打包成自包含 HTML
|
|
79
|
+
*
|
|
80
|
+
* @param {string} rawHtml canvas.html 原文
|
|
81
|
+
* @param {object} [opts]
|
|
82
|
+
* @param {string} [opts.sessionRoot] sessions/<sid>/ 绝对路径;用来 resolve 图片
|
|
83
|
+
* 相对路径(assets/generated/...)。不给则
|
|
84
|
+
* 跳过图片 inline(导出仍能用,但 <img> 会断)
|
|
85
|
+
* @param {boolean} [opts.injectFit=true] 是否注入分页 fit script。**站点必须传 false**:
|
|
86
|
+
* 那段脚本把每个 section 包成整屏 frame + scroll-snap,
|
|
87
|
+
* 是幻灯片范式,注进网站等于把长页改造成翻页器
|
|
88
|
+
* @returns {Promise<string>} 自包含 HTML(CDN + 图片全 inline,可离线打开)
|
|
89
|
+
* @throws 任一步骤失败抛——调用方应降级到 injectViewportFit 文本替换
|
|
90
|
+
*/
|
|
91
|
+
export async function buildStandaloneHtml(rawHtml, opts = {}) {
|
|
92
|
+
if (!isHybridHtml(rawHtml)) {
|
|
93
|
+
throw new Error('Not a hybrid HTML — caller should fall back to injectViewportFit');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 1. parse —— 抽出关键段
|
|
97
|
+
const parsed = parseHybridDoc(rawHtml);
|
|
98
|
+
|
|
99
|
+
// 2. bundle JS(esbuild + esm.sh HTTP plugin)—— commit 2 填实现
|
|
100
|
+
const bundledJs = await bundleBabelScripts(parsed.babelScripts, parsed.importmap);
|
|
101
|
+
|
|
102
|
+
// 3. extract Tailwind used CSS(tailwindcss CLI on stdin)—— commit 2 填实现
|
|
103
|
+
// 把 agent 解析出的 fontFamily 传进去 merge superset,否则 agent 选的 latin
|
|
104
|
+
// family(Manrope / Playfair / Geist / Lyon 等)在 build 时会被 hardcoded
|
|
105
|
+
// superset 的 'Inter' / 'Instrument Serif' 覆盖,导出 PDF/PPT 英文字体跟
|
|
106
|
+
// preview 不一致(preview 走运行时 Tailwind config 看着对、导出错的根因)。
|
|
107
|
+
const tailwindCss = await extractTailwindCss(rawHtml, parsed.agentFontFamily);
|
|
108
|
+
|
|
109
|
+
// 4-7. 重写 HTML —— inline 所有外部 CDN
|
|
110
|
+
let html = assembleStandaloneHtml({
|
|
111
|
+
rawHtml,
|
|
112
|
+
parsed,
|
|
113
|
+
bundledJs,
|
|
114
|
+
tailwindCss,
|
|
115
|
+
injectFit: opts.injectFit !== false,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// 8. inline 本地图片(<img src="assets/..."> + background-image: url(...))。
|
|
119
|
+
// 单文件 self-contained 必须做这一步——脱离 sessions/<sid>/ 后相对路径都失效。
|
|
120
|
+
// sessionRoot 不给时跳过(fail-soft,调用方可能不知道 sessionRoot)
|
|
121
|
+
if (opts.sessionRoot) {
|
|
122
|
+
// baseDir:相对路径以 deck 文件所在目录为基准(任务 deck 写的是
|
|
123
|
+
// `../../assets/generated/x.png`,按 sessionRoot 解会跳出 workspace → 全裂图)
|
|
124
|
+
html = await inlineLocalImages(html, opts.baseDir || opts.sessionRoot, opts.sessionRoot);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// 9. inline Google Fonts → @font-face data URL。
|
|
128
|
+
// 离线打开 / CDN 慢时字体不再 fallback 到系统字体;fail-soft(fetch 失败保留原 link)
|
|
129
|
+
html = await inlineGoogleFonts(html);
|
|
130
|
+
|
|
131
|
+
return html;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ──────────────────────────────────────────────────────────────────
|
|
135
|
+
// Step 1 · parseHybridDoc
|
|
136
|
+
// ──────────────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* 抽出 hybrid HTML 的关键段:importmap / babel scripts / Tailwind CDN tag / Babel standalone tag
|
|
140
|
+
*
|
|
141
|
+
* @param {string} html
|
|
142
|
+
* @returns {{
|
|
143
|
+
* importmap: object | null,
|
|
144
|
+
* importmapTagFull: string | null,
|
|
145
|
+
* babelScripts: Array<{ content: string, fullTag: string }>,
|
|
146
|
+
* tailwindCdnTag: string | null,
|
|
147
|
+
* babelCdnTag: string | null,
|
|
148
|
+
* tailwindConfigTag: string | null
|
|
149
|
+
* }}
|
|
150
|
+
*/
|
|
151
|
+
export function parseHybridDoc(html) {
|
|
152
|
+
// 关键:先 strip HTML 注释——避免 template 顶部 doc-comment 里的字面 <script>
|
|
153
|
+
// 字符串被 regex 误抓。assemble 阶段仍 replace 原始 rawHtml,所以注释保留。
|
|
154
|
+
const scan = stripHtmlComments(html);
|
|
155
|
+
|
|
156
|
+
// importmap
|
|
157
|
+
let importmap = null;
|
|
158
|
+
let importmapTagFull = null;
|
|
159
|
+
const importmapMatch = scan.match(/<script[^>]*type=["']importmap["'][^>]*>([\s\S]*?)<\/script>/i);
|
|
160
|
+
if (importmapMatch) {
|
|
161
|
+
importmapTagFull = importmapMatch[0];
|
|
162
|
+
try { importmap = JSON.parse(importmapMatch[1]); } catch { /* malformed */ }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// babel scripts —— 多个段都收
|
|
166
|
+
const babelScripts = [];
|
|
167
|
+
const babelRe = /<script[^>]*type=["']text\/babel["'][^>]*>([\s\S]*?)<\/script>/gi;
|
|
168
|
+
let m;
|
|
169
|
+
while ((m = babelRe.exec(scan)) !== null) {
|
|
170
|
+
babelScripts.push({ content: m[1], fullTag: m[0] });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Tailwind CDN tag —— 替换目标
|
|
174
|
+
const tailwindCdnMatch = scan.match(/<script[^>]*src=["']https?:\/\/cdn\.tailwindcss\.com[^"']*["'][^>]*><\/script>/i);
|
|
175
|
+
const tailwindCdnTag = tailwindCdnMatch ? tailwindCdnMatch[0] : null;
|
|
176
|
+
|
|
177
|
+
// Tailwind config tag (tailwind.config = {...})—— 移除(运行时不再需要)
|
|
178
|
+
const tailwindConfigMatch = scan.match(/<script>[\s\S]*?tailwind\.config[\s\S]*?<\/script>/i);
|
|
179
|
+
const tailwindConfigTag = tailwindConfigMatch ? tailwindConfigMatch[0] : null;
|
|
180
|
+
|
|
181
|
+
// 解析 tailwind.config 里 agent 写的 fontFamily(agent 选的 latin family——
|
|
182
|
+
// Manrope / Playfair Display / Geist / Lyon 等)。提取出来后 build 阶段 merge
|
|
183
|
+
// 到 superset,否则 build-standalone 编译 Tailwind class 用硬编码 superset,
|
|
184
|
+
// agent 选的 latin family 在 PDF/PPT 导出里会被 override 成 Inter / Instrument
|
|
185
|
+
// Serif(preview 看着对、导出看着错的根因)。fail-soft:解析失败不抛错。
|
|
186
|
+
let agentFontFamily = null;
|
|
187
|
+
if (tailwindConfigTag) {
|
|
188
|
+
agentFontFamily = extractAgentFontFamily(tailwindConfigTag);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Babel standalone tag —— 删除(已 esbuild 预编译)
|
|
192
|
+
const babelCdnMatch = scan.match(/<script[^>]*src=["'][^"']*@babel\/standalone[^"']*["'][^>]*><\/script>/i);
|
|
193
|
+
const babelCdnTag = babelCdnMatch ? babelCdnMatch[0] : null;
|
|
194
|
+
|
|
195
|
+
return { importmap, importmapTagFull, babelScripts, tailwindCdnTag, babelCdnTag, tailwindConfigTag, agentFontFamily };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* 从 `<script>tailwind.config = {...}</script>` tag 内容里提取 fontFamily 对象。
|
|
200
|
+
*
|
|
201
|
+
* agent 写法多样(单引号 / 双引号 / 多种 indent),用 vm 沙箱跑代码 + 拿
|
|
202
|
+
* fontFamily —— 比正则鲁棒,比 acorn AST 解析轻量。沙箱 context 只暴露空对象,
|
|
203
|
+
* 跑出问题(语法错 / 引用未定义符号)也只影响该函数返 null,不阻塞导出。
|
|
204
|
+
*
|
|
205
|
+
* @param {string} tagText `<script>...</script>` 完整 tag
|
|
206
|
+
* @returns {Record<string, string[]> | null} fontFamily 对象 或 null
|
|
207
|
+
*/
|
|
208
|
+
function extractAgentFontFamily(tagText) {
|
|
209
|
+
try {
|
|
210
|
+
// 抽 <script> 标签里的代码
|
|
211
|
+
const codeMatch = tagText.match(/<script>([\s\S]*?)<\/script>/i);
|
|
212
|
+
if (!codeMatch) return null;
|
|
213
|
+
const code = codeMatch[1];
|
|
214
|
+
|
|
215
|
+
// 用 Node vm 沙箱跑:模拟 tailwind 全局对象,让 `tailwind.config = {...}` 赋值
|
|
216
|
+
// 后能从沙箱拿到 config.theme.extend.fontFamily
|
|
217
|
+
// 不用 eval/new Function 避免污染主进程作用域
|
|
218
|
+
const sandbox = { tailwind: { config: null } };
|
|
219
|
+
runInNewContext(code, sandbox, { timeout: 200 });
|
|
220
|
+
const ff = sandbox?.tailwind?.config?.theme?.extend?.fontFamily;
|
|
221
|
+
if (!ff || typeof ff !== 'object') return null;
|
|
222
|
+
|
|
223
|
+
// 校验每个 key 的 value 是 string[](agent 偶尔会写 string,规范化)
|
|
224
|
+
const out = {};
|
|
225
|
+
for (const [key, val] of Object.entries(ff)) {
|
|
226
|
+
if (Array.isArray(val) && val.every(s => typeof s === 'string')) {
|
|
227
|
+
out[key] = val;
|
|
228
|
+
} else if (typeof val === 'string') {
|
|
229
|
+
out[key] = [val];
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return Object.keys(out).length > 0 ? out : null;
|
|
233
|
+
} catch (err) {
|
|
234
|
+
// sandbox 跑挂(agent 引用了未定义的全局符号 / 语法错)—— 静默返 null,
|
|
235
|
+
// build 阶段会用 superset
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ──────────────────────────────────────────────────────────────────
|
|
241
|
+
// Step 2 · bundleBabelScripts —— esbuild + esm.sh HTTP plugin
|
|
242
|
+
// ──────────────────────────────────────────────────────────────────
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* In-memory LRU cache for esm.sh fetched modules.
|
|
246
|
+
* Key = absolute URL after redirect resolution.
|
|
247
|
+
* Value = { contents: string, contentType: string }
|
|
248
|
+
*
|
|
249
|
+
* 容量 500 entries(一个 deck bundle 涉及 ~30-100 个 esm.sh 子模块;缓存几个 deck 够用)。
|
|
250
|
+
* 每条 entry size 通常 5-50KB → cache 顶峰 ~10MB,进程重启就清。
|
|
251
|
+
*/
|
|
252
|
+
const REMOTE_CACHE_MAX = 500;
|
|
253
|
+
const remoteCache = new Map();
|
|
254
|
+
|
|
255
|
+
function cacheGet(key) {
|
|
256
|
+
if (!remoteCache.has(key)) return undefined;
|
|
257
|
+
const v = remoteCache.get(key);
|
|
258
|
+
remoteCache.delete(key); // LRU 用 Map 顺序,命中时挪到末尾
|
|
259
|
+
remoteCache.set(key, v);
|
|
260
|
+
return v;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function cacheSet(key, val) {
|
|
264
|
+
if (remoteCache.size >= REMOTE_CACHE_MAX) {
|
|
265
|
+
const oldestKey = remoteCache.keys().next().value;
|
|
266
|
+
remoteCache.delete(oldestKey);
|
|
267
|
+
}
|
|
268
|
+
remoteCache.set(key, val);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* 把每段 babel script 当独立 ES module 喂 esbuild bundle;用合成 entry
|
|
273
|
+
* `import './_nd_script_0_'; import './_nd_script_1_';` 串起来驱动执行。
|
|
274
|
+
*
|
|
275
|
+
* 为什么不直接 concat:浏览器里每个 `<script>` 是自己的作用域,agent 习惯写
|
|
276
|
+
* 多段(譬如 `__nd-shadcn-lite` 段定义 `function Card`,`__nd-app` 段再
|
|
277
|
+
* `const { Card } = window.__ndShadcn`)。两段直接拼成一个 esbuild entry 会
|
|
278
|
+
* 撞 "The symbol Card has already been declared",整条 standalone 管道炸 →
|
|
279
|
+
* 用户拿到没 inline 图片的降级 HTML。改成多 module 后各自顶层作用域隔离,
|
|
280
|
+
* 跟 `<script>` 语义对齐;共享 react 实例靠 importmap 同 URL 自动 dedupe。
|
|
281
|
+
*
|
|
282
|
+
* @param {Array<{ content: string, fullTag: string }>} babelScripts
|
|
283
|
+
* @param {object | null} importmap importmap 的 imports 字段是 bare specifier → URL 的映射
|
|
284
|
+
* @returns {Promise<string>} bundled ESM JS(已 minify)
|
|
285
|
+
*/
|
|
286
|
+
export async function bundleBabelScripts(babelScripts, importmap) {
|
|
287
|
+
// 0 个 babel script 是常见场景:agent 写了纯静态 + Tailwind 的 deck,
|
|
288
|
+
// importmap / @babel/standalone 是 boilerplate copy 但实际用不到。
|
|
289
|
+
// 这种 case 直接返空,assemble 阶段不注入 module script。
|
|
290
|
+
if (babelScripts.length === 0) return '';
|
|
291
|
+
|
|
292
|
+
const importmapImports = importmap?.imports || {};
|
|
293
|
+
|
|
294
|
+
// 每段当独立虚拟模块,合成 entry 按声明顺序依次 side-effect import
|
|
295
|
+
const scriptVfs = new Map();
|
|
296
|
+
const entryImports = babelScripts.map((s, i) => {
|
|
297
|
+
const vp = `/__nd_script_${i}__.tsx`;
|
|
298
|
+
scriptVfs.set(vp, s.content);
|
|
299
|
+
return `import '${vp}';`;
|
|
300
|
+
}).join('\n');
|
|
301
|
+
|
|
302
|
+
const result = await build({
|
|
303
|
+
stdin: {
|
|
304
|
+
contents: entryImports,
|
|
305
|
+
loader: 'tsx',
|
|
306
|
+
sourcefile: '__nd_entry__.tsx',
|
|
307
|
+
resolveDir: '/', // 让虚拟绝对路径 import 能命中 onResolve
|
|
308
|
+
},
|
|
309
|
+
bundle: true,
|
|
310
|
+
format: 'esm',
|
|
311
|
+
minify: true,
|
|
312
|
+
target: 'es2020',
|
|
313
|
+
write: false, // 不写盘,结果在 outputFiles
|
|
314
|
+
plugins: [
|
|
315
|
+
scriptVfsPlugin(scriptVfs),
|
|
316
|
+
esmShHttpPlugin(importmapImports),
|
|
317
|
+
],
|
|
318
|
+
// tsx loader 默认 jsx: classic(变 React.createElement),我们模板用 import React 就走通
|
|
319
|
+
jsx: 'transform',
|
|
320
|
+
jsxFactory: 'React.createElement',
|
|
321
|
+
jsxFragment: 'React.Fragment',
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
if (result.errors.length > 0) {
|
|
325
|
+
throw new Error('esbuild errors: ' + result.errors.map(e => e.text).join('; '));
|
|
326
|
+
}
|
|
327
|
+
return result.outputFiles[0].text;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* 把每段 babel script 解析成独立虚拟 module,给 esbuild bundle 用。
|
|
332
|
+
* 路径形如 `/__nd_script_0__.tsx`,靠 scriptVfs Map 查内容。
|
|
333
|
+
*/
|
|
334
|
+
function scriptVfsPlugin(scriptVfs) {
|
|
335
|
+
return {
|
|
336
|
+
name: 'nd-script-vfs',
|
|
337
|
+
setup(buildApi) {
|
|
338
|
+
buildApi.onResolve({ filter: /^\/__nd_script_\d+__\.tsx$/ }, (args) => ({
|
|
339
|
+
path: args.path,
|
|
340
|
+
namespace: 'nd-script-vfs',
|
|
341
|
+
}));
|
|
342
|
+
buildApi.onLoad({ filter: /.*/, namespace: 'nd-script-vfs' }, (args) => ({
|
|
343
|
+
contents: scriptVfs.get(args.path) || '',
|
|
344
|
+
loader: 'tsx',
|
|
345
|
+
}));
|
|
346
|
+
},
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* esbuild plugin:把 bare specifier(react / recharts / @radix-ui/...)按 importmap 解析到
|
|
352
|
+
* esm.sh URL,HTTP fetch 内容(with LRU cache + redirect 跟随),返给 esbuild bundle。
|
|
353
|
+
*
|
|
354
|
+
* @param {Record<string, string>} importmapImports
|
|
355
|
+
*/
|
|
356
|
+
function esmShHttpPlugin(importmapImports) {
|
|
357
|
+
return {
|
|
358
|
+
name: 'esm-sh-http',
|
|
359
|
+
setup(buildApi) {
|
|
360
|
+
// 第一层:bare specifier(react / 'react-dom/client' / '@radix-ui/react-dialog')
|
|
361
|
+
// 走 importmap 查表 → URL
|
|
362
|
+
buildApi.onResolve({ filter: /.*/ }, (args) => {
|
|
363
|
+
// 已经是 https:// 的相对/绝对 URL(来自前一个 fetch 的 import 解析),下面那条 filter 接
|
|
364
|
+
if (/^https?:\/\//.test(args.path)) {
|
|
365
|
+
return { path: args.path, namespace: 'esm-http' };
|
|
366
|
+
}
|
|
367
|
+
// bare specifier → importmap
|
|
368
|
+
if (importmapImports[args.path]) {
|
|
369
|
+
return { path: importmapImports[args.path], namespace: 'esm-http' };
|
|
370
|
+
}
|
|
371
|
+
// bare specifier 带子路径,如 'react-dom/client':先查 importmap 完全匹配
|
|
372
|
+
// 没有就找 trailing-slash 前缀(importmap 'react/' 风格)
|
|
373
|
+
for (const [k, v] of Object.entries(importmapImports)) {
|
|
374
|
+
if (k.endsWith('/') && args.path.startsWith(k)) {
|
|
375
|
+
const rest = args.path.slice(k.length);
|
|
376
|
+
return { path: v + rest, namespace: 'esm-http' };
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
// 不在 importmap 里就让 esbuild 走默认(多半 fail,但留余地)
|
|
380
|
+
return null;
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
// 第二层:从 esm-http 命名空间的 module 内部解析相对路径(esm.sh 返的代码会
|
|
384
|
+
// import './chunk-XXX.mjs' 这种 relative)
|
|
385
|
+
buildApi.onResolve({ filter: /.*/, namespace: 'esm-http' }, (args) => {
|
|
386
|
+
// resolve relative URL based on importer
|
|
387
|
+
if (args.path.startsWith('http')) {
|
|
388
|
+
return { path: args.path, namespace: 'esm-http' };
|
|
389
|
+
}
|
|
390
|
+
try {
|
|
391
|
+
const resolved = new URL(args.path, args.importer).toString();
|
|
392
|
+
return { path: resolved, namespace: 'esm-http' };
|
|
393
|
+
} catch {
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
// 第三层:HTTP fetch
|
|
399
|
+
buildApi.onLoad({ filter: /.*/, namespace: 'esm-http' }, async (args) => {
|
|
400
|
+
const cached = cacheGet(args.path);
|
|
401
|
+
if (cached) {
|
|
402
|
+
return { contents: cached.contents, loader: cached.loader };
|
|
403
|
+
}
|
|
404
|
+
const res = await fetch(args.path, { redirect: 'follow' });
|
|
405
|
+
if (!res.ok) {
|
|
406
|
+
throw new Error(`esm.sh fetch failed: ${res.status} ${args.path}`);
|
|
407
|
+
}
|
|
408
|
+
const finalUrl = res.url; // 跟随 redirect 后
|
|
409
|
+
const contents = await res.text();
|
|
410
|
+
const ct = res.headers.get('content-type') || '';
|
|
411
|
+
const loader = ct.includes('css') ? 'css' : 'js';
|
|
412
|
+
cacheSet(finalUrl, { contents, loader });
|
|
413
|
+
if (finalUrl !== args.path) cacheSet(args.path, { contents, loader });
|
|
414
|
+
return { contents, loader };
|
|
415
|
+
});
|
|
416
|
+
},
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// ──────────────────────────────────────────────────────────────────
|
|
421
|
+
// Step 3 · extractTailwindCss (commit 2 实现)
|
|
422
|
+
// ──────────────────────────────────────────────────────────────────
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* 跑 tailwindcss CLI 提取该 HTML 实际用到的 utility class 对应的 CSS。
|
|
427
|
+
*
|
|
428
|
+
* 输入 raw HTML(含 <head>/Tailwind config + <body>/Tailwind class 用法):
|
|
429
|
+
* - 写到 tmp html 文件让 tailwindcss --content 扫描
|
|
430
|
+
* - 跑 CLI on stdin 读 input CSS(@tailwind base/components/utilities),stdout 拿编译后 CSS
|
|
431
|
+
* - cleanup tmp file
|
|
432
|
+
*
|
|
433
|
+
* @param {string} rawHtml
|
|
434
|
+
* @param {Record<string, string[]> | null} [agentFontFamily] — agent 在原 HTML
|
|
435
|
+
* tailwind.config 里写的 fontFamily(parseHybridDoc 解析),merge 优先级最高。
|
|
436
|
+
* 不给 / null 时只用 superset 默认。
|
|
437
|
+
* @returns {Promise<string>} minified CSS(含 base reset + agent 用到的所有 utility)
|
|
438
|
+
*/
|
|
439
|
+
export async function extractTailwindCss(rawHtml, agentFontFamily = null) {
|
|
440
|
+
// 写 raw html 到 tmpfile,tailwindcss CLI 扫描它的 class
|
|
441
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'nd-tw-'));
|
|
442
|
+
const htmlPath = path.join(tmpDir, 'src.html');
|
|
443
|
+
const cfgPath = path.join(tmpDir, 'tailwind.config.js');
|
|
444
|
+
const inputCssPath = path.join(tmpDir, 'in.css');
|
|
445
|
+
|
|
446
|
+
// ── superset fontFamily(兜底)──
|
|
447
|
+
// 当 agent 没在 inline tailwind.config 里 extend fontFamily 时,用这份 superset。
|
|
448
|
+
//
|
|
449
|
+
// ⚠️ 字体 chain 4 段式(跟 SKILL.md 铁律 + canvas.template.html 一致):
|
|
450
|
+
// latin → 苹果 CJK(PingFang/Songti)→ Noto CJK(inline 兜底)→ generic
|
|
451
|
+
//
|
|
452
|
+
// 历史教训:
|
|
453
|
+
// 1. 之前没 PingFang/Noto SC——导致 agent 即使在原 HTML 写了 4 段 chain,
|
|
454
|
+
// build 时 tailwindcss CLI 用 superset 编译,font-sans / font-serif 等
|
|
455
|
+
// Tailwind class 全是老 chain(无 CJK)。font-serif 更夸张:没 extend
|
|
456
|
+
// 直接用 Tailwind 默认 [ui-serif, Georgia, ...],CJK 走 generic 全错。
|
|
457
|
+
// 2. agent 选 Manrope / Playfair / Geist 等 latin family,但 build superset
|
|
458
|
+
// 硬编码 sans=Inter / display=Instrument Serif —— agent 选的 latin family
|
|
459
|
+
// 在导出 PDF/PPT 里被 override 成 Inter,preview 看着对、导出英文错。
|
|
460
|
+
// 为修第 2 条:parseHybridDoc 解析 agent inline config 提取 fontFamily,
|
|
461
|
+
// 在这里 merge 进 superset,agent 选的 family 优先(见 finalFontFamily)。
|
|
462
|
+
const supersetFontFamily = {
|
|
463
|
+
sans: ['Inter', 'PingFang SC', 'Noto Sans SC', 'system-ui', 'sans-serif'],
|
|
464
|
+
serif: ['Instrument Serif', 'Songti SC', 'Noto Serif SC', 'serif'],
|
|
465
|
+
mono: ['JetBrains Mono', 'monospace'],
|
|
466
|
+
display: ['Instrument Serif', 'Songti SC', 'Noto Serif SC', 'serif'],
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
// ── merge:agent 写什么 build 就用什么 ──
|
|
470
|
+
// agent 是 senior,css 字体 chain 是设计意图——不要 second-guess、不要"贴心"
|
|
471
|
+
// 自动补 CJK / generic。SKILL.md 已经教 agent 写 4 段 chain,agent 自己负责
|
|
472
|
+
// 完整性。agent 没写的 key 才用 superset 兜底。
|
|
473
|
+
const finalFontFamily = { ...supersetFontFamily };
|
|
474
|
+
if (agentFontFamily && typeof agentFontFamily === 'object') {
|
|
475
|
+
for (const [key, list] of Object.entries(agentFontFamily)) {
|
|
476
|
+
if (!Array.isArray(list) || list.length === 0) continue;
|
|
477
|
+
finalFontFamily[key] = list; // 原样覆盖
|
|
478
|
+
|
|
479
|
+
// dev warn: agent chain 短于 4 段时给开发者排查信号(不改 merge 行为,trust 设计保留)
|
|
480
|
+
// 4 段铁律:latin → 苹果 CJK(PingFang/Songti)→ Noto CJK(inline 兜底)→ generic
|
|
481
|
+
// 不足 4 段在 Linux/Windows 没装 latin family 时 fallback 链断,跨平台字体效果可能跟设计意图差很远
|
|
482
|
+
if (list.length < 4) {
|
|
483
|
+
console.warn(
|
|
484
|
+
`[build-standalone] agent fontFamily.${key} 只有 ${list.length} 段 chain,` +
|
|
485
|
+
`跨平台可能 fallback 不全(建议 4 段:latin → 苹果 CJK → Noto CJK → generic)。` +
|
|
486
|
+
`当前 chain: ${JSON.stringify(list)}`
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const fontFamilyJson = JSON.stringify(finalFontFamily, null, 6).replace(/^/gm, ' ').trim();
|
|
493
|
+
|
|
494
|
+
const config = `module.exports = {
|
|
495
|
+
content: ['${htmlPath}'],
|
|
496
|
+
theme: {
|
|
497
|
+
extend: {
|
|
498
|
+
fontFamily: ${fontFamilyJson},
|
|
499
|
+
},
|
|
500
|
+
},
|
|
501
|
+
// 不开 corePlugins.preflight=false:保留 Tailwind base reset(tile 默认含 box-sizing 等基础)
|
|
502
|
+
};
|
|
503
|
+
`;
|
|
504
|
+
const inputCss = '@tailwind base;\n@tailwind components;\n@tailwind utilities;\n';
|
|
505
|
+
|
|
506
|
+
await Promise.all([
|
|
507
|
+
fs.writeFile(htmlPath, rawHtml, 'utf8'),
|
|
508
|
+
fs.writeFile(cfgPath, config, 'utf8'),
|
|
509
|
+
fs.writeFile(inputCssPath, inputCss, 'utf8'),
|
|
510
|
+
]);
|
|
511
|
+
|
|
512
|
+
try {
|
|
513
|
+
const { stdout } = await execFileAsync(TAILWIND_BIN, [
|
|
514
|
+
'-c', cfgPath,
|
|
515
|
+
'-i', inputCssPath,
|
|
516
|
+
'--minify',
|
|
517
|
+
], {
|
|
518
|
+
maxBuffer: 10 * 1024 * 1024, // CSS 最大 10MB(远超实际 ~50KB)
|
|
519
|
+
});
|
|
520
|
+
return stdout;
|
|
521
|
+
} finally {
|
|
522
|
+
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => { /* ignore */ });
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// ──────────────────────────────────────────────────────────────────
|
|
527
|
+
// Step 4-7 · assembleStandaloneHtml
|
|
528
|
+
// ──────────────────────────────────────────────────────────────────
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* 重写 HTML:删 CDN script tag / 替换 babel scripts / 注入 inline CSS
|
|
532
|
+
*
|
|
533
|
+
* @param {{
|
|
534
|
+
* rawHtml: string,
|
|
535
|
+
* parsed: ReturnType<typeof parseHybridDoc>,
|
|
536
|
+
* bundledJs: string,
|
|
537
|
+
* tailwindCss: string
|
|
538
|
+
* }} args
|
|
539
|
+
* @returns {string} 自包含 HTML
|
|
540
|
+
*/
|
|
541
|
+
export function assembleStandaloneHtml({ rawHtml, parsed, bundledJs, tailwindCss, injectFit = true }) {
|
|
542
|
+
let html = rawHtml;
|
|
543
|
+
|
|
544
|
+
// ⚠️ 关键:用 split+join 而不是 string.replace,因为 minified JS bundle 里
|
|
545
|
+
// 有大量 `$&` `$'` `$1` 等字面字符串,String.replace 的 replacement 字符串会
|
|
546
|
+
// 把它们当作 backreference(substitute matched/captured groups),导致 1MB
|
|
547
|
+
// bundle 替换后产物炸到 7 倍大小。split+join 是字面替换,无 $ 解读。
|
|
548
|
+
const literalReplace = (str, search, replacement) =>
|
|
549
|
+
str.split(search).join(replacement);
|
|
550
|
+
|
|
551
|
+
// 4a. 删 importmap(已 esbuild bundled)
|
|
552
|
+
if (parsed.importmapTagFull) {
|
|
553
|
+
html = literalReplace(html, parsed.importmapTagFull, '<!-- importmap inlined by build-standalone -->');
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// 4b. 删 Babel standalone CDN(已预编译)
|
|
557
|
+
if (parsed.babelCdnTag) {
|
|
558
|
+
html = literalReplace(html, parsed.babelCdnTag, '<!-- @babel/standalone removed (precompiled by esbuild) -->');
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// 4c. 替换 Tailwind CDN script tag → 后续 inline <style>
|
|
562
|
+
if (parsed.tailwindCdnTag) {
|
|
563
|
+
html = literalReplace(html, parsed.tailwindCdnTag, '<!-- Tailwind Play CDN replaced by extracted inline CSS -->');
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// 4d. 移除 tailwind.config 运行时 inline config(已在 extract 阶段消费)
|
|
567
|
+
if (parsed.tailwindConfigTag) {
|
|
568
|
+
html = literalReplace(html, parsed.tailwindConfigTag, '<!-- tailwind.config consumed at build time -->');
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// 4e. 替换所有 babel scripts 为单个 type=module bundle(必须 literal——bundledJs 含 $)
|
|
572
|
+
// 0 个 babel script 时跳过——纯静态 deck 不需要 module 入口
|
|
573
|
+
for (let i = 0; i < parsed.babelScripts.length; i++) {
|
|
574
|
+
const tag = parsed.babelScripts[i].fullTag;
|
|
575
|
+
const replacement = i === 0 && bundledJs
|
|
576
|
+
? `<script type="module">\n/* bundled by esbuild from ${parsed.babelScripts.length} babel script(s) */\n${bundledJs}\n</script>`
|
|
577
|
+
: '<!-- babel script bundled into the first module -->';
|
|
578
|
+
html = literalReplace(html, tag, replacement);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// 5. inline Tailwind CSS 到 <head>(CSS 也可能含 $,用 literal)
|
|
582
|
+
const tailwindStyleTag = `<style id="__nd-tailwind-extracted">\n${tailwindCss}\n</style>`;
|
|
583
|
+
if (html.includes('</head>')) {
|
|
584
|
+
html = literalReplace(html, '</head>', tailwindStyleTag + '\n</head>');
|
|
585
|
+
} else {
|
|
586
|
+
html = tailwindStyleTag + '\n' + html;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// 6-7. fit script(**只给 deck**,2026-07-28)——
|
|
590
|
+
// 去重:agent 偶尔会自己写一个 fit script(譬如 letterbox 风格的
|
|
591
|
+
// Math.min(vw/W, vh/H)),跟服务端注入的 frame-snap fit 叠加导致 scale 双重缩放或
|
|
592
|
+
// DOM 层级冲突。strategy:识别所有"看起来像 fit script"的 inline script 段全删,
|
|
593
|
+
// 再注入一个权威 standard fit script,保证只有一份在跑。
|
|
594
|
+
//
|
|
595
|
+
// 站点(injectFit=false)两步都跳过:注入会把自然滚动的长页改造成翻页器;
|
|
596
|
+
// stripFitScripts 的启发式(含 `transform: scale(` 写法)会误删站点里正当的
|
|
597
|
+
// hero 缩放动画。删了还查不出来 —— 导出物打开只是"动画没了",没人会怀疑导出。
|
|
598
|
+
if (injectFit) {
|
|
599
|
+
html = stripFitScripts(html);
|
|
600
|
+
html = injectStandardFitScript(html);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
return html;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Heuristic:找真·fit/scale 脚本删除(避免跟服务端注入的 standard fit script 双 scale)。
|
|
608
|
+
*
|
|
609
|
+
* 旧版命中条件 `body.includes('__nd-deck-wrap')` 太松——所有合法的 keyboard nav /
|
|
610
|
+
* scroll-spy / page indicator 都 query wrap,会被误杀。改严:只删带"明确 scale 行为
|
|
611
|
+
* 信号"的脚本。识别信号要求**同时含 transform-style scale 写法 + 1920/DESIGN_W
|
|
612
|
+
* 这种 viewport 比值算式**,单一标识符(如纯 `__nd-deck-wrap`)不再触发。
|
|
613
|
+
*
|
|
614
|
+
* Escape:带 `data-nodesign-keep` attribute 的 script 永不删(agent 显式声明保留)。
|
|
615
|
+
*/
|
|
616
|
+
function stripFitScripts(html) {
|
|
617
|
+
// 只匹配 inline script(无 src 属性);外联 fit script 在 NoDesign 范式里不存在
|
|
618
|
+
// attrs group 抓 <script ...> 里 attributes 字符串供 escape 检测
|
|
619
|
+
const scriptRe = /<script(?![^>]*\bsrc=)([^>]*)>([\s\S]*?)<\/script>/gi;
|
|
620
|
+
return html.replace(scriptRe, (full, attrs, body) => {
|
|
621
|
+
// Escape:显式声明保留(带 data-nodesign-keep 的 nav 脚本永不删)
|
|
622
|
+
if (/\bdata-nodesign-keep\b/i.test(attrs)) return full;
|
|
623
|
+
|
|
624
|
+
// Heuristic 1:使用 NoDesign 私有 class `__nd-fit-active` 且带 classList 操作
|
|
625
|
+
// —— 这是 fit script 唯一的"添加 fit 状态" 写法,nav/scroll-spy 不会碰这个 class
|
|
626
|
+
const usesFitActiveClass =
|
|
627
|
+
body.includes('__nd-fit-active') && /\bclassList\.(add|remove|toggle)\b/.test(body);
|
|
628
|
+
|
|
629
|
+
// Heuristic 2:viewport 比值算式 + 任何 scale 写法(即使 transform 跟 scale 之间被
|
|
630
|
+
// 三元/quote 隔开)—— 模板原 fit 写法 `var s = vw / W;` + `... 'scale(' + s + ')'`
|
|
631
|
+
const hasViewportRatio =
|
|
632
|
+
/Math\.min\s*\(\s*\w+\s*\/\s*1920/i.test(body)
|
|
633
|
+
|| (/DESIGN_W\b/.test(body) && /\bscale\b/.test(body))
|
|
634
|
+
|| (/\bvw\s*\/\s*W\b/.test(body) && /['"`]scale\s*\(/.test(body))
|
|
635
|
+
|| (/window\.innerWidth/.test(body) && /1920/.test(body) && /\bscale\b/.test(body));
|
|
636
|
+
|
|
637
|
+
// Heuristic 3:明示的 transform: scale 写法(CSS 风格连续)
|
|
638
|
+
const hasInlineScaleTransform = /transform\s*[:=]\s*['"]?\s*scale\s*\(/i.test(body);
|
|
639
|
+
|
|
640
|
+
const isFitScript = usesFitActiveClass || hasViewportRatio || hasInlineScaleTransform;
|
|
641
|
+
return isFitScript ? '<!-- fit script removed by build-standalone (will inject standard) -->' : full;
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* 注入唯一权威 standard fit script(调 standalone-fit 的 fitInjectionBlock)。
|
|
647
|
+
* 放在 </body> 前;如无 </body> 末尾追加。
|
|
648
|
+
*/
|
|
649
|
+
function injectStandardFitScript(html) {
|
|
650
|
+
const block = fitInjectionBlock();
|
|
651
|
+
if (html.includes('</body>')) {
|
|
652
|
+
return html.split('</body>').join(block + '\n</body>');
|
|
653
|
+
}
|
|
654
|
+
return html + block;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// ────────────────────────────────────────────────────────────────────
|
|
658
|
+
// inlineLocalImages —— 自包含必需:把 <img src> + background:url() 里指
|
|
659
|
+
// 向本地 assets/ 的相对路径转 base64 data URL。
|
|
660
|
+
//
|
|
661
|
+
// 触发场景:generate_image MCP 工具落档 assets/generated/foo.png,agent
|
|
662
|
+
// 在 canvas.html 写 `<img src="assets/generated/foo.png">`。导出单文件
|
|
663
|
+
// HTML 后用户双击打开,浏览器 file:// 找不到 assets/,图全断。inline 后
|
|
664
|
+
// 100% self-contained。
|
|
665
|
+
//
|
|
666
|
+
// 范围:
|
|
667
|
+
// - 只处理本地相对路径(不带 scheme,不是 // 开头,不是 data:)
|
|
668
|
+
// - resolve 到 sessionRoot(sessions/<sid>/assets softlink → shared/assets,
|
|
669
|
+
// fs.readFile 自动跟链)
|
|
670
|
+
// - 防 traversal:必须 startsWith sessionRoot(realpath 校验)
|
|
671
|
+
// - mime 按扩展名定(png/jpg/jpeg/webp/gif/svg),其它扩展跳过
|
|
672
|
+
// - 缺失文件:替换为 broken-image SVG 占位 + 注释,不阻塞导出
|
|
673
|
+
// ────────────────────────────────────────────────────────────────────
|
|
674
|
+
|
|
675
|
+
const IMAGE_EXT_MIME = {
|
|
676
|
+
'.png': 'image/png',
|
|
677
|
+
'.jpg': 'image/jpeg',
|
|
678
|
+
'.jpeg': 'image/jpeg',
|
|
679
|
+
'.webp': 'image/webp',
|
|
680
|
+
'.gif': 'image/gif',
|
|
681
|
+
'.svg': 'image/svg+xml',
|
|
682
|
+
};
|
|
683
|
+
|
|
684
|
+
const BROKEN_PLACEHOLDER_DATA_URL =
|
|
685
|
+
'data:image/svg+xml;utf8,'
|
|
686
|
+
+ encodeURIComponent(
|
|
687
|
+
'<svg xmlns="http://www.w3.org/2000/svg" width="120" height="80" viewBox="0 0 120 80">'
|
|
688
|
+
+ '<rect width="120" height="80" fill="%23eee" stroke="%23bbb"/>'
|
|
689
|
+
+ '<text x="60" y="44" text-anchor="middle" font-family="sans-serif" font-size="11" fill="%23999">image not found</text>'
|
|
690
|
+
+ '</svg>',
|
|
691
|
+
);
|
|
692
|
+
|
|
693
|
+
async function inlineOneAsset(srcPath, baseDir, rootGuard, cache) {
|
|
694
|
+
if (cache.has(srcPath)) return cache.get(srcPath);
|
|
695
|
+
|
|
696
|
+
// 路径白名单:scheme/protocol-relative/data url 一律放过
|
|
697
|
+
if (/^(?:[a-z][a-z0-9+\-.]*:|\/\/)/i.test(srcPath)) {
|
|
698
|
+
cache.set(srcPath, srcPath);
|
|
699
|
+
return srcPath;
|
|
700
|
+
}
|
|
701
|
+
// 绝对路径(agent 不该写,防御性处理)—— 也放过原值
|
|
702
|
+
if (path.isAbsolute(srcPath)) {
|
|
703
|
+
cache.set(srcPath, srcPath);
|
|
704
|
+
return srcPath;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
const ext = path.extname(srcPath).toLowerCase();
|
|
708
|
+
const mime = IMAGE_EXT_MIME[ext];
|
|
709
|
+
if (!mime) {
|
|
710
|
+
cache.set(srcPath, srcPath); // 非图片资源不动
|
|
711
|
+
return srcPath;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// 相对 deck 自己的目录解(`../../assets/...` 正是从 tasks/<任务>/ 往上两级),
|
|
715
|
+
// 越界判定仍以 workspace 根为准
|
|
716
|
+
const abs = path.resolve(baseDir, srcPath);
|
|
717
|
+
const root = rootGuard || baseDir;
|
|
718
|
+
if (abs !== root && !abs.startsWith(root + path.sep)) {
|
|
719
|
+
console.warn(`[build-standalone] image path escapes session: ${srcPath}`);
|
|
720
|
+
cache.set(srcPath, BROKEN_PLACEHOLDER_DATA_URL);
|
|
721
|
+
return BROKEN_PLACEHOLDER_DATA_URL;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
let buf;
|
|
725
|
+
try {
|
|
726
|
+
buf = await fs.readFile(abs); // 跟 softlink
|
|
727
|
+
} catch (err) {
|
|
728
|
+
console.warn(`[build-standalone] image not found, using placeholder: ${srcPath} (${err.code || err.message})`);
|
|
729
|
+
cache.set(srcPath, BROKEN_PLACEHOLDER_DATA_URL);
|
|
730
|
+
return BROKEN_PLACEHOLDER_DATA_URL;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
const dataUrl = `data:${mime};base64,${buf.toString('base64')}`;
|
|
734
|
+
cache.set(srcPath, dataUrl);
|
|
735
|
+
return dataUrl;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* 扫 HTML 把所有本地 <img src> 和 url(...) 引用替换成 data URL。
|
|
740
|
+
*
|
|
741
|
+
* @param {string} html
|
|
742
|
+
* @param {string} baseDir 绝对路径,HTML 里的相对路径从这里 resolve(deck 自己的目录)
|
|
743
|
+
* @param {string} [rootGuard] 越界判定根(缺省 = baseDir)
|
|
744
|
+
* @returns {Promise<string>}
|
|
745
|
+
*/
|
|
746
|
+
export async function inlineLocalImages(html, baseDir, rootGuard = null) {
|
|
747
|
+
const cache = new Map(); // 同一图被多次引用只读一次盘 + base64 一次
|
|
748
|
+
|
|
749
|
+
// ── 收集所有候选 src(先收集再异步批量替换)──
|
|
750
|
+
const tasks = [];
|
|
751
|
+
|
|
752
|
+
// <img src="..."> / <img ... src='...' ...>
|
|
753
|
+
const imgRe = /<img\b[^>]*?\bsrc\s*=\s*(?:"([^"]+)"|'([^']+)')[^>]*>/gi;
|
|
754
|
+
let m;
|
|
755
|
+
while ((m = imgRe.exec(html)) !== null) {
|
|
756
|
+
const src = m[1] || m[2];
|
|
757
|
+
if (src) tasks.push(src);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// background-image: url(...) / background: ... url(...) (style 属性 + <style> 块)
|
|
761
|
+
const urlRe = /url\(\s*(?:"([^"]+)"|'([^']+)'|([^)]+?))\s*\)/gi;
|
|
762
|
+
while ((m = urlRe.exec(html)) !== null) {
|
|
763
|
+
const src = m[1] || m[2] || m[3];
|
|
764
|
+
if (src) tasks.push(src.trim());
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// 去重 + 并行 inline
|
|
768
|
+
const uniq = [...new Set(tasks)];
|
|
769
|
+
await Promise.all(uniq.map((s) => inlineOneAsset(s, baseDir, rootGuard || baseDir, cache)));
|
|
770
|
+
|
|
771
|
+
// ── 替换:用 cache 拿到的 data URL 替换原 src ──
|
|
772
|
+
// 注:split+join literal 替换避免 base64 里的 $ 被当 backreference
|
|
773
|
+
let out = html;
|
|
774
|
+
for (const [orig, replacement] of cache) {
|
|
775
|
+
if (orig === replacement) continue; // 没改的跳
|
|
776
|
+
// <img src="ORIG"> 和 url(ORIG) 都要替换;用包含 quote/paren 的字面字符串
|
|
777
|
+
// 减少误伤(避免一个 path 段也匹配 alt 文字之类)
|
|
778
|
+
const variants = [
|
|
779
|
+
`"${orig}"`, `'${orig}'`,
|
|
780
|
+
`(${orig})`, `( ${orig} )`,
|
|
781
|
+
`("${orig}")`, `('${orig}')`,
|
|
782
|
+
];
|
|
783
|
+
for (const v of variants) {
|
|
784
|
+
const replaceVariant = v
|
|
785
|
+
.replace(orig, replacement);
|
|
786
|
+
out = out.split(v).join(replaceVariant);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
return out;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// ────────────────────────────────────────────────────────────────────
|
|
794
|
+
// inlineGoogleFonts —— 把 Google Fonts CSS 链接转成 inline @font-face
|
|
795
|
+
// + woff2 base64 data URL,让导出 HTML 离线打开字体仍正确。
|
|
796
|
+
//
|
|
797
|
+
// 流程:
|
|
798
|
+
// 1. 找所有 <link href="https://fonts.googleapis.com/css2?..."> tag
|
|
799
|
+
// 2. fetch 每个 CSS(带浏览器 UA 让 Google 返 woff2 而不是 truetype)
|
|
800
|
+
// 3. CSS 里 `url(https://fonts.gstatic.com/...)` 全部 fetch + base64 替换
|
|
801
|
+
// 4. 把原 link tag 替换为 inline <style>
|
|
802
|
+
// 5. 顺手删 fonts.gstatic.com / fonts.googleapis.com 的 preconnect link
|
|
803
|
+
//
|
|
804
|
+
// 进程级 LRU cache:同一 deck reload 多次 / 多 deck 共字体免重 fetch
|
|
805
|
+
// fail-soft:fetch 失败保留原 link tag,离线失败但不破坏导出
|
|
806
|
+
// ────────────────────────────────────────────────────────────────────
|
|
807
|
+
|
|
808
|
+
const FONT_CACHE_MAX = 200; // 200 个字体文件够 50 个 deck 用
|
|
809
|
+
const fontCache = new Map();
|
|
810
|
+
|
|
811
|
+
function fontCacheGet(key) {
|
|
812
|
+
if (!fontCache.has(key)) return undefined;
|
|
813
|
+
const v = fontCache.get(key);
|
|
814
|
+
fontCache.delete(key);
|
|
815
|
+
fontCache.set(key, v);
|
|
816
|
+
return v;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function fontCacheSet(key, val) {
|
|
820
|
+
if (fontCache.size >= FONT_CACHE_MAX) {
|
|
821
|
+
const oldest = fontCache.keys().next().value;
|
|
822
|
+
fontCache.delete(oldest);
|
|
823
|
+
}
|
|
824
|
+
fontCache.set(key, val);
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
const FONT_EXT_MIME = {
|
|
828
|
+
'.woff2': 'font/woff2',
|
|
829
|
+
'.woff': 'font/woff',
|
|
830
|
+
'.ttf': 'font/ttf',
|
|
831
|
+
'.otf': 'font/otf',
|
|
832
|
+
};
|
|
833
|
+
|
|
834
|
+
// 模拟 Chrome UA 让 Google Fonts CSS 返 woff2 而不是 truetype
|
|
835
|
+
const FONT_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
|
|
836
|
+
|
|
837
|
+
async function fetchAsBase64(url) {
|
|
838
|
+
const cached = fontCacheGet(url);
|
|
839
|
+
if (cached) return cached;
|
|
840
|
+
const res = await fetch(url, { headers: { 'User-Agent': FONT_UA } });
|
|
841
|
+
if (!res.ok) throw new Error(`font fetch ${res.status}`);
|
|
842
|
+
const ab = await res.arrayBuffer();
|
|
843
|
+
const ext = (url.match(/\.(woff2|woff|ttf|otf)(?:[?#]|$)/i) || [])[1];
|
|
844
|
+
const mime = ext ? FONT_EXT_MIME['.' + ext.toLowerCase()] : 'font/woff2';
|
|
845
|
+
const dataUrl = `data:${mime};base64,${Buffer.from(ab).toString('base64')}`;
|
|
846
|
+
fontCacheSet(url, dataUrl);
|
|
847
|
+
return dataUrl;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* 解析 unicode-range 字符串为 [start, end] 数组。
|
|
852
|
+
* 支持:U+0041 / U+0041-005A / U+30?? (wildcards 替换为 0/F)
|
|
853
|
+
*
|
|
854
|
+
* @param {string} rangeStr "U+0000-00FF, U+0131, ..."
|
|
855
|
+
* @returns {Array<[number, number]>}
|
|
856
|
+
*/
|
|
857
|
+
function parseUnicodeRange(rangeStr) {
|
|
858
|
+
return rangeStr.split(',').map(r => r.trim()).map(r => {
|
|
859
|
+
const m = r.match(/^U\+([0-9A-Fa-f?]+)(?:-([0-9A-Fa-f]+))?$/);
|
|
860
|
+
if (!m) return null;
|
|
861
|
+
const startHex = m[1].replace(/\?/g, '0');
|
|
862
|
+
const endHex = m[2] || m[1].replace(/\?/g, 'F');
|
|
863
|
+
const start = parseInt(startHex, 16);
|
|
864
|
+
const end = parseInt(endHex, 16);
|
|
865
|
+
if (Number.isNaN(start) || Number.isNaN(end)) return null;
|
|
866
|
+
return [start, end];
|
|
867
|
+
}).filter(Boolean);
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/**
|
|
871
|
+
* 检查 @font-face 块的 unicode-range 是否跟 deck 用到的字符有交集。
|
|
872
|
+
* 没声明 unicode-range 的块认为匹配(无范围限制即覆盖全字符)。
|
|
873
|
+
*/
|
|
874
|
+
function fontFaceBlockNeeded(block, usedCharCodes) {
|
|
875
|
+
const m = block.match(/unicode-range\s*:\s*([^;}]+)/i);
|
|
876
|
+
if (!m) return true;
|
|
877
|
+
const ranges = parseUnicodeRange(m[1]);
|
|
878
|
+
if (ranges.length === 0) return true;
|
|
879
|
+
for (const cp of usedCharCodes) {
|
|
880
|
+
for (const [s, e] of ranges) {
|
|
881
|
+
if (cp >= s && cp <= e) return true;
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
return false;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
async function inlineGoogleFonts(html) {
|
|
888
|
+
// 找所有 Google Fonts CSS link
|
|
889
|
+
const linkRe = /<link\b[^>]*\bhref\s*=\s*(?:"([^"]+)"|'([^']+)')[^>]*>/gi;
|
|
890
|
+
const matches = [];
|
|
891
|
+
let m;
|
|
892
|
+
while ((m = linkRe.exec(html)) !== null) {
|
|
893
|
+
const href = m[1] || m[2];
|
|
894
|
+
if (/^https?:\/\/fonts\.googleapis\.com\/css/i.test(href)) {
|
|
895
|
+
matches.push({ fullTag: m[0], href });
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
if (matches.length === 0) return html;
|
|
900
|
+
|
|
901
|
+
// 一次性扫描整个 HTML 提取所有 codepoint(粗扫,包括 script/style/comment 内容也算。
|
|
902
|
+
// 安全过头比漏字符强 —— 漏字符 = 用户看到 □ tofu 灾难性,过头只是多 inline 一两个块)
|
|
903
|
+
const usedCharCodes = new Set();
|
|
904
|
+
for (const c of html) {
|
|
905
|
+
usedCharCodes.add(c.codePointAt(0));
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
let out = html;
|
|
909
|
+
|
|
910
|
+
for (const { fullTag, href } of matches) {
|
|
911
|
+
let cssText;
|
|
912
|
+
try {
|
|
913
|
+
const cached = fontCacheGet(href);
|
|
914
|
+
if (cached) {
|
|
915
|
+
cssText = cached;
|
|
916
|
+
} else {
|
|
917
|
+
const res = await fetch(href, { headers: { 'User-Agent': FONT_UA } });
|
|
918
|
+
if (!res.ok) throw new Error(`Google Fonts CSS ${res.status}`);
|
|
919
|
+
cssText = await res.text();
|
|
920
|
+
fontCacheSet(href, cssText);
|
|
921
|
+
}
|
|
922
|
+
} catch (err) {
|
|
923
|
+
console.warn(`[build-standalone] Google Fonts CSS fetch failed: ${href} (${err.message})`);
|
|
924
|
+
continue; // fail-soft 保留原 link
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// 拆 @font-face 块按 unicode-range 子集匹配 ——
|
|
928
|
+
// CJK 字体 (Noto Sans SC) 1 个 weight 能拆 7 个 range × 多 weight = 几十个 woff2,
|
|
929
|
+
// 全 inline 文件能上百 MB。按用到的字符过滤 unicode-range 后 latin-only deck 通常
|
|
930
|
+
// 只剩 1-2 个块、~50KB;CJK deck 也只 inline 实际用到的 range(也可能 5-10MB 但
|
|
931
|
+
// 不再是百 MB 灾难)
|
|
932
|
+
const blockRe = /@font-face\s*\{[^}]*\}/g;
|
|
933
|
+
const allBlocks = [...cssText.matchAll(blockRe)].map(x => x[0]);
|
|
934
|
+
const keptBlocks = allBlocks.filter(b => fontFaceBlockNeeded(b, usedCharCodes));
|
|
935
|
+
|
|
936
|
+
// 不在 @font-face 块里的 CSS 内容(注释 / 其他 rule)保留
|
|
937
|
+
const nonBlockCss = cssText.replace(blockRe, '').trim();
|
|
938
|
+
let assembledCss = (nonBlockCss ? nonBlockCss + '\n' : '') + keptBlocks.join('\n');
|
|
939
|
+
|
|
940
|
+
if (allBlocks.length > 0) {
|
|
941
|
+
console.log(`[build-standalone] fonts inline: ${keptBlocks.length}/${allBlocks.length} @font-face kept (${href})`);
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// 收集 keptBlocks 内的 woff2 url,base64 fetch(别的 url 不动)
|
|
945
|
+
const fontUrlRe = /url\(\s*(?:"([^"]+)"|'([^']+)'|([^)\s]+))\s*\)/g;
|
|
946
|
+
const fontUrls = new Set();
|
|
947
|
+
for (const block of keptBlocks) {
|
|
948
|
+
let fm;
|
|
949
|
+
const localRe = new RegExp(fontUrlRe.source, 'g');
|
|
950
|
+
while ((fm = localRe.exec(block)) !== null) {
|
|
951
|
+
const u = fm[1] || fm[2] || fm[3];
|
|
952
|
+
if (u && /^https?:\/\/.*\.(woff2|woff|ttf|otf)(?:[?#]|$)/i.test(u)) {
|
|
953
|
+
fontUrls.add(u);
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
const uniq = [...fontUrls];
|
|
959
|
+
const results = await Promise.allSettled(uniq.map(u => fetchAsBase64(u)));
|
|
960
|
+
for (let i = 0; i < uniq.length; i++) {
|
|
961
|
+
const r = results[i];
|
|
962
|
+
if (r.status === 'fulfilled') {
|
|
963
|
+
assembledCss = assembledCss.split(uniq[i]).join(r.value);
|
|
964
|
+
} else {
|
|
965
|
+
console.warn(`[build-standalone] font file fetch failed: ${uniq[i]} (${r.reason?.message})`);
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
const styleTag = `<style id="__nd-google-fonts-inlined">\n${assembledCss}\n</style>`;
|
|
970
|
+
out = out.split(fullTag).join(styleTag);
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
// 顺手删 preconnect(已不需要联网)
|
|
974
|
+
out = out.replace(
|
|
975
|
+
/<link\b[^>]*\bhref\s*=\s*["'](?:https?:\/\/fonts\.(?:googleapis\.com|gstatic\.com))[^"']*["'][^>]*>\s*/gi,
|
|
976
|
+
'',
|
|
977
|
+
);
|
|
978
|
+
|
|
979
|
+
return out;
|
|
980
|
+
}
|