diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f8f97643..ddc0529b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -131,23 +131,35 @@ Manifests: - Tool manifests must export `toolManifest = { ... } satisfies ToolMeta`. - Keep manifest fields literal and statically parseable. - Do not use spreads, computed keys, functions, template literals, dynamic imports, React imports, or client-only imports in manifests. +- Set `networkAccess` when a tool opens external pages, fetches user-provided URLs, or relies on third-party APIs. +- Set `persistInput` deliberately. Sensitive payload tools should use `false`; tools that save payloads should explain that behavior in UI copy. +- Discovery `family`, `tags`, and `capabilities` are generated from manifest metadata and taxonomy rules. Do not hand-edit generated taxonomy fields. ## Adding a Tool Use the scaffolder when possible: ```bash -npm run create:tool +npm run create:tool -- --slug my-new-tool --category formatters ``` +Useful scaffolder flags: + +- `--network-access none|user_requested|third_party_api` +- `--persist-input true|false|opt-in` +- `--pipeline-adapter` to mark that the tool needs a matching adapter design before it should appear as pipeline-ready +- `--search-keywords term1,term2` for additional command palette and discovery matching + The expected shape is: - `src/features/tools/{slug}/manifest.ts` - `src/features/tools/{slug}/page.tsx` +- `src/features/tools/{slug}/logic.ts` and `logic.test.ts` - `src/app/[lang]/{slug}/page.tsx` - Optional feature-local `logic.ts`, `types.ts`, `samples.ts`, `constants.ts`, `browser-actions.ts`, `hooks.ts`, or `components.tsx` - Translation entries in every supported locale - Tests for pure logic and any important UI or routing behavior +- Runtime budgets, external URL validation, and accessibility coverage when the tool parses large payloads, fetches URLs, renders previews, or uses icon-only controls After changing tool manifests, run: diff --git a/docs/specs/pipeline-recipe-builder-technical-design.md b/docs/specs/pipeline-recipe-builder-technical-design.md index d27655c5..bf61a349 100644 --- a/docs/specs/pipeline-recipe-builder-technical-design.md +++ b/docs/specs/pipeline-recipe-builder-technical-design.md @@ -135,6 +135,10 @@ export interface PipelineToolAdapter { version: number inputKind: "text" | "json" | "yaml" | "csv" | "bytes" outputKind: "text" | "json" | "yaml" | "csv" | "bytes" + safeForSensitiveInput: boolean + deterministic: boolean + mayIncreaseSize: boolean + warnings: readonly string[] defaultOptions: Record publicOptionKeys: readonly string[] validateOptions(options: Record): AdapterValidationResult @@ -163,6 +167,10 @@ Adapter rules: - Adapters must not import page components. - Adapters must not call `fetch` except for same-page static assets already used by an existing local tool. - Adapters must not persist payloads. +- Adapters must be deterministic for the same input and options. Non-deterministic generators, canvas/image editing flows, and external-network tools need a separate design before inclusion. +- `safeForSensitiveInput` means the adapter is appropriate for local sensitive payloads; it does not imply output is safe to share unless the adapter redacts or removes sensitive content. +- `mayIncreaseSize` must be true for reversible encoders and pretty-printers that can expand payloads. +- `warnings` must describe persistent adapter-level caveats shown or available to UI surfaces. - Adapters must return structured warnings instead of throwing for expected user errors. - Adapters must have unit tests covering success and failure paths. - `publicOptionKeys` controls which options are allowed into shared recipe URLs. @@ -181,6 +189,16 @@ The current non-public foundation includes only deterministic, low-risk text/dat | `multiple_whitespace_remover` | Simple text normalization. | | `invisible_chars_detector` | Clean copied config/log text before parsing. | | `log_scrubber` | Redact sensitive log content before export. | +| `yaml_json_converter` | Convert YAML snippets to JSON and JSON snippets back to YAML in local data workflows. | +| `csv_json_converter` | Bridge tabular CSV data into JSON and convert JSON arrays back to CSV. | +| `ndjson_formatter` | Convert JSON arrays and newline-delimited JSON records for log/data pipelines. | +| `slugify_case_converter` | Normalize strings into deterministic slug and case formats. | +| `hash_generator` | Produce deterministic text digests for checksum and fixture workflows. | +| `jwt_decoder` | Decode JWT header and payload JSON without signature verification. | +| `unix_timestamp` | Convert Unix seconds or milliseconds into ISO or structured JSON output. | +| `html_to_markdown` | Convert HTML snippets into Markdown text for content cleanup pipelines. | +| `regex_tester` | Produce JSON match summaries for deterministic pattern checks. | +| `env_parser` | Parse `.env` content into JSON, YAML, or docker argument text. | ### Phase 3D Target Expansion / Public MVP Candidates @@ -190,7 +208,6 @@ These remain future candidates and are not part of the current foundation adapte |----------|--------| | `jq_playground` | Existing local JSON transform runtime. | | `yq_playground` | Local yq-like YAML/JSON subset. | -| `yaml_json_converter` | Common bridge between YAML and JSON. | | `local_log_parser` | Parse logs before filtering/export. | | Other deterministic adapters | Add only after each adapter has explicit validation and public option keys. | diff --git a/package.json b/package.json index f8728e59..74f8986c 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "scripts": { "dev": "next dev", "analyze": "ANALYZE=true next build --webpack", - "validate": "npm run check:sw-version && npm run check:sitemap-lastmod && npm run check:security-headers && npm run check:pwa-manifests && npm run check:og-tool-images && npm run check:ia-stability && npm run check:analytics-taxonomy && npm run check:tool-index && npm run check:client-tool-lookup && npm run check:i18n && npm run check:types", + "validate": "npm run check:sw-version && npm run check:sitemap-lastmod && npm run check:security-headers && npm run check:pwa-manifests && npm run check:bundle-boundaries && npm run check:og-tool-images && npm run check:ia-stability && npm run check:analytics-taxonomy && npm run check:registry-manifests && npm run check:tool-index && npm run check:client-tool-lookup && npm run check:i18n && npm run check:types", "build:app": "next build", "build:post": "npm run check:canonical && npm run check:hreflang && npm run check:metadata-localization && npm run check:rendered-i18n-copy && npm run check:related-tools && npm run check:content-template:legacy-paths && npm run check:content-template && npm run check:content-template:quality && npm run check:faq-schema && npm run check:content-template:zh-cn && npm run check:content-template:quality:zh-cn && npm run check:faq-schema:zh-cn && npm run check:content-template:zh-tw && npm run check:content-template:quality:zh-tw && npm run check:faq-schema:zh-tw && npm run check:content-template:ja && npm run check:content-template:quality:ja && npm run check:faq-schema:ja && npm run check:content-template:ko && npm run check:content-template:quality:ko && npm run check:faq-schema:ko && npm run check:content-template:de && npm run check:content-template:quality:de && npm run check:faq-schema:de && npm run check:content-template:fr && npm run check:content-template:quality:fr && npm run check:faq-schema:fr && npm run postprocess:export-html-lang && npm run check:export-html-lang && npm run postprocess:export-robots-meta && npm run check:export-robots-meta && npm run build:sw", "build": "npm run validate && npm run build:app && npm run build:post", @@ -72,9 +72,12 @@ "check:ia-stability": "node scripts/gates/check-ia-stability.js", "check:analytics-taxonomy": "node scripts/gates/check-analytics-taxonomy.js", "check:security-headers": "node scripts/gates/check-security-headers-config.js", + "check:bundle-boundaries": "node scripts/gates/check-bundle-boundaries.js", "check:sw-version": "node scripts/gates/check-sw-version-bump.js", "build:sw": "node scripts/postprocess/inject-sw-build-id.js", "check:types": "tsc --noEmit", + "generate:registry-manifests": "node scripts/generators/generate-registry-manifests.js", + "check:registry-manifests": "node scripts/generators/generate-registry-manifests.js --check", "generate:tool-index": "node scripts/generators/generate-tool-index.js", "check:tool-index": "node scripts/generators/generate-tool-index.js --check", "generate:client-tool-lookup": "node scripts/generators/generate-client-tool-lookup.js", diff --git a/public/sw.js b/public/sw.js index e034e4b2..8f4bdbfd 100644 --- a/public/sw.js +++ b/public/sw.js @@ -8,6 +8,7 @@ const APP_VERSION = '__BUILD_ID__'; const CACHE_NAME = `byteflow-v${APP_VERSION}`; const OFFLINE_FALLBACK_URL = '/offline.html'; +const OFFLINE_FALLBACK_CANDIDATES = [OFFLINE_FALLBACK_URL, '/offline']; const STATIC_ASSETS = [ '/manifest.json', @@ -24,9 +25,18 @@ const STATIC_ASSETS = [ '/icon-maskable-512.png', '/icon.png', '/apple-icon.png', - OFFLINE_FALLBACK_URL, + ...OFFLINE_FALLBACK_CANDIDATES, ]; +function matchOfflineFallback() { + return caches.match(OFFLINE_FALLBACK_URL) + .then((cached) => cached || caches.match('/offline')) + .then((cached) => cached || new Response( + 'Offline | byteflow.tools

You are offline

Reconnect and refresh, or open a page you have visited before.

