From 61130237e6f628e286494c5a1749239a584a3674 Mon Sep 17 00:00:00 2001 From: LonelyGuy-SE1 Date: Mon, 9 Nov 2026 22:16:33 +0530 Subject: [PATCH 1/3] feat(site-qa): add local SEO and accessibility checklist Mini App Ship Site QA under plugins/lonelyguy-se1 so builders can check on-disk HTML, robots.txt, and sitemap.xml, then ask the Agent to fix findings from the Mini App or the bundled skill/CLI. --- README.md | 1 + README.zh-CN.md | 1 + .../site-qa/.minimax-plugin/plugin.json | 18 + plugins/lonelyguy-se1/site-qa/LICENSE | 21 + plugins/lonelyguy-se1/site-qa/README.md | 60 ++ plugins/lonelyguy-se1/site-qa/icon.png | Bin 0 -> 462 bytes .../site-qa/miniapp/client/app.css | 455 +++++++++ .../site-qa/miniapp/client/app.js | 335 +++++++ .../site-qa/miniapp/client/index.html | 89 ++ .../site-qa/miniapp/miniapp.json | 23 + .../site-qa/miniapp/node/audit.mjs | 948 ++++++++++++++++++ .../site-qa/miniapp/node/discover.mjs | 85 ++ .../site-qa/miniapp/node/html.mjs | 203 ++++ .../site-qa/miniapp/node/paths.mjs | 136 +++ .../site-qa/miniapp/node/prompts.mjs | 65 ++ .../site-qa/miniapp/node/scan.mjs | 114 +++ .../site-qa/miniapp/node/server.mjs | 228 +++++ .../site-qa/miniapp/node/sites.mjs | 108 ++ plugins/lonelyguy-se1/site-qa/package.json | 10 + .../site-qa/skills/site-qa/SKILL.md | 34 + .../site-qa/skills/site-qa/run.mjs | 71 ++ .../site-qa/tests/audit.test.mjs | 110 ++ .../lonelyguy-se1/site-qa/tests/cli.test.mjs | 38 + .../lonelyguy-se1/site-qa/tests/scan.test.mjs | 47 + .../site-qa/tests/server.test.mjs | 71 ++ .../site-qa/tests/sites.test.mjs | 19 + 26 files changed, 3290 insertions(+) create mode 100644 plugins/lonelyguy-se1/site-qa/.minimax-plugin/plugin.json create mode 100644 plugins/lonelyguy-se1/site-qa/LICENSE create mode 100644 plugins/lonelyguy-se1/site-qa/README.md create mode 100644 plugins/lonelyguy-se1/site-qa/icon.png create mode 100644 plugins/lonelyguy-se1/site-qa/miniapp/client/app.css create mode 100644 plugins/lonelyguy-se1/site-qa/miniapp/client/app.js create mode 100644 plugins/lonelyguy-se1/site-qa/miniapp/client/index.html create mode 100644 plugins/lonelyguy-se1/site-qa/miniapp/miniapp.json create mode 100644 plugins/lonelyguy-se1/site-qa/miniapp/node/audit.mjs create mode 100644 plugins/lonelyguy-se1/site-qa/miniapp/node/discover.mjs create mode 100644 plugins/lonelyguy-se1/site-qa/miniapp/node/html.mjs create mode 100644 plugins/lonelyguy-se1/site-qa/miniapp/node/paths.mjs create mode 100644 plugins/lonelyguy-se1/site-qa/miniapp/node/prompts.mjs create mode 100644 plugins/lonelyguy-se1/site-qa/miniapp/node/scan.mjs create mode 100644 plugins/lonelyguy-se1/site-qa/miniapp/node/server.mjs create mode 100644 plugins/lonelyguy-se1/site-qa/miniapp/node/sites.mjs create mode 100644 plugins/lonelyguy-se1/site-qa/package.json create mode 100644 plugins/lonelyguy-se1/site-qa/skills/site-qa/SKILL.md create mode 100644 plugins/lonelyguy-se1/site-qa/skills/site-qa/run.mjs create mode 100644 plugins/lonelyguy-se1/site-qa/tests/audit.test.mjs create mode 100644 plugins/lonelyguy-se1/site-qa/tests/cli.test.mjs create mode 100644 plugins/lonelyguy-se1/site-qa/tests/scan.test.mjs create mode 100644 plugins/lonelyguy-se1/site-qa/tests/server.test.mjs create mode 100644 plugins/lonelyguy-se1/site-qa/tests/sites.test.mjs diff --git a/README.md b/README.md index 97d9f7e..fe7f35d 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,7 @@ The MiniMax Code Agent communicates through the host MCP client. Business reques | [MCP OAuth Console](plugins/microbiosis/mcp-oauth-console/) | Sign in to MCP servers that require OAuth: device-code or authorization-code + PKCE flow, the `Authorization` header written into the MCP configuration, and an opt-in refresh before expiry | [Microbiosis](https://github.com/Microbiosis) | | [本机大盘 · Local Market Board](plugins/jzlikewei/kurumi-monitor/) | Watch your own machine as a stock terminal: CPU, GPU, temperature, memory, disk, network and every running process become listed stocks with live candlesticks, moving averages, a watchlist, a leaderboard and a trade tape, on macOS and Windows. Requires minimax Code 3.1.2 or later | [jzlikewei](https://github.com/jzlikewei) | | [Quota Bottle](plugins/7723qqq/quota-bottle/) | An octopus-shaped glass bottle that floats on your desktop and shows the remaining Token Plan quota from a login you configure yourself, with the liquid level and opacity following the remaining share, and a device-code login plus an opt-in local token-usage estimate as fallbacks | [7723qqq](https://github.com/7723qqq) | +| [Site QA](plugins/lonelyguy-se1/site-qa/) | Check local HTML/robots/sitemap for SEO and accessibility gaps, then ask the agent to fix them (Mini App + skill/CLI). Requires MiniMax Code 3.1.6 or later | [LonelyGuySE1](https://github.com/LonelyGuy-SE1) |
Preview: Cutline diff --git a/README.zh-CN.md b/README.zh-CN.md index 79c4d9a..acbdf5a 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -88,6 +88,7 @@ MiniMax Code Agent 通过宿主 MCP 客户端与 MiniApp 协作。业务请求 | [MCP OAuth 令牌台](plugins/microbiosis/mcp-oauth-console/README.zh-CN.md) | 给需要 OAuth 的 MCP 服务器完成登录:设备码或授权码 + PKCE 流程,把 `Authorization` 头写入 MCP 配置,并可选择在到期前自动续期 | [Microbiosis](https://github.com/Microbiosis) | | [本机大盘](plugins/jzlikewei/kurumi-monitor/README.zh-CN.md) | 把本机当成行情终端来看:CPU、GPU、温度、内存、磁盘、网络与每个运行中的进程都是一只个股,实时 K 线、均线、自选行情、主力动向与逐笔成交,支持 macOS 与 Windows。需要 minimax Code 3.1.2 或更高版本 | [jzlikewei](https://github.com/jzlikewei) | | [额度瓶](plugins/7723qqq/quota-bottle/README.zh-CN.md) | 一只悬浮在桌面上的章鱼形玻璃瓶,用你自己配置的登录读取 Token Plan 剩余额度;液面与透明度按剩余占比走,拿不到实时额度时回退到设备码登录或手填总额与可选的本机本月用量 | [7723qqq](https://github.com/7723qqq) | +| [Site QA](plugins/lonelyguy-se1/site-qa/) | 检查本地 HTML/robots/sitemap 的 SEO / 无障碍问题,再让 Agent 修复(Mini App + skill/CLI)。需要 MiniMax Code 3.1.6 或更高版本(说明文档为英文) | [LonelyGuySE1](https://github.com/LonelyGuy-SE1) |
预览:Cutline diff --git a/plugins/lonelyguy-se1/site-qa/.minimax-plugin/plugin.json b/plugins/lonelyguy-se1/site-qa/.minimax-plugin/plugin.json new file mode 100644 index 0000000..c8c4e03 --- /dev/null +++ b/plugins/lonelyguy-se1/site-qa/.minimax-plugin/plugin.json @@ -0,0 +1,18 @@ +{ + "schemaVersion": 1, + "name": "site-qa", + "displayName": "Site QA", + "version": "1.2.0", + "description": "Check the site you are building for SEO and accessibility gaps. Pick a site root, review findings, ask the agent to fix them.", + "author": "LonelyGuySE1", + "icon": "icon.png", + "category": "Code", + "exampleQueries": [ + "Open Site QA", + "Check this site for SEO problems", + "Scan my site for accessibility gaps" + ], + "apps": [], + "mcpServers": [], + "skills": ["skills/site-qa/SKILL.md"] +} diff --git a/plugins/lonelyguy-se1/site-qa/LICENSE b/plugins/lonelyguy-se1/site-qa/LICENSE new file mode 100644 index 0000000..bf8c9af --- /dev/null +++ b/plugins/lonelyguy-se1/site-qa/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 LonelyGuySE1 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/lonelyguy-se1/site-qa/README.md b/plugins/lonelyguy-se1/site-qa/README.md new file mode 100644 index 0000000..bf59397 --- /dev/null +++ b/plugins/lonelyguy-se1/site-qa/README.md @@ -0,0 +1,60 @@ +# Site QA (`site-qa`) + +Check the website project you are building for on-page SEO and accessibility gaps. Pick a site root, review findings, press **Ask agent** to put a fix prompt in the chat draft. You review it, press Send, and the Agent does that item. + +Author: [LonelyGuySE1](https://github.com/LonelyGuy-SE1) · Version: `1.2.0` + +Static HTML checklist aligned with common SEO starter guidance, Lighthouse-style signals, and WCAG items that can be judged from markup. Not a hosted Lighthouse / WAVE clone and not a WCAG conformance claim. + +## Install + +Copy this directory, including the hidden `.minimax-plugin/` directory, to: + +```text +/plugins/site-qa/ +``` + +`` defaults to `~/.minimax`. Restart MiniMax Code, then ask the Agent to “Open Site QA”, or open it from the plugin list. Prefer opening it from the conversation that owns the site project so the site-root menu can fill itself. + +## Use + +1. Open from the project chat. Site QA reads the project folder and lists likely site roots (`public/`, `dist/`, `docs/`, folders with `index.html`, etc.). +2. It auto-checks when a site root is ready. You can also press **Check site**. +3. Filter findings by severity or category. Press **Ask agent** on one finding, or **Ask agent to fix** for the top set. Confirm the Host append dialog, then press Send in chat. +4. The Agent can also run the same checks headlessly via the bundled skill / CLI (see below). + +## Agent skill / CLI + +The package ships `skills/site-qa/SKILL.md`. The Agent should open the Mini App for interactive review, or run: + +```bash +node ~/.minimax/plugins/site-qa/skills/site-qa/run.mjs /absolute/path/to/site +``` + +Use `--json` for machine-readable output. The skill tells the Agent not to dig through plugin source just to invent a parallel audit. + +## Tested environment + +- MiniMax Code 3.1.6 or later is required (`session.read` + `surface.window`). +- Package tests on Node.js 22 cover HTML rules, site-root discovery, path guards, scan discovery, HTTP entry, CLI output, and fix-prompt text. + +## Data & access + +- **Files:** reads HTML under the chosen site root (or project-folder candidates). Skips `node_modules`, VCS and cache directories, and hidden directories. Refuses home, filesystem roots, and MiniMax data dirs. Writes nothing outside its own runtime. The CLI uses the same scanner. +- **Network:** none. +- **Processes:** none beyond the Mini App Node runtime / CLI process you start. +- **Agent turns:** none started by this Mini App (`session.run` not declared). The skill may instruct the Agent to run the CLI in a shell. +- **Conversations:** `session.read` only for the current project folder. +- **Chat draft:** **Ask agent** / **Ask agent to fix** call `message.append` with Host confirmation. + +## Checks included + +**Document:** doctype, charset, favicon, `target=_blank` + `noopener`. + +**SEO:** title (presence, length, generic wording, cross-page duplicates), meta description (presence, length, duplicates), viewport, robots `noindex`/`nofollow`, canonical, hreflang validity, Open Graph + Twitter card, JSON-LD presence/parse/`@type`, H1, thin homepage copy, crawlable anchors, generic link text (“click here”), `robots.txt` (missing / blocks all / Sitemap line), `sitemap.xml`. + +**Accessibility:** `lang`, heading hierarchy / empty headings, image `alt`, link/button/ARIA-role names, form labels (`input`/`textarea`/`select`), iframe titles, `main` landmark, skip link, `aria-hidden` on focusable controls, positive `tabindex`, video captions track. + +## License + +[MIT](./LICENSE) diff --git a/plugins/lonelyguy-se1/site-qa/icon.png b/plugins/lonelyguy-se1/site-qa/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..e9054b8bba18b92f0499e2de2a03e71a4804de4d GIT binary patch literal 462 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7xzrV4Ujd;uunK>+LN=u0swYEEnz^ zIHvYsXU<;7EdIP?#amtl<}M;-<@XMhg#DIRN&9co*?|oHs4V~a<4O^4^l$gXjDrF^ zS`v+ntjY#65?I)TdJZTY5|nTn$584-$voN&Oak3nUZP?AQy%EWReatK7I&2kQQO0!DZ^13j z5bMUULvL7^64Wm!AMjIPC}U#WAj|>O#Bhg=2^coW;DI#*NFE{dg6Tl%PM%<(zyW2L z=pEJ_+y&ArVBUfFN|(7oq2>oz37bP0l+XkKq0N-) literal 0 HcmV?d00001 diff --git a/plugins/lonelyguy-se1/site-qa/miniapp/client/app.css b/plugins/lonelyguy-se1/site-qa/miniapp/client/app.css new file mode 100644 index 0000000..ee86111 --- /dev/null +++ b/plugins/lonelyguy-se1/site-qa/miniapp/client/app.css @@ -0,0 +1,455 @@ +:root { + color-scheme: light; + --bg: #f2f0ea; + --paper: #fffdf7; + --ink: #111111; + --muted: #3f3f3f; + --line: #111111; + --yellow: #ffe500; + --red: #ff2e00; + --lime: #b8ff3c; + --shadow: 5px 5px 0 #111111; + --pad: 28px; + --gap: 18px; + font-family: "Space Grotesk", "Segoe UI", sans-serif; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + min-height: 100vh; + color: var(--ink); + background: + repeating-linear-gradient( + -12deg, + transparent, + transparent 18px, + rgba(17, 17, 17, 0.03) 18px, + rgba(17, 17, 17, 0.03) 19px + ), + linear-gradient(160deg, #ebe7dc 0%, var(--bg) 45%, #e4efe0 100%); +} + +button, +input, +select { + font: inherit; + color: inherit; +} + +.frame { + max-width: 980px; + margin: 0 auto; + padding: calc(var(--pad) + 8px) var(--pad) 48px; + display: grid; + gap: var(--gap); +} + +.hero { + display: grid; + gap: 16px; + padding: 28px 28px 24px; + background: var(--paper); + border: 3px solid var(--line); + box-shadow: var(--shadow); +} + +.brand { + margin: 0 0 10px; + display: inline-block; + padding: 6px 10px; + background: var(--yellow); + border: 3px solid var(--line); + font-family: "IBM Plex Mono", monospace; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +h1 { + margin: 0; + max-width: 16ch; + font-size: clamp(32px, 5vw, 48px); + line-height: 0.98; + letter-spacing: -0.04em; + font-weight: 700; +} + +h2 { + margin: 0; + font-size: 14px; + font-family: "IBM Plex Mono", monospace; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.status { + margin: 0; + padding: 14px 16px; + border: 3px solid var(--line); + background: #fff; + font-size: 14px; + line-height: 1.45; + min-height: 3.2em; +} +.status[data-kind="bad"] { + background: #ffe8e2; + box-shadow: 4px 4px 0 var(--red); +} +.status[data-kind="ok"] { + background: #f1ffd8; + box-shadow: 4px 4px 0 #1f7a1f; +} + +.panel { + padding: 24px; + background: var(--paper); + border: 3px solid var(--line); + box-shadow: var(--shadow); +} + +.controls { + display: grid; + gap: 18px; +} + +.field { + display: grid; + gap: 8px; +} + +.label { + font-family: "IBM Plex Mono", monospace; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +input[type="text"] { + width: 100%; + min-height: 52px; + border: 3px solid var(--line); + background: #fff; + border-radius: 0; + padding: 12px 14px; + box-shadow: 3px 3px 0 #111111; +} + +.menu { + position: relative; + min-width: 0; +} + +.menu-trigger { + width: 100%; + min-height: 52px; + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + gap: 12px; + padding: 12px 14px; + border: 3px solid var(--line); + background: var(--yellow); + border-radius: 0; + box-shadow: 4px 4px 0 #111111; + cursor: pointer; + font-weight: 700; + text-align: left; +} + +.menu-trigger:hover { + transform: translate(-2px, -2px); + box-shadow: 6px 6px 0 #111111; +} + +.menu-trigger[aria-expanded="true"] { + background: var(--lime); + transform: translate(2px, 2px); + box-shadow: 2px 2px 0 #111111; +} + +.menu-trigger span:first-child { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.menu-caret { + width: 12px; + height: 12px; + border-right: 3px solid var(--line); + border-bottom: 3px solid var(--line); + transform: rotate(45deg) translate(-2px, -2px); + flex-shrink: 0; +} + +.menu-trigger[aria-expanded="true"] .menu-caret { + transform: rotate(225deg) translate(-2px, -2px); +} + +.menu-list { + position: absolute; + z-index: 20; + top: calc(100% + 8px); + left: 0; + right: 0; + margin: 0; + padding: 8px; + list-style: none; + max-height: 280px; + overflow: auto; + background: var(--paper); + border: 3px solid var(--line); + box-shadow: 5px 5px 0 #111111; +} + +.menu-list[hidden] { + display: none !important; +} + +.menu-option { + display: grid; + gap: 2px; + width: 100%; + padding: 12px 12px; + border: 3px solid transparent; + background: transparent; + text-align: left; + cursor: pointer; + font: inherit; + color: inherit; +} + +.menu-option:hover, +.menu-option[aria-selected="true"] { + background: var(--yellow); + border-color: var(--line); +} + +.menu-option strong { + font-size: 14px; + font-weight: 700; +} + +.menu-option small { + font-family: "IBM Plex Mono", monospace; + font-size: 11px; + color: var(--muted); +} + +input:focus, +button:focus-visible { + outline: 3px solid var(--yellow); + outline-offset: 2px; +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +.primary, +.ghost, +.fix { + min-height: 52px; + padding: 0 18px; + border: 3px solid var(--line); + border-radius: 0; + cursor: pointer; + font-weight: 700; + box-shadow: 4px 4px 0 #111111; + transition: transform 80ms ease, box-shadow 80ms ease; +} + +.primary { + background: var(--yellow); +} + +.ghost, +.fix { + background: #fff; +} + +.fix { + min-height: 42px; + padding: 0 14px; + font-size: 13px; +} + +.primary:hover, +.ghost:hover, +.fix:hover { + transform: translate(-2px, -2px); + box-shadow: 6px 6px 0 #111111; +} + +.primary:active, +.ghost:active, +.fix:active { + transform: translate(2px, 2px); + box-shadow: 2px 2px 0 #111111; +} + +button:disabled { + opacity: 0.45; + cursor: not-allowed; + transform: none; + box-shadow: 3px 3px 0 #111111; +} + +.summary { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 12px; +} + +.summary[hidden], +.filters[hidden], +.empty[hidden] { + display: none !important; +} + +.stat { + background: var(--paper); + border: 3px solid var(--line); + box-shadow: var(--shadow); + padding: 18px 16px; + display: grid; + gap: 6px; +} + +.stat.error { background: #ffe8e2; } +.stat.warn { background: #fff4b8; } + +.stat .num { + font-size: 34px; + font-weight: 700; + letter-spacing: -0.04em; + line-height: 1; +} + +.stat span:last-child { + font-family: "IBM Plex Mono", monospace; + font-size: 11px; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.filters { + display: flex; + flex-wrap: wrap; + gap: 10px; + padding: 18px 20px; +} + +.chip { + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 40px; + padding: 0 12px; + border: 3px solid var(--line); + background: #fff; + font-size: 13px; + font-weight: 600; + cursor: pointer; + box-shadow: 3px 3px 0 #111111; +} + +.chip:has(input:checked) { + background: var(--lime); +} + +.section-head { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: baseline; + margin-bottom: 16px; +} + +.meta { + margin: 0; + color: var(--muted); + font-family: "IBM Plex Mono", monospace; + font-size: 12px; + line-height: 1.4; + text-align: right; +} + +.findings { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 12px; +} + +.finding { + display: grid; + grid-template-columns: 96px 1fr auto; + gap: 16px; + align-items: start; + padding: 18px; + border: 3px solid var(--line); + background: #fff; + box-shadow: 4px 4px 0 #111111; +} + +.badge { + display: inline-flex; + justify-content: center; + align-items: center; + min-height: 32px; + padding: 4px 8px; + border: 3px solid var(--line); + font-family: "IBM Plex Mono", monospace; + font-size: 11px; + letter-spacing: 0.04em; + text-transform: uppercase; + font-weight: 700; +} + +.badge.error { + background: var(--red); + color: #fff; +} + +.badge.warning { + background: var(--yellow); +} + +.finding h3 { + margin: 0 0 8px; + font-size: 16px; + font-weight: 700; + line-height: 1.25; +} + +.finding p { + margin: 0; + color: var(--muted); + font-size: 13px; + line-height: 1.45; + word-break: break-word; + font-family: "IBM Plex Mono", monospace; +} + +.empty { + margin: 8px 0 0; + padding: 20px; + border: 3px dashed var(--line); + color: var(--muted); + font-size: 14px; +} + +@media (max-width: 860px) { + .summary { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .finding { grid-template-columns: 1fr; } + .row { grid-template-columns: 1fr; } + .meta { text-align: left; } +} diff --git a/plugins/lonelyguy-se1/site-qa/miniapp/client/app.js b/plugins/lonelyguy-se1/site-qa/miniapp/client/app.js new file mode 100644 index 0000000..eecb1b9 --- /dev/null +++ b/plugins/lonelyguy-se1/site-qa/miniapp/client/app.js @@ -0,0 +1,335 @@ +const rootInput = document.querySelector('#root'); +const siteMenu = document.querySelector('#site-menu'); +const siteTrigger = document.querySelector('#site-trigger'); +const siteLabel = document.querySelector('#site-label'); +const siteList = document.querySelector('#site-list'); +const statusNode = document.querySelector('#status'); +const findingsNode = document.querySelector('#findings'); +const emptyNode = document.querySelector('#empty'); +const summaryCards = document.querySelector('#summary-cards'); +const filtersNode = document.querySelector('#filters'); +const metaNode = document.querySelector('#meta'); +const scanButton = document.querySelector('#scan'); +const summaryButton = document.querySelector('#summary'); + +/** @type {any} */ +let report = null; +let filter = 'all'; +/** @type {{ path: string, label: string, reason: string }[]} */ +let sites = []; +/** @type {string} */ +let selectedSite = ''; +let checking = false; + +document.querySelectorAll('input[name="filter"]').forEach((input) => { + input.addEventListener('change', () => { + if (input.checked) { + filter = input.value; + renderFindings(); + } + }); +}); + +scanButton.addEventListener('click', () => void checkSite()); +summaryButton.addEventListener('click', () => void askAgentToFix()); +siteTrigger.addEventListener('click', () => { + if (siteTrigger.getAttribute('aria-expanded') === 'true') closeSiteMenu(); + else openSiteMenu(); +}); +document.addEventListener('click', (event) => { + if (!siteMenu.contains(event.target)) closeSiteMenu(); +}); +document.addEventListener('keydown', (event) => { + if (event.key === 'Escape') closeSiteMenu(); +}); +rootInput.addEventListener('keydown', (event) => { + if (event.key === 'Enter') { + event.preventDefault(); + void checkSite(); + } +}); + +void bootstrap(); + +async function bootstrap() { + const state = await api('/api/state'); + if (state?.root) rootInput.value = state.root; + if (state?.report) { + report = state.report; + paintReport(); + } + const folder = await detectWorkspace(); + if (folder) { + if (!rootInput.value) rootInput.value = folder; + await loadSites(folder); + if (!report && rootInput.value.trim()) { + setStatus(`Project ready. Checking ${rootInput.value}...`, 'ok'); + await checkSite({ quietStart: true }); + return; + } + setStatus('Project ready. Pick a site root or press Check site.', 'ok'); + } else if (!rootInput.value) { + setStatus('Open Site QA from a project chat, or paste a site path.', ''); + } +} + +async function detectWorkspace() { + const agent = window.mcode?.miniapp?.agent; + if (!agent || typeof agent.session !== 'function') return ''; + try { + const session = await Promise.race([ + agent.session(), + new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 4000)), + ]); + if (session && typeof session.workspaceDir === 'string' && session.workspaceDir.trim()) { + return session.workspaceDir.trim(); + } + } catch { + // Host may deny session.read or the Mini App may be open outside a chat. + } + return ''; +} + +async function loadSites(folder) { + const payload = await api('/api/sites', { path: folder }); + if (!payload?.sites) return; + sites = payload.sites; + renderSiteMenu(); + + const preferred = payload.sites.find((site) => /public|dist|out|docs|www|_site/i.test(site.label)) + || payload.sites.find((site) => site.label !== 'Workspace root') + || payload.sites[0]; + if (preferred && (!rootInput.value || rootInput.value === folder || rootInput.value === preferred.path)) { + chooseSite(preferred.path, false); + } else if (rootInput.value) { + const match = sites.find((site) => site.path === rootInput.value); + if (match) chooseSite(match.path, false); + else { + selectedSite = ''; + siteLabel.textContent = sites.length + ? `${sites.length} suggested site root${sites.length === 1 ? '' : 's'}` + : 'No site folders found'; + } + } +} + +function renderSiteMenu() { + siteList.replaceChildren(); + if (sites.length === 0) { + const empty = document.createElement('li'); + empty.className = 'menu-option'; + empty.textContent = 'No site folders found'; + siteList.append(empty); + return; + } + for (const site of sites) { + const li = document.createElement('li'); + li.setAttribute('role', 'option'); + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'menu-option'; + button.setAttribute('aria-selected', site.path === selectedSite ? 'true' : 'false'); + button.innerHTML = ''; + button.querySelector('strong').textContent = site.label; + button.querySelector('small').textContent = site.reason; + button.addEventListener('click', () => { + chooseSite(site.path, true); + closeSiteMenu(); + }); + li.append(button); + siteList.append(li); + } +} + +function chooseSite(path, announce) { + selectedSite = path; + rootInput.value = path; + const site = sites.find((item) => item.path === path); + siteLabel.textContent = site ? `${site.label}: ${site.reason}` : path; + renderSiteMenu(); + if (announce && site) setStatus(`Selected ${site.label}`, 'ok'); +} + +function openSiteMenu() { + siteList.hidden = false; + siteTrigger.setAttribute('aria-expanded', 'true'); +} + +function closeSiteMenu() { + siteList.hidden = true; + siteTrigger.setAttribute('aria-expanded', 'false'); +} + +async function checkSite(options = {}) { + const path = rootInput.value.trim(); + if (!path) { + setStatus('Choose a site root first.', 'bad'); + rootInput.focus(); + return false; + } + if (checking) return false; + checking = true; + let ok = false; + await runBusy(scanButton, 'Checking...', async () => { + const next = await api('/api/scan', { path }); + if (!next) return; + report = next; + paintReport(); + ok = true; + const total = next.counts.error + next.counts.warning; + if (options.quietStart && total === 0) { + setStatus(`Checked ${next.filesScanned} HTML file${next.filesScanned === 1 ? '' : 's'}. No automated issues.`, 'ok'); + return; + } + setStatus( + total === 0 + ? `Check finished. No automated issues in ${next.filesScanned} HTML file${next.filesScanned === 1 ? '' : 's'}.` + : `Check finished. ${next.counts.error} error${next.counts.error === 1 ? '' : 's'}, ${next.counts.warning} warning${next.counts.warning === 1 ? '' : 's'}.`, + total === 0 ? 'ok' : 'bad', + ); + }); + checking = false; + return ok; +} + +async function askAgentToFix() { + if (!report) { + const scanned = await checkSite(); + if (!scanned || !report) return; + } + if (report.findings.length === 0) { + setStatus('Nothing to send. The last check found no automated issues.', 'ok'); + return; + } + await sendPrompt('summary', undefined, summaryButton); +} + +function paintReport() { + if (!report) return; + summaryCards.hidden = false; + filtersNode.hidden = false; + document.querySelector('#count-error').textContent = String(report.counts.error); + document.querySelector('#count-warning').textContent = String(report.counts.warning); + document.querySelector('#count-seo').textContent = String(report.counts.seo); + document.querySelector('#count-a11y').textContent = String(report.counts.accessibility); + document.querySelector('#count-files').textContent = String(report.filesScanned); + const bits = [`${report.root}`]; + if (report.siteFiles?.robotsTxt) bits.push('robots.txt'); + if (report.siteFiles?.sitemapXml) bits.push('sitemap'); + if (report.truncated) bits.push('file cap reached'); + if (report.filesSkipped) bits.push(`${report.filesSkipped} skipped`); + metaNode.textContent = bits.join(' · '); + renderFindings(); +} + +function renderFindings() { + findingsNode.replaceChildren(); + if (!report) { + emptyNode.hidden = false; + emptyNode.textContent = 'Check a site folder to list SEO and accessibility gaps.'; + return; + } + const items = report.findings.filter((item) => { + if (filter === 'all') return true; + if (filter === 'error' || filter === 'warning') return item.severity === filter; + return item.category === filter; + }); + if (items.length === 0) { + emptyNode.hidden = false; + emptyNode.textContent = report.findings.length === 0 + ? 'No automated SEO or accessibility gaps in the scanned HTML.' + : 'No findings in this filter.'; + return; + } + emptyNode.hidden = true; + for (const item of items) { + const li = document.createElement('li'); + li.className = 'finding'; + const badge = document.createElement('span'); + badge.className = `badge ${item.severity}`; + badge.textContent = item.severity; + const body = document.createElement('div'); + const title = document.createElement('h3'); + title.textContent = item.message; + const detail = document.createElement('p'); + detail.textContent = [item.file, item.rule, item.detail].filter(Boolean).join(' · '); + body.append(title, detail); + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'fix'; + button.textContent = 'Ask agent'; + button.addEventListener('click', () => void sendPrompt('finding', item.id, button)); + li.append(badge, body, button); + findingsNode.append(li); + } +} + +async function sendPrompt(kind, id, button) { + const payload = kind === 'summary' ? { kind: 'summary' } : { kind: 'finding', id }; + await runBusy(button || summaryButton, 'Preparing...', async () => { + const result = await api('/api/prompt', payload); + if (!result?.text) return; + await appendToChat(result.text); + }); +} + +async function appendToChat(text) { + const message = window.mcode?.miniapp?.message; + if (!message?.append) { + try { + await navigator.clipboard.writeText(text); + setStatus('Copied the fix prompt. Paste it into chat and press Send.', 'ok'); + } catch { + setStatus('Could not reach chat or clipboard. Copy a finding manually.', 'bad'); + } + return; + } + try { + await message.append(text); + setStatus('Added to the chat input. Review it there and press Send.', 'ok'); + } catch (error) { + const code = error?.message; + if (code === 'user_cancelled') setStatus('Cancelled. Nothing was added.', 'bad'); + else if (code === 'target_unavailable') setStatus('Open Site QA from a conversation so it knows which chat to use.', 'bad'); + else if (code === 'result_unknown') setStatus('Result unknown. Check the chat draft before trying again.', 'bad'); + else setStatus(`Could not add the prompt (${code || 'error'}).`, 'bad'); + } +} + +async function api(path, body) { + try { + const response = await fetch(path, body + ? { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) } + : undefined); + const payload = await response.json(); + if (!response.ok) { + setStatus(payload.message || 'Request failed.', 'bad'); + return null; + } + return payload; + } catch { + setStatus('Site QA could not be reached. Reopen the Mini App if it was restarted.', 'bad'); + return null; + } +} + +async function runBusy(button, label, task) { + if (!button) { + await task(); + return; + } + const previous = button.textContent; + button.disabled = true; + button.textContent = label; + try { + await task(); + } finally { + button.disabled = false; + button.textContent = previous; + } +} + +function setStatus(message, kind) { + statusNode.textContent = message; + statusNode.dataset.kind = kind || ''; +} diff --git a/plugins/lonelyguy-se1/site-qa/miniapp/client/index.html b/plugins/lonelyguy-se1/site-qa/miniapp/client/index.html new file mode 100644 index 0000000..ce096bc --- /dev/null +++ b/plugins/lonelyguy-se1/site-qa/miniapp/client/index.html @@ -0,0 +1,89 @@ + + + + + + Site QA + + + + + + +
+
+
+

Site QA

+

Find what is hurting the site. Fix it in chat.

+
+

Pick a site root, then scan.

+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ + + + + +
+
+

Findings

+

+
+
    +

    Check a site folder to list SEO and accessibility gaps.

    +
    +
    + + + + diff --git a/plugins/lonelyguy-se1/site-qa/miniapp/miniapp.json b/plugins/lonelyguy-se1/site-qa/miniapp/miniapp.json new file mode 100644 index 0000000..16a4027 --- /dev/null +++ b/plugins/lonelyguy-se1/site-qa/miniapp/miniapp.json @@ -0,0 +1,23 @@ +{ + "schemaVersion": 1, + "artifacts": { + "client": ["./miniapp/client"], + "node": ["./miniapp/node"] + }, + "runtime": { + "kind": "process", + "entry": "./miniapp/node/server.mjs", + "lifecycle": "on-demand" + }, + "surface": { + "path": "/qa", + "window": { + "width": 980, + "height": 740, + "minWidth": 640, + "minHeight": 480 + } + }, + "agentAccess": ["session.read"], + "mcpEndpoints": [] +} diff --git a/plugins/lonelyguy-se1/site-qa/miniapp/node/audit.mjs b/plugins/lonelyguy-se1/site-qa/miniapp/node/audit.mjs new file mode 100644 index 0000000..e96cf1f --- /dev/null +++ b/plugins/lonelyguy-se1/site-qa/miniapp/node/audit.mjs @@ -0,0 +1,948 @@ +import { + accessibleName, + allMetaContent, + bodyText, + collectContainers, + collectTags, + documentLang, + firstTagInner, + hasCharset, + hasDoctype, + hasViewport, + isAbsoluteHttpUrl, + isGenericLinkText, + isNonCrawlableHref, + jsonLdBlocks, + looksLikeLang, + metaContent, + stripScriptsAndStyles, +} from './html.mjs'; + +/** + * @typedef {{ + * id: string, + * rule: string, + * severity: 'error' | 'warning', + * category: 'seo' | 'accessibility' | 'document', + * file: string, + * message: string, + * detail?: string, + * selectorHint?: string, + * }} Finding + */ + +/** + * Static on-page SEO + accessibility checklist for local HTML. + * Inspired by Google's SEO starter guide, Lighthouse SEO audits, and + * WCAG 2.2 Level A/AA items that can be judged from markup alone. + * + * @param {{ relative: string, html: string, skipped?: string }} page + * @returns {Finding[]} + */ +export function auditHtmlPage(page) { + if (page.skipped === 'too_large') { + return [ + finding({ + rule: 'file_too_large', + severity: 'warning', + category: 'document', + file: page.relative, + message: 'This HTML file is larger than 1.5 MB and was skipped.', + }), + ]; + } + + const raw = String(page.html ?? ''); + const html = stripScriptsAndStyles(raw); + const file = page.relative; + /** @type {Finding[]} */ + const out = []; + const isHome = /(?:^|\/)index\.html?$/i.test(file); + + if (!hasDoctype(raw)) { + out.push( + finding({ + rule: 'doctype_missing', + severity: 'warning', + category: 'document', + file, + message: 'The document is missing .', + detail: 'Browsers may render in quirks mode without it.', + }), + ); + } + + const lang = documentLang(html); + if (!lang) { + out.push( + finding({ + rule: 'html_lang_missing', + severity: 'error', + category: 'accessibility', + file, + message: 'The element has no lang attribute.', + selectorHint: 'html', + detail: 'Add lang="en" or the correct BCP 47 language code.', + }), + ); + } else if (!looksLikeLang(lang)) { + out.push( + finding({ + rule: 'html_lang_invalid', + severity: 'warning', + category: 'accessibility', + file, + message: `The lang value "${lang}" does not look like a valid language code.`, + selectorHint: 'html', + }), + ); + } + + if (!hasCharset(html)) { + out.push( + finding({ + rule: 'charset_missing', + severity: 'warning', + category: 'document', + file, + message: 'No character encoding meta tag was found.', + detail: 'Add early in .', + }), + ); + } + + const viewport = metaContent(html, 'viewport'); + if (!viewport) { + out.push( + finding({ + rule: 'viewport_missing', + severity: 'error', + category: 'seo', + file, + message: 'No viewport meta tag was found.', + detail: 'Add .', + }), + ); + } else if (!/width\s*=\s*device-width/i.test(viewport) && !/initial-scale/i.test(viewport)) { + out.push( + finding({ + rule: 'viewport_weak', + severity: 'warning', + category: 'seo', + file, + message: 'The viewport meta tag is missing width=device-width or initial-scale.', + detail: viewport, + }), + ); + } + + const title = firstTagInner(html, 'title'); + if (!title) { + out.push( + finding({ + rule: 'title_missing', + severity: 'error', + category: 'seo', + file, + message: 'The page has no .', + selectorHint: 'title', + }), + ); + } else { + if (title.length < 15) { + out.push( + finding({ + rule: 'title_too_short', + severity: 'warning', + category: 'seo', + file, + message: `The title is only ${title.length} characters.`, + detail: title, + selectorHint: 'title', + }), + ); + } else if (title.length > 60) { + out.push( + finding({ + rule: 'title_too_long', + severity: 'warning', + category: 'seo', + file, + message: `The title is ${title.length} characters (Google often truncates around 50-60).`, + detail: title, + selectorHint: 'title', + }), + ); + } + if (/^(untitled|home|page|document|new page)$/i.test(title.trim())) { + out.push( + finding({ + rule: 'title_generic', + severity: 'warning', + category: 'seo', + file, + message: 'The title looks generic and will not stand out in search results.', + detail: title, + selectorHint: 'title', + }), + ); + } + } + + const descriptions = allMetaContent(html, 'description'); + const description = descriptions[0] || ''; + if (!description) { + out.push( + finding({ + rule: 'meta_description_missing', + severity: 'error', + category: 'seo', + file, + message: 'No meta description was found.', + detail: 'Add <meta name="description" content="...">. It drives search snippet CTR.', + }), + ); + } else { + if (descriptions.length > 1) { + out.push( + finding({ + rule: 'meta_description_multiple', + severity: 'warning', + category: 'seo', + file, + message: `Found ${descriptions.length} meta description tags.`, + detail: 'Keep a single clear description per page.', + }), + ); + } + if (description.length < 70) { + out.push( + finding({ + rule: 'meta_description_too_short', + severity: 'warning', + category: 'seo', + file, + message: `The meta description is only ${description.length} characters.`, + detail: description, + }), + ); + } else if (description.length > 160) { + out.push( + finding({ + rule: 'meta_description_too_long', + severity: 'warning', + category: 'seo', + file, + message: `The meta description is ${description.length} characters (aim for about 120-160).`, + detail: description, + }), + ); + } + } + + const robots = metaContent(html, 'robots').toLowerCase(); + if (robots && /\bnoindex\b/.test(robots)) { + out.push( + finding({ + rule: 'meta_robots_noindex', + severity: isHome ? 'error' : 'warning', + category: 'seo', + file, + message: 'This page has a robots noindex directive.', + detail: robots, + }), + ); + } + if (robots && /\bnofollow\b/.test(robots)) { + out.push( + finding({ + rule: 'meta_robots_nofollow', + severity: 'warning', + category: 'seo', + file, + message: 'This page has a robots nofollow directive.', + detail: robots, + }), + ); + } + + const canonicals = collectTags(html, 'link').filter((tag) => /\bcanonical\b/i.test(tag.attrs.rel || '')); + if (canonicals.length === 0) { + out.push( + finding({ + rule: 'canonical_missing', + severity: 'warning', + category: 'seo', + file, + message: 'No rel=canonical link was found.', + detail: 'Add <link rel="canonical" href="https://example.com/this-page"> to avoid duplicate URL signals.', + }), + ); + } else { + if (canonicals.length > 1) { + out.push( + finding({ + rule: 'canonical_multiple', + severity: 'warning', + category: 'seo', + file, + message: `Found ${canonicals.length} canonical links.`, + }), + ); + } + const href = (canonicals[0].attrs.href || '').trim(); + if (!href) { + out.push( + finding({ + rule: 'canonical_empty', + severity: 'error', + category: 'seo', + file, + message: 'The canonical link has an empty href.', + }), + ); + } else if (!isAbsoluteHttpUrl(href) && !href.startsWith('/')) { + out.push( + finding({ + rule: 'canonical_invalid', + severity: 'warning', + category: 'seo', + file, + message: 'The canonical href should be an absolute URL or a root-relative path.', + detail: href, + }), + ); + } + } + + for (const link of collectTags(html, 'link')) { + const hreflang = (link.attrs.hreflang || '').trim(); + if (!hreflang) continue; + if (hreflang.toLowerCase() !== 'x-default' && !looksLikeLang(hreflang)) { + out.push( + finding({ + rule: 'hreflang_invalid', + severity: 'warning', + category: 'seo', + file, + message: `Invalid hreflang value "${hreflang}".`, + detail: link.attrs.href || '', + }), + ); + } + if (link.attrs.href && !isAbsoluteHttpUrl(link.attrs.href)) { + out.push( + finding({ + rule: 'hreflang_not_absolute', + severity: 'warning', + category: 'seo', + file, + message: 'hreflang alternate links should use absolute URLs.', + detail: link.attrs.href, + }), + ); + } + } + + for (const [property, rule, severity] of [ + ['og:title', 'og_title_missing', 'warning'], + ['og:description', 'og_description_missing', 'warning'], + ['og:image', 'og_image_missing', 'warning'], + ['og:url', 'og_url_missing', 'warning'], + ['og:type', 'og_type_missing', 'warning'], + ]) { + if (!metaContent(html, property)) { + out.push( + finding({ + rule, + severity, + category: 'seo', + file, + message: `Missing Open Graph tag ${property}.`, + detail: `Add <meta property="${property}" content="..."> for social previews.`, + }), + ); + } + } + + const ogImage = metaContent(html, 'og:image'); + if (ogImage && !isAbsoluteHttpUrl(ogImage) && !ogImage.startsWith('/')) { + out.push( + finding({ + rule: 'og_image_not_absolute', + severity: 'warning', + category: 'seo', + file, + message: 'og:image should be an absolute URL for crawlers and chat apps.', + detail: ogImage, + }), + ); + } + + if (!metaContent(html, 'twitter:card')) { + out.push( + finding({ + rule: 'twitter_card_missing', + severity: 'warning', + category: 'seo', + file, + message: 'No twitter:card meta tag was found.', + detail: 'Add <meta name="twitter:card" content="summary_large_image">.', + }), + ); + } + + const ldBlocks = jsonLdBlocks(raw); + if (ldBlocks.length === 0) { + out.push( + finding({ + rule: 'json_ld_missing', + severity: isHome ? 'warning' : 'warning', + category: 'seo', + file, + message: 'No JSON-LD structured data was found.', + detail: 'Add a <script type="application/ld+json"> block (Organization, WebSite, Article, FAQ, etc.).', + }), + ); + } else { + for (const block of ldBlocks.slice(0, 5)) { + try { + const parsed = JSON.parse(block); + const nodes = Array.isArray(parsed) ? parsed : parsed['@graph'] ? parsed['@graph'] : [parsed]; + for (const node of nodes) { + if (!node || typeof node !== 'object') continue; + if (!node['@type'] && !node['@context']) { + out.push( + finding({ + rule: 'json_ld_incomplete', + severity: 'warning', + category: 'seo', + file, + message: 'A JSON-LD block is missing @type.', + }), + ); + } + } + } catch { + out.push( + finding({ + rule: 'json_ld_invalid', + severity: 'error', + category: 'seo', + file, + message: 'A JSON-LD block is not valid JSON.', + detail: block.slice(0, 120), + }), + ); + } + } + } + + if (!collectTags(html, 'link').some((tag) => /\bicon\b/i.test(tag.attrs.rel || ''))) { + out.push( + finding({ + rule: 'favicon_missing', + severity: 'warning', + category: 'document', + file, + message: 'No favicon link was found.', + detail: 'Add <link rel="icon" href="/favicon.ico">.', + }), + ); + } + + const h1 = collectContainers(html, 'h1'); + if (h1.length === 0) { + out.push( + finding({ + rule: 'h1_missing', + severity: 'error', + category: 'seo', + file, + message: 'The page has no <h1>.', + selectorHint: 'h1', + }), + ); + } else if (h1.length > 1) { + out.push( + finding({ + rule: 'h1_multiple', + severity: 'warning', + category: 'seo', + file, + message: `The page has ${h1.length} <h1> headings.`, + detail: h1.map((item) => item.inner || '(empty)').slice(0, 5).join(' | '), + selectorHint: 'h1', + }), + ); + } else if (!h1[0].inner) { + out.push( + finding({ + rule: 'h1_empty', + severity: 'error', + category: 'seo', + file, + message: 'The <h1> is empty.', + selectorHint: 'h1', + }), + ); + } + + const ordered = []; + const headingRe = /<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1>/gi; + let hm; + while ((hm = headingRe.exec(html))) { + ordered.push({ + level: Number(hm[1]), + text: hm[2].replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(), + }); + } + let previous = 0; + for (const heading of ordered) { + if (!heading.text) { + out.push( + finding({ + rule: 'heading_empty', + severity: 'warning', + category: 'accessibility', + file, + message: `An h${heading.level} heading is empty.`, + selectorHint: `h${heading.level}`, + }), + ); + } + if (previous && heading.level > previous + 1) { + out.push( + finding({ + rule: 'heading_level_skip', + severity: 'warning', + category: 'accessibility', + file, + message: `Heading level jumps from h${previous} to h${heading.level}.`, + detail: heading.text || '(empty)', + selectorHint: `h${heading.level}`, + }), + ); + break; + } + previous = heading.level; + } + + const text = bodyText(html); + if (text.length > 0 && text.length < 200 && isHome) { + out.push( + finding({ + rule: 'thin_content', + severity: 'warning', + category: 'seo', + file, + message: `Visible body text looks thin (${text.length} characters).`, + detail: 'Search engines need enough unique copy to understand the page topic.', + }), + ); + } + + if (!/<main\b/i.test(html) && !/\brole\s*=\s*["']main["']/i.test(html)) { + out.push( + finding({ + rule: 'main_landmark_missing', + severity: 'warning', + category: 'accessibility', + file, + message: 'No <main> landmark was found.', + detail: 'Wrap primary content in <main> so assistive tech and crawlers can find it.', + }), + ); + } + + const hasSkip = collectContainers(html, 'a').some((anchor) => { + const href = (anchor.attrs.href || '').toLowerCase(); + const name = accessibleName(anchor.attrs, anchor.inner).toLowerCase(); + return (href.startsWith('#') && /skip|main|content/.test(name)) || /skip to (main|content)/i.test(name); + }); + if (!hasSkip) { + out.push( + finding({ + rule: 'skip_link_missing', + severity: 'warning', + category: 'accessibility', + file, + message: 'No skip-to-content link was found.', + detail: 'Add a first-focus link like “Skip to content” pointing at #main.', + }), + ); + } + + for (const img of collectTags(html, 'img')) { + if (!Object.prototype.hasOwnProperty.call(img.attrs, 'alt')) { + out.push( + finding({ + rule: 'img_missing_alt', + severity: 'error', + category: 'accessibility', + file, + message: 'An <img> is missing an alt attribute.', + detail: img.attrs.src || img.attrs['data-src'] || '', + selectorHint: 'img', + }), + ); + } else if ((img.attrs.alt || '').trim().length > 125) { + out.push( + finding({ + rule: 'img_alt_too_long', + severity: 'warning', + category: 'accessibility', + file, + message: 'An image alt text is longer than ~125 characters.', + detail: (img.attrs.alt || '').slice(0, 140), + selectorHint: 'img', + }), + ); + } + } + + for (const anchor of collectContainers(html, 'a')) { + const href = (anchor.attrs.href || '').trim(); + const name = accessibleName(anchor.attrs, anchor.inner); + if (isNonCrawlableHref(href)) { + out.push( + finding({ + rule: href.toLowerCase().startsWith('javascript:') ? 'a_javascript_href' : 'a_empty_href', + severity: 'warning', + category: 'seo', + file, + message: href.toLowerCase().startsWith('javascript:') + ? 'A link uses a javascript: href and is not crawlable.' + : 'A link has an empty or placeholder href.', + detail: name || href || '(no text)', + selectorHint: 'a', + }), + ); + } + if (!name) { + out.push( + finding({ + rule: 'a_empty_name', + severity: 'error', + category: 'accessibility', + file, + message: 'A link has no accessible name.', + detail: href || '(no href)', + selectorHint: 'a', + }), + ); + } else if (isGenericLinkText(name)) { + out.push( + finding({ + rule: 'a_generic_text', + severity: 'warning', + category: 'seo', + file, + message: `Link text “${name}” is too generic for SEO and screen readers.`, + detail: href || '', + selectorHint: 'a', + }), + ); + } + if ((anchor.attrs.target || '') === '_blank') { + const rel = (anchor.attrs.rel || '').toLowerCase(); + if (!/\bnoopener\b/.test(rel)) { + out.push( + finding({ + rule: 'a_target_blank_noopener', + severity: 'warning', + category: 'document', + file, + message: 'A target="_blank" link is missing rel="noopener".', + detail: href || name, + selectorHint: 'a', + }), + ); + } + } + } + + for (const button of collectContainers(html, 'button')) { + if (!accessibleName(button.attrs, button.inner)) { + out.push( + finding({ + rule: 'button_empty_name', + severity: 'error', + category: 'accessibility', + file, + message: 'A <button> has no accessible name.', + selectorHint: 'button', + }), + ); + } + } + + for (const el of [...collectTags(html, 'div'), ...collectTags(html, 'span')]) { + const role = (el.attrs.role || '').toLowerCase(); + if (!role) continue; + if (['button', 'link', 'menuitem', 'tab', 'checkbox', 'switch'].includes(role)) { + if (!accessibleName(el.attrs)) { + out.push( + finding({ + rule: 'aria_role_empty_name', + severity: 'error', + category: 'accessibility', + file, + message: `An element with role="${role}" has no accessible name.`, + detail: el.raw.slice(0, 120), + selectorHint: `[role="${role}"]`, + }), + ); + } + } + } + + for (const el of [...collectTags(html, 'a'), ...collectTags(html, 'button'), ...collectTags(html, 'input')]) { + if ((el.attrs['aria-hidden'] || '').toLowerCase() === 'true') { + out.push( + finding({ + rule: 'aria_hidden_focusable', + severity: 'warning', + category: 'accessibility', + file, + message: 'A focusable control is marked aria-hidden="true".', + detail: el.raw.slice(0, 120), + }), + ); + } + const tab = el.attrs.tabindex; + if (tab && Number(tab) > 0) { + out.push( + finding({ + rule: 'tabindex_positive', + severity: 'warning', + category: 'accessibility', + file, + message: 'A positive tabindex was found (prefer natural document order).', + detail: el.raw.slice(0, 120), + }), + ); + } + } + + const labels = collectTags(html, 'label'); + const labeledIds = new Set(labels.map((label) => label.attrs.for).filter(Boolean)); + for (const input of collectTags(html, 'input')) { + const type = (input.attrs.type || 'text').toLowerCase(); + if (['hidden', 'submit', 'button', 'reset', 'image'].includes(type)) continue; + const id = input.attrs.id || ''; + const named = accessibleName(input.attrs) || (id && labeledIds.has(id)); + if (!named) { + out.push( + finding({ + rule: 'input_missing_label', + severity: 'error', + category: 'accessibility', + file, + message: 'A form control has no label or accessible name.', + detail: [type, input.attrs.name || input.attrs.id || ''].filter(Boolean).join(' '), + selectorHint: 'input', + }), + ); + } + } + for (const tag of ['textarea', 'select']) { + for (const control of collectTags(html, tag)) { + const id = control.attrs.id || ''; + const named = accessibleName(control.attrs) || (id && labeledIds.has(id)); + if (!named) { + out.push( + finding({ + rule: `${tag}_missing_label`, + severity: 'error', + category: 'accessibility', + file, + message: `A <${tag}> has no label or accessible name.`, + detail: control.attrs.name || control.attrs.id || '', + selectorHint: tag, + }), + ); + } + } + } + + for (const frame of [...collectTags(html, 'iframe'), ...collectTags(html, 'frame')]) { + if (!accessibleName(frame.attrs)) { + out.push( + finding({ + rule: 'iframe_missing_title', + severity: 'error', + category: 'accessibility', + file, + message: 'An iframe has no title / accessible name.', + detail: frame.attrs.src || '', + selectorHint: 'iframe', + }), + ); + } + } + + if (collectTags(html, 'video').length > 0) { + const tracks = collectTags(html, 'track').filter((track) => /captions|subtitles/i.test(track.attrs.kind || 'subtitles')); + if (tracks.length === 0) { + out.push( + finding({ + rule: 'video_missing_captions', + severity: 'warning', + category: 'accessibility', + file, + message: 'A <video> was found without a captions/subtitles track.', + detail: 'Add <track kind="captions" ...>.', + }), + ); + } + } + + return out; +} + +/** + * Project-level SEO checks that need the whole scan (robots, sitemap, uniqueness). + * + * @param {{ + * root: string, + * pages: { relative: string, html?: string, title?: string, description?: string }[], + * siteFiles?: { robotsTxt?: string | null, sitemapXml?: string | null }, + * }} input + * @returns {Finding[]} + */ +export function auditSiteExtras(input) { + /** @type {Finding[]} */ + const out = []; + const file = '.'; + + if (!input.siteFiles?.robotsTxt) { + out.push( + finding({ + rule: 'robots_txt_missing', + severity: 'warning', + category: 'seo', + file, + message: 'No robots.txt was found at the site root.', + detail: 'Add robots.txt so crawlers know what to index. Include a Sitemap: line when you have one.', + }), + ); + } else { + const text = input.siteFiles.robotsTxt; + if (/syntax error|<<<<<<<|>>>>>>>/i.test(text)) { + out.push( + finding({ + rule: 'robots_txt_suspicious', + severity: 'warning', + category: 'seo', + file: 'robots.txt', + message: 'robots.txt looks malformed or contains merge-conflict markers.', + }), + ); + } + if (/^\s*disallow\s*:\s*\/\s*$/im.test(text) && !/^\s*allow\s*:/im.test(text)) { + out.push( + finding({ + rule: 'robots_txt_blocks_all', + severity: 'error', + category: 'seo', + file: 'robots.txt', + message: 'robots.txt Disallow: / blocks the whole site from crawling.', + }), + ); + } + if (!/^\s*sitemap\s*:/im.test(text)) { + out.push( + finding({ + rule: 'robots_txt_no_sitemap', + severity: 'warning', + category: 'seo', + file: 'robots.txt', + message: 'robots.txt does not declare a Sitemap: URL.', + }), + ); + } + } + + if (!input.siteFiles?.sitemapXml) { + out.push( + finding({ + rule: 'sitemap_missing', + severity: 'warning', + category: 'seo', + file, + message: 'No sitemap.xml was found at the site root.', + detail: 'Add sitemap.xml (or sitemap_index.xml) listing your public URLs.', + }), + ); + } else if (!/<url\b/i.test(input.siteFiles.sitemapXml) && !/<sitemap\b/i.test(input.siteFiles.sitemapXml)) { + out.push( + finding({ + rule: 'sitemap_empty', + severity: 'warning', + category: 'seo', + file: 'sitemap.xml', + message: 'sitemap.xml does not contain <url> or <sitemap> entries.', + }), + ); + } + + /** @type {Map<string, string[]>} */ + const titles = new Map(); + /** @type {Map<string, string[]>} */ + const descriptions = new Map(); + for (const page of input.pages) { + if (!page.title) continue; + const key = page.title.toLowerCase(); + titles.set(key, [...(titles.get(key) || []), page.relative]); + } + for (const page of input.pages) { + if (!page.description) continue; + const key = page.description.toLowerCase(); + descriptions.set(key, [...(descriptions.get(key) || []), page.relative]); + } + for (const [title, files] of titles) { + if (files.length < 2) continue; + out.push( + finding({ + rule: 'title_duplicate', + severity: 'error', + category: 'seo', + file: files[0], + message: `Duplicate <title> shared by ${files.length} pages.`, + detail: `${title} · ${files.slice(0, 6).join(', ')}`, + }), + ); + } + for (const [description, files] of descriptions) { + if (files.length < 2) continue; + out.push( + finding({ + rule: 'meta_description_duplicate', + severity: 'warning', + category: 'seo', + file: files[0], + message: `Duplicate meta description shared by ${files.length} pages.`, + detail: `${description.slice(0, 100)} · ${files.slice(0, 6).join(', ')}`, + }), + ); + } + + return out; +} + +/** + * @param {Omit<Finding, 'id'> & { id?: string }} input + * @returns {Finding} + */ +function finding(input) { + const id = input.id || `${input.rule}:${input.file}:${hash(input.detail || input.message)}`; + return { ...input, id }; +} + +/** @param {string} text */ +function hash(text) { + let value = 0; + for (let i = 0; i < text.length; i += 1) value = (value * 31 + text.charCodeAt(i)) >>> 0; + return value.toString(16).slice(0, 6); +} diff --git a/plugins/lonelyguy-se1/site-qa/miniapp/node/discover.mjs b/plugins/lonelyguy-se1/site-qa/miniapp/node/discover.mjs new file mode 100644 index 0000000..331657e --- /dev/null +++ b/plugins/lonelyguy-se1/site-qa/miniapp/node/discover.mjs @@ -0,0 +1,85 @@ +import { readdir, readFile, stat } from 'node:fs/promises'; +import path from 'node:path'; + +import { isHtmlFile, relativeToRoot, resolveUnderRoot, shouldSkipDir } from './paths.mjs'; + +export const MAX_FILES = 80; +export const MAX_DEPTH = 8; +export const MAX_BYTES = 1_500_000; + +/** + * @param {string} root + * @param {{ signal?: AbortSignal }} [options] + */ +export async function discoverHtmlFiles(root, options = {}) { + /** @type {string[]} */ + const skipped = []; + const found = await walk(root, root, 0, options.signal, skipped); + const truncated = found.length > MAX_FILES; + const files = found.slice(0, MAX_FILES); + + files.sort((a, b) => { + const ra = relativeToRoot(root, a); + const rb = relativeToRoot(root, b); + const ia = /(?:^|\/)index\.html?$/i.test(ra) ? 0 : 1; + const ib = /(?:^|\/)index\.html?$/i.test(rb) ? 0 : 1; + if (ia !== ib) return ia - ib; + return ra.localeCompare(rb); + }); + + return { files, truncated, skipped: [...new Set(skipped)].slice(0, 20) }; +} + +/** + * @param {string} dir + * @param {string} root + * @param {number} depth + * @param {AbortSignal | undefined} signal + * @param {string[]} skipped + */ +async function walk(dir, root, depth, signal, skipped) { + if (signal?.aborted) throw new Error('aborted'); + if (depth > MAX_DEPTH) return []; + /** @type {string[]} */ + const out = []; + let entries; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch { + skipped.push(relativeToRoot(root, dir) || '.'); + return out; + } + for (const entry of entries) { + if (signal?.aborted) throw new Error('aborted'); + if (entry.name === '.' || entry.name === '..') continue; + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (shouldSkipDir(entry.name)) continue; + out.push(...(await walk(full, root, depth + 1, signal, skipped))); + continue; + } + if (!entry.isFile() || !isHtmlFile(entry.name)) continue; + try { + await resolveUnderRoot(root, full); + } catch { + continue; + } + out.push(full); + } + return out; +} + +/** + * @param {string} root + * @param {string} filePath + */ +export async function readHtmlFile(root, filePath) { + const real = await resolveUnderRoot(root, filePath); + const info = await stat(real); + if (!info.isFile()) throw new Error('not_file'); + if (info.size > MAX_BYTES) { + return { path: real, relative: relativeToRoot(root, real), html: '', skipped: 'too_large', bytes: info.size }; + } + const html = await readFile(real, 'utf8'); + return { path: real, relative: relativeToRoot(root, real), html, skipped: '', bytes: info.size }; +} diff --git a/plugins/lonelyguy-se1/site-qa/miniapp/node/html.mjs b/plugins/lonelyguy-se1/site-qa/miniapp/node/html.mjs new file mode 100644 index 0000000..46afbed --- /dev/null +++ b/plugins/lonelyguy-se1/site-qa/miniapp/node/html.mjs @@ -0,0 +1,203 @@ +// Small HTML helpers for static audits. This is not a browser; it only pulls +// the tags and attributes the checks need. + +/** + * @param {string} html + * @returns {string} + */ +export function stripScriptsAndStyles(html) { + return String(html ?? '') + .replace(/<script\b(?![^>]*\btype\s*=\s*["']application\/ld\+json["'])[^>]*>[\s\S]*?<\/script>/gi, '') + .replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, '') + .replace(/<!--[\s\S]*?-->/g, ''); +} + +/** + * @param {string} html + * @param {string} tag + */ +export function firstTagInner(html, tag) { + const match = new RegExp(`<${tag}\\b[^>]*>([\\s\\S]*?)<\\/${tag}>`, 'i').exec(html); + return match ? decodeEntities(match[1]).trim() : ''; +} + +/** + * @param {string} html + * @param {string} tag + */ +export function collectTags(html, tag) { + const out = []; + const re = new RegExp(`<${tag}\\b([^>]*)\\/?>`, 'gi'); + let match; + while ((match = re.exec(html))) { + out.push({ attrs: parseAttrs(match[1]), index: match.index, raw: match[0] }); + } + return out; +} + +/** + * @param {string} html + * @param {string} tag + */ +export function collectContainers(html, tag) { + const out = []; + const re = new RegExp(`<${tag}\\b([^>]*)>([\\s\\S]*?)<\\/${tag}>`, 'gi'); + let match; + while ((match = re.exec(html))) { + out.push({ + attrs: parseAttrs(match[1]), + inner: decodeEntities(match[2]).replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(), + innerHtml: match[2], + index: match.index, + }); + } + return out; +} + +/** @param {string} attrText */ +export function parseAttrs(attrText) { + /** @type {Record<string, string>} */ + const attrs = {}; + const re = /([^\s=/>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g; + let match; + while ((match = re.exec(attrText))) { + const name = match[1].toLowerCase(); + const value = match[2] ?? match[3] ?? match[4] ?? ''; + attrs[name] = decodeEntities(value); + } + return attrs; +} + +/** + * @param {string} html + * @param {string} name + */ +export function metaContent(html, name) { + const tags = collectTags(html, 'meta'); + for (const tag of tags) { + const key = (tag.attrs.name || tag.attrs.property || tag.attrs.itemprop || '').toLowerCase(); + if (key === name.toLowerCase()) return (tag.attrs.content || '').trim(); + } + return ''; +} + +/** + * @param {string} html + * @param {string} name + */ +export function allMetaContent(html, name) { + return collectTags(html, 'meta') + .filter((tag) => { + const key = (tag.attrs.name || tag.attrs.property || tag.attrs.itemprop || '').toLowerCase(); + return key === name.toLowerCase(); + }) + .map((tag) => (tag.attrs.content || '').trim()) + .filter(Boolean); +} + +/** @param {string} html */ +export function hasCharset(html) { + if (/<meta\b[^>]*charset\s*=/i.test(html)) return true; + return collectTags(html, 'meta').some( + (tag) => + (tag.attrs['http-equiv'] || '').toLowerCase() === 'content-type' && /charset=/i.test(tag.attrs.content || ''), + ); +} + +/** @param {string} html */ +export function hasViewport(html) { + return Boolean(metaContent(html, 'viewport')); +} + +/** @param {string} html */ +export function documentLang(html) { + const htmlTag = /<html\b([^>]*)>/i.exec(html); + if (!htmlTag) return ''; + return (parseAttrs(htmlTag[1]).lang || '').trim(); +} + +/** @param {string} html */ +export function hasDoctype(html) { + return /^\s*<!doctype\s+html\b/i.test(String(html ?? '')); +} + +/** @param {string} html */ +export function bodyText(html) { + const body = /<body\b[^>]*>([\s\S]*?)<\/body>/i.exec(html); + const source = body ? body[1] : html; + return decodeEntities(source.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ')).trim(); +} + +/** @param {string} html */ +export function jsonLdBlocks(html) { + const out = []; + const re = /<script\b[^>]*type\s*=\s*["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi; + let match; + while ((match = re.exec(html))) { + out.push(match[1].trim()); + } + return out; +} + +/** @param {string} text */ +export function decodeEntities(text) { + return String(text ?? '') + .replace(/ /gi, ' ') + .replace(/&/gi, '&') + .replace(/</gi, '<') + .replace(/>/gi, '>') + .replace(/"/gi, '"') + .replace(/'/gi, "'") + .replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n))) + .replace(/&#x([0-9a-f]+);/gi, (_, n) => String.fromCharCode(parseInt(n, 16))); +} + +/** @param {Record<string, string>} attrs */ +export function accessibleName(attrs, inner = '') { + return (attrs['aria-label'] || attrs['aria-labelledby'] || attrs.title || attrs.alt || inner || '').trim(); +} + +/** @param {string} href */ +export function isNonCrawlableHref(href) { + const value = String(href ?? '').trim().toLowerCase(); + if (!value || value === '#') return true; + if (value.startsWith('javascript:')) return true; + if (value.startsWith('void(')) return true; + return false; +} + +/** @param {string} text */ +export function isGenericLinkText(text) { + const normalized = String(text ?? '') + .toLowerCase() + .replace(/[^\p{L}\p{N}\s]/gu, ' ') + .replace(/\s+/g, ' ') + .trim(); + if (!normalized) return false; + return [ + 'click here', + 'click', + 'here', + 'read more', + 'learn more', + 'more', + 'more info', + 'more information', + 'details', + 'link', + 'this link', + 'this page', + 'go', + 'continue', + ].includes(normalized); +} + +/** @param {string} code */ +export function looksLikeLang(code) { + return /^[a-z]{2,3}(-[a-z0-9]{2,8})*$/i.test(String(code ?? '').trim()); +} + +/** @param {string} url */ +export function isAbsoluteHttpUrl(url) { + return /^https?:\/\//i.test(String(url ?? '').trim()); +} diff --git a/plugins/lonelyguy-se1/site-qa/miniapp/node/paths.mjs b/plugins/lonelyguy-se1/site-qa/miniapp/node/paths.mjs new file mode 100644 index 0000000..49bef08 --- /dev/null +++ b/plugins/lonelyguy-se1/site-qa/miniapp/node/paths.mjs @@ -0,0 +1,136 @@ +// Every path the scanner opens is resolved and kept under the root the user +// chose. The Host data directory and filesystem roots are refused. + +import { homedir } from 'node:os'; +import path from 'node:path'; +import { realpath, stat } from 'node:fs/promises'; + +export class PathError extends Error { + /** @param {string} code @param {string} message */ + constructor(code, message) { + super(message); + this.name = 'PathError'; + this.code = code; + } +} + +const SKIP_DIRS = new Set([ + '.git', + '.svn', + '.hg', + 'node_modules', + '.next', + '.nuxt', + '.output', + '.turbo', + '.cache', + 'coverage', + '__pycache__', + '.venv', + 'venv', + '.minimax', + '.mavis', +]); + +/** + * @param {string} input + * @param {{ home?: string, minimaxDataDir?: string }} [options] + */ +export async function assertProjectRoot(input, options = {}) { + const raw = String(input ?? '').trim(); + if (!raw) throw new PathError('path_required', 'Choose a project folder first.'); + if (raw.includes('\0')) throw new PathError('path_invalid', 'That path is not usable.'); + + let resolved; + try { + resolved = await realpath(path.resolve(raw)); + } catch (error) { + if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') { + throw new PathError('path_missing', 'That folder does not exist.'); + } + throw new PathError('path_invalid', 'That path could not be opened.'); + } + + const info = await stat(resolved); + if (!info.isDirectory()) throw new PathError('path_not_directory', 'Point Site QA at a folder, not a file.'); + + const home = await safeReal(options.home ?? homedir()); + if (home && samePath(resolved, home)) { + throw new PathError('path_too_broad', 'The home folder is too broad. Open the site project folder instead.'); + } + if (isFilesystemRoot(resolved)) { + throw new PathError('path_too_broad', 'A drive root is too broad. Open the site project folder instead.'); + } + + const dataDir = options.minimaxDataDir ? await safeReal(options.minimaxDataDir) : await safeReal(path.join(home || '', '.minimax')); + if (dataDir && (samePath(resolved, dataDir) || isInside(resolved, dataDir))) { + throw new PathError('path_forbidden', 'Site QA does not scan MiniMax data directories. Pick the site project instead.'); + } + + return resolved; +} + +/** + * @param {string} root + * @param {string} candidate + */ +export async function resolveUnderRoot(root, candidate) { + const absolute = path.resolve(root, candidate); + let real; + try { + real = await realpath(absolute); + } catch { + // The path may not exist yet for a planned write; still confine the string form. + if (!isInside(absolute, root) && !samePath(absolute, root)) { + throw new PathError('path_escape', 'That file is outside the project folder.'); + } + return absolute; + } + if (!isInside(real, root) && !samePath(real, root)) { + throw new PathError('path_escape', 'That file is outside the project folder.'); + } + return real; +} + +/** @param {string} name */ +export function shouldSkipDir(name) { + return SKIP_DIRS.has(name) || name.startsWith('.'); +} + +/** @param {string} filePath */ +export function isHtmlFile(filePath) { + return /\.html?$/i.test(filePath); +} + +/** @param {string} root @param {string} filePath */ +export function relativeToRoot(root, filePath) { + const rel = path.relative(root, filePath); + return rel.split(path.sep).join('/'); +} + +/** @param {string} a @param {string} b */ +export function samePath(a, b) { + return path.resolve(a) === path.resolve(b); +} + +/** @param {string} child @param {string} parent */ +export function isInside(child, parent) { + const rel = path.relative(parent, child); + return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel); +} + +/** @param {string} dir */ +function isFilesystemRoot(dir) { + const parsed = path.parse(dir); + return samePath(dir, parsed.root); +} + +/** @param {string} candidate */ +async function safeReal(candidate) { + if (!candidate) return ''; + try { + return await realpath(candidate); + } catch { + return path.resolve(candidate); + } +} diff --git a/plugins/lonelyguy-se1/site-qa/miniapp/node/prompts.mjs b/plugins/lonelyguy-se1/site-qa/miniapp/node/prompts.mjs new file mode 100644 index 0000000..16a8eee --- /dev/null +++ b/plugins/lonelyguy-se1/site-qa/miniapp/node/prompts.mjs @@ -0,0 +1,65 @@ +/** + * Builds the text placed in the chat draft when the user clicks Fix. + * The Host confirms before it is appended; the user still presses Send. + * + * @param {{ + * rule: string, + * severity: string, + * category: string, + * file: string, + * message: string, + * detail?: string, + * selectorHint?: string, + * }} finding + * @param {{ root: string }} context + */ +export function buildFixPrompt(finding, context) { + const lines = [ + 'Fix this Site QA finding in the current project. Do only this item, then stop.', + '', + `Project folder: ${context.root}`, + `File: ${finding.file}`, + `Category: ${finding.category}`, + `Severity: ${finding.severity}`, + `Rule: ${finding.rule}`, + `Issue: ${finding.message}`, + ]; + if (finding.detail) lines.push(`Detail: ${finding.detail}`); + if (finding.selectorHint) lines.push(`Likely element: ${finding.selectorHint}`); + lines.push( + '', + 'Requirements:', + '- Edit the real source files for this page (not a one-off explanation).', + '- Keep existing design and copy unless the issue requires a small text change.', + '- After the change, say which file you edited and how to verify the issue is gone.', + '- Do not start unrelated refactors.', + ); + return lines.join('\n'); +} + +/** + * @param {{ findings: object[], root: string, filesScanned: number }} report + */ +export function buildSummaryPrompt(report) { + const errors = report.findings.filter((item) => item.severity === 'error'); + const warnings = report.findings.filter((item) => item.severity === 'warning'); + const top = errors.concat(warnings).slice(0, 8); + const lines = [ + 'I ran Site QA on this project. Please fix the highest-impact issues below.', + '', + `Project folder: ${report.root}`, + `HTML files scanned: ${report.filesScanned}`, + `Errors: ${errors.length}; warnings: ${warnings.length}`, + '', + 'Findings to fix:', + ]; + for (const item of top) { + lines.push(`- [${item.severity}] ${item.file}: ${item.message} (${item.rule})`); + } + if (top.length === 0) lines.push('- None.'); + lines.push( + '', + 'Start with the errors. Ask me before changing visual design.', + ); + return lines.join('\n'); +} diff --git a/plugins/lonelyguy-se1/site-qa/miniapp/node/scan.mjs b/plugins/lonelyguy-se1/site-qa/miniapp/node/scan.mjs new file mode 100644 index 0000000..e118f5d --- /dev/null +++ b/plugins/lonelyguy-se1/site-qa/miniapp/node/scan.mjs @@ -0,0 +1,114 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; + +import { auditHtmlPage, auditSiteExtras } from './audit.mjs'; +import { discoverHtmlFiles, readHtmlFile } from './discover.mjs'; +import { firstTagInner, metaContent, stripScriptsAndStyles } from './html.mjs'; +import { buildFixPrompt, buildSummaryPrompt } from './prompts.mjs'; +import { PathError, assertProjectRoot } from './paths.mjs'; + +/** + * @param {string} rootInput + * @param {{ signal?: AbortSignal, home?: string, minimaxDataDir?: string }} [options] + */ +export async function scanProject(rootInput, options = {}) { + const root = await assertProjectRoot(rootInput, options); + const discovered = await discoverHtmlFiles(root, { signal: options.signal }); + /** @type {import('./audit.mjs').Finding[]} */ + const findings = []; + /** @type {{ relative: string, bytes: number, skipped: string, title?: string, description?: string }[]} */ + const pages = []; + + for (const file of discovered.files) { + if (options.signal?.aborted) throw new PathError('aborted', 'The scan was cancelled.'); + const page = await readHtmlFile(root, file); + const cleaned = page.skipped ? '' : stripScriptsAndStyles(page.html); + const title = page.skipped ? '' : firstTagInner(cleaned, 'title'); + const description = page.skipped ? '' : metaContent(cleaned, 'description'); + pages.push({ + relative: page.relative, + bytes: page.bytes, + skipped: page.skipped, + title, + description, + }); + findings.push(...auditHtmlPage(page)); + } + + const siteFiles = await readSiteFiles(root); + findings.push( + ...auditSiteExtras({ + root, + pages: pages.filter((page) => !page.skipped), + siteFiles, + }), + ); + + findings.sort((a, b) => { + const severity = Number(a.severity === 'warning') - Number(b.severity === 'warning'); + if (severity !== 0) return severity; + return a.file.localeCompare(b.file) || a.rule.localeCompare(b.rule); + }); + + const report = { + root, + scannedAt: new Date().toISOString(), + filesScanned: pages.filter((page) => !page.skipped).length, + filesSkipped: pages.filter((page) => page.skipped).length, + truncated: discovered.truncated, + skippedDirs: discovered.skipped, + siteFiles: { + robotsTxt: Boolean(siteFiles.robotsTxt), + sitemapXml: Boolean(siteFiles.sitemapXml), + }, + pages, + findings, + counts: { + error: findings.filter((item) => item.severity === 'error').length, + warning: findings.filter((item) => item.severity === 'warning').length, + seo: findings.filter((item) => item.category === 'seo').length, + accessibility: findings.filter((item) => item.category === 'accessibility').length, + document: findings.filter((item) => item.category === 'document').length, + }, + }; + + return report; +} + +/** @param {string} root */ +async function readSiteFiles(root) { + return { + robotsTxt: await readOptional(path.join(root, 'robots.txt')), + sitemapXml: + (await readOptional(path.join(root, 'sitemap.xml'))) || + (await readOptional(path.join(root, 'sitemap_index.xml'))), + }; +} + +/** @param {string} filePath */ +async function readOptional(filePath) { + try { + return await readFile(filePath, 'utf8'); + } catch { + return null; + } +} + +/** + * @param {Awaited<ReturnType<typeof scanProject>>} report + * @param {string} findingId + */ +export function promptForFinding(report, findingId) { + const finding = report.findings.find((item) => item.id === findingId); + if (!finding) throw new PathError('finding_missing', 'That finding is not in the latest scan.'); + return buildFixPrompt(finding, { root: report.root }); +} + +/** + * @param {Awaited<ReturnType<typeof scanProject>>} report + */ +export function promptForSummary(report) { + return buildSummaryPrompt(report); +} + +export { PathError, assertProjectRoot }; diff --git a/plugins/lonelyguy-se1/site-qa/miniapp/node/server.mjs b/plugins/lonelyguy-se1/site-qa/miniapp/node/server.mjs new file mode 100644 index 0000000..e6da6d4 --- /dev/null +++ b/plugins/lonelyguy-se1/site-qa/miniapp/node/server.mjs @@ -0,0 +1,228 @@ +// @ts-check + +import { readFile } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { join } from 'node:path'; + +import { PathError, promptForFinding, promptForSummary, scanProject } from './scan.mjs'; +import { discoverSiteRoots } from './sites.mjs'; + +const PAGES = new Map([ + ['/qa', ['index.html', 'text/html; charset=utf-8']], + ['/qa/', ['index.html', 'text/html; charset=utf-8']], + ['/qa/app.js', ['app.js', 'text/javascript; charset=utf-8']], + ['/qa/app.css', ['app.css', 'text/css; charset=utf-8']], +]); + +/** + * @param {object} context + * @param {string} context.pluginRoot + * @param {string} context.dataDir + * @param {{ host: string, port: number }} context.listen + * @param {AbortSignal} context.signal + * @param {{ info: Function, warn: Function, error: Function }} context.logger + */ +export async function start(context) { + /** @type {Awaited<ReturnType<typeof scanProject>> | null} */ + let latest = null; + /** @type {string} */ + let selectedRoot = ''; + + const pages = new Map(); + for (const [route, [file, type]] of PAGES) { + pages.set(route, { type, body: await readFile(join(context.pluginRoot, 'miniapp/client', file)) }); + } + + const server = createServer((request, response) => { + void handle(request, response).catch((error) => { + if (response.headersSent || response.writableEnded) return; + const known = error instanceof PathError ? error : new PathError('request_failed', 'Something went wrong while scanning.'); + if (error instanceof PathError) context.logger.warn(`siteqa.refused ${error.code}`); + else context.logger.error('siteqa.failed'); + sendJson(response, statusFor(known.code), { error: known.code, message: known.message }); + }); + }); + + /** + * @param {import('node:http').IncomingMessage} request + * @param {import('node:http').ServerResponse} response + */ + async function handle(request, response) { + const url = new URL(request.url ?? '/', 'http://miniapp.local'); + if (request.method === 'GET') { + const page = pages.get(url.pathname); + if (page) { + response.writeHead(200, { 'content-type': page.type, 'cache-control': 'no-store' }); + response.end(page.body); + return; + } + if (url.pathname === '/api/state') { + sendJson(response, 200, { + root: selectedRoot || latest?.root || '', + report: latest ? publicReport(latest) : null, + }); + return; + } + } + + if (request.method !== 'POST') { + sendJson(response, 404, { error: 'not_found', message: 'That route is not part of Site QA.' }); + return; + } + + const body = await readJson(request); + if (url.pathname === '/api/workspace') { + const root = String(body.path ?? body.root ?? '').trim(); + selectedRoot = root; + sendJson(response, 200, { root }); + return; + } + if (url.pathname === '/api/sites') { + const root = String(body.path ?? body.root ?? selectedRoot ?? '').trim(); + const controller = abortOnClose(response); + const result = await discoverSiteRoots(root, { + signal: controller.signal, + minimaxDataDir: context.dataDir, + }); + sendJson(response, 200, result); + return; + } + if (url.pathname === '/api/scan') { + const root = String(body.path ?? body.root ?? selectedRoot ?? '').trim(); + const controller = abortOnClose(response); + const report = await scanProject(root, { + signal: controller.signal, + minimaxDataDir: context.dataDir, + }); + latest = report; + selectedRoot = report.root; + context.logger.info(`siteqa.scanned ${report.filesScanned}`); + sendJson(response, 200, publicReport(report)); + return; + } + if (url.pathname === '/api/prompt') { + if (!latest) throw new PathError('scan_required', 'Run a scan before asking for a fix prompt.'); + const kind = String(body.kind ?? 'finding'); + const text = + kind === 'summary' ? promptForSummary(latest) : promptForFinding(latest, String(body.id ?? '')); + if (text.length > 100_000) throw new PathError('prompt_too_large', 'That prompt is too large for the chat input.'); + sendJson(response, 200, { text }); + return; + } + sendJson(response, 404, { error: 'not_found', message: 'That route is not part of Site QA.' }); + } + + await listen(server, context.listen.host, context.listen.port); + context.logger.info('miniapp.runtime.listening'); + + let disposed = false; + const dispose = async () => { + if (disposed) return; + disposed = true; + context.signal.removeEventListener('abort', onAbort); + await close(server); + }; + const onAbort = () => { + void dispose(); + }; + context.signal.addEventListener('abort', onAbort, { once: true }); + if (context.signal.aborted) await dispose(); + return { dispose }; +} + +/** @param {Awaited<ReturnType<typeof scanProject>>} report */ +function publicReport(report) { + return { + root: report.root, + scannedAt: report.scannedAt, + filesScanned: report.filesScanned, + filesSkipped: report.filesSkipped, + truncated: report.truncated, + skippedDirs: report.skippedDirs, + counts: report.counts, + pages: report.pages.map((page) => ({ relative: page.relative, bytes: page.bytes, skipped: page.skipped })), + findings: report.findings, + }; +} + +/** @param {import('node:http').ServerResponse} response */ +function abortOnClose(response) { + const controller = new AbortController(); + response.on('close', () => { + if (!response.writableEnded) controller.abort(); + }); + return controller; +} + +/** @param {import('node:http').IncomingMessage} request */ +function readJson(request) { + return new Promise((resolve, reject) => { + /** @type {Buffer[]} */ + const chunks = []; + let size = 0; + request.on('data', (chunk) => { + size += chunk.length; + if (size > 200_000) { + reject(new PathError('body_too_large', 'That request is too large.')); + request.destroy(); + return; + } + chunks.push(chunk); + }); + request.on('end', () => { + const text = Buffer.concat(chunks).toString('utf8'); + if (!text) { + resolve({}); + return; + } + try { + const value = JSON.parse(text); + if (!value || typeof value !== 'object' || Array.isArray(value)) { + reject(new PathError('body_invalid', 'Expected a JSON object.')); + return; + } + resolve(value); + } catch { + reject(new PathError('body_invalid', 'The request body was not valid JSON.')); + } + }); + request.on('error', () => reject(new PathError('body_invalid', 'The request was interrupted.'))); + }); +} + +/** @param {string} code */ +function statusFor(code) { + if (code === 'not_found') return 404; + if (code === 'aborted') return 499; + return 400; +} + +/** @param {import('node:http').ServerResponse} response @param {number} status @param {unknown} value */ +function sendJson(response, status, value) { + const body = JSON.stringify(value); + response.writeHead(status, { + 'content-type': 'application/json; charset=utf-8', + 'cache-control': 'no-store', + 'content-length': Buffer.byteLength(body), + }); + response.end(body); +} + +/** @param {import('node:http').Server} server @param {string} host @param {number} port */ +function listen(server, host, port) { + return new Promise((resolve, reject) => { + const onError = (error) => reject(error); + server.once('error', onError); + server.listen(port, host, () => { + server.off('error', onError); + resolve(undefined); + }); + }); +} + +/** @param {import('node:http').Server} server */ +function close(server) { + return new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve(undefined))); + }); +} diff --git a/plugins/lonelyguy-se1/site-qa/miniapp/node/sites.mjs b/plugins/lonelyguy-se1/site-qa/miniapp/node/sites.mjs new file mode 100644 index 0000000..9ea9349 --- /dev/null +++ b/plugins/lonelyguy-se1/site-qa/miniapp/node/sites.mjs @@ -0,0 +1,108 @@ +import { readdir, stat } from 'node:fs/promises'; +import path from 'node:path'; + +import { assertProjectRoot, isHtmlFile, relativeToRoot, shouldSkipDir } from './paths.mjs'; + +const PREFERRED = new Set(['public', 'dist', 'out', 'build', 'docs', 'www', 'site', 'static', '_site', 'html']); + +/** + * Suggest site roots under a workspace so the UI can offer a dropdown + * instead of forcing a hand-typed path. + * + * @param {string} workspaceInput + * @param {{ signal?: AbortSignal, home?: string, minimaxDataDir?: string }} [options] + */ +export async function discoverSiteRoots(workspaceInput, options = {}) { + const workspace = await assertProjectRoot(workspaceInput, options); + /** @type {Map<string, { path: string, label: string, reason: string, score: number }>} */ + const found = new Map(); + + const add = (abs, reason, score) => { + const key = path.resolve(abs); + const current = found.get(key); + if (current && current.score >= score) return; + const rel = relativeToRoot(workspace, key) || '.'; + found.set(key, { + path: key, + label: rel === '.' ? 'Workspace root' : rel, + reason, + score, + }); + }; + + if (await hasHtml(workspace)) add(workspace, 'Contains HTML files', 10); + + const queue = [{ dir: workspace, depth: 0 }]; + while (queue.length) { + if (options.signal?.aborted) throw new Error('aborted'); + const { dir, depth } = queue.shift(); + if (depth > 3) continue; + let entries; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + if (!entry.isDirectory() || shouldSkipDir(entry.name)) continue; + const full = path.join(dir, entry.name); + const preferred = PREFERRED.has(entry.name.toLowerCase()); + const indexHtml = await hasIndexHtml(full); + if (indexHtml) { + add(full, preferred ? `Common site folder with index.html` : 'Folder with index.html', preferred ? 40 : 25); + } else if (preferred && (await hasHtml(full))) { + add(full, 'Common site folder with HTML', 30); + } + if (depth < 3 && (preferred || depth < 2)) { + queue.push({ dir: full, depth: depth + 1 }); + } + } + } + + const sites = [...found.values()].sort((a, b) => b.score - a.score || a.label.localeCompare(b.label)); + if (sites.length === 0) { + sites.push({ + path: workspace, + label: 'Workspace root', + reason: 'No HTML folders found yet. Scan here or paste a path', + score: 0, + }); + } + return { workspace, sites: sites.slice(0, 24) }; +} + +/** @param {string} dir */ +async function hasIndexHtml(dir) { + for (const name of ['index.html', 'index.htm', 'Index.html']) { + try { + const info = await stat(path.join(dir, name)); + if (info.isFile()) return true; + } catch { + // try next + } + } + return false; +} + +/** @param {string} dir */ +async function hasHtml(dir) { + let entries; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch { + return false; + } + for (const entry of entries) { + if (entry.isFile() && isHtmlFile(entry.name)) return true; + if (entry.isDirectory() && !shouldSkipDir(entry.name)) { + // shallow only + try { + const nested = await readdir(path.join(dir, entry.name), { withFileTypes: true }); + if (nested.some((item) => item.isFile() && isHtmlFile(item.name))) return true; + } catch { + // ignore + } + } + } + return false; +} diff --git a/plugins/lonelyguy-se1/site-qa/package.json b/plugins/lonelyguy-se1/site-qa/package.json new file mode 100644 index 0000000..bad87d2 --- /dev/null +++ b/plugins/lonelyguy-se1/site-qa/package.json @@ -0,0 +1,10 @@ +{ + "name": "site-qa", + "version": "1.2.0", + "private": true, + "description": "Local SEO and accessibility checklist with site-root picker and click-to-fix prompts for MiniMax Code.", + "mcode": { + "schemaVersion": 2, + "miniApp": "./miniapp/miniapp.json" + } +} diff --git a/plugins/lonelyguy-se1/site-qa/skills/site-qa/SKILL.md b/plugins/lonelyguy-se1/site-qa/skills/site-qa/SKILL.md new file mode 100644 index 0000000..3dbfb9e --- /dev/null +++ b/plugins/lonelyguy-se1/site-qa/skills/site-qa/SKILL.md @@ -0,0 +1,34 @@ +--- +name: site-qa +description: Check a local site project for SEO and accessibility gaps with Site QA. Use when the user asks to open Site QA, audit SEO, check accessibility on a site they are building, fix Site QA findings, or verify robots.txt / sitemap / meta tags on local HTML. +--- + +# Site QA + +Site QA scans **local HTML** (plus `robots.txt` / `sitemap.xml`) for SEO and accessibility gaps. Prefer the Mini App for interactive review. Prefer the CLI when you should produce findings yourself in chat. + +## Do this + +1. Open the Mini App with the Host open action and `pluginId: "site-qa"` when the user wants the UI. +2. For an Agent-driven check, run the CLI against the site root (often `public/`, `dist/`, `docs/`, or the folder with `index.html`): + +```bash +node "$HOME/.minimax/plugins/site-qa/skills/site-qa/run.mjs" "/absolute/path/to/site" +``` + +Add `--json` for machine-readable output. + +3. Fix the highest-impact **errors** first in the real site source. Re-run the CLI (or ask the user to re-scan in the Mini App) after edits. +4. If the chat draft already contains a Site QA finding prompt (from the Mini App **Ask agent** buttons), do **only that item**, then stop. + +## Do not do this + +- Do not explore or rewrite the Site QA plugin source unless the user is debugging the plugin itself. +- Do not invent a separate Lighthouse / WAVE crawl. This tool is static HTML only. +- Do not claim WCAG conformance from a green scan. + +## Paths + +Default install: `~/.minimax/plugins/site-qa/`. +If `MINIMAX_DATA_DIR` is set, use `$MINIMAX_DATA_DIR/plugins/site-qa/` instead. +Dev checkout may live under `plugins/lonelyguy-se1/site-qa/` in the MiniApps repo; same `skills/site-qa/run.mjs` entry. diff --git a/plugins/lonelyguy-se1/site-qa/skills/site-qa/run.mjs b/plugins/lonelyguy-se1/site-qa/skills/site-qa/run.mjs new file mode 100644 index 0000000..7a7204e --- /dev/null +++ b/plugins/lonelyguy-se1/site-qa/skills/site-qa/run.mjs @@ -0,0 +1,71 @@ +#!/usr/bin/env node +// Headless Site QA for the Agent skill. Prints a markdown report to stdout. + +import { scanProject } from '../../miniapp/node/scan.mjs'; + +async function main() { + const args = process.argv.slice(2); + const json = args.includes('--json'); + const pathArg = args.find((item) => item !== '--json'); + if (!pathArg) { + process.stderr.write('Usage: node cli.mjs <site-root> [--json]\n'); + process.exit(2); + } + + try { + const report = await scanProject(pathArg); + if (json) { + process.stdout.write(`${JSON.stringify(publicReport(report))}\n`); + return; + } + process.stdout.write(`${formatMarkdown(report)}\n`); + } catch (error) { + const code = error && typeof error === 'object' && 'code' in error ? error.code : 'scan_failed'; + const message = error instanceof Error ? error.message : 'Scan failed.'; + process.stderr.write(`${code}: ${message}\n`); + process.exit(1); + } +} + +/** @param {Awaited<ReturnType<typeof scanProject>>} report */ +function publicReport(report) { + return { + root: report.root, + scannedAt: report.scannedAt, + filesScanned: report.filesScanned, + counts: report.counts, + siteFiles: report.siteFiles, + findings: report.findings, + }; +} + +/** @param {Awaited<ReturnType<typeof scanProject>>} report */ +function formatMarkdown(report) { + const lines = [ + '# Site QA report', + '', + `Root: \`${report.root}\``, + `HTML files: ${report.filesScanned}`, + `Errors: ${report.counts.error} · Warnings: ${report.counts.warning} · SEO: ${report.counts.seo} · A11y: ${report.counts.accessibility}`, + '', + ]; + if (report.findings.length === 0) { + lines.push('No automated findings.'); + return lines.join('\n'); + } + lines.push('## Findings'); + for (const item of report.findings.slice(0, 40)) { + lines.push(`- **[${item.severity}]** ${item.file}: ${item.message} (\`${item.rule}\`)`); + if (item.detail) lines.push(` - ${item.detail}`); + } + if (report.findings.length > 40) { + lines.push(`- and ${report.findings.length - 40} more`); + } + lines.push( + '', + 'Fix the highest-impact errors first. Prefer editing the real site source, then re-run this command to verify.', + ); + return lines.join('\n'); +} + +await main(); diff --git a/plugins/lonelyguy-se1/site-qa/tests/audit.test.mjs b/plugins/lonelyguy-se1/site-qa/tests/audit.test.mjs new file mode 100644 index 0000000..39aa9af --- /dev/null +++ b/plugins/lonelyguy-se1/site-qa/tests/audit.test.mjs @@ -0,0 +1,110 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { auditHtmlPage, auditSiteExtras } from '../miniapp/node/audit.mjs'; +import { buildFixPrompt } from '../miniapp/node/prompts.mjs'; + +test('a bare page fails the core SEO and accessibility checks', () => { + const findings = auditHtmlPage({ + relative: 'index.html', + html: '<html><head></head><body><img src="a.png"><a href="#"></a><button></button><input type="text"></body></html>', + }); + const rules = new Set(findings.map((item) => item.rule)); + for (const rule of [ + 'html_lang_missing', + 'viewport_missing', + 'title_missing', + 'meta_description_missing', + 'h1_missing', + 'img_missing_alt', + 'a_empty_href', + 'a_empty_name', + 'button_empty_name', + 'input_missing_label', + ]) { + assert.ok(rules.has(rule), `missing ${rule}`); + } +}); + +test('a complete page is quiet for required errors', () => { + const html = `<!doctype html> + <html lang="en"> + <head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <title>Launch checklist for Site QA demos + + + + + + + + + + + + + Skip to content +
    +

    Launch checklist

    +

    Basics

    +

    ${'Enough visible copy for the homepage so thin-content does not fire. '.repeat(4)}

    + Product hero + Read the docs + + + +
    + + `; + const findings = auditHtmlPage({ relative: 'index.html', html }); + assert.deepEqual( + findings.filter((item) => item.severity === 'error').map((item) => item.rule), + [], + ); +}); + +test('heading skips, generic links, and duplicate h1 are flagged', () => { + const findings = auditHtmlPage({ + relative: 'page.html', + html: `Long enough title here

    One

    Two

    Skipped

    click hereOpen`, + }); + const rules = findings.map((item) => item.rule); + assert.ok(rules.includes('h1_multiple')); + assert.ok(rules.includes('heading_level_skip')); + assert.ok(rules.includes('a_generic_text')); + assert.ok(rules.includes('a_javascript_href')); +}); + +test('site extras catch missing robots/sitemap and duplicate titles', () => { + const findings = auditSiteExtras({ + root: '/tmp/demo', + pages: [ + { relative: 'index.html', title: 'Same Title For Search', description: 'one' }, + { relative: 'about.html', title: 'Same Title For Search', description: 'two' }, + ], + siteFiles: { robotsTxt: null, sitemapXml: null }, + }); + const rules = new Set(findings.map((item) => item.rule)); + assert.ok(rules.has('robots_txt_missing')); + assert.ok(rules.has('sitemap_missing')); + assert.ok(rules.has('title_duplicate')); +}); + +test('fix prompts name the file and the rule', () => { + const text = buildFixPrompt( + { + rule: 'title_missing', + severity: 'error', + category: 'seo', + file: 'index.html', + message: 'The page has no .', + selectorHint: 'title', + }, + { root: '/tmp/demo' }, + ); + assert.match(text, /index\.html/); + assert.match(text, /title_missing/); + assert.match(text, /Do only this item/); +}); diff --git a/plugins/lonelyguy-se1/site-qa/tests/cli.test.mjs b/plugins/lonelyguy-se1/site-qa/tests/cli.test.mjs new file mode 100644 index 0000000..5540c1f --- /dev/null +++ b/plugins/lonelyguy-se1/site-qa/tests/cli.test.mjs @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { spawn } from 'node:child_process'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const cli = fileURLToPath(new URL('../skills/site-qa/run.mjs', import.meta.url)); + +test('cli prints a markdown report for a site folder', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'site-qa-cli-')); + await writeFile( + path.join(root, 'index.html'), + '<html><head></head><body><img src="a.png"></body></html>', + 'utf8', + ); + const { code, stdout, stderr } = await run(cli, [root]); + assert.equal(code, 0, stderr); + assert.match(stdout, /Site QA report/); + assert.match(stdout, /img_missing_alt|title_missing/); +}); + +/** @param {string} script @param {string[]} args */ +function run(script, args) { + return new Promise((resolve) => { + const child = spawn(process.execPath, [script, ...args], { env: process.env }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { + stdout += chunk; + }); + child.stderr.on('data', (chunk) => { + stderr += chunk; + }); + child.on('close', (code) => resolve({ code: code ?? 1, stdout, stderr })); + }); +} diff --git a/plugins/lonelyguy-se1/site-qa/tests/scan.test.mjs b/plugins/lonelyguy-se1/site-qa/tests/scan.test.mjs new file mode 100644 index 0000000..7c2d83b --- /dev/null +++ b/plugins/lonelyguy-se1/site-qa/tests/scan.test.mjs @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { test } from 'node:test'; + +import { assertProjectRoot, promptForFinding, scanProject } from '../miniapp/node/scan.mjs'; + +async function fixture() { + const root = await mkdtemp(path.join(tmpdir(), 'site-qa-')); + await writeFile( + path.join(root, 'index.html'), + '<html><head></head><body><h1>Hi</h1><img src="x.png"></body></html>', + 'utf8', + ); + await mkdir(path.join(root, 'node_modules', 'pkg'), { recursive: true }); + await writeFile(path.join(root, 'node_modules', 'pkg', 'index.html'), '<html></html>', 'utf8'); + await mkdir(path.join(root, 'docs'), { recursive: true }); + await writeFile( + path.join(root, 'docs', 'guide.html'), + '<html lang="en"><head><title>Guide page title text

    Guide

    ', + 'utf8', + ); + return root; +} + +test('scan finds HTML outside node_modules and returns fixable findings', async () => { + const root = await fixture(); + const report = await scanProject(root); + assert.equal(report.filesScanned, 2); + assert.ok(report.findings.some((item) => item.file === 'index.html' && item.rule === 'img_missing_alt')); + assert.ok(!report.pages.some((page) => page.relative.includes('node_modules'))); + const img = report.findings.find((item) => item.rule === 'img_missing_alt'); + const prompt = promptForFinding(report, img.id); + assert.match(prompt, /img_missing_alt/); + assert.match(prompt, /index\.html/); +}); + +test('home, missing paths and MiniMax data dirs are refused', async () => { + const root = await fixture(); + await assert.rejects(() => assertProjectRoot(path.join(root, 'nope')), (error) => error.code === 'path_missing'); + await assert.rejects(() => assertProjectRoot(root, { home: root }), (error) => error.code === 'path_too_broad'); + const data = await mkdtemp(path.join(tmpdir(), 'minimax-data-')); + const nested = path.join(data, 'plugins', 'site-qa'); + await mkdir(nested, { recursive: true }); + await assert.rejects(() => assertProjectRoot(nested, { minimaxDataDir: data }), (error) => error.code === 'path_forbidden'); +}); diff --git a/plugins/lonelyguy-se1/site-qa/tests/server.test.mjs b/plugins/lonelyguy-se1/site-qa/tests/server.test.mjs new file mode 100644 index 0000000..3e0c7da --- /dev/null +++ b/plugins/lonelyguy-se1/site-qa/tests/server.test.mjs @@ -0,0 +1,71 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, writeFile } from 'node:fs/promises'; +import { createServer } from 'node:net'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { start } from '../miniapp/node/server.mjs'; + +const pluginRoot = fileURLToPath(new URL('..', import.meta.url)); + +test('the Mini App serves the page, scans a folder, and returns a fix prompt', async () => { + const dataDir = await mkdtemp(path.join(tmpdir(), 'site-qa-data-')); + const project = await mkdtemp(path.join(tmpdir(), 'site-qa-project-')); + await writeFile(path.join(project, 'index.html'), '', 'utf8'); + const port = await freePort(); + const running = await start({ + pluginRoot, + dataDir, + listen: { host: '127.0.0.1', port }, + signal: new AbortController().signal, + logger: { info() {}, warn() {}, error() {} }, + }); + try { + const page = await fetch(`http://127.0.0.1:${port}/qa`); + assert.equal(page.status, 200); + assert.match(await page.text(), /Find what is hurting the site/); + + const refused = await fetch(`http://127.0.0.1:${port}/api/scan`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ path: dataDir }), + }); + assert.equal(refused.status, 400); + assert.equal((await refused.json()).error, 'path_forbidden'); + + const scanned = await fetch(`http://127.0.0.1:${port}/api/scan`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ path: project }), + }); + assert.equal(scanned.status, 200); + const report = await scanned.json(); + assert.ok(report.findings.length > 0); + const finding = report.findings.find((item) => item.rule === 'img_missing_alt'); + assert.ok(finding); + + const prompt = await fetch(`http://127.0.0.1:${port}/api/prompt`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ kind: 'finding', id: finding.id }), + }); + assert.equal(prompt.status, 200); + assert.match((await prompt.json()).text, /img_missing_alt/); + } finally { + await running.dispose(); + } +}); + +function freePort() { + return new Promise((resolve, reject) => { + const probe = createServer(); + probe.once('error', reject); + probe.listen(0, '127.0.0.1', () => { + const address = probe.address(); + const port = address && typeof address !== 'string' ? address.port : 0; + probe.close(() => resolve(port)); + }); + }); +} diff --git a/plugins/lonelyguy-se1/site-qa/tests/sites.test.mjs b/plugins/lonelyguy-se1/site-qa/tests/sites.test.mjs new file mode 100644 index 0000000..2166e36 --- /dev/null +++ b/plugins/lonelyguy-se1/site-qa/tests/sites.test.mjs @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { test } from 'node:test'; + +import { discoverSiteRoots } from '../miniapp/node/sites.mjs'; + +test('discoverSiteRoots prefers public/ and folders with index.html', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'site-qa-sites-')); + await mkdir(path.join(root, 'public'), { recursive: true }); + await writeFile(path.join(root, 'public', 'index.html'), 'Home page title', 'utf8'); + await mkdir(path.join(root, 'src'), { recursive: true }); + await writeFile(path.join(root, 'src', 'note.html'), '', 'utf8'); + + const result = await discoverSiteRoots(root); + assert.ok(result.sites.some((site) => site.path.endsWith(`${path.sep}public`))); + assert.equal(result.workspace, await import('node:fs/promises').then(async (fs) => fs.realpath(root))); +}); From d07ed7a9c4d495db2a377b6fd1afa094790427e5 Mon Sep 17 00:00:00 2001 From: LonelyGuy-SE1 Date: Mon, 9 Nov 2026 22:24:01 +0530 Subject: [PATCH 2/3] docs(site-qa): spell out checks, OS limits, and Design & Sites category Match CONTRIBUTING README expectations and stop underselling the rule set in the package docs. --- .../site-qa/.minimax-plugin/plugin.json | 2 +- plugins/lonelyguy-se1/site-qa/README.md | 43 +++++++++++++++---- 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/plugins/lonelyguy-se1/site-qa/.minimax-plugin/plugin.json b/plugins/lonelyguy-se1/site-qa/.minimax-plugin/plugin.json index c8c4e03..1ba1cad 100644 --- a/plugins/lonelyguy-se1/site-qa/.minimax-plugin/plugin.json +++ b/plugins/lonelyguy-se1/site-qa/.minimax-plugin/plugin.json @@ -6,7 +6,7 @@ "description": "Check the site you are building for SEO and accessibility gaps. Pick a site root, review findings, ask the agent to fix them.", "author": "LonelyGuySE1", "icon": "icon.png", - "category": "Code", + "category": "Design & Sites", "exampleQueries": [ "Open Site QA", "Check this site for SEO problems", diff --git a/plugins/lonelyguy-se1/site-qa/README.md b/plugins/lonelyguy-se1/site-qa/README.md index bf59397..7397a91 100644 --- a/plugins/lonelyguy-se1/site-qa/README.md +++ b/plugins/lonelyguy-se1/site-qa/README.md @@ -4,7 +4,7 @@ Check the website project you are building for on-page SEO and accessibility gap Author: [LonelyGuySE1](https://github.com/LonelyGuy-SE1) · Version: `1.2.0` -Static HTML checklist aligned with common SEO starter guidance, Lighthouse-style signals, and WCAG items that can be judged from markup. Not a hosted Lighthouse / WAVE clone and not a WCAG conformance claim. +Static HTML checklist aligned with Google-style SEO starter signals, Lighthouse SEO-style audits, and WCAG items that can be judged from markup. About 60 automated rules across document, SEO, and accessibility. Not a hosted Lighthouse / WAVE clone and not a WCAG conformance claim. ## Install @@ -36,24 +36,49 @@ Use `--json` for machine-readable output. The skill tells the Agent not to dig t ## Tested environment - MiniMax Code 3.1.6 or later is required (`session.read` + `surface.window`). -- Package tests on Node.js 22 cover HTML rules, site-root discovery, path guards, scan discovery, HTTP entry, CLI output, and fix-prompt text. +- Verified while developing on Linux (Node.js 22 package tests + local install at `~/.minimax/plugins/site-qa/` against a sample site project). +- macOS and Windows MiniMax Code installs are not verified in this package yet. +- Package tests cover HTML rules, site-root discovery, path guards, scan discovery, HTTP entry, CLI output, and fix-prompt text. ## Data & access -- **Files:** reads HTML under the chosen site root (or project-folder candidates). Skips `node_modules`, VCS and cache directories, and hidden directories. Refuses home, filesystem roots, and MiniMax data dirs. Writes nothing outside its own runtime. The CLI uses the same scanner. +- **Files:** reads HTML under the chosen site root (or project-folder candidates). Also reads `robots.txt` / `sitemap.xml` / `sitemap_index.xml` at that root when present. Skips `node_modules`, VCS and cache directories, and hidden directories. Refuses home, filesystem roots, and MiniMax data dirs. Writes nothing outside its own runtime. The CLI uses the same scanner. - **Network:** none. - **Processes:** none beyond the Mini App Node runtime / CLI process you start. - **Agent turns:** none started by this Mini App (`session.run` not declared). The skill may instruct the Agent to run the CLI in a shell. -- **Conversations:** `session.read` only for the current project folder. +- **Conversations:** `session.read` only for the current project folder (`workspaceDir`). Does not list conversations, observe turns, or read message text. - **Chat draft:** **Ask agent** / **Ask agent to fix** call `message.append` with Host confirmation. +- **Tools / microphone / camera / notifications / current page:** not used. No `toolAccess`, `deviceAccess`, or `hostAccess`. ## Checks included -**Document:** doctype, charset, favicon, `target=_blank` + `noopener`. - -**SEO:** title (presence, length, generic wording, cross-page duplicates), meta description (presence, length, duplicates), viewport, robots `noindex`/`nofollow`, canonical, hreflang validity, Open Graph + Twitter card, JSON-LD presence/parse/`@type`, H1, thin homepage copy, crawlable anchors, generic link text (“click here”), `robots.txt` (missing / blocks all / Sitemap line), `sitemap.xml`. - -**Accessibility:** `lang`, heading hierarchy / empty headings, image `alt`, link/button/ARIA-role names, form labels (`input`/`textarea`/`select`), iframe titles, `main` landmark, skip link, `aria-hidden` on focusable controls, positive `tabindex`, video captions track. +**Document:** doctype, charset, favicon, `target="_blank"` without `rel="noopener"`, oversized HTML skip. + +**SEO (on-page + site files):** +- Title: missing, too short/long, generic wording, cross-page duplicates +- Meta description: missing, multiple, too short/long, cross-page duplicates +- Viewport missing / weak +- Robots meta `noindex` / `nofollow` +- Canonical: missing, multiple, empty, non-absolute +- hreflang validity and absolute URLs +- Open Graph: `og:title`, `og:description`, `og:image`, `og:url`, `og:type`, absolute `og:image` +- Twitter card +- JSON-LD: missing, invalid JSON, missing `@type` +- H1 missing / multiple / empty; thin homepage copy +- Crawlable anchors (`#` / empty / `javascript:`), generic link text (“click here”, “read more”, …) +- `robots.txt`: missing, blocks all (`Disallow: /`), missing `Sitemap:`, suspicious content +- `sitemap.xml` / `sitemap_index.xml`: missing or empty + +**Accessibility:** +- `lang` missing / invalid +- Heading level skips and empty headings +- Image `alt` missing / too long +- Link / button / ARIA-role accessible names +- Form labels for `input` / `textarea` / `select` +- iframe title +- `
    ` landmark, skip-to-content link +- `aria-hidden` on focusable controls, positive `tabindex` +- Video captions / subtitles track ## License From 27810b3ae236a84b9745b6b03dc58aca99155a18 Mon Sep 17 00:00:00 2001 From: LonelyGuy-SE1 Date: Mon, 9 Nov 2026 22:28:45 +0530 Subject: [PATCH 3/3] docs(site-qa): keep tested-environment notes factual and positive Drop the hedging OS/out-of-scope caveats from the package README. --- plugins/lonelyguy-se1/site-qa/README.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/plugins/lonelyguy-se1/site-qa/README.md b/plugins/lonelyguy-se1/site-qa/README.md index 7397a91..8362815 100644 --- a/plugins/lonelyguy-se1/site-qa/README.md +++ b/plugins/lonelyguy-se1/site-qa/README.md @@ -36,9 +36,8 @@ Use `--json` for machine-readable output. The skill tells the Agent not to dig t ## Tested environment - MiniMax Code 3.1.6 or later is required (`session.read` + `surface.window`). -- Verified while developing on Linux (Node.js 22 package tests + local install at `~/.minimax/plugins/site-qa/` against a sample site project). -- macOS and Windows MiniMax Code installs are not verified in this package yet. -- Package tests cover HTML rules, site-root discovery, path guards, scan discovery, HTTP entry, CLI output, and fix-prompt text. +- Linux desktop: package installed to `~/.minimax/plugins/site-qa/`, opened from a project chat, site-root picker + check + Ask agent flow used against a real local site build. +- Node.js 22+: unit/integration tests for rules, discovery, path guards, HTTP entry, CLI, and fix prompts. ## Data & access