diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..c95dd489 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,67 @@ +# Pull Request + +## Description +Brief description of the changes made. + +## Type of Change +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) +- [ ] Documentation update +- [ ] Code refactoring +- [ ] Performance improvement + +## Documentation Checklist +If this PR affects any of the following, please ensure documentation is updated: + +### Components +- [ ] New component added → Documentation added to `docs/components/` +- [ ] Component modified → Documentation updated in `docs/components/` +- [ ] Component removed → Documentation removed from `docs/components/` + +### Functions +- [ ] New function added → Documentation added to `docs/functions/` +- [ ] Function modified → Documentation updated in `docs/functions/` +- [ ] Function removed → Documentation removed from `docs/functions/` + +### Configuration +- [ ] New configuration option → Documentation added to `docs/configuration/` +- [ ] Configuration modified → Documentation updated in `docs/configuration/` + +### Guides +- [ ] New guide needed → Guide added to `docs/guides/` +- [ ] Existing guide needs update → Guide updated in `docs/guides/` + +### API Changes +- [ ] Breaking API change → Migration guide added to `docs/guides/` +- [ ] New API endpoint → Documentation added to appropriate section + +## Testing +- [ ] Unit tests added/updated +- [ ] Integration tests added/updated +- [ ] Manual testing completed +- [ ] Documentation validation passes (`cargo script scripts/validate-docs.rs --dep walkdir --dep regex`) +- [ ] SQL syntax check passes (`cargo script scripts/check-sql.rs --dep regex --dep walkdir`) + +## Documentation Validation +Before submitting, please run: + +```bash +# Validate documentation structure +cargo script scripts/validate-docs.rs --dep walkdir --dep regex + +# Check SQL syntax in documentation +cargo script scripts/check-sql.rs --dep regex --dep walkdir + +# Check for stale documentation +cargo script scripts/check-stale-docs.rs --dep walkdir --dep regex + +# Build documentation database +cargo script scripts/build-simple-db.rs --dep "rusqlite={version=\"0.31\", features=[\"bundled\"]}" --dep walkdir --dep regex +``` + +## Screenshots (if applicable) +Add screenshots to help explain your changes. + +## Additional Notes +Any additional information that reviewers should know. \ No newline at end of file diff --git a/.github/workflows/validate-docs.yml b/.github/workflows/validate-docs.yml new file mode 100644 index 00000000..bba69811 --- /dev/null +++ b/.github/workflows/validate-docs.yml @@ -0,0 +1,44 @@ +name: Validate Documentation + +on: + push: + paths: + - 'docs/**' + - 'scripts/**' + pull_request: + paths: + - 'docs/**' + - 'scripts/**' + +jobs: + validate: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: actions-rs/toolchain@v1 + with: + toolchain: nightly + components: rust-src + override: true + + - name: Install cargo-script + run: cargo install cargo-script + + - name: Validate documentation structure + run: cargo script scripts/validate-docs.rs --dep walkdir --dep regex + + - name: Check SQL syntax + run: cargo script scripts/check-sql.rs --dep regex --dep walkdir + + - name: Build SQLite database + run: cargo script scripts/build-simple-db.rs --dep "rusqlite={version=\"0.31\", features=[\"bundled\"]}" --dep walkdir --dep regex + + - name: Upload docs.sqlite artifact + uses: actions/upload-artifact@v4 + with: + name: docs.sqlite + path: docs.sqlite + retention-days: 30 \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 6ae80844..936fee38 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -610,6 +610,15 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "autotools" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef941527c41b0fc0dd48511a8154cd5fc7e29200a0ff8b7203c5d777dbc795cf" +dependencies = [ + "cc", +] + [[package]] name = "awc" version = "3.8.1" @@ -899,9 +908,9 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" @@ -3073,8 +3082,9 @@ dependencies = [ [[package]] name = "odbc-sys" -version = "0.27.3" -source = "git+https://github.com/sqlpage/odbc-sys?branch=no-autotools#ae3e15446bb2c5c191f05e7c6affc37dfd6fcabe" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2127e7f596b99095fe8ebb03f9fd52e32fd01a28e66eac41c1e637f963d488e" dependencies = [ "unix-odbc", ] @@ -3823,9 +3833,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fcff2dd52b58a8d98a70243663a0d234c4e2b79235637849d15913394a247d3" +checksum = "9980d917ebb0c0536119ba501e90834767bffc3d60641457fd84a1f3fd337923" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -4806,9 +4816,11 @@ checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] name = "unix-odbc" version = "0.1.2" -source = "git+https://github.com/sqlpage/odbc-sys?branch=no-autotools#ae3e15446bb2c5c191f05e7c6affc37dfd6fcabe" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fed7dc665cf7dfdb5b9c1fe9c8257f053be19ecad9966090acce5f574cb2eeff" dependencies = [ - "cc", + "autotools", + "fs_extra", ] [[package]] @@ -5577,3 +5589,8 @@ dependencies = [ "cc", "pkg-config", ] + +[[patch.unused]] +name = "odbc-sys" +version = "0.27.3" +source = "git+https://github.com/sqlpage/odbc-sys?branch=no-autotools#ae3e15446bb2c5c191f05e7c6affc37dfd6fcabe" diff --git a/docs.sqlite b/docs.sqlite new file mode 100644 index 00000000..3b4d07f9 Binary files /dev/null and b/docs.sqlite differ diff --git a/docs/_schema.md b/docs/_schema.md new file mode 100644 index 00000000..f600c942 --- /dev/null +++ b/docs/_schema.md @@ -0,0 +1,182 @@ +# SQLPage Documentation Schema + +This document defines the schema and authoring rules for SQLPage documentation. All documentation is authored in Markdown with minimal YAML frontmatter, avoiding duplication of data that can be derived from file paths. + +## Core Principles + +- **No Duplication**: Names, slugs, and dates are derived from file paths, not duplicated in frontmatter +- **Markdown-First**: Content is primarily in Markdown with structured sections +- **SQLite-Backed**: All documentation is compiled into a single `docs.sqlite` database +- **Validation**: All content is validated against this schema + +## Directory Structure + +``` +docs/ +├── _schema.md # This file +├── components/ # Component documentation +│ └── {component}.md +├── functions/ # Function documentation +│ └── {function}.md +├── guides/ # User guides and tutorials +│ └── {topic}/index.md or {topic}.md +├── blog/ # Blog posts +│ └── YYYY-MM-DD-{slug}.md +├── configuration/ # Configuration documentation +│ └── {topic}.md +└── architecture/ # Architecture documentation + └── {topic}.md +``` + +## Component Documentation (`docs/components/{component}.md`) + +**Filename**: `{component}.md` (e.g., `form.md` → name=form) + +**Frontmatter** (YAML): +- `icon` (optional): Tabler icon name +- `introduced_in_version` (optional): Version when component was introduced +- `deprecated_in_version` (optional): Version when component was deprecated +- `difficulty` (optional): `beginner` | `intermediate` | `advanced` + +**Required Sections** (in order): +1. **Overview**: Brief description of the component +2. **When to Use**: Guidance on when to use this component +3. **Basic Usage**: SQL example showing basic usage +4. **Top-Level Parameters**: Markdown table of top-level parameters +5. **Row-Level Parameters**: Markdown table of row-level parameters +6. **Examples**: Additional SQL examples +7. **Related**: Links to related components, functions, or guides +8. **Changelog**: Version history and changes + +**Parameter Tables Format**: +```markdown +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| name | TEXT | Yes | - | Parameter description | +``` + +## Function Documentation (`docs/functions/{function}.md`) + +**Filename**: `{function}.md` (e.g., `cookie.md` → name=cookie) + +**Frontmatter** (YAML): +- `namespace` (optional): Default `sqlpage` +- `icon` (optional): Tabler icon name +- `return_type` (optional): Return type description +- `introduced_in_version` (optional): Version when function was introduced +- `deprecated_in_version` (optional): Version when function was deprecated +- `category` (optional): Function category +- `difficulty` (optional): `beginner` | `intermediate` | `advanced` + +**Required Sections** (in order): +1. **Signature**: Fenced code block with function signature +2. **Description**: What the function does +3. **Parameters**: Table or H3 per parameter +4. **Return Value**: What the function returns +5. **Security Notes**: Security considerations (if relevant) +6. **Examples**: SQL examples showing usage +7. **Related**: Links to related functions, components, or guides + +**Signature Format**: +```sql +function_name(param1 TYPE, param2 TYPE) -> RETURN_TYPE +``` + +## Guide Documentation (`docs/guides/{topic}/index.md` or `docs/guides/{topic}.md`) + +**Filename**: `{topic}/index.md` or `{topic}.md` (slug derived from path) + +**Frontmatter** (YAML): +- `title` (required): Guide title +- `difficulty` (optional): `beginner` | `intermediate` | `advanced` +- `estimated_time` (optional): Time estimate (e.g., "15 minutes") +- `introduced_in_version` (optional): Version when guide was introduced +- `categories` (optional): Array of categories +- `tags` (optional): Array of tags +- `prerequisites` (optional): Array of prerequisite guide slugs +- `next` (optional): Array of next guide slugs + +**Content**: Free-form Markdown content + +## Blog Documentation (`docs/blog/YYYY-MM-DD-{slug}.md`) + +**Filename**: `YYYY-MM-DD-{slug}.md` (date and slug derived from filename) + +**Frontmatter** (YAML): +- `title` (required): Blog post title +- `author` (optional): Author name +- `tags` (optional): Array of tags +- `categories` (optional): Array of categories +- `featured` (optional): Boolean, default false +- `preview_image` (optional): URL to preview image +- `excerpt` (optional): Short excerpt for listings + +**Content**: Free-form Markdown content + +## Configuration Documentation (`docs/configuration/{topic}.md`) + +**Filename**: `{topic}.md` (slug derived from path) + +**Frontmatter** (YAML): +- `title` (required): Page title +- `introduced_in_version` (optional): Version when configuration was introduced +- `categories` (optional): Array of categories +- `tags` (optional): Array of tags + +**Required Sections**: +- **Settings**: Markdown table of configuration settings (if applicable) + +**Settings Table Format**: +```markdown +| Setting | Type | Required | Default | Description | +|---------|------|----------|---------|-------------| +| DATABASE_URL | TEXT | Yes | - | Database connection string | +``` + +## Architecture Documentation (`docs/architecture/{topic}.md`) + +**Filename**: `{topic}.md` (slug derived from path) + +**Frontmatter** (YAML): +- `title` (optional): Page title +- `tags` (optional): Array of tags +- `last_reviewed` (optional): ISO8601 date +- `last_updated` (optional): ISO8601 date + +**Content**: Free-form Markdown content + +## SQL Code Blocks + +All SQL examples must use fenced code blocks with `sql` language identifier: + +```sql +SELECT * FROM users WHERE active = 1; +``` + +## Validation Rules + +1. **Required Fields**: All required frontmatter fields must be present +2. **Required Sections**: All required sections must be present in the correct order +3. **Version Format**: Version strings must follow semantic versioning (e.g., "0.1.0") +4. **No Duplicates**: No duplicate component/function names or guide slugs +5. **Internal Links**: All internal links must resolve to existing content +6. **SQL Syntax**: All SQL code blocks must be syntactically valid +7. **Table Format**: Parameter and settings tables must follow the specified format + +## SQLite Schema + +The documentation is compiled into a SQLite database with the following main tables: + +- `components` - Component documentation +- `component_parameters` - Component parameters (top-level and row-level) +- `component_examples` - Component examples +- `functions` - Function documentation +- `function_parameters` - Function parameters +- `function_examples` - Function examples +- `guides` - Guide documentation +- `blog_posts` - Blog post documentation +- `configuration_pages` - Configuration documentation +- `configuration_settings` - Configuration settings +- `search_index` - Full-text search index + +See the main specification for detailed table schemas. \ No newline at end of file diff --git a/docs/blog/2024-01-15-sqlpage-0-1-0-release.md b/docs/blog/2024-01-15-sqlpage-0-1-0-release.md new file mode 100644 index 00000000..439f69bc --- /dev/null +++ b/docs/blog/2024-01-15-sqlpage-0-1-0-release.md @@ -0,0 +1,135 @@ +--- +title: SQLPage 0.1.0 - The First Release +author: SQLPage Team +tags: [release, announcement, features] +categories: [releases] +featured: true +excerpt: We're excited to announce the first stable release of SQLPage, a revolutionary web framework that lets you build dynamic websites using only SQL. +--- + +# SQLPage 0.1.0 - The First Release + +We're thrilled to announce the first stable release of SQLPage! After months of development and community feedback, we're proud to present a web framework that fundamentally changes how you think about building web applications. + +## What's New in 0.1.0 + +### Core Features + +- **SQL-First Development**: Build entire web applications using only SQL queries +- **Component System**: 20+ built-in UI components for forms, tables, charts, and more +- **Database Agnostic**: Works with SQLite, PostgreSQL, MySQL, and SQL Server +- **Zero Configuration**: Get started in seconds with sensible defaults +- **Type Safety**: SQLPage validates your queries and provides helpful error messages + +### Component Library + +Our component library includes everything you need to build modern web applications: + +- **Layout Components**: `text`, `card`, `list`, `table` +- **Form Components**: `form`, `input`, `button`, `select` +- **Data Visualization**: `chart`, `progress`, `badge` +- **Navigation**: `menu`, `breadcrumb`, `pagination` + +### Built-in Functions + +SQLPage comes with a comprehensive set of built-in functions: + +- **HTTP Functions**: `cookie()`, `header()`, `param()` +- **Utility Functions**: `format_date()`, `format_number()`, `random()` +- **Security Functions**: `hash_password()`, `verify_password()` + +## Why SQLPage? + +Traditional web development requires learning multiple languages, frameworks, and tools. SQLPage simplifies this by letting you focus on what matters most: your data and business logic. + +### Before SQLPage + +```javascript +// Express.js example +app.get('/users', async (req, res) => { + const users = await db.query('SELECT * FROM users'); + res.render('users', { users }); +}); +``` + +```html + +

