npm.io
9.0.0 • Published 1 month ago

@uttori/wiki

Licence
MIT
Version
9.0.0
Deps
2
Size
1.1 MB
Vulns
0
Weekly
0
Stars
10

view on npm npm module downloads Coverage Status

Uttori Wiki

UttoriWiki is a fast, simple, blog / wiki / knowledge base / generic website built around Express.js using the 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.js or the config doc for all options. Below is an example configuration using some plugins:

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:

// 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:

import FormHandler from './src/plugins/form-handler.js';
import EmailHandler from './src/plugins/form-handlers/email-handler.js';
import GoogleDocsHandler from './src/plugins/form-handlers/google-docs-handler.js';

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:

{
  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:

import EmailHandler from './src/plugins/form-handlers/email-handler.js';

// 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:

import GoogleDocsHandler from './src/plugins/form-handlers/google-docs-handler.js';

// 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:

{
  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:

// 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
// 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.:

{
  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:

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:

{
  '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
import UttoriWiki from './src/wiki.js';
import TagRoutesPlugin from './src/plugins/tag-routes.js';

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.


Tests

To run the test suite, first install the dependencies, then run npm test:

npm install
DEBUG=Uttori* npm test

Contributors

License

MIT

Keywords