# @uttori/wiki

> Uttori Wiki is a wiki functionality module for Uttori.

Latest version **9.2.0** (published 2026-09-27) · MIT license · 0 weekly downloads

## Install

```sh
npm install @uttori/wiki
pnpm add @uttori/wiki
yarn add @uttori/wiki
bun add @uttori/wiki
```

## Health

**Score 70/100 (B)** — status: active.

Positive: has types; esm support; no vulnerabilities; recently updated; high maintenance score; high quality score.

Warnings: low downloads.

## Facts

| | |
|---|---|
| Version | 9.2.0 |
| Published | 2026-09-27 |
| First published | 2020-06-24 |
| Weekly downloads | 0 |
| License | MIT |
| TypeScript types | bundled |
| Module format | ESM + CommonJS |
| Node | >= 24 |
| Dependencies | 9 |
| Unpacked size | 2.9 MB |
| Known vulnerabilities | 0 |
| Install scripts | no |
| GitHub stars | 10 |
| Author | Matthew Callis |
| Maintainers | matthewcallis |
| Keywords | uttori, wiki, knowledge base, knowledge management, note taking |

## Links

- npm: https://www.npmjs.com/package/@uttori/wiki
- Repository: https://github.com/uttori/uttori-wiki
- Issues: https://github.com/uttori/uttori-wiki/issues
- npm.io page: https://npm.io/package/@uttori/wiki

## Dependencies (9)

