diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8fe790fd..174205d2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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" diff --git a/mkdocs.yml b/mkdocs.yml index 2672ea62..d6709edb 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -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 \ No newline at end of file + - stylesheets/extra.css diff --git a/package-lock.json b/package-lock.json index 1d2a2acd..bfe2a692 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index fa126cdb..b0743795 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/check-docs.mjs b/scripts/check-docs.mjs new file mode 100644 index 00000000..1bbfa62e --- /dev/null +++ b/scripts/check-docs.mjs @@ -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.'); diff --git a/scripts/sync-privacy.mjs b/scripts/sync-privacy.mjs new file mode 100644 index 00000000..5092beb3 --- /dev/null +++ b/scripts/sync-privacy.mjs @@ -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'); diff --git a/scripts/update-release-index.mjs b/scripts/update-release-index.mjs new file mode 100644 index 00000000..40f57553 --- /dev/null +++ b/scripts/update-release-index.mjs @@ -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');