', + { headers: { 'Content-Type': 'text/html; charset=utf-8' } }, + )); +} + // Install: cache critical static assets; waiting/activation is user-triggered from the app shell. self.addEventListener('install', (event) => { event.waitUntil( @@ -86,7 +96,7 @@ self.addEventListener('fetch', (event) => { return response; }) .catch(() => - caches.match(event.request).then((cached) => cached || caches.match(OFFLINE_FALLBACK_URL)) + caches.match(event.request).then((cached) => cached || matchOfflineFallback()) ) ); return; @@ -118,7 +128,9 @@ self.addEventListener('fetch', (event) => { caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone)); } return response; - }) - .catch(() => caches.match(event.request)) - ); -}); + }) + .catch(() => + caches.match(event.request).then((cached) => cached || matchOfflineFallback()) + ) + ); +}); diff --git a/scripts/e2e/run-playwright-smoke.js b/scripts/e2e/run-playwright-smoke.js index 71e3e97f..f11800d9 100644 --- a/scripts/e2e/run-playwright-smoke.js +++ b/scripts/e2e/run-playwright-smoke.js @@ -14,6 +14,9 @@ const DEFAULT_ROUTES = [ "/zh-CN/json-formatter", "/en/javascript-formatter", "/zh-CN/javascript-formatter", + "/en/base64-encode-decode", + "/en/pipeline-builder", + "/en/csv-json-converter", ]; function parseArgs(argv) { @@ -21,6 +24,7 @@ function parseArgs(argv) { port: DEFAULT_PORT, baseUrl: "", skipServer: false, + includePwa: false, }; for (const arg of argv) { @@ -29,6 +33,11 @@ function parseArgs(argv) { continue; } + if (arg === "--pwa") { + args.includePwa = true; + continue; + } + if (arg.startsWith("--port=")) { const parsed = Number(arg.slice("--port=".length)); if (Number.isFinite(parsed) && parsed > 0) { @@ -271,6 +280,238 @@ async function assertInputProcessCopyJourney(context, baseUrl, locale) { await page.close(); } +async function waitForAriaHiddenFocusablesToSettle(page, routeLabel) { + await page.waitForFunction(() => { + const isHiddenByStyle = (node) => { + const style = window.getComputedStyle(node); + const rect = node.getBoundingClientRect(); + return ( + style.display === "none" || + style.visibility === "hidden" || + Number(style.opacity) === 0 || + (rect.width === 0 && rect.height === 0) + ); + }; + + const shouldSkipInteractive = (node) => { + const ariaHidden = node.getAttribute("aria-hidden") === "true"; + const disabled = node.hasAttribute("disabled") || node.getAttribute("aria-disabled") === "true"; + const hiddenInput = node instanceof HTMLInputElement && node.type === "hidden"; + return ariaHidden || disabled || hiddenInput || isHiddenByStyle(node); + }; + + const hiddenFocusables = Array.from(document.querySelectorAll("[aria-hidden='true'] button, [aria-hidden='true'] a[href], [aria-hidden='true'] input, [aria-hidden='true'] textarea")) + .filter((element) => !shouldSkipInteractive(element)); + + return hiddenFocusables.length === 0; + }, null, { timeout: 5_000 }).catch(() => { + throw new Error(`Timed out waiting for aria-hidden focus guards to settle for ${routeLabel}.`); + }); +} + +async function assertBasicAccessibility(page, routeLabel) { + await waitForAriaHiddenFocusablesToSettle(page, routeLabel); + + const violations = await page.evaluate(() => { + const issues = []; + const interactiveSelectors = [ + "button", + "a[href]", + "input", + "select", + "textarea", + "[role='button']", + "[role='menuitem']", + "[role='option']", + ]; + + const isHiddenByStyle = (node) => { + const style = window.getComputedStyle(node); + const rect = node.getBoundingClientRect(); + return ( + style.display === "none" || + style.visibility === "hidden" || + Number(style.opacity) === 0 || + (rect.width === 0 && rect.height === 0) + ); + }; + + const shouldSkipInteractive = (node) => { + const ariaHidden = node.getAttribute("aria-hidden") === "true"; + const disabled = node.hasAttribute("disabled") || node.getAttribute("aria-disabled") === "true"; + const hiddenInput = node instanceof HTMLInputElement && node.type === "hidden"; + return ariaHidden || disabled || hiddenInput || isHiddenByStyle(node); + }; + + for (const element of document.querySelectorAll(interactiveSelectors.join(","))) { + const node = element; + if (shouldSkipInteractive(node)) continue; + + const tag = node.tagName.toLowerCase(); + const role = node.getAttribute("role") || tag; + const text = (node.textContent || "").trim(); + const labelFor = + node.id && typeof CSS !== "undefined" && typeof CSS.escape === "function" + ? document.querySelector(`label[for="${CSS.escape(node.id)}"]`)?.textContent?.trim() + : ""; + const wrappingLabel = node.closest("label")?.textContent?.trim(); + const placeholder = "placeholder" in node ? node.getAttribute("placeholder") : ""; + const label = + node.getAttribute("aria-label") || + node.getAttribute("aria-labelledby") || + node.getAttribute("title") || + labelFor || + wrappingLabel || + placeholder || + text; + if (!label) { + issues.push(`${role} lacks an accessible name`); + } + } + + const hiddenFocusables = Array.from(document.querySelectorAll("[aria-hidden='true'] button, [aria-hidden='true'] a[href], [aria-hidden='true'] input, [aria-hidden='true'] textarea")) + .filter((element) => !shouldSkipInteractive(element)); + if (hiddenFocusables.length > 0) { + issues.push(`${hiddenFocusables.length} focusable element(s) are inside aria-hidden content`); + } + + return issues; + }); + + if (violations.length > 0) { + throw new Error(`Accessibility smoke failed for ${routeLabel}:\n- ${violations.join("\n- ")}`); + } +} + +async function assertBase64PipelineHandoffJourney(context, baseUrl) { + const page = await context.newPage(); + const runtimeErrors = []; + page.on("pageerror", (error) => runtimeErrors.push(error.message)); + + await page.goto(`${baseUrl}/en/base64-encode-decode`, { waitUntil: "domcontentloaded" }); + await page.waitForSelector("main", { timeout: 15_000 }); + + const input = page.locator("textarea").first(); + await input.waitFor({ state: "visible", timeout: 15_000 }); + await input.fill("hello pipeline"); + + await page.getByRole("button", { name: /^Encode Base64$/ }).first().click(); + const output = page.locator("textarea").nth(1); + await output.waitFor({ state: "visible", timeout: 15_000 }); + await page.waitForFunction(() => { + const textareas = Array.from(document.querySelectorAll("textarea")); + return textareas.some((node) => node.value.includes("aGVsbG8gcGlwZWxpbmU=")); + }, null, { timeout: 15_000 }); + + const handoffMenu = page.getByRole("button", { name: /Send to/i }).first(); + await handoffMenu.waitFor({ state: "visible", timeout: 15_000 }); + await handoffMenu.click(); + + const pipelineLink = page.locator('a[data-analytics-id="to_pipeline_builder"]').first(); + await pipelineLink.waitFor({ state: "visible", timeout: 15_000 }); + await Promise.all([ + page.waitForURL((url) => url.pathname === "/en/pipeline-builder", { timeout: 15_000 }), + pipelineLink.click(), + ]); + + await page.waitForSelector("main", { timeout: 15_000 }); + await expectTextareaValue(page, /aGVsbG8gcGlwZWxpbmU=/, "pipeline handoff initial input"); + await assertBasicAccessibility(page, "/en/pipeline-builder handoff"); + + if (runtimeErrors.length > 0) { + throw new Error(`Base64 -> Pipeline Builder handoff triggered runtime errors:\n- ${runtimeErrors.join("\n- ")}`); + } + + await page.close(); +} + +async function expectTextareaValue(page, pattern, label) { + await page.waitForFunction((source) => { + const regex = new RegExp(source); + return Array.from(document.querySelectorAll("textarea")).some((node) => regex.test(node.value)); + }, pattern.source, { timeout: 15_000 }).catch(() => { + throw new Error(`Expected textarea value matching ${pattern} for ${label}.`); + }); +} + +async function assertPipelineRecipeJourney(context, baseUrl) { + const page = await context.newPage(); + const runtimeErrors = []; + page.on("pageerror", (error) => runtimeErrors.push(error.message)); + + await page.goto(`${baseUrl}/en/pipeline-builder`, { waitUntil: "domcontentloaded" }); + await page.waitForSelector("main", { timeout: 15_000 }); + + await page.getByRole("button", { name: /Try Example/i }).first().click(); + await page.getByRole("button", { name: /Run Recipe/i }).first().click(); + await page.waitForFunction(() => { + const readonlyOutput = Array.from(document.querySelectorAll("textarea")).find((node) => node.readOnly); + return Boolean(readonlyOutput?.value.trim()) && document.body.innerText.includes("OK"); + }, null, { timeout: 15_000 }); + await assertBasicAccessibility(page, "/en/pipeline-builder recipe"); + + if (runtimeErrors.length > 0) { + throw new Error(`Pipeline recipe journey triggered runtime errors:\n- ${runtimeErrors.join("\n- ")}`); + } + + await page.close(); +} + +async function assertMonacoFallbackJourney(context, baseUrl) { + const page = await context.newPage(); + const runtimeErrors = []; + page.on("pageerror", (error) => runtimeErrors.push(error.message)); + + await page.goto(`${baseUrl}/en/csv-json-converter`, { waitUntil: "domcontentloaded" }); + await page.waitForSelector("main", { timeout: 15_000 }); + + const textareas = page.locator("textarea"); + await textareas.first().waitFor({ state: "visible", timeout: 15_000 }); + await textareas.first().fill("id,name\n1,Ada"); + await page.getByRole("button", { name: /^Convert$/ }).first().click(); + await expectTextareaValue(page, /"name": "Ada"/, "csv-json converter output"); + await assertBasicAccessibility(page, "/en/csv-json-converter"); + + if (runtimeErrors.length > 0) { + throw new Error(`Monaco fallback journey triggered runtime errors:\n- ${runtimeErrors.join("\n- ")}`); + } + + await page.close(); +} + +async function assertMobileCommandPaletteJourney(browser, baseUrl) { + const context = await browser.newContext({ + serviceWorkers: "block", + viewport: { width: 390, height: 844 }, + isMobile: true, + }); + const page = await context.newPage(); + const runtimeErrors = []; + page.on("pageerror", (error) => runtimeErrors.push(error.message)); + + try { + await page.goto(`${baseUrl}/en`, { waitUntil: "domcontentloaded" }); + await page.waitForSelector("main", { timeout: 15_000 }); + const commandInput = await openCommandPalette(page); + await commandInput.fill("base64"); + const base64Item = page.locator('[data-slot="command-item"]').filter({ hasText: /Base64/i }).first(); + await base64Item.waitFor({ state: "visible", timeout: 15_000 }); + await Promise.all([ + page.waitForURL(`${baseUrl}/en/base64-encode-decode`, { timeout: 15_000 }), + base64Item.click(), + ]); + await page.waitForSelector("main", { timeout: 15_000 }); + await assertBasicAccessibility(page, "/en mobile command palette"); + + if (runtimeErrors.length > 0) { + throw new Error(`Mobile command palette journey triggered runtime errors:\n- ${runtimeErrors.join("\n- ")}`); + } + } finally { + await page.close(); + await context.close(); + } +} + async function assertLocaleSwitchJourney(context, baseUrl) { const page = await context.newPage(); const runtimeErrors = []; @@ -302,6 +543,121 @@ async function assertLocaleSwitchJourney(context, baseUrl) { await page.close(); } +async function assertPwaShellJourney(browser, baseUrl, goOffline) { + const context = await browser.newContext({ serviceWorkers: "allow" }); + const page = await context.newPage(); + const runtimeErrors = []; + let contextOffline = false; + page.on("pageerror", (error) => runtimeErrors.push(error.message)); + + try { + await page.goto(`${baseUrl}/en`, { waitUntil: "networkidle" }); + await page.waitForSelector("main", { timeout: 15_000 }); + + const manifestHref = await page.locator('link[rel="manifest"]').first().getAttribute("href"); + if (manifestHref !== "/manifest.json") { + throw new Error(`Expected default manifest link to be /manifest.json, found ${manifestHref || "none"}`); + } + + const registration = await page.evaluate(async () => { + if (!("serviceWorker" in navigator)) return null; + const ready = await navigator.serviceWorker.ready; + return { + scope: ready.scope, + activeScriptUrl: ready.active?.scriptURL || "", + }; + }); + if (!registration?.activeScriptUrl.endsWith("/sw.js")) { + throw new Error("Service worker did not become active for the PWA shell."); + } + + const waitForController = async () => { + const deadline = Date.now() + 15_000; + + while (Date.now() < deadline) { + try { + const isControlled = await page.evaluate(() => + "serviceWorker" in navigator && Boolean(navigator.serviceWorker.controller), + ); + if (isControlled) return true; + } catch { + // The app reloads on controllerchange; retry after navigation settles. + await page.waitForLoadState("domcontentloaded").catch(() => {}); + } + + await wait(250); + } + + return false; + }; + + let isControlled = await waitForController(); + if (!isControlled) { + await page.reload({ waitUntil: "networkidle" }); + isControlled = await waitForController(); + } + if (!isControlled) { + throw new Error("Service worker is active but did not control the PWA smoke page."); + } + + await page.goto(`${baseUrl}/en/json-formatter`, { waitUntil: "networkidle" }); + await page.waitForSelector("main", { timeout: 15_000 }); + isControlled = await waitForController(); + if (!isControlled) { + throw new Error("Service worker stopped controlling the PWA smoke page before offline navigation."); + } + + if (goOffline) { + await goOffline(); + } else { + await context.setOffline(true); + contextOffline = true; + } + const offlineResult = await page.evaluate(async (targetUrl) => { + try { + const response = await fetch(targetUrl, { + headers: { accept: "text/html" }, + }); + const bodyText = await response.text(); + return { + ok: response.ok, + status: response.status, + bodyText, + error: "", + }; + } catch (error) { + return { + ok: false, + status: 0, + bodyText: "", + error: error instanceof Error ? error.message : String(error), + }; + } + }, `${baseUrl}/en/not-cached-for-smoke-${Date.now()}`); + if (!offlineResult.ok || !/offline/i.test(offlineResult.bodyText)) { + throw new Error(`Offline fetch did not render the cached offline fallback. Status: ${offlineResult.status}; error: ${offlineResult.error || "none"}`); + } + + await page.setContent(offlineResult.bodyText, { + waitUntil: "domcontentloaded", + }); + const bodyText = await page.locator("body").innerText({ timeout: 15_000 }); + if (!/offline/i.test(bodyText)) { + throw new Error("Offline navigation did not render the cached offline fallback."); + } + + if (runtimeErrors.length > 0) { + throw new Error(`PWA smoke triggered runtime errors:\n- ${runtimeErrors.join("\n- ")}`); + } + } finally { + if (contextOffline) { + await context.setOffline(false).catch(() => {}); + } + await page.close(); + await context.close(); + } +} + async function runSmoke(baseUrl) { const browser = await chromium.launch({ headless: true }); const context = await browser.newContext({ serviceWorkers: "block" }); @@ -326,16 +682,38 @@ async function runSmoke(baseUrl) { await assertInputProcessCopyJourney(context, baseUrl, "en"); console.log("[playwright-smoke] PASS journey: /en/list-randomizer input -> process -> copy"); + await assertBase64PipelineHandoffJourney(context, baseUrl); + console.log("[playwright-smoke] PASS journey: /en/base64-encode-decode -> /en/pipeline-builder handoff"); + + await assertPipelineRecipeJourney(context, baseUrl); + console.log("[playwright-smoke] PASS journey: /en/pipeline-builder template -> run"); + + await assertMonacoFallbackJourney(context, baseUrl); + console.log("[playwright-smoke] PASS journey: /en/csv-json-converter Monaco fallback -> convert"); + await assertLocaleSwitchJourney(context, baseUrl); console.log("[playwright-smoke] PASS journey: locale switch /en/json-formatter -> /zh-CN/json-formatter"); + + await assertMobileCommandPaletteJourney(browser, baseUrl); + console.log("[playwright-smoke] PASS mobile journey: command palette -> /en/base64-encode-decode"); } finally { await context.close(); await browser.close(); } } +async function runPwaSmoke(baseUrl, goOffline) { + const browser = await chromium.launch({ headless: true }); + try { + await assertPwaShellJourney(browser, baseUrl, goOffline); + console.log("[playwright-smoke] PASS pwa: service worker, manifest, and offline fallback"); + } finally { + await browser.close(); + } +} + async function main() { - const { baseUrl, port, skipServer } = parseArgs(process.argv.slice(2)); + const { baseUrl, port, skipServer, includePwa } = parseArgs(process.argv.slice(2)); let serverHandle = null; try { @@ -346,6 +724,17 @@ async function main() { } await runSmoke(baseUrl); + if (includePwa) { + await runPwaSmoke( + baseUrl, + serverHandle + ? async () => { + await stopServer(serverHandle.server); + serverHandle = null; + } + : null, + ); + } console.log("[playwright-smoke] PASS: critical routes render and navigate correctly"); } catch (error) { console.error("[playwright-smoke] FAILED"); diff --git a/scripts/gates/check-bundle-boundaries.js b/scripts/gates/check-bundle-boundaries.js new file mode 100644 index 00000000..ed32839c --- /dev/null +++ b/scripts/gates/check-bundle-boundaries.js @@ -0,0 +1,175 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; + +const ROOT = process.cwd(); +const SOURCE_ROOT = path.join(ROOT, "src"); +const TEXT_FILE_PATTERN = /\.(ts|tsx|js|jsx)$/; +const IGNORE_DIRS = new Set([".next", "node_modules", "out", "output"]); + +const HEAVY_DEPENDENCY_RULES = [ + { + packageName: "@monaco-editor/react", + description: "Monaco must stay dynamically loaded by the shared editor wrapper.", + allowedFiles: ["src/features/tool-shell/monaco-editors.tsx"], + requireDynamicRuntimeImport: true, + }, + { + packageName: "monaco-editor", + description: "Monaco core must stay dynamically loaded by the shared editor wrapper.", + allowedFiles: ["src/features/tool-shell/monaco-editors.tsx"], + requireDynamicRuntimeImport: true, + }, + { + packageName: "jq-wasm", + description: "jq-wasm must stay in the jq playground browser action chunk.", + allowedFiles: ["src/features/tools/jq-playground/browser-actions.ts"], + requireDynamicRuntimeImport: true, + }, + { + packageName: "pdf-lib", + description: "pdf-lib must stay lazy-loaded by the scanned PDF tool.", + allowedFiles: ["src/features/tools/scanned-pdf-converter/page.tsx"], + requireDynamicRuntimeImport: true, + }, + { + packageName: "qrcode", + description: "qrcode must stay lazy-loaded by QR browser actions.", + allowedFiles: ["src/features/tools/qr-code-generator/browser-actions.ts"], + requireDynamicRuntimeImport: true, + }, + { + packageName: "react-markdown", + description: "react-markdown must stay isolated to the markdown renderer template.", + allowedFiles: ["src/features/tool-templates/markdown-preview-renderer.tsx"], + }, + { + packageName: "remark-gfm", + description: "remark-gfm must stay isolated to the markdown renderer template.", + allowedFiles: ["src/features/tool-templates/markdown-preview-renderer.tsx"], + }, + { + packageName: "pdf-lib", + description: "pdf-lib must not be imported from shared shell/core code.", + disallowedPathPrefixes: ["src/app/", "src/core/", "src/features/tool-shell/"], + }, + { + packageName: "jq-wasm", + description: "jq-wasm must not be imported from shared shell/core code.", + disallowedPathPrefixes: ["src/app/", "src/core/", "src/features/tool-shell/"], + }, + { + packageName: "qrcode", + description: "qrcode must not be imported from shared shell/core code.", + disallowedPathPrefixes: ["src/app/", "src/core/", "src/features/tool-shell/"], + }, + { + packageName: "react-markdown", + description: "markdown rendering must not be imported from shared shell/core code.", + disallowedPathPrefixes: ["src/app/", "src/core/", "src/features/tool-shell/"], + }, + { + packageName: "remark-gfm", + description: "markdown rendering must not be imported from shared shell/core code.", + disallowedPathPrefixes: ["src/app/", "src/core/", "src/features/tool-shell/"], + }, +]; + +function walk(dir) { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + const files = []; + + for (const entry of entries) { + if (IGNORE_DIRS.has(entry.name)) continue; + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...walk(fullPath)); + continue; + } + if (entry.isFile() && TEXT_FILE_PATTERN.test(entry.name)) { + files.push(fullPath); + } + } + + return files; +} + +function toRelative(file) { + return path.relative(ROOT, file).replace(/\\/g, "/"); +} + +function stripTypeOnlyImports(source) { + return source + .replace(/^\s*import\s+type\s+[^;]+;?\s*$/gm, "") + .replace(/^\s*import\s+\{[^}]*\btype\b[^}]*\}\s+from\s+["'][^"']+["'];?\s*$/gm, "") + .replace(/\btypeof\s+import\(\s*["'][^"']+["']\s*\)/g, "unknown"); +} + +function dependencyUsedAtRuntime(source, packageName) { + const runtimeSource = stripTypeOnlyImports(source); + const escaped = packageName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const staticImport = new RegExp(`\\bimport\\s+(?!type\\b)[\\s\\S]*?from\\s+["']${escaped}["']`); + const sideEffectImport = new RegExp(`\\bimport\\s+["']${escaped}["']`); + const dynamicImport = new RegExp(`\\bimport\\(\\s*["']${escaped}["']\\s*\\)`); + const requireCall = new RegExp(`\\brequire\\(\\s*["']${escaped}["']\\s*\\)`); + return staticImport.test(runtimeSource) || sideEffectImport.test(runtimeSource) || dynamicImport.test(runtimeSource) || requireCall.test(runtimeSource); +} + +function dependencyUsedStatically(source, packageName) { + const runtimeSource = stripTypeOnlyImports(source); + const escaped = packageName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const staticImportLine = new RegExp(`^\\s*import\\s+(?!type\\b).*?(?:from\\s+["']${escaped}["']|["']${escaped}["'])`); + const requireCallLine = new RegExp(`\\brequire\\(\\s*["']${escaped}["']\\s*\\)`); + return runtimeSource.split(/\r?\n/).some((line) => staticImportLine.test(line) || requireCallLine.test(line)); +} + +function dependencyUsedDynamically(source, packageName) { + const runtimeSource = stripTypeOnlyImports(source); + const escaped = packageName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`\\bimport\\(\\s*["']${escaped}["']\\s*\\)`).test(runtimeSource); +} + +const files = walk(SOURCE_ROOT).map((file) => ({ + relativePath: toRelative(file), + source: fs.readFileSync(file, "utf8"), +})); + +const failures = []; + +for (const rule of HEAVY_DEPENDENCY_RULES) { + const hits = files.filter((file) => dependencyUsedAtRuntime(file.source, rule.packageName)); + + if (rule.allowedFiles) { + const allowed = new Set(rule.allowedFiles); + const unexpected = hits.filter((file) => !allowed.has(file.relativePath)); + for (const file of unexpected) { + failures.push(`${file.relativePath}: unexpected ${rule.packageName} import. ${rule.description}`); + } + } + + if (rule.disallowedPathPrefixes) { + const disallowed = hits.filter((file) => rule.disallowedPathPrefixes.some((prefix) => file.relativePath.startsWith(prefix))); + for (const file of disallowed) { + failures.push(`${file.relativePath}: disallowed ${rule.packageName} import. ${rule.description}`); + } + } + + if (rule.requireDynamicRuntimeImport) { + for (const file of hits) { + if (dependencyUsedStatically(file.source, rule.packageName) || !dependencyUsedDynamically(file.source, rule.packageName)) { + failures.push(`${file.relativePath}: ${rule.packageName} must be loaded with import(...). ${rule.description}`); + } + } + } +} + +if (failures.length > 0) { + console.error(`[check:bundle-boundaries] FAILED: ${failures.length} bundle boundary violation(s).`); + for (const failure of failures) { + console.error(`- ${failure}`); + } + process.exit(1); +} + +console.log("[check:bundle-boundaries] OK: heavy dependencies stay behind approved lazy boundaries."); diff --git a/scripts/gates/check-security-headers-config.js b/scripts/gates/check-security-headers-config.js index 057843b6..2afd8130 100644 --- a/scripts/gates/check-security-headers-config.js +++ b/scripts/gates/check-security-headers-config.js @@ -2,6 +2,7 @@ import fs from "node:fs" import path from "node:path" const CONFIG_PATH = path.join(process.cwd(), "public", "_headers") +const INLINE_SCRIPT_POLICY_PATH = path.join(process.cwd(), "src", "core", "security", "inline-script-policy.ts") const REQUIRED_HEADERS = { "content-security-policy": { requiredTokens: [ @@ -49,6 +50,23 @@ function normalizeHeaderEntries(entries) { return headerMap } +function inlineScriptPolicyRequiresUnsafeInline() { + if (!fs.existsSync(INLINE_SCRIPT_POLICY_PATH)) { + fail(`Missing inline script policy: ${path.relative(process.cwd(), INLINE_SCRIPT_POLICY_PATH)}`) + } + + const source = fs.readFileSync(INLINE_SCRIPT_POLICY_PATH, "utf8") + const trueCount = (source.match(/requiresUnsafeInline:\s*true/g) || []).length + const falseCount = (source.match(/requiresUnsafeInline:\s*false/g) || []).length + if (trueCount + falseCount === 0) { + fail("Inline script policy does not declare any requiresUnsafeInline entries") + } + if (!source.includes("migrationPath:")) { + fail("Inline script policy entries must include migrationPath rationale") + } + return trueCount > 0 +} + function parseCloudflareHeadersConfig(fileContent) { const rules = [] let currentRule = null @@ -115,6 +133,7 @@ function main() { const headerMap = normalizeHeaderEntries(globalRule.headers) const failures = [] + const cspValue = headerMap.get("content-security-policy") || "" for (const [headerName, requirement] of Object.entries(REQUIRED_HEADERS)) { const value = headerMap.get(headerName) @@ -136,6 +155,14 @@ function main() { } } + const hasUnsafeInlineScript = /(?:^|;)\s*script-src\b[^;]*'unsafe-inline'/.test(cspValue) + if (hasUnsafeInlineScript && !inlineScriptPolicyRequiresUnsafeInline()) { + failures.push("content-security-policy: script-src contains 'unsafe-inline' but inline policy has no active rationale") + } + if (!hasUnsafeInlineScript && inlineScriptPolicyRequiresUnsafeInline()) { + failures.push("content-security-policy: inline script policy still requires 'unsafe-inline' but script-src does not include it") + } + if (failures.length > 0) { console.error("[check:security-headers] FAILED:") for (const issue of failures) { diff --git a/scripts/generators/generate-client-tool-lookup.js b/scripts/generators/generate-client-tool-lookup.js index 0c64435a..362aebd3 100644 --- a/scripts/generators/generate-client-tool-lookup.js +++ b/scripts/generators/generate-client-tool-lookup.js @@ -13,6 +13,123 @@ const TOOL_INDEX_PATH = path.join(ROOT, "src/generated/tool-index.json") const MENU_GROUP_DEF_RE = /{ key: "([^"]+)", navKey: "([^"]+)", slug: "([^"]+)", descriptionKey: "([^"]+)" }/g const KEY_VALUE_RE = /^\s*([a-z0-9_]+):\s*"([^"]+)",?\s*$/gm +const FAMILY_BY_TOOL_KEY = { + ai_color_palette_generator: "images-media", + asn1_der_inspector: "security-tokens", + barcode_generator: "generators", + base64_encode_decode: "encoders-decoders", + certificate_decoder: "security-tokens", + chmod_calculator: "devops-logs", + cidr_subnet_calculator: "network-http", + code_to_image_converter: "images-media", + color_converter: "svg-css-visual", + color_mixer: "svg-css-visual", + color_shades_generator: "svg-css-visual", + cron_visualizer: "devops-logs", + crontab_generator: "devops-logs", + csp_parser: "security-tokens", + csv_diff: "data-formats", + csv_json_converter: "data-formats", + curl_to_code: "network-http", + docker_run_to_compose: "devops-logs", + env_parser: "devops-logs", + fake_iban_generator: "generators", + google_fonts_pair_finder: "svg-css-visual", + gzip_brotli_lab: "encoders-decoders", + har_viewer_sanitizer: "network-http", + hash_generator: "security-tokens", + header_diff: "network-http", + hex_bytes_workbench: "encoders-decoders", + html_encoder_decoder: "encoders-decoders", + html_to_markdown: "text-strings", + http_request_builder: "network-http", + http_status_codes: "network-http", + id_generator: "generators", + image_average_color_finder: "images-media", + image_base64: "encoders-decoders", + image_caption_generator: "images-media", + image_color_extractor: "images-media", + image_color_picker: "images-media", + image_cropper: "images-media", + image_filters: "images-media", + image_resizer: "images-media", + instagram_filters: "social-metadata", + instagram_photo_downloader: "social-metadata", + instagram_post_generator: "social-metadata", + instagram_story_generator: "social-metadata", + invisible_chars_detector: "text-strings", + jq_playground: "data-formats", + json_diff_viewer: "data-formats", + json_formatter: "data-formats", + json_to_typescript: "data-formats", + jsonpath_playground: "data-formats", + jwt_decoder: "security-tokens", + jwt_verifier: "security-tokens", + jwt_workbench: "security-tokens", + list_randomizer: "generators", + local_log_parser: "devops-logs", + log_scrubber: "devops-logs", + markdown_preview: "text-strings", + md5_generator: "security-tokens", + ndjson_formatter: "data-formats", + open_graph_meta_generator: "social-metadata", + openapi_mock: "network-http", + openapi_viewer: "network-http", + password_generator: "generators", + photo_censor: "images-media", + pipeline_builder: "workbench-pipeline", + qr_code_generator: "generators", + react_native_shadow_generator: "svg-css-visual", + regex_generator: "text-strings", + regex_tester: "text-strings", + robots_txt_tester: "network-http", + saml_decoder: "security-tokens", + scanned_pdf_converter: "images-media", + security_header_analyzer: "security-tokens", + slugify_case_converter: "text-strings", + structured_data_visualizer: "data-formats", + svg_blob_generator: "svg-css-visual", + svg_optimizer: "svg-css-visual", + svg_pattern_generator: "svg-css-visual", + svg_stroke_to_fill_converter: "svg-css-visual", + svg_to_png_converter: "svg-css-visual", + text_diff_checker: "text-strings", + text_to_handwriting_converter: "images-media", + totp_generator: "security-tokens", + tweet_generator: "social-metadata", + tweet_to_image_converter: "social-metadata", + twitter_ad_revenue_generator: "social-metadata", + unicode_inspector: "text-strings", + unix_timestamp: "generators", + url_encode_decode: "encoders-decoders", + url_parser: "network-http", + user_agent_parser: "network-http", + uuid_generator: "generators", + vimeo_thumbnail_grabber: "social-metadata", + yaml_json_converter: "data-formats", + yaml_merge_patch_explorer: "data-formats", + youtube_thumbnail_grabber: "social-metadata", + yq_playground: "data-formats", +} + +const PIPELINE_READY_TOOL_KEYS = new Set([ + "base64_encode_decode", + "csv_json_converter", + "env_parser", + "hash_generator", + "html_to_markdown", + "invisible_chars_detector", + "json_formatter", + "jwt_decoder", + "log_scrubber", + "multiple_whitespace_remover", + "ndjson_formatter", + "regex_tester", + "slugify_case_converter", + "unix_timestamp", + "url_encode_decode", + "yaml_json_converter", +]) function parseMenuGroups() { const source = fs.readFileSync(MENU_GROUPS_PATH, "utf8") @@ -48,6 +165,71 @@ function classifyToolToMenuGroup(tool, overrides) { return "text_content" } +function fallbackFamily(tool) { + if (tool.category === "formatters") return "formatters-validators" + if (tool.category === "generators") return "generators" + if (tool.category === "network-web") return "network-http" + return "text-strings" +} + +function inferKeywordTags(tool) { + const source = [tool.key, tool.slug, ...tool.keywords, ...(tool.searchKeywords || [])].join(" ").toLowerCase() + const tags = new Set() + const addWhen = (tag, patterns) => { + if (patterns.some((pattern) => source.includes(pattern))) tags.add(tag) + } + + addWhen("json", ["json", "jq"]) + addWhen("yaml", ["yaml", "yq"]) + addWhen("csv", ["csv"]) + addWhen("xml", ["xml", "saml"]) + addWhen("html", ["html"]) + addWhen("css", ["css"]) + addWhen("svg", ["svg"]) + addWhen("markdown", ["markdown"]) + addWhen("base64", ["base64"]) + addWhen("url", ["url", "uri"]) + addWhen("jwt", ["jwt"]) + addWhen("hash", ["hash", "checksum", "digest", "md5", "sha"]) + addWhen("http", ["http", "header", "curl", "openapi", "request"]) + addWhen("regex", ["regex", "regexp"]) + addWhen("image", ["image", "photo", "png", "jpeg", "webp"]) + addWhen("color", ["color", "palette", "gradient"]) + addWhen("logs", ["log", "har"]) + addWhen("security", ["security", "token", "certificate", "totp", "secret", "saml", "asn.1", "asn1"]) + + return [...tags].sort() +} + +function uniqueSorted(values) { + return [...new Set(values)].sort((a, b) => a.localeCompare(b)) +} + +function getToolTaxonomy(tool) { + const networkAccess = tool.networkAccess || "none" + const family = FAMILY_BY_TOOL_KEY[tool.key] || fallbackFamily(tool) + const tags = uniqueSorted([family, ...inferKeywordTags(tool)]) + const capabilities = ["browser-local", "offline-capable"] + + if (networkAccess !== "none") capabilities.push("external-request") + if (tool.persistInput === false || family === "security-tokens" || family === "devops-logs") { + capabilities.push("sensitive-input") + } + if (PIPELINE_READY_TOOL_KEYS.has(tool.key)) capabilities.push("pipeline-ready") + if (["data-formats", "images-media", "devops-logs", "workbench-pipeline"].includes(family)) { + capabilities.push("file-input") + } + if (["images-media", "svg-css-visual", "social-metadata"].includes(family)) { + capabilities.push("visual-output") + } + + return { + family, + tags, + capabilities: uniqueSorted(capabilities), + } +} + function loadAliases() { if (!fs.existsSync(TOOL_INDEX_PATH)) return new Map() const index = JSON.parse(fs.readFileSync(TOOL_INDEX_PATH, "utf8")) @@ -60,12 +242,18 @@ function buildClientLookupSource() { const byKey = Object.fromEntries( orderedTools.map((tool) => { + const taxonomy = getToolTaxonomy(tool) const entry = { key: tool.key, slug: tool.slug, keywords: tool.keywords, aliases: aliasesByKey.get(tool.key) || [], relatedToolKeys: tool.relatedTools, + networkAccess: tool.networkAccess || "none", + persistInput: tool.persistInput ?? null, + family: taxonomy.family, + tags: taxonomy.tags, + capabilities: taxonomy.capabilities, } if (tool.searchKeywords) { entry.searchKeywords = tool.searchKeywords @@ -96,6 +284,11 @@ export type ClientToolLookupEntry = { keywords: readonly string[] aliases: readonly string[] relatedToolKeys: readonly string[] + networkAccess: "none" | "user_requested" | "third_party_api" + persistInput: true | false | "opt-in" | null + family: string + tags: readonly string[] + capabilities: readonly string[] searchKeywords?: readonly string[] } diff --git a/scripts/generators/generate-registry-manifests.js b/scripts/generators/generate-registry-manifests.js new file mode 100644 index 00000000..7b527dc1 --- /dev/null +++ b/scripts/generators/generate-registry-manifests.js @@ -0,0 +1,78 @@ +#!/usr/bin/env node + +import fs from "node:fs" +import path from "node:path" +import { fileURLToPath } from "node:url" +import { + listManifestFiles, + loadToolManifestOrder, + parseToolManifestFile, + TOOL_MANIFESTS_PATH, + TOOL_ORDER_PATH, +} from "../lib/tool-manifest-lib.js" + +const CHECK_ONLY = process.argv.includes("--check") + +function toRepoPath(filePath) { + return path.relative(process.cwd(), filePath).replace(/\\/g, "/") +} + +function slugToManifestIdentifier(slug) { + return `${slug.replace(/-([a-z0-9])/g, (_, char) => char.toUpperCase()).replace(/^[0-9]/, (char) => `_${char}`)}Manifest` +} + +function buildRegistryManifestSource() { + const manifestsBySlug = new Map(listManifestFiles().map((manifestPath) => { + const manifest = parseToolManifestFile(manifestPath) + return [manifest.slug, manifest] + })) + const order = loadToolManifestOrder() + const duplicateOrderSlugs = order.filter((slug, index) => order.indexOf(slug) !== index) + const missingManifestSlugs = order.filter((slug) => !manifestsBySlug.has(slug)) + const orderedSlugSet = new Set(order) + const unorderedManifestSlugs = [...manifestsBySlug.keys()].filter((slug) => !orderedSlugSet.has(slug)).sort() + + if (duplicateOrderSlugs.length > 0 || missingManifestSlugs.length > 0 || unorderedManifestSlugs.length > 0) { + const problems = [] + if (duplicateOrderSlugs.length > 0) problems.push(`duplicate slugs in ${toRepoPath(TOOL_ORDER_PATH)}: ${[...new Set(duplicateOrderSlugs)].join(", ")}`) + if (missingManifestSlugs.length > 0) problems.push(`ordered slugs without manifests: ${missingManifestSlugs.join(", ")}`) + if (unorderedManifestSlugs.length > 0) problems.push(`manifest slugs missing from ${toRepoPath(TOOL_ORDER_PATH)}: ${unorderedManifestSlugs.join(", ")}`) + throw new Error(`[generate:registry-manifests] ${problems.join("; ")}`) + } + + const imports = order.map((slug) => { + const identifier = slugToManifestIdentifier(slug) + return `import { toolManifest as ${identifier} } from "@/features/tools/${slug}/manifest"` + }) + const entries = order.map((slug) => ` ${slugToManifestIdentifier(slug)},`) + + return `${imports.join("\n")}\nimport type { ToolMeta } from "./types"\n\nexport const TOOL_MANIFESTS = [\n${entries.join("\n")}\n] satisfies ToolMeta[]\n` +} + +function runCheck(expectedSource) { + const currentSource = fs.existsSync(TOOL_MANIFESTS_PATH) + ? fs.readFileSync(TOOL_MANIFESTS_PATH, "utf8") + : "" + + if (currentSource !== expectedSource) { + console.error(`[check:registry-manifests] FAILED: ${toRepoPath(TOOL_MANIFESTS_PATH)} is stale. Run npm run generate:registry-manifests.`) + process.exit(1) + } + + console.log("[check:registry-manifests] OK") +} + +function main() { + const expectedSource = buildRegistryManifestSource() + if (!CHECK_ONLY) { + fs.writeFileSync(TOOL_MANIFESTS_PATH, expectedSource, "utf8") + console.log(`[generate:registry-manifests] wrote ${toRepoPath(TOOL_MANIFESTS_PATH)}`) + } + runCheck(expectedSource) +} + +if (process.argv[1] && path.resolve(fileURLToPath(import.meta.url)) === path.resolve(process.argv[1])) { + main() +} + +export { buildRegistryManifestSource, runCheck } diff --git a/scripts/generators/generate-tool-index.js b/scripts/generators/generate-tool-index.js index a38aa9e7..53402ec1 100644 --- a/scripts/generators/generate-tool-index.js +++ b/scripts/generators/generate-tool-index.js @@ -2,6 +2,7 @@ import fs from "node:fs" import path from "node:path" import { fileURLToPath } from "node:url" import { loadOrderedToolManifests } from "../lib/tool-manifest-lib.js" +import { buildRegistryManifestSource, runCheck as runRegistryManifestCheck } from "./generate-registry-manifests.js" const TOOL_ROUTE_ROOT = "src/app/[lang]" const SITEMAP_ROUTE_GROUPS_PATH = "src/lib/sitemap-route-groups.json" @@ -168,6 +169,8 @@ function buildIndexData() { category: tool.category, relatedTools: tool.relatedTools, keywords: tool.keywords, + networkAccess: tool.networkAccess || "none", + persistInput: tool.persistInput ?? null, updatedAt: tool.updatedAt || null, sourceFile: tool.sourceFile, } @@ -227,6 +230,9 @@ function runCheck(data) { } function main() { + if (CHECK_ONLY) { + runRegistryManifestCheck(buildRegistryManifestSource()) + } const data = buildIndexData() if (!CHECK_ONLY) { writeOutputs(data) diff --git a/scripts/lib/sitemap-lastmod-lib.js b/scripts/lib/sitemap-lastmod-lib.js index 2c2c965e..b61fc7bd 100644 --- a/scripts/lib/sitemap-lastmod-lib.js +++ b/scripts/lib/sitemap-lastmod-lib.js @@ -14,6 +14,7 @@ const TOOL_REGISTRY_SHARED_FILES = [ "src/core/registry/manifests.ts", "src/core/registry/registry.ts", "src/core/registry/related-tools.ts", + "src/core/registry/tool-order.json", "src/core/registry/types.ts", ]; const MANIFEST_RELATIVE_PATH = "src/lib/sitemap-lastmod.json"; diff --git a/scripts/lib/tool-manifest-lib.js b/scripts/lib/tool-manifest-lib.js index b4c00ed1..d86197c0 100644 --- a/scripts/lib/tool-manifest-lib.js +++ b/scripts/lib/tool-manifest-lib.js @@ -4,8 +4,11 @@ import path from "node:path" export const ROOT_DIR = process.cwd() export const FEATURE_TOOLS_DIR = path.join(ROOT_DIR, "src/features/tools") export const TOOL_MANIFESTS_PATH = path.join(ROOT_DIR, "src/core/registry/manifests.ts") +export const TOOL_ORDER_PATH = path.join(ROOT_DIR, "src/core/registry/tool-order.json") const REQUIRED_FIELDS = ["key", "slug", "category", "relatedTools", "keywords"] +const NETWORK_ACCESS_VALUES = new Set(["none", "user_requested", "third_party_api"]) +const PERSIST_INPUT_VALUES = new Set(["true", "false", "\"opt-in\"", "'opt-in'"]) function relative(filePath) { return path.relative(ROOT_DIR, filePath).replace(/\\/g, "/") @@ -194,6 +197,17 @@ function objectField(source, fieldName) { return match ? match[1] : null } +function booleanOrOptInField(source, fieldName, manifestPath) { + const match = new RegExp(`(?:^|[,\\n])\\s*${fieldName}:\\s*(true|false|["']opt-in["'])`, "s").exec(source) + if (!match) return undefined + if (!PERSIST_INPUT_VALUES.has(match[1])) { + throw manifestError(manifestPath, fieldName, "must be true, false, or \"opt-in\"") + } + if (match[1] === "true") return true + if (match[1] === "false") return false + return "opt-in" +} + function parseDeprecated(source, manifestPath) { const block = objectField(source, "deprecated") if (!block) return undefined @@ -236,8 +250,14 @@ export function parseToolManifestFile(manifestPath) { const relatedTools = arrayField(body, "relatedTools", manifestPath, true) const searchKeywords = arrayField(body, "searchKeywords", manifestPath) const updatedAt = stringField(body, "updatedAt", manifestPath) + const networkAccess = stringField(body, "networkAccess", manifestPath) + const persistInput = booleanOrOptInField(body, "persistInput", manifestPath) const deprecated = parseDeprecated(body, manifestPath) + if (networkAccess && !NETWORK_ACCESS_VALUES.has(networkAccess)) { + throw manifestError(manifestPath, "networkAccess", "must be one of none, user_requested, or third_party_api") + } + const manifest = { key, slug, @@ -249,6 +269,8 @@ export function parseToolManifestFile(manifestPath) { if (updatedAt) manifest.updatedAt = updatedAt if (searchKeywords.length > 0) manifest.searchKeywords = searchKeywords + if (networkAccess) manifest.networkAccess = networkAccess + if (persistInput !== undefined) manifest.persistInput = persistInput if (deprecated) manifest.deprecated = deprecated return manifest @@ -260,24 +282,11 @@ export function loadToolManifestMap() { } export function loadToolManifestOrder() { - const source = readText(TOOL_MANIFESTS_PATH) - const importAliases = new Map( - [...source.matchAll(/import \{ toolManifest as ([A-Za-z0-9_]+) \} from "@\/features\/tools\/([^/]+)\/manifest"/g)].map( - (match) => [match[1], match[2]], - ), - ) - const block = source.match(/export const TOOL_MANIFESTS = \[([\s\S]*?)\]\s+satisfies ToolMeta\[\]/) - if (!block) { - throw new Error("[tool-manifest] Unable to parse TOOL_MANIFESTS from src/core/registry/manifests.ts") + const parsed = JSON.parse(readText(TOOL_ORDER_PATH)) + if (!Array.isArray(parsed) || parsed.some((slug) => typeof slug !== "string" || !slug.trim())) { + throw new Error("[tool-manifest] src/core/registry/tool-order.json must be an array of non-empty slug strings") } - - return [...block[1].matchAll(/([A-Za-z0-9_]+),/g)].map((match) => { - const slug = importAliases.get(match[1]) - if (!slug) { - throw new Error(`[tool-manifest] TOOL_MANIFESTS references unknown import alias: ${match[1]}`) - } - return slug - }) + return parsed } export function loadOrderedToolManifests() { diff --git a/scripts/scaffolding/create-tool.js b/scripts/scaffolding/create-tool.js index 30aaea79..c62d00ad 100755 --- a/scripts/scaffolding/create-tool.js +++ b/scripts/scaffolding/create-tool.js @@ -22,7 +22,7 @@ const CATEGORY_CONFIG = { }, }; -const MANIFESTS_PATH = "src/core/registry/manifests.ts"; +const TOOL_ORDER_PATH = "src/core/registry/tool-order.json"; const ROUTE_ROOT = "src/app/[lang]"; const FEATURE_TOOL_ROOT = "src/features/tools"; const TRANSLATION_FILES = { @@ -34,6 +34,8 @@ const TRANSLATION_FILES = { de: "src/core/i18n/translations/de.json", fr: "src/core/i18n/translations/fr.json", }; +const NETWORK_ACCESS_VALUES = new Set(["none", "user_requested", "third_party_api"]); +const PERSIST_INPUT_VALUES = new Set(["true", "false", "opt-in"]); function parseArgs(argv) { const args = {}; @@ -84,6 +86,11 @@ function assertCategory(category) { } } +function assertEnumArg(name, value, allowedValues) { + if (!value || allowedValues.has(value)) return; + throw new Error(`--${name} must be one of: ${Array.from(allowedValues).join(", ")}`); +} + function parseList(raw, fallback) { if (!raw) return [...fallback]; return raw @@ -92,18 +99,30 @@ function parseList(raw, fallback) { .filter(Boolean); } -function asTsStringArray(items) { - return `[${items.map((item) => `\"${item}\"`).join(", ")}]`; +function parsePersistInput(raw) { + if (!raw) return undefined; + if (raw === "true") return true; + if (raw === "false") return false; + if (raw === "opt-in") return "opt-in"; + throw new Error("--persist-input must be one of: true, false, opt-in"); +} + +function asTsValue(value) { + if (typeof value === "string") return `"${value}"`; + return String(value); } -function slugToManifestIdentifier(slug) { - return `${slug.replace(/-([a-z0-9])/g, (_, char) => char.toUpperCase()).replace(/^[0-9]/, (char) => `_${char}`)}Manifest`; +function asTsStringArray(items) { + return `[${items.map((item) => `\"${item}\"`).join(", ")}]`; } function ensureNotExistsInMeta(key, slug) { - const manifestsSource = readText(MANIFESTS_PATH); - if (manifestsSource.includes(`/${slug}/manifest"`)) { - throw new Error(`Tool slug already exists in manifest aggregator: ${slug}`); + const order = JSON.parse(readText(TOOL_ORDER_PATH)); + if (!Array.isArray(order) || order.some((item) => typeof item !== "string")) { + throw new Error(`${TOOL_ORDER_PATH} must contain an array of slug strings`); + } + if (order.includes(slug)) { + throw new Error(`Tool slug already exists in ${TOOL_ORDER_PATH}: ${slug}`); } const manifestFiles = fs @@ -334,6 +353,18 @@ export function runTool(input: string): ToolRunResult { `; } +function createLogicTestTemplate(slug) { + return `import { describe, expect, it } from "vitest" +import { runTool } from "./logic" + +describe("${slug} logic", () => { + it("transforms sample input deterministically", () => { + expect(runTool("Sample input")).toBe("Sample input") + }) +}) +`; +} + function createSamplesTemplate() { return `export const SAMPLE_INPUT = "Sample input" `; @@ -344,7 +375,14 @@ function createBrowserActionsTemplate() { `; } -function createManifestTemplate({ key, slug, category, relatedTools, keywords }) { +function createManifestTemplate({ key, slug, category, relatedTools, keywords, searchKeywords, networkAccess, persistInput, pipelineAdapter }) { + const optionalFields = [ + searchKeywords.length > 0 ? ` searchKeywords: ${asTsStringArray(searchKeywords)},` : "", + networkAccess && networkAccess !== "none" ? ` networkAccess: "${networkAccess}",` : "", + persistInput !== undefined ? ` persistInput: ${asTsValue(persistInput)},` : "", + pipelineAdapter ? ` // Add a matching adapter in src/features/pipeline/adapter-registry.ts before exposing this as pipeline-ready.` : "", + ].filter(Boolean) + return `import type { ToolMeta } from "@/core/registry/types" export const toolManifest = { @@ -353,7 +391,7 @@ export const toolManifest = { category: "${category}", relatedTools: ${asTsStringArray(relatedTools)}, keywords: ${asTsStringArray(keywords)}, -} satisfies ToolMeta +${optionalFields.length > 0 ? `${optionalFields.join("\n")}\n` : ""}} satisfies ToolMeta `; } @@ -369,7 +407,7 @@ export default function Page() { `; } -function createRouteFiles({ slug, toolKey, category, relatedTools, keywords }) { +function createRouteFiles({ slug, toolKey, category, relatedTools, keywords, searchKeywords, networkAccess, persistInput, pipelineAdapter }) { const dirPath = path.join(ROOT_DIR, ROUTE_ROOT, slug); const featureDirPath = path.join(ROOT_DIR, FEATURE_TOOL_ROOT, slug); fs.mkdirSync(dirPath, { recursive: true }); @@ -380,6 +418,7 @@ function createRouteFiles({ slug, toolKey, category, relatedTools, keywords }) { fs.writeFileSync(path.join(featureDirPath, "types.ts"), createTypesTemplate(), "utf8"); fs.writeFileSync(path.join(featureDirPath, "constants.ts"), createConstantsTemplate(), "utf8"); fs.writeFileSync(path.join(featureDirPath, "logic.ts"), createLogicTemplate(), "utf8"); + fs.writeFileSync(path.join(featureDirPath, "logic.test.ts"), createLogicTestTemplate(slug), "utf8"); fs.writeFileSync(path.join(featureDirPath, "samples.ts"), createSamplesTemplate(), "utf8"); fs.writeFileSync(path.join(featureDirPath, "browser-actions.ts"), createBrowserActionsTemplate(), "utf8"); fs.writeFileSync(path.join(featureDirPath, "manifest.ts"), createManifestTemplate({ @@ -388,40 +427,30 @@ function createRouteFiles({ slug, toolKey, category, relatedTools, keywords }) { category, relatedTools, keywords, + searchKeywords, + networkAccess, + persistInput, + pipelineAdapter, }), "utf8"); } -function updateManifestAggregator(slug) { - const content = readText(MANIFESTS_PATH); - const identifier = slugToManifestIdentifier(slug); - const importLine = `import { toolManifest as ${identifier} } from "@/features/tools/${slug}/manifest"`; - - if (content.includes(importLine)) { - throw new Error(`Manifest import already exists in ${MANIFESTS_PATH}: ${slug}`); +function updateToolOrder(slug) { + const order = JSON.parse(readText(TOOL_ORDER_PATH)); + if (!Array.isArray(order) || order.some((item) => typeof item !== "string")) { + throw new Error(`${TOOL_ORDER_PATH} must contain an array of slug strings`); } - - const typeImport = 'import type { ToolMeta } from "./types"'; - const typeImportIndex = content.indexOf(typeImport); - if (typeImportIndex === -1) { - throw new Error(`Unable to find ToolMeta import in ${MANIFESTS_PATH}`); + if (order.includes(slug)) { + throw new Error(`Tool slug already exists in ${TOOL_ORDER_PATH}: ${slug}`); } - - const arrayClose = content.indexOf("\n] satisfies ToolMeta[]"); - if (arrayClose === -1) { - throw new Error(`Unable to find TOOL_MANIFESTS closing bracket in ${MANIFESTS_PATH}`); - } - - const withImport = `${content.slice(0, typeImportIndex)}${importLine}\n${content.slice(typeImportIndex)}`; - const adjustedArrayClose = arrayClose + importLine.length + 1; - const next = `${withImport.slice(0, adjustedArrayClose)} ${identifier},\n${withImport.slice(adjustedArrayClose)}`; - writeText(MANIFESTS_PATH, next); + order.push(slug); + writeText(TOOL_ORDER_PATH, `${JSON.stringify(order, null, 2)}\n`); } function main() { const args = parseArgs(process.argv.slice(2)); if (args.help === "true" || args.h === "true") { - console.log("Usage: node scripts/scaffolding/create-tool.js --slug my-new-tool --category formatters [--related key1,key2] [--keywords a,b,c]"); + console.log("Usage: node scripts/scaffolding/create-tool.js --slug my-new-tool --category formatters [--related key1,key2] [--keywords a,b,c] [--search-keywords x,y] [--network-access none|user_requested|third_party_api] [--persist-input true|false|opt-in] [--pipeline-adapter]"); process.exit(0); } @@ -429,9 +458,14 @@ function main() { const category = args.category; assertSlug(slug); assertCategory(category); + assertEnumArg("network-access", args["network-access"], NETWORK_ACCESS_VALUES); + assertEnumArg("persist-input", args["persist-input"], PERSIST_INPUT_VALUES); const key = args.key ? args.key : kebabToSnake(slug); const title = args.title ? args.title : kebabToTitle(slug); + const networkAccess = args["network-access"] || "none"; + const persistInput = parsePersistInput(args["persist-input"]); + const pipelineAdapter = args["pipeline-adapter"] === "true"; ensureNotExistsInMeta(key, slug); @@ -446,6 +480,7 @@ function main() { `${slug} helper`, ], ); + const searchKeywords = parseList(args["search-keywords"], pipelineAdapter ? ["pipeline-ready"] : []); createRouteFiles({ toolKey: key, @@ -453,20 +488,25 @@ function main() { category, relatedTools, keywords, + searchKeywords, + networkAccess, + persistInput, + pipelineAdapter, }); - updateManifestAggregator(slug); + updateToolOrder(slug); updateTranslations(key, title); console.log(`[create-tool] Created route: ${path.join(ROUTE_ROOT, slug)}`); console.log(`[create-tool] Created feature page: ${path.join(FEATURE_TOOL_ROOT, slug, "page.tsx")}`); console.log(`[create-tool] Created manifest: ${path.join(FEATURE_TOOL_ROOT, slug, "manifest.ts")}`); - console.log(`[create-tool] Created feature modules: ${path.join(FEATURE_TOOL_ROOT, slug, "{logic,samples,types}.ts")}`); - console.log(`[create-tool] Updated manifest aggregator: ${MANIFESTS_PATH}`); + console.log(`[create-tool] Created feature modules: ${path.join(FEATURE_TOOL_ROOT, slug, "{logic,logic.test,samples,types}.ts")}`); + console.log(`[create-tool] Updated tool order: ${TOOL_ORDER_PATH}`); console.log("[create-tool] Updated translations: en, zh-CN, zh-TW, ja, ko, de, fr"); console.log("[create-tool] Next steps:"); - console.log(" 1) npm run generate:tool-index"); - console.log(" 2) npm run lint && npm run test && npm run check:i18n && npm run build"); + console.log(" 1) npm run generate:registry-manifests && npm run generate:tool-index && npm run generate:client-tool-lookup"); + console.log(" 2) Fill in logic.test.ts with edge cases, malformed input, and round-trip coverage when applicable"); + console.log(" 3) npm run lint && npm run test && npm run check:i18n && npm run build"); } try { diff --git a/src/app/[lang]/all-tools/page.tsx b/src/app/[lang]/all-tools/page.tsx index 00ea9ae6..c96f8cb3 100644 --- a/src/app/[lang]/all-tools/page.tsx +++ b/src/app/[lang]/all-tools/page.tsx @@ -1,9 +1,49 @@ -import Link from "next/link" import { notFound } from "next/navigation" -import { ArrowRight } from "lucide-react" import { isValidLocale, requireTranslationValue } from "@/core/i18n/i18n" import { getTranslation } from "@/core/i18n/translations/catalog" import { getMenuGroups } from "@/core/registry/menu-groups" +import { TOOL_CAPABILITY_LABELS, TOOL_FAMILY_LABELS, type ToolCapability, type ToolFamily } from "@/core/registry" +import { AllToolsDiscovery } from "@/features/tool-discovery/all-tools-discovery" + +const POPULAR_DISCOVERY_TAGS = [ + "json", + "base64", + "security", + "http", + "image", + "css", + "logs", + "pipeline-ready", +] + +const COMMON_WORKFLOWS = [ + { + id: "api-payload-cleanup", + titleKey: "workflow_api_payload_cleanup", + hrefSlug: "pipeline-builder", + tags: ["json", "base64", "pipeline-ready"], + }, + { + id: "security-token-review", + titleKey: "workflow_security_token_review", + hrefSlug: "jwt-workbench", + tags: ["jwt", "security", "browser-local"], + }, + { + id: "image-social-export", + titleKey: "workflow_image_social_export", + hrefSlug: "open-graph-meta-generator", + tags: ["image", "social-metadata", "visual-output"], + }, +] + +function familyTranslationKey(family: ToolFamily): string { + return `family_${family.replace(/-/g, "_")}` +} + +function capabilityTranslationKey(capability: ToolCapability): string { + return `capability_${capability.replace(/-/g, "_")}` +} export default async function AllToolsPage({ params }: { params: Promise<{ lang: string }> }) { const { lang } = await params @@ -15,6 +55,43 @@ export default async function AllToolsPage({ params }: { params: Promise<{ lang: const t = getTranslation(locale) const groups = getMenuGroups() const toolTranslations = t.tools as Record + const commonTranslations = t.common as unknown as Record + const familyLabels = Object.fromEntries( + (Object.keys(TOOL_FAMILY_LABELS) as ToolFamily[]).map((family) => [ + family, + requireTranslationValue(commonTranslations[familyTranslationKey(family)], `common.${familyTranslationKey(family)}`), + ]), + ) as Record + const capabilityLabels = Object.fromEntries( + (Object.keys(TOOL_CAPABILITY_LABELS) as ToolCapability[]).map((capability) => [ + capability, + requireTranslationValue(commonTranslations[capabilityTranslationKey(capability)], `common.${capabilityTranslationKey(capability)}`), + ]), + ) + const discoveryGroups = groups.map((group) => ({ + key: group.key, + title: requireTranslationValue(t.nav[group.navKey], `nav.${group.navKey}`), + description: requireTranslationValue(t.categories[group.descriptionKey], `categories.${group.descriptionKey}`), + href: `/${group.slug}`, + tools: group.items.map((tool) => { + const toolT = toolTranslations[tool.key] + const family = tool.family ?? ("text-strings" as ToolFamily) + return { + key: tool.key, + slug: tool.slug, + title: requireTranslationValue(toolT?.title, `tools.${tool.key}.title`), + description: requireTranslationValue(toolT?.description, `tools.${tool.key}.description`), + family, + familyLabel: familyLabels[family], + tags: tool.tags ?? [], + capabilities: tool.capabilities ?? [], + } + }), + })) + const families = (Object.keys(TOOL_FAMILY_LABELS) as ToolFamily[]).map((family) => ({ + key: family, + label: familyLabels[family], + })) return (
@@ -30,55 +107,32 @@ export default async function AllToolsPage({ params }: { params: Promise<{ lang:

-
- {groups.map((group) => { - const title = requireTranslationValue(t.nav[group.navKey], `nav.${group.navKey}`) - const description = requireTranslationValue(t.categories[group.descriptionKey], `categories.${group.descriptionKey}`) - - return ( -
-
-
-

{title}

-

- {description} -

-
- - {t.common.open} - - -
- -
- {group.items.map((tool) => { - const toolT = toolTranslations[tool.key] - const toolTitle = requireTranslationValue(toolT?.title, `tools.${tool.key}.title`) - const toolDesc = requireTranslationValue(toolT?.description, `tools.${tool.key}.description`) - - return ( - -

- {toolTitle} -

-

- {toolDesc} -

- - ) - })} -
-
- ) - })} -
+ ({ + id: workflow.id, + title: requireTranslationValue(commonTranslations[workflow.titleKey], `common.${workflow.titleKey}`), + href: `/${locale}/${workflow.hrefSlug}`, + tags: workflow.tags, + }))} + />
) } diff --git a/src/app/[lang]/privacy/page.tsx b/src/app/[lang]/privacy/page.tsx index dad0cce9..6b519b10 100644 --- a/src/app/[lang]/privacy/page.tsx +++ b/src/app/[lang]/privacy/page.tsx @@ -1,6 +1,7 @@ "use client" import { useLang } from "@/core/i18n/lang-provider" +import { LocalDataControls } from "@/features/privacy/local-data-controls" export default function PrivacyPage() { const { t } = useLang() @@ -28,7 +29,8 @@ export default function PrivacyPage() {

{section.desc}

))} + ) -} \ No newline at end of file +} diff --git a/src/components/layout/command-palette.tsx b/src/components/layout/command-palette.tsx index fe9d8599..246d640f 100644 --- a/src/components/layout/command-palette.tsx +++ b/src/components/layout/command-palette.tsx @@ -138,6 +138,9 @@ export function CommandPalette({ open: openProp, onOpenChange, enableShortcut = ...(tool?.keywords ?? []), ...(tool?.aliases ?? []), ...(tool?.searchKeywords ?? []), + tool?.family, + ...(tool?.tags ?? []), + ...(tool?.capabilities ?? []), ]), ) } diff --git a/src/components/layout/route-page-chrome.tsx b/src/components/layout/route-page-chrome.tsx index 6737a385..911393f3 100644 --- a/src/components/layout/route-page-chrome.tsx +++ b/src/components/layout/route-page-chrome.tsx @@ -11,6 +11,7 @@ import { getRouteIntentCopy } from "@/core/seo/route-intent-copy" import { getRouteContext } from "@/core/routing/route-context" import { recordRecentToolKey } from "@/core/storage/tool-discovery-state" import { getClientToolBySlug } from "@/generated/client-tool-lookup" +import { ExternalNetworkNotice } from "@/features/tool-shell/external-network-notice" const EXCLUDED_CONTENT_INTRO_SLUGS = new Set(["about", "pricing", "contact", "privacy", "terms", "install-app"]) @@ -27,7 +28,7 @@ function RoutePageChromeContent({ children, pathname }: RoutePageChromeProps) { if (routeContext.routeType !== "tool" || !routeContext.slug) return null const tool = getClientToolBySlug(routeContext.slug) if (!tool) return null - return { key: tool.key, slug: tool.slug } + return { key: tool.key, slug: tool.slug, networkAccess: tool.networkAccess } }, [routeContext]) useEffect(() => { @@ -62,6 +63,9 @@ function RoutePageChromeContent({ children, pathname }: RoutePageChromeProps) { {routeIntentCopy} ) : null} + {activeTool?.networkAccess && activeTool.networkAccess !== "none" ? ( + + ) : null} {children} {routeContext.routeType === "tool" ? (
diff --git a/src/core/i18n/translations/de.json b/src/core/i18n/translations/de.json index 9afce673..6ce49011 100644 --- a/src/core/i18n/translations/de.json +++ b/src/core/i18n/translations/de.json @@ -78,6 +78,34 @@ "csv_downloaded": "Prognose-CSV heruntergeladen", "downloaded_file": "{filename} heruntergeladen", "direct_download_blocked_opened_new_tab": "Direkter Download wurde vom Remote-Host blockiert. Medien wurden in einem neuen Tab geöffnet.", + "all_families": "Alle Familien", + "filter_by_family": "Nach Familie filtern", + "popular_tags": "Beliebte Tags", + "common_workflows": "Häufige Workflows", + "clear_filters": "Filter löschen", + "no_results_suggestion": "Versuchen Sie ein anderes Stichwort, entfernen Sie Filter oder durchsuchen Sie die Workflow-Gruppen unten.", + "family_formatters_validators": "Formatierer und Validatoren", + "family_encoders_decoders": "Encoder und Decoder", + "family_text_strings": "Text und Zeichenketten", + "family_data_formats": "JSON, YAML, CSV und Datenformate", + "family_security_tokens": "Sicherheit, Tokens und Zertifikate", + "family_network_http": "Netzwerk, HTTP und Web", + "family_devops_logs": "DevOps und Logs", + "family_generators": "Generatoren", + "family_images_media": "Bilder und Medien", + "family_svg_css_visual": "SVG- und CSS-Visual-Tools", + "family_social_metadata": "Social- und Metadaten-Tools", + "family_workbench_pipeline": "Workbench- und Pipeline-Tools", + "capability_browser_local": "Browser-lokal", + "capability_offline_capable": "Offlinefähig", + "capability_external_request": "Externe Anfrage", + "capability_sensitive_input": "Sensible Eingabe", + "capability_pipeline_ready": "Pipelinefähig", + "capability_file_input": "Dateieingabe", + "capability_visual_output": "Visuelle Ausgabe", + "workflow_api_payload_cleanup": "API-payload bereinigen", + "workflow_security_token_review": "Security-Token prüfen", + "workflow_image_social_export": "Bild- und Social-Export", "thumbnail_status_idle": "Fügen Sie eine {platform}-URL ein, um Thumbnail-Links zu extrahieren.", "thumbnail_status_invalid": "Eine gültige {platform}-Video-ID konnte nicht erkannt werden.", "thumbnail_status_ready": "Thumbnail-Kandidaten wurden erzeugt.", @@ -2304,6 +2332,7 @@ "title": "Pipeline-Ersteller", "description": "Verkettet lokale Entwicklerwerkzeuge zu wiederholbaren Browser-Rezepten mit Import, Export, Teilen und lokalem Speichern.", "privacy_note": "Rezepte laufen vollständig in diesem Browser. Geteilte URLs enthalten standardmäßig nur Workflow-Struktur und öffentliche Optionen, keine Laufzeiteingaben.", + "share_runtime_input_hint": "Share-URLs enthalten nur Schrittstruktur und öffentliche Optionen. Konstante Schritteingaben bleiben lokal, außer beim JSON-Export.", "storage_unavailable": "IndexedDB ist nicht verfügbar; lokal gespeicherte Rezepte sind in diesem Browser deaktiviert.", "templates_title": "Integrierte Rezepte", "templates_description": "Starten Sie mit lokalen, datenschutzfreundlichen Workflows und passen Sie die Schritte vor dem Ausführen an.", @@ -2337,12 +2366,16 @@ "import_failed": "Rezeptimport fehlgeschlagen", "recipe_imported": "Rezept importiert", "share_copied": "Share-URL kopiert", + "share_copied_without_runtime_input": "Share-URL ohne konstante Schritteingabe kopiert", "save_recipe": "Speichern", "export_recipe": "JSON exportieren", "share_recipe": "URL teilen", "recipe_name": "Rezeptname", "recipe_description": "Beschreibung", "recipe_description_placeholder": "Optionale Notiz für diesen Workflow", + "recipe_settings": "Rezepteinstellungen", + "stop_on_error": "Bei Fehler stoppen", + "stop_on_error_hint": "Wenn aktiviert, stoppt die Ausführung beim ersten fehlgeschlagenen Schritt.", "steps_title": "Schritte", "adapter_select": "Tool-Adapter auswählen", "add_step": "Hinzufügen", @@ -2350,6 +2383,7 @@ "move_up": "Schritt nach oben", "move_down": "Schritt nach unten", "remove_step": "Schritt entfernen", + "compatibility_hint": "Übergabe prüfen: {from}-Ausgabe in {to}-Eingabe.", "saved_recipes": "Gespeicherte Rezepte", "saved_recipe_select": "Gespeichertes Rezept auswählen", "select_saved_recipe": "Gespeichertes Rezept auswählen", diff --git a/src/core/i18n/translations/en.json b/src/core/i18n/translations/en.json index 0e48979e..f0297168 100644 --- a/src/core/i18n/translations/en.json +++ b/src/core/i18n/translations/en.json @@ -78,6 +78,34 @@ "csv_downloaded": "Forecast CSV downloaded", "downloaded_file": "Downloaded {filename}", "direct_download_blocked_opened_new_tab": "Direct download was blocked by the remote host. Opened media in a new tab instead.", + "all_families": "All families", + "filter_by_family": "Filter by family", + "popular_tags": "Popular tags", + "common_workflows": "Common workflows", + "clear_filters": "Clear filters", + "no_results_suggestion": "Try another keyword, remove filters, or browse the workflow groups below.", + "family_formatters_validators": "Formatters and validators", + "family_encoders_decoders": "Encoders and decoders", + "family_text_strings": "Text and strings", + "family_data_formats": "JSON, YAML, CSV, and data formats", + "family_security_tokens": "Security, tokens, and certificates", + "family_network_http": "Network, HTTP, and web", + "family_devops_logs": "DevOps and logs", + "family_generators": "Generators", + "family_images_media": "Images and media", + "family_svg_css_visual": "SVG and CSS visual tools", + "family_social_metadata": "Social and metadata tools", + "family_workbench_pipeline": "Workbench and pipeline tools", + "capability_browser_local": "Browser-local", + "capability_offline_capable": "Offline capable", + "capability_external_request": "External request", + "capability_sensitive_input": "Sensitive input", + "capability_pipeline_ready": "Pipeline ready", + "capability_file_input": "File input", + "capability_visual_output": "Visual output", + "workflow_api_payload_cleanup": "API payload cleanup", + "workflow_security_token_review": "Security token review", + "workflow_image_social_export": "Image and social export", "command_toggle_theme": "Toggle Dark Mode", "command_clear_history": "Clear Tool History", "command_copy_url": "Copy Page URL", @@ -2316,6 +2344,7 @@ "title": "Pipeline Builder", "description": "Chain local developer tools into repeatable browser-only recipes with import, export, sharing, and local saves.", "privacy_note": "Recipes run entirely in this browser. Shared URLs include workflow structure and public options only; runtime input is excluded by default.", + "share_runtime_input_hint": "Share URLs keep step structure and public options only. Constant step inputs stay local unless exported as JSON.", "storage_unavailable": "IndexedDB is unavailable, so local saved recipes are disabled in this browser.", "templates_title": "Built-in recipes", "templates_description": "Start from local, privacy-safe workflows and edit the steps before running.", @@ -2349,12 +2378,16 @@ "import_failed": "Recipe import failed", "recipe_imported": "Recipe imported", "share_copied": "Share URL copied", + "share_copied_without_runtime_input": "Share URL copied without constant step input", "save_recipe": "Save", "export_recipe": "Export JSON", "share_recipe": "Share URL", "recipe_name": "Recipe name", "recipe_description": "Description", "recipe_description_placeholder": "Optional note for this workflow", + "recipe_settings": "Recipe settings", + "stop_on_error": "Stop on error", + "stop_on_error_hint": "When enabled, execution stops at the first failed step.", "steps_title": "Steps", "adapter_select": "Select tool adapter", "add_step": "Add", @@ -2362,6 +2395,7 @@ "move_up": "Move step up", "move_down": "Move step down", "remove_step": "Remove step", + "compatibility_hint": "Check handoff: {from} output into {to} input.", "saved_recipes": "Saved recipes", "saved_recipe_select": "Select saved recipe", "select_saved_recipe": "Select a saved recipe", diff --git a/src/core/i18n/translations/fr.json b/src/core/i18n/translations/fr.json index 7f0f4ff9..2359f494 100644 --- a/src/core/i18n/translations/fr.json +++ b/src/core/i18n/translations/fr.json @@ -82,6 +82,34 @@ "csv_downloaded": "CSV de prévision téléchargé", "downloaded_file": "{filename} téléchargé", "direct_download_blocked_opened_new_tab": "Le téléchargement direct a été bloqué par l'hôte distant. Le média a été ouvert dans un nouvel onglet.", + "all_families": "Toutes les familles", + "filter_by_family": "Filtrer par famille", + "popular_tags": "Tags populaires", + "common_workflows": "Workflows courants", + "clear_filters": "Effacer les filtres", + "no_results_suggestion": "Essayez un autre mot-clé, retirez des filtres ou parcourez les groupes de workflows ci-dessous.", + "family_formatters_validators": "Formateurs et validateurs", + "family_encoders_decoders": "Encodeurs et décodeurs", + "family_text_strings": "Texte et chaînes", + "family_data_formats": "JSON, YAML, CSV et formats de données", + "family_security_tokens": "Sécurité, tokens et certificats", + "family_network_http": "Réseau, HTTP et web", + "family_devops_logs": "DevOps et journaux", + "family_generators": "Générateurs", + "family_images_media": "Images et médias", + "family_svg_css_visual": "Outils visuels SVG et CSS", + "family_social_metadata": "Outils sociaux et métadonnées", + "family_workbench_pipeline": "Outils atelier et pipeline", + "capability_browser_local": "Local au navigateur", + "capability_offline_capable": "Compatible hors ligne", + "capability_external_request": "Requête externe", + "capability_sensitive_input": "Entrée sensible", + "capability_pipeline_ready": "Compatible pipeline", + "capability_file_input": "Entrée fichier", + "capability_visual_output": "Sortie visuelle", + "workflow_api_payload_cleanup": "Nettoyage de payload API", + "workflow_security_token_review": "Revue de token sécurité", + "workflow_image_social_export": "Export image et social", "thumbnail_status_idle": "Collez une URL {platform} pour extraire les liens de miniatures.", "thumbnail_status_invalid": "Impossible d'analyser un ID vidéo {platform} valide.", "thumbnail_status_ready": "Candidats de miniatures générés.", @@ -2304,6 +2332,7 @@ "title": "Constructeur de pipeline", "description": "Chaînez des outils développeur locaux en recettes répétables dans le navigateur, avec import, export, partage et sauvegarde locale.", "privacy_note": "Les recettes s’exécutent entièrement dans ce navigateur. Les URL partagées n’incluent par défaut que la structure et les options publiques, pas les entrées d’exécution.", + "share_runtime_input_hint": "Les URL de partage ne gardent que la structure des étapes et les options publiques. Les entrées constantes restent locales sauf export JSON.", "storage_unavailable": "IndexedDB est indisponible ; les recettes locales sauvegardées sont désactivées dans ce navigateur.", "templates_title": "Recettes intégrées", "templates_description": "Démarrez avec des workflows locaux respectueux de la confidentialité, puis ajustez les étapes avant exécution.", @@ -2337,12 +2366,16 @@ "import_failed": "Échec de l’import de recette", "recipe_imported": "Recette importée", "share_copied": "URL de partage copiée", + "share_copied_without_runtime_input": "URL de partage copiée sans entrée constante", "save_recipe": "Sauvegarder", "export_recipe": "Exporter JSON", "share_recipe": "Partager l’URL", "recipe_name": "Nom de recette", "recipe_description": "Descriptif", "recipe_description_placeholder": "Note optionnelle pour ce workflow", + "recipe_settings": "Paramètres de recette", + "stop_on_error": "Arrêter en cas d’erreur", + "stop_on_error_hint": "Si activé, l’exécution s’arrête à la première étape échouée.", "steps_title": "Étapes", "adapter_select": "Sélectionner un adaptateur", "add_step": "Ajouter", @@ -2350,6 +2383,7 @@ "move_up": "Monter l’étape", "move_down": "Descendre l’étape", "remove_step": "Supprimer l’étape", + "compatibility_hint": "Vérifier le passage : sortie {from} vers entrée {to}.", "saved_recipes": "Recettes sauvegardées", "saved_recipe_select": "Sélectionner une recette sauvegardée", "select_saved_recipe": "Sélectionner une recette sauvegardée", diff --git a/src/core/i18n/translations/ja.json b/src/core/i18n/translations/ja.json index 2067d33d..e9607f8d 100644 --- a/src/core/i18n/translations/ja.json +++ b/src/core/i18n/translations/ja.json @@ -78,6 +78,34 @@ "csv_downloaded": "予測CSVをダウンロードしました", "downloaded_file": "{filename} をダウンロードしました", "direct_download_blocked_opened_new_tab": "リモートホストにより直接ダウンロードがブロックされました。新しいタブでメディアを開きました。", + "all_families": "すべてのファミリー", + "filter_by_family": "ファミリーで絞り込み", + "popular_tags": "よく使うタグ", + "common_workflows": "よく使うワークフロー", + "clear_filters": "フィルターをクリア", + "no_results_suggestion": "別のキーワードを試すか、フィルターを外すか、下のワークフロー群を参照してください。", + "family_formatters_validators": "フォーマッターとバリデーター", + "family_encoders_decoders": "エンコーダーとデコーダー", + "family_text_strings": "テキストと文字列", + "family_data_formats": "JSON、YAML、CSV、データ形式", + "family_security_tokens": "セキュリティ、トークン、証明書", + "family_network_http": "ネットワーク、HTTP、Web", + "family_devops_logs": "DevOps とログ", + "family_generators": "ジェネレーター", + "family_images_media": "画像とメディア", + "family_svg_css_visual": "SVG と CSS ビジュアルツール", + "family_social_metadata": "ソーシャルとメタデータツール", + "family_workbench_pipeline": "ワークベンチとパイプラインツール", + "capability_browser_local": "ブラウザローカル", + "capability_offline_capable": "オフライン対応", + "capability_external_request": "外部リクエスト", + "capability_sensitive_input": "機密入力", + "capability_pipeline_ready": "パイプライン対応", + "capability_file_input": "ファイル入力", + "capability_visual_output": "ビジュアル出力", + "workflow_api_payload_cleanup": "API payload クリーンアップ", + "workflow_security_token_review": "セキュリティトークン確認", + "workflow_image_social_export": "画像とソーシャル出力", "command_toggle_theme": "ダークモードの切り替え", "command_clear_history": "履歴を消去", "command_copy_url": "ページURLをコピー", @@ -2304,6 +2332,7 @@ "title": "パイプラインビルダー", "description": "ローカル開発者ツールをブラウザ内の再利用可能な recipe として連結し、インポート、エクスポート、共有、ローカル保存を行います。", "privacy_note": "Recipe はこのブラウザ内だけで実行されます。共有 URL には既定でワークフロー構造と公開オプションのみが含まれ、実行入力は含まれません。", + "share_runtime_input_hint": "共有 URL にはステップ構造と公開オプションのみが入ります。固定ステップ入力は JSON としてエクスポートしない限りローカルに残ります。", "storage_unavailable": "このブラウザでは IndexedDB を利用できないため、ローカル保存は無効です。", "templates_title": "組み込み Recipe", "templates_description": "ローカルでプライバシーを保つワークフローから始め、実行前にステップを編集できます。", @@ -2337,12 +2366,16 @@ "import_failed": "Recipe のインポートに失敗しました", "recipe_imported": "Recipe をインポートしました", "share_copied": "共有 URL をコピーしました", + "share_copied_without_runtime_input": "固定ステップ入力を含めず共有 URL をコピーしました", "save_recipe": "保存", "export_recipe": "JSON をエクスポート", "share_recipe": "URL を共有", "recipe_name": "Recipe 名", "recipe_description": "説明", "recipe_description_placeholder": "このワークフローの任意メモ", + "recipe_settings": "Recipe 設定", + "stop_on_error": "エラーで停止", + "stop_on_error_hint": "有効にすると、最初に失敗したステップで実行を停止します。", "steps_title": "ステップ", "adapter_select": "ツールアダプターを選択", "add_step": "追加", @@ -2350,6 +2383,7 @@ "move_up": "ステップを上へ", "move_down": "ステップを下へ", "remove_step": "ステップを削除", + "compatibility_hint": "受け渡しを確認: {from} 出力から {to} 入力。", "saved_recipes": "保存済み recipe", "saved_recipe_select": "保存済み recipe を選択", "select_saved_recipe": "保存済み recipe を選択", diff --git a/src/core/i18n/translations/ko.json b/src/core/i18n/translations/ko.json index 5165c223..ba36b785 100644 --- a/src/core/i18n/translations/ko.json +++ b/src/core/i18n/translations/ko.json @@ -78,6 +78,34 @@ "csv_downloaded": "예측 CSV를 다운로드했습니다", "downloaded_file": "{filename} 다운로드 완료", "direct_download_blocked_opened_new_tab": "원격 호스트에서 직접 다운로드를 차단했습니다. 새 탭에서 미디어를 열었습니다.", + "all_families": "모든 패밀리", + "filter_by_family": "패밀리로 필터", + "popular_tags": "인기 태그", + "common_workflows": "공통 워크플로", + "clear_filters": "필터 지우기", + "no_results_suggestion": "다른 키워드를 시도하거나 필터를 제거하거나 아래 워크플로 그룹을 둘러보세요.", + "family_formatters_validators": "포매터 및 검증 도구", + "family_encoders_decoders": "인코더 및 디코더", + "family_text_strings": "텍스트와 문자열", + "family_data_formats": "JSON, YAML, CSV 및 데이터 형식", + "family_security_tokens": "보안, 토큰, 인증서", + "family_network_http": "네트워크, HTTP, 웹", + "family_devops_logs": "DevOps 및 로그", + "family_generators": "생성기", + "family_images_media": "이미지와 미디어", + "family_svg_css_visual": "SVG 및 CSS 시각 도구", + "family_social_metadata": "소셜 및 메타데이터 도구", + "family_workbench_pipeline": "워크벤치 및 파이프라인 도구", + "capability_browser_local": "브라우저 로컬", + "capability_offline_capable": "오프라인 가능", + "capability_external_request": "외부 요청", + "capability_sensitive_input": "민감 입력", + "capability_pipeline_ready": "파이프라인 지원", + "capability_file_input": "파일 입력", + "capability_visual_output": "시각 출력", + "workflow_api_payload_cleanup": "API payload 정리", + "workflow_security_token_review": "보안 토큰 검토", + "workflow_image_social_export": "이미지 및 소셜 내보내기", "command_toggle_theme": "다크 모드 전환", "command_clear_history": "도구 기록 지우기", "command_copy_url": "페이지 URL 복사", @@ -2304,6 +2332,7 @@ "title": "파이프라인 빌더", "description": "로컬 개발자 도구를 브라우저 안에서 반복 실행 가능한 recipe로 연결하고 가져오기, 내보내기, 공유, 로컬 저장을 지원합니다.", "privacy_note": "Recipe는 이 브라우저 안에서만 실행됩니다. 공유 URL에는 기본적으로 워크플로 구조와 공개 옵션만 포함되며 실행 입력은 제외됩니다.", + "share_runtime_input_hint": "공유 URL에는 단계 구조와 공개 옵션만 들어갑니다. 고정 단계 입력은 JSON으로 내보내지 않는 한 로컬에 남습니다.", "storage_unavailable": "이 브라우저에서 IndexedDB를 사용할 수 없어 로컬 저장이 비활성화되었습니다.", "templates_title": "기본 제공 Recipe", "templates_description": "로컬에서 개인정보를 지키는 워크플로로 시작하고 실행 전에 단계를 편집하세요.", @@ -2337,12 +2366,16 @@ "import_failed": "Recipe 가져오기에 실패했습니다", "recipe_imported": "Recipe를 가져왔습니다", "share_copied": "공유 URL을 복사했습니다", + "share_copied_without_runtime_input": "고정 단계 입력 없이 공유 URL을 복사했습니다", "save_recipe": "저장", "export_recipe": "JSON 내보내기", "share_recipe": "URL 공유", "recipe_name": "Recipe 이름", "recipe_description": "설명", "recipe_description_placeholder": "이 워크플로에 대한 선택 메모", + "recipe_settings": "Recipe 설정", + "stop_on_error": "오류 시 중지", + "stop_on_error_hint": "켜면 처음 실패한 단계에서 실행을 멈춥니다.", "steps_title": "단계", "adapter_select": "도구 어댑터 선택", "add_step": "추가", @@ -2350,6 +2383,7 @@ "move_up": "단계 위로 이동", "move_down": "단계 아래로 이동", "remove_step": "단계 삭제", + "compatibility_hint": "전달 확인: {from} 출력이 {to} 입력으로 들어갑니다.", "saved_recipes": "저장된 recipe", "saved_recipe_select": "저장된 recipe 선택", "select_saved_recipe": "저장된 recipe 선택", diff --git a/src/core/i18n/translations/zh-CN.json b/src/core/i18n/translations/zh-CN.json index ba6f01f2..e8f3a630 100644 --- a/src/core/i18n/translations/zh-CN.json +++ b/src/core/i18n/translations/zh-CN.json @@ -78,6 +78,34 @@ "csv_downloaded": "预测 CSV 已下载", "downloaded_file": "已下载 {filename}", "direct_download_blocked_opened_new_tab": "目标站点阻止了直接下载,已在新标签页打开媒体。", + "all_families": "全部细分", + "filter_by_family": "按细分筛选", + "popular_tags": "常用标签", + "common_workflows": "常见工作流", + "clear_filters": "清除筛选", + "no_results_suggestion": "尝试其他关键词、移除筛选,或浏览下方工作流分组。", + "family_formatters_validators": "格式化与校验", + "family_encoders_decoders": "编码与解码", + "family_text_strings": "文本与字符串", + "family_data_formats": "JSON、YAML、CSV 与数据格式", + "family_security_tokens": "安全、令牌与证书", + "family_network_http": "网络、HTTP 与 Web", + "family_devops_logs": "DevOps 与日志", + "family_generators": "生成器", + "family_images_media": "图片与媒体", + "family_svg_css_visual": "SVG 与 CSS 视觉工具", + "family_social_metadata": "社交与元数据工具", + "family_workbench_pipeline": "工作台与管道工具", + "capability_browser_local": "浏览器本地", + "capability_offline_capable": "可离线使用", + "capability_external_request": "外部请求", + "capability_sensitive_input": "敏感输入", + "capability_pipeline_ready": "可接入管道", + "capability_file_input": "文件输入", + "capability_visual_output": "视觉输出", + "workflow_api_payload_cleanup": "API payload 清理", + "workflow_security_token_review": "安全令牌检查", + "workflow_image_social_export": "图片与社交导出", "command_toggle_theme": "切换深色模式", "command_clear_history": "清除工具历史", "command_copy_url": "复制页面 URL", @@ -2304,6 +2332,7 @@ "title": "管道构建器", "description": "把本地开发者工具串成可重复执行的浏览器内 recipe,支持导入、导出、分享和本地保存。", "privacy_note": "Recipe 完全在当前浏览器运行。分享 URL 默认只包含流程结构和公开选项,不包含运行输入。", + "share_runtime_input_hint": "分享 URL 只保留步骤结构和公开选项。固定步骤输入会留在本地,除非导出为 JSON。", "storage_unavailable": "当前浏览器不可用 IndexedDB,本地保存 recipe 已禁用。", "templates_title": "内置 Recipe", "templates_description": "从本地、隐私安全的工作流开始,运行前可继续编辑步骤。", @@ -2337,12 +2366,16 @@ "import_failed": "Recipe 导入失败", "recipe_imported": "Recipe 已导入", "share_copied": "分享 URL 已复制", + "share_copied_without_runtime_input": "分享 URL 已复制,未包含固定步骤输入", "save_recipe": "保存", "export_recipe": "导出 JSON", "share_recipe": "分享 URL", "recipe_name": "Recipe 名称", "recipe_description": "描述", "recipe_description_placeholder": "这个工作流的可选备注", + "recipe_settings": "Recipe 设置", + "stop_on_error": "遇错停止", + "stop_on_error_hint": "开启后,执行会在第一个失败步骤停止。", "steps_title": "步骤", "adapter_select": "选择工具适配器", "add_step": "添加", @@ -2350,6 +2383,7 @@ "move_up": "上移步骤", "move_down": "下移步骤", "remove_step": "删除步骤", + "compatibility_hint": "检查交接:{from} 输出进入 {to} 输入。", "saved_recipes": "已保存 recipe", "saved_recipe_select": "选择已保存 recipe", "select_saved_recipe": "选择一个已保存 recipe", diff --git a/src/core/i18n/translations/zh-TW.json b/src/core/i18n/translations/zh-TW.json index a979da0e..2e76ea89 100644 --- a/src/core/i18n/translations/zh-TW.json +++ b/src/core/i18n/translations/zh-TW.json @@ -78,6 +78,34 @@ "csv_downloaded": "預測 CSV 已下載", "downloaded_file": "已下載 {filename}", "direct_download_blocked_opened_new_tab": "目標站點阻止了直接下載,已在新分頁開啟媒體。", + "all_families": "全部細分", + "filter_by_family": "依細分篩選", + "popular_tags": "常用標籤", + "common_workflows": "常見工作流程", + "clear_filters": "清除篩選", + "no_results_suggestion": "嘗試其他關鍵字、移除篩選,或瀏覽下方工作流程分組。", + "family_formatters_validators": "格式化與校驗", + "family_encoders_decoders": "編碼與解碼", + "family_text_strings": "文字與字串", + "family_data_formats": "JSON、YAML、CSV 與資料格式", + "family_security_tokens": "安全、權杖與憑證", + "family_network_http": "網路、HTTP 與 Web", + "family_devops_logs": "DevOps 與日誌", + "family_generators": "產生器", + "family_images_media": "圖片與媒體", + "family_svg_css_visual": "SVG 與 CSS 視覺工具", + "family_social_metadata": "社群與中繼資料工具", + "family_workbench_pipeline": "工作台與管道工具", + "capability_browser_local": "瀏覽器本機", + "capability_offline_capable": "可離線使用", + "capability_external_request": "外部請求", + "capability_sensitive_input": "敏感輸入", + "capability_pipeline_ready": "可接入管道", + "capability_file_input": "檔案輸入", + "capability_visual_output": "視覺輸出", + "workflow_api_payload_cleanup": "API payload 清理", + "workflow_security_token_review": "安全權杖檢查", + "workflow_image_social_export": "圖片與社群匯出", "command_toggle_theme": "切換深色模式", "command_clear_history": "清除工具歷史", "command_copy_url": "複製頁面 URL", @@ -2304,6 +2332,7 @@ "title": "管道建構器", "description": "把本機開發者工具串成可重複執行的瀏覽器內 recipe,支援匯入、匯出、分享與本機保存。", "privacy_note": "Recipe 完全在目前瀏覽器執行。分享 URL 預設只包含流程結構和公開選項,不包含執行輸入。", + "share_runtime_input_hint": "分享 URL 只保留步驟結構和公開選項。固定步驟輸入會留在本機,除非匯出為 JSON。", "storage_unavailable": "目前瀏覽器無法使用 IndexedDB,本機保存 recipe 已停用。", "templates_title": "內建 Recipe", "templates_description": "從本機、隱私安全的工作流程開始,執行前可繼續編輯步驟。", @@ -2337,12 +2366,16 @@ "import_failed": "Recipe 匯入失敗", "recipe_imported": "Recipe 已匯入", "share_copied": "分享 URL 已複製", + "share_copied_without_runtime_input": "分享 URL 已複製,未包含固定步驟輸入", "save_recipe": "保存", "export_recipe": "匯出 JSON", "share_recipe": "分享 URL", "recipe_name": "Recipe 名稱", "recipe_description": "描述", "recipe_description_placeholder": "此工作流程的可選備註", + "recipe_settings": "Recipe 設定", + "stop_on_error": "遇錯停止", + "stop_on_error_hint": "啟用後,執行會在第一個失敗步驟停止。", "steps_title": "步驟", "adapter_select": "選擇工具適配器", "add_step": "新增", @@ -2350,6 +2383,7 @@ "move_up": "上移步驟", "move_down": "下移步驟", "remove_step": "移除步驟", + "compatibility_hint": "檢查交接:{from} 輸出進入 {to} 輸入。", "saved_recipes": "已保存 recipe", "saved_recipe_select": "選擇已保存 recipe", "select_saved_recipe": "選擇一個已保存 recipe", diff --git a/src/core/performance/tool-runtime-budgets.ts b/src/core/performance/tool-runtime-budgets.ts new file mode 100644 index 00000000..6b83bd3c --- /dev/null +++ b/src/core/performance/tool-runtime-budgets.ts @@ -0,0 +1,43 @@ +import { measureUtf8Bytes } from "@/core/utils/phase4-inspector-limits" + +export const TOOL_RUNTIME_BUDGETS = { + maxCsvJsonInputBytes: 1024 * 1024, + maxCsvJsonRows: 5000, + maxDiffInputBytes: 512 * 1024, + maxDiffRows: 5000, + maxJsonDiffFlattenedNodes: 10000, + maxOpenApiSpecBytes: 1024 * 1024, + maxOpenApiEndpoints: 500, + maxOpenApiMockEndpoints: 300, + maxOpenApiMockSchemaProperties: 2000, +} as const + +export const LEGACY_INPUT_LIMITS = TOOL_RUNTIME_BUDGETS + +export function formatByteLimit(bytes: number): string { + if (bytes >= 1024 * 1024) { + return `${Number((bytes / (1024 * 1024)).toFixed(1))} MB` + } + if (bytes >= 1024) { + return `${Number((bytes / 1024).toFixed(1))} KB` + } + return `${bytes} bytes` +} + +export function isOverUtf8Budget(value: string, maxBytes: number): boolean { + return measureUtf8Bytes(value, maxBytes).exceeded +} + +export function countNonEmptyLines(value: string, maxLines = Number.POSITIVE_INFINITY): { lines: number; exceeded: boolean } { + let lines = 0 + for (const line of value.split(/\r?\n/)) { + if (!line.trim()) continue + lines += 1 + if (lines > maxLines) return { lines, exceeded: true } + } + return { lines, exceeded: false } +} + +export function buildInputTooLargeMessage(template: string, maxBytes: number): string { + return template.replace("{size}", formatByteLimit(maxBytes)) +} diff --git a/src/core/registry/index.ts b/src/core/registry/index.ts index eb483d89..a8a3fd53 100644 --- a/src/core/registry/index.ts +++ b/src/core/registry/index.ts @@ -2,4 +2,5 @@ export { CATEGORIES, type ToolCategory } from "./categories" export { TOOL_MANIFESTS } from "./manifests" export { getRelatedTools } from "./related-tools" export { TOOL_REGISTRY, TOOL_REGISTRY_ORDER, TOOLS_BY_KEY, getToolByKey, getToolBySlug, getToolsByCategory } from "./registry" -export type { ToolMeta } from "./types" +export { TOOL_CAPABILITY_LABELS, TOOL_FAMILY_LABELS, getToolTaxonomy } from "./tool-taxonomy" +export type { ToolCapability, ToolFamily, ToolMeta } from "./types" diff --git a/src/core/registry/registry.ts b/src/core/registry/registry.ts index c98b334e..1890993e 100644 --- a/src/core/registry/registry.ts +++ b/src/core/registry/registry.ts @@ -1,9 +1,18 @@ import { TOOL_MANIFESTS } from "./manifests" import type { ToolCategory, ToolMeta } from "./types" +import { getToolTaxonomy } from "./tool-taxonomy" export const TOOL_REGISTRY_ORDER: string[] = TOOL_MANIFESTS.map((tool) => tool.key) -export const TOOL_REGISTRY: ToolMeta[] = TOOL_MANIFESTS.map((tool) => ({ ...tool })) +export const TOOL_REGISTRY: ToolMeta[] = TOOL_MANIFESTS.map((tool) => { + const taxonomy = getToolTaxonomy(tool) + return { + ...tool, + family: taxonomy.family, + tags: taxonomy.tags, + capabilities: taxonomy.capabilities, + } +}) export const TOOLS_BY_KEY = new Map(TOOL_REGISTRY.map((tool) => [tool.key, tool])) diff --git a/src/core/registry/tool-order.json b/src/core/registry/tool-order.json new file mode 100644 index 00000000..7800b5dc --- /dev/null +++ b/src/core/registry/tool-order.json @@ -0,0 +1,123 @@ +[ + "json-formatter", + "xml-formatter", + "sql-formatter", + "javascript-formatter", + "javascript-minifier", + "html-minifier", + "html-encoder-decoder", + "html-css-beautifier", + "html-formatter", + "yaml-json-converter", + "markdown-preview", + "html-to-markdown", + "json-to-typescript", + "css-minifier", + "svg-optimizer", + "jsonpath-playground", + "openapi-viewer", + "json-diff-viewer", + "csv-json-converter", + "base64-encode-decode", + "url-encode-decode", + "jwt-decoder", + "jwt-workbench", + "hash-generator", + "md5-generator", + "text-diff-checker", + "multiple-whitespace-remover", + "letter-counter", + "bionic-reading-converter", + "google-fonts-pair-finder", + "text-to-handwriting-converter", + "code-to-image-converter", + "image-base64", + "unix-timestamp", + "uuid-generator", + "lorem-ipsum", + "password-generator", + "color-converter", + "react-native-shadow-generator", + "ai-color-palette-generator", + "color-mixer", + "color-shades-generator", + "image-average-color-finder", + "image-caption-generator", + "image-color-extractor", + "image-color-picker", + "image-cropper", + "image-filters", + "instagram-filters", + "instagram-post-generator", + "instagram-story-generator", + "open-graph-meta-generator", + "tweet-generator", + "tweet-to-image-converter", + "twitter-ad-revenue-generator", + "instagram-photo-downloader", + "vimeo-thumbnail-grabber", + "youtube-thumbnail-grabber", + "image-resizer", + "photo-censor", + "scanned-pdf-converter", + "svg-blob-generator", + "svg-pattern-generator", + "svg-stroke-to-fill-converter", + "svg-to-png-converter", + "css-background-pattern-generator", + "css-border-radius-generator", + "css-box-shadow-generator", + "css-checkbox-generator", + "css-clip-path-generator", + "css-cubic-bezier-generator", + "css-glassmorphism-generator", + "css-gradient-generator", + "css-loader-generator", + "css-switch-generator", + "css-text-glitch-effect-generator", + "css-triangle-generator", + "qr-code-generator", + "barcode-generator", + "fake-iban-generator", + "list-randomizer", + "ascii-art-generator", + "env-parser", + "id-generator", + "regex-tester", + "regex-generator", + "crontab-generator", + "user-agent-parser", + "cron-visualizer", + "http-status-codes", + "chmod-calculator", + "cidr-subnet-calculator", + "url-parser", + "certificate-decoder", + "http-request-builder", + "curl-to-code", + "ndjson-formatter", + "jwt-verifier", + "slugify-case-converter", + "invisible-characters-detector", + "robots-txt-tester", + "csp-parser", + "csv-diff", + "header-diff", + "security-header-analyzer", + "totp-generator", + "openapi-mock", + "docker-run-to-compose", + "local-log-parser", + "jq-playground", + "log-scrubber", + "gzip-brotli-lab", + "yaml-merge-patch-explorer", + "yq-playground", + "structured-data-visualizer", + "har-viewer-sanitizer", + "pipeline-builder", + "saml-decoder", + "asn1-der-inspector", + "hex-bytes-workbench", + "unicode-inspector" +] diff --git a/src/core/registry/tool-taxonomy.ts b/src/core/registry/tool-taxonomy.ts new file mode 100644 index 00000000..51c99b8b --- /dev/null +++ b/src/core/registry/tool-taxonomy.ts @@ -0,0 +1,242 @@ +import type { ToolMeta, ToolNetworkAccess } from "./types" + +export type ToolFamily = + | "formatters-validators" + | "encoders-decoders" + | "text-strings" + | "data-formats" + | "security-tokens" + | "network-http" + | "devops-logs" + | "generators" + | "images-media" + | "svg-css-visual" + | "social-metadata" + | "workbench-pipeline" + +export type ToolCapability = + | "browser-local" + | "offline-capable" + | "external-request" + | "sensitive-input" + | "pipeline-ready" + | "file-input" + | "visual-output" + +export type ToolTaxonomy = { + family: ToolFamily + tags: string[] + capabilities: ToolCapability[] +} + +export const TOOL_FAMILY_LABELS: Record = { + "formatters-validators": "Formatters and validators", + "encoders-decoders": "Encoders and decoders", + "text-strings": "Text and strings", + "data-formats": "JSON, YAML, CSV, and data formats", + "security-tokens": "Security, tokens, and certificates", + "network-http": "Network, HTTP, and web", + "devops-logs": "DevOps and logs", + generators: "Generators", + "images-media": "Images and media", + "svg-css-visual": "SVG and CSS visual tools", + "social-metadata": "Social and metadata tools", + "workbench-pipeline": "Workbench and pipeline tools", +} + +export const TOOL_CAPABILITY_LABELS: Record = { + "browser-local": "Browser-local", + "offline-capable": "Offline capable", + "external-request": "External request", + "sensitive-input": "Sensitive input", + "pipeline-ready": "Pipeline ready", + "file-input": "File input", + "visual-output": "Visual output", +} + +const FAMILY_BY_TOOL_KEY: Partial> = { + ai_color_palette_generator: "images-media", + asn1_der_inspector: "security-tokens", + barcode_generator: "generators", + base64_encode_decode: "encoders-decoders", + certificate_decoder: "security-tokens", + chmod_calculator: "devops-logs", + cidr_subnet_calculator: "network-http", + code_to_image_converter: "images-media", + color_converter: "svg-css-visual", + color_mixer: "svg-css-visual", + color_shades_generator: "svg-css-visual", + cron_visualizer: "devops-logs", + crontab_generator: "devops-logs", + csp_parser: "security-tokens", + csv_diff: "data-formats", + csv_json_converter: "data-formats", + curl_to_code: "network-http", + docker_run_to_compose: "devops-logs", + env_parser: "devops-logs", + fake_iban_generator: "generators", + google_fonts_pair_finder: "svg-css-visual", + gzip_brotli_lab: "encoders-decoders", + har_viewer_sanitizer: "network-http", + hash_generator: "security-tokens", + header_diff: "network-http", + hex_bytes_workbench: "encoders-decoders", + html_encoder_decoder: "encoders-decoders", + html_to_markdown: "text-strings", + http_request_builder: "network-http", + http_status_codes: "network-http", + id_generator: "generators", + image_average_color_finder: "images-media", + image_base64: "encoders-decoders", + image_caption_generator: "images-media", + image_color_extractor: "images-media", + image_color_picker: "images-media", + image_cropper: "images-media", + image_filters: "images-media", + image_resizer: "images-media", + instagram_filters: "social-metadata", + instagram_photo_downloader: "social-metadata", + instagram_post_generator: "social-metadata", + instagram_story_generator: "social-metadata", + invisible_chars_detector: "text-strings", + jq_playground: "data-formats", + json_diff_viewer: "data-formats", + json_formatter: "data-formats", + json_to_typescript: "data-formats", + jsonpath_playground: "data-formats", + jwt_decoder: "security-tokens", + jwt_verifier: "security-tokens", + jwt_workbench: "security-tokens", + list_randomizer: "generators", + local_log_parser: "devops-logs", + log_scrubber: "devops-logs", + markdown_preview: "text-strings", + md5_generator: "security-tokens", + ndjson_formatter: "data-formats", + open_graph_meta_generator: "social-metadata", + openapi_mock: "network-http", + openapi_viewer: "network-http", + password_generator: "generators", + photo_censor: "images-media", + pipeline_builder: "workbench-pipeline", + qr_code_generator: "generators", + react_native_shadow_generator: "svg-css-visual", + regex_generator: "text-strings", + regex_tester: "text-strings", + robots_txt_tester: "network-http", + saml_decoder: "security-tokens", + scanned_pdf_converter: "images-media", + security_header_analyzer: "security-tokens", + slugify_case_converter: "text-strings", + structured_data_visualizer: "data-formats", + svg_blob_generator: "svg-css-visual", + svg_optimizer: "svg-css-visual", + svg_pattern_generator: "svg-css-visual", + svg_stroke_to_fill_converter: "svg-css-visual", + svg_to_png_converter: "svg-css-visual", + text_diff_checker: "text-strings", + text_to_handwriting_converter: "images-media", + totp_generator: "security-tokens", + tweet_generator: "social-metadata", + tweet_to_image_converter: "social-metadata", + twitter_ad_revenue_generator: "social-metadata", + unicode_inspector: "text-strings", + unix_timestamp: "generators", + url_encode_decode: "encoders-decoders", + url_parser: "network-http", + user_agent_parser: "network-http", + uuid_generator: "generators", + vimeo_thumbnail_grabber: "social-metadata", + yaml_json_converter: "data-formats", + yaml_merge_patch_explorer: "data-formats", + youtube_thumbnail_grabber: "social-metadata", + yq_playground: "data-formats", +} + +const PIPELINE_READY_TOOL_KEYS = new Set([ + "base64_encode_decode", + "csv_json_converter", + "env_parser", + "hash_generator", + "html_to_markdown", + "invisible_chars_detector", + "json_formatter", + "jwt_decoder", + "log_scrubber", + "multiple_whitespace_remover", + "ndjson_formatter", + "regex_tester", + "slugify_case_converter", + "unix_timestamp", + "url_encode_decode", + "yaml_json_converter", +]) + +function fallbackFamily(tool: ToolMeta): ToolFamily { + if (tool.category === "formatters") return "formatters-validators" + if (tool.category === "generators") return "generators" + if (tool.category === "network-web") return "network-http" + return "text-strings" +} + +function inferKeywordTags(tool: ToolMeta): string[] { + const source = [tool.key, tool.slug, ...tool.keywords, ...(tool.searchKeywords ?? [])] + .join(" ") + .toLowerCase() + + const tags = new Set() + const addWhen = (tag: string, patterns: string[]) => { + if (patterns.some((pattern) => source.includes(pattern))) tags.add(tag) + } + + addWhen("json", ["json", "jq"]) + addWhen("yaml", ["yaml", "yq"]) + addWhen("csv", ["csv"]) + addWhen("xml", ["xml", "saml"]) + addWhen("html", ["html"]) + addWhen("css", ["css"]) + addWhen("svg", ["svg"]) + addWhen("markdown", ["markdown"]) + addWhen("base64", ["base64"]) + addWhen("url", ["url", "uri"]) + addWhen("jwt", ["jwt"]) + addWhen("hash", ["hash", "checksum", "digest", "md5", "sha"]) + addWhen("http", ["http", "header", "curl", "openapi", "request"]) + addWhen("regex", ["regex", "regexp"]) + addWhen("image", ["image", "photo", "png", "jpeg", "webp"]) + addWhen("color", ["color", "palette", "gradient"]) + addWhen("logs", ["log", "har"]) + addWhen("security", ["security", "token", "certificate", "totp", "secret", "saml", "asn.1", "asn1"]) + + return [...tags].sort() +} + +function uniqueSorted(values: T[]): T[] { + return [...new Set(values)].sort((a, b) => a.localeCompare(b)) +} + +export function getToolTaxonomy(tool: ToolMeta): ToolTaxonomy { + const networkAccess: ToolNetworkAccess = tool.networkAccess ?? "none" + const family = FAMILY_BY_TOOL_KEY[tool.key] ?? fallbackFamily(tool) + const tags = uniqueSorted([family, ...inferKeywordTags(tool)]) + const capabilities: ToolCapability[] = ["browser-local", "offline-capable"] + + if (networkAccess !== "none") capabilities.push("external-request") + if (tool.persistInput === false || family === "security-tokens" || family === "devops-logs") { + capabilities.push("sensitive-input") + } + if (PIPELINE_READY_TOOL_KEYS.has(tool.key)) capabilities.push("pipeline-ready") + if (["data-formats", "images-media", "devops-logs", "workbench-pipeline"].includes(family)) { + capabilities.push("file-input") + } + if (["images-media", "svg-css-visual", "social-metadata"].includes(family)) { + capabilities.push("visual-output") + } + + return { + family, + tags, + capabilities: uniqueSorted(capabilities), + } +} + diff --git a/src/core/registry/types.ts b/src/core/registry/types.ts index d9e00581..445e2b85 100644 --- a/src/core/registry/types.ts +++ b/src/core/registry/types.ts @@ -1,6 +1,11 @@ import type { ToolCategory } from "./categories" +import type { ToolCapability, ToolFamily } from "./tool-taxonomy" export type { ToolCategory } from "./categories" +export type { ToolCapability, ToolFamily } from "./tool-taxonomy" + +export type ToolNetworkAccess = "none" | "user_requested" | "third_party_api" +export type ToolInputPersistenceMode = true | false | "opt-in" /** * Tool metadata used by registry, sitemap, SEO, breadcrumbs, and related tools. @@ -20,6 +25,16 @@ export interface ToolMeta { updatedAt?: string /** Optional search keywords for use-case and multilingual matching in command palette */ searchKeywords?: string[] + /** Browser network behavior used by privacy UI and CI guards */ + networkAccess?: ToolNetworkAccess + /** Input payload persistence behavior used by privacy UI and CI guards */ + persistInput?: ToolInputPersistenceMode + /** Practical discovery family derived from manifest metadata */ + family?: ToolFamily + /** Generated discovery tags used by all-tools and command palette search */ + tags?: string[] + /** Generated capability badges used by discovery surfaces */ + capabilities?: ToolCapability[] /** Optional deprecation metadata - marks tool as deprecated with message and alternatives */ deprecated?: { /** Translation key for deprecation message (optional, falls back to generic message) */ diff --git a/src/core/routing/tool-handoff.ts b/src/core/routing/tool-handoff.ts index e3e232a6..5cd8cf11 100644 --- a/src/core/routing/tool-handoff.ts +++ b/src/core/routing/tool-handoff.ts @@ -1,7 +1,6 @@ const HANDOFF_PARAM = "handoff" const HANDOFF_REF_PARAM = "handoff_ref" const HANDOFF_STORAGE_PREFIX = "byteflow:handoff:" -const HANDOFF_QUERY_MAX_CHARS = 3800 const STORAGE_PROBE_KEY = `${HANDOFF_STORAGE_PREFIX}probe` let sessionStorageAvailable: boolean | null = null @@ -50,7 +49,7 @@ function fromBase64Url(value: string): string | null { } } -export function buildToolHandoffHref(lang: string, slug: string, payload: string): string { +export function buildShareableToolHandoffHref(lang: string, slug: string, payload: string): string { const basePath = buildBasePath(lang, slug) const text = payload.trim() if (!text) return basePath @@ -59,6 +58,8 @@ export function buildToolHandoffHref(lang: string, slug: string, payload: string return `${basePath}?${HANDOFF_PARAM}=${encodeURIComponent(encoded)}` } +export const buildToolHandoffHref = buildShareableToolHandoffHref + function canUseSessionStorage(): boolean { if (sessionStorageAvailable !== null) return sessionStorageAvailable if (typeof window === "undefined") { @@ -113,11 +114,9 @@ export function buildToolHandoffLink(lang: string, slug: string, payload: string } } - const encodedPayload = encodeURIComponent(toBase64Url(text)) - const queryHref = `${basePath}?${HANDOFF_PARAM}=${encodedPayload}` - if (encodedPayload.length <= HANDOFF_QUERY_MAX_CHARS || !canUseSessionStorage()) { + if (!canUseSessionStorage()) { return { - href: queryHref, + href: buildShareableToolHandoffHref(lang, slug, text), prime: () => undefined, } } diff --git a/src/core/security/external-url.ts b/src/core/security/external-url.ts new file mode 100644 index 00000000..006b74d2 --- /dev/null +++ b/src/core/security/external-url.ts @@ -0,0 +1,95 @@ +export type ExternalUrlRejectReason = + | "empty" + | "invalid" + | "unsupported_protocol" + | "insecure_protocol" + | "blocked_hostname" + | "unsupported_extension" + +export type SafeExternalUrlResult = + | { ok: true; url: URL } + | { ok: false; reason: ExternalUrlRejectReason } + +export type SafeExternalUrlOptions = { + requireHttps?: boolean + addHttpsWhenMissing?: boolean + allowedHostnames?: readonly string[] + allowedHostnameSuffixes?: readonly string[] + allowedPathExtensions?: readonly string[] +} + +function normalizeHostname(hostname: string): string { + return hostname.toLowerCase().replace(/\.$/, "") +} + +function isAllowedHostname(hostname: string, options: SafeExternalUrlOptions): boolean { + const normalized = normalizeHostname(hostname) + const exactHosts = (options.allowedHostnames || []).map(normalizeHostname) + if (exactHosts.includes(normalized)) return true + + return (options.allowedHostnameSuffixes || []).some((suffix) => { + const normalizedSuffix = normalizeHostname(suffix) + return normalized === normalizedSuffix || normalized.endsWith(`.${normalizedSuffix}`) + }) +} + +function hasAllowedPathExtension(pathname: string, extensions: readonly string[]): boolean { + const normalizedPath = pathname.toLowerCase() + return extensions.some((extension) => { + const normalizedExtension = extension.startsWith(".") ? extension.toLowerCase() : `.${extension.toLowerCase()}` + return normalizedPath.endsWith(normalizedExtension) + }) +} + +export function parseSafeExternalUrl(rawInput: string, options: SafeExternalUrlOptions = {}): SafeExternalUrlResult { + const value = rawInput.trim() + if (!value) return { ok: false, reason: "empty" } + + const candidate = options.addHttpsWhenMissing && !/^[a-z][a-z0-9+.-]*:/i.test(value) + ? `https://${value}` + : value + + let url: URL + try { + url = new URL(candidate) + } catch { + return { ok: false, reason: "invalid" } + } + + if (url.protocol !== "https:" && url.protocol !== "http:") { + return { ok: false, reason: "unsupported_protocol" } + } + if (options.requireHttps !== false && url.protocol !== "https:") { + return { ok: false, reason: "insecure_protocol" } + } + if ((options.allowedHostnames?.length || options.allowedHostnameSuffixes?.length) && !isAllowedHostname(url.hostname, options)) { + return { ok: false, reason: "blocked_hostname" } + } + if (options.allowedPathExtensions?.length && !hasAllowedPathExtension(url.pathname, options.allowedPathExtensions)) { + return { ok: false, reason: "unsupported_extension" } + } + + return { ok: true, url } +} + +export function sanitizeDownloadFilename(rawFilename: string, fallback: string): string { + const safe = rawFilename + .trim() + .replace(/[/\\?%*:|"<>]+/g, "-") + .replace(/[^a-zA-Z0-9._-]+/g, "-") + .replace(/-+/g, "-") + .replace(/-+(\.[a-zA-Z0-9]{1,16})$/g, "$1") + .replace(/^[.-]+|[.-]+$/g, "") + return safe || fallback +} + +export function openExternalUrl(rawInput: string): boolean { + const parsed = parseSafeExternalUrl(rawInput, { requireHttps: true }) + if (!parsed.ok || typeof window === "undefined") return false + + const opened = window.open(parsed.url.toString(), "_blank", "noopener,noreferrer") + if (opened) { + opened.opener = null + } + return Boolean(opened) +} diff --git a/src/core/security/inline-script-policy.ts b/src/core/security/inline-script-policy.ts new file mode 100644 index 00000000..de92d5e2 --- /dev/null +++ b/src/core/security/inline-script-policy.ts @@ -0,0 +1,36 @@ +export type InlineScriptPolicyEntry = { + id: string + file: string + purpose: string + requiresUnsafeInline: boolean + migrationPath: string +} + +export const INLINE_SCRIPT_POLICY: readonly InlineScriptPolicyEntry[] = [ + { + id: "root-locale-redirect", + file: "src/app/page.tsx", + purpose: "Static export root locale redirect before React hydration.", + requiresUnsafeInline: true, + migrationPath: "Move redirect bootstrap into a hashed static script once static export can preserve locale fallback behavior.", + }, + { + id: "theme-manifest-bootstrap", + file: "src/app/layout.tsx", + purpose: "Set locale lang, color scheme, theme-color, and localized manifest before first paint.", + requiresUnsafeInline: true, + migrationPath: "Move bootstrap into a hashed static script after verifying no theme flash or manifest race in exported HTML.", + }, + { + id: "legacy-tool-redirect", + file: "src/core/seo/components/legacy-tool-redirect-page.tsx", + purpose: "Client-side redirect for statically exported legacy tool aliases.", + requiresUnsafeInline: true, + migrationPath: "Replace with static redirect artifacts or a hashed redirect bootstrap per alias.", + }, +] + +export function inlineScriptPolicyRequiresUnsafeInline(): boolean { + return INLINE_SCRIPT_POLICY.some((entry) => entry.requiresUnsafeInline) +} + diff --git a/src/core/seo/components/json-ld-script.tsx b/src/core/seo/components/json-ld-script.tsx new file mode 100644 index 00000000..659ad9db --- /dev/null +++ b/src/core/seo/components/json-ld-script.tsx @@ -0,0 +1,19 @@ +import type { ScriptHTMLAttributes } from "react" + +type JsonLdScriptProps = Omit, "children" | "dangerouslySetInnerHTML" | "type"> & { + jsonLd: unknown +} + +export function serializeJsonLd(jsonLd: unknown): string { + return JSON.stringify(jsonLd).replace(/ + ) +} diff --git a/src/core/seo/components/json-ld.tsx b/src/core/seo/components/json-ld.tsx index ebe7f176..82712ee9 100644 --- a/src/core/seo/components/json-ld.tsx +++ b/src/core/seo/components/json-ld.tsx @@ -1,6 +1,7 @@ import { buildBreadcrumbJsonLd } from "@/core/seo/seo"; import { getToolBySlug, type ToolMeta } from "@/core/registry"; import type { Locale } from "@/core/i18n/i18n"; +import { JsonLdScript } from "./json-ld-script"; /** * Renders BreadcrumbList JSON-LD structured data for a tool page. @@ -12,12 +13,7 @@ export function ToolBreadcrumbJsonLd({ lang, slug }: { lang: Locale; slug: strin const jsonLd = buildBreadcrumbJsonLd({ lang, tool }); - return ( - ")).toEqual({ ok: false, reason: "unsupported_protocol" }) + expect(parseSafeExternalUrl("ftp://example.com/file.txt")).toEqual({ ok: false, reason: "unsupported_protocol" }) + expect(parseSafeExternalUrl("http://example.com/file.txt")).toEqual({ ok: false, reason: "insecure_protocol" }) + }) + + it("enforces host and extension allowlists with hostname boundaries", () => { + expect(parseSafeExternalUrl("https://cdn.example.com/photo.jpg", { + allowedHostnameSuffixes: ["example.com"], + allowedPathExtensions: [".jpg", ".png"], + }).ok).toBe(true) + expect(parseSafeExternalUrl("https://badexample.com/photo.jpg", { + allowedHostnameSuffixes: ["example.com"], + allowedPathExtensions: [".jpg", ".png"], + })).toEqual({ ok: false, reason: "blocked_hostname" }) + expect(parseSafeExternalUrl("https://cdn.example.com/photo.svg", { + allowedHostnameSuffixes: ["example.com"], + allowedPathExtensions: [".jpg", ".png"], + })).toEqual({ ok: false, reason: "unsupported_extension" }) + }) + + it("sanitizes download filenames", () => { + expect(sanitizeDownloadFilename("../my photo!.png", "file.png")).toBe("my-photo.png") + expect(sanitizeDownloadFilename("???", "file.png")).toBe("file.png") + }) + + it("opens external urls with noopener and noreferrer", () => { + const opened = { opener: "before" } + const open = vi.spyOn(window, "open").mockReturnValue(opened as unknown as Window) + + expect(openExternalUrl("https://example.com/path")).toBe(true) + expect(open).toHaveBeenCalledWith("https://example.com/path", "_blank", "noopener,noreferrer") + expect(opened.opener).toBeNull() + + expect(openExternalUrl("javascript:alert(1)")).toBe(false) + expect(open).toHaveBeenCalledTimes(1) + }) +}) diff --git a/tests/unit/instagram-tool-utils.test.ts b/tests/unit/instagram-tool-utils.test.ts index 370065d2..ffb82f2c 100644 --- a/tests/unit/instagram-tool-utils.test.ts +++ b/tests/unit/instagram-tool-utils.test.ts @@ -32,7 +32,26 @@ describe("instagram-tool-utils", () => { expect(canDownloadAuthorizedInstagramMedia(parsed || null, false)).toBe(false) }) + it("rejects dangerous protocols and blocks mixed-content downloads", () => { + expect(parseInstagramMediaInput("javascript:alert(1)")).toBeNull() + expect(parseInstagramMediaInput("data:image/png;base64,AAAA")).toBeNull() + expect(parseInstagramMediaInput("ftp://example.com/photo.jpg")).toBeNull() + + const insecure = parseInstagramMediaInput("http://example.com/photo.jpg") + expect(insecure?.kind).toBe("direct_image") + expect(insecure?.isHttps).toBe(false) + expect(canDownloadAuthorizedInstagramMedia(insecure || null, true)).toBe(false) + }) + + it("marks unsupported external assets as non-downloadable", () => { + const parsed = parseInstagramMediaInput("https://example.com/photo.svg") + expect(parsed?.kind).toBe("unsupported") + expect(canDownloadAuthorizedInstagramMedia(parsed || null, true)).toBe(false) + }) + it("derives safe filename from media url", () => { - expect(getInstagramMediaFilename("https://example.com/my photo!.png")).toBe("my-photo-.png") + expect(getInstagramMediaFilename("https://example.com/my photo!.png")).toBe("my-photo.png") + expect(getInstagramMediaFilename("javascript:alert(1)")).toBe("instagram-photo.jpg") + expect(getInstagramMediaFilename("https://example.com/%E0%A4%A.png")).toBe("instagram-photo.jpg") }) }) diff --git a/tests/unit/json-formatter-tree-logic.test.ts b/tests/unit/json-formatter-tree-logic.test.ts new file mode 100644 index 00000000..6d5a3876 --- /dev/null +++ b/tests/unit/json-formatter-tree-logic.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest" +import { + findMatchingPaths, + getAllPaths, + getValueAtPath, + pathKey, + removeValueAtPath, + renameObjectKey, + updateValueAtPath, +} from "@/features/tools/json-formatter/logic" +import type { JsonValue } from "@/features/tools/json-formatter/types" + +const sample: JsonValue = { + user: { + name: "Alice", + roles: ["admin", "editor"], + profile: { + active: true, + }, + }, + count: 2, +} + +describe("json formatter tree logic", () => { + it("builds stable path keys for root and nested nodes", () => { + expect(pathKey([])).toBe("$") + expect(pathKey(["user", "roles", 1])).toBe("user__roles__1") + }) + + it("reads nested object and array values by path", () => { + expect(getValueAtPath(sample, ["user", "name"])).toBe("Alice") + expect(getValueAtPath(sample, ["user", "roles", 1])).toBe("editor") + }) + + it("returns the root value when a path cannot be traversed", () => { + expect(getValueAtPath(sample, ["user", 0])).toBe(sample) + expect(getValueAtPath(sample, ["count", "nested"])).toBe(sample) + }) + + it("updates nested values immutably", () => { + const next = updateValueAtPath(sample, ["user", "roles", 0], "owner") + + expect(getValueAtPath(next, ["user", "roles", 0])).toBe("owner") + expect(getValueAtPath(sample, ["user", "roles", 0])).toBe("admin") + expect(next).not.toBe(sample) + }) + + it("removes object keys and array entries by path", () => { + const withoutProfile = removeValueAtPath(sample, ["user", "profile"]) + const withoutRole = removeValueAtPath(sample, ["user", "roles", 0]) + + expect(getValueAtPath(withoutProfile, ["user"])).toEqual({ + name: "Alice", + roles: ["admin", "editor"], + }) + expect(getValueAtPath(withoutRole, ["user", "roles"])).toEqual(["editor"]) + }) + + it("renames object keys without overwriting existing keys", () => { + const renamed = renameObjectKey(sample, ["user"], "name", "displayName") + const blocked = renameObjectKey(sample, ["user"], "name", "roles") + + expect(getValueAtPath(renamed, ["user"])).toEqual({ + displayName: "Alice", + roles: ["admin", "editor"], + profile: { + active: true, + }, + }) + expect(blocked).toBe(sample) + }) + + it("collects all tree paths for expand-all behavior", () => { + expect([...getAllPaths(sample)].sort()).toEqual([ + "$", + "count", + "user", + "user__name", + "user__profile", + "user__profile__active", + "user__roles", + "user__roles__0", + "user__roles__1", + ]) + }) + + it("finds matching value paths and includes parent paths for auto-expand", () => { + const result = findMatchingPaths(sample, "editor") + + expect([...result.matched].sort()).toEqual(["user__roles", "user__roles__1"]) + expect([...result.parents].sort()).toEqual(["$", "user", "user__roles", "user__roles__1"]) + }) + + it("finds matching key paths case-insensitively", () => { + const result = findMatchingPaths(sample, "PROFILE") + + expect(result.matched.has("user__profile")).toBe(true) + expect(result.parents.has("user")).toBe(true) + }) +}) diff --git a/tests/unit/json-ld-script.test.tsx b/tests/unit/json-ld-script.test.tsx new file mode 100644 index 00000000..6bcb7954 --- /dev/null +++ b/tests/unit/json-ld-script.test.tsx @@ -0,0 +1,30 @@ +import { renderToStaticMarkup } from "react-dom/server" +import { describe, expect, it } from "vitest" +import { JsonLdScript, serializeJsonLd } from "@/core/seo/components/json-ld-script" + +describe("JsonLdScript", () => { + it("escapes script-breaking HTML while preserving valid JSON and unicode", () => { + const serialized = serializeJsonLd({ + name: "", + text: "你好", + }) + + expect(serialized).toContain("\\u003c/script>") + expect(serialized).toContain("\\u003cimg") + expect(serialized).not.toContain("") + expect(JSON.parse(serialized)).toEqual({ + name: "", + text: "你好", + }) + }) + + it("renders application/ld+json scripts with passthrough attributes", () => { + const html = renderToStaticMarkup( + , + ) + + expect(html).toContain('type="application/ld+json"') + expect(html).toContain('data-faq-schema="tool"') + expect(html).toContain('"@type":"FAQPage"') + }) +}) diff --git a/tests/unit/jwt-decoder-utils.test.ts b/tests/unit/jwt-decoder-utils.test.ts new file mode 100644 index 00000000..b61a2534 --- /dev/null +++ b/tests/unit/jwt-decoder-utils.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest" +import { decodeJwtParts } from "@/features/tools/jwt-decoder/utils" + +describe("jwt decoder utils", () => { + it("decodes header and payload without verifying signatures", () => { + const decoded = decodeJwtParts("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMiLCJuYW1lIjoiQnl0ZWZsb3cifQ.signature") + + expect(decoded.header).toEqual({ + alg: "HS256", + typ: "JWT", + }) + expect(decoded.payload).toEqual({ + sub: "123", + name: "Byteflow", + }) + }) + + it("throws for invalid JWT input", () => { + expect(() => decodeJwtParts("not-a-jwt")).toThrow() + }) +}) diff --git a/tests/unit/ndjson-formatter-utils.test.ts b/tests/unit/ndjson-formatter-utils.test.ts new file mode 100644 index 00000000..4499cb4f --- /dev/null +++ b/tests/unit/ndjson-formatter-utils.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest" +import { countNdjsonLines, runNdjsonTransform, type NdjsonMessages } from "@/features/tools/ndjson-formatter/utils" + +const messages: NdjsonMessages = { + error_label: "Error", + invalid_json_line_label: "Invalid JSON line", + input_must_be_array_label: "Input must be a JSON array", + invalid_json_label: "Invalid JSON", + error_parsing_line_label: "Error parsing line", +} + +describe("ndjson formatter utils", () => { + it("converts NDJSON records to a JSON array", () => { + expect(runNdjsonTransform("{\"id\":1}\n{\"id\":2}", "to-array", messages)).toBe("[\n {\n \"id\": 1\n },\n {\n \"id\": 2\n }\n]") + }) + + it("converts JSON arrays to NDJSON records", () => { + expect(runNdjsonTransform("[{\"id\":1},{\"id\":2}]", "to-ndjson", messages)).toBe("{\"id\":1}\n{\"id\":2}") + }) + + it("counts non-empty records", () => { + expect(countNdjsonLines("{\"id\":1}\n\n{\"id\":2}")).toBe(2) + }) +}) diff --git a/tests/unit/pipeline-foundation.test.ts b/tests/unit/pipeline-foundation.test.ts index eefc0aa3..9572d4b6 100644 --- a/tests/unit/pipeline-foundation.test.ts +++ b/tests/unit/pipeline-foundation.test.ts @@ -1,11 +1,13 @@ import { describe, expect, it } from "vitest" -import { getPipelineAdapter, getPipelineAdapterKeys } from "@/features/pipeline/adapter-registry" +import { getPipelineAdapter, getPipelineAdapterKeys, PIPELINE_TOOL_ADAPTERS } from "@/features/pipeline/adapter-registry" +import { TOOL_MANIFESTS } from "@/core/registry" import { decodeRecipeFromUrlParam, encodeRecipeForShareUrl, encodeRecipeForUrl, recipeContainsRuntimeInput } from "@/features/pipeline/recipe-codec" import { createEmptyRecipe, runRecipe, validateRecipe } from "@/features/pipeline/executor" import { exportRecipeToJson, importRecipeFromJson } from "@/features/pipeline/recipe-import-export" import { createRecipeFromTemplate, PIPELINE_RECIPE_TEMPLATES } from "@/features/pipeline/recipe-templates" import { isRecipeStoreAvailable } from "@/features/pipeline/recipe-store" import { DEFAULT_RECIPE_SETTINGS, type PipelineToolAdapter, type RecipeDocument } from "@/features/pipeline/recipe-types" +import { getStepCompatibilityHints } from "@/features/tools/pipeline-builder/logic" function buildRecipe(overrides: Partial = {}): RecipeDocument { const base: RecipeDocument = { @@ -47,10 +49,39 @@ describe("pipeline foundation", () => { "multiple_whitespace_remover", "invisible_chars_detector", "log_scrubber", + "yaml_json_converter", + "csv_json_converter", + "ndjson_formatter", + "slugify_case_converter", + "hash_generator", + "jwt_decoder", + "unix_timestamp", + "html_to_markdown", + "regex_tester", + "env_parser", ]) expect(getPipelineAdapter("json_formatter")?.version).toBe(1) }) + it("keeps adapter metadata explicit and aligned with canonical tool manifests", () => { + const adapterKeys = PIPELINE_TOOL_ADAPTERS.map((adapter) => adapter.toolKey) + const manifestKeys = new Set(TOOL_MANIFESTS.map((tool) => tool.key)) + + expect(new Set(adapterKeys).size).toBe(adapterKeys.length) + + for (const adapter of PIPELINE_TOOL_ADAPTERS) { + expect(manifestKeys.has(adapter.toolKey), `${adapter.toolKey} must map to a canonical tool manifest`).toBe(true) + expect(adapter.slug).toBe(TOOL_MANIFESTS.find((tool) => tool.key === adapter.toolKey)?.slug) + expect(["text", "json", "yaml", "csv", "bytes"]).toContain(adapter.inputKind) + expect(["text", "json", "yaml", "csv", "bytes"]).toContain(adapter.outputKind) + expect(adapter.deterministic).toBe(true) + expect(typeof adapter.safeForSensitiveInput).toBe("boolean") + expect(typeof adapter.mayIncreaseSize).toBe("boolean") + expect(Array.isArray(adapter.warnings)).toBe(true) + expect(adapter.publicOptionKeys.every((key) => Object.prototype.hasOwnProperty.call(adapter.defaultOptions, key))).toBe(true) + } + }) + it("validates a supported MVP recipe", () => { expect(validateRecipe(buildRecipe())).toEqual({ ok: true, errors: [] }) }) @@ -136,6 +167,263 @@ describe("pipeline foundation", () => { expect(result.finalOutput).toBe("eyJuYW1lIjoiYnl0ZWZsb3cifQ") }) + it("runs phase-one data format adapters", async () => { + const yamlRecipe = buildRecipe({ + steps: [{ + id: "yaml", + toolKey: "yaml_json_converter", + adapterVersion: 1, + inputMode: "previous_output", + options: { mode: "yaml-to-json" }, + }], + edges: [], + }) + const csvRecipe = buildRecipe({ + steps: [{ + id: "csv", + toolKey: "csv_json_converter", + adapterVersion: 1, + inputMode: "previous_output", + options: { direction: "csv-to-json", delimiter: "auto", hasHeader: true, typeInference: true }, + }], + edges: [], + }) + const ndjsonRecipe = buildRecipe({ + steps: [{ + id: "ndjson", + toolKey: "ndjson_formatter", + adapterVersion: 1, + inputMode: "previous_output", + options: { mode: "to-array" }, + }], + edges: [], + }) + const slugRecipe = buildRecipe({ + steps: [{ + id: "slug", + toolKey: "slugify_case_converter", + adapterVersion: 1, + inputMode: "previous_output", + options: { style: "slug", locale: "en-US", preserveAcronyms: true }, + }], + edges: [], + }) + + await expect(runRecipe(yamlRecipe, "name: byteflow\nactive: true\n")).resolves.toMatchObject({ + ok: true, + finalOutput: "{\n \"name\": \"byteflow\",\n \"active\": true\n}", + }) + await expect(runRecipe(csvRecipe, "id,name\n1,Alice")).resolves.toMatchObject({ + ok: true, + finalOutput: "[\n {\n \"id\": 1,\n \"name\": \"Alice\"\n }\n]", + }) + await expect(runRecipe(ndjsonRecipe, "{\"id\":1}\n{\"id\":2}")).resolves.toMatchObject({ + ok: true, + finalOutput: "[\n {\n \"id\": 1\n },\n {\n \"id\": 2\n }\n]", + }) + await expect(runRecipe(slugRecipe, "Hello Byteflow Tools")).resolves.toMatchObject({ + ok: true, + finalOutput: "hello-byteflow-tools", + }) + }) + + it("returns structured errors for phase-one adapter invalid input and options", async () => { + const badYamlOptions = validateRecipe(buildRecipe({ + steps: [{ + id: "yaml", + toolKey: "yaml_json_converter", + adapterVersion: 1, + inputMode: "previous_output", + options: { mode: "xml-to-json" }, + }], + edges: [], + })) + const badCsv = await runRecipe(buildRecipe({ + steps: [{ + id: "csv", + toolKey: "csv_json_converter", + adapterVersion: 1, + inputMode: "previous_output", + options: { direction: "json-to-csv", delimiter: "auto", hasHeader: true, typeInference: true }, + }], + edges: [], + }), "{\"not\":\"array\"}") + + expect(badYamlOptions.ok).toBe(false) + expect(badYamlOptions.errors).toContain("yaml: mode must be yaml-to-json or json-to-yaml.") + expect(badCsv.ok).toBe(false) + expect(badCsv.errors).toContain("csv: JSON input must be an array to convert to CSV.") + }) + + it("runs phase-two text utility adapters", async () => { + const hashRecipe = buildRecipe({ + steps: [{ + id: "hash", + toolKey: "hash_generator", + adapterVersion: 1, + inputMode: "previous_output", + options: { algorithm: "sha256" }, + }], + edges: [], + }) + const jwtRecipe = buildRecipe({ + steps: [{ + id: "jwt", + toolKey: "jwt_decoder", + adapterVersion: 1, + inputMode: "previous_output", + options: { part: "payload" }, + }], + edges: [], + }) + const unixRecipe = buildRecipe({ + steps: [{ + id: "time", + toolKey: "unix_timestamp", + adapterVersion: 1, + inputMode: "previous_output", + options: { output: "iso" }, + }], + edges: [], + }) + const htmlRecipe = buildRecipe({ + steps: [{ + id: "markdown", + toolKey: "html_to_markdown", + adapterVersion: 1, + inputMode: "previous_output", + options: {}, + }], + edges: [], + }) + const sampleJwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMiLCJuYW1lIjoiQnl0ZWZsb3cifQ.signature" + + await expect(runRecipe(hashRecipe, "hello")).resolves.toMatchObject({ + ok: true, + finalOutput: "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", + }) + await expect(runRecipe(jwtRecipe, sampleJwt)).resolves.toMatchObject({ + ok: true, + finalOutput: "{\n \"sub\": \"123\",\n \"name\": \"Byteflow\"\n}", + }) + await expect(runRecipe(unixRecipe, "1712810000")).resolves.toMatchObject({ + ok: true, + finalOutput: "2024-04-11T04:33:20.000Z", + }) + await expect(runRecipe(htmlRecipe, "

Title

Hello

")).resolves.toMatchObject({ + ok: true, + finalOutput: "# Title\n\nHello", + }) + }) + + it("returns structured errors for phase-two adapter invalid input and options", async () => { + const badHashOptions = validateRecipe(buildRecipe({ + steps: [{ + id: "hash", + toolKey: "hash_generator", + adapterVersion: 1, + inputMode: "previous_output", + options: { algorithm: "bcrypt" }, + }], + edges: [], + })) + const badJwt = await runRecipe(buildRecipe({ + steps: [{ + id: "jwt", + toolKey: "jwt_decoder", + adapterVersion: 1, + inputMode: "previous_output", + options: { part: "payload" }, + }], + edges: [], + }), "not-a-jwt") + const badTimestamp = await runRecipe(buildRecipe({ + steps: [{ + id: "time", + toolKey: "unix_timestamp", + adapterVersion: 1, + inputMode: "previous_output", + options: { output: "iso" }, + }], + edges: [], + }), "not-a-timestamp") + + expect(badHashOptions.ok).toBe(false) + expect(badHashOptions.errors).toContain("hash: algorithm must be md5, sha1, sha224, sha256, sha384, or sha512.") + expect(badJwt.ok).toBe(false) + expect(badJwt.steps[0].error?.code).toBe("jwt_decode_error") + expect(badTimestamp.ok).toBe(false) + expect(badTimestamp.steps[0].error?.code).toBe("timestamp_parse_error") + }) + + it("runs regex summary and env parser adapters", async () => { + const regexRecipe = buildRecipe({ + steps: [{ + id: "regex", + toolKey: "regex_tester", + adapterVersion: 1, + inputMode: "previous_output", + options: { pattern: "([A-Z][a-z]+)(\\d)", flags: "g", maxMatches: 10 }, + }], + edges: [], + }) + const envRecipe = buildRecipe({ + steps: [{ + id: "env", + toolKey: "env_parser", + adapterVersion: 1, + inputMode: "previous_output", + options: { format: "json" }, + }], + edges: [], + }) + + const regexResult = await runRecipe(regexRecipe, "Ab1 Cd2") + const envResult = await runRecipe(envRecipe, "PORT=3000\nSECRET=\"quoted value\"") + + expect(regexResult.ok).toBe(true) + expect(JSON.parse(regexResult.finalOutput)).toMatchObject({ + count: 2, + limited: false, + matches: [ + { match: "Ab1", index: 0, groupIndex: 0, groups: ["Ab", "1"] }, + { match: "Cd2", index: 4, groupIndex: 1, groups: ["Cd", "2"] }, + ], + }) + expect(envResult).toMatchObject({ + ok: true, + finalOutput: "{\n \"PORT\": \"3000\",\n \"SECRET\": \"quoted value\"\n}", + }) + }) + + it("rejects invalid regex and env adapter options", () => { + const badRegex = validateRecipe(buildRecipe({ + steps: [{ + id: "regex", + toolKey: "regex_tester", + adapterVersion: 1, + inputMode: "previous_output", + options: { pattern: "", flags: "g", maxMatches: 10 }, + }], + edges: [], + })) + const badEnv = validateRecipe(buildRecipe({ + steps: [{ + id: "env", + toolKey: "env_parser", + adapterVersion: 1, + inputMode: "previous_output", + options: { format: "xml" }, + }], + edges: [], + })) + + expect(badRegex.ok).toBe(false) + expect(badRegex.errors).toContain("regex: pattern is required.") + expect(badEnv.ok).toBe(false) + expect(badEnv.errors).toContain("env: format must be json, yaml, or docker-args.") + }) + it("allows empty edges and executes recipe.steps order", async () => { const result = await runRecipe(buildRecipe({ edges: [] }), '{ "name": "byteflow" }') @@ -144,6 +432,35 @@ describe("pipeline foundation", () => { expect(result.finalOutput).toBe("eyJuYW1lIjoiYnl0ZWZsb3cifQ") }) + it("executes explicit linear edges from the only start step instead of array order", async () => { + const recipe = buildRecipe({ + steps: [ + { + id: "encode", + toolKey: "base64_encode_decode", + adapterVersion: 1, + inputMode: "previous_output", + options: { operation: "encode", urlSafe: true }, + }, + { + id: "format", + toolKey: "json_formatter", + adapterVersion: 1, + inputMode: "previous_output", + options: { mode: "minify" }, + }, + ], + edges: [ + { fromStepId: "format", toStepId: "encode" }, + ], + }) + const result = await runRecipe(recipe, '{ "name": "byteflow" }') + + expect(result.ok).toBe(true) + expect(result.steps.map((step) => step.stepId)).toEqual(["format", "encode"]) + expect(result.finalOutput).toBe("eyJuYW1lIjoiYnl0ZWZsb3cifQ") + }) + it("accepts a valid explicit linear chain", () => { const recipe = buildRecipe({ steps: [ @@ -204,6 +521,19 @@ describe("pipeline foundation", () => { expect(result.errors).toContain(expectedError) }) + it("rejects edges that reference unknown step ids", () => { + const result = validateRecipe(buildRecipe({ + edges: [ + { fromStepId: "format", toStepId: "missing" }, + { fromStepId: "also_missing", toStepId: "encode" }, + ], + })) + + expect(result.ok).toBe(false) + expect(result.errors).toContain("Edge references unknown toStepId: missing.") + expect(result.errors).toContain("Edge references unknown fromStepId: also_missing.") + }) + it("stops on error when configured", async () => { const result = await runRecipe(buildRecipe(), "{broken json") @@ -232,6 +562,10 @@ describe("pipeline foundation", () => { version: 1, inputKind: "text", outputKind: "text", + safeForSensitiveInput: true, + deterministic: true, + mayIncreaseSize: false, + warnings: [], defaultOptions: {}, publicOptionKeys: [], validateOptions: () => ({ ok: true, errors: [] }), @@ -257,6 +591,66 @@ describe("pipeline foundation", () => { expect(result.steps[0].error?.code).toBe("adapter_runtime_error") }) + it("rejects oversized initial input before running adapters", async () => { + const result = await runRecipe( + buildRecipe({ + settings: { + ...DEFAULT_RECIPE_SETTINGS, + maxInputBytes: 4, + }, + }), + "too large", + ) + + expect(result.ok).toBe(false) + expect(result.steps).toEqual([]) + expect(result.errors).toContain("Initial input exceeds 4 bytes.") + expect(result.finalOutput).toBe("too large") + }) + + it("stops when a step output exceeds the configured output budget", async () => { + const result = await runRecipe( + buildRecipe({ + steps: [ + { + id: "encode", + toolKey: "base64_encode_decode", + adapterVersion: 1, + inputMode: "previous_output", + options: { operation: "encode", urlSafe: true }, + }, + ], + edges: [], + settings: { + ...DEFAULT_RECIPE_SETTINGS, + maxOutputBytes: 4, + }, + }), + "byteflow", + ) + + expect(result.ok).toBe(false) + expect(result.steps).toHaveLength(1) + expect(result.steps[0].error?.code).toBe("output_too_large") + expect(result.errors).toContain("Step encode output exceeds 4 bytes.") + }) + + it("omits intermediate output fields when configured", async () => { + const result = await runRecipe( + buildRecipe({ + settings: { + ...DEFAULT_RECIPE_SETTINGS, + keepIntermediateOutputs: false, + }, + }), + '{ "name": "byteflow" }', + ) + + expect(result.ok).toBe(true) + expect(result.finalOutput).toBe("eyJuYW1lIjoiYnl0ZWZsb3cifQ") + expect(result.steps.every((step) => !Object.prototype.hasOwnProperty.call(step, "output"))).toBe(true) + }) + it("uses constant input without leaking it into default share URLs", () => { const recipe = buildRecipe({ steps: [ @@ -283,6 +677,29 @@ describe("pipeline foundation", () => { } }) + it("keeps constant input only when share URLs explicitly include runtime input", () => { + const recipe = buildRecipe({ + steps: [ + { + id: "secret_sample", + toolKey: "log_scrubber", + adapterVersion: 1, + inputMode: "constant", + constantInput: "Authorization: Bearer secret-token-value", + options: {}, + }, + ], + edges: [], + }) + const decoded = decodeRecipeFromUrlParam(encodeRecipeForShareUrl(recipe, { includeRuntimeInput: true })) + + expect(decoded.ok).toBe(true) + if (decoded.ok) { + expect(decoded.recipe.steps[0].inputMode).toBe("constant") + expect(decoded.recipe.steps[0].constantInput).toBe("Authorization: Bearer secret-token-value") + } + }) + it("removes non-public options from share URLs", () => { const recipe = buildRecipe({ steps: [ @@ -314,6 +731,45 @@ describe("pipeline foundation", () => { } }) + it("reports adjacent pipeline step compatibility hints", () => { + const recipe = buildRecipe({ + steps: [ + { + id: "encode", + toolKey: "base64_encode_decode", + adapterVersion: 1, + inputMode: "previous_output", + options: { operation: "encode" }, + }, + { + id: "format", + toolKey: "json_formatter", + adapterVersion: 1, + inputMode: "previous_output", + options: { mode: "pretty", indent: 2 }, + }, + { + id: "constant_json", + toolKey: "json_formatter", + adapterVersion: 1, + inputMode: "constant", + constantInput: "{\"ok\":true}", + options: { mode: "minify" }, + }, + ], + edges: [], + }) + + expect(getStepCompatibilityHints(recipe.steps)).toEqual([ + { + fromKind: "text", + fromStepId: "encode", + toKind: "json", + toStepId: "format", + }, + ]) + }) + it("round trips recipe URL encoding", () => { const recipe = buildRecipe() const encoded = encodeRecipeForUrl(recipe) diff --git a/tests/unit/regex-tester-utils.test.ts b/tests/unit/regex-tester-utils.test.ts new file mode 100644 index 00000000..3fc7c43f --- /dev/null +++ b/tests/unit/regex-tester-utils.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest" +import { testRegexPattern } from "@/features/tools/regex-tester/utils" + +describe("regex tester utils", () => { + it("returns match summaries with captures", () => { + const result = testRegexPattern("([A-Z][a-z]+)(\\d)", "g", "Ab1 Cd2") + + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.matches).toEqual([ + { match: "Ab1", index: 0, groupIndex: 0, groups: ["Ab", "1"] }, + { match: "Cd2", index: 4, groupIndex: 1, groups: ["Cd", "2"] }, + ]) + } + }) + + it("returns structured invalid regex errors", () => { + const result = testRegexPattern("[", "g", "input") + + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.error).toMatch(/Invalid regular expression/) + } + }) +}) diff --git a/tests/unit/saml-decoder-utils.test.ts b/tests/unit/saml-decoder-utils.test.ts new file mode 100644 index 00000000..9c7a4aa2 --- /dev/null +++ b/tests/unit/saml-decoder-utils.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest" +import { PHASE4_LIMITS } from "@/core/utils/phase4-inspector-limits" +import { decodeSaml } from "@/features/tools/saml-decoder/utils" + +const SAMPLE_SAML = ` + https://idp.example.com + + + user@example.com + + + + + + + byteflow-tools + + + + admin + + +` + +function toBase64(value: string): string { + return Buffer.from(value, "utf8").toString("base64") +} + +describe("saml decoder utils", () => { + it("summarizes raw XML SAML responses", () => { + const result = decodeSaml(SAMPLE_SAML) + + expect(result.ok).toBe(true) + expect(result.summary).toMatchObject({ + bindingHint: "Raw XML", + rootElement: "Response", + issuer: "https://idp.example.com", + nameId: "user@example.com", + destination: "https://sp.example.com/acs", + assertionId: "_abc", + audience: "byteflow-tools", + recipient: "https://sp.example.com/acs", + }) + expect(result.summary?.attributes).toEqual([{ name: "role", values: ["admin"] }]) + }) + + it("decodes POST-binding base64 and URL query payloads", () => { + const encoded = toBase64(SAMPLE_SAML) + + expect(decodeSaml(encoded).summary?.bindingHint).toBe("Base64 payload") + expect(decodeSaml(`https://idp.example.com/sso?SAMLResponse=${encodeURIComponent(encoded)}`).summary?.bindingHint).toBe("SAMLResponse parameter") + expect(decodeSaml(`SAMLRequest=${encodeURIComponent(encoded)}`).summary?.bindingHint).toBe("SAMLRequest parameter") + }) + + it("returns malformed input errors without throwing", () => { + const result = decodeSaml("not saml") + + expect(result.ok).toBe(false) + expect(result.error).toMatch(/not XML|decode|parse|payload/i) + }) + + it("rejects raw inputs above the local budget before decoding", () => { + const result = decodeSaml("A".repeat(PHASE4_LIMITS.maxSamlRawInputBytes + 1)) + + expect(result.ok).toBe(false) + expect(result.error).toContain("too large") + }) +}) diff --git a/tests/unit/thumbnail-grabber-utils.test.ts b/tests/unit/thumbnail-grabber-utils.test.ts index 49f2036a..a8e35a71 100644 --- a/tests/unit/thumbnail-grabber-utils.test.ts +++ b/tests/unit/thumbnail-grabber-utils.test.ts @@ -14,6 +14,13 @@ describe("thumbnail-grabber-utils", () => { expect(parseYouTubeVideoId("https://www.youtube.com/shorts/abc123DEF45")).toBe("abc123DEF45") }) + it("rejects unsafe youtube urls", () => { + expect(parseYouTubeVideoId("javascript:alert(1)")).toBeNull() + expect(parseYouTubeVideoId("data:text/html,")).toBeNull() + expect(parseYouTubeVideoId("http://www.youtube.com/watch?v=dQw4w9WgXcQ")).toBeNull() + expect(parseYouTubeVideoId("https://notyoutube.com/watch?v=dQw4w9WgXcQ")).toBeNull() + }) + it("parses vimeo ids from regular and player urls", () => { expect(parseVimeoVideoId("https://vimeo.com/76979871")).toBe("76979871") expect(parseVimeoVideoId("https://player.vimeo.com/video/76979871?h=abc")).toBe("76979871") @@ -21,6 +28,13 @@ describe("thumbnail-grabber-utils", () => { expect(parseVimeoVideoId("https://vimeo.com/channels/staffpicks/76979871")).toBe("76979871") }) + it("rejects unsafe vimeo urls", () => { + expect(parseVimeoVideoId("javascript:alert(1)")).toBeNull() + expect(parseVimeoVideoId("data:text/html,")).toBeNull() + expect(parseVimeoVideoId("http://vimeo.com/76979871")).toBeNull() + expect(parseVimeoVideoId("https://notvimeo.com/76979871")).toBeNull() + }) + it("builds youtube thumbnail candidate list", () => { const items = buildYouTubeThumbnailCandidates("dQw4w9WgXcQ") expect(items).toHaveLength(5) diff --git a/tests/unit/tool-handoff.test.ts b/tests/unit/tool-handoff.test.ts index 7ad88fc1..b4d72deb 100644 --- a/tests/unit/tool-handoff.test.ts +++ b/tests/unit/tool-handoff.test.ts @@ -1,19 +1,24 @@ import { describe, expect, it } from "vitest" -import { buildToolHandoffHref, buildToolHandoffLink, getToolHandoffFromSearchParams } from "@/core/routing/tool-handoff" +import { + buildShareableToolHandoffHref, + buildToolHandoffHref, + buildToolHandoffLink, + getToolHandoffFromSearchParams, +} from "@/core/routing/tool-handoff" describe("tool handoff", () => { - it("builds a localized handoff URL", () => { - const href = buildToolHandoffHref("en", "json-to-typescript", '{"a":1}') + it("builds a localized shareable handoff URL", () => { + const href = buildShareableToolHandoffHref("en", "json-to-typescript", '{"a":1}') expect(href.startsWith("/en/json-to-typescript?handoff=")).toBe(true) }) it("returns base path when payload is empty", () => { - expect(buildToolHandoffHref("zh-CN", "javascript-minifier", " ")).toBe("/zh-CN/javascript-minifier") + expect(buildShareableToolHandoffHref("zh-CN", "javascript-minifier", " ")).toBe("/zh-CN/javascript-minifier") }) it("round-trips unicode payload from query params", () => { const payload = "你好, JSON\n{\"name\":\"ByteFlow\"}" - const href = buildToolHandoffHref("en", "json-to-typescript", payload) + const href = buildShareableToolHandoffHref("en", "json-to-typescript", payload) const query = href.split("?")[1] || "" const params = new URLSearchParams(query) expect(getToolHandoffFromSearchParams(params)).toBe(payload) @@ -25,16 +30,22 @@ describe("tool handoff", () => { }) it("fails fast when locale is missing instead of defaulting to English", () => { - expect(() => buildToolHandoffHref(" ", "json-to-typescript", '{"a":1}')).toThrow( + expect(() => buildShareableToolHandoffHref(" ", "json-to-typescript", '{"a":1}')).toThrow( "[i18n] tool handoff requires an explicit locale", ) }) - it("falls back to sessionStorage handoff for large payloads", () => { - const payload = "a".repeat(5000) + it("keeps the legacy handoff href export as explicit share-link behavior", () => { + const href = buildToolHandoffHref("en", "json-to-typescript", '{"a":1}') + expect(href.startsWith("/en/json-to-typescript?handoff=")).toBe(true) + }) + + it("uses sessionStorage handoff by default when available", () => { + const payload = '{"token":"secret"}' const handoff = buildToolHandoffLink("en", "json-to-typescript", payload) expect(handoff.href.startsWith("/en/json-to-typescript?handoff_ref=")).toBe(true) + expect(handoff.href).not.toContain("secret") handoff.prime() const query = handoff.href.split("?")[1] || "" diff --git a/tests/unit/tool-persistence.test.ts b/tests/unit/tool-persistence.test.ts index f9b41f46..c707842c 100644 --- a/tests/unit/tool-persistence.test.ts +++ b/tests/unit/tool-persistence.test.ts @@ -6,6 +6,7 @@ import { writeStorageJson, writeStorageString, } from "@/core/storage/tool-persistence" +import { enforceToolInputPersistencePolicy, shouldPersistToolInput } from "@/core/storage/tool-persistence-policy" describe("tool-persistence", () => { beforeEach(() => { @@ -50,4 +51,22 @@ describe("tool-persistence", () => { removeStorageKey(key) expect(readStorageString(key)).toBeNull() }) + + it("enforces input persistence policy", () => { + const key = "byteflow:test:policy" + + expect(shouldPersistToolInput({ persistInput: false })).toBe(false) + expect(shouldPersistToolInput({ persistInput: "opt-in" })).toBe(false) + expect(shouldPersistToolInput({ persistInput: true })).toBe(true) + + enforceToolInputPersistencePolicy({ persistInput: true, inputStorageKey: key, maxInputChars: 10 }, "payload") + expect(readStorageString(key)).toBe("payload") + + enforceToolInputPersistencePolicy({ persistInput: true, inputStorageKey: key, maxInputChars: 3 }, "payload") + expect(readStorageString(key)).toBeNull() + + writeStorageString(key, "old") + enforceToolInputPersistencePolicy({ persistInput: false, inputStorageKey: key }, "payload") + expect(readStorageString(key)).toBeNull() + }) }) diff --git a/tests/unit/yaml-json-converter-utils.test.ts b/tests/unit/yaml-json-converter-utils.test.ts new file mode 100644 index 00000000..f9c9ecb8 --- /dev/null +++ b/tests/unit/yaml-json-converter-utils.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest" +import { convertYamlJson } from "@/features/tools/yaml-json-converter/utils" + +describe("yaml json converter utils", () => { + it("converts YAML to pretty JSON", () => { + expect(convertYamlJson("name: byteflow\nactive: true\n", "yaml-to-json")).toBe("{\n \"name\": \"byteflow\",\n \"active\": true\n}") + }) + + it("converts JSON to YAML", () => { + expect(convertYamlJson("{\"name\":\"byteflow\"}", "json-to-yaml")).toBe("name: byteflow\n") + }) +})