Files
houseplan-card/scripts/check-docs.mjs
Sergey Matyuninandclaude[bot] 85cba3d8b4 docs: синхронизировать английское руководство (#668)
Английская версия догнана до структуры и содержания русского руководства. Структурный гейт защищает H2-H4 и маркер актуальной версии от нового рассинхрона.

Issue: #668
User-Visible: no
2026-09-27 09:18:50 +00:00

267 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
import { createHash } from 'node:crypto';
import { existsSync, readFileSync, statSync } from 'node:fs';
import { dirname, extname, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { DOC_SCREENSHOTS } from '../demo/docs/screenshots.mjs';
import { visualFingerprint } from './source-fingerprint.mjs';
import { freshnessSink, screenshotsMode } from './docs-freshness.mjs';
import { guideParityErrors } from './user-guide-parity.mjs';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const EXTERNAL = process.argv.includes('--external');
const PUBLIC_DOCS = [
'README.md', 'README.ru.md', 'docs/USER-GUIDE.md', 'docs/USER-GUIDE.ru.md',
'docs/TOUCH-SUPPORT.md', 'docs/DECOR-EDITOR.md', 'docs/VACUUM.md',
];
// Каталог один и живёт рядом с капчуром (#246): третья копия списка сценариев
// расходилась бы с ним молча.
const EXPECTED_SCREENSHOTS = DOC_SCREENSHOTS.map((scenario) => scenario.id);
const errors = [];
const warnings = [];
// Свежесть скриншотов: `--screenshots=warn|strict`, см. docs-freshness.mjs (#479).
const freshness = freshnessSink(screenshotsMode(process.argv), { errors, warnings });
const externalUrls = new Set();
const sha256 = (value) => createHash('sha256').update(value).digest('hex');
const canonicalText = (path) => readFileSync(path, 'utf8').replace(/\r\n?/g, '\n');
const withoutFences = (text) => text.replace(/^```[^\n]*\n[\s\S]*?^```\s*$/gm, '');
const yamlFences = (text) => [...text.matchAll(/^```yaml[^\S\r\n]*\n([\s\S]*?)^```[^\S\r\n]*$/gm)]
.map((match) => match[1].split('\n').map((line) => line.replace(/[\t ]+$/, '')).join('\n').trim());
const slug = (heading) => heading
.trim().toLowerCase()
.replace(/<[^>]+>/g, '')
.replace(/[^\p{L}\p{N}\s_-]/gu, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-');
const headingsFor = (path) => {
const seen = new Map();
const headings = new Set();
for (const line of canonicalText(path).split('\n')) {
const match = line.match(/^#{1,6}\s+(.+?)\s*#*\s*$/);
if (!match) continue;
const base = slug(match[1]);
const count = seen.get(base) || 0;
seen.set(base, count + 1);
headings.add(count ? `${base}-${count}` : base);
}
return headings;
};
const rootPrefix = `${ROOT}${sep}`.toLowerCase();
for (const relative of PUBLIC_DOCS) {
const path = resolve(ROOT, relative);
if (!existsSync(path)) {
errors.push(`${relative}: required public document is missing`);
continue;
}
const text = withoutFences(canonicalText(path));
const links = /(!?)\[([^\]]*)\]\(([^)]+)\)/g;
for (const match of text.matchAll(links)) {
const image = match[1] === '!';
const alt = match[2].trim();
const raw = match[3].trim().replace(/\s+"[^"]*"$/, '');
if (image && !alt) errors.push(`${relative}: image has empty alt text (${raw})`);
if (/^(?:https?:)?\/\//i.test(raw)) {
try {
const url = new URL(raw.startsWith('//') ? `https:${raw}` : raw);
if (url.protocol !== 'https:') errors.push(`${relative}: external link must use https (${raw})`);
externalUrls.add(url.href);
} catch {
errors.push(`${relative}: invalid external URL (${raw})`);
}
continue;
}
if (/^(?:mailto:|#|\/api\/|\/houseplan_files\/)/.test(raw)) {
if (raw.startsWith('#')) {
const anchor = decodeURIComponent(raw.slice(1));
if (anchor && !headingsFor(path).has(anchor))
errors.push(`${relative}: missing local heading #${anchor}`);
}
continue;
}
const hashAt = raw.indexOf('#');
const filePart = decodeURIComponent(hashAt >= 0 ? raw.slice(0, hashAt) : raw);
const anchor = hashAt >= 0 ? decodeURIComponent(raw.slice(hashAt + 1)) : '';
const target = resolve(dirname(path), filePart || '.');
const targetLower = target.toLowerCase();
if (targetLower !== ROOT.toLowerCase() && !targetLower.startsWith(rootPrefix)) {
errors.push(`${relative}: link escapes repository (${raw})`);
continue;
}
if (!existsSync(target)) {
errors.push(`${relative}: missing relative target (${raw})`);
continue;
}
if (image && !statSync(target).isFile()) errors.push(`${relative}: image target is not a file (${raw})`);
if (anchor && extname(target).toLowerCase() === '.md' && !headingsFor(target).has(anchor))
errors.push(`${relative}: missing heading ${filePart}#${anchor}`);
}
}
// #462: a flat top-level `resources:` block looks plausible but is not a valid
// configuration.yaml fragment. Keep all four public install paths on the same
// versioned Storage/current-YAML/legacy-YAML contract.
const RESOURCE_INSTALL_DOCS = [
'README.md', 'README.ru.md', 'docs/USER-GUIDE.md', 'docs/USER-GUIDE.ru.md',
];
const RESOURCE_URL = '/houseplan_files/houseplan-card.js';
const RESOURCE_SNIPPETS = [
[
'lovelace:',
' resource_mode: yaml',
' resources:',
` - url: ${RESOURCE_URL}`,
' type: module',
].join('\n'),
[
'lovelace:',
' mode: yaml',
' resources:',
` - url: ${RESOURCE_URL}`,
' type: module',
].join('\n'),
];
for (const relative of RESOURCE_INSTALL_DOCS) {
const text = canonicalText(resolve(ROOT, relative));
const blocks = yamlFences(text).filter((block) => block.includes(RESOURCE_URL));
if (blocks.length !== RESOURCE_SNIPPETS.length) {
errors.push(`${relative}: expected exactly two mode-specific House Plan resource snippets`);
}
if (blocks.some((block) => /^resources:\s*$/m.test(block))) {
errors.push(`${relative}: House Plan resources must be nested under lovelace:`);
}
for (const expected of RESOURCE_SNIPPETS) {
const count = blocks.filter((block) => block === expected).length;
if (count !== 1) {
const mode = expected.includes('resource_mode') ? 'HA 2026.2+ resource_mode' : 'legacy mode';
errors.push(`${relative}: expected one exact ${mode} House Plan snippet, found ${count}`);
}
}
if (blocks.length === RESOURCE_SNIPPETS.length
&& blocks.some((block, index) => block !== RESOURCE_SNIPPETS[index])) {
errors.push(`${relative}: current resource_mode snippet must precede the legacy mode snippet`);
}
const russian = relative.endsWith('.ru.md');
const prose = text.replace(/\s+/g, ' ');
const proseContracts = russian ? [
'#### Режим Storage (по умолчанию в Home Assistant)',
'#### YAML-ресурсы в Home Assistant 2026.2+',
'#### Home Assistant 2024.6–2026.1: устаревший режим',
'полностью управляется через YAML',
'Настройки → Панели управления → меню ⋮ → Ресурсы → Добавить ресурс',
'Не переключайте storage-панель в устаревший YAML только ради House Plan',
] : [
'#### Storage mode (Home Assistant default)',
'#### YAML resources mode (Home Assistant 2026.2+)',
'#### Legacy Home Assistant 2024.6–2026.1',
'full-YAML dashboard',
'Settings → Dashboards → menu ⋮ → Resources → Add resource',
'Do not switch a storage dashboard to legacy YAML just for House Plan',
];
for (const fragment of proseContracts) {
if (!prose.includes(fragment)) errors.push(`${relative}: missing resource guidance “${fragment}”`);
}
for (const shortcut of ['Ctrl+F5', 'Cmd+Shift+R']) {
if (!text.includes(shortcut)) errors.push(`${relative}: missing hard reload shortcut ${shortcut}`);
}
}
const sectionMarkers = (relative) => [...canonicalText(resolve(ROOT, relative))
.matchAll(/<!--\s*docs-section:\s*([a-z0-9-]+)\s*-->/g)].map((match) => match[1]);
for (const [en, ru, required] of [
['README.md', 'README.ru.md', ['overview', 'features', 'first-run', 'installation', 'support']],
['docs/USER-GUIDE.md', 'docs/USER-GUIDE.ru.md', ['model', 'installation', 'first-run', 'modes', 'input', 'spaces', 'plan-tools', 'devices', 'visual-states', 'background', 'multiple-cards', 'limits', 'diagnostics']],
]) {
const enMarkers = sectionMarkers(en);
const ruMarkers = sectionMarkers(ru);
if (JSON.stringify(enMarkers) !== JSON.stringify(ruMarkers))
errors.push(`${en} / ${ru}: docs-section markers differ or are ordered differently`);
for (const marker of required) {
if (!enMarkers.includes(marker)) errors.push(`${en} / ${ru}: missing required section marker ${marker}`);
}
}
errors.push(...guideParityErrors(
canonicalText(resolve(ROOT, 'docs/USER-GUIDE.md')),
canonicalText(resolve(ROOT, 'docs/USER-GUIDE.ru.md')),
));
const staleTerms = [
[/\bMarkup (?:tab|mode|editor)\b/gi, 'Plan'],
[/(?:вкладка|режим|редактор) «Разметка»/gi, '«План»'],
[/\bDecor editor\b/gi, 'Background editor'],
];
for (const relative of PUBLIC_DOCS.slice(0, 4)) {
const text = withoutFences(canonicalText(resolve(ROOT, relative)));
for (const [pattern, replacement] of staleTerms) {
const matches = text.match(pattern) || [];
if (matches.length) errors.push(`${relative}: stale term “${matches[0]}”; use ${replacement}`);
}
}
const manifestPath = resolve(ROOT, 'docs/images/screenshots.json');
if (!existsSync(manifestPath)) {
errors.push('docs/images/screenshots.json: missing screenshot index');
} else {
const manifest = JSON.parse(canonicalText(manifestPath));
if (manifest.fixture !== 'synthetic-only') errors.push('screenshot manifest must declare synthetic-only fixture');
// Версионно-нечувствительный отпечаток (#245): бамп версии не меняет ни одного
// пикселя, поэтому не обязан требовать пересъёмки — иначе каждый релизный
// коммит оставляет этот гейт красным.
if (manifest.sourceFingerprint !== visualFingerprint(ROOT))
freshness.push('screenshot source fingerprint is stale; run npm run docs:capture and accept before the beta candidate (#479)');
const scriptPath = resolve(ROOT, 'demo/docs/capture.mjs');
if (manifest.captureScriptSha256 !== sha256(readFileSync(scriptPath)))
freshness.push('screenshot capture script changed; run npm run docs:capture and accept before the beta candidate (#479)');
const ids = Object.keys(manifest.scenarios || {});
if (JSON.stringify(ids.sort()) !== JSON.stringify([...EXPECTED_SCREENSHOTS].sort()))
errors.push('screenshot manifest scenario set is incomplete');
for (const [id, scenario] of Object.entries(manifest.scenarios || {})) {
const imagePath = resolve(ROOT, 'docs/images', scenario.file || '');
if (!existsSync(imagePath)) errors.push(`screenshot ${id}: missing ${scenario.file}`);
else if (scenario.imageSha256 !== sha256(readFileSync(imagePath)))
errors.push(`screenshot ${id}: image hash does not match manifest`);
if (scenario.sourceSha256 !== manifest.sourceFingerprint)
errors.push(`screenshot ${id}: source SHA does not match manifest`);
if (!scenario.viewport?.width || !scenario.viewport?.height || !scenario.theme || !scenario.language)
errors.push(`screenshot ${id}: capture metadata is incomplete`);
}
}
if (EXTERNAL) {
const allowlist = JSON.parse(canonicalText(resolve(ROOT, 'docs/external-link-allowlist.json')));
const transientHosts = new Set(allowlist.transientHosts || []);
for (const href of [...externalUrls].sort()) {
const url = new URL(href);
try {
const response = await fetch(url, {
method: 'HEAD', redirect: 'follow', signal: AbortSignal.timeout(8000),
headers: { 'user-agent': 'houseplan-docs-check/1' },
});
if (response.status >= 200 && response.status < 400) continue;
if ([403, 408, 425, 429].includes(response.status) || response.status >= 500) {
if (transientHosts.has(url.hostname)) {
warnings.push(`transient external response ${response.status}: ${href}`);
continue;
}
}
errors.push(`external link returned ${response.status}: ${href}`);
} catch (error) {
if (transientHosts.has(url.hostname)) warnings.push(`transient external failure: ${href} (${error.message})`);
else errors.push(`external link failed: ${href} (${error.message})`);
}
}
}
for (const warning of warnings) console.warn(`WARN ${warning}`);
if (errors.length) {
for (const error of errors) console.error(`ERROR ${error}`);
process.exitCode = 1;
} else {
console.log(`Documentation checks passed (${PUBLIC_DOCS.length} files, ${externalUrls.size} external links).`);
}