diff --git a/.github/CI_CD_DOCUMENTATION.md b/.github/CI_CD_DOCUMENTATION.md index 13c7822..4cae8f4 100644 --- a/.github/CI_CD_DOCUMENTATION.md +++ b/.github/CI_CD_DOCUMENTATION.md @@ -13,6 +13,10 @@ This repository implements comprehensive quality gates to ensure code quality an All pull requests to `main` undergo automated validation: - **Linting** - Code style and quality checks via ESLint +- **Unit Tests** - Vitest coverage for TypeScript utilities +- **Spell Checking** - Source and generated HTML spelling checks via CSpell +- **Prose Linting** - Article terminology and style checks via Vale +- **Vale Fixture Tests** - Terminology rule fixtures that assert expected pass/fail behavior - **Type Checking** - TypeScript validation via `astro check` - **Build Verification** - Ensures the site builds successfully - **Link Validation** - Checks all internal links are valid @@ -32,7 +36,9 @@ Custom Node.js script that: **Usage:** ```bash npm run validate:links # Run link validation -npm run validate:all # Run all validation (lint, build, links) +npm run lint:prose # Run Vale prose linting +npm run test:vale # Run Vale terminology fixture tests +npm run validate:all # Run validation (lint, prose, Vale fixtures, build, links) ``` ### 3. Deployment Workflow @@ -84,7 +90,7 @@ Edit files, add content, modify styles, etc. ### 3. Test Locally ```bash -npm run validate:all # Run all checks locally +npm run test:ci # Run all CI checks locally ``` ### 4. Create Pull Request @@ -151,4 +157,4 @@ Changes are automatically deployed to GitHub Pages. - [Branch Protection Setup](./BRANCH_PROTECTION.md) - [PR Validation Workflow](./workflows/pr-validation.yml) - [Deployment Workflow](./workflows/deploy.yml) -- [Link Validation Script](../scripts/validate-links.js) \ No newline at end of file +- [Link Validation Script](../scripts/validate-links.js) diff --git a/.github/WORKFLOW_ARCHITECTURE.md b/.github/WORKFLOW_ARCHITECTURE.md index 9f3eb5c..47c23d8 100644 --- a/.github/WORKFLOW_ARCHITECTURE.md +++ b/.github/WORKFLOW_ARCHITECTURE.md @@ -14,7 +14,10 @@ This repository uses a modular GitHub Actions workflow architecture to ensure co - Accepts optional `checkout-ref` input for specific git references - Performs all validation steps: - Linting (ESLint) + - Unit tests - Spell checking (source files) + - Prose linting (Vale) + - Vale fixture tests - TypeScript checking and Astro build - Spell checking (generated HTML) - Internal link validation @@ -64,6 +67,8 @@ Multiple safeguards prevent accidental deployment: PRs undergo the exact same validation as deployment, including: - All linting and type checking - Spell checking (both source and generated HTML) +- Prose linting +- Vale fixture tests - Full site build - Link validation @@ -120,4 +125,4 @@ if: | ### Permission Model - **PR Validation**: Read-only access (can't modify repository or deploy) - **Deployment**: Write access only when pushing to main branch -- **Manual Workflow**: Deployment only allowed from main branch with explicit flag \ No newline at end of file +- **Manual Workflow**: Deployment only allowed from main branch with explicit flag diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ba1b056..5bb166f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -51,6 +51,12 @@ jobs: - name: Run Spell Check (Source) run: npm run spellcheck + + - name: Run Prose Lint (Vale) + run: npm run lint:prose + + - name: Run Vale Fixture Tests + run: npm run test:vale - name: Build Site run: npm run build diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index 8dce8cc..2280ad3 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -42,7 +42,10 @@ jobs: // Build check list with actual results const checks = [ '✓ Linting', + '✓ Unit tests', '✓ Spell check (source)', + '✓ Prose lint (Vale)', + '✓ Vale fixture tests', '✓ Type checking & Build', '✓ Spell check (HTML)', '✓ Internal link validation', @@ -59,4 +62,4 @@ jobs: owner: context.repo.owner, repo: context.repo.repo, body: `## PR Validation ${icon}\n\n${message}\n\n### Checks Performed:\n${checkList}\n\n*This is a complete dry-run of the deployment process, ensuring your changes will deploy successfully when merged.*` - }); \ No newline at end of file + }); diff --git a/.vale.ini b/.vale.ini new file mode 100644 index 0000000..fb2ed3c --- /dev/null +++ b/.vale.ini @@ -0,0 +1,12 @@ +StylesPath = .vale/styles +MinAlertLevel = warning +IgnoredScopes = code, tt + +[formats] +mdx = md + +[src/content/**/*.{md,mdx}] +BasedOnStyles = Terminology + +[.vale/fixtures/*.md] +BasedOnStyles = Terminology diff --git a/.vale/README.md b/.vale/README.md new file mode 100644 index 0000000..c1006c6 --- /dev/null +++ b/.vale/README.md @@ -0,0 +1,38 @@ +# Vale Prose Linting + +Vale checks article prose in `src/content`. + +## Structure + +- `.vale.ini`: selects the content paths and enabled style namespaces. +- `.vale/styles/Terminology/`: consistency checks for product, platform, API, and brand terms. + +Add new rule files under the most specific style namespace that fits the rule. If a new family of checks does not fit `Terminology`, add another directory under `.vale/styles` and include it in `.vale.ini` with `BasedOnStyles`. + +## Rule Notes + +Apple terminology substitutions are case-sensitive and list the incorrect forms they should catch. That keeps correctly cased terms from depending on case-insensitive substitution behavior. + +The substitution list is a regression-tested guardrail for common incorrect forms, not an exhaustive casing detector. Add explicit variants when a new mistake shows up in content or review. + +Historical Apple platform names such as `classic Mac OS` and `Mac OS X` are valid when discussing older systems. If a passage intentionally uses a form that a rule flags, keep the exception local: + +```markdown + +classic mac os spelling retained for quoted or historical context + +``` + +MDX files are treated as Markdown. JSX component attribute values can be linted as prose, so use local Vale disable comments around intentional non-prose values when needed. + +Image alt text is linted with the rest of article prose because it ships to readers and assistive technology. + +The local `@vvago/vale` package downloads the Vale binary during npm install. The lint command itself uses the checked-in rules and does not run `vale sync`. + +## Usage + +```bash +npm run lint:prose +npm run test:vale +just lint-prose +``` diff --git a/.vale/fixtures/apple-terminology-invalid.md b/.vale/fixtures/apple-terminology-invalid.md new file mode 100644 index 0000000..d19264d --- /dev/null +++ b/.vale/fixtures/apple-terminology-invalid.md @@ -0,0 +1,7 @@ +# Invalid Apple Terminology + +An ios app built in xcode can mention Swift UI, Objective C, Test Flight, MacOS, and Apple Silicon. + +An ipad tablet can pair with an iphone, while ipad os, ipad-Os, IPad os, IPAD OS, IPAD-OS, watch os, tv os, and vision os keep platform names consistent. + +Publish through the app store. diff --git a/.vale/fixtures/apple-terminology-valid.md b/.vale/fixtures/apple-terminology-valid.md new file mode 100644 index 0000000..373a9ac --- /dev/null +++ b/.vale/fixtures/apple-terminology-valid.md @@ -0,0 +1,9 @@ +# Valid Apple Terminology + +An iOS app built in Xcode can mention SwiftUI, Objective-C, TestFlight, macOS, and Apple silicon. + +An iPad tablet can pair with an iPhone, while iPadOS, watchOS, tvOS, and visionOS keep platform names consistent. + +Publish through the App Store. + +Historical names such as classic Mac OS and Mac OS X can be correct in older platform context. diff --git a/.vale/styles/Terminology/Apple.yml b/.vale/styles/Terminology/Apple.yml new file mode 100644 index 0000000..afdb3cb --- /dev/null +++ b/.vale/styles/Terminology/Apple.yml @@ -0,0 +1,19 @@ +extends: substitution +message: "Use '%s' instead of '%s'." +level: error +ignorecase: false +swap: + '\b(ios|IOS|Ios)\b': iOS + '\b(?:ipad|IPad|Ipad|IPAD)(?![ -]?[oO][sS]\b)\b': iPad + '\b((?:ipad|IPad|Ipad|IPAD)[ -]?[oO][sS]|iPad(?:[ -][oO][sS]|os|Os|oS))\b': iPadOS + '\b(iphone|IPhone|Iphone|IPHONE)\b': iPhone + '\b(mac[ -]?os|MacOS|Mac-OS|MAC[ -]?OS)\b': macOS + '\b(watch[ -]?os|watch[ -]OS|Watch[ -]?OS|Watch[ -]?os|WATCH[ -]?OS)\b': watchOS + '\b(tv[ -]?os|tv[ -]OS|TV[ -]?OS|Tv[ -]?OS)\b': tvOS + '\b(vision[ -]?os|vision[ -]OS|Vision[ -]?OS|Vision[ -]?os|VISION[ -]?OS)\b': visionOS + '\b(xcode|XCode|XCODE)\b': Xcode + '\b(swift[ -]?ui|swift[ -]?UI|Swift[ -]UI|Swift[ -]?Ui|SWIFT[ -]?UI)\b': SwiftUI + '\b(objective[ -]?c|Objective C|Objective-c|ObjectiveC|OBJECTIVE[ -]?C)\b': Objective-C + '\b(app\s+store|App\s+store|app\s+Store|APP\s+STORE)\b': App Store + '\b(test\s*flight|Test\s+Flight|Testflight|TEST\s*FLIGHT)\b': TestFlight + '\b(apple\s+silicon|Apple\s+Silicon|APPLE\s+SILICON)\b': Apple silicon diff --git a/CLAUDE.md b/CLAUDE.md index 00f1f81..8259e75 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,8 +21,10 @@ Some key commands are: - just spellcheck-html: checks spelling in built HTML output - just lint: runs ESLint on all files - just lint-fix: auto-fixes ESLint issues where possible +- just lint-prose: runs Vale on article content +- just test-vale: verifies Vale terminology fixture behavior - just validate-feed: builds the site then validates the RSS feed XML (well-formedness + namespaces, via xmllint) -- just validate: runs all validation checks (lint + spellcheck + build + links + feed) +- just validate: runs all validation checks (lint + spellcheck + prose + Vale fixtures + build + links + feed) ## Key Technical Decisions diff --git a/SPELLCHECK.md b/SPELLCHECK.md index f6599aa..a645e21 100644 --- a/SPELLCHECK.md +++ b/SPELLCHECK.md @@ -1,6 +1,6 @@ # Spell Checking Setup -This project uses CSpell for spell checking both source files and generated HTML output. +This project uses CSpell for spell checking both source files and generated HTML output. Vale is used separately for prose style and terminology consistency. ## Features @@ -68,11 +68,11 @@ We evaluated several spell-checking solutions: - **Hunspell/Aspell**: Traditional spell checkers, but poor at handling technical terms and camelCase - **LanguageTool**: Excellent for grammar but overkill for basic spell checking - **textlint**: Good but requires more configuration for technical content -- **Vale**: Great for style guides but complex setup for simple spell checking +- **Vale**: Strong for style guides; used here for prose style and terminology consistency, not spelling CSpell was chosen because it: - Has built-in understanding of code conventions - Includes extensive technical dictionaries - Integrates easily with npm scripts and CI/CD - Provides fast performance -- Offers smart suggestions for technical terms \ No newline at end of file +- Offers smart suggestions for technical terms diff --git a/VALIDATION.md b/VALIDATION.md index bef90d8..398eeb3 100644 --- a/VALIDATION.md +++ b/VALIDATION.md @@ -45,23 +45,36 @@ npm run spellcheck:html npm run spellcheck:all ``` -### 3. Build +### 3. Prose Linting +Checks article content for Vale style and terminology rules: +```bash +npm run lint:prose +``` + +### 4. Vale Fixture Tests +Checks that Vale terminology fixtures fail and pass as expected: +```bash +npm run test:vale +``` + +### 5. Build Generates the static site: ```bash npm run build ``` -### 4. Link Validation +### 6. Link Validation Checks for broken internal links (requires build first): ```bash npm run validate:links ``` -### 5. All Validations +### 7. All Validations Runs everything in sequence: ```bash npm run validate:all -# OR equivalently: + +# Full CI parity, including unit tests: npm run test:ci ``` @@ -69,10 +82,13 @@ npm run test:ci The GitHub Actions workflow runs these exact same checks: 1. Linting (`npm run lint`) -2. Source spell check (`npm run spellcheck`) -3. Build (`npm run build`) -4. HTML spell check (`npm run spellcheck:html`) -5. Link validation (`npm run validate:links`) +2. Unit tests (`npm run test:unit`) +3. Source spell check (`npm run spellcheck`) +4. Prose lint (`npm run lint:prose`) +5. Vale fixture tests (`npm run test:vale`) +6. Build (`npm run build`) +7. HTML spell check (`npm run spellcheck:html`) +8. Link validation (`npm run validate:links`) ## Troubleshooting @@ -122,7 +138,8 @@ Before pushing changes: 1. ✅ Run `npm run test:ci` locally 2. ✅ Fix any issues that arise 3. ✅ If adding new terms, update `cspell.json` -4. ✅ Commit all changes including config updates +4. ✅ If adding new terminology rules, update `.vale/styles/` +5. ✅ Commit all changes including config updates ## Common Gotchas @@ -130,6 +147,7 @@ Before pushing changes: 2. **HTML spell check requires build**: Always run `npm run build` first 3. **CI uses exact npm scripts**: Don't rely on different local commands 4. **Case sensitivity**: File paths are case-sensitive in CI (Linux) but may not be locally (macOS/Windows) +5. **Vale fixture failures**: Update `.vale/fixtures/` and `scripts/check-vale-fixtures.js` when changing expected terminology rule behavior ## Maintaining CI/CD Parity @@ -141,4 +159,4 @@ To ensure local development matches CI: 4. If CI fails but local passes, check for: - Missing files in git - Different Node.js versions - - Platform-specific issues (Linux CI vs local macOS/Windows) \ No newline at end of file + - Platform-specific issues (Linux CI vs local macOS/Windows) diff --git a/justfile b/justfile index a420d78..70b60fa 100644 --- a/justfile +++ b/justfile @@ -113,7 +113,15 @@ lint-fix: lint-markdown: npm run lint:markdown -# Validate: runs all validation checks (lint + unit tests + spellcheck + build + links + feed) +# Lint-prose: runs Vale on article content +lint-prose: + npm run lint:prose + +# Test-vale: verifies Vale terminology fixture behavior +test-vale: + npm run test:vale + +# Validate: runs all validation checks (lint + unit tests + spellcheck + prose + Vale fixtures + build + links + feed) validate: npm run validate:all diff --git a/package-lock.json b/package-lock.json index 9fc98c8..6cc2b37 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,6 +44,7 @@ "@cspell/dict-typescript": "^3.2.3", "@playwright/test": "^1.59.1", "@types/node": "^24.12.2", + "@vvago/vale": "^3.15.1", "cspell": "^10.0.0", "markdownlint-cli2": "^0.22.0", "prettier": "^3.8.3", @@ -3871,6 +3872,22 @@ "integrity": "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==", "license": "MIT" }, + "node_modules/@vvago/vale": { + "version": "3.15.1", + "resolved": "https://registry.npmjs.org/@vvago/vale/-/vale-3.15.1.tgz", + "integrity": "sha512-2tVObU1Tprq/ItvQNGnWFKYd5rAqlMw5BPuqSTBz0yYI2g8B7xPf/xQXUi7FtmNpwD4VImvuZtvpTh6eQO1AZg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "rimraf": "^6.1.3", + "tar": "^7.5.15", + "unzipper": "^0.12.3" + }, + "bin": { + "vale": "bin/vale" + } + }, "node_modules/accessible-astro-components": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/accessible-astro-components/-/accessible-astro-components-4.1.3.tgz", @@ -4530,6 +4547,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true, + "license": "MIT" + }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -5041,6 +5065,13 @@ "integrity": "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==", "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -5551,6 +5582,16 @@ "node": ">=4" } }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.343", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.343.tgz", @@ -6390,6 +6431,21 @@ "url": "https://github.com/sponsors/rawify" } }, + "node_modules/fs-extra": { + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.1.tgz", + "integrity": "sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -6474,6 +6530,24 @@ "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==", "license": "ISC" }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -6535,6 +6609,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, "node_modules/h3": { "version": "1.15.11", "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.11.tgz", @@ -6955,6 +7036,13 @@ "node": ">=0.8.19" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, "node_modules/ini": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", @@ -7176,6 +7264,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -7257,6 +7352,19 @@ "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", "license": "MIT" }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, "node_modules/jsonpointer": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz", @@ -8818,6 +8926,13 @@ "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", "license": "MIT" }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, "node_modules/node-mock-http": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/node-mock-http/-/node-mock-http-1.0.4.tgz", @@ -9028,6 +9143,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/package-manager-detector": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", @@ -9171,6 +9293,33 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "license": "MIT" }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -9463,6 +9612,13 @@ "node": ">=6" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -9605,6 +9761,22 @@ "pify": "^2.3.0" } }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -10015,6 +10187,26 @@ "node": ">=0.10.0" } }, + "node_modules/rimraf": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", + "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "glob": "^13.0.3", + "package-json-from-dist": "^1.0.1" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/rollup": { "version": "4.60.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", @@ -10082,6 +10274,13 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -10301,6 +10500,16 @@ "integrity": "sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w==", "license": "MIT" }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/string-width": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", @@ -10590,9 +10799,9 @@ } }, "node_modules/tar": { - "version": "7.5.13", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz", - "integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==", + "version": "7.5.19", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.19.tgz", + "integrity": "sha512-4LeEWl96twnS2Q7Bz4MGqgazLqO+hJN63GZxXoIqh1T3VweYD997gbU1ItNsQafqqXTXd5WFyFdReLtwvRBNiw==", "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", @@ -11011,6 +11220,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/unstorage": { "version": "1.17.5", "resolved": "https://registry.npmjs.org/unstorage/-/unstorage-1.17.5.tgz", @@ -11144,6 +11363,20 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/unzipper": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.12.5.tgz", + "integrity": "sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "bluebird": "~3.7.2", + "duplexer2": "~0.1.4", + "fs-extra": "11.3.1", + "graceful-fs": "^4.2.2", + "node-int64": "^0.4.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", diff --git a/package.json b/package.json index 9c00817..1a7c926 100644 --- a/package.json +++ b/package.json @@ -18,15 +18,17 @@ "lint": "eslint .", "lint:fix": "eslint . --fix", "lint:markdown": "markdownlint-cli2 \"src/content/**/*.{md,mdx}\"", + "lint:prose": "node scripts/run-vale.js --config=.vale.ini src/content", + "test:vale": "node scripts/check-vale-fixtures.js", "spellcheck": "cspell \"src/**/*.{md,mdx,ts,tsx,js,jsx,astro}\" --no-progress", "spellcheck:html": "cspell \"dist/**/*.html\" --no-progress", "spellcheck:all": "npm run spellcheck && npm run build && npm run spellcheck:html", "validate:links": "node scripts/validate-links.js", "validate:feed": "node scripts/validate-feed.js", - "validate:all": "npm run lint && npm run test:unit && npm run spellcheck && npm run build && npm run spellcheck:html && npm run validate:links && npm run validate:feed", + "validate:all": "npm run lint && npm run test:unit && npm run spellcheck && npm run lint:prose && npm run test:vale && npm run build && npm run spellcheck:html && npm run validate:links && npm run validate:feed", "test:unit": "vitest run", - "test:ci": "npm run lint && npm run test:unit && npm run spellcheck && npm run build && npm run spellcheck:html && npm run validate:links && npm run validate:feed", - "test:ci:verbose": "echo '🔍 Running CI validation locally...' && npm run lint && echo '✓ Linting passed' && npm run test:unit && echo '✓ Unit tests passed' && npm run spellcheck && echo '✓ Source spell check passed' && npm run build && echo '✓ Build succeeded' && npm run spellcheck:html && echo '✓ HTML spell check passed' && npm run validate:links && echo '✓ Link validation passed' && npm run validate:feed && echo '✓ Feed validation passed' && echo '✅ All CI checks passed!'", + "test:ci": "npm run lint && npm run test:unit && npm run spellcheck && npm run lint:prose && npm run test:vale && npm run build && npm run spellcheck:html && npm run validate:links && npm run validate:feed", + "test:ci:verbose": "echo '🔍 Running CI validation locally...' && npm run lint && echo '✓ Linting passed' && npm run test:unit && echo '✓ Unit tests passed' && npm run spellcheck && echo '✓ Source spell check passed' && npm run lint:prose && echo '✓ Prose lint passed' && npm run test:vale && echo '✓ Vale fixture tests passed' && npm run build && echo '✓ Build succeeded' && npm run spellcheck:html && echo '✓ HTML spell check passed' && npm run validate:links && echo '✓ Link validation passed' && npm run validate:feed && echo '✓ Feed validation passed' && echo '✅ All CI checks passed!'", "qa": "playwright test --ignore-snapshots", "qa:headed": "playwright test --headed --ignore-snapshots", "qa:ui": "playwright test --ui", @@ -71,6 +73,7 @@ "@cspell/dict-typescript": "^3.2.3", "@playwright/test": "^1.59.1", "@types/node": "^24.12.2", + "@vvago/vale": "^3.15.1", "cspell": "^10.0.0", "markdownlint-cli2": "^0.22.0", "prettier": "^3.8.3", diff --git a/scripts/check-vale-fixtures.js b/scripts/check-vale-fixtures.js new file mode 100644 index 0000000..0ca2e01 --- /dev/null +++ b/scripts/check-vale-fixtures.js @@ -0,0 +1,112 @@ +import { spawnSync } from "node:child_process"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { requireValeExecutable } from "./vale.js"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const valeExecutable = requireValeExecutable(root); + +const cases = [ + { + file: ".vale/fixtures/apple-terminology-valid.md", + expectedMatches: [], + }, + { + file: ".vale/fixtures/apple-terminology-invalid.md", + expectedMatches: [ + "ios", + "ipad", + "ipad-Os", + "ipad os", + "IPad os", + "IPAD OS", + "IPAD-OS", + "iphone", + "xcode", + "watch os", + "tv os", + "vision os", + "Swift UI", + "Objective C", + "app store", + "Test Flight", + "MacOS", + "Apple Silicon", + ], + }, +]; + +function runVale(file) { + const result = spawnSync( + valeExecutable, + ["--config=.vale.ini", "--output=JSON", file], + { + cwd: root, + encoding: "utf8", + }, + ); + + if (result.error) { + throw result.error; + } + + const stdout = result.stdout.trim(); + let output; + + try { + output = stdout ? JSON.parse(stdout) : {}; + } catch (error) { + throw new Error( + `Could not parse Vale JSON output for ${file}:\n${stdout}`, + { cause: error }, + ); + } + + return { + status: result.status, + stderr: result.stderr.trim(), + alerts: Object.values(output).flat(), + }; +} + +let failed = false; + +for (const { file, expectedMatches } of cases) { + const { status, stderr, alerts } = runVale(file); + const matches = alerts.map((alert) => alert.Match).sort(); + const sortedExpectedMatches = expectedMatches.toSorted(); + + if (expectedMatches.length === 0) { + if (status !== 0 || alerts.length !== 0) { + failed = true; + console.error(`${file} should pass without Vale alerts.`); + console.error(JSON.stringify(alerts, null, 2)); + } + + continue; + } + + if (status === 0 || alerts.length === 0) { + failed = true; + console.error(`${file} should fail with Vale alerts.`); + } + + if (JSON.stringify(matches) !== JSON.stringify(sortedExpectedMatches)) { + failed = true; + console.error(`${file} reported unexpected Vale matches.`); + console.error("Expected:"); + console.error(JSON.stringify(sortedExpectedMatches, null, 2)); + console.error("Actual:"); + console.error(JSON.stringify(matches, null, 2)); + } + + if (stderr) { + console.error(stderr); + } +} + +if (failed) { + process.exit(1); +} + +console.log("Vale fixture checks passed."); diff --git a/scripts/run-vale.js b/scripts/run-vale.js new file mode 100644 index 0000000..f700925 --- /dev/null +++ b/scripts/run-vale.js @@ -0,0 +1,27 @@ +import { spawnSync } from "node:child_process"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { requireValeExecutable } from "./vale.js"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +let executable; + +try { + executable = requireValeExecutable(root); +} catch (error) { + console.error(error.message); + process.exit(1); +} + +const result = spawnSync(executable, process.argv.slice(2), { + cwd: root, + stdio: "inherit", + windowsHide: true, +}); + +if (result.error) { + console.error(result.error.message); + process.exit(1); +} + +process.exit(result.status ?? 1); diff --git a/scripts/vale.js b/scripts/vale.js new file mode 100644 index 0000000..7ccbf3b --- /dev/null +++ b/scripts/vale.js @@ -0,0 +1,28 @@ +import { accessSync, constants } from "node:fs"; +import { resolve } from "node:path"; + +export function getValeExecutable(root) { + return resolve( + root, + "node_modules", + "@vvago", + "vale", + "bin", + process.platform === "win32" ? "vale.exe" : "vale", + ); +} + +export function requireValeExecutable(root) { + const executable = getValeExecutable(root); + + try { + accessSync(executable, constants.F_OK | constants.X_OK); + } catch (error) { + throw new Error( + `Vale executable was not found at ${executable}. Run npm ci before invoking Vale.`, + { cause: error }, + ); + } + + return executable; +} diff --git a/src/content/briefs/objective-c/the-three-file-pattern.md b/src/content/briefs/objective-c/the-three-file-pattern.md index d6ea1eb..9cfc2a2 100644 --- a/src/content/briefs/objective-c/the-three-file-pattern.md +++ b/src/content/briefs/objective-c/the-three-file-pattern.md @@ -34,7 +34,7 @@ As an example illustrating both, let's say we're writing a class cluster that: - represents an in-memory OpenCV image - is essentially "just a wrapper around an OpenCV `cv::Mat` value" - has subclasses for various concrete types (e.g. monochrome, RGB, BGRA, etc.) -- should have a purely objective-c API (e.g. no C++ "leakage") +- should have a purely Objective-C API (e.g. no C++ "leakage") - will have a large API surface area, broken down into topic-specific categories In that case, our base class might look like this: @@ -180,4 +180,3 @@ The closest matches I could find were the below, neither of which feels like the - [Microsoft's (legacy?) Objective-C style guide](https://microsoft.github.io/objc-guide/Headers/Factoring.html) - [A WWDC talk from 2011](https://nonstrict.eu/wwdcindex/wwdc2011/322/) (strangely hard to locate) -