├── .devcontainer └── devcontainer.json ├── .github └── workflows │ └── test.yml ├── .gitignore ├── .husky └── pre-commit ├── LICENSE ├── README.md ├── artifacts ├── package_test.js ├── sample_asciidoc.adoc ├── sample_markdown.json ├── sample_markdown.md └── sample_markdown_headless.md ├── package-lock.json ├── package.json ├── src ├── cli.test.js ├── index.js ├── index.test.js ├── parsers │ ├── asciidoc.js │ └── markdown.js ├── rules │ ├── ValidationError.js │ ├── codeBlocksValidator.js │ ├── codeBlocksValidator.test.js │ ├── headingValidator.js │ ├── headingValidator.test.js │ ├── instructionValidator.js │ ├── instructionValidator.test.js │ ├── listValidator.js │ ├── listValidator.test.js │ ├── paragraphsValidator.js │ ├── paragraphsValidator.test.js │ ├── sequenceValidator.js │ ├── sequenceValidator.test.js │ ├── structureValidator.js │ ├── structureValidator.test.js │ ├── subsectionValidator.js │ └── subsectionValidator.test.js ├── schema.js ├── templateLoader.js ├── templateLoader.test.js └── util │ ├── getFile.js │ ├── getFile.test.js │ ├── preloadModel.js │ └── tempDir.js └── templates.yaml /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "image": "mcr.microsoft.com/devcontainers/universal:2", 3 | "customizations": { 4 | "vscode": { 5 | "extensions": [ 6 | "GitHub.copilot", 7 | "GitHub.copilot-chat", 8 | "esbenp.prettier-vscode", 9 | "hbenl.vscode-mocha-test-adapter", 10 | "nilobarp.javascript-test-runner", 11 | "aaron-bond.better-comments", 12 | "hbenl.vscode-test-explorer" 13 | ] 14 | } 15 | } 16 | } -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | types: [ opened, synchronize ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Set up Node.js 17 | uses: actions/setup-node@v4 18 | 19 | - name: Install dependencies 20 | run: npm ci 21 | env: 22 | DOC_STRUCTURE_LINT_PRELOAD: 1 23 | 24 | - name: Run tests 25 | run: npm test 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npm test 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Manny Silva 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Doc Structure Lint 2 | 3 | > **This is an alpha release.** Interfaces and structures are subject to change. 4 | 5 | A tool to validate Markdown document structure against specified templates, ensuring consistent documentation across your projects. 6 | 7 | ## Features 8 | 9 | - Validate Markdown documents against YAML-defined templates 10 | - Rich validation capabilities: 11 | - [Section and subsection structure validation](#section-properties) 12 | - [Paragraph count requirements](#paragraphs) 13 | - [List validation](#lists) (ordered/unordered, item counts) 14 | - [Code block requirements](#code-blocks) 15 | - Detailed error reporting with precise document positions 16 | - Template dereferencing support for modular template definitions 17 | - JSON Schema validation for template files 18 | 19 | ### Planned Features 20 | 21 | - Frontmatter validation 22 | - AsciiDoc support 23 | - reStructuredText support 24 | - Infer template from document structure 25 | 26 | ## Usage (as a CLI tool) 27 | 28 | ```bash 29 | npx doc-structure-lint --file-path path/to/doc.md --template path/to/template.yaml 30 | ``` 31 | 32 | Doc Structure Lint uses a _local_ language model to evaluate the `instructions` rules of your templates. This model only takes about 2 GB of storage, and it's only downloaded once. The first time you run the tool with a template that uses `instructions`, it may take a few minutes to download the language model. If you want to preload the model during installation, set the `DOC_STRUCTURE_LINT_PRELOAD` environment variable to `1`. 33 | 34 | ```bash 35 | export DOC_STRUCTURE_LINT_PRELOAD=1 && npx doc-structure-lint --file-path path/to/doc.md --template path/to/template.yaml 36 | ``` 37 | 38 | ### Options 39 | 40 | - `--file-path` or `-f`: URL or path to the content to lint. Local paths can be individual files or directories. 41 | - `--template-path` or `-p`: URL or path to the template file (default: `./template.yaml`). 42 | - `--template` or `-t`: Name of the template to use 43 | - `--json`: Output results in JSON format 44 | 45 | ## Usage (as a package) 46 | 47 | ### Installation 48 | 49 | ```bash 50 | npm install doc-structure-lint 51 | ``` 52 | 53 | ### API Usage 54 | 55 | ```javascript 56 | import { lintDocument } from "doc-structure-lint"; 57 | 58 | async function validateDocument() { 59 | const result = await lintDocument({ 60 | file: "path/to/doc.md", // Path or URL. Doesn't support directories. 61 | templatePath: "path/to/template.yaml", // Path or URL 62 | template: "Template name", // Name of the template to use 63 | }); 64 | } 65 | ``` 66 | 67 | ## Template Format 68 | 69 | Templates are defined in YAML (as shown here) or JSON and specify the structure and content requirements for your documents. Each template can contain multiple sections with various validation rules. 70 | 71 | Template definitions also support referencing with the `$ref` key, allowing you to reuse common section definitions across multiple templates. 72 | 73 | ### Basic Structure 74 | 75 | ```yaml 76 | templates: 77 | template-name: # Must be alphanumeric, can include hyphens and underscores 78 | sections: # Required - contains section definitions 79 | section-name: # Must be alphanumeric, can include hyphens and underscores 80 | # Section properties go here 81 | ``` 82 | 83 | ### Section Properties 84 | 85 | ```yml 86 | description: Description of the section's purpose 87 | instructions: # List of instructions that a section must follow, evaluated by a local language model. Doesn't evaluate content from subsections. 88 | - Instruction 1 89 | - Instruction 2 90 | required: true # Whether the section must be present (default: true) 91 | heading: 92 | const: Exact heading text # Exact heading text match 93 | pattern: ^Regex pattern$ # Regex pattern for heading text 94 | sections: # Nested subsection definitions 95 | nested-section: 96 | # Nested section properties 97 | additionalSections: false # Allow undefined subsections (default: false) 98 | ``` 99 | 100 | ### Content Validation Rules 101 | 102 | #### Paragraphs 103 | 104 | ```yaml 105 | paragraphs: 106 | min: 0 # Minimum number of paragraphs 107 | max: 10 # Maximum number of paragraphs 108 | patterns: # Array of regex patterns applied sequentially 109 | - "^Start with.*" 110 | - ".*end with this$" 111 | ``` 112 | 113 | #### Code Blocks 114 | 115 | ```yaml 116 | code_blocks: 117 | min: 0 # Minimum number of code blocks 118 | max: 5 # Maximum number of code blocks 119 | ``` 120 | 121 | #### Lists 122 | 123 | ```yaml 124 | lists: 125 | min: 0 # Minimum number of lists 126 | max: 5 # Maximum number of lists 127 | items: # Requirements for list items 128 | min: 1 # Minimum items per list 129 | max: 10 # Maximum items per list 130 | paragraphs: # Paragraph requirements within items 131 | min: 0 132 | max: 2 133 | code_blocks: # Code block requirements within items 134 | min: 0 135 | max: 1 136 | lists: # Nested list requirements 137 | min: 0 138 | max: 2 139 | ``` 140 | 141 | #### Content Sequence 142 | 143 | Use `sequence` to specify a strict order of content elements: 144 | 145 | ```yaml 146 | sequence: 147 | - paragraphs: 148 | min: 1 # Must start with at least one paragraph 149 | max: 3 # But a maximum of three paragraphs 150 | - code_blocks: 151 | max: 1 # Followed by at most one code block 152 | - lists: 153 | min: 1 # Then at least one list 154 | - paragraphs: 155 | min: 1 # And ending with at least one paragraph 156 | ``` 157 | 158 | ### Example Template 159 | 160 | The following definition includes templates for a "How To" guide and an "API Operation" reference. Note that the `parameters` component is used in multiple sections with the `$ref` key. 161 | 162 | ```yaml 163 | templates: 164 | how-to: 165 | sections: 166 | title: 167 | instructions: 168 | - Must mention the intent of the document 169 | paragraphs: 170 | min: 1 171 | sections: 172 | overview: 173 | heading: 174 | const: Overview 175 | paragraphs: 176 | min: 1 177 | before you start: 178 | heading: 179 | const: Before you start 180 | paragraphs: 181 | min: 1 182 | task: 183 | paragraphs: 184 | min: 1 185 | additionalSections: true 186 | sections: 187 | Sub-task: 188 | paragraphs: 189 | min: 1 190 | see also: 191 | heading: 192 | const: See also 193 | paragraphs: 194 | min: 1 195 | api-operation: 196 | sections: 197 | overview: 198 | heading: 199 | const: "Overview" 200 | paragraphs: 201 | min: 1 202 | max: 3 203 | request-parameters: 204 | $ref: "#/components/parameters" 205 | response-parameters: 206 | $ref: "#/components/parameters" 207 | examples: 208 | required: true 209 | code_blocks: 210 | min: 1 211 | sections: 212 | success: 213 | heading: 214 | const: "Success Response" 215 | sequence: 216 | - paragraphs: 217 | min: 1 218 | - code_blocks: 219 | min: 1 220 | error: 221 | required: false 222 | heading: 223 | const: "Error Response" 224 | 225 | components: 226 | parameters: 227 | required: false 228 | heading: 229 | pattern: "^Parameters|Request Parameters$" 230 | lists: 231 | min: 1 232 | items: 233 | min: 1 234 | ``` 235 | 236 | To use this template, save it to a file (like the default `templates.yaml`) and specify the template name that matches the key you set in your definition: 237 | 238 | ```bash how-to 239 | npx doc-structure-lint --file-path path/to/doc.md --template-path path/to/templates.yaml --template how-to 240 | ``` 241 | 242 | ```bash api-operation 243 | npx doc-structure-lint --file-path path/to/operation.md --template-path path/to/templates.yaml --template api-operation 244 | ``` 245 | 246 | ## Development 247 | 248 | 1. Clone the repository 249 | 2. Install dependencies: 250 | 251 | ```bash 252 | npm install 253 | ``` 254 | 255 | 3. Run tests: 256 | 257 | ```bash 258 | npm test 259 | ``` 260 | 261 | ## Contributing 262 | 263 | Contributions are welcome! Please feel free to submit a Pull Request. 264 | 265 | ## License 266 | 267 | MIT License - see the [LICENSE](LICENSE) file for details. 268 | -------------------------------------------------------------------------------- /artifacts/package_test.js: -------------------------------------------------------------------------------- 1 | import { lintDocument } from '../src/index.js'; 2 | 3 | const result = await lintDocument({ 4 | file: './test/artifacts/sample_markdown.md', 5 | templatePath: './templates.yaml', 6 | template: 'Sample' 7 | }); 8 | 9 | console.log(result); -------------------------------------------------------------------------------- /artifacts/sample_asciidoc.adoc: -------------------------------------------------------------------------------- 1 | = How-to Guide 2 | 3 | This is an introductory paragraph. 4 | 5 | Here's another paragraph to meet the minimum requirement. 6 | 7 | == Prerequisites 8 | 9 | Here's a prerequisite. 10 | 11 | == Setup 12 | 13 | Here's how to set up. 14 | 15 | == Usage 16 | 17 | Here's how to use it. 18 | 19 | [source,javascript] 20 | ---- 21 | console.log("Hello, World!"); 22 | ---- 23 | 24 | === Troubleshooting 25 | 26 | Here's how to troubleshoot. 27 | 28 | == Next steps 29 | 30 | Here's what to do next. 31 | 32 | And another paragraph to meet the minimum requirement. 33 | -------------------------------------------------------------------------------- /artifacts/sample_markdown.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Sample 3 | description: A sample file for testing purposes. 4 | --- 5 | 6 | # How to Use Our Amazing Product 7 | 8 | This guide will walk you through the process of using our amazing product effectively. 9 | 10 | Follow along and be amazed! 11 | 12 | ## Prerequisites 13 | 14 | Before you begin, make sure you have the following: 15 | 16 | - A valid license key 17 | - At least 4GB of RAM 18 | 19 | - foo 20 | - bar 21 | 22 | - x 23 | - y 24 | 25 | ## Setup 26 | 27 | Follow these steps to set up our amazing product: 28 | 29 | 1. Download the installer from our website. 30 | 2. Run the installer and follow the on-screen instructions. 31 | 3. Enter your license key when prompted. 32 | 4. Restart your computer to complete the installation. 33 | 34 | ## Usage 35 | 36 | To start using the product, follow these steps: 37 | 38 | 1. Open the application. 39 | 2. Click on the "New Project" button. 40 | 3. Choose a template from the list. 41 | 42 | ```python 43 | import amazing_product 44 | 45 | project = amazing_product.create_project("My First Project") 46 | project.run() 47 | ``` 48 | 49 | ### Advanced Features 50 | 51 | For advanced users, we offer additional features: 52 | 53 | 1. Custom scripting 54 | 2. API integration 55 | 3. Automated workflows 56 | 57 | ### Troubleshooting 58 | 59 | If you encounter any issues, try these steps: 60 | 61 | 1. Restart the application. 62 | 2. Check your internet connection. 63 | 3. Verify your license key. 64 | 65 | ## Next steps 66 | 67 | To get the most out of our amazing product, consider: 68 | 69 | - Joining our community forums 70 | - Attending our weekly webinars 71 | - Exploring our extensive documentation -------------------------------------------------------------------------------- /artifacts/sample_markdown_headless.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Sample 3 | description: A sample file for testing purposes. 4 | --- 5 | 6 | This guide will walk you through the process of using our amazing product effectively. 7 | 8 | Follow along and be amazed! 9 | 10 | ## Prerequisites 11 | 12 | Before you begin, make sure you have the following: 13 | 14 | - A valid license key 15 | - At least 4GB of RAM 16 | 17 | - foo 18 | - bar 19 | 20 | - x 21 | - y 22 | 23 | ## Setup 24 | 25 | Follow these steps to set up our amazing product: 26 | 27 | 1. Download the installer from our website. 28 | 2. Run the installer and follow the on-screen instructions. 29 | 3. Enter your license key when prompted. 30 | 4. Restart your computer to complete the installation. 31 | 32 | ## Usage 33 | 34 | To start using the product, follow these steps: 35 | 36 | 1. Open the application. 37 | 2. Click on the "New Project" button. 38 | 3. Choose a template from the list. 39 | 40 | ```python 41 | import amazing_product 42 | 43 | project = amazing_product.create_project("My First Project") 44 | project.run() 45 | ``` 46 | 47 | ### Advanced Features 48 | 49 | For advanced users, we offer additional features: 50 | 51 | 1. Custom scripting 52 | 2. API integration 53 | 3. Automated workflows 54 | 55 | ### Troubleshooting 56 | 57 | If you encounter any issues, try these steps: 58 | 59 | 1. Restart the application. 60 | 2. Check your internet connection. 61 | 3. Verify your license key. 62 | 63 | ## Next steps 64 | 65 | To get the most out of our amazing product, consider: 66 | 67 | - Joining our community forums 68 | - Attending our weekly webinars 69 | - Exploring our extensive documentation -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "doc-structure-lint", 3 | "version": "0.0.5", 4 | "description": "A tool to validate Markdown and AsciiDoc structure against a specified template", 5 | "bin": { 6 | "doc-structure-lint": "src/index.js" 7 | }, 8 | "main": "src/index.js", 9 | "scripts": { 10 | "postinstall": "node src/util/preloadModel.js", 11 | "clean": "node src/util/tempDir.js clean", 12 | "start": "node src/index.js", 13 | "test": "mocha 'src/**/*.test.js'", 14 | "prepare": "husky" 15 | }, 16 | "author": "Manny Silva", 17 | "license": "MIT", 18 | "type": "module", 19 | "dependencies": { 20 | "@apidevtools/json-schema-ref-parser": "^11.7.3", 21 | "ajv": "^8.17.1", 22 | "asciidoctor": "^3.0.4", 23 | "axios": "^1.7.9", 24 | "crypto": "^1.0.1", 25 | "node-llama-cpp": "^3.3.1", 26 | "remark": "^15.0.1", 27 | "remark-frontmatter": "^5.0.0", 28 | "uuid": "^11.0.3", 29 | "yaml": "^2.6.1", 30 | "yargs": "^17.7.2" 31 | }, 32 | "devDependencies": { 33 | "chai": "^5.1.2", 34 | "husky": "^9.1.7", 35 | "mocha": "^11.0.1", 36 | "sinon": "^19.0.2" 37 | }, 38 | "files": [ 39 | "index.js", 40 | "src/**/*.js" 41 | ], 42 | "publishConfig": { 43 | "access": "public" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/cli.test.js: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { exec } from "child_process"; 3 | import fs from "fs"; 4 | import path from "path"; 5 | import os from "os"; 6 | import { promisify } from "util"; 7 | 8 | const execAsync = promisify(exec); 9 | 10 | describe("CLI functionality", () => { 11 | let tempDir; 12 | let templateFile; 13 | let documentFile; 14 | 15 | beforeEach(() => { 16 | tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-test-')); 17 | templateFile = path.join(tempDir, 'templates.yaml'); 18 | documentFile = path.join(tempDir, 'test-document.md'); 19 | }); 20 | 21 | afterEach(() => { 22 | fs.rmSync(tempDir, { recursive: true, force: true }); 23 | }); 24 | 25 | it("should validate document successfully via npx command", async () => { 26 | const template = ` 27 | templates: 28 | test-template: 29 | sections: 30 | intro: 31 | heading: 32 | const: "Introduction" 33 | required: true`; 34 | 35 | const document = "# Introduction\nSome content here."; 36 | 37 | fs.writeFileSync(templateFile, template); 38 | fs.writeFileSync(documentFile, document); 39 | 40 | const { stdout, stderr } = await execAsync( 41 | `npx . --file-path ${documentFile} --template-path ${templateFile} --template test-template` 42 | ); 43 | 44 | expect(stdout).to.include("Validation successful"); 45 | }).timeout(20000); 46 | 47 | it("should report validation errors via npx command", async () => { 48 | const template = ` 49 | templates: 50 | test-template: 51 | sections: 52 | intro: 53 | heading: 54 | const: "Expected Heading" 55 | required: true`; 56 | 57 | const document = "# Wrong Heading\nSome content here."; 58 | 59 | fs.writeFileSync(templateFile, template); 60 | fs.writeFileSync(documentFile, document); 61 | 62 | try { 63 | await execAsync( 64 | `npx . --file-path="${documentFile}" --template-path="${templateFile}" --template="test-template"` 65 | ); 66 | expect.fail("Should have thrown an error"); 67 | } catch (error) { 68 | expect(error.stdout).to.include("Expected title"); 69 | } 70 | }).timeout(20000); 71 | 72 | it("should show help message with --help flag", async () => { 73 | const { stdout } = await execAsync("npx . --help"); 74 | 75 | expect(stdout).to.include("Options:"); 76 | expect(stdout).to.include("--file"); 77 | expect(stdout).to.include("--template-path"); 78 | expect(stdout).to.include("--template"); 79 | }).timeout(20000); 80 | 81 | it("should fail with meaningful error for missing arguments", async () => { 82 | try { 83 | await execAsync("npx ."); 84 | expect.fail("Should have thrown an error"); 85 | } catch (error) { 86 | expect(error.stderr).to.include("Options"); 87 | } 88 | }).timeout(20000); 89 | 90 | it("should handle invalid template path gracefully", async () => { 91 | try { 92 | await execAsync( 93 | `npx . --file-path="${documentFile}" --template-path="non-existent.yaml" --template="test-template"` 94 | ); 95 | expect.fail("Should have thrown an error"); 96 | } catch (error) { 97 | expect(error.stderr).to.include("Error reading template file"); 98 | expect(error.code).to.equal(1); 99 | } 100 | }).timeout(20000); 101 | 102 | it("should handle malformed template file", async () => { 103 | fs.writeFileSync(templateFile, "invalid: yaml: content:"); 104 | try { 105 | await execAsync( 106 | `npx . --file-path="${documentFile}" --template-path="${templateFile}" --template="test-template"` 107 | ); 108 | expect.fail("Should have thrown an error"); 109 | } catch (error) { 110 | expect(error.stderr).to.include("Failed to load and validate templates"); 111 | expect(error.code).to.equal(1); 112 | } 113 | }).timeout(20000); 114 | 115 | // it("should validate document successfully via URL", async () => { 116 | // const template = ` 117 | // templates: 118 | // test-template: 119 | // sections: 120 | // intro: 121 | // heading: 122 | // const: "Introduction" 123 | // required: true`; 124 | 125 | // const document = "# Introduction\nSome content here."; 126 | 127 | // fs.writeFileSync(templateFile, template); 128 | // fs.writeFileSync(documentFile, document); 129 | 130 | // const templateUrl = `file://${templateFile}`; 131 | // const documentUrl = `file://${documentFile}`; 132 | 133 | // const { stdout, stderr } = await execAsync( 134 | // `npx . --file-path ${documentUrl} --template-path ${templateUrl} --template test-template` 135 | // ); 136 | 137 | // expect(stdout).to.include("Validation successful"); 138 | // }).timeout(20000); 139 | 140 | // it("should report validation errors via URL", async () => { 141 | // const template = ` 142 | // templates: 143 | // test-template: 144 | // sections: 145 | // intro: 146 | // heading: 147 | // const: "Expected Heading" 148 | // required: true`; 149 | 150 | // const document = "# Wrong Heading\nSome content here."; 151 | 152 | // fs.writeFileSync(templateFile, template); 153 | // fs.writeFileSync(documentFile, document); 154 | 155 | // const templateUrl = `file://${templateFile}`; 156 | // const documentUrl = `file://${documentFile}`; 157 | 158 | // try { 159 | // await execAsync( 160 | // `npx . --file-path="${documentUrl}" --template-path="${templateUrl}" --template="test-template"` 161 | // ); 162 | // expect.fail("Should have thrown an error"); 163 | // } catch (error) { 164 | // expect(error.stdout).to.include("Expected title"); 165 | // } 166 | // }).timeout(20000); 167 | 168 | // it("should handle invalid template URL gracefully", async () => { 169 | // const document = "# Introduction\nSome content here."; 170 | // fs.writeFileSync(documentFile, document); 171 | 172 | // const invalidTemplateUrl = "http://invalid-url.com/non-existent.yaml"; 173 | // const documentUrl = `file://${documentFile}`; 174 | 175 | // try { 176 | // await execAsync( 177 | // `npx . --file-path="${documentUrl}" --template-path="${invalidTemplateUrl}" --template="test-template"` 178 | // ); 179 | // expect.fail("Should have thrown an error"); 180 | // } catch (error) { 181 | // expect(error.stdout).to.include("Failed to fetch template file"); 182 | // expect(error.code).to.equal(1); 183 | // } 184 | // }).timeout(20000); 185 | 186 | // it("should handle invalid document URL gracefully", async () => { 187 | // const template = ` 188 | // templates: 189 | // test-template: 190 | // sections: 191 | // intro: 192 | // heading: 193 | // const: "Introduction" 194 | // required: true`; 195 | 196 | // fs.writeFileSync(templateFile, template); 197 | 198 | // const templateUrl = `file://${templateFile}`; 199 | // const invalidDocumentUrl = "http://invalid-url.com/non-existent.md"; 200 | 201 | // try { 202 | // await execAsync( 203 | // `npx . --file-path="${invalidDocumentUrl}" --template-path="${templateUrl}" --template="test-template"` 204 | // ); 205 | // expect.fail("Should have thrown an error"); 206 | // } catch (error) { 207 | // expect(error.stdout).to.include("Failed to fetch file"); 208 | // expect(error.code).to.equal(1); 209 | // } 210 | // }).timeout(20000); 211 | 212 | it("should validate all markdown files in a directory", async () => { 213 | const template = ` 214 | templates: 215 | test-template: 216 | sections: 217 | intro: 218 | heading: 219 | const: "Introduction" 220 | required: true`; 221 | 222 | const document1 = "# Introduction\nContent 1"; 223 | const document2 = "# Introduction\nContent 2"; 224 | 225 | fs.writeFileSync(templateFile, template); 226 | fs.writeFileSync(path.join(tempDir, 'doc1.md'), document1); 227 | fs.writeFileSync(path.join(tempDir, 'doc2.md'), document2); 228 | 229 | const { stdout } = await execAsync( 230 | `npx . --file-path ${tempDir} --template-path ${templateFile} --template test-template` 231 | ); 232 | 233 | expect(stdout).to.include("Validation successful"); 234 | expect(stdout).to.include("doc1.md"); 235 | expect(stdout).to.include("doc2.md"); 236 | }).timeout(20000); 237 | 238 | it("should report errors for invalid files in directory", async () => { 239 | const template = ` 240 | templates: 241 | test-template: 242 | sections: 243 | intro: 244 | heading: 245 | const: "Introduction" 246 | required: true`; 247 | 248 | const validDoc = "# Introduction\nValid content"; 249 | const invalidDoc = "# Wrong Heading\nInvalid content"; 250 | 251 | fs.writeFileSync(templateFile, template); 252 | fs.writeFileSync(path.join(tempDir, 'valid.md'), validDoc); 253 | fs.writeFileSync(path.join(tempDir, 'invalid.md'), invalidDoc); 254 | 255 | try { 256 | await execAsync( 257 | `npx . --file-path ${tempDir} --template-path ${templateFile} --template test-template` 258 | ); 259 | expect.fail("Should have thrown an error"); 260 | } catch (error) { 261 | expect(error.stdout).to.include("Validation successful"); 262 | expect(error.stdout).to.include("Expected title"); 263 | } 264 | }).timeout(20000); 265 | 266 | it("should ignore non-markdown files in directory", async () => { 267 | const template = ` 268 | templates: 269 | test-template: 270 | sections: 271 | intro: 272 | heading: 273 | const: "Introduction" 274 | required: true`; 275 | 276 | const markdownDoc = "# Introduction\nValid content"; 277 | 278 | fs.writeFileSync(templateFile, template); 279 | fs.writeFileSync(path.join(tempDir, 'document.md'), markdownDoc); 280 | fs.writeFileSync(path.join(tempDir, 'ignore.txt'), 'Some text'); 281 | fs.writeFileSync(path.join(tempDir, 'ignore.json'), '{}'); 282 | 283 | const { stdout } = await execAsync( 284 | `npx . --file-path ${tempDir} --template-path ${templateFile} --template test-template` 285 | ); 286 | expect(stdout).to.include("document.md"); 287 | expect(stdout).to.include("Validation successful"); 288 | expect(stdout).to.not.include("ignore.txt"); 289 | expect(stdout).to.not.include("ignore.json"); 290 | }).timeout(20000); 291 | 292 | it("should handle empty directory gracefully", async () => { 293 | const template = ` 294 | templates: 295 | test-template: 296 | sections: 297 | intro: 298 | heading: 299 | const: "Introduction" 300 | required: true`; 301 | 302 | fs.writeFileSync(templateFile, template); 303 | const emptyDir = path.join(tempDir, 'empty'); 304 | fs.mkdirSync(emptyDir); 305 | 306 | try { 307 | await execAsync( 308 | `npx . --file-path ${emptyDir} --template-path ${templateFile} --template test-template` 309 | ); 310 | expect.fail("Should have thrown an error"); 311 | } catch (error) { 312 | expect(error.stdout).to.include("No supported files found"); 313 | expect(error.code).to.equal(1); 314 | } 315 | }).timeout(20000); 316 | }); 317 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { readFileSync, statSync, readdirSync } from "fs"; 4 | import path from "path"; 5 | import yargs from "yargs/yargs"; 6 | import { hideBin } from "yargs/helpers"; 7 | import { loadAndValidateTemplates } from "./templateLoader.js"; 8 | import { parseMarkdown } from "./parsers/markdown.js"; 9 | import { parseAsciiDoc } from "./parsers/asciidoc.js"; 10 | import { validateStructure } from "./rules/structureValidator.js"; 11 | import { getFile } from "./util/getFile.js"; 12 | 13 | const inferFileType = (filePath, content) => { 14 | const extension = path.extname(filePath).toLowerCase(); 15 | if ([".md", ".markdown"].includes(extension)) { 16 | return "markdown"; 17 | // } else if ([".adoc", ".asciidoc"].includes(extension)) { 18 | // return "asciidoc"; 19 | } 20 | 21 | // If extension is not conclusive, check content 22 | // if (content.trim().startsWith("= ")) { 23 | // return "asciidoc"; 24 | // } 25 | 26 | // Default to markdown if unable to determine 27 | return "markdown"; 28 | }; 29 | 30 | const isDirectory = (path) => { 31 | try { 32 | return statSync(path).isDirectory(); 33 | } catch (error) { 34 | return false; 35 | } 36 | }; 37 | 38 | const getSupportedFiles = (dirPath) => { 39 | const files = []; 40 | const items = readdirSync(dirPath); 41 | 42 | for (const item of items) { 43 | const fullPath = path.join(dirPath, item); 44 | if (isDirectory(fullPath)) { 45 | files.push(...getSupportedFiles(fullPath)); 46 | } else { 47 | const extension = path.extname(fullPath).toLowerCase(); 48 | if ([".md", ".markdown"].includes(extension)) { 49 | files.push(fullPath); 50 | } 51 | } 52 | } 53 | 54 | return files; 55 | }; 56 | 57 | /** 58 | * Lints a document against a specified template. 59 | * 60 | * @param {Object} params - The parameters for the linting function. 61 | * @param {string} params.file - The path to the file to be linted. 62 | * @param {string} params.templatePath - The path to the directory containing templates. 63 | * @param {string} params.template - The name of the template to use for linting. 64 | * @returns {Promise} The result of the linting process. 65 | * @returns {boolean} returns.success - Indicates if the linting was successful. 66 | * @returns {Array} returns.errors - An array of errors found during linting. 67 | * @throws {Error} If the file type is unsupported or the template is not found. 68 | */ 69 | export async function lintDocument({ file, templatePath, template }) { 70 | let templates; 71 | try { 72 | templates = await loadAndValidateTemplates(templatePath); 73 | } catch (error) { 74 | throw new Error(`Failed to load and validate templates: ${error.message}`); 75 | } 76 | let fetchedFile; 77 | try { 78 | fetchedFile = await getFile(file); 79 | } catch (error) { 80 | throw new Error(`Failed to read file: ${error.message}`); 81 | } 82 | const fileType = inferFileType(file, fetchedFile.content); 83 | 84 | let structure; 85 | if (fileType === "markdown") { 86 | structure = parseMarkdown(fetchedFile.content); 87 | // } else if (fileType === "asciidoc") { 88 | // structure = parseAsciiDoc(fetchedFile); 89 | } else { 90 | throw new Error(`Unsupported file type: ${fileType}`); 91 | } 92 | 93 | const templateConfig = templates[template]; 94 | if (!templateConfig) { 95 | throw new Error(`Template "${template}" not found`); 96 | } 97 | 98 | const errors = await validateStructure(structure, templateConfig); 99 | return { 100 | success: errors.length === 0, 101 | errors: errors, 102 | }; 103 | } 104 | 105 | async function main() { 106 | // Parse command-line arguments 107 | const argv = yargs(hideBin(process.argv)) 108 | .option("file-path", { 109 | alias: "f", 110 | description: "Path to the file (or directory of files) to lint", 111 | type: "string", 112 | demandOption: true, 113 | }) 114 | .option("template-path", { 115 | alias: "p", 116 | description: "Path to the file containing the templates", 117 | type: "string", 118 | default: "./templates.yaml", 119 | }) 120 | .option("template", { 121 | alias: "t", 122 | description: "Name of the template to use", 123 | type: "string", 124 | demandOption: true, 125 | }) 126 | .option("json", { 127 | description: "Output results in JSON format", 128 | type: "boolean", 129 | default: false, 130 | }) 131 | .help() 132 | .alias("help", "h").argv; 133 | 134 | try { 135 | const filePath = argv.filePath; 136 | let results = []; 137 | 138 | let files = []; 139 | if (isDirectory(filePath)) { 140 | files.push(...getSupportedFiles(filePath)); 141 | } else { 142 | files.push(filePath); 143 | } 144 | 145 | if (files.length === 0) { 146 | console.log("No supported files found."); 147 | process.exit(1); 148 | } 149 | 150 | for (const file of files) { 151 | const result = await lintDocument({ 152 | file, 153 | templatePath: argv.templatePath, 154 | template: argv.template, 155 | }); 156 | results.push({ file, ...result }); 157 | } 158 | 159 | if (argv.json) { 160 | console.log(JSON.stringify(results, null, 2)); 161 | } else { 162 | let hasErrors = false; 163 | results.forEach(({ file, errors }) => { 164 | console.log(file); 165 | if (errors.length > 0) { 166 | hasErrors = true; 167 | errors.forEach((error) => 168 | console.log( 169 | `- [${error.type}] ${error.heading} (start: ${error.position.start.offset}, end: ${error.position.end.offset}): ${error.message}` 170 | ) 171 | ); 172 | process.exitCode = 1; 173 | } else { 174 | console.log(" Validation successful! 🎉"); 175 | } 176 | }); 177 | 178 | } 179 | } catch (error) { 180 | console.error(error.message); 181 | process.exit(1); 182 | } 183 | } 184 | 185 | // Only run main() if this file is being executed directly 186 | if ( 187 | process.argv[1].endsWith("doc-structure-lint") || 188 | process.argv[1].endsWith("doc-structure-lint/src/index.js") || 189 | process.argv[1].endsWith("doc-structure-lint\\src\\index.js") 190 | ) { 191 | main(); 192 | } 193 | -------------------------------------------------------------------------------- /src/index.test.js: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { lintDocument } from "./index.js"; 3 | import fs from "fs"; 4 | import path from "path"; 5 | import os from "os"; 6 | 7 | describe("lintDocument", () => { 8 | let tempDir; 9 | let templateFile; 10 | let documentFile; 11 | 12 | beforeEach(() => { 13 | // Create temporary directory and files for each test 14 | tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lint-test-')); 15 | templateFile = path.join(tempDir, 'templates.yaml'); 16 | documentFile = path.join(tempDir, 'test-document.md'); 17 | }); 18 | 19 | afterEach(() => { 20 | // Cleanup temporary files 21 | fs.rmSync(tempDir, { recursive: true, force: true }); 22 | }); 23 | 24 | it("should lint a markdown file successfully", async () => { 25 | const validTemplate = ` 26 | templates: 27 | test-template: 28 | sections: 29 | section1: 30 | heading: 31 | const: "Test Section" 32 | required: true`; 33 | 34 | const validDocument = "# Test Section\nSome content here."; 35 | 36 | fs.writeFileSync(templateFile, validTemplate); 37 | fs.writeFileSync(documentFile, validDocument); 38 | 39 | const result = await lintDocument({ 40 | file: documentFile, 41 | templatePath: templateFile, 42 | template: "test-template" 43 | }); 44 | 45 | expect(result.success).to.be.true; 46 | expect(result.errors).to.be.empty; 47 | }); 48 | 49 | it("should detect heading mismatch", async () => { 50 | const template = ` 51 | templates: 52 | test-template: 53 | sections: 54 | section1: 55 | heading: 56 | const: "Expected Heading" 57 | required: true`; 58 | 59 | const document = "# Wrong Heading\nSome content here."; 60 | 61 | fs.writeFileSync(templateFile, template); 62 | fs.writeFileSync(documentFile, document); 63 | 64 | const result = await lintDocument({ 65 | file: documentFile, 66 | templatePath: templateFile, 67 | template: "test-template" 68 | }); 69 | 70 | expect(result.success).to.be.false; 71 | expect(result.errors).to.have.lengthOf(1); 72 | expect(result.errors[0].message).to.include('Expected title "Expected Heading"'); 73 | }); 74 | 75 | it("should throw error for non-existent template", async () => { 76 | const template = `templates: {}`; 77 | const document = "# Test\nContent"; 78 | 79 | fs.writeFileSync(templateFile, template); 80 | fs.writeFileSync(documentFile, document); 81 | 82 | try { 83 | await lintDocument({ 84 | file: documentFile, 85 | templatePath: templateFile, 86 | template: "non-existent-template" 87 | }); 88 | expect.fail('Should have thrown an error'); 89 | } catch (error) { 90 | expect(error.message).to.include('Template "non-existent-template" not found'); 91 | } 92 | }); 93 | 94 | it("should validate complex document structure", async () => { 95 | const template = ` 96 | templates: 97 | test-template: 98 | sections: 99 | intro: 100 | heading: 101 | const: "Introduction" 102 | required: true 103 | sections: 104 | details: 105 | heading: 106 | pattern: "^Details: .*$" 107 | required: true`; 108 | 109 | const document = `# Introduction 110 | Some intro content. 111 | ## Details: Section 1 112 | Detail content here.`; 113 | 114 | fs.writeFileSync(templateFile, template); 115 | fs.writeFileSync(documentFile, document); 116 | 117 | const result = await lintDocument({ 118 | file: documentFile, 119 | templatePath: templateFile, 120 | template: "test-template" 121 | }); 122 | 123 | expect(result.success).to.be.true; 124 | expect(result.errors).to.be.empty; 125 | }); 126 | 127 | // it("should lint a markdown file from URL successfully", async () => { 128 | // const validTemplate = ` 129 | // templates: 130 | // test-template: 131 | // sections: 132 | // section1: 133 | // heading: 134 | // const: "Test Section" 135 | // required: true`; 136 | 137 | // const validDocument = "# Test Section\nSome content here."; 138 | 139 | // fs.writeFileSync(templateFile, validTemplate); 140 | // fs.writeFileSync(documentFile, validDocument); 141 | 142 | // const templateUrl = `file://${templateFile}`; 143 | // const documentUrl = `file://${documentFile}`; 144 | 145 | // const result = await lintDocument({ 146 | // file: documentUrl, 147 | // templatePath: templateUrl, 148 | // template: "test-template" 149 | // }); 150 | 151 | // expect(result.success).to.be.true; 152 | // expect(result.errors).to.be.empty; 153 | // }); 154 | 155 | // it("should detect heading mismatch from URL", async () => { 156 | // const template = ` 157 | // templates: 158 | // test-template: 159 | // sections: 160 | // section1: 161 | // heading: 162 | // const: "Expected Heading" 163 | // required: true`; 164 | 165 | // const document = "# Wrong Heading\nSome content here."; 166 | 167 | // fs.writeFileSync(templateFile, template); 168 | // fs.writeFileSync(documentFile, document); 169 | 170 | // const templateUrl = `file://${templateFile}`; 171 | // const documentUrl = `file://${documentFile}`; 172 | 173 | // const result = await lintDocument({ 174 | // file: documentUrl, 175 | // templatePath: templateUrl, 176 | // template: "test-template" 177 | // }); 178 | 179 | // expect(result.success).to.be.false; 180 | // expect(result.errors).to.have.lengthOf(1); 181 | // expect(result.errors[0].message).to.include('Expected title "Expected Heading"'); 182 | // }); 183 | 184 | // it("should throw error for non-existent template from URL", async () => { 185 | // const template = `templates: {}`; 186 | // const document = "# Test\nContent"; 187 | 188 | // fs.writeFileSync(templateFile, template); 189 | // fs.writeFileSync(documentFile, document); 190 | 191 | // const templateUrl = `file://${templateFile}`; 192 | // const documentUrl = `file://${documentFile}`; 193 | 194 | // try { 195 | // await lintDocument({ 196 | // file: documentUrl, 197 | // templatePath: templateUrl, 198 | // template: "non-existent-template" 199 | // }); 200 | // expect.fail('Should have thrown an error'); 201 | // } catch (error) { 202 | // expect(error.message).to.include('Template "non-existent-template" not found'); 203 | // } 204 | // }); 205 | 206 | // it("should handle invalid template URL gracefully", async () => { 207 | // const document = "# Introduction\nSome content here."; 208 | // fs.writeFileSync(documentFile, document); 209 | 210 | // const invalidTemplateUrl = "http://invalid-url.com/non-existent.yaml"; 211 | // const documentUrl = `file://${documentFile}`; 212 | 213 | // try { 214 | // await lintDocument({ 215 | // file: documentUrl, 216 | // templatePath: invalidTemplateUrl, 217 | // template: "test-template" 218 | // }); 219 | // expect.fail('Should have thrown an error'); 220 | // } catch (error) { 221 | // expect(error.message).to.include("Failed to fetch template file"); 222 | // } 223 | // }); 224 | 225 | // it("should handle invalid document URL gracefully", async () => { 226 | // const template = ` 227 | // templates: 228 | // test-template: 229 | // sections: 230 | // intro: 231 | // heading: 232 | // const: "Introduction" 233 | // required: true`; 234 | 235 | // fs.writeFileSync(templateFile, template); 236 | 237 | // const templateUrl = `file://${templateFile}`; 238 | // const invalidDocumentUrl = "http://invalid-url.com/non-existent.md"; 239 | 240 | // try { 241 | // await lintDocument({ 242 | // file: invalidDocumentUrl, 243 | // templatePath: templateUrl, 244 | // template: "test-template" 245 | // }); 246 | // expect.fail('Should have thrown an error'); 247 | // } catch (error) { 248 | // expect(error.message).to.include("Failed to fetch file"); 249 | // } 250 | // }); 251 | }); 252 | -------------------------------------------------------------------------------- /src/parsers/asciidoc.js: -------------------------------------------------------------------------------- 1 | import asciidoctor from 'asciidoctor'; 2 | 3 | export const parseAsciiDoc = (content) => { 4 | const processor = asciidoctor(); 5 | const doc = processor.load(content); 6 | 7 | const rootSection = { 8 | title: doc.getDocumentTitle(), 9 | startIndex: 0, 10 | endIndex: content.length, 11 | paragraphs: 0, 12 | code_blocks: 0, 13 | subsections: [] 14 | }; 15 | 16 | const processBlock = (block, currentSection, content) => { 17 | if (block.getContext() === 'section') { 18 | const sectionTitle = block.getTitle(); 19 | const sectionLevel = block.getLevel(); 20 | const sectionMarker = '='.repeat(sectionLevel) + ' ' + sectionTitle; 21 | const startIndex = content.indexOf(sectionMarker, currentSection.startIndex); 22 | 23 | const newSection = { 24 | title: sectionTitle, 25 | startIndex: startIndex, 26 | endIndex: content.length, // Initially set to end of content, will be updated later 27 | paragraphs: 0, 28 | code_blocks: 0, 29 | subsections: [] 30 | }; 31 | 32 | currentSection.subsections.push(newSection); 33 | 34 | block.getBlocks().forEach(childBlock => processBlock(childBlock, newSection, content)); 35 | 36 | // Update the endIndex of the new section 37 | if (newSection.subsections.length > 0) { 38 | newSection.endIndex = newSection.subsections[newSection.subsections.length - 1].endIndex; 39 | } else { 40 | const nextSectionIndex = content.indexOf('=', newSection.startIndex + sectionMarker.length); 41 | newSection.endIndex = nextSectionIndex !== -1 ? nextSectionIndex : content.length; 42 | } 43 | } else if (block.getContext() === 'paragraph') { 44 | currentSection.paragraphs++; 45 | } else if (block.getContext() === 'listing' && block.getStyle() === 'source') { 46 | currentSection.code_blocks++; 47 | } 48 | }; 49 | 50 | doc.getBlocks().forEach(block => processBlock(block, rootSection, content)); 51 | 52 | // Count paragraphs and code blocks in the root section 53 | const countRootBlocks = (section) => { 54 | section.subsections.forEach(subsection => { 55 | section.paragraphs += subsection.paragraphs; 56 | section.code_blocks += subsection.code_blocks; 57 | countRootBlocks(subsection); 58 | }); 59 | }; 60 | 61 | countRootBlocks(rootSection); 62 | 63 | return rootSection; 64 | }; 65 | -------------------------------------------------------------------------------- /src/parsers/markdown.js: -------------------------------------------------------------------------------- 1 | import { remark } from "remark"; 2 | import remarkFrontmatter from "remark-frontmatter"; 3 | import { v4 as uuid } from "uuid"; 4 | 5 | export function parseMarkdown(content) { 6 | const tree = remark().use(remarkFrontmatter).parse(content); 7 | let currentSection = null; 8 | 9 | const result = { 10 | frontmatter: [], 11 | sections: [], 12 | }; 13 | 14 | const updateParentPositions = (section, endPosition) => { 15 | if (!section || !endPosition) return; 16 | 17 | // Ensure position object exists 18 | if (!section.position) { 19 | section.position = { 20 | start: { line: 0, column: 0, offset: 0 }, 21 | end: { line: 0, column: 0, offset: 0 }, 22 | }; 23 | } 24 | 25 | // Update section's end position if the new end position is greater 26 | if (section.position.end.offset < endPosition.offset) { 27 | section.position.end = { ...endPosition }; 28 | } 29 | 30 | // Find and update parent's position 31 | const parent = findParent(result, section.id); 32 | if (parent && parent.position) { 33 | updateParentPositions(parent, endPosition); 34 | } 35 | }; 36 | 37 | /** 38 | * Adds a node to a section's content sequence based on the specified type. 39 | * If the last element in the section's content is not of the specified type, 40 | * a new sequence node is created and added to the content. Otherwise, the node 41 | * is appended to the existing sequence of the same type. 42 | * 43 | * @param {Object} section - The section object containing the content array. 44 | * @param {string} type - The type of the node to be added. 45 | * @param {Object} node - The node to be added to the section's content. 46 | */ 47 | const addToSequence = (section, type, node) => { 48 | if ( 49 | section.content.length > 0 && 50 | Object.hasOwn(section.content[section.content.length - 1], type) 51 | ) { 52 | section.content[section.content.length - 1][type].push(node); 53 | section.content[section.content.length - 1].position.end = 54 | node.position.end; 55 | } else { 56 | const sequenceNode = { 57 | heading: section.heading, 58 | position: node.position, 59 | }; 60 | sequenceNode[type] = [node]; 61 | section.content.push(sequenceNode); 62 | } 63 | }; 64 | 65 | const processSection = (node) => { 66 | const newSection = { 67 | id: uuid(), 68 | position: node.position, 69 | content: [], 70 | rawContent: getNodeRawContent(node), 71 | heading: { 72 | level: node.depth, 73 | position: node.position, 74 | content: node.children.map((child) => child.value).join(""), 75 | }, 76 | paragraphs: [], 77 | codeBlocks: [], 78 | lists: [], 79 | sections: [], 80 | }; 81 | return newSection; 82 | }; 83 | 84 | const processParagraph = (node) => { 85 | const result = { 86 | content: node.children.map((child) => child.value).join(""), 87 | position: node.position, 88 | }; 89 | return result; 90 | }; 91 | 92 | const processCodeBlock = (node) => { 93 | const result = { 94 | content: `\`\`\`${node.lang}\n${node.value}\`\`\``, 95 | position: node.position, 96 | }; 97 | return result; 98 | }; 99 | 100 | const processList = (node) => { 101 | const result = { 102 | ordered: node.ordered, 103 | items: node.children.map((item) => { 104 | if (item.type === "listItem") { 105 | return { 106 | position: item.position, 107 | content: item.children.map((child) => { 108 | switch (child.type) { 109 | case "paragraph": 110 | return processParagraph(child); 111 | case "code": 112 | return processCodeBlock(child); 113 | case "list": 114 | return processList(child); 115 | default: 116 | return { 117 | position: child.position, 118 | content: child.value || "", 119 | }; 120 | } 121 | }), 122 | }; 123 | } 124 | }), 125 | position: node.position, 126 | }; 127 | return result; 128 | }; 129 | 130 | const createDefaultSection = (node) => { 131 | return { 132 | id: uuid(), 133 | position: node.position, 134 | content: [], 135 | rawContent: getNodeRawContent(node), 136 | heading: { 137 | level: 0, 138 | position: null, 139 | content: null, 140 | }, 141 | paragraphs: [], 142 | codeBlocks: [], 143 | lists: [], 144 | sections: [], 145 | }; 146 | }; 147 | 148 | const getNodeRawContent = (node) => { 149 | if (!node || !node.type) return ''; 150 | switch (node.type) { 151 | case 'heading': 152 | return `${'#'.repeat(node.depth)} ${node.children?.map(child => child.value || '').join('') || ''}\n`; 153 | case 'paragraph': 154 | return `${node.children?.map(child => child.value || '').join('') || ''}\n`; 155 | case 'code': 156 | return `\`\`\`${node.lang || ''}\n${node.value || ''}\n\`\`\`\n`; 157 | case 'list': 158 | return node.children.map((item, index) => { 159 | const prefix = node.ordered ? `${index + 1}. ` : '- '; 160 | return prefix + item.children.map(child => { 161 | switch(child.type) { 162 | case 'paragraph': 163 | return child.children?.map(c => c.value || '').join('') || ''; 164 | case 'code': 165 | return getNodeRawContent(child); 166 | case 'list': 167 | return getNodeRawContent(child); 168 | default: 169 | return child.value || ''; 170 | } 171 | }).join('\n'); 172 | }).join('\n') + '\n'; 173 | default: 174 | return ''; 175 | } 176 | }; 177 | 178 | const processNode = (node, parentSection) => { 179 | if (!currentSection && node.type !== "yaml" && node.type !== "heading" && node.type !== "root") { 180 | currentSection = createDefaultSection(node); 181 | result.sections.push(currentSection); 182 | } 183 | 184 | if (node.type === "yaml") { 185 | const items = node.value 186 | .trim() 187 | .split("\n") 188 | .map((item) => { 189 | const parts = item.split(":"); 190 | return { 191 | key: parts[0].trim(), 192 | value: parts.slice(1).join(":").trim(), 193 | }; 194 | }); 195 | result.frontmatter = items; 196 | } else if (node.type === "heading") { 197 | // Ensure result has a position object before creating new section 198 | const newSection = processSection(node); 199 | 200 | // Update parent section's end position 201 | if (parentSection) { 202 | updateParentPositions(parentSection, node.position.end); 203 | } 204 | 205 | if (node.depth === 1) { 206 | result.sections.push(newSection); 207 | } else if (currentSection && node.depth > currentSection.heading.level) { 208 | currentSection.sections.push(newSection); 209 | } else if (currentSection && node.depth <= currentSection.heading.level) { 210 | let parent = findParent(result, currentSection.id); 211 | while (parent && node.depth <= parent.heading.level) { 212 | parent = findParent(result, parent.id); 213 | } 214 | if (parent) { 215 | parent.sections.push(newSection); 216 | } 217 | } 218 | 219 | currentSection = newSection; 220 | } else if (node.type === "paragraph") { 221 | const paragraph = processParagraph(node); 222 | addToSequence(currentSection, "paragraphs", paragraph); 223 | currentSection.paragraphs.push(paragraph); 224 | currentSection.rawContent += '\n' + getNodeRawContent(node); 225 | updateParentPositions(parentSection, node.position.end); 226 | } else if (node.type === "code") { 227 | const codeBlock = processCodeBlock(node); 228 | addToSequence(currentSection, "code_blocks", codeBlock); 229 | currentSection.codeBlocks.push(codeBlock); 230 | currentSection.rawContent += '\n' + getNodeRawContent(node); 231 | updateParentPositions(parentSection, node.position.end); 232 | } else if (node.type === "list") { 233 | const list = processList(node); 234 | addToSequence(currentSection, "lists", list); 235 | currentSection.lists.push(list); 236 | currentSection.rawContent += '\n' + getNodeRawContent(node); 237 | updateParentPositions(parentSection, node.position.end); 238 | } 239 | 240 | 241 | if (node.children && node.type !== "list") { 242 | node.children.forEach((child) => processNode(child, currentSection)); 243 | } 244 | }; 245 | 246 | processNode(tree, null); 247 | 248 | return result; 249 | } 250 | 251 | // Function to find immediate parent of an object with given ID 252 | function findParent(obj, targetId, parent = null) { 253 | // If current object has the target ID, return its parent 254 | if (obj.id === targetId) { 255 | return parent; 256 | } 257 | 258 | // If object has sections, search through them 259 | if (obj.sections && Array.isArray(obj.sections)) { 260 | for (const section of obj.sections) { 261 | const result = findParent(section, targetId, obj); 262 | if (result !== null) { 263 | return result; 264 | } 265 | } 266 | } 267 | 268 | return null; 269 | } 270 | -------------------------------------------------------------------------------- /src/rules/ValidationError.js: -------------------------------------------------------------------------------- 1 | 2 | export class ValidationError { 3 | constructor(type, heading, message, position) { 4 | this.type = type; 5 | this.heading = heading; 6 | this.message = message; 7 | this.position = position; 8 | } 9 | } -------------------------------------------------------------------------------- /src/rules/codeBlocksValidator.js: -------------------------------------------------------------------------------- 1 | import { ValidationError } from "./ValidationError.js"; 2 | 3 | /** 4 | * Validates that a section contains at least the minimum number of code blocks specified in the template. 5 | * 6 | * @param {Object} section - The section to validate. 7 | * @param {Object} section.codeBlocks - An array of code blocks in the section. 8 | * @param {Object} section.heading - The heading of the section. 9 | * @param {Object} section.heading.content - The content of the heading. 10 | * @param {Object} section.position - The position of the section in the document. 11 | * @param {Object} template - The template containing validation rules. 12 | * @param {Object} template.code_blocks - The code block validation rules. 13 | * @param {number} template.code_blocks.min - The minimum number of code blocks required. 14 | * @returns {ValidationError|null} - Returns a ValidationError if the section does not meet the minimum code block requirement, otherwise returns null. 15 | */ 16 | function checkMinCodeBlocks(section, template) { 17 | if (!template.code_blocks?.min) return null; 18 | 19 | if (section.codeBlocks.length < template.code_blocks.min) { 20 | return new ValidationError( 21 | "code_blocks_count_error", 22 | section.heading?.content, 23 | `Expected at least ${template.code_blocks.min} code blocks, but found ${section.codeBlocks.length}`, 24 | section.position 25 | ); 26 | } 27 | return null; 28 | } 29 | 30 | /** 31 | * Validates the number of code blocks in a section against the maximum allowed by the template. 32 | * 33 | * @param {Object} section - The section to validate. 34 | * @param {Array} section.codeBlocks - The code blocks in the section. 35 | * @param {Object} section.heading - The heading of the section. 36 | * @param {Object} section.position - The position of the section in the document. 37 | * @param {Object} template - The template containing validation rules. 38 | * @param {Object} template.code_blocks - The code block validation rules. 39 | * @param {number} template.code_blocks.max - The maximum number of code blocks allowed. 40 | * @returns {ValidationError|null} - Returns a ValidationError if the number of code blocks exceeds the maximum, otherwise null. 41 | */ 42 | function checkMaxCodeBlocks(section, template) { 43 | if (!template.code_blocks?.max) return null; 44 | 45 | if (section.codeBlocks.length > template.code_blocks.max) { 46 | return new ValidationError( 47 | "code_blocks_count_error", 48 | section.heading?.content, 49 | `Expected at most ${template.code_blocks.max} code blocks, but found ${section.codeBlocks.length}`, 50 | section.position 51 | ); 52 | } 53 | return null; 54 | } 55 | 56 | /** 57 | * Validates the code blocks in a given section against a template. 58 | * 59 | * @param {Object} section - The section to validate. 60 | * @param {Array} section.codeBlocks - The code blocks in the section. 61 | * @param {Object} template - The template to validate against. 62 | * @param {Array} template.code_blocks - The code blocks in the template. 63 | * @returns {Array} An array of error messages, if any. 64 | */ 65 | export function validateCodeBlocks(section, template) { 66 | const errors = []; 67 | if (!template.code_blocks || !section.codeBlocks) return errors; 68 | 69 | const minError = checkMinCodeBlocks(section, template); 70 | if (minError) errors.push(minError); 71 | 72 | const maxError = checkMaxCodeBlocks(section, template); 73 | if (maxError) errors.push(maxError); 74 | 75 | return errors; 76 | } 77 | -------------------------------------------------------------------------------- /src/rules/codeBlocksValidator.test.js: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { validateCodeBlocks } from "./codeBlocksValidator.js"; 3 | import { ValidationError } from "./ValidationError.js"; 4 | 5 | describe("validateCodeBlocks", () => { 6 | it("should return an empty array if template.code_blocks is not defined", () => { 7 | const section = { codeBlocks: [] }; 8 | const template = {}; 9 | const result = validateCodeBlocks(section, template); 10 | expect(result).to.be.an("array").that.is.empty; 11 | }); 12 | 13 | it("should return an empty array if section.codeBlocks is not defined", () => { 14 | const section = {}; 15 | const template = { code_blocks: { min: 1, max: 3 } }; 16 | const result = validateCodeBlocks(section, template); 17 | expect(result).to.be.an("array").that.is.empty; 18 | }); 19 | 20 | it("should return a ValidationError if the number of code blocks is less than the minimum required", () => { 21 | const section = { codeBlocks: [], heading: { content: "Test Heading" }, position: 1 }; 22 | const template = { code_blocks: { min: 1 } }; 23 | const result = validateCodeBlocks(section, template); 24 | expect(result).to.have.lengthOf(1); 25 | expect(result[0]).to.be.instanceOf(ValidationError); 26 | expect(result[0].message).to.equal("Expected at least 1 code blocks, but found 0"); 27 | }); 28 | 29 | it("should return a ValidationError if the number of code blocks exceeds the maximum allowed", () => { 30 | const section = { codeBlocks: [1, 2, 3, 4], heading: { content: "Test Heading" }, position: 1 }; 31 | const template = { code_blocks: { max: 3 } }; 32 | const result = validateCodeBlocks(section, template); 33 | expect(result).to.have.lengthOf(1); 34 | expect(result[0]).to.be.instanceOf(ValidationError); 35 | expect(result[0].message).to.equal("Expected at most 3 code blocks, but found 4"); 36 | }); 37 | 38 | it("should return an empty array if the number of code blocks is within the allowed range", () => { 39 | const section = { codeBlocks: [1, 2], heading: { content: "Test Heading" }, position: 1 }; 40 | const template = { code_blocks: { min: 1, max: 3 } }; 41 | const result = validateCodeBlocks(section, template); 42 | expect(result).to.be.an("array").that.is.empty; 43 | }); 44 | }); -------------------------------------------------------------------------------- /src/rules/headingValidator.js: -------------------------------------------------------------------------------- 1 | import { ValidationError } from "./ValidationError.js"; 2 | 3 | /** 4 | * Validates that the heading of a section matches a constant value specified in the template. 5 | * 6 | * @param {Object} section - The section object containing the heading to validate. 7 | * @param {Object} template - The template object containing the expected heading constant. 8 | * @param {Object} template.heading - The heading object within the template. 9 | * @param {string} template.heading.const - The constant value that the section heading should match. 10 | * @param {Object} section.heading - The heading object within the section. 11 | * @param {string} section.heading.content - The actual heading content of the section. 12 | * @param {Object} section.position - The position object indicating the location of the section. 13 | * @returns {ValidationError|null} - Returns a ValidationError if the heading does not match the constant, otherwise null. 14 | */ 15 | function checkHeadingConst(section, template) { 16 | if (!template.heading?.const) return null; 17 | 18 | if (section.heading.content !== template.heading.const) { 19 | return new ValidationError( 20 | "heading_const_error", 21 | section.heading.content, 22 | `Expected title "${template.heading.const}", but found "${section.heading.content}"`, 23 | section.position 24 | ); 25 | } 26 | return null; 27 | } 28 | 29 | /** 30 | * Validates the heading of a section against a specified pattern in the template. 31 | * 32 | * @param {Object} section - The section object containing the heading to be validated. 33 | * @param {Object} template - The template object containing the heading pattern to validate against. 34 | * @param {Object} template.heading - The heading object within the template. 35 | * @param {RegExp} template.heading.pattern - The regular expression pattern to validate the heading against. 36 | * @param {Object} section.heading - The heading object within the section. 37 | * @param {string} section.heading.content - The content of the heading to be validated. 38 | * @param {Object} section.position - The position object indicating the location of the section. 39 | * @returns {ValidationError|null} Returns a ValidationError if the heading does not match the pattern, otherwise null. 40 | */ 41 | function checkHeadingPattern(section, template) { 42 | if (!template.heading?.pattern) return null; 43 | 44 | const pattern = new RegExp(template.heading.pattern); 45 | if (!pattern.test(section.heading.content)) { 46 | return new ValidationError( 47 | "heading_pattern_error", 48 | section.heading.content, 49 | `Title "${section.heading.content}" doesn't match pattern "${template.heading.pattern}"`, 50 | section.position 51 | ); 52 | } 53 | return null; 54 | } 55 | 56 | /** 57 | * Validates the heading of a given section against a template. 58 | * 59 | * @param {Object} section - The section object containing the heading to be validated. 60 | * @param {Object} template - The template object containing the heading rules. 61 | * @returns {Array} An array of error messages, if any. 62 | */ 63 | export function validateHeading(section, template) { 64 | const errors = []; 65 | if (!template.heading) return errors; 66 | 67 | const constError = checkHeadingConst(section, template); 68 | if (constError) errors.push(constError); 69 | 70 | const patternError = checkHeadingPattern(section, template); 71 | if (patternError) errors.push(patternError); 72 | 73 | return errors; 74 | } 75 | -------------------------------------------------------------------------------- /src/rules/headingValidator.test.js: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { validateHeading } from "./headingValidator.js"; 3 | import { ValidationError } from "./ValidationError.js"; 4 | 5 | describe("validateHeading", () => { 6 | it("should return an empty array if template.heading is not defined", () => { 7 | const section = { heading: { content: "Test Heading" }, position: {} }; 8 | const template = {}; 9 | const result = validateHeading(section, template); 10 | expect(result).to.be.an("array").that.is.empty; 11 | }); 12 | 13 | it("should return a ValidationError if heading does not match the constant", () => { 14 | const section = { heading: { content: "Wrong Heading" }, position: {} }; 15 | const template = { heading: { const: "Expected Heading" } }; 16 | const result = validateHeading(section, template); 17 | expect(result).to.have.lengthOf(1); 18 | expect(result[0]).to.be.instanceOf(ValidationError); 19 | expect(result[0].message).to.equal('Expected title "Expected Heading", but found "Wrong Heading"'); 20 | }); 21 | 22 | it("should return a ValidationError if heading does not match the pattern", () => { 23 | const section = { heading: { content: "Invalid Heading" }, position: {} }; 24 | const template = { heading: { pattern: "^Valid Heading$" } }; 25 | const result = validateHeading(section, template); 26 | expect(result).to.have.lengthOf(1); 27 | expect(result[0]).to.be.instanceOf(ValidationError); 28 | expect(result[0].message).to.equal('Title "Invalid Heading" doesn\'t match pattern "^Valid Heading$"'); 29 | }); 30 | 31 | it("should return multiple ValidationErrors if heading does not match both constant and pattern", () => { 32 | const section = { heading: { content: "Wrong Heading" }, position: {} }; 33 | const template = { heading: { const: "Expected Heading", pattern: /^Valid Heading$/ } }; 34 | const result = validateHeading(section, template); 35 | expect(result).to.have.lengthOf(2); 36 | expect(result[0]).to.be.instanceOf(ValidationError); 37 | expect(result[1]).to.be.instanceOf(ValidationError); 38 | }); 39 | 40 | it("should return an empty array if heading matches both constant and pattern", () => { 41 | const section = { heading: { content: "Valid Heading" }, position: {} }; 42 | const template = { heading: { const: "Valid Heading", pattern: /^Valid Heading$/ } }; 43 | const result = validateHeading(section, template); 44 | expect(result).to.be.an("array").that.is.empty; 45 | }); 46 | }); -------------------------------------------------------------------------------- /src/rules/instructionValidator.js: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import { getLlama, LlamaChatSession, resolveModelFile } from "node-llama-cpp"; 3 | import { ValidationError } from "./ValidationError.js"; 4 | import { getTempDir, cleanTempDir } from "../util/tempDir.js"; 5 | 6 | /** 7 | * Prepares and loads a Llama model. 8 | * 9 | * This function creates a temporary directory for the model, cleans up any 10 | * unexpected files in the directory, resolves the model file by downloading 11 | * it if necessary, and then loads the model. 12 | * 13 | * @param {Object} llama - The Llama instance used to load the model. 14 | * @returns {Promise} - A promise that resolves to the loaded model. 15 | */ 16 | export async function prepareModel(llama) { 17 | try { 18 | // Create a temporary directory for the model 19 | const dir = getTempDir(); 20 | 21 | // Clean up any files in the directory that aren't expected 22 | const expectedFiles = ["Llama-3.2-3B-Instruct-Q4_K_M.gguf"]; 23 | cleanTempDir(expectedFiles); 24 | 25 | // Resolve the model path, downloading if necessary 26 | await resolveModelFile( 27 | "hf:bartowski/Llama-3.2-3B-Instruct-GGUF/Llama-3.2-3B-Instruct-Q4_K_M.gguf", 28 | { directory: dir, fileName: "Llama-3.2-3B-Instruct-Q4_K_M.gguf" } 29 | ); 30 | 31 | const model = await llama.loadModel({ 32 | modelPath: path.join(dir, "Llama-3.2-3B-Instruct-Q4_K_M.gguf"), 33 | }); 34 | 35 | return model; 36 | } catch (error) { 37 | console.error("Error preparing the model:", error); 38 | throw error; 39 | } 40 | } 41 | 42 | /** 43 | * Prepares a grammar for JSON schema validation. 44 | * 45 | * @param {Object} llama - The object that provides the createGrammarForJsonSchema method. 46 | * @returns {Promise} A promise that resolves to the created grammar object. 47 | */ 48 | async function prepareGrammar(llama) { 49 | return await llama.createGrammarForJsonSchema({ 50 | type: "object", 51 | required: ["assessment"], 52 | properties: { 53 | assessment: { 54 | description: "The result of the evaluation", 55 | type: "string", 56 | enum: ["pass", "fail"], 57 | }, 58 | explanation: { 59 | description: "Suggestions for improvement, if any.", 60 | type: "string", 61 | maxLength: 500, 62 | }, 63 | }, 64 | }); 65 | } 66 | 67 | /** 68 | * Validates the given instruction against the provided content using the specified model and grammar. 69 | * 70 | * @param {Object} model - The model used to create a context for validation. 71 | * @param {Object} grammar - The grammar rules used for parsing the response. 72 | * @param {string} instruction - The instruction to be validated. 73 | * @param {string} content - The content to be evaluated against the instruction. 74 | * @returns {Promise} - Returns the parsed response if the assessment fails, otherwise returns null. 75 | */ 76 | async function validateInstruction(model, grammar, instruction, content) { 77 | const context = await model.createContext(); 78 | const session = new LlamaChatSession({ 79 | contextSequence: context.getSequence(), 80 | systemPrompt: 81 | "You are a technical editor who evaluates instructions against sections of a document. Evaluate if the supplied content follows the specified instruction. Output your results as JSON.", 82 | }); 83 | const input = { instruction, content }; 84 | const response = await session.prompt(JSON.stringify(input), { grammar }); 85 | const parsedResponse = grammar.parse(response); 86 | await context.dispose(); 87 | await session.dispose(); 88 | if (parsedResponse.assessment === "fail") return parsedResponse; 89 | return null; 90 | } 91 | 92 | /** 93 | * Validates the instructions in a given section against a template. 94 | * 95 | * @param {Object} section - The section containing the raw content and heading. 96 | * @param {Object} template - The template containing the instructions to validate against. 97 | * @returns {Promise} A promise that resolves to an array of validation errors. 98 | */ 99 | export async function validateInstructions(section, template) { 100 | const errors = []; 101 | if (!template.instructions) return errors; 102 | 103 | const llama = await getLlama(); // TODO: Figure out how to silence the terminal output, then move to top of file 104 | const model = await prepareModel(llama); 105 | const grammar = await prepareGrammar(llama); 106 | 107 | for (const index in template.instructions) { 108 | const instruction = template.instructions[index]; 109 | const error = await validateInstruction( 110 | model, 111 | grammar, 112 | instruction, 113 | section.rawContent 114 | ); 115 | if (error) { 116 | errors.push( 117 | new ValidationError( 118 | "instruction_error", 119 | section.heading.content, 120 | `Instruction: ${instruction}${ 121 | !instruction.endsWith(".") ? "." : "" 122 | } Explanation: ${error.explanation}`, 123 | section.position 124 | ) 125 | ); 126 | } 127 | } 128 | llama.dispose(); // Free up resources 129 | return errors; 130 | } 131 | -------------------------------------------------------------------------------- /src/rules/instructionValidator.test.js: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { validateInstructions } from "./instructionValidator.js"; 3 | import { ValidationError } from "./ValidationError.js"; 4 | 5 | describe("validateInstructions", () => { 6 | it("should return an empty array if there are no instructions", async () => { 7 | const section = { 8 | rawContent: "Test content", 9 | heading: { content: "Test heading" }, 10 | position: { start: { line: 1, column: 1 } } 11 | }; 12 | const template = {}; 13 | 14 | const result = await validateInstructions(section, template); 15 | expect(result).to.be.an("array").that.is.empty; 16 | }).timeout(20000); 17 | 18 | it("should return validation errors if instructions fail", async () => { 19 | const section = { 20 | rawContent: "Invalid content that will fail validation", 21 | heading: { content: "Test heading" }, 22 | position: { start: { line: 1, column: 1 } } 23 | }; 24 | const template = { 25 | instructions: ["Content must mention all three primary colors"] 26 | }; 27 | 28 | const result = await validateInstructions(section, template); 29 | expect(result).to.be.an("array").that.is.not.empty; 30 | expect(result[0]).to.be.instanceOf(ValidationError); 31 | }).timeout(20000); 32 | 33 | it("should return an empty array if all instructions pass", async () => { 34 | const section = { 35 | rawContent: "Valid content that meets all requirements", 36 | heading: { content: "Test heading" }, 37 | position: { start: { line: 1, column: 1 } } 38 | }; 39 | const template = { 40 | instructions: ["Content must not be empty"] 41 | }; 42 | 43 | const result = await validateInstructions(section, template); 44 | expect(result).to.be.an("array").that.is.empty; 45 | }).timeout(20000); 46 | }); -------------------------------------------------------------------------------- /src/rules/listValidator.js: -------------------------------------------------------------------------------- 1 | import { ValidationError } from "./ValidationError.js"; 2 | 3 | /** 4 | * Checks if the number of lists in a section meets the minimum required by the template. 5 | * 6 | * @param {Object} section - The section to validate. 7 | * @param {Array} section.lists - The lists in the section. 8 | * @param {Object} section.heading - The heading of the section. 9 | * @param {string} section.heading.content - The content of the heading. 10 | * @param {Object} section.position - The position of the section in the document. 11 | * @param {Object} template - The template to validate against. 12 | * @param {Object} template.lists - The lists configuration in the template. 13 | * @param {number} template.lists.min - The minimum number of lists required. 14 | * @returns {ValidationError|null} - Returns a ValidationError if the section does not meet the minimum list requirement, otherwise null. 15 | */ 16 | function checkMinLists(section, template) { 17 | if (!template.lists?.min) return null; 18 | 19 | if (section.lists.length < template.lists.min) { 20 | return new ValidationError( 21 | "lists_count_error", 22 | section.heading?.content, 23 | `Expected at least ${template.lists.min} lists, but found ${section.lists.length}`, 24 | section.position 25 | ); 26 | } 27 | return null; 28 | } 29 | 30 | /** 31 | * Checks if the number of lists in a section exceeds the maximum allowed by the template. 32 | * 33 | * @param {Object} section - The section to validate. 34 | * @param {Array} section.lists - The lists in the section. 35 | * @param {Object} section.heading - The heading of the section. 36 | * @param {string} section.heading.content - The content of the heading. 37 | * @param {Object} section.position - The position of the section in the document. 38 | * @param {Object} template - The template to validate against. 39 | * @param {Object} template.lists - The lists configuration in the template. 40 | * @param {number} template.lists.max - The maximum number of lists allowed. 41 | * @returns {ValidationError|null} - Returns a ValidationError if the number of lists exceeds the maximum, otherwise null. 42 | */ 43 | function checkMaxLists(section, template) { 44 | if (!template.lists?.max) return null; 45 | 46 | if (section.lists.length > template.lists.max) { 47 | return new ValidationError( 48 | "lists_count_error", 49 | section.heading?.content, 50 | `Expected at most ${template.lists.max} lists, but found ${section.lists.length}`, 51 | section.position 52 | ); 53 | } 54 | return null; 55 | } 56 | 57 | /** 58 | * Checks if any list in the given section exceeds the maximum number of items specified in the template. 59 | * 60 | * @param {Object} section - The section of the document to validate. 61 | * @param {Object} section.lists - The lists within the section. 62 | * @param {Array} section.lists.items - The items within each list. 63 | * @param {Object} section.heading - The heading of the section. 64 | * @param {Object} section.heading.content - The content of the heading. 65 | * @param {Object} section.position - The position of the section in the document. 66 | * @param {Object} template - The template containing validation rules. 67 | * @param {Object} template.lists - The list validation rules in the template. 68 | * @param {Object} template.lists.items - The item validation rules in the template. 69 | * @param {number} template.lists.items.max - The maximum number of items allowed in a list. 70 | * @returns {ValidationError|null} - Returns a ValidationError if any list exceeds the maximum number of items, otherwise returns null. 71 | */ 72 | function checkMaxListItems(section, template) { 73 | if (!template.lists?.items?.max) return null; 74 | 75 | if (section.lists.some((list) => list.items.length > template.lists.items.max)) { 76 | return new ValidationError( 77 | "list_items_count_error", 78 | section.heading?.content, 79 | `Expected at most ${template.lists.items.max} items in a list`, 80 | section.position 81 | ); 82 | } 83 | return null; 84 | } 85 | 86 | /** 87 | * Validates that each list in the given section has at least the minimum number of items specified in the template. 88 | * 89 | * @param {Object} section - The section of the document to validate. 90 | * @param {Object} section.lists - The lists within the section. 91 | * @param {Array} section.lists.items - The items within each list. 92 | * @param {Object} section.heading - The heading of the section. 93 | * @param {string} section.heading.content - The content of the heading. 94 | * @param {Object} section.position - The position of the section in the document. 95 | * @param {Object} template - The template specifying validation rules. 96 | * @param {Object} template.lists - The list validation rules in the template. 97 | * @param {Object} template.lists.items - The item validation rules for lists. 98 | * @param {number} template.lists.items.min - The minimum number of items required in each list. 99 | * @returns {ValidationError|null} - Returns a ValidationError if any list has fewer items than the minimum required, otherwise returns null. 100 | */ 101 | function checkMinListItems(section, template) { 102 | if (!template.lists?.items?.min) return null; 103 | 104 | if (section.lists.some((list) => list.items.length < template.lists.items.min)) { 105 | return new ValidationError( 106 | "list_items_count_error", 107 | section.heading?.content, 108 | `Expected at least ${template.lists.items.min} items in a list`, 109 | section.position 110 | ); 111 | } 112 | return null; 113 | } 114 | 115 | /** 116 | * Validates the lists in a section against a template. 117 | * 118 | * @param {Object} section - The section to validate. 119 | * @param {Object} template - The template to validate against. 120 | * @param {Array} template.lists - The lists defined in the template. 121 | * @param {Array} section.lists - The lists defined in the section. 122 | * @returns {Array} An array of error messages, if any. 123 | */ 124 | export function validateLists(section, template) { 125 | const errors = []; 126 | if (!template.lists || !section.lists) return errors; 127 | 128 | const minListsError = checkMinLists(section, template); 129 | if (minListsError) errors.push(minListsError); 130 | 131 | const maxListsError = checkMaxLists(section, template); 132 | if (maxListsError) errors.push(maxListsError); 133 | 134 | const maxItemsError = checkMaxListItems(section, template); 135 | if (maxItemsError) errors.push(maxItemsError); 136 | 137 | const minItemsError = checkMinListItems(section, template); 138 | if (minItemsError) errors.push(minItemsError); 139 | 140 | return errors; 141 | } 142 | -------------------------------------------------------------------------------- /src/rules/listValidator.test.js: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { validateLists } from "./listValidator.js"; 3 | import { ValidationError } from "./ValidationError.js"; 4 | 5 | describe("validateLists", () => { 6 | let section, template; 7 | 8 | beforeEach(() => { 9 | section = { 10 | lists: [], 11 | heading: { content: "Section Heading" }, 12 | position: { start: 1, end: 2 }, 13 | }; 14 | 15 | template = { 16 | lists: { 17 | min: 1, 18 | max: 3, 19 | items: { 20 | min: 1, 21 | max: 5, 22 | }, 23 | }, 24 | }; 25 | }); 26 | 27 | it("should return an empty array if template.lists is not defined", () => { 28 | template.lists = undefined; 29 | const result = validateLists(section, template); 30 | expect(result).to.be.an("array").that.is.empty; 31 | }); 32 | 33 | it("should return an empty array if section.lists is not defined", () => { 34 | section.lists = undefined; 35 | const result = validateLists(section, template); 36 | expect(result).to.be.an("array").that.is.empty; 37 | }); 38 | 39 | it("should return a ValidationError if the number of lists is less than the minimum required", () => { 40 | section.lists = []; 41 | const result = validateLists(section, template); 42 | expect(result).to.have.lengthOf(1); 43 | expect(result[0]).to.be.instanceOf(ValidationError); 44 | expect(result[0].message).to.include("Expected at least 1 lists"); 45 | }); 46 | 47 | it("should return a ValidationError if the number of lists exceeds the maximum allowed", () => { 48 | section.lists = [ 49 | { items: [1] }, 50 | { items: [1] }, 51 | { items: [1] }, 52 | { items: [1] }, 53 | ]; 54 | const result = validateLists(section, template); 55 | expect(result).to.have.lengthOf(1); 56 | expect(result[0]).to.be.instanceOf(ValidationError); 57 | expect(result[0].message).to.include("Expected at most 3 lists"); 58 | }); 59 | 60 | it("should return a ValidationError if any list exceeds the maximum number of items", () => { 61 | section.lists = [{ items: [1, 2, 3, 4, 5, 6] }]; 62 | const result = validateLists(section, template); 63 | expect(result).to.have.lengthOf(1); 64 | expect(result[0]).to.be.instanceOf(ValidationError); 65 | expect(result[0].message).to.include("Expected at most 5 items in a list"); 66 | }); 67 | 68 | it("should return a ValidationError if any list has fewer items than the minimum required", () => { 69 | section.lists = [{ items: [] }]; 70 | const result = validateLists(section, template); 71 | expect(result).to.have.lengthOf(1); 72 | expect(result[0]).to.be.instanceOf(ValidationError); 73 | expect(result[0].message).to.include("Expected at least 1 items in a list"); 74 | }); 75 | 76 | it("should return multiple ValidationErrors if multiple validation rules are violated", () => { 77 | section.lists = [{ items: [] }, { items: [1, 2, 3, 4, 5, 6] }]; 78 | const result = validateLists(section, template); 79 | expect(result).to.have.lengthOf(2); 80 | expect(result[0]).to.be.instanceOf(ValidationError); 81 | expect(result[1]).to.be.instanceOf(ValidationError); 82 | }); 83 | 84 | it("should return an empty array if all validation rules are satisfied", () => { 85 | section.lists = [{ items: [1, 2] }, { items: [1, 2, 3] }]; 86 | const result = validateLists(section, template); 87 | expect(result).to.be.an("array").that.is.empty; 88 | }); 89 | }); 90 | -------------------------------------------------------------------------------- /src/rules/paragraphsValidator.js: -------------------------------------------------------------------------------- 1 | import { ValidationError } from "./ValidationError.js"; 2 | 3 | /** 4 | * Validates that a section contains at least a minimum number of paragraphs. 5 | * 6 | * @param {Object} section - The section to validate. 7 | * @param {number} minCount - The minimum number of paragraphs required. 8 | * @returns {ValidationError|null} - Returns a ValidationError if the section has fewer paragraphs than the minimum count, otherwise returns null. 9 | */ 10 | function validateMinParagraphs(section, minCount) { 11 | if (minCount && section.paragraphs.length < minCount) { 12 | return new ValidationError( 13 | "paragraphs_count_error", 14 | section.heading?.content, 15 | `Expected at least ${minCount} paragraphs, but found ${section.paragraphs.length}`, 16 | section.position 17 | ); 18 | } 19 | return null; 20 | } 21 | 22 | /** 23 | * Validates the number of paragraphs in a section against a maximum count. 24 | * 25 | * @param {Object} section - The section to validate. 26 | * @param {Array} section.paragraphs - The paragraphs in the section. 27 | * @param {Object} section.heading - The heading of the section. 28 | * @param {string} section.heading.content - The content of the heading. 29 | * @param {Object} section.position - The position of the section in the document. 30 | * @param {number} maxCount - The maximum allowed number of paragraphs. 31 | * @returns {ValidationError|null} - Returns a ValidationError if the number of paragraphs exceeds the maximum count, otherwise returns null. 32 | */ 33 | function validateMaxParagraphs(section, maxCount) { 34 | if (maxCount && section.paragraphs.length > maxCount) { 35 | return new ValidationError( 36 | "paragraphs_count_error", 37 | section.heading?.content, 38 | `Expected at most ${maxCount} paragraphs, but found ${section.paragraphs.length}`, 39 | section.position 40 | ); 41 | } 42 | return null; 43 | } 44 | 45 | /** 46 | * Validates the paragraphs in a section against a set of patterns. 47 | * 48 | * @param {Object} section - The section containing paragraphs to validate. 49 | * @param {Array} patterns - An array of regular expression patterns to validate paragraphs against. 50 | * @returns {Array} An array of validation errors, if any. 51 | */ 52 | function validateParagraphPatterns(section, patterns) { 53 | const errors = []; 54 | if (!patterns) return errors; 55 | 56 | // Validate patterns 57 | const validatedPatterns = patterns.map((pattern) => { 58 | try { 59 | // Set timeout to prevent ReDoS 60 | const timeout = setTimeout(() => { 61 | throw new Error("Pattern compilation timeout"); 62 | }, 1000); 63 | const regex = new RegExp(pattern); 64 | clearTimeout(timeout); 65 | return regex; 66 | } catch (e) { 67 | throw new Error(`Invalid pattern "${pattern}": ${e.message}`); 68 | } 69 | }); 70 | 71 | section.paragraphs.forEach((paragraph, index) => { 72 | // Get pattern for current paragraph using cycle 73 | const patternIndex = index % validatedPatterns.length; 74 | const pattern = validatedPatterns[patternIndex]; 75 | const regex = new RegExp(pattern); 76 | 77 | if (!regex.test(paragraph.content)) { 78 | errors.push( 79 | new ValidationError( 80 | "paragraph_pattern_error", 81 | section.heading?.content, 82 | `Paragraph ${index + 1} doesn't match expected pattern.`, 83 | paragraph.position 84 | ) 85 | ); 86 | } 87 | }); 88 | 89 | return errors; 90 | } 91 | 92 | /** 93 | * Validates the paragraphs of a given section against a template. 94 | * 95 | * @param {Object} section - The section to validate. 96 | * @param {Object} template - The template containing paragraph validation rules. 97 | * @param {Object} template.paragraphs - The paragraph validation rules. 98 | * @param {number} [template.paragraphs.min] - The minimum number of paragraphs required. 99 | * @param {number} [template.paragraphs.max] - The maximum number of paragraphs allowed. 100 | * @param {Array} [template.paragraphs.patterns] - An array of regular expressions to validate paragraph patterns. 101 | * @returns {Array} An array of error messages, if any. 102 | */ 103 | export function validateParagraphs(section, template) { 104 | const errors = []; 105 | 106 | if (template.paragraphs) { 107 | const minError = validateMinParagraphs(section, template.paragraphs.min); 108 | if (minError) errors.push(minError); 109 | 110 | const maxError = validateMaxParagraphs(section, template.paragraphs.max); 111 | if (maxError) errors.push(maxError); 112 | 113 | errors.push( 114 | ...validateParagraphPatterns(section, template.paragraphs.patterns) 115 | ); 116 | } 117 | 118 | return errors; 119 | } 120 | -------------------------------------------------------------------------------- /src/rules/paragraphsValidator.test.js: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { validateParagraphs } from "./paragraphsValidator.js"; 3 | import { ValidationError } from "./ValidationError.js"; 4 | 5 | describe("validateParagraphs", () => { 6 | it("should return an error if the section has fewer paragraphs than the minimum count", () => { 7 | const section = { 8 | paragraphs: [{ content: "Paragraph 1" }], 9 | heading: { content: "Section 1" }, 10 | position: { start: 0, end: 10 } 11 | }; 12 | const template = { 13 | paragraphs: { min: 2 } 14 | }; 15 | 16 | const errors = validateParagraphs(section, template); 17 | expect(errors).to.have.lengthOf(1); 18 | expect(errors[0]).to.be.instanceOf(ValidationError); 19 | expect(errors[0].message).to.equal("Expected at least 2 paragraphs, but found 1"); 20 | }); 21 | 22 | it("should return an error if the section has more paragraphs than the maximum count", () => { 23 | const section = { 24 | paragraphs: [{ content: "Paragraph 1" }, { content: "Paragraph 2" }, { content: "Paragraph 3" }], 25 | heading: { content: "Section 1" }, 26 | position: { start: 0, end: 10 } 27 | }; 28 | const template = { 29 | paragraphs: { max: 2 } 30 | }; 31 | 32 | const errors = validateParagraphs(section, template); 33 | expect(errors).to.have.lengthOf(1); 34 | expect(errors[0]).to.be.instanceOf(ValidationError); 35 | expect(errors[0].message).to.equal("Expected at most 2 paragraphs, but found 3"); 36 | }); 37 | 38 | it("should return an error if a paragraph does not match the pattern", () => { 39 | const section = { 40 | paragraphs: [{ content: "Paragraph 1" }, { content: "Invalid Paragraph" }], 41 | heading: { content: "Section 1" }, 42 | position: { start: 0, end: 10 } 43 | }; 44 | const template = { 45 | paragraphs: { patterns: ["^Paragraph \\d+$"] } 46 | }; 47 | 48 | const errors = validateParagraphs(section, template); 49 | expect(errors).to.have.lengthOf(1); 50 | expect(errors[0]).to.be.instanceOf(ValidationError); 51 | expect(errors[0].message).to.include("doesn't match expected pattern"); 52 | }); 53 | 54 | it("should return multiple errors if multiple validation rules are violated", () => { 55 | const section = { 56 | paragraphs: [{ content: "Invalid Paragraph" }], 57 | heading: { content: "Section 1" }, 58 | position: { start: 0, end: 10 } 59 | }; 60 | const template = { 61 | paragraphs: { min: 2, max: 1, patterns: ["^Paragraph \\d+$"] } 62 | }; 63 | 64 | const errors = validateParagraphs(section, template); 65 | expect(errors).to.have.lengthOf(2); 66 | }); 67 | 68 | it("should return no errors if all validation rules are satisfied", () => { 69 | const section = { 70 | paragraphs: [{ content: "Paragraph 1" }, { content: "Paragraph 2" }], 71 | heading: { content: "Section 1" }, 72 | position: { start: 0, end: 10 } 73 | }; 74 | const template = { 75 | paragraphs: { min: 2, max: 2, patterns: ["^Paragraph \\d+$"] } 76 | }; 77 | 78 | const errors = validateParagraphs(section, template); 79 | expect(errors).to.have.lengthOf(0); 80 | }); 81 | }); 82 | -------------------------------------------------------------------------------- /src/rules/sequenceValidator.js: -------------------------------------------------------------------------------- 1 | import { validateParagraphs } from "./paragraphsValidator.js"; 2 | import { validateCodeBlocks } from "./codeBlocksValidator.js"; 3 | import { validateLists } from "./listValidator.js"; 4 | import { ValidationError } from "./ValidationError.js"; 5 | 6 | /** 7 | * Validates that the length of the sequence in the structure matches the length of the sequence in the template. 8 | * 9 | * @param {Object} structure - The structure object to validate. 10 | * @param {Object} structure.content - The content array of the structure. 11 | * @param {Object} structure.heading - The heading object of the structure. 12 | * @param {string} structure.heading.content - The content of the heading. 13 | * @param {Object} structure.position - The position object of the structure. 14 | * @param {Object} template - The template object to validate against. 15 | * @param {Array} template.sequence - The sequence array of the template. 16 | * @returns {ValidationError|null} Returns a ValidationError if the sequence lengths do not match, otherwise returns null. 17 | */ 18 | function checkSequenceLength(structure, template) { 19 | if (template.sequence.length !== structure.content.length) { 20 | return new ValidationError( 21 | "sequence_length_error", 22 | structure.heading.content, 23 | `Expected ${template.sequence.length} content types in sequence, but found ${structure.content.length}`, 24 | structure.position 25 | ); 26 | } 27 | return null; 28 | } 29 | 30 | /** 31 | * Maps the structure items to their respective types. 32 | * 33 | * @param {Object} structure - The structure object containing content items. 34 | * @param {Array} structure.content - An array of content items. 35 | * @param {Object} structure.content[].paragraphs - Optional paragraphs property of a content item. 36 | * @param {Object} structure.content[].code_blocks - Optional code_blocks property of a content item. 37 | * @param {Object} structure.content[].lists - Optional lists property of a content item. 38 | * @returns {Array} An array of strings representing the type of each content item, or null if no type matches. 39 | */ 40 | function mapStructureItemTypes(structure) { 41 | return structure.content.map(item => { 42 | if (Object.hasOwn(item, "paragraphs")) return "paragraphs"; 43 | if (Object.hasOwn(item, "code_blocks")) return "code_blocks"; 44 | if (Object.hasOwn(item, "lists")) return "lists"; 45 | return null; 46 | }); 47 | } 48 | 49 | /** 50 | * Validates the order of items in a given structure against a template sequence. 51 | * 52 | * @param {Object} structure - The structure to validate. 53 | * @param {Object} template - The template containing the expected sequence. 54 | * @param {Array} template.sequence - The expected sequence of item types. 55 | * @returns {ValidationError|null} - Returns a ValidationError if the sequence order is incorrect, otherwise null. 56 | */ 57 | function checkSequenceOrder(structure, template) { 58 | const templateItemTypes = template.sequence.map(item => Object.keys(item)[0]); 59 | const structureItemTypes = mapStructureItemTypes(structure); 60 | 61 | if (structureItemTypes.includes(null)) { 62 | return new ValidationError( 63 | "sequence_order_error", 64 | structure.heading.content, 65 | "Unexpected content type found in sequence", 66 | structure.position 67 | ); 68 | } 69 | 70 | if (JSON.stringify(templateItemTypes) !== JSON.stringify(structureItemTypes)) { 71 | return new ValidationError( 72 | "sequence_order_error", 73 | structure.heading.content, 74 | `Expected sequence ${JSON.stringify(templateItemTypes)}, but found sequence ${JSON.stringify(structureItemTypes)}`, 75 | structure.position 76 | ); 77 | } 78 | return null; 79 | } 80 | 81 | /** 82 | * Validates a sequence item based on its type. 83 | * 84 | * @param {Object} structure - The overall structure containing the sequence. 85 | * @param {Object} templateItem - The template item to validate against. 86 | * @param {Object} structureItem - The actual structure item to validate. 87 | * @param {string} type - The type of the sequence item (e.g., "paragraphs", "code_blocks", "lists"). 88 | * @returns {Array|ValidationError[]} - Returns an array of validation errors if any, otherwise an empty array. 89 | */ 90 | function validateSequenceItem(structure, templateItem, structureItem, type) { 91 | switch (type) { 92 | case "paragraphs": 93 | return validateParagraphs(structureItem, templateItem); 94 | case "code_blocks": 95 | return validateCodeBlocks(structureItem, templateItem); 96 | case "lists": 97 | return validateLists(structureItem, templateItem); 98 | default: 99 | return [new ValidationError( 100 | "sequence_order_error", 101 | structure.heading.content, 102 | `Unexpected content type (${type}) found in sequence`, 103 | structure.position 104 | )]; 105 | } 106 | } 107 | 108 | /** 109 | * Validates the sequence of the given structure against the provided template. 110 | * 111 | * @param {Object} structure - The structure to be validated. 112 | * @param {Object} template - The template containing the expected sequence. 113 | * @returns {Array} An array of error messages, if any. 114 | */ 115 | export function validateSequence(structure, template) { 116 | const errors = []; 117 | if (!template.sequence || !structure.content) return errors; 118 | 119 | const lengthError = checkSequenceLength(structure, template); 120 | if (lengthError) { 121 | errors.push(lengthError); 122 | return errors; 123 | } 124 | 125 | const orderError = checkSequenceOrder(structure, template); 126 | if (orderError) { 127 | errors.push(orderError); 128 | return errors; 129 | } 130 | 131 | const templateItemTypes = template.sequence.map(item => Object.keys(item)[0]); 132 | for (let index = 0; index < template.sequence.length; index++) { 133 | errors.push(...validateSequenceItem( 134 | structure, 135 | template.sequence[index], 136 | structure.content[index], 137 | templateItemTypes[index] 138 | )); 139 | } 140 | 141 | return errors; 142 | } 143 | -------------------------------------------------------------------------------- /src/rules/sequenceValidator.test.js: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { validateSequence } from "./sequenceValidator.js"; 3 | import { ValidationError } from "./ValidationError.js"; 4 | 5 | describe("validateSequence", () => { 6 | it("should return an empty array if template.sequence or structure.content is missing", () => { 7 | const structure = {}; 8 | const template = {}; 9 | const result = validateSequence(structure, template); 10 | expect(result).to.be.an("array").that.is.empty; 11 | }); 12 | 13 | it("should return a length error if sequence lengths do not match", () => { 14 | const structure = { 15 | content: [{ paragraphs: "content1" }], 16 | heading: { content: "heading1" }, 17 | position: { line: 1, column: 1 }, 18 | }; 19 | const template = { 20 | sequence: [{ paragraphs: "template1" }, { code_blocks: "template2" }], 21 | }; 22 | const result = validateSequence(structure, template); 23 | expect(result).to.have.lengthOf(1); 24 | expect(result[0]).to.be.instanceOf(ValidationError); 25 | expect(result[0].message).to.include( 26 | "Expected 2 content types in sequence, but found 1" 27 | ); 28 | }); 29 | 30 | it("should return an order error if sequence order is incorrect", () => { 31 | const structure = { 32 | content: [{ paragraphs: "content1" }, { code_blocks: "content2" }], 33 | heading: { content: "heading1" }, 34 | position: { line: 1, column: 1 }, 35 | }; 36 | const template = { 37 | sequence: [{ code_blocks: "template1" }, { paragraphs: "template2" }], 38 | }; 39 | const result = validateSequence(structure, template); 40 | expect(result).to.have.lengthOf(1); 41 | expect(result[0]).to.be.instanceOf(ValidationError); 42 | expect(result[0].message).to.include( 43 | 'Expected sequence ["code_blocks","paragraphs"], but found sequence ["paragraphs","code_blocks"]' 44 | ); 45 | }); 46 | 47 | it("should return validation errors for each sequence item", () => { 48 | const structure = { 49 | content: [ 50 | { 51 | paragraphs: [ 52 | { 53 | content: "Before you begin, make sure you have the following:", 54 | position: { 55 | start: { 56 | line: 14, 57 | column: 1, 58 | offset: 241, 59 | }, 60 | end: { 61 | line: 14, 62 | column: 52, 63 | offset: 292, 64 | }, 65 | }, 66 | }, 67 | ], 68 | }, 69 | { 70 | lists: [ 71 | { 72 | ordered: false, 73 | items: [ 74 | { 75 | position: { 76 | start: { 77 | line: 16, 78 | column: 1, 79 | offset: 294, 80 | }, 81 | end: { 82 | line: 16, 83 | column: 22, 84 | offset: 315, 85 | }, 86 | }, 87 | content: [ 88 | { 89 | content: "A valid license key", 90 | position: { 91 | start: { 92 | line: 16, 93 | column: 3, 94 | offset: 296, 95 | }, 96 | end: { 97 | line: 16, 98 | column: 22, 99 | offset: 315, 100 | }, 101 | }, 102 | }, 103 | ], 104 | }, 105 | { 106 | position: { 107 | start: { 108 | line: 17, 109 | column: 1, 110 | offset: 316, 111 | }, 112 | end: { 113 | line: 17, 114 | column: 22, 115 | offset: 337, 116 | }, 117 | }, 118 | content: [ 119 | { 120 | content: "At least 4GB of RAM", 121 | position: { 122 | start: { 123 | line: 17, 124 | column: 3, 125 | offset: 318, 126 | }, 127 | end: { 128 | line: 17, 129 | column: 22, 130 | offset: 337, 131 | }, 132 | }, 133 | }, 134 | ], 135 | }, 136 | { 137 | position: { 138 | start: { 139 | line: 19, 140 | column: 1, 141 | offset: 339, 142 | }, 143 | end: { 144 | line: 19, 145 | column: 6, 146 | offset: 344, 147 | }, 148 | }, 149 | content: [ 150 | { 151 | content: "foo", 152 | position: { 153 | start: { 154 | line: 19, 155 | column: 3, 156 | offset: 341, 157 | }, 158 | end: { 159 | line: 19, 160 | column: 6, 161 | offset: 344, 162 | }, 163 | }, 164 | }, 165 | ], 166 | }, 167 | { 168 | position: { 169 | start: { 170 | line: 20, 171 | column: 1, 172 | offset: 345, 173 | }, 174 | end: { 175 | line: 20, 176 | column: 6, 177 | offset: 350, 178 | }, 179 | }, 180 | content: [ 181 | { 182 | content: "bar", 183 | position: { 184 | start: { 185 | line: 20, 186 | column: 3, 187 | offset: 347, 188 | }, 189 | end: { 190 | line: 20, 191 | column: 6, 192 | offset: 350, 193 | }, 194 | }, 195 | }, 196 | ], 197 | }, 198 | { 199 | position: { 200 | start: { 201 | line: 22, 202 | column: 1, 203 | offset: 352, 204 | }, 205 | end: { 206 | line: 22, 207 | column: 4, 208 | offset: 355, 209 | }, 210 | }, 211 | content: [ 212 | { 213 | content: "x", 214 | position: { 215 | start: { 216 | line: 22, 217 | column: 3, 218 | offset: 354, 219 | }, 220 | end: { 221 | line: 22, 222 | column: 4, 223 | offset: 355, 224 | }, 225 | }, 226 | }, 227 | ], 228 | }, 229 | { 230 | position: { 231 | start: { 232 | line: 23, 233 | column: 1, 234 | offset: 356, 235 | }, 236 | end: { 237 | line: 23, 238 | column: 4, 239 | offset: 359, 240 | }, 241 | }, 242 | content: [ 243 | { 244 | content: "y", 245 | position: { 246 | start: { 247 | line: 23, 248 | column: 3, 249 | offset: 358, 250 | }, 251 | end: { 252 | line: 23, 253 | column: 4, 254 | offset: 359, 255 | }, 256 | }, 257 | }, 258 | ], 259 | }, 260 | ], 261 | position: { 262 | start: { 263 | line: 16, 264 | column: 1, 265 | offset: 294, 266 | }, 267 | end: { 268 | line: 23, 269 | column: 4, 270 | offset: 359, 271 | }, 272 | }, 273 | }, 274 | ], 275 | }, 276 | ], 277 | heading: { content: "heading1" }, 278 | position: { line: 1, column: 1 }, 279 | }; 280 | const template = { 281 | sequence: [{ paragraphs: { min: 5 } }, { lists: { min: 5 } }], 282 | }; 283 | 284 | const result = validateSequence(structure, template); 285 | expect(result).to.have.lengthOf(2); 286 | expect(result[0]).to.be.instanceOf(ValidationError); 287 | expect(result[0].message).to.include("Expected at least 5 paragraphs, but found 1"); 288 | expect(result[1]).to.be.instanceOf(ValidationError); 289 | expect(result[1].message).to.include("Expected at least 5 lists, but found 1"); 290 | }); 291 | }); 292 | -------------------------------------------------------------------------------- /src/rules/structureValidator.js: -------------------------------------------------------------------------------- 1 | import { validateHeading } from "./headingValidator.js"; 2 | import { validateParagraphs } from "./paragraphsValidator.js"; 3 | import { validateCodeBlocks } from "./codeBlocksValidator.js"; 4 | import { validateLists } from "./listValidator.js"; 5 | import { validateSequence } from "./sequenceValidator.js"; 6 | import { validateInstructions } from "./instructionValidator.js"; 7 | import { validateSubsections } from "./subsectionValidator.js"; 8 | 9 | /** 10 | * Validates the structure of a given document against a template. 11 | * 12 | * @param {Object} structure - The structure of the document to be validated. 13 | * @param {Object} template - The template to validate the document against. 14 | * @param {Object} template.sections - The sections defined in the template. 15 | * @param {Object} structure.sections - The sections defined in the document structure. 16 | * @returns {Array} An array of error messages, if any. 17 | */ 18 | export async function validateStructure(structure, template) { 19 | let errors = []; 20 | 21 | // TODO: Check frontmatter 22 | 23 | // Check sections 24 | if (template.sections && structure.sections) { 25 | for (let i = 0; i < Object.keys(template.sections).length; i++) { 26 | const templateKey = Object.keys(template.sections)[i]; 27 | const templateSection = template.sections[templateKey]; 28 | const structureSection = structure.sections[i]; 29 | 30 | errors = errors.concat( 31 | await validateSection(structureSection, templateSection) 32 | ); 33 | } 34 | } 35 | 36 | return errors; 37 | } 38 | 39 | /** 40 | * Validates a structure section against a template section. 41 | * 42 | * @param {Object} structureSection - The structure section to validate. 43 | * @param {Object} templateSection - The template section to validate against. 44 | * @returns {Array} An array of error messages, if any. 45 | */ 46 | export async function validateSection(structureSection, templateSection) { 47 | let errors = []; 48 | 49 | // Check sequence if defined 50 | if (templateSection.sequence) { 51 | errors = errors.concat(validateSequence(structureSection, templateSection)); 52 | } 53 | 54 | // Check heading 55 | if (templateSection.heading && structureSection.heading) { 56 | errors = errors.concat(validateHeading(structureSection, templateSection)); 57 | } 58 | 59 | // Check paragraphs 60 | if (templateSection.paragraphs && structureSection.paragraphs) { 61 | errors = errors.concat( 62 | validateParagraphs(structureSection, templateSection) 63 | ); 64 | } 65 | 66 | // Check code blocks 67 | if (templateSection.code_blocks && structureSection.codeBlocks) { 68 | errors = errors.concat( 69 | validateCodeBlocks(structureSection, templateSection) 70 | ); 71 | } 72 | 73 | // Check lists 74 | if (templateSection.lists && structureSection.lists) { 75 | errors = errors.concat(validateLists(structureSection, templateSection)); 76 | } 77 | 78 | // Check instructions 79 | if (templateSection.instructions) { 80 | errors = errors.concat( 81 | await validateInstructions(structureSection, templateSection) 82 | ); 83 | } 84 | 85 | // Check subsections 86 | if (templateSection.sections) { 87 | errors = errors.concat( 88 | await validateSubsections(structureSection, templateSection) 89 | ); 90 | } 91 | 92 | return errors; 93 | } 94 | -------------------------------------------------------------------------------- /src/rules/structureValidator.test.js: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { validateSection, validateStructure } from "./structureValidator.js"; 3 | 4 | describe("validateStructure", () => { 5 | it("should return an empty array if no sections are defined in the template", async () => { 6 | const structure = { sections: [] }; 7 | const template = { sections: [] }; 8 | const result = await validateStructure(structure, template); 9 | expect(result).to.be.an("array").that.is.empty; 10 | }); 11 | 12 | it("should return an empty array if no sections are defined in the structure", async () => { 13 | const structure = { sections: [] }; 14 | const template = { sections: { section1: {} } }; 15 | const result = await validateStructure(structure, template); 16 | expect(result).to.be.an("array").that.is.empty; 17 | }); 18 | 19 | it("should validate sections according to template", async () => { 20 | const structure = { 21 | sections: [{ heading: "Section 1" }, { heading: "Section 2" }], 22 | }; 23 | const template = { 24 | sections: { 25 | section1: { heading: { const: "Section 1" } }, 26 | section2: { heading: { const: "Section 2" } }, 27 | }, 28 | }; 29 | const result = await validateStructure(structure, template); 30 | expect(result).to.be.an("array"); 31 | }); 32 | 33 | it("should collect errors from section validation", async () => { 34 | const structure = { 35 | sections: [ 36 | { heading: { content: "Wrong Heading" } }, 37 | { heading: { content: "Another Wrong Heading" } }, 38 | ], 39 | }; 40 | const template = { 41 | sections: { 42 | section1: { heading: { const: "Expected Heading" } }, 43 | section2: { heading: { const: "Another Expected Heading" } }, 44 | }, 45 | }; 46 | const result = await validateStructure(structure, template); 47 | expect(result).to.be.an("array"); 48 | // We can't predict exact errors since they come from actual validation 49 | // but we can check that validation occurred 50 | expect(result.length).to.equal(2); 51 | }); 52 | }); 53 | 54 | describe("validateSection", () => { 55 | it("should return no errors if no validation rules are defined", async () => { 56 | const structureSection = {}; 57 | const templateSection = {}; 58 | const result = await validateSection(structureSection, templateSection); 59 | expect(result).to.be.an("array").that.is.empty; 60 | }); 61 | 62 | it("should validate heading against template constraints", async () => { 63 | const structureSection = { heading: { content: "Test Heading" } }; 64 | const templateSection = { heading: { const: "Test Heading" } }; 65 | const result = await validateSection(structureSection, templateSection); 66 | expect(result).to.be.an("array").that.is.empty; 67 | }); 68 | 69 | it("should validate paragraphs against template constraints", async () => { 70 | const structureSection = { 71 | paragraphs: [ 72 | { content: "First paragraph" }, 73 | { content: "Second paragraph" }, 74 | ], 75 | }; 76 | const templateSection = { 77 | paragraphs: { min: 1, max: 3 }, 78 | }; 79 | const result = await validateSection(structureSection, templateSection); 80 | expect(result).to.be.an("array").that.is.empty; 81 | }); 82 | 83 | it("should validate code blocks against template constraints", async () => { 84 | const structureSection = { 85 | code_blocks: [{ content: "```bash\nconsole.log('test');\n```" }], 86 | }; 87 | const templateSection = { 88 | code_blocks: { min: 1 }, 89 | }; 90 | const result = await validateSection(structureSection, templateSection); 91 | expect(result).to.be.an("array").that.is.empty; 92 | }); 93 | 94 | it("should validate lists against template constraints", async () => { 95 | const structureSection = { 96 | lists: [{ items: [{}] }, { items: [{}] }], 97 | }; 98 | const templateSection = { 99 | lists: { min: 1 }, 100 | }; 101 | const result = await validateSection(structureSection, templateSection); 102 | expect(result).to.be.an("array").that.is.empty; 103 | }); 104 | 105 | it("should validate sequence when specified", async () => { 106 | const structureSection = { 107 | heading: "Title", 108 | paragraphs: ["Text"], 109 | code_blocks: ["code"], 110 | }; 111 | const templateSection = { 112 | sequence: ["heading", "paragraphs", "code_blocks"], 113 | }; 114 | const result = await validateSection(structureSection, templateSection); 115 | expect(result).to.be.an("array").that.is.empty; 116 | }); 117 | 118 | it("should validate nested sections", async () => { 119 | const structureSection = { 120 | sections: [ 121 | { heading: { content: "Subsection 1" } }, 122 | { heading: { content: "Subsection 2" } }, 123 | ], 124 | }; 125 | const templateSection = { 126 | sections: { 127 | subsection1: { heading: { const: "Subsection 1" } }, 128 | subsection2: { heading: { const: "Subsection 2" } }, 129 | }, 130 | }; 131 | const result = await validateSection(structureSection, templateSection); 132 | expect(result).to.be.an("array").that.is.empty; 133 | }); 134 | 135 | it("should accumulate all validation errors", async () => { 136 | const structureSection = { 137 | heading: { content: "Wrong Heading" }, 138 | paragraphs: [], // Should have at least one 139 | lists: [{ items: [{}] }], // Should have at least two 140 | }; 141 | const templateSection = { 142 | heading: { const: "Expected Heading" }, 143 | paragraphs: { min: 1 }, 144 | lists: { min: 2 }, 145 | }; 146 | const result = await validateSection(structureSection, templateSection); 147 | expect(result).to.be.an("array"); 148 | expect(result.length).to.equal(3); 149 | }); 150 | }); 151 | -------------------------------------------------------------------------------- /src/rules/subsectionValidator.js: -------------------------------------------------------------------------------- 1 | import { ValidationError } from "./ValidationError.js"; 2 | import { validateSection } from "./structureValidator.js"; 3 | 4 | /** 5 | * Checks for missing subsections in a given structure section based on a template section. 6 | * 7 | * @param {Object} structureSection - The structure section to validate. 8 | * @param {Object} templateSection - The template section to validate against. 9 | * @param {Object} [structureSection.sections] - The subsections of the structure section. 10 | * @param {Object} templateSection.sections - The subsections of the template section. 11 | * @param {boolean} templateSection.sections[].required - Indicates if the subsection is required. 12 | * @param {string} [structureSection.heading.content] - The heading content of the structure section. 13 | * @param {Object} structureSection.position - The position of the structure section in the document. 14 | * @returns {ValidationError|null} Returns a ValidationError if a required subsection is missing, otherwise null. 15 | */ 16 | function checkMissingSubsections(structureSection, templateSection) { 17 | if (!structureSection.sections) { 18 | const allOptional = Object.values(templateSection.sections).every( 19 | (subsection) => !subsection.required 20 | ); 21 | if (!allOptional) { 22 | return new ValidationError( 23 | "missing_section", 24 | structureSection.heading?.content, 25 | `Missing section ${Object.keys(templateSection.sections)[0]}`, 26 | structureSection.position 27 | ); 28 | } 29 | } 30 | return null; 31 | } 32 | 33 | /** 34 | * Validates the number of subsections in a given structure section against a template section. 35 | * 36 | * @param {Object} structureSection - The section of the structure to validate. 37 | * @param {Object} templateSection - The template section to validate against. 38 | * @param {Object} templateSection.sections - The subsections defined in the template. 39 | * @param {boolean} [templateSection.additionalSections] - Whether additional sections are allowed. 40 | * @returns {ValidationError|null} - Returns a ValidationError if the subsection count does not match the template requirements, otherwise null. 41 | */ 42 | function checkSubsectionCount(structureSection, templateSection) { 43 | const requiredCount = Object.values(templateSection.sections).filter( 44 | (subsection) => subsection.required 45 | ).length; 46 | const optionalCount = 47 | Object.keys(templateSection.sections).length - requiredCount; 48 | const actualCount = structureSection.sections.length; 49 | 50 | if ( 51 | actualCount < requiredCount || 52 | (actualCount > requiredCount + optionalCount && 53 | !templateSection.additionalSections) 54 | ) { 55 | return new ValidationError( 56 | "section_count_mismatch", 57 | structureSection.heading?.content, 58 | `Expected between ${requiredCount} and ${ 59 | requiredCount + optionalCount 60 | } sections, but found ${actualCount}`, 61 | structureSection.position 62 | ); 63 | } 64 | return null; 65 | } 66 | 67 | /** 68 | * Validates additional sections in a structure against a template. 69 | * 70 | * @param {Object} structureSection - The structure section to validate. 71 | * @param {Object} templateSection - The template section to validate against. 72 | * @param {Object} templateSection.sections - The subsections of the template. 73 | * @param {boolean} templateSection.sections[].required - Indicates if the subsection is required. 74 | * @param {string} [structureSection.heading.content] - The heading content of the structure section. 75 | * @param {Array} structureSection.sections - The subsections of the structure section. 76 | * @param {Object} structureSection.position - The position of the structure section. 77 | * @returns {Array} An array of ValidationError objects if there are validation errors, otherwise an empty array. 78 | */ 79 | async function validateAdditionalSections(structureSection, templateSection) { 80 | const errors = []; 81 | const sectionMap = {}; 82 | 83 | for (let j = 0; j < Object.keys(templateSection.sections).length; j++) { 84 | const templateKey = Object.keys(templateSection.sections)[j]; 85 | const templateSubsection = templateSection.sections[templateKey]; 86 | 87 | let sectionFound = false; 88 | // Iterate through each subsection in the structure section 89 | for (let k = 0; k < structureSection.sections.length; k++) { 90 | // Validate the current subsection against the template subsection 91 | const structureSubsection = structureSection.sections[k]; 92 | const sectionErrors = await validateSection(structureSubsection, templateSubsection); 93 | if ( 94 | sectionErrors.length === 0 95 | ) { 96 | // If the subsection is valid, add it to the section map 97 | if (!sectionMap[templateKey]) sectionMap[templateKey] = []; 98 | sectionMap[templateKey].push(k); 99 | sectionFound = true; 100 | } 101 | } 102 | 103 | if (!sectionFound && templateSubsection.required) { 104 | errors.push( 105 | new ValidationError( 106 | "missing_section", 107 | structureSection.heading?.content, 108 | `Missing section ${templateKey}`, 109 | structureSection.position 110 | ) 111 | ); 112 | } 113 | } 114 | 115 | return errors; 116 | } 117 | 118 | /** 119 | * Validates that the required subsections in the structure match the template. 120 | * 121 | * @param {Object} structureSection - The section of the structure to validate. 122 | * @param {Object} templateSection - The section of the template to validate against. 123 | * @returns {Array} An array of error messages, if any. 124 | */ 125 | async function validateRequiredSubsections(structureSection, templateSection) { 126 | const errors = []; 127 | 128 | for (let i = 0; i < structureSection.sections.length; i++) { 129 | const structureSubsection = structureSection.sections[i]; 130 | const templateSubsection = 131 | templateSection.sections[Object.keys(templateSection.sections)[i]]; 132 | errors.push(...await validateSection(structureSubsection, templateSubsection)); 133 | } 134 | 135 | return errors; 136 | } 137 | 138 | /** 139 | * Validates the subsections of a given structure section against a template section. 140 | * 141 | * @param {Object} structureSection - The structure section to validate. 142 | * @param {Object} templateSection - The template section to validate against. 143 | * @returns {Array} An array of error messages, if any. 144 | */ 145 | export async function validateSubsections(structureSection, templateSection) { 146 | const errors = []; 147 | if (!templateSection.sections) return errors; 148 | 149 | const missingError = checkMissingSubsections( 150 | structureSection, 151 | templateSection 152 | ); 153 | if (missingError) { 154 | errors.push(missingError); 155 | return errors; 156 | } 157 | 158 | const countError = checkSubsectionCount(structureSection, templateSection); 159 | if (countError) { 160 | errors.push(countError); 161 | return errors; 162 | } 163 | 164 | if ( 165 | structureSection.sections.length > 166 | Object.keys(templateSection.sections).length && 167 | templateSection.additionalSections 168 | ) { 169 | errors.push( 170 | ...await validateAdditionalSections(structureSection, templateSection) 171 | ); 172 | } else { 173 | errors.push( 174 | ...await validateRequiredSubsections(structureSection, templateSection) 175 | ); 176 | } 177 | 178 | return errors; 179 | } 180 | -------------------------------------------------------------------------------- /src/rules/subsectionValidator.test.js: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { validateSubsections } from "./subsectionValidator.js"; 3 | import { ValidationError } from "./ValidationError.js"; 4 | 5 | describe("validateSubsections", () => { 6 | it("should return an empty array if templateSection has no sections", async () => { 7 | const structureSection = { sections: [] }; 8 | const templateSection = {}; 9 | 10 | const result = await validateSubsections(structureSection, templateSection); 11 | expect(result).to.be.an("array").that.is.empty; 12 | }); 13 | 14 | it("should return a missing section error if structureSection has no sections and templateSection has required sections", async () => { 15 | const structureSection = { sections: [], heading: { content: "Heading" }, position: { line: 1, column: 1 } }; 16 | const templateSection = { sections: { subsection1: { required: true } } }; 17 | 18 | const result = await validateSubsections(structureSection, templateSection); 19 | expect(result).to.be.an("array").that.is.not.empty; 20 | expect(result[0]).to.be.instanceOf(ValidationError); 21 | expect(result[0].message).to.include("Expected between 1 and 1 sections, but found 0"); 22 | }); 23 | 24 | it("should return a section count mismatch error if the number of sections does not match the template requirements", async () => { 25 | const structureSection = { sections: [{}, {}, {}], heading: { content: "Heading" }, position: { line: 1, column: 1 } }; 26 | const templateSection = { sections: { subsection1: { required: true }, subsection2: { required: true } }, additionalSections: false }; 27 | 28 | const result = await validateSubsections(structureSection, templateSection); 29 | expect(result).to.be.an("array").that.is.not.empty; 30 | expect(result[0]).to.be.instanceOf(ValidationError); 31 | expect(result[0].message).to.include("Expected between"); 32 | }); 33 | 34 | it("should validate additional sections if allowed", async () => { 35 | const structureSection = { sections: [{}, {}, {}], heading: { content: "Heading" }, position: { line: 1, column: 1 } }; 36 | const templateSection = { sections: { subsection1: { required: true }, subsection2: { required: true } }, additionalSections: true }; 37 | 38 | const result = await validateSubsections(structureSection, templateSection); 39 | expect(result).to.be.an("array").that.is.empty; 40 | }); 41 | 42 | it("should validate required subsections", async () => { 43 | const structureSection = { sections: [{}, {}], heading: { content: "Heading" }, position: { line: 1, column: 1 } }; 44 | const templateSection = { sections: { subsection1: { required: true }, subsection2: { required: true } } }; 45 | 46 | const result = await validateSubsections(structureSection, templateSection); 47 | expect(result).to.be.an("array").that.is.empty; 48 | }); 49 | }); -------------------------------------------------------------------------------- /src/schema.js: -------------------------------------------------------------------------------- 1 | export const schema = { 2 | type: "object", 3 | additionalProperties: false, 4 | patternProperties: { 5 | "^[A-Za-z0-9-_]+$": { 6 | type: "object", 7 | additionalProperties: false, 8 | properties: { 9 | sections: { 10 | type: "object", 11 | additionalProperties: false, 12 | patternProperties: { 13 | "^[A-Za-z0-9-_]+$": { 14 | $ref: "#/definitions/section", 15 | }, 16 | }, 17 | }, 18 | }, 19 | }, 20 | }, 21 | definitions: { 22 | paragraphs: { 23 | type: "object", 24 | properties: { 25 | min: { 26 | description: "Minimum number of paragraphs", 27 | type: "integer", 28 | minimum: 0, 29 | }, 30 | max: { 31 | description: "Maximum number of paragraphs", 32 | type: "integer", 33 | }, 34 | patterns: { 35 | description: 36 | "Array of regex patterns for paragraphs, applied in sequence to paragraphs as they appear", 37 | type: "array", 38 | items: { 39 | type: "string", 40 | }, 41 | }, 42 | }, 43 | }, 44 | code_blocks: { 45 | description: "Code block requirements", 46 | type: "object", 47 | properties: { 48 | min: { 49 | description: "Minimum number of code blocks", 50 | type: "integer", 51 | minimum: 0, 52 | }, 53 | max: { 54 | description: "Maximum number of code blocks", 55 | type: "integer", 56 | }, 57 | }, 58 | }, 59 | lists: { 60 | description: "List requirements", 61 | type: "object", 62 | properties: { 63 | min: { 64 | description: "Minimum number of lists", 65 | type: "integer", 66 | minimum: 0, 67 | }, 68 | max: { 69 | description: "Maximum number of lists", 70 | type: "integer", 71 | }, 72 | items: { 73 | description: "Item requirements", 74 | type: "object", 75 | properties: { 76 | min: { 77 | description: "Minimum number of items in a list", 78 | type: "integer", 79 | minimum: 0, 80 | }, 81 | max: { 82 | description: "Maximum number of items in a list", 83 | type: "integer", 84 | }, 85 | paragraphs: { 86 | $ref: "#/definitions/paragraphs", 87 | }, 88 | code_blocks: { 89 | $ref: "#/definitions/code_blocks", 90 | }, 91 | lists: { 92 | $ref: "#/definitions/lists", 93 | }, 94 | }, 95 | }, 96 | }, 97 | }, 98 | sequence_item: { 99 | type: "object", 100 | anyOf: [ 101 | { 102 | additionalProperties: false, 103 | properties: { 104 | paragraphs: { 105 | $ref: "#/definitions/paragraphs", 106 | }, 107 | }, 108 | }, 109 | { 110 | additionalProperties: false, 111 | properties: { 112 | code_blocks: { 113 | $ref: "#/definitions/code_blocks", 114 | }, 115 | }, 116 | }, 117 | { 118 | additionalProperties: false, 119 | properties: { 120 | lists: { 121 | $ref: "#/definitions/lists", 122 | }, 123 | }, 124 | }, 125 | ], 126 | }, 127 | section: { 128 | description: "A section of a document demarkated by a heading", 129 | type: "object", 130 | additionalProperties: false, 131 | properties: { 132 | description: { 133 | description: "Description of the section", 134 | type: "string", 135 | }, 136 | instructions: { 137 | description: "Instructions for the author to follow when using the template. Evaluated against content by a local language model.", 138 | type: "array", 139 | minItems: 1, 140 | items: { 141 | type: "string", 142 | minLength: 5, 143 | maxLength: 200, 144 | }, 145 | }, 146 | heading: { 147 | description: "Heading rules", 148 | type: "object", 149 | anyOf: [ 150 | { 151 | properties: { 152 | const: { 153 | description: "Exact heading of the section", 154 | type: "string", 155 | }, 156 | }, 157 | }, 158 | { 159 | properties: { 160 | pattern: { 161 | description: "Regex pattern for the heading", 162 | type: "string", 163 | }, 164 | }, 165 | }, 166 | ], 167 | }, 168 | required: { 169 | description: "Whether the section is required", 170 | type: "boolean", 171 | default: true, 172 | }, 173 | sequence: { 174 | description: "Ordered sequence of elements in the section", 175 | type: "array", 176 | minItems: 1, 177 | items: { 178 | $ref: "#/definitions/sequence_item", 179 | }, 180 | }, 181 | paragraphs: { 182 | $ref: "#/definitions/paragraphs", 183 | }, 184 | code_blocks: { 185 | $ref: "#/definitions/code_blocks", 186 | }, 187 | lists: { 188 | $ref: "#/definitions/lists", 189 | }, 190 | additionalSections: { 191 | description: "Allow undefined sections", 192 | type: "boolean", 193 | default: false, 194 | }, 195 | sections: { 196 | description: "Object of subsections", 197 | type: "object", 198 | patternProperties: { 199 | "^[A-Za-z0-9-_]+$": { 200 | anyOf: [{ $ref: "#/definitions/section" }], 201 | }, 202 | }, 203 | }, 204 | }, 205 | }, 206 | }, 207 | }; 208 | -------------------------------------------------------------------------------- /src/templateLoader.js: -------------------------------------------------------------------------------- 1 | import { readFileSync, existsSync, mkdirSync } from "fs"; 2 | import path from "path"; 3 | import { parse } from "yaml"; 4 | import { dereference } from "@apidevtools/json-schema-ref-parser"; 5 | import Ajv from "ajv"; 6 | import { schema } from "./schema.js"; 7 | import {getFile} from "./util/getFile.js"; 8 | 9 | const ajv = new Ajv({ useDefaults: true }); 10 | 11 | /** 12 | * Dereferences the provided template descriptions. 13 | * 14 | * This function attempts to dereference the given template descriptions using the `dereference` function. 15 | * If an error occurs during the dereferencing process, it handles the error by calling `handleFatalError`. 16 | * 17 | * @param {Object} templateDescriptions - The template descriptions to be dereferenced. 18 | * @returns {Promise} A promise that resolves to the dereferenced template descriptions. 19 | * @throws Will throw an error if the dereferencing process fails. 20 | */ 21 | async function dereferenceTemplates(templateDescriptions) { 22 | try { 23 | return await dereference(templateDescriptions); 24 | } catch (error) { 25 | throw new Error("Failed to dereference template schemas", {message: error}); 26 | } 27 | } 28 | 29 | /** 30 | * Validates the provided template descriptions against a predefined schema. 31 | * 32 | * @param {Object} templateDescriptions - An object containing the templates to be validated. 33 | * @param {Object} templateDescriptions.templates - The templates to be validated. 34 | * @throws Will throw an error if a template is invalid. 35 | * @returns {Object} The validated templates. 36 | */ 37 | function validateTemplates(templateDescriptions) { 38 | const validateTemplate = ajv.compile(schema); 39 | 40 | for (const templateName in templateDescriptions.templates) { 41 | const template = { [templateName]: templateDescriptions.templates[templateName] }; 42 | if (!validateTemplate(template)) { 43 | throw new Error("Template is invalid", { message: JSON.stringify(validateTemplate.errors) }); 44 | } 45 | } 46 | 47 | return templateDescriptions.templates; 48 | } 49 | 50 | /** 51 | * Loads and validates templates from the specified file path. 52 | * 53 | * @param {string} templatesFilePath - The path to the templates file. 54 | * @returns {Promise} A promise that resolves to the validated templates. 55 | */ 56 | export async function loadAndValidateTemplates(templatesFilePath) { 57 | const fetchedFile = await getFile(templatesFilePath); 58 | if (fetchedFile.result === "error") { 59 | throw new Error("Error reading template file"); 60 | } 61 | const dereferencedTemplates = await dereferenceTemplates(fetchedFile.content); 62 | return validateTemplates(dereferencedTemplates); 63 | } 64 | -------------------------------------------------------------------------------- /src/templateLoader.test.js: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { loadAndValidateTemplates } from "./templateLoader.js"; 3 | import fs from "fs"; 4 | import path from "path"; 5 | import os from "os"; 6 | 7 | describe("loadAndValidateTemplates", () => { 8 | let tempDir; 9 | let tempFile; 10 | 11 | beforeEach(() => { 12 | // Create temporary directory and file for each test 13 | tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'template-test-')); 14 | tempFile = path.join(tempDir, 'test-templates.yaml'); 15 | }); 16 | 17 | afterEach(() => { 18 | // Cleanup temporary files after each test 19 | fs.rmSync(tempDir, { recursive: true, force: true }); 20 | }); 21 | 22 | it("should load, dereference, and validate templates successfully", async () => { 23 | const validTemplate = ` 24 | templates: 25 | template1: 26 | sections: 27 | section1: 28 | heading: 29 | const: "Test Heading" 30 | required: true`; 31 | 32 | fs.writeFileSync(tempFile, validTemplate); 33 | const result = await loadAndValidateTemplates(tempFile); 34 | 35 | expect(result).to.be.an('object'); 36 | expect(result).to.have.property('template1'); 37 | expect(result.template1.sections.section1.heading.const).to.equal('Test Heading'); 38 | }); 39 | 40 | it("should throw error for non-existent file", async () => { 41 | const nonExistentFile = path.join(tempDir, 'non-existent.yaml'); 42 | 43 | try { 44 | await loadAndValidateTemplates(nonExistentFile); 45 | expect.fail('Should have thrown an error'); 46 | } catch (error) { 47 | expect(error.message).to.include('Error reading template file'); 48 | } 49 | }); 50 | 51 | it("should throw error for invalid YAML", async () => { 52 | const invalidYaml = ` 53 | templates: 54 | template1: 55 | heading: { 56 | const: "Incomplete YAML`; 57 | 58 | fs.writeFileSync(tempFile, invalidYaml); 59 | 60 | try { 61 | await loadAndValidateTemplates(tempFile); 62 | expect.fail('Should have thrown an error'); 63 | } catch (error) { 64 | expect(error.message).to.include('Error reading template file'); 65 | } 66 | }); 67 | 68 | it("should throw error for invalid template schema", async () => { 69 | const invalidTemplate = ` 70 | templates: 71 | template1: 72 | invalid_field: true`; 73 | 74 | fs.writeFileSync(tempFile, invalidTemplate); 75 | 76 | try { 77 | await loadAndValidateTemplates(tempFile); 78 | expect.fail('Should have thrown an error'); 79 | } catch (error) { 80 | expect(error.message).to.include('Template is invalid'); 81 | } 82 | }); 83 | 84 | it("should handle templates with references", async () => { 85 | const templateWithRefs = ` 86 | templates: 87 | template1: 88 | $ref: '#/templates/template2' 89 | template2: 90 | sections: 91 | section1: 92 | heading: 93 | const: "Referenced Heading" 94 | required: true`; 95 | 96 | fs.writeFileSync(tempFile, templateWithRefs); 97 | const result = await loadAndValidateTemplates(tempFile); 98 | 99 | expect(result).to.be.an('object'); 100 | expect(result.template1.sections.section1.heading.const).to.equal('Referenced Heading'); 101 | }); 102 | }); -------------------------------------------------------------------------------- /src/util/getFile.js: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import crypto from "crypto"; 3 | import fs from "fs"; 4 | import os from "os"; 5 | import path from "path"; 6 | import YAML from "yaml"; 7 | import { fileURLToPath } from "url"; 8 | 9 | /** 10 | * Gets content from a URL or file path, processes it, and returns the content or saves to temp directory if remote. 11 | * 12 | * @async 13 | * @function getFile 14 | * @param {string} pathOrUrl - The URL or file path to get content from. 15 | * @returns {Promise} - A promise that resolves to an object containing the result status and content info. 16 | * @property {string} result - The result status, either "success" or "error". 17 | * @property {string} [path] - The path to the content (file path or saved temp file). 18 | * @property {string} [content] - The file content (only for local files). 19 | * @property {string} [message] - The error message (only present if result is "error"). 20 | */ 21 | export async function getFile(pathOrUrl) { 22 | try { 23 | // Check if the input is a URL or file path 24 | const isURL = 25 | pathOrUrl.startsWith("http://") || pathOrUrl.startsWith("https://"); 26 | 27 | let content; 28 | if (isURL) { 29 | // Fetch content from URL 30 | const response = await axios.get(pathOrUrl); 31 | content = response.data; 32 | } else { 33 | // Handle local file path 34 | const currentDir = process.cwd(); 35 | // Convert relative path to absolute if needed 36 | const absolutePath = path.isAbsolute(pathOrUrl) 37 | ? pathOrUrl 38 | : path.resolve(currentDir, pathOrUrl); 39 | 40 | // Check if file exists 41 | if (!fs.existsSync(absolutePath)) { 42 | return { result: "error", message: "File not found" }; 43 | } 44 | 45 | // Read and return file content 46 | content = fs.readFileSync(absolutePath, "utf8"); 47 | pathOrUrl = absolutePath; 48 | } 49 | 50 | if (typeof content === "string") { 51 | // If file is YAML, parse it 52 | if (pathOrUrl.endsWith(".yaml") || pathOrUrl.endsWith(".yml")) { 53 | content = YAML.parse(content); 54 | } else if (pathOrUrl.endsWith(".json")) { 55 | // If file is JSON, parse it 56 | content = JSON.parse(content); 57 | } 58 | } 59 | return { result: "success", content, path: pathOrUrl }; 60 | } catch (error) { 61 | // Return any errors encountered 62 | return { result: "error", message: error }; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/util/getFile.test.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import sinon from 'sinon'; 3 | import axios from 'axios'; 4 | import fs from 'fs'; 5 | import path from 'path'; 6 | import os from 'os'; 7 | import crypto from 'crypto'; 8 | import { getFile } from './getFile.js'; 9 | 10 | describe('getFile', () => { 11 | let sandbox; 12 | 13 | beforeEach(() => { 14 | sandbox = sinon.createSandbox(); 15 | }); 16 | 17 | afterEach(() => { 18 | sandbox.restore(); 19 | }); 20 | 21 | it('should handle local file paths and return content', async () => { 22 | const testContent = 'test file content'; 23 | const testPath = 'test.txt'; 24 | 25 | sandbox.stub(fs, 'existsSync').returns(true); 26 | sandbox.stub(fs, 'readFileSync').returns(testContent); 27 | 28 | const result = await getFile(testPath); 29 | 30 | expect(result.result).to.equal('success'); 31 | expect(result.content).to.equal(testContent); 32 | expect(result.path).to.include(testPath); 33 | }); 34 | 35 | it('should return error for non-existent local files', async () => { 36 | sandbox.stub(fs, 'existsSync').returns(false); 37 | 38 | const result = await getFile('./nonexistent.txt'); 39 | 40 | expect(result.result).to.equal('error'); 41 | expect(result.message).to.equal('File not found'); 42 | }); 43 | 44 | it('should handle string responses from URLs', async () => { 45 | const testUrl = 'https://test.com/data.txt'; 46 | const testData = 'value'; 47 | 48 | sandbox.stub(axios, 'get').resolves({ data: testData }); 49 | sandbox.stub(fs, 'existsSync').returns(false); 50 | sandbox.stub(fs, 'mkdirSync'); 51 | sandbox.stub(fs, 'writeFileSync'); 52 | 53 | const result = await getFile(testUrl); 54 | 55 | expect(typeof result.content).to.equal('string'); 56 | }); 57 | 58 | it('should handle JSON responses from URLs', async () => { 59 | const testUrl = 'https://test.com/data.json'; 60 | const testData = { key: 'value' }; 61 | 62 | sandbox.stub(axios, 'get').resolves({ data: testData }); 63 | sandbox.stub(fs, 'existsSync').returns(false); 64 | sandbox.stub(fs, 'mkdirSync'); 65 | sandbox.stub(fs, 'writeFileSync'); 66 | 67 | const result = await getFile(testUrl); 68 | 69 | expect(typeof result.content).to.equal("object"); 70 | }); 71 | 72 | it('should handle YAML responses from URLs', async () => { 73 | const testUrl = 'https://test.com/data.yaml'; 74 | const testData = 'key: value'; 75 | 76 | sandbox.stub(axios, 'get').resolves({ data: testData }); 77 | sandbox.stub(fs, 'existsSync').returns(false); 78 | sandbox.stub(fs, 'mkdirSync'); 79 | sandbox.stub(fs, 'writeFileSync'); 80 | 81 | const result = await getFile(testUrl); 82 | 83 | expect(typeof result.content).to.equal("object"); 84 | } 85 | ) 86 | 87 | it('should handle errors during file operations', async () => { 88 | sandbox.stub(fs, 'existsSync').throws(new Error('File system error')); 89 | 90 | const result = await getFile('./test.txt'); 91 | 92 | expect(result.result).to.equal('error'); 93 | expect(result.message).to.be.an('error'); 94 | }); 95 | }); -------------------------------------------------------------------------------- /src/util/preloadModel.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { prepareModel } from "../rules/instructionValidator.js"; 4 | import { getLlama } from "node-llama-cpp"; 5 | 6 | // Preload the Llama model if the environment variable is set to something other than 0 7 | if ( 8 | typeof process.env.DOC_STRUCTURE_LINT_PRELOAD !== "undefined" && 9 | process.env.DOC_STRUCTURE_LINT_PRELOAD != 0 10 | ) { 11 | (async () => { 12 | const llama = await getLlama(); 13 | await prepareModel(llama); 14 | })(); 15 | } 16 | -------------------------------------------------------------------------------- /src/util/tempDir.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import fs from "fs"; 4 | import os from "os"; 5 | import path from "path"; 6 | 7 | const dir = path.join(os.tmpdir(), "doc-structure-lint"); 8 | 9 | export function getTempDir() { 10 | // Recursively create the directory if it doesn't exist 11 | if (!fs.existsSync(dir)) { 12 | fs.mkdirSync(dir, { recursive: true }); 13 | } 14 | return dir; 15 | } 16 | 17 | export function cleanTempDir(filesToKeep) { 18 | // Clean up any files in the directory that aren't expected 19 | fs.readdirSync(dir).forEach((file) => { 20 | if (!filesToKeep.includes(file)) { 21 | fs.unlinkSync(path.join(dir, file)); 22 | } 23 | }); 24 | } 25 | 26 | // Only run the cleanup if this file is being executed directly 27 | if (process.argv[1].endsWith("tempDir.js") && process.argv[2] === "clean") { 28 | try { 29 | cleanTempDir([]); 30 | } catch (error) { 31 | console.error("Error during cleanup:", error); 32 | process.exit(1); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /templates.yaml: -------------------------------------------------------------------------------- 1 | doc-structure-lint: 0.0.1 2 | info: 3 | title: Sample Templates 4 | version: 1.0.0 5 | 6 | templates: # Array of templates, each with a unique key 7 | how-to: 8 | sections: 9 | title: 10 | instructions: 11 | - Must mention the intent of the document 12 | paragraphs: 13 | min: 1 14 | sections: 15 | overview: 16 | heading: 17 | const: Overview 18 | paragraphs: 19 | min: 1 20 | before you start: 21 | heading: 22 | const: Before you start 23 | paragraphs: 24 | min: 1 25 | task: 26 | paragraphs: 27 | min: 1 28 | additionalSections: true 29 | sections: 30 | Sub-task: 31 | paragraphs: 32 | min: 1 33 | see also: 34 | heading: 35 | const: See also 36 | paragraphs: 37 | min: 1 38 | api-operation: 39 | sections: 40 | overview: 41 | heading: 42 | const: "Overview" 43 | paragraphs: 44 | min: 1 45 | max: 3 46 | request-parameters: 47 | $ref: "#/components/parameters" 48 | response-parameters: 49 | $ref: "#/components/parameters" 50 | examples: 51 | required: true 52 | code_blocks: 53 | min: 1 54 | sections: 55 | success: 56 | heading: 57 | const: "Success Response" 58 | sequence: 59 | - paragraphs: 60 | min: 1 61 | - code_blocks: 62 | min: 1 63 | error: 64 | required: false 65 | heading: 66 | const: "Error Response" 67 | Sample: # Template definition correspondings to the H1 level of the document 68 | sections: # Array of sections, each with a unique key 69 | Introduction: # Each sections corresponds to a heading level one below the parent, starting with an H1 at the first level 70 | description: A template for how-to guides that include prerequisites, setup, usage, troubleshooting, and next steps sections. 71 | instructions: # Instructions for the author to follow when using the template. Evaluated against content by a local language model. 72 | - Mention the intent of the guide 73 | sequence: 74 | - paragraphs: 75 | min: 2 76 | paragraphs: 77 | min: 2 78 | max: 5 79 | code_blocks: 80 | max: 1 # Allow a maximum of 1 code block 81 | sections: # Array of sections, each with a unique key 82 | Prerequisites: # Each section corresponds to a heading level one below the parent, in this case Prerequisites is an H2 83 | heading: 84 | const: Prerequisites # Require the section to be titled "Prerequisites" 85 | paragraphs: 86 | max: 3 87 | lists: 88 | max: 1 89 | items: 90 | min: 1 91 | max: 7 92 | Setup: 93 | paragraphs: 94 | max: 5 # Allow a maximum of 5 paragraphs 95 | Usage: 96 | paragraphs: 97 | max: 4 98 | code_blocks: 99 | min: 1 # Require at least one code block 100 | additionalSections: true # Allow additional use case sections 101 | sections: 102 | Troubleshooting: 103 | heading: 104 | const: Troubleshooting # Require the section to be titled "Troubleshooting" 105 | paragraphs: 106 | max: 5 107 | Next steps: 108 | $ref: "#/components/sections/Next steps" # Reference the 'Next steps' section component 109 | 110 | components: 111 | sections: 112 | Next steps: 113 | heading: 114 | const: Next steps # Back to an H2 level section 115 | required: false # Allow the section to be omitted 116 | paragraphs: 117 | min: 1 118 | lists: 119 | min: 1 120 | parameters: 121 | required: false 122 | heading: 123 | pattern: "^Parameters|Request Parameters$" 124 | lists: 125 | min: 1 126 | items: 127 | min: 1 128 | --------------------------------------------------------------------------------