@zuroku/cli
Advanced tools
| { | ||
| "name": "zuroku", | ||
| "owner": { | ||
| "name": "AI-Driven-R-D-Dept" | ||
| }, | ||
| "description": "zuroku publisher skills for Claude Code.", | ||
| "plugins": [ | ||
| { | ||
| "name": "zuroku", | ||
| "source": "./", | ||
| "description": "zuroku-publish + run-explainer-page skills. Includes a bundled `zuroku` CLI wrapper on PATH." | ||
| } | ||
| ] | ||
| } |
| { | ||
| "name": "zuroku", | ||
| "description": "Publish AI-generated graphic-recording HTML + assets to your zuroku instance. Bundles the zuroku-publish and run-explainer-page skills.", | ||
| "version": "0.1.7", | ||
| "author": { | ||
| "name": "AI-Driven-R-D-Dept" | ||
| }, | ||
| "homepage": "https://github.com/AI-Driven-R-D-Dept/zuroku-cli#readme", | ||
| "repository": "https://github.com/AI-Driven-R-D-Dept/zuroku-cli.git", | ||
| "license": "MIT" | ||
| } |
Sorry, the diff of this file is not supported yet
+281
-19
@@ -78,3 +78,3 @@ #!/usr/bin/env node | ||
| // src/commands/publish.ts | ||
| import { promises as fs } from "fs"; | ||
| import { promises as fs2 } from "fs"; | ||
| import path from "path"; | ||
@@ -186,2 +186,77 @@ import { | ||
| // src/lib/referrer-policy.ts | ||
| var TAG_RE = /<(img|iframe)\b([^>]*)>/gi; | ||
| function buildLineLookup(html) { | ||
| const lineStartOffsets = [0]; | ||
| for (let i = 0; i < html.length; i++) { | ||
| if (html.charCodeAt(i) === 10) lineStartOffsets.push(i + 1); | ||
| } | ||
| return (offset) => { | ||
| let lo = 0; | ||
| let hi = lineStartOffsets.length - 1; | ||
| while (lo < hi) { | ||
| const mid = lo + hi + 1 >>> 1; | ||
| if (lineStartOffsets[mid] <= offset) lo = mid; | ||
| else hi = mid - 1; | ||
| } | ||
| return lo + 1; | ||
| }; | ||
| } | ||
| function ensureNoReferrerForExternal(html) { | ||
| const added = []; | ||
| const warnings = []; | ||
| const lineFor = buildLineLookup(html); | ||
| const out = html.replace(TAG_RE, (full, tagName, attrs, offset) => { | ||
| const lowerTag = tagName.toLowerCase(); | ||
| const srcMatch = /\bsrc\s*=\s*["']([^"']+)["']/i.exec(attrs); | ||
| if (!srcMatch) return full; | ||
| const src = srcMatch[1]; | ||
| if (!/^https?:\/\//i.test(src)) return full; | ||
| const rpMatch = /\breferrerpolicy\s*=\s*["']([^"']*)["']/i.exec(attrs); | ||
| if (rpMatch) { | ||
| const existing = rpMatch[1].trim().toLowerCase(); | ||
| if (existing !== "no-referrer") { | ||
| warnings.push({ tag: lowerTag, src, existing, line: lineFor(offset) }); | ||
| } | ||
| return full; | ||
| } | ||
| const insertAt = srcMatch.index + srcMatch[0].length; | ||
| const newAttrs = attrs.slice(0, insertAt) + ' referrerpolicy="no-referrer"' + attrs.slice(insertAt); | ||
| added.push({ tag: lowerTag, src, line: lineFor(offset) }); | ||
| return `<${tagName}${newAttrs}>`; | ||
| }); | ||
| return { html: out, added, warnings }; | ||
| } | ||
| // src/lib/thumbnail.ts | ||
| import { promises as fs } from "fs"; | ||
| import sharp from "sharp"; | ||
| var THUMB_RE = /^thumb\.(png|jpe?g|webp|gif)$/i; | ||
| var JPEG_QUALITY = 82; | ||
| var DEFAULT_MAX_LONG_EDGE = 2e3; | ||
| function isThumbName(basename) { | ||
| return THUMB_RE.test(basename); | ||
| } | ||
| function isGifThumb(basename) { | ||
| return /\.gif$/i.test(basename); | ||
| } | ||
| async function compressThumbToJpeg(srcPath, opts = {}) { | ||
| const maxLongEdge = opts.maxLongEdge ?? DEFAULT_MAX_LONG_EDGE; | ||
| const input = await fs.readFile(srcPath); | ||
| const pipeline = sharp(input, { failOn: "none" }).rotate(); | ||
| const meta = await pipeline.metadata(); | ||
| const w = meta.width ?? 0; | ||
| const h = meta.height ?? 0; | ||
| const longEdge = Math.max(w, h); | ||
| if (longEdge > maxLongEdge && longEdge > 0) { | ||
| if (w >= h) { | ||
| pipeline.resize({ width: maxLongEdge, withoutEnlargement: true }); | ||
| } else { | ||
| pipeline.resize({ height: maxLongEdge, withoutEnlargement: true }); | ||
| } | ||
| } | ||
| const buffer = await pipeline.jpeg({ quality: JPEG_QUALITY, mozjpeg: true }).toBuffer(); | ||
| return { buffer, contentType: "image/jpeg", filename: "thumb.jpg" }; | ||
| } | ||
| // src/commands/publish.ts | ||
@@ -197,3 +272,3 @@ var HTML_MAX_BYTES = 5 * 1024 * 1024; | ||
| out = out.replace( | ||
| new RegExp(`(?:img|images)/${escaped}(?=["'\\s),])`, "g"), | ||
| new RegExp(`(?:img|images)/${escaped}(?=["'\\s),>])`, "g"), | ||
| `img/${to}` | ||
@@ -204,2 +279,60 @@ ); | ||
| } | ||
| function rewriteHtmlToServerAssets(html, serverFilenames) { | ||
| const exact = new Set(serverFilenames); | ||
| const stemMap = /* @__PURE__ */ new Map(); | ||
| const ambiguousStems = /* @__PURE__ */ new Set(); | ||
| for (const f of serverFilenames) { | ||
| const stem = f.replace(/\.[^.]+$/, ""); | ||
| if (stemMap.has(stem) && stemMap.get(stem) !== f) ambiguousStems.add(stem); | ||
| else stemMap.set(stem, f); | ||
| } | ||
| const rewritten = /* @__PURE__ */ new Set(); | ||
| const unmatched = /* @__PURE__ */ new Set(); | ||
| const LOCAL_REF = /^(?:\.\/)?(?:img|images)\/([A-Za-z0-9._@()\-]+\.[A-Za-z0-9]+)([?#][^\s]*)?$/; | ||
| const resolveRef = (raw) => { | ||
| const v = raw.trim(); | ||
| if (/^(?:[a-z][a-z0-9+.\-]*:|\/\/|#|data:)/i.test(v)) return raw; | ||
| const m = v.match(LOCAL_REF); | ||
| if (!m) return raw; | ||
| const fname = m[1]; | ||
| const suffix = m[2] ?? ""; | ||
| let target; | ||
| if (exact.has(fname)) { | ||
| target = fname; | ||
| } else { | ||
| const stem = fname.replace(/\.[^.]+$/, ""); | ||
| if (!ambiguousStems.has(stem)) target = stemMap.get(stem); | ||
| } | ||
| if (!target) { | ||
| unmatched.add(v); | ||
| return raw; | ||
| } | ||
| const next = `img/${target}${suffix}`; | ||
| if (next !== v) rewritten.add(v); | ||
| return next; | ||
| }; | ||
| let out = html; | ||
| out = out.replace( | ||
| /\b(src|href|poster)(\s*=\s*)(["'])([^"']*)\3/gi, | ||
| (_m, attr, eq, q, val) => `${attr}${eq}${q}${resolveRef(val)}${q}` | ||
| ); | ||
| out = out.replace( | ||
| /\b(src|href|poster)(\s*=\s*)([^"'\s>]+)/gi, | ||
| (_m, attr, eq, val) => `${attr}${eq}${resolveRef(val)}` | ||
| ); | ||
| out = out.replace( | ||
| /\bsrcset(\s*=\s*)(["'])([^"']*)\2/gi, | ||
| (_m, eq, q, val) => { | ||
| const cands = val.split(",").map((c) => { | ||
| const seg = c.trim(); | ||
| if (!seg) return ""; | ||
| const sp = seg.split(/\s+/); | ||
| sp[0] = resolveRef(sp[0]); | ||
| return sp.join(" "); | ||
| }).filter((c) => c.length > 0); | ||
| return `srcset${eq}${q}${cands.join(", ")}${q}`; | ||
| } | ||
| ); | ||
| return { html: out, rewritten: [...rewritten], unmatched: [...unmatched] }; | ||
| } | ||
| function extractHtmlAssetRefs(html) { | ||
@@ -278,3 +411,3 @@ const references = []; | ||
| "-V, --visibility <mode>", | ||
| "Visibility: private | curator. Server reserves 'public' and rejects it. Falls back to --private then ~/.config/zuroku/config.json then server default (curator) when omitted." | ||
| "Visibility: private | curator | public. 'public' is viewable by anyone with the link (kept out of timeline/search, served noindex); it can be set per publish but not stored as a config default. Falls back to --private then ~/.config/zuroku/config.json then server default (curator) when omitted." | ||
| ).option("--private", "Shortcut for --visibility private (wins over --visibility if both are set)").action(async (htmlArg, images, opts) => { | ||
@@ -286,3 +419,3 @@ try { | ||
| try { | ||
| htmlStat = await fs.stat(htmlPath); | ||
| htmlStat = await fs2.stat(htmlPath); | ||
| } catch { | ||
@@ -301,3 +434,3 @@ throw new ZurokuError3("NOT_FOUND", 0, `HTML file not found: ${htmlArg}`); | ||
| } | ||
| const htmlBuf = await fs.readFile(htmlPath); | ||
| const htmlBuf = await fs2.readFile(htmlPath); | ||
| info(`html: ${path.basename(htmlPath)} (${htmlStat.size} bytes)`); | ||
@@ -309,3 +442,15 @@ const assets = []; | ||
| const label = path.basename(abs); | ||
| const payload = opts.compress ? await compressForUpload(abs) : await passthroughForUpload(abs); | ||
| let payload; | ||
| if (opts.compress && isThumbName(label) && !isGifThumb(label)) { | ||
| payload = await compressThumbToJpeg(abs); | ||
| } else if (opts.compress) { | ||
| payload = await compressForUpload(abs); | ||
| } else { | ||
| payload = await passthroughForUpload(abs); | ||
| if (isThumbName(label) && payload.contentType === "image/webp") { | ||
| warn( | ||
| "thumb.* \u304C WebP \u3067\u3059\u3002OG/SNS unfurl (LinkedIn/Facebook/LINE) \u3067\u8868\u793A\u3055\u308C\u306A\u3044\u5834\u5408\u304C\u3042\u308A\u307E\u3059\u3002\u30B5\u30E0\u30CD\u306F PNG/JPEG \u63A8\u5968\u3002" | ||
| ); | ||
| } | ||
| } | ||
| info( | ||
@@ -325,6 +470,18 @@ `asset: ${label} -> ${payload.filename} (${payload.buffer.byteLength} bytes, ${payload.contentType})` | ||
| const providedFilenames = assets.map((a) => a.filename); | ||
| const htmlText = rewriteHtmlForRename(htmlOriginal, renameMap, providedFilenames); | ||
| let htmlText = rewriteHtmlForRename(htmlOriginal, renameMap, providedFilenames); | ||
| if (htmlText !== htmlOriginal) { | ||
| info(`html: rewrote img src references (path normalize / extension rename)`); | ||
| } | ||
| const rp = ensureNoReferrerForExternal(htmlText); | ||
| htmlText = rp.html; | ||
| if (rp.added.length > 0) { | ||
| info( | ||
| `html: added referrerpolicy="no-referrer" to ${rp.added.length} external <img>/<iframe> (hotlink protection)` | ||
| ); | ||
| } | ||
| for (const w of rp.warnings) { | ||
| warn( | ||
| `<${w.tag}> at line ${w.line} has referrerpolicy="${w.existing}" (recommended: "no-referrer" for hotlink-protected hosts): ${w.src}` | ||
| ); | ||
| } | ||
| const htmlForUpload = htmlText !== htmlOriginal ? Buffer.from(htmlText, "utf8") : htmlBuf; | ||
@@ -406,3 +563,3 @@ if (process.env.ZUROKU_SKIP_PREFLIGHT !== "1") { | ||
| // src/commands/update.ts | ||
| import { promises as fs2 } from "fs"; | ||
| import { promises as fs3 } from "fs"; | ||
| import path2 from "path"; | ||
@@ -415,3 +572,3 @@ import { | ||
| var HTML_MAX_BYTES2 = 5 * 1024 * 1024; | ||
| var ULID_RE = /^[0-9A-HJKMNP-TV-Z]{26}$/i; | ||
| var ULID_RE = /^[0-9A-HJKMNP-TV-Z]{26}$/; | ||
| var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; | ||
@@ -457,10 +614,54 @@ function looksLikeId(s) { | ||
| } | ||
| async function fetchProjectAssetFilenames(config, projectId) { | ||
| const base = config.base_url.replace(/\/+$/, ""); | ||
| const res = await fetch(`${base}/api/projects/${encodeURIComponent(projectId)}`, { | ||
| headers: { Accept: "application/json", Authorization: `Bearer ${config.token}` } | ||
| }); | ||
| if (!res.ok) { | ||
| throw new Error(`GET /api/projects/${projectId} -> ${res.status}`); | ||
| } | ||
| const j = await res.json(); | ||
| return new Set((j.assets ?? []).map((a) => a.filename)); | ||
| } | ||
| async function patchProjectTitle(config, projectId, title) { | ||
| const base = config.base_url.replace(/\/+$/, ""); | ||
| const res = await fetch(`${base}/api/projects/${encodeURIComponent(projectId)}`, { | ||
| method: "PATCH", | ||
| headers: { | ||
| Accept: "application/json", | ||
| "Content-Type": "application/json", | ||
| Authorization: `Bearer ${config.token}` | ||
| }, | ||
| body: JSON.stringify({ title }) | ||
| }); | ||
| if (!res.ok) { | ||
| const j = await res.json().catch(() => ({})); | ||
| throw new ZurokuError4( | ||
| j.error?.code ?? "HTTP_ERROR", | ||
| res.status, | ||
| `title update failed: ${j.error?.message ?? res.statusText}` | ||
| ); | ||
| } | ||
| } | ||
| function registerUpdateCommand(parent) { | ||
| parent.command("update").description("Republish an existing project, keeping its slug/URL (HTML + asset replace)").argument("<slug-or-id>", "Existing project slug or id (URL part after /p/)").argument("<html>", "Path to the new HTML file (<= 5 MiB)").argument("[images...]", "Image files to upload as the new asset set (full replacement)").option("--no-compress", "Skip image compression (upload originals)").option("-u, --base-url <url>", "Override API base URL").action(async (slugOrId, htmlArg, images, opts) => { | ||
| parent.command("update").description("Republish an existing project, keeping its slug/URL (HTML + asset replace)").argument("<slug-or-id>", "Existing project slug or id (URL part after /p/)").argument("<html>", "Path to the new HTML file (<= 5 MiB)").argument("[images...]", "Image files to upload as the new asset set (full replacement)").option("--no-compress", "Skip image compression (upload originals)").option( | ||
| "--keep-assets", | ||
| "Update HTML only and keep all existing images untouched (ignores [images...])" | ||
| ).option( | ||
| "-T, --title <title>", | ||
| "Also change the project's registered title (otherwise the title is kept from the original publish)" | ||
| ).option("-u, --base-url <url>", "Override API base URL").action(async (slugOrId, htmlArg, images, opts) => { | ||
| try { | ||
| const config = await loadRuntimeConfig({ ...opts.baseUrl ? { baseUrl: opts.baseUrl } : {} }); | ||
| if (opts.title !== void 0) { | ||
| const t = opts.title.trim(); | ||
| if (!t) throw new ZurokuError4("INVALID_INPUT", 0, "--title must not be empty"); | ||
| if (t.length > 200) { | ||
| throw new ZurokuError4("INVALID_INPUT", 0, `--title exceeds 200 chars (got ${t.length})`); | ||
| } | ||
| } | ||
| const htmlPath = path2.resolve(htmlArg); | ||
| let htmlStat; | ||
| try { | ||
| htmlStat = await fs2.stat(htmlPath); | ||
| htmlStat = await fs3.stat(htmlPath); | ||
| } catch { | ||
@@ -479,10 +680,27 @@ throw new ZurokuError4("NOT_FOUND", 0, `HTML file not found: ${htmlArg}`); | ||
| } | ||
| const htmlBuf = await fs2.readFile(htmlPath); | ||
| const htmlBuf = await fs3.readFile(htmlPath); | ||
| info(`html: ${path2.basename(htmlPath)} (${htmlStat.size} bytes)`); | ||
| const assets = []; | ||
| const renameMap = []; | ||
| for (const img of images) { | ||
| if (opts.keepAssets && images.length > 0) { | ||
| warn( | ||
| `--keep-assets specified: ignoring ${images.length} image arg(s); existing images are kept as-is` | ||
| ); | ||
| } | ||
| for (const img of opts.keepAssets ? [] : images) { | ||
| const abs = path2.resolve(img); | ||
| const label = path2.basename(abs); | ||
| const payload = opts.compress ? await compressForUpload2(abs) : await passthroughForUpload2(abs); | ||
| let payload; | ||
| if (opts.compress && isThumbName(label) && !isGifThumb(label)) { | ||
| payload = await compressThumbToJpeg(abs); | ||
| } else if (opts.compress) { | ||
| payload = await compressForUpload2(abs); | ||
| } else { | ||
| payload = await passthroughForUpload2(abs); | ||
| if (isThumbName(label) && payload.contentType === "image/webp") { | ||
| warn( | ||
| "thumb.* \u304C WebP \u3067\u3059\u3002OG/SNS unfurl (LinkedIn/Facebook/LINE) \u3067\u8868\u793A\u3055\u308C\u306A\u3044\u5834\u5408\u304C\u3042\u308A\u307E\u3059\u3002\u30B5\u30E0\u30CD\u306F PNG/JPEG \u63A8\u5968\u3002" | ||
| ); | ||
| } | ||
| } | ||
| info( | ||
@@ -502,7 +720,19 @@ `asset: ${label} -> ${payload.filename} (${payload.buffer.byteLength} bytes, ${payload.contentType})` | ||
| const providedFilenames = assets.map((a) => a.filename); | ||
| const htmlText = rewriteHtmlForRename(htmlOriginal, renameMap, providedFilenames); | ||
| let htmlText = rewriteHtmlForRename(htmlOriginal, renameMap, providedFilenames); | ||
| if (htmlText !== htmlOriginal) { | ||
| info(`html: rewrote img src references (path normalize / extension rename)`); | ||
| } | ||
| const htmlForUpload = htmlText !== htmlOriginal ? Buffer.from(htmlText, "utf8") : htmlBuf; | ||
| const rp = ensureNoReferrerForExternal(htmlText); | ||
| htmlText = rp.html; | ||
| if (rp.added.length > 0) { | ||
| info( | ||
| `html: added referrerpolicy="no-referrer" to ${rp.added.length} external <img>/<iframe> (hotlink protection)` | ||
| ); | ||
| } | ||
| for (const w of rp.warnings) { | ||
| warn( | ||
| `<${w.tag}> at line ${w.line} has referrerpolicy="${w.existing}" (recommended: "no-referrer" for hotlink-protected hosts): ${w.src}` | ||
| ); | ||
| } | ||
| let htmlForUpload = htmlText !== htmlOriginal ? Buffer.from(htmlText, "utf8") : htmlBuf; | ||
| if (process.env.ZUROKU_SKIP_PREFLIGHT !== "1") { | ||
@@ -518,3 +748,3 @@ const leaks = scanLocalPathLeaks(htmlText); | ||
| } | ||
| if (process.env.ZUROKU_SKIP_PREFLIGHT !== "1") { | ||
| if (!opts.keepAssets && process.env.ZUROKU_SKIP_PREFLIGHT !== "1") { | ||
| const { references, expectedFilenames } = extractHtmlAssetRefs(htmlText); | ||
@@ -540,7 +770,33 @@ const provided = new Set(providedFilenames); | ||
| info(`project_id=${projectId}`); | ||
| info(`republish-init: declaring ${assets.length} asset(s)...`); | ||
| if (opts.keepAssets) { | ||
| try { | ||
| const existing = await fetchProjectAssetFilenames(config, projectId); | ||
| const rw = rewriteHtmlToServerAssets(htmlText, [...existing]); | ||
| if (rw.rewritten.length > 0) { | ||
| htmlText = rw.html; | ||
| htmlForUpload = Buffer.from(htmlText, "utf8"); | ||
| info( | ||
| `html: rewrote ${rw.rewritten.length} local img ref(s) to existing server assets (keep-assets)` | ||
| ); | ||
| } | ||
| if (rw.unmatched.length > 0) { | ||
| warn( | ||
| "--keep-assets: HTML references images not present on the server (these will 404 \u2014 fix the refs or re-run without --keep-assets and pass every image):" | ||
| ); | ||
| for (const u of rw.unmatched) warn(` - ${u}`); | ||
| } | ||
| } catch (e) { | ||
| warn( | ||
| `--keep-assets: could not verify existing assets (${e.message ?? String(e)}); skipping reference check` | ||
| ); | ||
| } | ||
| } | ||
| const initBody = opts.keepAssets ? { keep_assets: true } : { asset_filenames: assets.map((a) => a.filename) }; | ||
| info( | ||
| opts.keepAssets ? "republish-init: keep-assets mode (HTML only, existing images preserved)..." : `republish-init: declaring ${assets.length} asset(s)...` | ||
| ); | ||
| const init = await callRepublishApi( | ||
| config, | ||
| `/api/projects/${encodeURIComponent(projectId)}/republish-init`, | ||
| { asset_filenames: assets.map((a) => a.filename) } | ||
| initBody | ||
| ); | ||
@@ -562,2 +818,8 @@ info("uploading html + assets..."); | ||
| success(`updated: slug=${res.slug} id=${res.id}`); | ||
| if (opts.title !== void 0 && opts.title.trim()) { | ||
| const t = opts.title.trim(); | ||
| info(`updating registered title to "${t}"...`); | ||
| await patchProjectTitle(config, projectId, t); | ||
| success("title updated"); | ||
| } | ||
| process.stdout.write(`${res.url} | ||
@@ -564,0 +826,0 @@ `); |
+5
-2
| { | ||
| "name": "@zuroku/cli", | ||
| "version": "0.1.4", | ||
| "version": "0.1.8", | ||
| "type": "module", | ||
@@ -12,2 +12,4 @@ "description": "Command-line publisher for zuroku — upload AI-generated graphic-recording HTML + assets to your zuroku instance.", | ||
| "skills", | ||
| "bin", | ||
| ".claude-plugin", | ||
| "README.md", | ||
@@ -48,3 +50,4 @@ "LICENSE" | ||
| "commander": "^12.1.0", | ||
| "kleur": "^4.1.5" | ||
| "kleur": "^4.1.5", | ||
| "sharp": "^0.33.5" | ||
| }, | ||
@@ -51,0 +54,0 @@ "devDependencies": { |
+14
-6
@@ -31,7 +31,7 @@ # @zuroku/cli | ||
| # 2. Make sure your HTML references images as `<img src="img/foo.png">`. | ||
| # If your generator emits `images/...`, rewrite it first: | ||
| # 2. Collect the HTML and its images into one directory and publish. | ||
| # `images/<file>` references are auto-normalized to `img/<file>` for your | ||
| # provided assets — no manual sed needed. | ||
| mkdir -p /tmp/zuroku-deploy | ||
| sed 's|images/|img/|g' /path/to/index.html > /tmp/zuroku-deploy/index.html | ||
| cp /path/to/images/*.png /tmp/zuroku-deploy/ | ||
| cp /path/to/index.html /path/to/images/*.png /tmp/zuroku-deploy/ | ||
@@ -41,6 +41,13 @@ cd /tmp/zuroku-deploy | ||
| # - assets are compressed to WebP client-side (sharp, 85% quality, max 2000px long-edge) | ||
| # - <img src="img/foo.png"> in the HTML is auto-rewritten to .webp on the fly (since v0.1.1) | ||
| # - `images/foo.png` / `img/foo.png` are auto-rewritten to `img/foo.webp` (anchored to | ||
| # your provided assets — external URLs that contain `images/` are left intact) | ||
| # - stdout last line is the public URL | ||
| ``` | ||
| > **Do not run a bare `sed 's|images/|img/|g'`.** It rewrites the substring | ||
| > `images/` everywhere, including external image URLs like | ||
| > `https://.../images/foo.webp`, turning them into broken `.../img/...` links (404). | ||
| > The CLI normalizes local references safely on its own. If you must hand-edit a | ||
| > different prefix (e.g. `assets/`), anchor to `src="assets/` so external URLs are untouched. | ||
| ## Commands | ||
@@ -93,2 +100,3 @@ | ||
| - `curator` — viewable by Discord members with the configured curator role. | ||
| - `public` — viewable by anyone with the link (not just curators). Still kept out of the in-app timeline/search, and served with `X-Robots-Tag: noindex,nofollow` (link sharing / SNS unfurl only, no SEO indexing). | ||
@@ -104,3 +112,3 @@ Order of precedence: CLI flag (`--private` > `--visibility`) → per-user config `default-visibility` → server default (`curator`). | ||
| `public` is reserved by the server and rejected by both CLI and API. To make something publicly visible, publish as `curator` and toggle visibility from the web UI. | ||
| `public` can be set per publish (`zuroku publish … --visibility public`), but **cannot** be stored as a `default-visibility` config value — public exposure should always be a deliberate per-publish choice, never a silent default. You can also toggle visibility (including to/from `public`) later from the web UI. | ||
@@ -107,0 +115,0 @@ ## Output contract |
@@ -253,2 +253,21 @@ --- | ||
| ### 外部 subresource (`<img src="https://...">` / `<iframe>`) | ||
| 原則として **外部画像を直貼りしない**。本 skill は `run-ai-images` で生成した | ||
| ローカル画像 (`images/concept-NN-01.png`) を asset として upload する 1 枚 HTML が | ||
| 基本形。公式ロゴ等を引きたい場面で外部 `<img>` を埋め込むときは **必ず** | ||
| `referrerpolicy="no-referrer"` を付ける: | ||
| ```html | ||
| <img src="https://example.com/logo.png" referrerpolicy="no-referrer" alt="..."> | ||
| ``` | ||
| 理由は **hotlink protection (Referer 検査)** 対策。zuroku 配信ドメインを | ||
| Referer に載せると X / 一部 CDN / 報道サイトが 403 / placeholder を返す。 | ||
| `curl` だと 200 が返るのでローカル検証では気付けない (= 初見エージェントの | ||
| 頻出ミス)。zuroku CLI v0.1.5+ は publish/update 時に自動付与するが、 | ||
| HTML 生成段階で明示しておくと preflight diff が読みやすい。 | ||
| `<a href="https://...">` (ナビゲーション) には不要。 | ||
| ### アクセント色の決め方 | ||
@@ -283,2 +302,3 @@ | ||
| - **数字には出典** → Phase 1 で取得した価格 / 性能数字は出典 URL を outline に入れて `<p style="font-size: 13px; color: var(--ink-2);">※ 出典: ...</p>` で section 末尾に置く。出典なしの数字を断定形で書かない | ||
| - **外部画像を貼るなら `referrerpolicy="no-referrer"`** → `<img src="https://...">` を埋めるとき、公開ドメインから読まれた瞬間に Referer ベースの hotlink protection で 403 / placeholder になる (X / 一部 CDN / 報道サイト)。`curl` で 200 が返るので「リンク切れ」と誤認しやすい。CLI v0.1.5+ は自動付与するが、HTML 側で明示しておくのが事故率最低。`<a>` には不要 | ||
@@ -285,0 +305,0 @@ ## Additional resources |
@@ -7,3 +7,3 @@ --- | ||
| author: AI-Driven-R-D-Dept | ||
| version: '0.1.3' | ||
| version: '0.1.8' | ||
| user-invocable: true | ||
@@ -21,11 +21,12 @@ argument-hint: <html-path> [image-paths...] --title "..." [--no-compress] [--visibility private|curator] [--private] | ||
| ```bash | ||
| # HTML 内の <img src> を `img/<basename>` 形式に揃える (img/ prefix 必須) | ||
| # HTML と画像をデプロイ用ディレクトリに集める (sed での書き換えは原則不要、下の注意参照) | ||
| mkdir -p /tmp/zuroku-deploy | ||
| sed 's|images/|img/|g' /path/to/index.html > /tmp/zuroku-deploy/index.html | ||
| cp /path/to/images/*.png /tmp/zuroku-deploy/ | ||
| cp /path/to/index.html /path/to/images/*.png /tmp/zuroku-deploy/ | ||
| cd /tmp/zuroku-deploy | ||
| zuroku publish ./index.html ./*.png --title "..." | ||
| # - sharp で client-side WebP 圧縮 (85%、長辺 2000px) → 通信量 / 表示も軽量 | ||
| # - HTML 内 <img src="img/foo.png"> は CLI が自動で .webp に rewrite (v0.1.1+) | ||
| # - `images/<画像>` 参照は CLI が **provided asset の filename にアンカーして** | ||
| # 自動で `img/<画像>` に正規化する。手動 sed は不要 (外部 URL は壊さない)。 | ||
| # - sharp で client-side WebP 圧縮 (85%、長辺 2000px)。<img src="img/foo.png"> も | ||
| # CLI が自動で .webp に rewrite (v0.1.1+)。 | ||
| # - 標準出力の最終行が公開 URL | ||
@@ -37,2 +38,8 @@ | ||
| > ⚠️ **`sed 's|images/|img/|g'` のような bare 置換は使わない。** 文字列 `images/` | ||
| > をどこでも置換するため、HTML 内の外部画像 URL | ||
| > (`https://.../images/foo.webp` 等) まで `.../img/...` に化けさせて 404 にする。 | ||
| > ローカル参照の正規化は CLI が安全に行うので不要。どうしても手動で直す必要がある | ||
| > 場合 (後述 `assets/` 等) は `src="images/` のように**ローカル参照だけにアンカー**すること。 | ||
| ## 制約 | ||
@@ -42,3 +49,4 @@ | ||
| - 配信ルートが `/p/:slug/img/:filename` 固定。HTML 側は相対 `<img src="img/foo.png">` で参照する。 | ||
| - `images/`、`./assets/` 等で書かれていれば publish 前に sed で書き換える。 | ||
| - **`images/<画像>` (複数形) は CLI が provided asset について自動で `img/` に正規化する**ので手動修正は不要。外部 URL (`https://.../images/...`) は provided にアンカーされるため触られない。 | ||
| - `assets/`、`style/` 等の**別ディレクトリ名**は自動正規化の対象外。必要なら `src="assets/` のように**ローカル参照だけにアンカー**して書き換える。**bare `images/` を global 置換しないこと** (外部 URL 内の `images/` まで壊して 404 になる)。 | ||
| - SVG (`<img src="img/foo.svg">`) は受け付けない。PNG / JPEG / WebP / GIF のみ。 | ||
@@ -52,2 +60,12 @@ | ||
| ### R2.5. サムネ / OG 画像 (`thumb.*` 規約) | ||
| - OG 画像 (SNS unfurl / アプリ内一覧のサムネ) は **`thumb.{png|jpg|jpeg|webp|gif}` という名前の画像**が自動で選ばれる。専用フラグは無く、ファイル名規約で指定する。 | ||
| - `thumb.*` が無ければ asset の **1 枚目** (`created_at`→`filename` 昇順の先頭) に自動フォールバックする。意図しない画像がサムネになりがちなので、サムネを効かせたい記事では必ず `thumb.*` を用意する。 | ||
| - **どの画像を `thumb` にするか**: その記事の **全体像を最もよく表す 1 枚** を選んで `thumb.*` にリネームして含める。 | ||
| - 良い例: 図解全体の俯瞰図 / 完成形のキービジュアル / 記事の結論を 1 枚で示す図。 | ||
| - 避ける: 部分拡大・補足の細部図・文脈なしでは意味が伝わらない断片。SNS のカードや一覧で「これは何の記事か」が一目で伝わる 1 枚を選ぶ。 | ||
| - **`thumb.*` は OG/SNS unfurl 互換のため CLI が自動で JPEG (`thumb.jpg`) に変換する** (v0.1.8+)。他の asset は WebP 圧縮されるが、サムネだけは LinkedIn / Facebook / LINE 等が WebP の og:image を描画しない問題を避けるため JPEG に揃える (Slack/Discord は WebP でも可)。HTML 内の `img/thumb.png` 参照も `img/thumb.jpg` に自動 rewrite される。 | ||
| - GIF の `thumb.gif` はアニメ保持のため変換しない。`--no-compress` で WebP の thumb を渡すと warn が出る (OG が表示されない可能性)。 | ||
| - `update` の全置換でも `thumb.*` を含めれば再選定される。`--keep-assets` では既存のサムネがそのまま維持される。 | ||
| ### R3. サイズ上限 | ||
@@ -59,3 +77,3 @@ - HTML: 5 MiB | ||
| ### R4. visibility (公開範囲) | ||
| - `-V, --visibility <mode>`: `private` (本人のみ) / `curator` (curator role を持つ Discord メンバーのみ閲覧可)。 | ||
| - `-V, --visibility <mode>`: `private` (本人のみ) / `curator` (curator role を持つ Discord メンバーのみ閲覧可) / `public` (リンクを知る誰でも閲覧可)。 | ||
| - `--private`: `--visibility private` のショートカット。`-V curator` と併用された場合は `--private` が勝つ (CLI が warn を出す)。 | ||
@@ -66,4 +84,5 @@ - どちらも未指定なら順に下記の優先順で解決: | ||
| 3. server default = `curator` | ||
| - **`public` は server 予約語**で、CLI も server も reject する。一般公開したい場合は curator にした上で Web UI から個別操作する。 | ||
| - private のまま publish 後に visibility を変えたいときは stderr に表示される `manage visibility: <app>/settings/projects` の URL から切り替える。 | ||
| - **`public` は per-publish で指定可能** (`--visibility public`)。リンクを知る誰でも閲覧できるが、アプリ内 timeline/検索には出さず `X-Robots-Tag: noindex,nofollow` で配信される (リンク共有 / SNS unfurl 用、SEO index はしない)。 | ||
| - ただし **`public` を `config set default-visibility` のデフォルトには保存できない** (公開は毎回明示的に選ぶべきで、暗黙のデフォルトにはしない設計)。 | ||
| - private のまま publish 後に visibility を変えたいときは stderr に表示される `manage visibility: <app>/settings/projects` の URL から切り替える (Web UI からは public への切替も可)。 | ||
@@ -78,6 +97,25 @@ ## CLI が publish 前に弾くケース | ||
| | `[UNUSED]` | asset 引数にあるが HTML 未参照 | 余分な image を引数から外す | | ||
| | `[WRONG-PATH]` | `img/` で始まらない相対参照 | sed で `images/` → `img/` 等に書換 | | ||
| | `[WRONG-PATH]` | `img/` で始まらない相対参照 | `src="images/` のように**ローカル参照だけにアンカー**して書換 (bare `images/` の global 置換は外部 URL を壊すので不可) | | ||
| 緊急 bypass: `ZUROKU_SKIP_PREFLIGHT=1 zuroku publish ...` (debug 用、本番では使わない)。 | ||
| ### R6. 外部 subresource の hotlink protection (v0.1.5+, 自動) | ||
| zuroku CLI は publish/update 時に HTML を scan し、`<img src="https://...">` と | ||
| `<iframe src="https://...">` に `referrerpolicy="no-referrer"` が無ければ | ||
| 自動付与する。理由: | ||
| - 配信ドメイン (例: `app.zuroku.masao.ai`) を Referer に載せると、X / 一部 CDN / | ||
| 報道サイトの hotlink protection が **403 / placeholder** を返す。 | ||
| - `curl` / `fetch(url)` だと 200 が返るので「URL は生きている」と誤認しがちだが、 | ||
| ブラウザ subresource として読むと壊れる。**初見エージェントが最も機械的に踏む罠**。 | ||
| - 自動付与時は stderr に `info html: added referrerpolicy="no-referrer" to N external <img>/<iframe>` が出る。 | ||
| - 既に `referrerpolicy` が指定済みで値が `no-referrer` 以外 (`origin` / `unsafe-url` 等) なら | ||
| CLI は **書き換えず warn を出す** (誤設定の hint)。 | ||
| スコープ外: | ||
| - `<a href="https://...">` (ナビゲーションは hotlink 制限の対象外) | ||
| - `img/<basename>` (zuroku asset。同一 origin) | ||
| - `data:` / `blob:` URI | ||
| ### R5. local-path leak preflight (v0.1.3+) | ||
@@ -110,2 +148,16 @@ | ||
| ### `--keep-assets` — 本文だけ直して画像はそのまま (v0.1.5+) | ||
| ```bash | ||
| # HTML だけ差し替え、既存の画像は一切触らない。img 引数は不要 (渡しても無視)。 | ||
| zuroku update my-cool-page ./index.html --keep-assets | ||
| ``` | ||
| - 既存画像を **温存** したまま HTML だけ更新する。全置換モードと違い画像の再アップロード不要。 | ||
| - 「文言だけ直したい」「typo 修正」など本文のみの更新で、画像の渡し忘れによる一括削除事故を防げる。 | ||
| - asset 欠落 preflight はスキップされる (HTML が参照する `img/*` はサーバ側に温存されている前提)。 | ||
| - ただし新 HTML が**サーバに無い `img/*` を参照している**場合は CLI が warn を出す (`--keep-assets: HTML references img/ files not present on the server`)。「本文だけ直す」つもりで画像参照名を変えると沈黙して 404 になる事故を防ぐためのもの。warn が出たら参照名を直すか、`--keep-assets` を外して全画像を渡す全置換モードに切り替える。 | ||
| - thumbnail / OG 画像も従来のまま維持される。 | ||
| - 画像を **足す / 差し替える / 消す** ときは `--keep-assets` を付けず、全画像を positional で渡す全置換モードを使う。 | ||
| ## 認証 | ||
@@ -131,3 +183,3 @@ | ||
| config は `~/.config/zuroku/config.json` に `0600` で保存される (`XDG_CONFIG_HOME` 尊重)。 | ||
| `public` は server 予約のため `set` でも reject される。`publish` は flag 未指定時に config を fallback する (info 行 `visibility: <mode> (from config default_visibility)` が出る)。 | ||
| `default-visibility` に設定できるのは `private` / `curator` のみ。**`public` は `set` で reject される** (公開は per-publish で `--visibility public` を明示する設計で、暗黙のデフォルトにはしない)。`publish` は flag 未指定時に config を fallback する (info 行 `visibility: <mode> (from config default_visibility)` が出る)。 | ||
@@ -158,2 +210,3 @@ ## list / delete | ||
| | `LOCAL_PATH_LEAK` | HTML 本文に `/Users/...` 等の作者マシン path が混入 (v0.1.3+ で検知) | HTML 側で絶対パスを削除 / 公開 URL に置換 / ファイル名だけ抽象的に言及 | | ||
| | 公開ページで外部画像だけ 403 / placeholder | hotlink protection (Referer 検査) | v0.1.5+ は自動付与。**warn 行 `referrerpolicy="..." (recommended: "no-referrer")` が出たら** HTML 側を `no-referrer` に直す。`<a>` には不要 | | ||
| | 配信ページで画像 404 | (v0.1.1+ では自動 rewrite される。それ以前 / HTML を直接書き換えていた場合) basename 不一致。HTML の `<img src="img/...">` と asset 引数の filename を再確認 | | ||
@@ -160,0 +213,0 @@ | `UNSUPPORTED_MEDIA 415` | SVG / Content-Type 偽装 | PNG/JPEG/WebP/GIF のみ | |
Sorry, the diff of this file is too big to display
Unpublished package
Supply chain riskPackage version was not found on the registry. It may exist on a different registry and need to be configured to pull from that registry.
Found 1 instance in 1 package
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 1 instance in 1 package
186243
34.73%12
33.33%1068
36.4%141
6.02%4
33.33%8
14.29%4
300%+ Added