- [@types/ws](https://npm.io/package/@types/ws.md) ^8.18.1
- [@types/ejs](https://npm.io/package/@types/ejs.md) ^3.1.5
- [@types/lunr](https://npm.io/package/@types/lunr.md) ^2.3.7
- [@types/node](https://npm.io/package/@types/node.md) ^26.6.3
- [@types/express](https://npm.io/package/@types/express.md) ^5.0.6
- [@uttori/data-tools](https://npm.io/package/@uttori/data-tools.md) ^4.1.0
- [@types/better-sqlite3](https://npm.io/package/@types/better-sqlite3.md) ^9.6.0
- [@types/express-session](https://npm.io/package/@types/express-session.md) ^1.19.0
- [@uttori/event-dispatcher](https://npm.io/package/@uttori/event-dispatcher.md) ^3.0.2

## Recent versions

- 9.2.0 (latest) — 2026-09-27
- 9.1.0 — 2026-09-23
- 9.0.0 — 2026-06-27
- 8.2.0 — 2026-05-31
- 8.1.0 — 2026-05-30
- 8.0.0 — 2026-05-10
- 7.1.0 — 2025-10-11
- 7.0.2 — 2025-10-06
- 7.0.1 — 2025-10-06
- 7.0.0 — 2025-10-05
- 6.1.4 — 2025-08-26
- 6.1.3 — 2025-08-25
- 6.1.2 — 2025-08-23
- 6.1.1 — 2025-05-25
- 6.1.0 — 2025-05-14
- … 26 more at https://npm.io/package/@uttori/wiki/versions

## README

[![view on npm](https://img.shields.io/npm/v/@uttori/wiki.svg)](https://www.npmjs.com/package/@uttori/wiki)
[![npm module downloads](https://img.shields.io/npm/dt/@uttori/wiki.svg)](https://www.npmjs.com/package/@uttori/wiki)
[![Coverage Status](https://coveralls.io/repos/github/uttori/uttori-wiki/badge.svg?branch=master)](https://coveralls.io/github/uttori/uttori-wiki?branch=master)

# Uttori Wiki

UttoriWiki is a fast, simple, blog / wiki / knowledge base / generic website built around Express.js using the [Uttori](https://github.com/uttori) set of components allowing specific chunks of functionality be changed or swapped out to fit specific needs.

Why yet another knowledge management / note taking app? I wanted to have something that functioned as a wiki or blog or similar small app that I could reuse components for and keep extensible without having to rewrite everything or learn a new framework.

Because of that, UttoriWiki is plugin based. Search and Storage engines are fully configurable. The format of the data is also up to you: Markdown, Wikitext, Creole, AsciiDoc, Textile, reStructuredText, BBCode, Pendown, etc. Markdown is the default and best supported.

Nothing is prescribed. Don't want to write in Markdown? You don't need to! Don't want to store files on disk? Choose a database storage engine! Already running a bunch of external dependencies and want to plug into those? You can _most likely_ do it!

Rendering happens in a pipeline making it easy to render to Markdown, then filter content out and manipulate the content like removing tags or replacing text with emojis.

## Configuration

Please see `src/config.ts` or [the config doc](https://github.com/uttori/uttori-wiki/blob/master/docs/config.md) for all options. Below is an example configuration using some plugins:

- [@uttori/storage-provider-json-file](https://github.com/uttori/uttori-storage-provider-json-file)
- [@uttori/search-provider-lunr](https://github.com/uttori/uttori-search-provider-lunr)
- [@uttori/plugin-renderer-replacer](https://github.com/uttori/uttori-plugin-renderer-replacer)
- [@uttori/plugin-renderer-markdown-it](https://github.com/uttori/uttori-plugin-renderer-markdown-it)
- [@uttori/plugin-upload-multer](https://github.com/uttori/uttori-plugin-upload-multer)
- [@uttori/plugin-generator-sitemap](https://github.com/uttori/uttori-plugin-generator-sitemap)
- [@uttori/plugin-analytics-json-file](https://github.com/uttori/uttori-plugin-analytics-json-file)

```javascript
import { Plugin: StorageProvider } from '@uttori/storage-provider-json-file';
import { Plugin: SearchProvider } from '@uttori/search-provider-lunr';

import AnalyticsPlugin from '@uttori/plugin-analytics-json-file';
import MarkdownItRenderer from '@uttori/plugin-renderer-markdown-it';
import ReplacerRenderer from '@uttori/plugin-renderer-replacer';
import MulterUpload from '@uttori/plugin-upload-multer';
import SitemapGenerator from '@uttori/plugin-generator-sitemap';
import { AddQueryOutputToViewModel } from '@uttori/wiki';

const config = {
  homePage: 'home-page',
  ignoreSlugs: ['home-page'],
  excerptLength: 400,
  publicUrl: 'http://127.0.0.1:8000/wiki',
  themePath: path.join(__dirname, 'theme'),
  publicPath: path.join(__dirname, 'public'),
  useDeleteKey: false,
  deleteKey: process.env.DELETE_KEY || '',
  useEditKey: false,
  editKey: process.env.EDIT_KEY || '',
  publicHistory: true,
  allowedDocumentKeys: [],

  // Plugins
  plugins: [
    StorageProvider,
    SearchProvider,
    AnalyticsPlugin,
    MarkdownItRenderer,
    ReplacerRenderer,
    MulterUpload,
    SitemapGenerator,
  ],

  // Use the JSON to Disk Storage Provider
  [StorageProvider.configKey]: {
    // Path in which to store content (markdown files, etc.)
    contentDirectory: `${__dirname}/content`,

    // Path in which to store content history (markdown files, etc.)
    historyDirectory: `${__dirname}/content/history`,

    // File Extension
    extension: 'json',
  },

  // Use the Lunr Search Provider
  [SearchProvider.configKey]: {
    // Optional Lunr locale
    lunr_locales: [],

    // Ignore Slugs
    ignoreSlugs: ['home-page'],
  },

  // Plugin: Analytics with JSON Files
  [AnalyticsPlugin.configKey]: {
    events: {
      getPopularDocuments: ['popular-documents'],
      updateDocument: ['document-save', 'document-delete'],
      validateConfig: ['validate-config'],
    },

    // Directory files will be uploaded to.
    directory: `${__dirname}/data`,

    // Name of the JSON file.
    name: 'visits',

    // File extension to use for the JSON file.
    extension: 'json',
  },

  // Plugin: Markdown rendering with MarkdownIt
  [MarkdownItRenderer.configKey]: {
    events: {
      renderContent: ['render-content'],
      renderCollection: ['render-search-results'],
      validateConfig: ['validate-config'],
    },


    // Uttori Specific Configuration
    uttori: {
      // Prefix for relative URLs, useful when the Express app is not at root.
      baseUrl: '',

      // Safe List, if a domain is not in this list, it is set to 'external nofollow noreferrer'.
      allowedExternalDomains: [
        'my-site.org',
      ],

      // Open external domains in a new window.
      openNewWindow: true,

      // Table of Contents
      toc: {
        // The opening DOM tag for the TOC container.
        openingTag: '<nav class="table-of-contents">',

        // The closing DOM tag for the TOC container.
        closingTag: '</nav>',

        // Slugify options for convering content to anchor links.
        slugify: {
          lower: true,
        },
      },
    },
  },

  // Plugin: Replace text
  [ReplacerRenderer.configKey]: {
    events: {
      renderContent: ['render-content'],
      renderCollection: ['render-search-results'],
      validateConfig: ['validate-config'],
    },

    // Rules for text replace
    rules: [
      {
        test: /bunny|rabbit/gm,
        output: '🐰',
      },
    ],
  },

  // Plugin: Multer Upload
  [MulterUpload.configKey]: {
    events: {
      bindRoutes: ['bind-routes'],
      validateConfig: ['validate-config'],
    },

    // Directory files will be uploaded to
    directory: `${__dirname}/uploads`,

    // URL to POST files to
    route: '/upload',

    // URL to GET uploads from
    publicRoute: '/uploads',
  },

  // Plugin: Sitemap Generator
  [SitemapGenerator.configKey]: {
    events: {
      callback: ['document-save', 'document-delete'],
      validateConfig: ['validate-config'],
    },

    // Sitemap URL (ie https://wiki.domain.tld)
    base_url: 'https://wiki.domain.tld',

    // Location where the XML sitemap will be written to.
    directory: `${__dirname}/themes/default/public`,

    urls: [
      {
        url: '/',
        lastmod: new Date().toISOString(),
        priority: '1.00',
      },
      {
        url: '/tags',
        lastmod: new Date().toISOString(),
        priority: '0.90',
      },
      {
        url: '/new',
        lastmod: new Date().toISOString(),
        priority: '0.70',
      },
    ],
  },

  // Plugin: View Model Related Documents
  [AddQueryOutputToViewModel.configKey]: {
    events: {
      callback: [
        'view-model-home',
        'view-model-edit',
        'view-model-new',
        'view-model-search',
        'view-model-tag',
        'view-model-tag-index',
        'view-model-detail',
      ],
    },
    queries: {
      'view-model-home' : [
        {
          key: 'tags',
          query: `SELECT tags FROM documents WHERE slug NOT_IN ("${ignoreSlugs.join('", "')}") ORDER BY id ASC LIMIT -1`,
          format: (tags) => [...new Set(tags.flatMap((t) => t.tags))].filter(Boolean).sort((a, b) => a.localeCompare(b)),
          fallback: [],
        },
        {
          key: 'documents',
          query: `SELECT * FROM documents WHERE slug NOT_IN ("${ignoreSlugs.join('", "')}") ORDER BY id ASC LIMIT -1`,
          fallback: [],
        },
        {
          key: 'popularDocuments',
          fallback: [],
          format: (results) => results.map((result) => result.slug),
          queryFunction: async (target, context) => {
            const ignoreSlugs = ['home-page'];
            const [popular] = await context.hooks.fetch('popular-documents', { limit: 5 }, context);
            const slugs = `"${popular.map(({ slug }) => slug).join('", "')}"`;
            const query = `SELECT 'slug', 'title' FROM documents WHERE slug NOT_IN (${ignoreSlugs}) AND slug IN (${slugs}) ORDER BY updateDate DESC LIMIT 5`;
            const [results] = await context.hooks.fetch('storage-query', query);
            return [results];
          },
        }
      ],
    },
  },

  // Middleware Configuration in the form of ['function', 'param1', 'param2', ...]
  middleware: [
    ['disable', 'x-powered-by'],
    ['enable', 'view cache'],
    ['set', 'views', path.join(`${__dirname}/themes/`, 'default', 'templates')],

    // EJS Specific Setup
    ['use', layouts],
    ['set', 'layout extractScripts', true],
    ['set', 'layout extractStyles', true],
    // If you use the `.ejs` extension use the below:
    // ['set', 'view engine', 'ejs'],
    // I prefer using `.html` templates:
    ['set', 'view engine', 'html'],
    ['engine', 'html', ejs.renderFile],
  ],

  redirects: [
    {
      route: '/:year/:slug',
      target: '/:slug',
      status: 301,
      appendQueryString: true,
    },
  ],

  // Override route handlers
  homeRoute: (request, response, next) => { ... },
  tagIndexRoute: (request, response, next) => { ... },
  tagRoute: (request, response, next) => { ... },
  searchRoute: (request, response, next) => { ... },
  editRoute: (request, response, next) => { ... },
  deleteRoute: (request, response, next) => { ... },
  saveRoute: (request, response, next) => { ... },
  saveNewRoute: (request, response, next) => { ... },
  newRoute: (request, response, next) => { ... },
  detailRoute: (request, response, next) => { ... },
  previewRoute: (request, response, next) => { ... },
  historyIndexRoute: (request, response, next) => { ... },
  historyDetailRoute: (request, response, next) => { ... },
  historyRestoreRoute: (request, response, next) => { ... },
  notFoundRoute: (request, response, next) => { ... },
  saveValidRoute: (request, response, next) => { ... },

  // Custom per route middleware, in the order they should be used
  routeMiddleware: {
    home: [],
    tagIndex: [],
    tag: [],
    search: [],
    notFound: [],
    create: [],
    saveNew: [],
    preview: [],
    edit: [],
    delete: [],
    historyIndex: [],
    historyDetail: [],
    historyRestore: [],
    save: [],
    detail: [],
  },
};

export default config;
```

Use in an example Express.js app:

```javascript
// Server
import express from 'express';

// Reference the Uttori Wiki middleware
import { wiki as middleware } from '@uttori/wiki';

// Pull in our custom config, example above
import config from './config.js';

// Initilize Your app
const app = express();

// Setup the app
app.set('port', process.env.PORT || 8000);
app.set('ip', process.env.IP || '127.0.0.1');

// Setup Express
app.use(express.json({ limit: '50mb' }));
app.use(express.urlencoded({ limit: '50mb', extended: true }));

// Setup the wiki, could also mount under a sub directory path with other applications
app.use('/', middleware(config));

// Listen for connections
app.listen(app.get('port'), app.get('ip'), () => {
  console.log('✔ listening at %s:%d', app.get('ip'), app.get('port'));
});
```

## Events

The following events are avaliable to hook into through plugins and are used in the methods below:

| Name                         | Type       | Returns                   | Description |
|------------------------------|------------|---------------------------|-------------|
| `bind-routes`                | `dispatch` |                           | Called after the default routes are bound to the server. |
| `before-server-listening`    | `dispatch` |                           | Called before the server is listening for connections. |
| `server-listening`           | `dispatch` |                           | Called when the server is listening for connections. |
| `document-delete`            | `dispatch` |                           | Called when a document is about to be deleted. |
| `document-save`              | `filter`   | Uttori Document           | Called when a document is about to be saved. |
| `render-content`             | `filter`   | HTML Content              | Called when content is being prepared to be shown. |
| `render-search-results`      | `filter`   | Array of Uttori Documents | Called when search results have been collected and is being prepared to be shown. |
| `validate-config`            | `dispatch` |                           | Called after initial configuration validation. |
| `validate-invalid`           | `dispatch` |                           | Called when a document is found invalid (spam?). |
| `validate-valid`             | `dispatch` |                           | Called when a document is found to be valid. |
| `validate-save`              | `validate` | Boolean                   | Called before saving a document to validate the document. |
| `view-model-detail`          | `filter`   | View Model                | Called when rendering the detail page just before being shown. |
| `view-model-edit`            | `filter`   | View Model                | Called when rendering the edit page just before being shown. |
| `view-model-error-404`       | `filter`   | View Model                | Called when rendering a 404 Not Found error page just before being shown. |
| `view-model-history-detail`  | `filter`   | View Model                | Called when rendering a history detail page just before being shown. |
| `view-model-history-index`   | `filter`   | View Model                | Called when rendering a history index page just before being shown. |
| `view-model-history-restore` | `filter`   | View Model                | Called when rendering a history restore page just before being shown. |
| `view-model-home`            | `filter`   | View Model                | Called when rendering the home page just before being shown. |
| `view-model-metadata`        | `filter`   | View Model                | Called after the initial view model metadata is setup. |
| `view-model-new`             | `filter`   | View Model                | Called when rendering the new document page just before being shown. |
| `view-model-search`          | `filter`   | View Model                | Called when rendering a search result page just before being shown. |
| `view-model-tag-index`       | `filter`   | View Model                | Called when rendering the tag index page just before being shown. |
| `view-model-tag`             | `filter`   | View Model                | Called when rendering a tag detail page just before being shown. |

* * *

## Included Plugins

## Form Handler Plugin

A flexible form handling plugin for Uttori Wiki that allows you to easily define multiple forms through configuration objects and handle submissions with configurable handlers.

### Features

- **Multiple Form Support**: Define multiple forms with different configurations
- **Flexible Field Types**: Support for text, email, textarea, number, url, and custom validation
- **Configurable Handlers**: Default console logging, email sending, Google Sheets integration
- **JSON and Form Data**: Accepts both JSON and form-encoded data
- **Validation**: Built-in validation with custom regex support
- **Middleware Support**: Add custom middleware for authentication, rate limiting, etc.
- **Error Handling**: Comprehensive error handling and validation

### Configuration

Add the plugin to your Uttori Wiki configuration:

```javascript
import { FormHandler, EmailHandler, GoogleDocsHandler } from '@uttori/wiki';

const config = {
  // ... other config
  plugins: [
    FormHandler,
    // ... other plugins
  ],
  [FormHandler.configKe]: {
    baseRoute: '/forms', // Optional: base route for all forms
    forms: [
      {
        name: 'contact',
        route: '/contact',
        fields: [
          {
            name: 'name',
            type: 'text',
            required: true,
            label: 'Full Name',
            placeholder: 'Enter your full name'
          },
          {
            name: 'email',
            type: 'email',
            required: true,
            label: 'Email Address',
            placeholder: 'Enter your email address'
          },
          {
            name: 'message',
            type: 'textarea',
            required: true,
            label: 'Message',
            placeholder: 'Enter your message'
          }
        ],
        handler: EmailHandler.create({
          host: 'smtp.gmail.com',
          port: 587,
          secure: false,
          user: 'your-email@gmail.com',
          pass: 'your-app-password',
          from: 'your-email@gmail.com',
          to: 'contact@yoursite.com',
          subject: 'Contact Form Submission from {name}',
          template: `
            <h2>New Contact Form Submission</h2>
            <p><strong>Name:</strong> {name}</p>
            <p><strong>Email:</strong> {email}</p>
            <p><strong>Message:</strong></p>
            <p>{message}</p>
            <hr>
            <p><em>Submitted at: {timestamp}</em></p>
          `
        }),
        successMessage: 'Thank you for your message! We will get back to you soon.',
        errorMessage: 'There was an error submitting your message. Please try again.'
      }
    ]
  }
};
```

### Form Configuration

#### Form Object Properties

- **name** (string, required): Unique identifier for the form
- **route** (string, required): URL route for form submission (relative to baseRoute)
- **fields** (array, required): Array of field configurations
- **handler** (function, optional): Custom handler function for form processing
- **successMessage** (string, required): Success message to return
- **errorMessage** (string, required): Error message to return
- **middleware** (array, optional): Custom Express middleware for the form route

#### Field Object Properties

- **name** (string, required): Field name (used as form data key)
- **type** (string, required): Field type (text, email, textarea, number, url)
- **required** (boolean, optional): Whether the field is required
- **label** (string, optional): Display label for the field
- **placeholder** (string, optional): Placeholder text for the field
- **validation** (function, optional): Custom validation function
- **errorMessage** (string, optional): Custom error message for validation

### Built-in Handlers

#### Default Handler (Console Logging)

If no custom handler is provided, the form data will be logged to the console:

```javascript
{
  name: 'feedback',
  route: '/feedback',
  fields: [
    { name: 'rating', type: 'number', required: true },
    { name: 'comment', type: 'textarea', required: false }
  ]
  // No handler - uses default console.log
}
```

#### Email Handler

Send form submissions via email using `nodemailer`:

```javascript
import { EmailHandler } from '@uttori/wiki';

// In your form configuration
handler: EmailHandler.create({
  transportOptions: { ... },
  from: 'your-email@gmail.com',
  to: 'contact@yoursite.com',
  subject: 'Contact Form Submission from {name}',
  template: `
    <h2>New Contact Form Submission</h2>
    <p><strong>Name:</strong> {name}</p>
    <p><strong>Email:</strong> {email}</p>
    <p><strong>Message:</strong></p>
    <p>{message}</p>
  `
})
```

##### Email Handler Configuration

- **transportOptions.host** (string, required): SMTP host
- **transportOptions.port** (number, required): SMTP port
- **transportOptions.secure** (boolean, optional): Whether to use SSL/TLS
- **transportOptions.auth.user** (string, required): SMTP username
- **transportOptions.auth.pass** (string, required): SMTP password
- **from** (string, required): Email address to send from
- **to** (string, required): Email address to send to
- **subject** (string, required): Email subject template
- **template** (string, optional): Email body HTML template

##### Email Template Variables

- `{formName}`: The form name
- `{timestamp}`: Current timestamp
- `{fieldName}`: Any form field value (replace `fieldName` with actual field name)

#### Google Sheets Handler

Write form submissions to Google Sheets:

```javascript
import { GoogleDocsHandler } from '@uttori/wiki';

// In your form configuration
handler: GoogleDocsHandler.create({
  credentialsPath: './google-credentials.json',
  spreadsheetId: 'your-spreadsheet-id',
  sheetName: 'Form Submissions',
  headers: ['name', 'email', 'message'],
  appendTimestamp: true
})
```

##### Google Sheets Handler Configuration

- **credentialsPath** (string, required): Path to Google service account credentials JSON file
- **spreadsheetId** (string, required): Google Sheets spreadsheet ID
- **sheetName** (string, required): Name of the sheet to write to
- **headers** (array, optional): Custom headers for the spreadsheet
- **appendTimestamp** (boolean, optional): Whether to append timestamp to each row

##### Setting up Google Sheets

1. Create a Google Cloud Project and enable the Google Sheets API
2. Create a service account and download the credentials JSON file
3. Share your Google Sheet with the service account email
4. Use `GoogleDocsHandler.setupHeaders(config)` to initialize the sheet headers

### Custom Handlers

You can create custom handlers by providing a function that accepts form data, form config, request, and response:

```javascript
{
  name: 'custom-form',
  route: '/custom',
  fields: [
    { name: 'data', type: 'text', required: true }
  ],
  handler: async (formData, formConfig, req, res) => {
    // Custom processing logic
    console.log('Custom handler processing:', formData);

    // Save to database, send to API, etc.
    await saveToDatabase(formData);

    return {
      message: 'Data processed successfully',
      id: 'some-id'
    };
  }
}
```

### API Endpoints

Forms are accessible at: `{baseRoute}{formRoute}`

For example, with `baseRoute: '/forms'` and form `route: '/contact'`:

- POST `/forms/contact`

### Request/Response Format

#### Request

Accepts both JSON and form-encoded data:

```javascript
// JSON
fetch('/forms/contact', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    name: 'John Doe',
    email: 'john@example.com',
    message: 'Hello world!'
  })
});

// Form data
const formData = new FormData();
formData.append('name', 'John Doe');
formData.append('email', 'john@example.com');
formData.append('message', 'Hello world!');

fetch('/forms/contact', {
  method: 'POST',
  body: formData
});
```

#### Response

```javascript
// Success
{
  "success": true,
  "message": "Thank you for your message! We will get back to you soon.",
  "data": {
    "messageId": "email-message-id",
    "message": "Email sent successfully"
  }
}

// Error
{
  "success": false,
  "message": "There was an error submitting your message. Please try again.",
  "errors": [
    "Field \"email\" must be a valid email address"
  ]
}
```

### Validation

The plugin provides built-in validation for:

- **Required fields**: Checks if required fields are present and not empty
- **Email format**: Validates email addresses using regex
- **Number format**: Validates numeric values
- **URL format**: Validates URLs
- **Custom regex**: Supports custom validation patterns

### Middleware Support

Add custom middleware for authentication, rate limiting, etc.:

```javascript
{
  name: 'admin-form',
  route: '/admin/feedback',
  fields: [
    { name: 'feedback', type: 'textarea', required: true }
  ],
  middleware: [
    // Authentication middleware
    (req, res, next) => {
      if (!req.session || !req.session.user) {
        return res.status(401).json({ error: 'Authentication required' });
      }
      next();
    },
    // Rate limiting middleware
    rateLimit({
      windowMs: 15 * 60 * 1000, // 15 minutes
      max: 5 // limit each IP to 5 requests per windowMs
    })
  ],
  handler: customHandler
}
```

### Error Handling

The plugin handles various error scenarios:

- **Validation errors**: Returns 400 with validation details
- **Handler errors**: Returns 500 with error message
- **Configuration errors**: Throws during plugin registration
- **Missing fields**: Validates required fields
- **Invalid data types**: Validates field types and formats

### Dependencies

- **nodemailer**: For email handler (install with `npm install nodemailer`)
- **googleapis**: For Google Sheets handler (install with `npm install googleapis`)

### Security Considerations

- Validate all input data
- Use HTTPS in production
- Implement rate limiting for public forms
- Sanitize email templates to prevent injection
- Secure Google credentials file
- Use environment variables for sensitive configuration

---

## Tag Routes Plugin

This plugin provides tag index and individual tag pages.

### Installation

Add the plugin to your wiki configuration:

```javascript
import TagRoutesPlugin from './plugins/tag-routes.js';

const config = {
  plugins: [
    TagRoutesPlugin,
    // ... other plugins
  ],
  'uttori-plugin-tag-routes': {
    // plugin configuration
  }
};
```

### Configuration

The plugin accepts the following configuration options:

```javascript
{
  'uttori-plugin-tag-routes': {
    route: 'tags',                    // Route path for tag pages (default: 'tags')
    title: 'Tags',                    // Default title for tag pages (default: 'Tags')
    ignoreTags: [],                   // Tags to ignore when generating the tags page (default: [])
    limit: 1024,                      // Max documents per tag (default: 1024)
    titles: {},                       // Custom titles for specific tags (default: {})
    tagIndexRoute: undefined,         // Custom tag index route handler (default: undefined)
    tagRoute: undefined,              // Custom tag detail route handler (default: undefined)
    routeMiddleware: {                // Middleware for tag routes
      tagIndex: [],
      tag: []
    }
  }
}
```

### Required Hooks

The plugin uses the following hooks to maintain existing functionality:

#### Core Hooks Used

1. **`bind-routes`** (dispatch)
   - **Purpose**: Registers tag routes with the Express server
   - **Usage**: Plugin listens to this hook to add its routes
   - **Implementation**: `context.hooks.on('bind-routes', TagRoutesPlugin.bindRoutes(plugin))`

2. **`storage-query`** (fetch)
   - **Purpose**: Queries the storage system for documents
   - **Usage**: Used in `getTaggedDocuments()` to find documents with specific tags
   - **Implementation**: `await this.context.hooks.fetch('storage-query', query, this.context)`

3. **`view-model-tag-index`** (filter)
   - **Purpose**: Allows modification of the tag index view model
   - **Usage**: Applied to the view model before rendering the tag index page
   - **Implementation**: `await this.context.hooks.filter('view-model-tag-index', viewModel, this.context)`

4. **`view-model-tag`** (filter)
   - **Purpose**: Allows modification of the individual tag view model
   - **Usage**: Applied to the view model before rendering individual tag pages
   - **Implementation**: `await this.context.hooks.filter('view-model-tag', viewModel, this.context)`

#### Context Methods Used

The plugin relies on the following methods from the wiki context:

1. **`buildMetadata(document, path, robots)`**
   - **Purpose**: Builds metadata for view models
   - **Usage**: Creates metadata for tag index and tag detail pages

2. **`config.ignoreSlugs`**
   - **Purpose**: List of slugs to exclude from tag queries
   - **Usage**: Used in storage queries to filter out ignored documents

### Routes Provided

The plugin registers the following routes:

1. **`GET /{route}`** (default: `GET /tags`)
   - **Handler**: `tagIndex`
   - **Purpose**: Displays the tag index page with all available tags
   - **Template**: `tags`

2. **`GET /{route}/:tag`** (default: `GET /tags/:tag`)
   - **Handler**: `tag`
   - **Purpose**: Displays all documents with a specific tag
   - **Template**: `tag`

### Templates Required

The plugin expects the following templates to exist in your theme:

1. **`tags`** - Tag index page template
   - **Variables**: `title`, `config`, `session`, `taggedDocuments`, `meta`, `basePath`, `flash`

2. **`tag`** - Individual tag page template
   - **Variables**: `title`, `config`, `session`, `taggedDocuments`, `meta`, `basePath`, `flash`

### Example Usage

```javascript
import { UttoriWiki, TagRoutesPlugin } from '@uttori/wiki';

const config = {
  plugins: [TagRoutesPlugin],
  'uttori-plugin-tag-routes': {
    route: 'categories',              // Use 'categories' instead of 'tags'
    title: 'Categories',              // Custom title
    ignoreTags: ['private', 'draft'], // Ignore these tags
    limit: 50,                        // Limit to 50 documents per tag
    titles: {                         // Custom titles for specific tags
      'javascript': 'JavaScript',
      'nodejs': 'Node.js'
    }
  }
};

const wiki = new UttoriWiki(config, server);
```

This will create routes at `/categories` and `/categories/:tag` with the specified configuration.

---

## Development and TypeScript

The source lives in `src/**/*.ts`. `npm run build` cleans `dist/` and emits ESM JavaScript, declarations, and source maps. The package entry points all use `dist/**/*.js`, so JavaScript applications do not need a TypeScript loader. TypeScript sources are included for source and declaration map navigation.

Declaration files retain the source JSDoc, including parameter descriptions, defaults, and examples. Edit types and documentation in `src/`; the build generates all of `dist/`. There is no separate `make-types` step or handwritten declaration patching.

```bash
npm install
npm run build       # Emit JavaScript and documented declarations
npm run typecheck   # Check source types without emitting files
npm run lint        # Check code; warnings fail the command
npm test            # Build, check public types, and test emitted JavaScript
npm run test:full   # Include the slower SQLite and embedding tests
npm run test:types  # Build, then check public package types and augmentation
npm run make        # Build, then generate Markdown API docs and type signatures
```

Coverage maps the compiled JavaScript back to the TypeScript source. `npm pack` and `npm publish` build the package automatically through `prepack`. Use `DEBUG=Uttori* npm test` to enable debug output.

Import public types from the package root or an exported plugin path:

```typescript
import { config, type UttoriWikiConfig } from '@uttori/wiki';

const settings: UttoriWikiConfig = { ...config, publicUrl: 'https://wiki.example.com' };
```

See [TypeScript plugin extensions](docs/typescript-plugin-extensions.md) for document augmentation and typed plugin contexts.

## Contributors

- [Matthew Callis](https://github.com/MatthewCallis) - author of UttoriWiki

## License

[MIT](LICENSE)

---
_Source: https://npm.io/package/@uttori/wiki · Machine-readable twin of the npm.io package page. Health data is recomputed on every publish._
