chore(docs): add docs sync and CI quality checks

This commit is contained in:
callumalpass 2026-02-15 10:38:52 +11:00
parent 8f79190ada
commit 413df27517
7 changed files with 329 additions and 4 deletions

View file

@ -67,6 +67,10 @@ jobs:
echo "NPM version: $(npm --version)"
echo "Timezone: $(date)"
echo "Jest version: $(npx jest --version)"
- name: Run docs quality checks
run: npm run docs:check
timeout-minutes: 3
- name: Run linter
run: npm run lint || echo "Linting completed with warnings"

View file

@ -75,6 +75,7 @@ nav:
- NLP API: nlp-api.md
- Webhooks: webhooks.md
- Workflows: workflows.md
- Calendar Setup: calendar-setup.md
- Migration Guide: migration-v3-to-v4.md
- Development:
- Translation Workflow: development/translations.md
@ -91,4 +92,4 @@ markdown_extensions:
- attr_list
extra_css:
- stylesheets/extra.css
- stylesheets/extra.css

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "tasknotes",
"version": "4.3.0",
"version": "4.3.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "tasknotes",
"version": "4.3.0",
"version": "4.3.2",
"license": "MIT",
"dependencies": {
"@codemirror/view": "^6.37.2",

View file

@ -14,7 +14,9 @@
"e2e:docs:copy": "mkdir -p media/docs && cp test-results/docs/*.png media/docs/",
"e2e:setup": "bash e2e-setup.sh",
"e2e:launch": "bash e2e-launch.sh",
"version": "node version-bump.mjs && node generate-release-notes-import.mjs && git add manifest.json versions.json src/releaseNotes.ts",
"version": "node version-bump.mjs && node generate-release-notes-import.mjs && npm run docs:sync && git add manifest.json versions.json src/releaseNotes.ts docs/releases.md PRIVACY.md",
"docs:sync": "node scripts/update-release-index.mjs && node scripts/sync-privacy.mjs",
"docs:check": "node scripts/update-release-index.mjs --check && node scripts/sync-privacy.mjs --check && node scripts/check-docs.mjs",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",

141
scripts/check-docs.mjs Normal file
View file

@ -0,0 +1,141 @@
#!/usr/bin/env node
import fs from 'fs';
import path from 'path';
const rootDir = process.cwd();
const docsDir = path.join(rootDir, 'docs');
const mkdocsPath = path.join(rootDir, 'mkdocs.yml');
const readmePath = path.join(rootDir, 'README.md');
const manifestPath = path.join(rootDir, 'manifest.json');
const releaseIndexPath = path.join(docsDir, 'releases.md');
const privacyPath = path.join(docsDir, 'privacy.md');
const rootPrivacyPath = path.join(rootDir, 'PRIVACY.md');
const errors = [];
function normalizeLink(link) {
return decodeURIComponent(link.split('#')[0].split('?')[0]).trim();
}
function stripMarkdownCodeBlocks(content) {
return content
.replace(/```[\s\S]*?```/g, '')
.replace(/`[^`\n]+`/g, '');
}
function markdownFiles() {
const files = [readmePath];
function walk(dir) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(fullPath);
} else if (entry.isFile() && entry.name.endsWith('.md')) {
files.push(fullPath);
}
}
}
walk(docsDir);
return files;
}
function verifyMkdocsNavTargetsExist() {
const config = fs.readFileSync(mkdocsPath, 'utf8');
const matches = [...config.matchAll(/:\s*([A-Za-z0-9_./-]+\.md)\s*$/gm)];
for (const match of matches) {
const relative = match[1];
const full = path.join(docsDir, relative);
if (!fs.existsSync(full)) {
errors.push(`mkdocs nav references missing file: docs/${relative}`);
}
}
if (!config.includes('calendar-setup.md')) {
errors.push('mkdocs nav is missing docs/calendar-setup.md');
}
}
function verifyCanonicalDocsUrl() {
const readme = fs.readFileSync(readmePath, 'utf8');
if (!readme.includes('https://tasknotes.dev/')) {
errors.push('README.md is missing canonical docs URL: https://tasknotes.dev/');
}
}
function verifyReleaseIndexMatchesManifest() {
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
const releaseIndex = fs.readFileSync(releaseIndexPath, 'utf8');
const releaseFile = path.join(docsDir, 'releases', `${manifest.version}.md`);
if (!fs.existsSync(releaseFile)) {
errors.push(`Missing release file for manifest version: docs/releases/${manifest.version}.md`);
}
if (!releaseIndex.includes(`releases/${manifest.version}.md`)) {
errors.push(`docs/releases.md does not include current manifest version ${manifest.version}`);
}
}
function verifyPrivacyMirror() {
const docsPrivacy = fs.readFileSync(privacyPath, 'utf8').trimEnd();
const rootPrivacy = fs.readFileSync(rootPrivacyPath, 'utf8').trimEnd();
if (docsPrivacy !== rootPrivacy) {
errors.push('PRIVACY.md is out of sync with docs/privacy.md');
}
}
function verifyLocalMarkdownLinks() {
const files = markdownFiles();
const mdLinkPattern = /!?\[[^\]]*\]\(([^)]+)\)/g;
for (const file of files) {
if (file.includes(`${path.sep}docs${path.sep}releases${path.sep}`)) {
continue;
}
const directory = path.dirname(file);
const content = stripMarkdownCodeBlocks(fs.readFileSync(file, 'utf8'));
for (const match of content.matchAll(mdLinkPattern)) {
const raw = match[1].replace(/^<|>$/g, '').trim();
if (!raw) continue;
if (raw.startsWith('#')) continue;
if (raw.startsWith('http://') || raw.startsWith('https://')) continue;
if (raw.startsWith('mailto:')) continue;
if (raw.startsWith('data:')) continue;
const normalized = normalizeLink(raw);
if (!normalized) continue;
const resolved = normalized.startsWith('/')
? path.join(rootDir, normalized.slice(1))
: path.resolve(directory, normalized);
if (!fs.existsSync(resolved)) {
const relativeFile = path.relative(rootDir, file);
errors.push(`Broken local link in ${relativeFile}: ${raw}`);
}
}
}
}
verifyMkdocsNavTargetsExist();
verifyCanonicalDocsUrl();
verifyReleaseIndexMatchesManifest();
verifyPrivacyMirror();
verifyLocalMarkdownLinks();
if (errors.length > 0) {
console.error('Documentation checks failed:');
for (const error of errors) {
console.error(`- ${error}`);
}
process.exit(1);
}
console.log('Documentation checks passed.');

24
scripts/sync-privacy.mjs Normal file
View file

@ -0,0 +1,24 @@
#!/usr/bin/env node
import fs from 'fs';
import path from 'path';
const rootDir = process.cwd();
const sourcePath = path.join(rootDir, 'docs', 'privacy.md');
const targetPath = path.join(rootDir, 'PRIVACY.md');
const checkMode = process.argv.includes('--check');
const source = fs.readFileSync(sourcePath, 'utf8').trimEnd();
const output = `${source}\n`;
if (checkMode) {
const current = fs.readFileSync(targetPath, 'utf8');
if (current !== output) {
console.error('PRIVACY.md is out of sync with docs/privacy.md. Run: npm run docs:sync');
process.exit(1);
}
process.exit(0);
}
fs.writeFileSync(targetPath, output, 'utf8');
console.log('Synced PRIVACY.md from docs/privacy.md');

View file

@ -0,0 +1,153 @@
#!/usr/bin/env node
import fs from 'fs';
import path from 'path';
const rootDir = process.cwd();
const releasesDir = path.join(rootDir, 'docs', 'releases');
const outputPath = path.join(rootDir, 'docs', 'releases.md');
const manifestPath = path.join(rootDir, 'manifest.json');
const checkMode = process.argv.includes('--check');
function parseVersion(filename) {
const stem = filename.replace(/\.md$/, '');
const match = stem.match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/);
if (!match) {
return null;
}
return {
raw: stem,
major: Number(match[1]),
minor: Number(match[2]),
patch: Number(match[3]),
prerelease: match[4] ?? null,
};
}
function compareIdentifiers(a, b) {
const aNum = /^\d+$/.test(a) ? Number(a) : NaN;
const bNum = /^\d+$/.test(b) ? Number(b) : NaN;
if (!Number.isNaN(aNum) && !Number.isNaN(bNum)) {
return aNum - bNum;
}
if (!Number.isNaN(aNum)) {
return -1;
}
if (!Number.isNaN(bNum)) {
return 1;
}
return a.localeCompare(b);
}
function comparePrerelease(a, b) {
if (a === null && b === null) return 0;
if (a === null) return 1;
if (b === null) return -1;
const aParts = a.split('.');
const bParts = b.split('.');
const max = Math.max(aParts.length, bParts.length);
for (let i = 0; i < max; i += 1) {
const aPart = aParts[i];
const bPart = bParts[i];
if (aPart === undefined) return -1;
if (bPart === undefined) return 1;
const cmp = compareIdentifiers(aPart, bPart);
if (cmp !== 0) {
return cmp;
}
}
return 0;
}
function sortVersionsDesc(a, b) {
if (a.major !== b.major) return b.major - a.major;
if (a.minor !== b.minor) return b.minor - a.minor;
if (a.patch !== b.patch) return b.patch - a.patch;
return -comparePrerelease(a.prerelease, b.prerelease);
}
function buildReleaseIndex(currentMajor, versions) {
const groups = new Map();
for (const version of versions) {
const list = groups.get(version.major) ?? [];
list.push(version);
groups.set(version.major, list);
}
const majors = [...groups.keys()].sort((a, b) => b - a);
const lines = [];
lines.push('# Release Notes');
lines.push('');
lines.push('Welcome to the TaskNotes release notes. Here you can find detailed information about each version, including new features, bug fixes, and improvements.');
lines.push('');
lines.push('## Latest Releases');
lines.push('');
for (const major of majors) {
const title = major === 0
? '### Early Versions (0.x)'
: major === currentMajor
? `### Version ${major}.x (Current)`
: `### Version ${major}.x`;
lines.push(title);
lines.push('');
const items = (groups.get(major) ?? []).sort(sortVersionsDesc);
for (const item of items) {
lines.push(`- [${item.raw}](releases/${item.raw}.md)`);
}
lines.push('');
}
lines.push('## Getting Updates');
lines.push('');
lines.push('To update TaskNotes:');
lines.push('1. Open Obsidian');
lines.push('2. Go to Settings → Community Plugins');
lines.push('3. Find TaskNotes and click "Update"');
lines.push('4. Restart Obsidian if prompted');
lines.push('');
lines.push('## Feedback');
lines.push('');
lines.push('Found a bug or have a feature request? Please:');
lines.push('');
lines.push('- Check existing [GitHub Issues](https://github.com/callumalpass/tasknotes/issues)');
lines.push('- Create a new issue with details');
lines.push('');
return `${lines.join('\n')}`;
}
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
const currentMajor = Number(String(manifest.version).split('.')[0]);
const versions = fs
.readdirSync(releasesDir)
.filter((name) => name.endsWith('.md') && name !== 'unreleased.md')
.map(parseVersion)
.filter((value) => value !== null)
.sort(sortVersionsDesc);
const output = buildReleaseIndex(currentMajor, versions);
if (checkMode) {
const current = fs.readFileSync(outputPath, 'utf8');
if (current !== output) {
console.error('docs/releases.md is out of date. Run: npm run docs:sync');
process.exit(1);
}
process.exit(0);
}
fs.writeFileSync(outputPath, output, 'utf8');
console.log('Updated docs/releases.md');