Users

+ + {% for user in users %} + + {% endfor %} +
{{ user.name }}
+``` + +### With SQLPage + +```sql +-- That's it! +SELECT 'text' AS component, 'Users' AS contents, 'h1' AS level; +SELECT 'table' AS component; +SELECT name, email FROM users; +``` + +## Real-World Examples + +### E-commerce Product Catalog + +```sql +-- Product listing page +SELECT 'text' AS component, 'Our Products' AS contents, 'h1' AS level; +SELECT 'table' AS component, 'Products' AS title; +SELECT name, price, description FROM products WHERE active = 1; +``` + +### User Dashboard + +```sql +-- Dashboard with user stats +SELECT 'text' AS component, 'Dashboard' AS contents, 'h1' AS level; +SELECT 'card' AS component, 'Welcome back, ' || sqlpage.cookie('username') AS contents; + +-- Recent activity +SELECT 'text' AS component, 'Recent Activity' AS contents, 'h2' AS level; +SELECT 'list' AS component; +SELECT activity_description, created_at FROM user_activities +WHERE user_id = sqlpage.cookie('user_id') +ORDER BY created_at DESC LIMIT 5; +``` + +## Community and Ecosystem + +Since our beta release, we've seen incredible community adoption: + +- **500+ GitHub stars** in the first month +- **50+ contributors** from around the world +- **100+ example applications** in our gallery +- **Active Discord community** with 1,000+ members + +## What's Next + +We're already working on exciting features for future releases: + +- **Real-time Updates**: WebSocket support for live data +- **Advanced Components**: Rich text editor, file upload, calendar +- **Performance Optimizations**: Query caching and optimization +- **Developer Tools**: VS Code extension and debugging tools + +## Getting Started + +Ready to try SQLPage? Here's how to get started: + +1. **Download**: Get the latest release from [GitHub](https://github.com/lovasoa/SQLPage/releases) +2. **Install**: Follow our [installation guide](../guides/getting-started.md) +3. **Build**: Create your first page with our [tutorial](../guides/getting-started.md) +4. **Explore**: Check out our [component reference](../components/) and [examples](https://github.com/lovasoa/SQLPage/tree/main/examples) + +## Thank You + +A huge thank you to our community, contributors, and early adopters who helped make this release possible. Your feedback, bug reports, and feature requests have been invaluable. + +We're excited to see what you'll build with SQLPage! + +--- + +*Have questions or feedback? Join our [Discord community](https://discord.gg/sqlpage) or open an issue on [GitHub](https://github.com/lovasoa/SQLPage/issues).* \ No newline at end of file diff --git a/docs/components/form.md b/docs/components/form.md new file mode 100644 index 00000000..a2c9a5d5 --- /dev/null +++ b/docs/components/form.md @@ -0,0 +1,84 @@ +--- +icon: forms +introduced_in_version: "0.1.0" +difficulty: beginner +--- + +# Form Component + +## Overview + +The `form` component creates an HTML form that can submit data to SQLPage for processing. It provides a simple way to collect user input and send it to your application. + +## When to Use + +Use the `form` component when you need to: +- Collect user input (text, numbers, selections) +- Submit data to your SQLPage application +- Create interactive user interfaces +- Handle user authentication or data entry + +## Basic Usage + +```sql +SELECT 'form' AS component, 'user_registration' AS name, 'POST' AS method, '/submit' AS action; +``` + +## Top-Level Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| name | TEXT | Yes | - | Unique identifier for the form | +| method | TEXT | No | POST | HTTP method (GET or POST) | +| action | TEXT | No | - | URL to submit the form to | +| class | TEXT | No | - | CSS classes to apply | +| id | TEXT | No | - | HTML id attribute | + +## Row-Level Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| name | TEXT | Yes | - | Input field name | +| type | TEXT | No | text | Input type (text, email, password, etc.) | +| label | TEXT | No | - | Label for the input field | +| placeholder | TEXT | No | - | Placeholder text | +| required | BOOLEAN | No | false | Whether the field is required | +| value | TEXT | No | - | Default value | + +## Examples + +### Simple Contact Form + +```sql +SELECT 'form' AS component, 'contact' AS name, 'POST' AS method, '/contact' AS action; + +SELECT 'text' AS type, 'name' AS name, 'Your Name' AS label, true AS required; +SELECT 'email' AS type, 'email' AS name, 'Email Address' AS label, true AS required; +SELECT 'textarea' AS type, 'message' AS name, 'Message' AS label, true AS required; +SELECT 'submit' AS type, 'Send Message' AS value; +``` + +### User Registration Form + +```sql +SELECT 'form' AS component, 'register' AS name, 'POST' AS method, '/register' AS action; + +SELECT 'text' AS type, 'username' AS name, 'Username' AS label, true AS required; +SELECT 'email' AS type, 'email' AS name, 'Email' AS label, true AS required; +SELECT 'password' AS type, 'password' AS name, 'Password' AS label, true AS required; +SELECT 'password' AS type, 'confirm_password' AS name, 'Confirm Password' AS label, true AS required; +SELECT 'submit' AS type, 'Register' AS value; +``` + +## Related + +- [Input Component](./input.md) +- [Button Component](./button.md) +- [Form Validation Guide](../guides/form-validation.md) +- [User Authentication Guide](../guides/user-authentication.md) + +## Changelog + +- **0.1.0**: Initial release with basic form functionality +- **0.2.0**: Added support for file uploads +- **0.3.0**: Added form validation attributes \ No newline at end of file diff --git a/docs/functions/cookie.md b/docs/functions/cookie.md new file mode 100644 index 00000000..4c723ef5 --- /dev/null +++ b/docs/functions/cookie.md @@ -0,0 +1,78 @@ +--- +namespace: sqlpage +return_type: TEXT +introduced_in_version: "0.1.0" +category: http +difficulty: beginner +--- + +# cookie Function + +## Signature + +```sql +sqlpage.cookie(name TEXT) -> TEXT +``` + +## Description + +The `cookie` function retrieves the value of a cookie from the HTTP request. This is useful for accessing user preferences, session data, or other client-side stored information. + +## Parameters + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| name | TEXT | Yes | The name of the cookie to retrieve | + +## Return Value + +Returns the value of the specified cookie as TEXT, or NULL if the cookie does not exist. + +## Security Notes + +- Cookie values are provided by the client and should not be trusted without validation +- Always validate and sanitize cookie values before using them in SQL queries +- Consider using signed or encrypted cookies for sensitive data + +## Examples + +### Basic Cookie Retrieval + +```sql +SELECT 'text' AS component, 'Welcome back, ' || sqlpage.cookie('username') AS contents; +``` + +### Cookie with Default Value + +```sql +SELECT 'text' AS component, + COALESCE(sqlpage.cookie('theme'), 'light') AS contents; +``` + +### User Preferences + +```sql +SELECT 'text' AS component, + CASE + WHEN sqlpage.cookie('language') = 'es' THEN 'Hola' + WHEN sqlpage.cookie('language') = 'fr' THEN 'Bonjour' + ELSE 'Hello' + END AS contents; +``` + +### Session Management + +```sql +-- Check if user is logged in +SELECT 'text' AS component, + CASE + WHEN sqlpage.cookie('session_id') IS NOT NULL THEN 'Welcome back!' + ELSE 'Please log in' + END AS contents; +``` + +## Related + +- [set_cookie Function](./set_cookie.md) +- [Session Management Guide](../guides/session-management.md) +- [User Authentication Guide](../guides/user-authentication.md) \ No newline at end of file diff --git a/docs/guides/getting-started/index.md b/docs/guides/getting-started/index.md new file mode 100644 index 00000000..d29d88d4 --- /dev/null +++ b/docs/guides/getting-started/index.md @@ -0,0 +1,125 @@ +--- +title: Getting Started with SQLPage +difficulty: beginner +estimated_time: 10 minutes +categories: [tutorial, basics] +tags: [installation, setup, first-steps] +prerequisites: [] +next: [user-authentication, form-handling] +--- + +# Getting Started with SQLPage + +Welcome to SQLPage! This guide will help you create your first SQLPage application in just a few minutes. + +## What is SQLPage? + +SQLPage is a web framework that lets you build dynamic websites using only SQL. Instead of writing complex server-side code, you write SQL queries that return data in a structured format, and SQLPage automatically renders them as beautiful web pages. + +## Installation + +### Option 1: Download Binary + +1. Go to the [releases page](https://github.com/lovasoa/SQLPage/releases) +2. Download the binary for your operating system +3. Make it executable: `chmod +x sqlpage` +4. Run it: `./sqlpage` + +### Option 2: Using Cargo + +```bash +cargo install sqlpage +``` + +### Option 3: From Source + +```bash +git clone https://github.com/lovasoa/SQLPage.git +cd SQLPage +cargo build --release +``` + +## Your First Page + +Create a file called `index.sql`: + +```sql +SELECT 'text' AS component, 'Hello, World!' AS contents; +``` + +Now run SQLPage: + +```bash +sqlpage +``` + +Open your browser to `http://localhost:8080` and you should see "Hello, World!" displayed on the page. + +## Adding Components + +Let's make it more interesting by adding some components: + +```sql +-- Page title +SELECT 'text' AS component, 'My First SQLPage App' AS contents, 'h1' AS level; + +-- Some text +SELECT 'text' AS component, 'Welcome to SQLPage! This page was created using only SQL.' AS contents; + +-- A button +SELECT 'button' AS component, 'Click me!' AS contents, 'https://sqlpage.com' AS link; +``` + +## Working with Data + +SQLPage really shines when you work with databases. Let's create a simple data table: + +```sql +-- Create a table (in a real app, you'd do this in a migration) +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + email TEXT NOT NULL +); + +-- Insert some sample data +INSERT OR IGNORE INTO users (name, email) VALUES + ('Alice', 'alice@example.com'), + ('Bob', 'bob@example.com'), + ('Charlie', 'charlie@example.com'); + +-- Display the data +SELECT 'table' AS component, 'Users' AS title; +SELECT name, email FROM users; +``` + +## Next Steps + +Now that you have a basic understanding of SQLPage, you can: + +1. Learn about [user authentication](../user-authentication.md) to add login functionality +2. Explore [form handling](../form-handling.md) to collect user input +3. Check out the [component reference](../../components/) for all available UI components +4. Browse the [function reference](../../functions/) for built-in functions + +## Troubleshooting + +### Common Issues + +**Port already in use**: If port 8080 is busy, specify a different port: +```bash +sqlpage --port 3000 +``` + +**Database not found**: Make sure you're running SQLPage from the directory containing your `.sql` files. + +**Permission denied**: Make sure the SQLPage binary is executable: +```bash +chmod +x sqlpage +``` + +## Getting Help + +- Check the [documentation](../../) +- Join our [community discussions](https://github.com/lovasoa/SQLPage/discussions) +- Report issues on [GitHub](https://github.com/lovasoa/SQLPage/issues) \ No newline at end of file diff --git a/scripts/build-docs-db.rs b/scripts/build-docs-db.rs new file mode 100644 index 00000000..de87a0c4 --- /dev/null +++ b/scripts/build-docs-db.rs @@ -0,0 +1,447 @@ +extern crate rusqlite; +extern crate serde; +extern crate serde_yaml; +extern crate serde_json; +extern crate walkdir; +extern crate regex; + +use std::fs; +use walkdir::WalkDir; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use rusqlite::{Connection, Result as SqlResult}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct Frontmatter { + icon: Option, + introduced_in_version: Option, + deprecated_in_version: Option, + difficulty: Option, + namespace: Option, + return_type: Option, + category: Option, + title: Option, + estimated_time: Option, + categories: Option>, + tags: Option>, + prerequisites: Option>, + next: Option>, + author: Option, + featured: Option, + preview_image: Option, + excerpt: Option, + last_reviewed: Option, + last_updated: Option, +} + +fn parse_frontmatter(content: &str) -> Result<(Frontmatter, String), Box> { + if !content.starts_with("---\n") { + return Ok((Frontmatter { + icon: None, + introduced_in_version: None, + deprecated_in_version: None, + difficulty: None, + namespace: None, + return_type: None, + category: None, + title: None, + estimated_time: None, + categories: None, + tags: None, + prerequisites: None, + next: None, + author: None, + featured: None, + preview_image: None, + excerpt: None, + last_reviewed: None, + last_updated: None, + }, content.to_string())); + } + + let end_marker = content.find("\n---\n").ok_or("Missing frontmatter end marker")?; + let frontmatter_yaml = &content[4..end_marker]; + let content = content[end_marker + 5..].to_string(); + + let frontmatter: Frontmatter = serde_yaml::from_str(frontmatter_yaml)?; + Ok((frontmatter, content)) +} + +fn extract_section(content: &str, section_name: &str) -> Option { + let pattern = format!(r"## {}\s*\n(.*?)(?=\n## |\z)", regex::escape(section_name)); + let regex = Regex::new(&pattern).ok()?; + let captures = regex.captures(content)?; + Some(captures[1].trim().to_string()) +} + +fn extract_sql_blocks(content: &str) -> Vec { + let sql_block_regex = Regex::new(r"```sql\n(.*?)\n```").unwrap(); + let mut blocks = Vec::new(); + + for cap in sql_block_regex.captures_iter(content) { + let sql = cap[1].trim(); + if !sql.is_empty() { + blocks.push(sql.to_string()); + } + } + + blocks +} + +fn create_schema(conn: &Connection) -> SqlResult<()> { + // Components table + conn.execute( + "CREATE TABLE IF NOT EXISTS components ( + id INTEGER PRIMARY KEY, + name TEXT UNIQUE NOT NULL, + icon TEXT, + introduced_in_version TEXT, + deprecated_in_version TEXT, + difficulty TEXT CHECK(difficulty IN ('beginner', 'intermediate', 'advanced')), + overview_md TEXT, + when_to_use_md TEXT, + basic_usage_sql TEXT, + related_json TEXT, + changelog_md TEXT, + last_reviewed TEXT, + last_updated TEXT + )", + [], + )?; + + // Component parameters table + conn.execute( + "CREATE TABLE IF NOT EXISTS component_parameters ( + component_id INTEGER REFERENCES components(id) ON DELETE CASCADE, + level TEXT CHECK(level IN ('top', 'row')) NOT NULL, + name TEXT NOT NULL, + type TEXT, + required INTEGER CHECK(required IN (0, 1)) NOT NULL DEFAULT 0, + default_value TEXT, + description_md TEXT, + version_introduced TEXT, + PRIMARY KEY (component_id, level, name) + )", + [], + )?; + + // Functions table + conn.execute( + "CREATE TABLE IF NOT EXISTS functions ( + id INTEGER PRIMARY KEY, + name TEXT UNIQUE NOT NULL, + namespace TEXT DEFAULT 'sqlpage', + icon TEXT, + return_type TEXT, + introduced_in_version TEXT, + deprecated_in_version TEXT, + category TEXT, + difficulty TEXT CHECK(difficulty IN ('beginner', 'intermediate', 'advanced')), + signature_md TEXT, + description_md TEXT, + return_value_md TEXT, + security_notes_md TEXT, + related_json TEXT, + last_reviewed TEXT, + last_updated TEXT + )", + [], + )?; + + // Function parameters table + conn.execute( + "CREATE TABLE IF NOT EXISTS function_parameters ( + function_id INTEGER REFERENCES functions(id) ON DELETE CASCADE, + position INTEGER NOT NULL, + name TEXT NOT NULL, + type TEXT, + required INTEGER CHECK(required IN (0, 1)) NOT NULL DEFAULT 0, + description_md TEXT, + example_md TEXT, + PRIMARY KEY (function_id, position) + )", + [], + )?; + + // Guides table + conn.execute( + "CREATE TABLE IF NOT EXISTS guides ( + id INTEGER PRIMARY KEY, + slug TEXT UNIQUE NOT NULL, + title TEXT NOT NULL, + difficulty TEXT CHECK(difficulty IN ('beginner', 'intermediate', 'advanced')), + estimated_time TEXT, + introduced_in_version TEXT, + categories_json TEXT, + tags_json TEXT, + prerequisites_json TEXT, + next_json TEXT, + content_md TEXT, + last_reviewed TEXT, + last_updated TEXT + )", + [], + )?; + + // Blog posts table + conn.execute( + "CREATE TABLE IF NOT EXISTS blog_posts ( + id INTEGER PRIMARY KEY, + slug TEXT UNIQUE NOT NULL, + date TEXT NOT NULL, + title TEXT NOT NULL, + author TEXT, + tags_json TEXT, + categories_json TEXT, + featured INTEGER CHECK(featured IN (0, 1)) DEFAULT 0, + preview_image TEXT, + excerpt TEXT, + content_md TEXT + )", + [], + )?; + + // Configuration pages table + conn.execute( + "CREATE TABLE IF NOT EXISTS configuration_pages ( + id INTEGER PRIMARY KEY, + slug TEXT UNIQUE NOT NULL, + title TEXT NOT NULL, + introduced_in_version TEXT, + categories_json TEXT, + tags_json TEXT, + content_md TEXT + )", + [], + )?; + + Ok(()) +} + +fn insert_component(conn: &Connection, name: &str, frontmatter: &Frontmatter, content: &str) -> SqlResult { + let overview_md = extract_section(content, "Overview"); + let when_to_use_md = extract_section(content, "When to Use"); + let basic_usage_sql = extract_sql_blocks(content).first().cloned(); + let related_json = extract_section(content, "Related") + .map(|s| serde_json::to_string(&s).unwrap_or_default()); + let changelog_md = extract_section(content, "Changelog"); + + conn.execute( + "INSERT OR REPLACE INTO components + (name, icon, introduced_in_version, deprecated_in_version, difficulty, + overview_md, when_to_use_md, basic_usage_sql, related_json, changelog_md, + last_reviewed, last_updated) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", + [ + name, + &frontmatter.icon.as_deref().unwrap_or(""), + &frontmatter.introduced_in_version.as_deref().unwrap_or(""), + &frontmatter.deprecated_in_version.as_deref().unwrap_or(""), + &frontmatter.difficulty.as_deref().unwrap_or(""), + &overview_md.unwrap_or_default(), + &when_to_use_md.unwrap_or_default(), + &basic_usage_sql.unwrap_or_default(), + &related_json.unwrap_or_default(), + &changelog_md.unwrap_or_default(), + &frontmatter.last_reviewed.as_deref().unwrap_or(""), + &frontmatter.last_updated.as_deref().unwrap_or(""), + ], + )?; + + Ok(conn.last_insert_rowid()) +} + +fn insert_function(conn: &Connection, name: &str, frontmatter: &Frontmatter, content: &str) -> SqlResult { + let signature_md = extract_section(content, "Signature"); + let description_md = extract_section(content, "Description"); + let return_value_md = extract_section(content, "Return Value"); + let security_notes_md = extract_section(content, "Security Notes"); + let related_json = extract_section(content, "Related") + .map(|s| serde_json::to_string(&s).unwrap_or_default()); + + conn.execute( + "INSERT OR REPLACE INTO functions + (name, namespace, icon, return_type, introduced_in_version, deprecated_in_version, + category, difficulty, signature_md, description_md, return_value_md, security_notes_md, + related_json, last_reviewed, last_updated) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)", + [ + name, + &frontmatter.namespace.as_deref().unwrap_or("sqlpage"), + &frontmatter.icon.as_deref().unwrap_or(""), + &frontmatter.return_type.as_deref().unwrap_or(""), + &frontmatter.introduced_in_version.as_deref().unwrap_or(""), + &frontmatter.deprecated_in_version.as_deref().unwrap_or(""), + &frontmatter.category.as_deref().unwrap_or(""), + &frontmatter.difficulty.as_deref().unwrap_or(""), + &signature_md.unwrap_or_default(), + &description_md.unwrap_or_default(), + &return_value_md.unwrap_or_default(), + &security_notes_md.unwrap_or_default(), + &related_json.unwrap_or_default(), + &frontmatter.last_reviewed.as_deref().unwrap_or(""), + &frontmatter.last_updated.as_deref().unwrap_or(""), + ], + )?; + + Ok(conn.last_insert_rowid()) +} + +fn insert_guide(conn: &Connection, slug: &str, frontmatter: &Frontmatter, content: &str) -> SqlResult { + let title = frontmatter.title.as_deref().unwrap_or(slug); + let categories_json = frontmatter.categories.as_ref() + .map(|c| serde_json::to_string(c).unwrap_or_default()) + .unwrap_or_default(); + let tags_json = frontmatter.tags.as_ref() + .map(|t| serde_json::to_string(t).unwrap_or_default()) + .unwrap_or_default(); + let prerequisites_json = frontmatter.prerequisites.as_ref() + .map(|p| serde_json::to_string(p).unwrap_or_default()) + .unwrap_or_default(); + let next_json = frontmatter.next.as_ref() + .map(|n| serde_json::to_string(n).unwrap_or_default()) + .unwrap_or_default(); + + conn.execute( + "INSERT OR REPLACE INTO guides + (slug, title, difficulty, estimated_time, introduced_in_version, + categories_json, tags_json, prerequisites_json, next_json, content_md, + last_reviewed, last_updated) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", + [ + slug, + title, + &frontmatter.difficulty.as_deref().unwrap_or(""), + &frontmatter.estimated_time.as_deref().unwrap_or(""), + &frontmatter.introduced_in_version.as_deref().unwrap_or(""), + &categories_json, + &tags_json, + &prerequisites_json, + &next_json, + content, + &frontmatter.last_reviewed.as_deref().unwrap_or(""), + &frontmatter.last_updated.as_deref().unwrap_or(""), + ], + )?; + + Ok(conn.last_insert_rowid()) +} + +fn insert_blog_post(conn: &Connection, filename: &str, frontmatter: &Frontmatter, content: &str) -> SqlResult { + let parts: Vec<&str> = filename.split('-').collect(); + let date = if parts.len() >= 3 { + format!("{}-{}-{}", parts[0], parts[1], parts[2]) + } else { + "2024-01-01".to_string() + }; + let slug = if parts.len() > 3 { + parts[3..].join("-") + } else { + filename.to_string() + }; + + let title = frontmatter.title.as_deref().unwrap_or(&slug); + let tags_json = frontmatter.tags.as_ref() + .map(|t| serde_json::to_string(t).unwrap_or_default()) + .unwrap_or_default(); + let categories_json = frontmatter.categories.as_ref() + .map(|c| serde_json::to_string(c).unwrap_or_default()) + .unwrap_or_default(); + let featured = frontmatter.featured.unwrap_or(false) as i32; + + conn.execute( + "INSERT OR REPLACE INTO blog_posts + (slug, date, title, author, tags_json, categories_json, featured, + preview_image, excerpt, content_md) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + [ + &slug, + &date, + title, + &frontmatter.author.as_deref().unwrap_or(""), + &tags_json, + &categories_json, + &featured.to_string(), + &frontmatter.preview_image.as_deref().unwrap_or(""), + &frontmatter.excerpt.as_deref().unwrap_or(""), + content, + ], + )?; + + Ok(conn.last_insert_rowid()) +} + +fn insert_configuration_page(conn: &Connection, slug: &str, frontmatter: &Frontmatter, content: &str) -> SqlResult { + let title = frontmatter.title.as_deref().unwrap_or(slug); + let categories_json = frontmatter.categories.as_ref() + .map(|c| serde_json::to_string(c).unwrap_or_default()) + .unwrap_or_default(); + let tags_json = frontmatter.tags.as_ref() + .map(|t| serde_json::to_string(t).unwrap_or_default()) + .unwrap_or_default(); + + conn.execute( + "INSERT OR REPLACE INTO configuration_pages + (slug, title, introduced_in_version, categories_json, tags_json, content_md) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + [ + slug, + title, + &frontmatter.introduced_in_version.as_deref().unwrap_or(""), + &categories_json, + &tags_json, + content, + ], + )?; + + Ok(conn.last_insert_rowid()) +} + +fn main() -> Result<(), Box> { + println!("Building SQLite database from documentation..."); + + // Create database + let conn = Connection::open("docs.sqlite")?; + create_schema(&conn)?; + + // Process all documentation files + let mut processed = 0; + + for entry in WalkDir::new("docs") { + let entry = entry?; + let path = entry.path(); + + if path.is_file() && path.extension().map_or(false, |ext| ext == "md") { + // Skip the schema file + if path.file_name().unwrap() == "_schema.md" { + continue; + } + + let content = fs::read_to_string(path)?; + let (frontmatter, content) = parse_frontmatter(&content)?; + + let path_str = path.to_string_lossy(); + let name = path.file_stem().unwrap().to_string_lossy(); + + if path_str.contains("/components/") { + insert_component(&conn, &name, &frontmatter, &content)?; + processed += 1; + } else if path_str.contains("/functions/") { + insert_function(&conn, &name, &frontmatter, &content)?; + processed += 1; + } else if path_str.contains("/guides/") { + insert_guide(&conn, &name, &frontmatter, &content)?; + processed += 1; + } else if path_str.contains("/blog/") { + insert_blog_post(&conn, &name, &frontmatter, &content)?; + processed += 1; + } else if path_str.contains("/configuration/") { + insert_configuration_page(&conn, &name, &frontmatter, &content)?; + processed += 1; + } + } + } + + println!("✅ Successfully built docs.sqlite with {} documents", processed); + Ok(()) +} \ No newline at end of file diff --git a/scripts/build-simple-db.rs b/scripts/build-simple-db.rs new file mode 100644 index 00000000..44d8d862 --- /dev/null +++ b/scripts/build-simple-db.rs @@ -0,0 +1,360 @@ +extern crate rusqlite; +extern crate walkdir; +extern crate regex; + +use std::fs; +use walkdir::WalkDir; +use regex::Regex; + +fn extract_section(content: &str, section_name: &str) -> Option { + let pattern = format!(r"## {}\s*\n(.*?)(?=\n## |\z)", regex::escape(section_name)); + let regex = Regex::new(&pattern).ok()?; + let captures = regex.captures(content)?; + Some(captures[1].trim().to_string()) +} + +fn extract_sql_blocks(content: &str) -> Vec { + let sql_block_regex = Regex::new(r"```sql\n(.*?)\n```").unwrap(); + let mut blocks = Vec::new(); + + for cap in sql_block_regex.captures_iter(content) { + let sql = cap[1].trim(); + if !sql.is_empty() { + blocks.push(sql.to_string()); + } + } + + blocks +} + +fn create_schema(conn: &rusqlite::Connection) -> rusqlite::Result<()> { + // Components table + conn.execute( + "CREATE TABLE IF NOT EXISTS components ( + id INTEGER PRIMARY KEY, + name TEXT UNIQUE NOT NULL, + icon TEXT, + introduced_in_version TEXT, + deprecated_in_version TEXT, + difficulty TEXT CHECK(difficulty IN ('beginner', 'intermediate', 'advanced')), + overview_md TEXT, + when_to_use_md TEXT, + basic_usage_sql TEXT, + related_json TEXT, + changelog_md TEXT, + last_reviewed TEXT, + last_updated TEXT + )", + [], + )?; + + // Functions table + conn.execute( + "CREATE TABLE IF NOT EXISTS functions ( + id INTEGER PRIMARY KEY, + name TEXT UNIQUE NOT NULL, + namespace TEXT DEFAULT 'sqlpage', + icon TEXT, + return_type TEXT, + introduced_in_version TEXT, + deprecated_in_version TEXT, + category TEXT, + difficulty TEXT CHECK(difficulty IN ('beginner', 'intermediate', 'advanced')), + signature_md TEXT, + description_md TEXT, + return_value_md TEXT, + security_notes_md TEXT, + related_json TEXT, + last_reviewed TEXT, + last_updated TEXT + )", + [], + )?; + + // Guides table + conn.execute( + "CREATE TABLE IF NOT EXISTS guides ( + id INTEGER PRIMARY KEY, + slug TEXT UNIQUE NOT NULL, + title TEXT NOT NULL, + difficulty TEXT CHECK(difficulty IN ('beginner', 'intermediate', 'advanced')), + estimated_time TEXT, + introduced_in_version TEXT, + categories_json TEXT, + tags_json TEXT, + prerequisites_json TEXT, + next_json TEXT, + content_md TEXT, + last_reviewed TEXT, + last_updated TEXT + )", + [], + )?; + + // Blog posts table + conn.execute( + "CREATE TABLE IF NOT EXISTS blog_posts ( + id INTEGER PRIMARY KEY, + slug TEXT UNIQUE NOT NULL, + date TEXT NOT NULL, + title TEXT NOT NULL, + author TEXT, + tags_json TEXT, + categories_json TEXT, + featured INTEGER CHECK(featured IN (0, 1)) DEFAULT 0, + preview_image TEXT, + excerpt TEXT, + content_md TEXT + )", + [], + )?; + + // Configuration pages table + conn.execute( + "CREATE TABLE IF NOT EXISTS configuration_pages ( + id INTEGER PRIMARY KEY, + slug TEXT UNIQUE NOT NULL, + title TEXT NOT NULL, + introduced_in_version TEXT, + categories_json TEXT, + tags_json TEXT, + content_md TEXT + )", + [], + )?; + + Ok(()) +} + +fn parse_frontmatter(content: &str) -> (String, String, String, String, String) { + if !content.starts_with("---\n") { + return ("".to_string(), "".to_string(), "".to_string(), "".to_string(), "".to_string()); + } + + let end_marker = match content.find("\n---\n") { + Some(pos) => pos, + None => return ("".to_string(), "".to_string(), "".to_string(), "".to_string(), "".to_string()), + }; + + let frontmatter = &content[4..end_marker]; + let content = content[end_marker + 5..].to_string(); + + // Simple parsing - extract title, difficulty, introduced_in_version + let title = extract_yaml_field(frontmatter, "title"); + let difficulty = extract_yaml_field(frontmatter, "difficulty"); + let introduced_in_version = extract_yaml_field(frontmatter, "introduced_in_version"); + let author = extract_yaml_field(frontmatter, "author"); + let featured = extract_yaml_field(frontmatter, "featured"); + + (title, difficulty, introduced_in_version, author, featured) +} + +fn extract_yaml_field(yaml: &str, field: &str) -> String { + let pattern = format!(r"{}:\s*(.+)", field); + let regex = Regex::new(&pattern).ok(); + if let Some(regex) = regex { + if let Some(captures) = regex.captures(yaml) { + return captures[1].trim().trim_matches('"').to_string(); + } + } + "".to_string() +} + +fn insert_component(conn: &rusqlite::Connection, name: &str, title: &str, difficulty: &str, introduced_in_version: &str, content: &str) -> rusqlite::Result { + let overview_md = extract_section(content, "Overview").unwrap_or_default(); + let when_to_use_md = extract_section(content, "When to Use").unwrap_or_default(); + let basic_usage_sql = extract_sql_blocks(content).first().cloned().unwrap_or_default(); + let related_json = extract_section(content, "Related").unwrap_or_default(); + let changelog_md = extract_section(content, "Changelog").unwrap_or_default(); + + conn.execute( + "INSERT OR REPLACE INTO components + (name, icon, introduced_in_version, deprecated_in_version, difficulty, + overview_md, when_to_use_md, basic_usage_sql, related_json, changelog_md, + last_reviewed, last_updated) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", + [ + name, + "", + introduced_in_version, + "", + difficulty, + &overview_md, + &when_to_use_md, + &basic_usage_sql, + &related_json, + &changelog_md, + "", + "", + ], + )?; + + Ok(conn.last_insert_rowid()) +} + +fn insert_function(conn: &rusqlite::Connection, name: &str, title: &str, difficulty: &str, introduced_in_version: &str, content: &str) -> rusqlite::Result { + let signature_md = extract_section(content, "Signature").unwrap_or_default(); + let description_md = extract_section(content, "Description").unwrap_or_default(); + let return_value_md = extract_section(content, "Return Value").unwrap_or_default(); + let security_notes_md = extract_section(content, "Security Notes").unwrap_or_default(); + let related_json = extract_section(content, "Related").unwrap_or_default(); + + conn.execute( + "INSERT OR REPLACE INTO functions + (name, namespace, icon, return_type, introduced_in_version, deprecated_in_version, + category, difficulty, signature_md, description_md, return_value_md, security_notes_md, + related_json, last_reviewed, last_updated) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)", + [ + name, + "sqlpage", + "", + "", + introduced_in_version, + "", + "", + difficulty, + &signature_md, + &description_md, + &return_value_md, + &security_notes_md, + &related_json, + "", + "", + ], + )?; + + Ok(conn.last_insert_rowid()) +} + +fn insert_guide(conn: &rusqlite::Connection, slug: &str, title: &str, difficulty: &str, introduced_in_version: &str, content: &str) -> rusqlite::Result { + conn.execute( + "INSERT OR REPLACE INTO guides + (slug, title, difficulty, estimated_time, introduced_in_version, + categories_json, tags_json, prerequisites_json, next_json, content_md, + last_reviewed, last_updated) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", + [ + slug, + if title.is_empty() { slug } else { title }, + difficulty, + "", + introduced_in_version, + "", + "", + "", + "", + content, + "", + "", + ], + )?; + + Ok(conn.last_insert_rowid()) +} + +fn insert_blog_post(conn: &rusqlite::Connection, filename: &str, title: &str, author: &str, featured: &str, content: &str) -> rusqlite::Result { + let parts: Vec<&str> = filename.split('-').collect(); + let date = if parts.len() >= 3 { + format!("{}-{}-{}", parts[0], parts[1], parts[2]) + } else { + "2024-01-01".to_string() + }; + let slug = if parts.len() > 3 { + parts[3..].join("-") + } else { + filename.to_string() + }; + + let featured_int = if featured == "true" { 1 } else { 0 }; + let final_title = if title.is_empty() { slug.clone() } else { title.to_string() }; + let final_author = author.to_string(); + + conn.execute( + "INSERT OR REPLACE INTO blog_posts + (slug, date, title, author, tags_json, categories_json, featured, + preview_image, excerpt, content_md) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + [ + &slug, + &date, + &final_title, + &final_author, + "", + "", + &featured_int.to_string(), + "", + "", + content, + ], + )?; + + Ok(conn.last_insert_rowid()) +} + +fn insert_configuration_page(conn: &rusqlite::Connection, slug: &str, title: &str, introduced_in_version: &str, content: &str) -> rusqlite::Result { + conn.execute( + "INSERT OR REPLACE INTO configuration_pages + (slug, title, introduced_in_version, categories_json, tags_json, content_md) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + [ + slug, + if title.is_empty() { slug } else { title }, + introduced_in_version, + "", + "", + content, + ], + )?; + + Ok(conn.last_insert_rowid()) +} + +fn main() -> Result<(), Box> { + println!("Building SQLite database from documentation..."); + + // Create database + let conn = rusqlite::Connection::open("docs.sqlite")?; + create_schema(&conn)?; + + // Process all documentation files + let mut processed = 0; + + for entry in WalkDir::new("docs") { + let entry = entry?; + let path = entry.path(); + + if path.is_file() && path.extension().map_or(false, |ext| ext == "md") { + // Skip the schema file + if path.file_name().unwrap() == "_schema.md" { + continue; + } + + let content = fs::read_to_string(path)?; + let (title, difficulty, introduced_in_version, author, featured) = parse_frontmatter(&content); + + let path_str = path.to_string_lossy(); + let name = path.file_stem().unwrap().to_string_lossy(); + + if path_str.contains("/components/") { + insert_component(&conn, &name, &title, &difficulty, &introduced_in_version, &content)?; + processed += 1; + } else if path_str.contains("/functions/") { + insert_function(&conn, &name, &title, &difficulty, &introduced_in_version, &content)?; + processed += 1; + } else if path_str.contains("/guides/") { + insert_guide(&conn, &name, &title, &difficulty, &introduced_in_version, &content)?; + processed += 1; + } else if path_str.contains("/blog/") { + insert_blog_post(&conn, &name, &title, &author, &featured, &content)?; + processed += 1; + } else if path_str.contains("/configuration/") { + insert_configuration_page(&conn, &name, &title, &introduced_in_version, &content)?; + processed += 1; + } + } + } + + println!("✅ Successfully built docs.sqlite with {} documents", processed); + Ok(()) +} \ No newline at end of file diff --git a/scripts/check-review-dates.rs b/scripts/check-review-dates.rs new file mode 100644 index 00000000..83a45ad5 --- /dev/null +++ b/scripts/check-review-dates.rs @@ -0,0 +1,124 @@ +extern crate walkdir; +extern crate regex; + +use std::fs; +use walkdir::WalkDir; +use regex::Regex; +use std::collections::HashMap; + +fn parse_frontmatter(content: &str) -> HashMap { + let mut fields = HashMap::new(); + + if !content.starts_with("---\n") { + return fields; + } + + let end_marker = match content.find("\n---\n") { + Some(pos) => pos, + None => return fields, + }; + + let frontmatter = &content[4..end_marker]; + + // Simple parsing - extract key-value pairs + for line in frontmatter.lines() { + if line.contains(':') { + let parts: Vec<&str> = line.splitn(2, ':').collect(); + if parts.len() == 2 { + let key = parts[0].trim(); + let value = parts[1].trim().trim_matches('"'); + fields.insert(key.to_string(), value.to_string()); + } + } + } + + fields +} + +fn is_date_old(date_str: &str, days_threshold: i64) -> bool { + if date_str.is_empty() { + return true; // Missing date is considered old + } + + // Parse ISO8601 date (YYYY-MM-DD) + let date_regex = Regex::new(r"^(\d{4})-(\d{2})-(\d{2})$").unwrap(); + if let Some(captures) = date_regex.captures(date_str) { + let year: i32 = captures[1].parse().unwrap_or(0); + let month: u32 = captures[2].parse().unwrap_or(0); + let day: u32 = captures[3].parse().unwrap_or(0); + + // Simple date comparison (not perfect but good enough for this check) + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + + // Approximate days since epoch for the date + let doc_date_epoch = (year as i64 - 1970) * 365 + (month as i64 - 1) * 30 + day as i64; + let days_old = (now / 86400) - doc_date_epoch; + + return days_old > days_threshold; + } + + true // Invalid date format is considered old +} + +fn check_doc_file(path: &std::path::Path, days_threshold: i64) -> Vec { + let mut issues = Vec::new(); + + if let Ok(content) = fs::read_to_string(path) { + let fields = parse_frontmatter(&content); + + let last_reviewed = fields.get("last_reviewed").map(|s| s.as_str()).unwrap_or(""); + let last_updated = fields.get("last_updated").map(|s| s.as_str()).unwrap_or(""); + + if last_reviewed.is_empty() && last_updated.is_empty() { + issues.push(format!("Missing both 'last_reviewed' and 'last_updated' fields")); + } else if last_reviewed.is_empty() { + issues.push(format!("Missing 'last_reviewed' field")); + } else if is_date_old(last_reviewed, days_threshold) { + issues.push(format!("'last_reviewed' is older than {} days: {}", days_threshold, last_reviewed)); + } + + if !last_updated.is_empty() && is_date_old(last_updated, days_threshold) { + issues.push(format!("'last_updated' is older than {} days: {}", days_threshold, last_updated)); + } + } + + issues +} + +fn main() -> Result<(), Box> { + let days_threshold = 90; // 3 months + println!("Checking documentation review dates (older than {} days)...", days_threshold); + + let mut all_issues = Vec::new(); + + for entry in WalkDir::new("docs") { + let entry = entry?; + let path = entry.path(); + + if path.is_file() && path.extension().map_or(false, |ext| ext == "md") { + // Skip the schema file + if path.file_name().unwrap() == "_schema.md" { + continue; + } + + let issues = check_doc_file(path, days_threshold); + for issue in issues { + all_issues.push(format!("{}: {}", path.display(), issue)); + } + } + } + + if all_issues.is_empty() { + println!("✅ All documentation is up to date!"); + Ok(()) + } else { + println!("❌ Found {} documentation review issues:", all_issues.len()); + for issue in &all_issues { + println!(" {}", issue); + } + std::process::exit(1); + } +} \ No newline at end of file diff --git a/scripts/check-sql.rs b/scripts/check-sql.rs new file mode 100644 index 00000000..215716d6 --- /dev/null +++ b/scripts/check-sql.rs @@ -0,0 +1,59 @@ +extern crate regex; + +use std::path::Path; +use std::fs; +use regex::Regex; + +fn main() -> Result<(), Box> { + println!("Checking SQL syntax in documentation files..."); + + let mut all_errors = Vec::new(); + + // Find all markdown files + for entry in walkdir::WalkDir::new("docs") { + let entry = entry?; + let path = entry.path(); + + if path.is_file() && path.extension().map_or(false, |ext| ext == "md") { + // Skip the schema file + if path.file_name().unwrap() == "_schema.md" { + continue; + } + + let content = fs::read_to_string(path)?; + let errors = check_file_sql(path, &content); + all_errors.extend(errors); + } + } + + if all_errors.is_empty() { + println!("✅ All SQL code blocks are syntactically valid!"); + Ok(()) + } else { + println!("❌ Found {} SQL syntax errors:", all_errors.len()); + for error in &all_errors { + println!(" {}", error); + } + std::process::exit(1); + } +} + +fn check_file_sql(file_path: &Path, content: &str) -> Vec { + let sql_block_regex = Regex::new(r"```sql\n(.*?)\n```").unwrap(); + let mut errors = Vec::new(); + + for cap in sql_block_regex.captures_iter(content) { + let sql = &cap[1]; + if sql.trim().is_empty() { + errors.push(format!("{}: Empty SQL code block found", file_path.display())); + } else { + // Basic SQL validation - check for common syntax issues + if !sql.trim().ends_with(';') && !sql.trim().is_empty() { + // This is just a warning, not an error + // errors.push(format!("{}: SQL block should end with semicolon", file_path.display())); + } + } + } + + errors +} \ No newline at end of file diff --git a/scripts/check-stale-docs.rs b/scripts/check-stale-docs.rs new file mode 100644 index 00000000..bb302e3f --- /dev/null +++ b/scripts/check-stale-docs.rs @@ -0,0 +1,121 @@ +extern crate walkdir; +extern crate regex; + +use std::fs; +use walkdir::WalkDir; +use regex::Regex; + +fn find_functions_in_code() -> Vec { + let mut functions = Vec::new(); + + // Look for functions in the functions.rs file + if let Ok(content) = fs::read_to_string("src/webserver/database/sqlpage_functions/functions.rs") { + let function_regex = Regex::new(r"make_function!\s*\(\s*(\w+)").unwrap(); + for cap in function_regex.captures_iter(&content) { + functions.push(cap[1].to_string()); + } + } + + functions +} + +fn find_components_in_code() -> Vec { + let mut components = Vec::new(); + + // Look for template files + for entry in WalkDir::new("sqlpage/templates") { + if let Ok(entry) = entry { + let path = entry.path(); + if path.is_file() && path.extension().map_or(false, |ext| ext == "handlebars") { + if let Some(name) = path.file_stem() { + components.push(name.to_string_lossy().to_string()); + } + } + } + } + + components +} + +fn find_documented_functions() -> Vec { + let mut functions = Vec::new(); + + for entry in WalkDir::new("docs/functions") { + if let Ok(entry) = entry { + let path = entry.path(); + if path.is_file() && path.extension().map_or(false, |ext| ext == "md") { + if let Some(name) = path.file_stem() { + functions.push(name.to_string_lossy().to_string()); + } + } + } + } + + functions +} + +fn find_documented_components() -> Vec { + let mut components = Vec::new(); + + for entry in WalkDir::new("docs/components") { + if let Ok(entry) = entry { + let path = entry.path(); + if path.is_file() && path.extension().map_or(false, |ext| ext == "md") { + if let Some(name) = path.file_stem() { + components.push(name.to_string_lossy().to_string()); + } + } + } + } + + components +} + +fn main() -> Result<(), Box> { + println!("Checking for stale documentation..."); + + let mut errors = Vec::new(); + + // Check functions + let code_functions = find_functions_in_code(); + let doc_functions = find_documented_functions(); + + for func in &code_functions { + if !doc_functions.contains(func) { + errors.push(format!("Function '{}' is implemented but not documented", func)); + } + } + + for func in &doc_functions { + if !code_functions.contains(func) { + errors.push(format!("Function '{}' is documented but not implemented", func)); + } + } + + // Check components + let code_components = find_components_in_code(); + let doc_components = find_documented_components(); + + for comp in &code_components { + if !doc_components.contains(comp) { + errors.push(format!("Component '{}' is implemented but not documented", comp)); + } + } + + for comp in &doc_components { + if !code_components.contains(comp) { + errors.push(format!("Component '{}' is documented but not implemented", comp)); + } + } + + if errors.is_empty() { + println!("✅ All code and documentation are in sync!"); + Ok(()) + } else { + println!("❌ Found {} stale documentation issues:", errors.len()); + for error in &errors { + println!(" {}", error); + } + std::process::exit(1); + } +} \ No newline at end of file diff --git a/scripts/doc-build-sqlite.rs b/scripts/doc-build-sqlite.rs new file mode 100644 index 00000000..14adaaab --- /dev/null +++ b/scripts/doc-build-sqlite.rs @@ -0,0 +1,713 @@ +#!/usr/bin/env cargo +nightly -Zscript +//! Build SQLite database from documentation files +//! +//! This script parses all documentation files and builds a single +//! SQLite database with the schema defined in the specification. + +// Cargo.toml +[package] +name = "doc-build-sqlite" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_yaml = "0.9" +walkdir = "2.3" +regex = "1.10" +chrono = { version = "0.4", features = ["serde"] } +rusqlite = { version = "0.31", features = ["bundled"] } +tempfile = "3.8" + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use walkdir::WalkDir; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use std::fs; +use rusqlite::{Connection, Result as SqlResult}; +use tempfile::NamedTempFile; + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct Frontmatter { + // Component fields + icon: Option, + introduced_in_version: Option, + deprecated_in_version: Option, + difficulty: Option, + + // Function fields + namespace: Option, + return_type: Option, + category: Option, + + // Guide fields + title: Option, + estimated_time: Option, + categories: Option>, + tags: Option>, + prerequisites: Option>, + next: Option>, + + // Blog fields + author: Option, + featured: Option, + preview_image: Option, + excerpt: Option, + + // Configuration fields + last_reviewed: Option, + last_updated: Option, +} + +#[derive(Debug)] +struct DocFile { + path: PathBuf, + frontmatter: Frontmatter, + content: String, + doc_type: DocType, +} + +#[derive(Debug, Clone)] +enum DocType { + Component, + Function, + Guide, + Blog, + Configuration, + Architecture, +} + +impl DocFile { + fn from_path(path: &Path) -> Result> { + let content = fs::read_to_string(path)?; + let (frontmatter, content) = parse_frontmatter(&content)?; + let doc_type = determine_doc_type(path)?; + + Ok(DocFile { + path: path.to_path_buf(), + frontmatter, + content, + doc_type, + }) + } +} + +fn parse_frontmatter(content: &str) -> Result<(Frontmatter, String), Box> { + if !content.starts_with("---\n") { + return Ok((Frontmatter { + icon: None, + introduced_in_version: None, + deprecated_in_version: None, + difficulty: None, + namespace: None, + return_type: None, + category: None, + title: None, + estimated_time: None, + categories: None, + tags: None, + prerequisites: None, + next: None, + author: None, + featured: None, + preview_image: None, + excerpt: None, + last_reviewed: None, + last_updated: None, + }, content.to_string())); + } + + let end_marker = content.find("\n---\n").ok_or("Missing frontmatter end marker")?; + let frontmatter_yaml = &content[4..end_marker]; + let content = content[end_marker + 5..].to_string(); + + let frontmatter: Frontmatter = serde_yaml::from_str(frontmatter_yaml)?; + Ok((frontmatter, content)) +} + +fn determine_doc_type(path: &Path) -> Result> { + let path_str = path.to_string_lossy(); + + if path_str.contains("/components/") { + Ok(DocType::Component) + } else if path_str.contains("/functions/") { + Ok(DocType::Function) + } else if path_str.contains("/guides/") { + Ok(DocType::Guide) + } else if path_str.contains("/blog/") { + Ok(DocType::Blog) + } else if path_str.contains("/configuration/") { + Ok(DocType::Configuration) + } else if path_str.contains("/architecture/") { + Ok(DocType::Architecture) + } else { + Err("Unknown document type".into()) + } +} + +fn extract_section(content: &str, section_name: &str) -> Option { + let pattern = format!(r"## {}\s*\n(.*?)(?=\n## |\z)", regex::escape(section_name)); + let regex = Regex::new(&pattern).ok()?; + let captures = regex.captures(content)?; + Some(captures[1].trim().to_string()) +} + +fn extract_sql_blocks(content: &str) -> Vec { + let sql_block_regex = Regex::new(r"```sql\n(.*?)\n```").unwrap(); + let mut blocks = Vec::new(); + + for cap in sql_block_regex.captures_iter(content) { + let sql = cap[1].trim(); + if !sql.is_empty() { + blocks.push(sql.to_string()); + } + } + + blocks +} + +fn parse_parameter_table(content: &str, section_name: &str) -> Vec> { + let section_content = extract_section(content, section_name).unwrap_or_default(); + let mut parameters = Vec::new(); + + for line in section_content.lines() { + if line.starts_with('|') && !line.starts_with("|---") { + let parts: Vec<&str> = line.split('|').map(|s| s.trim()).collect(); + if parts.len() >= 5 { + let mut param = HashMap::new(); + param.insert("name".to_string(), parts[1].to_string()); + param.insert("type".to_string(), parts[2].to_string()); + param.insert("required".to_string(), parts[3].to_string()); + param.insert("default".to_string(), parts[4].to_string()); + if parts.len() > 5 { + param.insert("description".to_string(), parts[5].to_string()); + } + parameters.push(param); + } + } + } + + parameters +} + +fn create_schema(conn: &Connection) -> SqlResult<()> { + // Components table + conn.execute( + "CREATE TABLE IF NOT EXISTS components ( + id INTEGER PRIMARY KEY, + name TEXT UNIQUE NOT NULL, + icon TEXT, + introduced_in_version TEXT, + deprecated_in_version TEXT, + difficulty TEXT CHECK(difficulty IN ('beginner', 'intermediate', 'advanced')), + overview_md TEXT, + when_to_use_md TEXT, + basic_usage_sql TEXT, + related_json TEXT, + changelog_md TEXT, + last_reviewed TEXT, + last_updated TEXT + )", + [], + )?; + + // Component parameters table + conn.execute( + "CREATE TABLE IF NOT EXISTS component_parameters ( + component_id INTEGER REFERENCES components(id) ON DELETE CASCADE, + level TEXT CHECK(level IN ('top', 'row')) NOT NULL, + name TEXT NOT NULL, + type TEXT, + required INTEGER CHECK(required IN (0, 1)) NOT NULL DEFAULT 0, + default_value TEXT, + description_md TEXT, + version_introduced TEXT, + PRIMARY KEY (component_id, level, name) + )", + [], + )?; + + // Component examples table + conn.execute( + "CREATE TABLE IF NOT EXISTS component_examples ( + id INTEGER PRIMARY KEY, + component_id INTEGER REFERENCES components(id) ON DELETE CASCADE, + title TEXT, + description_md TEXT, + sql TEXT, + difficulty TEXT, + compatibility_json TEXT, + featured INTEGER CHECK(featured IN (0, 1)) DEFAULT 0 + )", + [], + )?; + + // Functions table + conn.execute( + "CREATE TABLE IF NOT EXISTS functions ( + id INTEGER PRIMARY KEY, + name TEXT UNIQUE NOT NULL, + namespace TEXT DEFAULT 'sqlpage', + icon TEXT, + return_type TEXT, + introduced_in_version TEXT, + deprecated_in_version TEXT, + category TEXT, + difficulty TEXT CHECK(difficulty IN ('beginner', 'intermediate', 'advanced')), + signature_md TEXT, + description_md TEXT, + return_value_md TEXT, + security_notes_md TEXT, + related_json TEXT, + last_reviewed TEXT, + last_updated TEXT + )", + [], + )?; + + // Function parameters table + conn.execute( + "CREATE TABLE IF NOT EXISTS function_parameters ( + function_id INTEGER REFERENCES functions(id) ON DELETE CASCADE, + position INTEGER NOT NULL, + name TEXT NOT NULL, + type TEXT, + required INTEGER CHECK(required IN (0, 1)) NOT NULL DEFAULT 0, + description_md TEXT, + example_md TEXT, + PRIMARY KEY (function_id, position) + )", + [], + )?; + + // Function examples table + conn.execute( + "CREATE TABLE IF NOT EXISTS function_examples ( + id INTEGER PRIMARY KEY, + function_id INTEGER REFERENCES functions(id) ON DELETE CASCADE, + title TEXT, + description_md TEXT, + sql TEXT + )", + [], + )?; + + // Guides table + conn.execute( + "CREATE TABLE IF NOT EXISTS guides ( + id INTEGER PRIMARY KEY, + slug TEXT UNIQUE NOT NULL, + title TEXT NOT NULL, + difficulty TEXT CHECK(difficulty IN ('beginner', 'intermediate', 'advanced')), + estimated_time TEXT, + introduced_in_version TEXT, + categories_json TEXT, + tags_json TEXT, + prerequisites_json TEXT, + next_json TEXT, + content_md TEXT, + last_reviewed TEXT, + last_updated TEXT + )", + [], + )?; + + // Blog posts table + conn.execute( + "CREATE TABLE IF NOT EXISTS blog_posts ( + id INTEGER PRIMARY KEY, + slug TEXT UNIQUE NOT NULL, + date TEXT NOT NULL, + title TEXT NOT NULL, + author TEXT, + tags_json TEXT, + categories_json TEXT, + featured INTEGER CHECK(featured IN (0, 1)) DEFAULT 0, + preview_image TEXT, + excerpt TEXT, + content_md TEXT + )", + [], + )?; + + // Configuration pages table + conn.execute( + "CREATE TABLE IF NOT EXISTS configuration_pages ( + id INTEGER PRIMARY KEY, + slug TEXT UNIQUE NOT NULL, + title TEXT NOT NULL, + introduced_in_version TEXT, + categories_json TEXT, + tags_json TEXT, + content_md TEXT + )", + [], + )?; + + // Configuration settings table + conn.execute( + "CREATE TABLE IF NOT EXISTS configuration_settings ( + page_id INTEGER REFERENCES configuration_pages(id) ON DELETE CASCADE, + name TEXT NOT NULL, + aliases_json TEXT, + type TEXT, + required INTEGER CHECK(required IN (0, 1)) DEFAULT 0, + default_value TEXT, + description_md TEXT, + example_md TEXT, + introduced_in_version TEXT, + PRIMARY KEY (page_id, name) + )", + [], + )?; + + // Search index table + conn.execute( + "CREATE TABLE IF NOT EXISTS search_index ( + id INTEGER PRIMARY KEY, + type TEXT, + key TEXT, + title TEXT, + content_tsv TEXT + )", + [], + )?; + + Ok(()) +} + +fn insert_component(conn: &Connection, doc: &DocFile) -> SqlResult { + let name = doc.path.file_stem().unwrap().to_string_lossy(); + let overview_md = extract_section(&doc.content, "Overview"); + let when_to_use_md = extract_section(&doc.content, "When to Use"); + let basic_usage_sql = extract_sql_blocks(&doc.content).first().cloned(); + let related_json = extract_section(&doc.content, "Related") + .map(|s| serde_json::to_string(&s).unwrap_or_default()); + let changelog_md = extract_section(&doc.content, "Changelog"); + + conn.execute( + "INSERT OR REPLACE INTO components + (name, icon, introduced_in_version, deprecated_in_version, difficulty, + overview_md, when_to_use_md, basic_usage_sql, related_json, changelog_md, + last_reviewed, last_updated) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", + [ + &name, + &doc.frontmatter.icon.as_deref().unwrap_or(""), + &doc.frontmatter.introduced_in_version.as_deref().unwrap_or(""), + &doc.frontmatter.deprecated_in_version.as_deref().unwrap_or(""), + &doc.frontmatter.difficulty.as_deref().unwrap_or(""), + &overview_md.unwrap_or_default(), + &when_to_use_md.unwrap_or_default(), + &basic_usage_sql.unwrap_or_default(), + &related_json.unwrap_or_default(), + &changelog_md.unwrap_or_default(), + &doc.frontmatter.last_reviewed.as_deref().unwrap_or(""), + &doc.frontmatter.last_updated.as_deref().unwrap_or(""), + ], + )?; + + let component_id = conn.last_insert_rowid(); + + // Insert parameters + let top_params = parse_parameter_table(&doc.content, "Top-Level Parameters"); + for param in top_params { + conn.execute( + "INSERT OR REPLACE INTO component_parameters + (component_id, level, name, type, required, default_value, description_md) + VALUES (?1, 'top', ?2, ?3, ?4, ?5, ?6)", + [ + &component_id.to_string(), + ¶m.get("name").unwrap_or(&"".to_string()), + ¶m.get("type").unwrap_or(&"".to_string()), + &(param.get("required").unwrap_or(&"false").to_lowercase() == "true").to_string(), + ¶m.get("default").unwrap_or(&"".to_string()), + ¶m.get("description").unwrap_or(&"".to_string()), + ], + )?; + } + + let row_params = parse_parameter_table(&doc.content, "Row-Level Parameters"); + for param in row_params { + conn.execute( + "INSERT OR REPLACE INTO component_parameters + (component_id, level, name, type, required, default_value, description_md) + VALUES (?1, 'row', ?2, ?3, ?4, ?5, ?6)", + [ + &component_id.to_string(), + ¶m.get("name").unwrap_or(&"".to_string()), + ¶m.get("type").unwrap_or(&"".to_string()), + &(param.get("required").unwrap_or(&"false").to_lowercase() == "true").to_string(), + ¶m.get("default").unwrap_or(&"".to_string()), + ¶m.get("description").unwrap_or(&"".to_string()), + ], + )?; + } + + Ok(component_id) +} + +fn insert_function(conn: &Connection, doc: &DocFile) -> SqlResult { + let name = doc.path.file_stem().unwrap().to_string_lossy(); + let signature_md = extract_section(&doc.content, "Signature"); + let description_md = extract_section(&doc.content, "Description"); + let return_value_md = extract_section(&doc.content, "Return Value"); + let security_notes_md = extract_section(&doc.content, "Security Notes"); + let related_json = extract_section(&doc.content, "Related") + .map(|s| serde_json::to_string(&s).unwrap_or_default()); + + conn.execute( + "INSERT OR REPLACE INTO functions + (name, namespace, icon, return_type, introduced_in_version, deprecated_in_version, + category, difficulty, signature_md, description_md, return_value_md, security_notes_md, + related_json, last_reviewed, last_updated) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)", + [ + &name, + &doc.frontmatter.namespace.as_deref().unwrap_or("sqlpage"), + &doc.frontmatter.icon.as_deref().unwrap_or(""), + &doc.frontmatter.return_type.as_deref().unwrap_or(""), + &doc.frontmatter.introduced_in_version.as_deref().unwrap_or(""), + &doc.frontmatter.deprecated_in_version.as_deref().unwrap_or(""), + &doc.frontmatter.category.as_deref().unwrap_or(""), + &doc.frontmatter.difficulty.as_deref().unwrap_or(""), + &signature_md.unwrap_or_default(), + &description_md.unwrap_or_default(), + &return_value_md.unwrap_or_default(), + &security_notes_md.unwrap_or_default(), + &related_json.unwrap_or_default(), + &doc.frontmatter.last_reviewed.as_deref().unwrap_or(""), + &doc.frontmatter.last_updated.as_deref().unwrap_or(""), + ], + )?; + + let function_id = conn.last_insert_rowid(); + + // Insert parameters + let params = parse_parameter_table(&doc.content, "Parameters"); + for (i, param) in params.iter().enumerate() { + conn.execute( + "INSERT OR REPLACE INTO function_parameters + (function_id, position, name, type, required, description_md) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + [ + &function_id.to_string(), + &i.to_string(), + ¶m.get("name").unwrap_or(&"".to_string()), + ¶m.get("type").unwrap_or(&"".to_string()), + &(param.get("required").unwrap_or(&"false").to_lowercase() == "true").to_string(), + ¶m.get("description").unwrap_or(&"".to_string()), + ], + )?; + } + + Ok(function_id) +} + +fn insert_guide(conn: &Connection, doc: &DocFile) -> SqlResult { + let slug = doc.path.file_stem().unwrap().to_string_lossy(); + let title = doc.frontmatter.title.as_deref().unwrap_or(&slug); + let categories_json = doc.frontmatter.categories.as_ref() + .map(|c| serde_json::to_string(c).unwrap_or_default()) + .unwrap_or_default(); + let tags_json = doc.frontmatter.tags.as_ref() + .map(|t| serde_json::to_string(t).unwrap_or_default()) + .unwrap_or_default(); + let prerequisites_json = doc.frontmatter.prerequisites.as_ref() + .map(|p| serde_json::to_string(p).unwrap_or_default()) + .unwrap_or_default(); + let next_json = doc.frontmatter.next.as_ref() + .map(|n| serde_json::to_string(n).unwrap_or_default()) + .unwrap_or_default(); + + conn.execute( + "INSERT OR REPLACE INTO guides + (slug, title, difficulty, estimated_time, introduced_in_version, + categories_json, tags_json, prerequisites_json, next_json, content_md, + last_reviewed, last_updated) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", + [ + &slug, + title, + &doc.frontmatter.difficulty.as_deref().unwrap_or(""), + &doc.frontmatter.estimated_time.as_deref().unwrap_or(""), + &doc.frontmatter.introduced_in_version.as_deref().unwrap_or(""), + &categories_json, + &tags_json, + &prerequisites_json, + &next_json, + &doc.content, + &doc.frontmatter.last_reviewed.as_deref().unwrap_or(""), + &doc.frontmatter.last_updated.as_deref().unwrap_or(""), + ], + )?; + + Ok(conn.last_insert_rowid()) +} + +fn insert_blog_post(conn: &Connection, doc: &DocFile) -> SqlResult { + let filename = doc.path.file_stem().unwrap().to_string_lossy(); + let parts: Vec<&str> = filename.split('-').collect(); + let date = if parts.len() >= 3 { + format!("{}-{}-{}", parts[0], parts[1], parts[2]) + } else { + "2024-01-01".to_string() + }; + let slug = if parts.len() > 3 { + parts[3..].join("-") + } else { + filename.to_string() + }; + + let title = doc.frontmatter.title.as_deref().unwrap_or(&slug); + let tags_json = doc.frontmatter.tags.as_ref() + .map(|t| serde_json::to_string(t).unwrap_or_default()) + .unwrap_or_default(); + let categories_json = doc.frontmatter.categories.as_ref() + .map(|c| serde_json::to_string(c).unwrap_or_default()) + .unwrap_or_default(); + let featured = doc.frontmatter.featured.unwrap_or(false) as i32; + + conn.execute( + "INSERT OR REPLACE INTO blog_posts + (slug, date, title, author, tags_json, categories_json, featured, + preview_image, excerpt, content_md) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + [ + &slug, + &date, + title, + &doc.frontmatter.author.as_deref().unwrap_or(""), + &tags_json, + &categories_json, + &featured.to_string(), + &doc.frontmatter.preview_image.as_deref().unwrap_or(""), + &doc.frontmatter.excerpt.as_deref().unwrap_or(""), + &doc.content, + ], + )?; + + Ok(conn.last_insert_rowid()) +} + +fn insert_configuration_page(conn: &Connection, doc: &DocFile) -> SqlResult { + let slug = doc.path.file_stem().unwrap().to_string_lossy(); + let title = doc.frontmatter.title.as_deref().unwrap_or(&slug); + let categories_json = doc.frontmatter.categories.as_ref() + .map(|c| serde_json::to_string(c).unwrap_or_default()) + .unwrap_or_default(); + let tags_json = doc.frontmatter.tags.as_ref() + .map(|t| serde_json::to_string(t).unwrap_or_default()) + .unwrap_or_default(); + + conn.execute( + "INSERT OR REPLACE INTO configuration_pages + (slug, title, introduced_in_version, categories_json, tags_json, content_md) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + [ + &slug, + title, + &doc.frontmatter.introduced_in_version.as_deref().unwrap_or(""), + &categories_json, + &tags_json, + &doc.content, + ], + )?; + + let page_id = conn.last_insert_rowid(); + + // Parse settings table if present + let settings = parse_parameter_table(&doc.content, "Settings"); + for setting in settings { + conn.execute( + "INSERT OR REPLACE INTO configuration_settings + (page_id, name, type, required, default_value, description_md) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + [ + &page_id.to_string(), + &setting.get("name").unwrap_or(&"".to_string()), + &setting.get("type").unwrap_or(&"".to_string()), + &(setting.get("required").unwrap_or(&"false").to_lowercase() == "true").to_string(), + &setting.get("default").unwrap_or(&"".to_string()), + &setting.get("description").unwrap_or(&"".to_string()), + ], + )?; + } + + Ok(page_id) +} + +fn find_doc_files() -> Result, Box> { + let mut files = Vec::new(); + + for entry in WalkDir::new("docs") { + let entry = entry?; + let path = entry.path(); + + if path.is_file() && path.extension().map_or(false, |ext| ext == "md") { + // Skip the schema file + if path.file_name().unwrap() != "_schema.md" { + files.push(path.to_path_buf()); + } + } + } + + Ok(files) +} + +fn main() -> Result<(), Box> { + println!("Building SQLite database from documentation..."); + + // Create temporary database + let temp_file = NamedTempFile::new()?; + let temp_path = temp_file.path(); + + let conn = Connection::open(temp_path)?; + create_schema(&conn)?; + + // Process all documentation files + let doc_files = find_doc_files()?; + let mut processed = 0; + + for file_path in doc_files { + let doc = DocFile::from_path(&file_path)?; + + match doc.doc_type { + DocType::Component => { + insert_component(&conn, &doc)?; + processed += 1; + }, + DocType::Function => { + insert_function(&conn, &doc)?; + processed += 1; + }, + DocType::Guide => { + insert_guide(&conn, &doc)?; + processed += 1; + }, + DocType::Blog => { + insert_blog_post(&conn, &doc)?; + processed += 1; + }, + DocType::Configuration => { + insert_configuration_page(&conn, &doc)?; + processed += 1; + }, + DocType::Architecture => { + // Architecture docs are not stored in the database yet + // but could be added in the future + }, + } + } + + // Move temporary file to final location + let final_path = "docs.sqlite"; + if std::path::Path::new(final_path).exists() { + std::fs::remove_file(final_path)?; + } + std::fs::rename(temp_path, final_path)?; + + println!("✅ Successfully built docs.sqlite with {} documents", processed); + Ok(()) +} \ No newline at end of file diff --git a/scripts/doc-validate-simple.rs b/scripts/doc-validate-simple.rs new file mode 100644 index 00000000..cece9c56 --- /dev/null +++ b/scripts/doc-validate-simple.rs @@ -0,0 +1,264 @@ +#!/usr/bin/env cargo +nightly -Zscript +//! Validate SQLPage documentation against schema rules + +// Cargo.toml +[package] +name = "doc-validate-simple" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_yaml = "0.9" +walkdir = "2.3" +regex = "1.10" + +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use walkdir::WalkDir; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use std::fs; + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct Frontmatter { + icon: Option, + introduced_in_version: Option, + deprecated_in_version: Option, + difficulty: Option, + namespace: Option, + return_type: Option, + category: Option, + title: Option, + estimated_time: Option, + categories: Option>, + tags: Option>, + prerequisites: Option>, + next: Option>, + author: Option, + featured: Option, + preview_image: Option, + excerpt: Option, + last_reviewed: Option, + last_updated: Option, +} + +#[derive(Debug)] +struct ValidationError { + file: PathBuf, + message: String, +} + +fn parse_frontmatter(content: &str) -> Result<(Frontmatter, String), Box> { + if !content.starts_with("---\n") { + return Ok((Frontmatter { + icon: None, + introduced_in_version: None, + deprecated_in_version: None, + difficulty: None, + namespace: None, + return_type: None, + category: None, + title: None, + estimated_time: None, + categories: None, + tags: None, + prerequisites: None, + next: None, + author: None, + featured: None, + preview_image: None, + excerpt: None, + last_reviewed: None, + last_updated: None, + }, content.to_string())); + } + + let end_marker = content.find("\n---\n").ok_or("Missing frontmatter end marker")?; + let frontmatter_yaml = &content[4..end_marker]; + let content = content[end_marker + 5..].to_string(); + + let frontmatter: Frontmatter = serde_yaml::from_str(frontmatter_yaml)?; + Ok((frontmatter, content)) +} + +fn validate_version(version: &str) -> bool { + let version_regex = Regex::new(r"^\d+\.\d+\.\d+$").unwrap(); + version_regex.is_match(version) +} + +fn validate_difficulty(difficulty: &str) -> bool { + matches!(difficulty, "beginner" | "intermediate" | "advanced") +} + +fn validate_required_sections(content: &str, doc_type: &str) -> Vec { + let mut missing = Vec::new(); + + match doc_type { + "component" => { + let required_sections = [ + "## Overview", + "## When to Use", + "## Basic Usage", + "## Top-Level Parameters", + "## Row-Level Parameters", + "## Examples", + "## Related", + "## Changelog", + ]; + + for section in &required_sections { + if !content.contains(section) { + missing.push(section.to_string()); + } + } + }, + "function" => { + let required_sections = [ + "## Signature", + "## Description", + "## Parameters", + "## Return Value", + "## Security Notes", + "## Examples", + "## Related", + ]; + + for section in &required_sections { + if !content.contains(section) { + missing.push(section.to_string()); + } + } + }, + _ => {} + } + + missing +} + +fn validate_doc_file(path: &Path, frontmatter: &Frontmatter, content: &str) -> Vec { + let mut errors = Vec::new(); + + // Determine document type + let path_str = path.to_string_lossy(); + let doc_type = if path_str.contains("/components/") { + "component" + } else if path_str.contains("/functions/") { + "function" + } else if path_str.contains("/guides/") { + "guide" + } else if path_str.contains("/blog/") { + "blog" + } else if path_str.contains("/configuration/") { + "configuration" + } else { + "other" + }; + + // Validate frontmatter fields + if let Some(version) = &frontmatter.introduced_in_version { + if !validate_version(version) { + errors.push(ValidationError { + file: path.to_path_buf(), + message: format!("Invalid version format: {}", version), + }); + } + } + + if let Some(version) = &frontmatter.deprecated_in_version { + if !validate_version(version) { + errors.push(ValidationError { + file: path.to_path_buf(), + message: format!("Invalid version format: {}", version), + }); + } + } + + if let Some(difficulty) = &frontmatter.difficulty { + if !validate_difficulty(difficulty) { + errors.push(ValidationError { + file: path.to_path_buf(), + message: format!("Invalid difficulty: {}", difficulty), + }); + } + } + + // Validate required sections + let missing_sections = validate_required_sections(content, doc_type); + for section in missing_sections { + errors.push(ValidationError { + file: path.to_path_buf(), + message: format!("Missing required section: {}", section), + }); + } + + // Validate required frontmatter fields + match doc_type { + "guide" | "blog" | "configuration" => { + if frontmatter.title.is_none() { + errors.push(ValidationError { + file: path.to_path_buf(), + message: format!("{} missing required 'title' field", doc_type), + }); + } + }, + _ => {} + } + + errors +} + +fn find_doc_files() -> Result, Box> { + let mut files = Vec::new(); + + for entry in WalkDir::new("docs") { + let entry = entry?; + let path = entry.path(); + + if path.is_file() && path.extension().map_or(false, |ext| ext == "md") { + // Skip the schema file + if path.file_name().unwrap() != "_schema.md" { + files.push(path.to_path_buf()); + } + } + } + + Ok(files) +} + +fn main() -> Result<(), Box> { + println!("Validating SQLPage documentation..."); + + let doc_files = find_doc_files()?; + let mut all_errors = Vec::new(); + let mut names = HashSet::new(); + + for file_path in doc_files { + let content = fs::read_to_string(&file_path)?; + let (frontmatter, content) = parse_frontmatter(&content)?; + + // Check for duplicate names/slugs + let name = file_path.file_stem().unwrap().to_string_lossy(); + if !names.insert(name.to_string()) { + all_errors.push(ValidationError { + file: file_path.clone(), + message: format!("Duplicate name/slug: {}", name), + }); + } + + // Validate the document + let errors = validate_doc_file(&file_path, &frontmatter, &content); + all_errors.extend(errors); + } + + if all_errors.is_empty() { + println!("✅ All documentation files are valid!"); + Ok(()) + } else { + println!("❌ Found {} validation errors:", all_errors.len()); + for error in &all_errors { + println!(" {}: {}", error.file.display(), error.message); + } + std::process::exit(1); + } +} \ No newline at end of file diff --git a/scripts/doc-validate.rs b/scripts/doc-validate.rs new file mode 100644 index 00000000..5e794a6c --- /dev/null +++ b/scripts/doc-validate.rs @@ -0,0 +1,380 @@ +#!/usr/bin/env cargo +nightly -Zscript +//! Validate SQLPage documentation against schema rules +//! +//! This script validates all documentation files in the docs/ directory +//! against the schema defined in docs/_schema.md. + +// Cargo.toml +[package] +name = "doc-validate" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_yaml = "0.9" +walkdir = "2.3" +regex = "1.10" +chrono = { version = "0.4", features = ["serde"] } + +fn main() { + println!("Documentation validation script"); +} + +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use walkdir::WalkDir; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use std::fs; + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct Frontmatter { + // Component fields + icon: Option, + introduced_in_version: Option, + deprecated_in_version: Option, + difficulty: Option, + + // Function fields + namespace: Option, + return_type: Option, + category: Option, + + // Guide fields + title: Option, + estimated_time: Option, + categories: Option>, + tags: Option>, + prerequisites: Option>, + next: Option>, + + // Blog fields + author: Option, + featured: Option, + preview_image: Option, + excerpt: Option, + + // Configuration fields + last_reviewed: Option, + last_updated: Option, +} + +#[derive(Debug)] +struct ValidationError { + file: PathBuf, + line: Option, + message: String, +} + +#[derive(Debug)] +struct DocFile { + path: PathBuf, + frontmatter: Frontmatter, + content: String, + doc_type: DocType, +} + +#[derive(Debug, Clone)] +enum DocType { + Component, + Function, + Guide, + Blog, + Configuration, + Architecture, +} + +impl DocFile { + fn from_path(path: &Path) -> Result> { + let content = fs::read_to_string(path)?; + let (frontmatter, content) = parse_frontmatter(&content)?; + let doc_type = determine_doc_type(path)?; + + Ok(DocFile { + path: path.to_path_buf(), + frontmatter, + content, + doc_type, + }) + } +} + +fn parse_frontmatter(content: &str) -> Result<(Frontmatter, String), Box> { + if !content.starts_with("---\n") { + return Ok((Frontmatter { + icon: None, + introduced_in_version: None, + deprecated_in_version: None, + difficulty: None, + namespace: None, + return_type: None, + category: None, + title: None, + estimated_time: None, + categories: None, + tags: None, + prerequisites: None, + next: None, + author: None, + featured: None, + preview_image: None, + excerpt: None, + last_reviewed: None, + last_updated: None, + }, content.to_string())); + } + + let end_marker = content.find("\n---\n").ok_or("Missing frontmatter end marker")?; + let frontmatter_yaml = &content[4..end_marker]; + let content = content[end_marker + 5..].to_string(); + + let frontmatter: Frontmatter = serde_yaml::from_str(frontmatter_yaml)?; + Ok((frontmatter, content)) +} + +fn determine_doc_type(path: &Path) -> Result> { + let path_str = path.to_string_lossy(); + + if path_str.contains("/components/") { + Ok(DocType::Component) + } else if path_str.contains("/functions/") { + Ok(DocType::Function) + } else if path_str.contains("/guides/") { + Ok(DocType::Guide) + } else if path_str.contains("/blog/") { + Ok(DocType::Blog) + } else if path_str.contains("/configuration/") { + Ok(DocType::Configuration) + } else if path_str.contains("/architecture/") { + Ok(DocType::Architecture) + } else { + Err("Unknown document type".into()) + } +} + +fn validate_version(version: &str) -> bool { + let version_regex = Regex::new(r"^\d+\.\d+\.\d+$").unwrap(); + version_regex.is_match(version) +} + +fn validate_difficulty(difficulty: &str) -> bool { + matches!(difficulty, "beginner" | "intermediate" | "advanced") +} + +fn validate_required_sections(content: &str, doc_type: &DocType) -> Vec { + let mut missing = Vec::new(); + + match doc_type { + DocType::Component => { + let required_sections = [ + "## Overview", + "## When to Use", + "## Basic Usage", + "## Top-Level Parameters", + "## Row-Level Parameters", + "## Examples", + "## Related", + "## Changelog", + ]; + + for section in &required_sections { + if !content.contains(section) { + missing.push(section.to_string()); + } + } + }, + DocType::Function => { + let required_sections = [ + "## Signature", + "## Description", + "## Parameters", + "## Return Value", + "## Security Notes", + "## Examples", + "## Related", + ]; + + for section in &required_sections { + if !content.contains(section) { + missing.push(section.to_string()); + } + } + }, + DocType::Guide => { + // Guides have flexible content structure + }, + DocType::Blog => { + // Blog posts have flexible content structure + }, + DocType::Configuration => { + // Configuration pages may need Settings section + if content.contains("Settings") && !content.contains("## Settings") { + missing.push("## Settings".to_string()); + } + }, + DocType::Architecture => { + // Architecture docs have flexible content structure + }, + } + + missing +} + +fn validate_sql_blocks(content: &str) -> Vec { + let mut errors = Vec::new(); + let sql_block_regex = Regex::new(r"```sql\n(.*?)\n```").unwrap(); + + for cap in sql_block_regex.captures_iter(content) { + let sql = &cap[1]; + if sql.trim().is_empty() { + errors.push("Empty SQL code block found".to_string()); + } + } + + errors +} + +fn validate_doc_file(doc: &DocFile) -> Vec { + let mut errors = Vec::new(); + + // Validate frontmatter fields + if let Some(version) = &doc.frontmatter.introduced_in_version { + if !validate_version(version) { + errors.push(ValidationError { + file: doc.path.clone(), + line: None, + message: format!("Invalid version format: {}", version), + }); + } + } + + if let Some(version) = &doc.frontmatter.deprecated_in_version { + if !validate_version(version) { + errors.push(ValidationError { + file: doc.path.clone(), + line: None, + message: format!("Invalid version format: {}", version), + }); + } + } + + if let Some(difficulty) = &doc.frontmatter.difficulty { + if !validate_difficulty(difficulty) { + errors.push(ValidationError { + file: doc.path.clone(), + line: None, + message: format!("Invalid difficulty: {}", difficulty), + }); + } + } + + // Validate required sections + let missing_sections = validate_required_sections(&doc.content, &doc.doc_type); + for section in missing_sections { + errors.push(ValidationError { + file: doc.path.clone(), + line: None, + message: format!("Missing required section: {}", section), + }); + } + + // Validate SQL blocks + let sql_errors = validate_sql_blocks(&doc.content); + for error in sql_errors { + errors.push(ValidationError { + file: doc.path.clone(), + line: None, + message: error, + }); + } + + // Validate required frontmatter fields + match doc.doc_type { + DocType::Guide => { + if doc.frontmatter.title.is_none() { + errors.push(ValidationError { + file: doc.path.clone(), + line: None, + message: "Guide missing required 'title' field".to_string(), + }); + } + }, + DocType::Blog => { + if doc.frontmatter.title.is_none() { + errors.push(ValidationError { + file: doc.path.clone(), + line: None, + message: "Blog post missing required 'title' field".to_string(), + }); + } + }, + DocType::Configuration => { + if doc.frontmatter.title.is_none() { + errors.push(ValidationError { + file: doc.path.clone(), + line: None, + message: "Configuration page missing required 'title' field".to_string(), + }); + } + }, + _ => {} + } + + errors +} + +fn find_doc_files() -> Result, Box> { + let mut files = Vec::new(); + + for entry in WalkDir::new("docs") { + let entry = entry?; + let path = entry.path(); + + if path.is_file() && path.extension().map_or(false, |ext| ext == "md") { + // Skip the schema file + if path.file_name().unwrap() != "_schema.md" { + files.push(path.to_path_buf()); + } + } + } + + Ok(files) +} + +fn main() -> Result<(), Box> { + println!("Validating SQLPage documentation..."); + + let doc_files = find_doc_files()?; + let mut all_errors = Vec::new(); + let mut names = HashSet::new(); + + for file_path in doc_files { + let doc = DocFile::from_path(&file_path)?; + + // Check for duplicate names/slugs + let name = doc.path.file_stem().unwrap().to_string_lossy(); + if !names.insert(name.to_string()) { + all_errors.push(ValidationError { + file: doc.path.clone(), + line: None, + message: format!("Duplicate name/slug: {}", name), + }); + } + + // Validate the document + let errors = validate_doc_file(&doc); + all_errors.extend(errors); + } + + if all_errors.is_empty() { + println!("✅ All documentation files are valid!"); + Ok(()) + } else { + println!("❌ Found {} validation errors:", all_errors.len()); + for error in &all_errors { + println!(" {}: {}", error.file.display(), error.message); + } + std::process::exit(1); + } +} \ No newline at end of file diff --git a/scripts/sql-syntax-check.rs b/scripts/sql-syntax-check.rs new file mode 100644 index 00000000..6cb105f9 --- /dev/null +++ b/scripts/sql-syntax-check.rs @@ -0,0 +1,118 @@ +#!/usr/bin/env cargo +nightly -Zscript +//! Check SQL syntax in documentation files +//! +//! This script extracts SQL code blocks from documentation files +//! and validates their syntax using sqlparser. + +// Cargo.toml +[package] +name = "sql-syntax-check" +version = "0.1.0" +edition = "2021" + +[dependencies] +sqlparser = "0.45" +walkdir = "2.3" +regex = "1.10" + +use std::path::Path; +use walkdir::WalkDir; +use regex::Regex; +use std::fs; + +#[derive(Debug)] +struct SqlError { + file: String, + sql: String, + error: String, +} + +fn extract_sql_blocks(content: &str) -> Vec { + let sql_block_regex = Regex::new(r"```sql\n(.*?)\n```").unwrap(); + let mut blocks = Vec::new(); + + for cap in sql_block_regex.captures_iter(content) { + let sql = cap[1].trim(); + if !sql.is_empty() { + blocks.push(sql.to_string()); + } + } + + blocks +} + +fn validate_sql_syntax(sql: &str) -> Result<(), String> { + use sqlparser::dialect::GenericDialect; + use sqlparser::parser::Parser; + + let dialect = GenericDialect {}; + let mut parser = Parser::new(&dialect); + + match parser.try_with_sql(sql) { + Ok(_) => Ok(()), + Err(e) => Err(format!("SQL syntax error: {}", e)), + } +} + +fn check_file_sql(file_path: &Path) -> Vec { + let content = match fs::read_to_string(file_path) { + Ok(content) => content, + Err(_) => return vec![], + }; + + let sql_blocks = extract_sql_blocks(&content); + let mut errors = Vec::new(); + + for sql in sql_blocks { + if let Err(error) = validate_sql_syntax(&sql) { + errors.push(SqlError { + file: file_path.to_string_lossy().to_string(), + sql: sql.clone(), + error, + }); + } + } + + errors +} + +fn find_doc_files() -> Vec { + let mut files = Vec::new(); + + for entry in WalkDir::new("docs") { + if let Ok(entry) = entry { + let path = entry.path(); + if path.is_file() && path.extension().map_or(false, |ext| ext == "md") { + files.push(path.to_path_buf()); + } + } + } + + files +} + +fn main() -> Result<(), Box> { + println!("Checking SQL syntax in documentation files..."); + + let doc_files = find_doc_files(); + let mut all_errors = Vec::new(); + + for file_path in doc_files { + let errors = check_file_sql(&file_path); + all_errors.extend(errors); + } + + if all_errors.is_empty() { + println!("✅ All SQL code blocks are syntactically valid!"); + Ok(()) + } else { + println!("❌ Found {} SQL syntax errors:", all_errors.len()); + for error in &all_errors { + println!(" File: {}", error.file); + println!(" SQL: {}", error.sql); + println!(" Error: {}", error.error); + println!(); + } + std::process::exit(1); + } +} \ No newline at end of file diff --git a/scripts/validate-docs.rs b/scripts/validate-docs.rs new file mode 100644 index 00000000..cf67ea73 --- /dev/null +++ b/scripts/validate-docs.rs @@ -0,0 +1,174 @@ +extern crate walkdir; +extern crate regex; + +use std::collections::HashSet; +use std::path::Path; +use std::fs; +use walkdir::WalkDir; +use regex::Regex; + +fn main() -> Result<(), Box> { + println!("Validating SQLPage documentation..."); + + let mut all_errors = Vec::new(); + let mut names = HashSet::new(); + + // Find all markdown files + for entry in WalkDir::new("docs") { + let entry = entry?; + let path = entry.path(); + + if path.is_file() && path.extension().map_or(false, |ext| ext == "md") { + // Skip the schema file + if path.file_name().unwrap() == "_schema.md" { + continue; + } + + let content = fs::read_to_string(path)?; + + // Check for duplicate names/slugs + let name = path.file_stem().unwrap().to_string_lossy(); + if !names.insert(name.to_string()) { + all_errors.push(format!("Duplicate name/slug: {} in {}", name, path.display())); + } + + // Basic validation - check for required sections based on path + let path_str = path.to_string_lossy(); + if path_str.contains("/components/") { + validate_component(&content, path, &mut all_errors); + } else if path_str.contains("/functions/") { + validate_function(&content, path, &mut all_errors); + } else if path_str.contains("/guides/") { + validate_guide(&content, path, &mut all_errors); + } else if path_str.contains("/blog/") { + validate_blog(&content, path, &mut all_errors); + } else if path_str.contains("/configuration/") { + validate_configuration(&content, path, &mut all_errors); + } + } + } + + if all_errors.is_empty() { + println!("✅ All documentation files are valid!"); + Ok(()) + } else { + println!("❌ Found {} validation errors:", all_errors.len()); + for error in &all_errors { + println!(" {}", error); + } + std::process::exit(1); + } +} + +fn validate_component(content: &str, path: &Path, errors: &mut Vec) { + let required_sections = [ + "## Overview", + "## When to Use", + "## Basic Usage", + "## Top-Level Parameters", + "## Row-Level Parameters", + "## Examples", + "## Related", + "## Changelog", + ]; + + for section in &required_sections { + if !content.contains(section) { + errors.push(format!("{}: Missing required section: {}", path.display(), section)); + } + } + + // Check for SQL code blocks + let sql_regex = Regex::new(r"```sql\n.*?\n```").unwrap(); + if !sql_regex.is_match(content) { + errors.push(format!("{}: Component should have SQL code blocks", path.display())); + } +} + +fn validate_function(content: &str, path: &Path, errors: &mut Vec) { + let required_sections = [ + "## Signature", + "## Description", + "## Parameters", + "## Return Value", + "## Security Notes", + "## Examples", + "## Related", + ]; + + for section in &required_sections { + if !content.contains(section) { + errors.push(format!("{}: Missing required section: {}", path.display(), section)); + } + } + + // Check for SQL code blocks + let sql_regex = Regex::new(r"```sql\n.*?\n```").unwrap(); + if !sql_regex.is_match(content) { + errors.push(format!("{}: Function should have SQL code blocks", path.display())); + } +} + +fn validate_guide(content: &str, path: &Path, errors: &mut Vec) { + // Check for frontmatter with title + if !content.starts_with("---\n") { + errors.push(format!("{}: Guide should have YAML frontmatter", path.display())); + return; + } + + let end_marker = content.find("\n---\n"); + if end_marker.is_none() { + errors.push(format!("{}: Guide frontmatter should end with ---", path.display())); + return; + } + + let frontmatter = &content[4..end_marker.unwrap()]; + if !frontmatter.contains("title:") { + errors.push(format!("{}: Guide should have 'title' in frontmatter", path.display())); + } +} + +fn validate_blog(content: &str, path: &Path, errors: &mut Vec) { + // Check filename format (YYYY-MM-DD-slug.md) + let filename = path.file_stem().unwrap().to_string_lossy(); + let date_regex = Regex::new(r"^\d{4}-\d{2}-\d{2}-").unwrap(); + if !date_regex.is_match(&filename) { + errors.push(format!("{}: Blog post filename should be YYYY-MM-DD-slug.md", path.display())); + } + + // Check for frontmatter with title + if !content.starts_with("---\n") { + errors.push(format!("{}: Blog post should have YAML frontmatter", path.display())); + return; + } + + let end_marker = content.find("\n---\n"); + if end_marker.is_none() { + errors.push(format!("{}: Blog post frontmatter should end with ---", path.display())); + return; + } + + let frontmatter = &content[4..end_marker.unwrap()]; + if !frontmatter.contains("title:") { + errors.push(format!("{}: Blog post should have 'title' in frontmatter", path.display())); + } +} + +fn validate_configuration(content: &str, path: &Path, errors: &mut Vec) { + // Check for frontmatter with title + if !content.starts_with("---\n") { + errors.push(format!("{}: Configuration page should have YAML frontmatter", path.display())); + return; + } + + let end_marker = content.find("\n---\n"); + if end_marker.is_none() { + errors.push(format!("{}: Configuration page frontmatter should end with ---", path.display())); + return; + } + + let frontmatter = &content[4..end_marker.unwrap()]; + if !frontmatter.contains("title:") { + errors.push(format!("{}: Configuration page should have 'title' in frontmatter", path.display())); + } +} \ No newline at end of file