Case checkers (#206)

This commit is contained in:
Ian Bassi
2026-04-08 15:26:54 -03:00
committed by GitHub
parent 8ae656ae77
commit 7f9504f374
2 changed files with 81 additions and 11 deletions

View File

@@ -104,6 +104,11 @@ jobs:
failures.push(formatFailure(reference, 'sameDocEmptyAnchor', reference.target));
continue;
}
const decodedAnchor = decodeLinkComponent(classification.anchorRaw);
if (!isKebabCase(decodedAnchor)) {
failures.push(formatFailure(reference, 'anchorNotKebabCase', `#${classification.anchorRaw}`));
continue;
}
const anchors = getAnchors(reference.filePath);
if (!anchors.has(classification.anchorSlug)) {
failures.push(formatFailure(reference, 'missingSameDocAnchor', `#${classification.anchorRaw}`));
@@ -132,6 +137,12 @@ jobs:
continue;
}
const decodedAnchor = decodeLinkComponent(classification.anchorRaw);
if (!isKebabCase(decodedAnchor)) {
failures.push(formatFailure(reference, 'anchorNotKebabCase', `${docResult.linkPath}#${classification.anchorRaw}`));
continue;
}
const anchors = getAnchors(docResult.relativePath);
if (!anchors.has(classification.anchorSlug)) {
failures.push(formatFailure(reference, 'missingCrossDocAnchor', `${docResult.linkPath}#${classification.anchorRaw}`));
@@ -238,6 +249,12 @@ jobs:
return result;
}
if (!isSnakeCase(sanitized)) {
result.error = 'docNotSnakeCase';
result.detail = rawPath;
return result;
}
ensureMarkdownIndex();
const matches = findMarkdownDocuments(sanitized);
if (!matches.length) {
@@ -311,6 +328,24 @@ jobs:
return markdownNameIndex.get(baseName) || [];
}
function isSnakeCase(value) {
return /^[a-z0-9]+(?:_[a-z0-9]+)*$/.test(value);
}
function isKebabCase(value) {
return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value);
}
function decodeLinkComponent(value) {
let decoded = value.trim();
try {
decoded = decodeURIComponent(decoded);
} catch (_) {
// Ignore decode failure.
}
return decoded;
}
function getAnchors(relativePath) {
if (headingCache.has(relativePath)) {
return headingCache.get(relativePath);
@@ -417,6 +452,8 @@ jobs:
return `${reference.filePath} line ${reference.line}: document links must not include directories; use just the filename (got "${details}").`;
case 'extensionNotAllowed':
return `${reference.filePath} line ${reference.line}: link target "${details}" must omit the .md suffix.`;
case 'docNotSnakeCase':
return `${reference.filePath} line ${reference.line}: document name "${details}" must be snake_case.`;
case 'missingDocName':
return `${reference.filePath} line ${reference.line}: document link "${details}" must include a file name (without .md).`;
case 'missingDocument':
@@ -425,6 +462,8 @@ jobs:
return `${reference.filePath} line ${reference.line}: document link matches multiple files (${details}).`;
case 'crossDocEmptyAnchor':
return `${reference.filePath} line ${reference.line}: link to ${details} must include a heading name after '#'.`;
case 'anchorNotKebabCase':
return `${reference.filePath} line ${reference.line}: heading reference ${details} must be kebab-case.`;
case 'missingCrossDocAnchor':
return `${reference.filePath} line ${reference.line}: heading ${details} was not found.`;
case 'invalidHashCount':

View File

@@ -76,13 +76,19 @@ jobs:
continue;
}
const matches = findMarkdownDocuments(docName);
const decodedDocName = decodeLinkComponent(docName);
if (!isSnakeCase(decodedDocName)) {
failures.push(formatFailure(reference, 'docNotSnakeCase', docName));
continue;
}
const matches = findMarkdownDocuments(decodedDocName);
if (!matches.length) {
failures.push(formatFailure(reference, 'missingDocument', `${docName}.md`));
failures.push(formatFailure(reference, 'missingDocument', `${decodedDocName}.md`));
continue;
}
if (matches.length > 1) {
failures.push(formatFailure(reference, 'ambiguousDocument', `${docName} -> ${matches.slice(0, 5).join(', ')}`));
failures.push(formatFailure(reference, 'ambiguousDocument', `${decodedDocName} -> ${matches.slice(0, 5).join(', ')}`));
continue;
}
@@ -102,6 +108,12 @@ jobs:
continue;
}
const decodedAnchor = decodeLinkComponent(anchorRaw);
if (!isKebabCase(decodedAnchor)) {
failures.push(formatFailure(reference, 'anchorNotKebabCase', `${decodedDocName}#${anchorRaw}`));
continue;
}
const anchors = getAnchors(relativePath);
const anchorSlug = normalizeAnchor(anchorRaw);
if (!anchorSlug) {
@@ -109,7 +121,7 @@ jobs:
continue;
}
if (!anchors.has(anchorSlug)) {
failures.push(formatFailure(reference, 'missingCrossDocAnchor', `${docName}#${anchorRaw}`));
failures.push(formatFailure(reference, 'missingCrossDocAnchor', `${decodedDocName}#${anchorRaw}`));
}
}
@@ -281,6 +293,24 @@ jobs:
return markdownNameIndex.get(baseName) || [];
}
function isSnakeCase(value) {
return /^[a-z0-9]+(?:_[a-z0-9]+)*$/.test(value);
}
function isKebabCase(value) {
return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value);
}
function decodeLinkComponent(value) {
let decoded = value.trim();
try {
decoded = decodeURIComponent(decoded);
} catch (_) {
// ignore decode failures
}
return decoded;
}
function getAnchors(relativePath) {
if (headingCache.has(relativePath)) {
return headingCache.get(relativePath);
@@ -348,13 +378,8 @@ jobs:
if (!raw) {
return '';
}
let decoded = raw.trim();
try {
decoded = decodeURIComponent(decoded);
} catch (_) {
// ignore decode failures
}
return slugify(decoded, { preserveCase: true });
const decoded = decodeLinkComponent(raw);
return slugify(decoded);
}
function buildLineOffsets(text) {
@@ -401,12 +426,18 @@ jobs:
case 'extensionNotAllowed':
failure.message = `${lineInfo}: link "${details}" must omit the .md suffix.`;
break;
case 'docNotSnakeCase':
failure.message = `${lineInfo}: document name "${details}" must be snake_case.`;
break;
case 'missingDocument':
failure.message = `${lineInfo}: document ${details} does not exist in the wiki.`;
break;
case 'ambiguousDocument':
failure.message = `${lineInfo}: document reference is ambiguous (${details}).`;
break;
case 'anchorNotKebabCase':
failure.message = `${lineInfo}: heading reference ${details} must be kebab-case.`;
break;
case 'missingCrossDocAnchor':
failure.message = `${lineInfo}: heading ${details} was not found.`;
break;