Skip to content

uttori/uttori-wiki

Repository files navigation

view on npm npm module downloads Build Status Coverage Status

Uttori Wiki

UttoriWiki is a fast, simple, wiki / knowledge base built around Express.js using the Uttori set of components allowing single 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.

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.

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 words out and replace text with emojis.

If you want to test it out, check out the demo repo to get up and going in a minutes.

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

API Reference

Classes

UttoriWiki

UttoriWiki is a fast, simple, wiki knowledge base.

Constants

asyncHandler : AsyncRequestHandler

Typedefs

UttoriWikiDocument : object
UttoriWikiDocumentMetaData : object

UttoriWiki

UttoriWiki is a fast, simple, wiki knowledge base.

Kind: global class
Properties

Name Type Description
config UttoriWikiConfig The configuration object.
hooks module:@uttori/event-dispatcher~EventDispatcher The hook / event dispatching object.

new UttoriWiki(config, server)

Creates an instance of UttoriWiki.

Param Type Description
config UttoriWikiConfig A configuration object.
server module:express~Application The Express server instance.

Example (Init UttoriWiki)

const server = express();
const wiki = new UttoriWiki(config, server);
server.listen(server.get('port'), server.get('ip'), () => { ... });

uttoriWiki.config : UttoriWikiConfig

Kind: instance property of UttoriWiki

uttoriWiki.hooks : module:@uttori/event-dispatcher~EventDispatcher

Kind: instance property of UttoriWiki

uttoriWiki.registerPlugins(config)

Registers plugins with the Event Dispatcher.

Kind: instance method of UttoriWiki

Param Type Description
config UttoriWikiConfig A configuration object.

uttoriWiki.validateConfig(config)

Validates the config.

Hooks:

  • dispatch - validate-config - Passes in the config object.

Kind: instance method of UttoriWiki

Param Type Description
config UttoriWikiConfig A configuration object.

uttoriWiki.buildMetadata(document, [path], [robots]) ⇒ Promise.<UttoriWikiDocumentMetaData>

Builds the metadata for the view model.

Hooks:

  • filter - render-content - Passes in the meta description.

Kind: instance method of UttoriWiki
Returns: Promise.<UttoriWikiDocumentMetaData> - Metadata object.

Param Type Description
document Partial.<UttoriWikiDocument> A UttoriWikiDocument.
[path] string The URL path to build meta data for with leading slash.
[robots] string A meta robots tag value.

Example

const metadata = await wiki.buildMetadata(document, '/private-document-path', 'no-index');
 {
  canonical,   // `${this.config.publicUrl}/private-document-path`
  robots,      // 'no-index'
  title,       // document.title
  description, // document.excerpt || document.content.slice(0, 160)
  modified,    // new Date(document.updateDate).toISOString()
  published,   // new Date(document.createDate).toISOString()
}

uttoriWiki.bindRoutes(server)

Bind the routes to the server. Routes are bound in the order of Home, Tags, Search, Not Found Placeholder, Document, Plugins, Not Found - Catch All

Hooks:

  • dispatch - bind-routes - Passes in the server instance.

Kind: instance method of UttoriWiki

Param Type Description
server module:express~Application The Express server instance.

uttoriWiki.home(request, response, next)

Renders the homepage with the home template.

Hooks:

  • filter - render-content - Passes in the home-page content.
  • filter - view-model-home - Passes in the viewModel.

Kind: instance method of UttoriWiki

Param Type Description
request module:express~Request The Express Request object.
response module:express~Response The Express Response object.
next module:express~NextFunction The Express Next function.

uttoriWiki.homepageRedirect(request, response, _next)

Redirects to the homepage.

Kind: instance method of UttoriWiki

Param Type Description
request module:express~Request The Express Request object.
response module:express~Response The Express Response object.
_next module:express~NextFunction The Express Next function.

uttoriWiki.tagIndex(request, response, next)

Renders the tag index page with the tags template.

Hooks:

  • filter - view-model-tag-index - Passes in the viewModel.

Kind: instance method of UttoriWiki

Param Type Description
request module:express~Request The Express Request object.
response module:express~Response The Express Response object.
next module:express~NextFunction The Express Next function.

uttoriWiki.tag(request, response, next)

Renders the tag detail page with tag template. Sets the X-Robots-Tag header to noindex. Attempts to pull in the relevant site section for the tag if defined in the config site sections.

Hooks:

  • filter - view-model-tag - Passes in the viewModel.

Kind: instance method of UttoriWiki

Param Type Description
request module:express~Request The Express Request object.
response module:express~Response The Express Response object.
next module:express~NextFunction The Express Next function.

uttoriWiki.search(request, response, next)

Renders the search page using the search template.

Hooks:

  • filter - render-search-results - Passes in the search results.
  • filter - view-model-search - Passes in the viewModel.

Kind: instance method of UttoriWiki

Param Type Description
request module:express~Request The Express Request object.
response module:express~Response The Express Response object.
next module:express~NextFunction The Express Next function.

uttoriWiki.edit(request, response, next)

Renders the edit page using the edit template.

Hooks:

  • filter - view-model-edit - Passes in the viewModel.

Kind: instance method of UttoriWiki

Param Type Description
request module:express~Request The Express Request object.
response module:express~Response The Express Response object.
next module:express~NextFunction The Express Next function.

uttoriWiki.delete(request, response, next)

Attempts to delete a document and redirect to the homepage. If the config useDeleteKey value is true, the key is verified before deleting.

Hooks:

  • dispatch - document-delete - Passes in the document beind deleted.

Kind: instance method of UttoriWiki

Param Type Description
request module:express~Request The Express Request object.
response module:express~Response The Express Response object.
next module:express~NextFunction The Express Next function.

uttoriWiki.save(request, response, next)

Attempts to update an existing document and redirects to the detail view of that document when successful.

Hooks:

  • validate - validate-save - Passes in the request.
  • dispatch - validate-invalid - Passes in the request.
  • dispatch - validate-valid - Passes in the request.

Kind: instance method of UttoriWiki

Param Type Description
request module:express~Request.<SaveParams, {}, UttoriWikiDocument> The Express Request object.
response module:express~Response The Express Response object.
next module:express~NextFunction The Express Next function.

uttoriWiki.saveNew(request, response, next)

Attempts to save a new document and redirects to the detail view of that document when successful.

Hooks:

  • validate - validate-save - Passes in the request.
  • dispatch - validate-invalid - Passes in the request.
  • dispatch - validate-valid - Passes in the request.

Kind: instance method of UttoriWiki

Param Type Description
request module:express~Request.<SaveParams, {}, UttoriWikiDocument> The Express Request object.
response module:express~Response The Express Response object.
next module:express~NextFunction The Express Next function.

uttoriWiki.create(request, response, next)

Renders the creation page using the edit template.

Hooks:

  • filter - view-model-new - Passes in the viewModel.

Kind: instance method of UttoriWiki

Param Type Description
request module:express~Request The Express Request object.
response module:express~Response The Express Response object.
next module:express~NextFunction The Express Next function.

uttoriWiki.detail(request, response, next)

Renders the detail page using the detail template.

Hooks:

  • fetch - storage-get - Get the requested content from the storage.
  • filter - render-content - Passes in the document content.
  • filter - view-model-detail - Passes in the viewModel.

Kind: instance method of UttoriWiki

Param Type Description
request module:express~Request The Express Request object.
response module:express~Response The Express Response object.
next module:express~NextFunction The Express Next function.

uttoriWiki.preview(request, response, next)

Renders the a preview of the passed in content. Sets the X-Robots-Tag header to noindex.

Hooks:

  • render-content - render-content - Passes in the request body content.

Kind: instance method of UttoriWiki

Param Type Description
request module:express~Request The Express Request object.
response module:express~Response The Express Response object.
next module:express~NextFunction The Express Next function.

uttoriWiki.historyIndex(request, response, next)

Renders the history index page using the history_index template. Sets the X-Robots-Tag header to noindex.

Hooks:

  • filter - view-model-history-index - Passes in the viewModel.

Kind: instance method of UttoriWiki

Param Type Description
request module:express~Request The Express Request object.
response module:express~Response The Express Response object.
next module:express~NextFunction The Express Next function.

uttoriWiki.historyDetail(request, response, next)

Renders the history detail page using the detail template. Sets the X-Robots-Tag header to noindex.

Hooks:

  • render-content - render-content - Passes in the document content.
  • filter - view-model-history-index - Passes in the viewModel.

Kind: instance method of UttoriWiki

Param Type Description
request module:express~Request The Express Request object.
response module:express~Response The Express Response object.
next module:express~NextFunction The Express Next function.

uttoriWiki.historyRestore(request, response, next)

Renders the history restore page using the edit template. Sets the X-Robots-Tag header to noindex.

Hooks:

  • filter - view-model-history-restore - Passes in the viewModel.

Kind: instance method of UttoriWiki

Param Type Description
request module:express~Request The Express Request object.
response module:express~Response The Express Response object.
next module:express~NextFunction The Express Next function.

uttoriWiki.notFound(request, response, next)

Renders the 404 Not Found page using the 404 template. Sets the X-Robots-Tag header to noindex.

Hooks:

  • filter - view-model-error-404 - Passes in the viewModel.

Kind: instance method of UttoriWiki

Param Type Description
request module:express~Request The Express Request object.
response module:express~Response The Express Response object.
next module:express~NextFunction The Express Next function.

uttoriWiki.saveValid(request, response, next)

Handles saving documents, and changing the slug of documents, then redirecting to the document.

title, excerpt, and content will default to a blank string tags is expected to be a comma delimited string in the request body, "tag-1,tag-2" slug will be converted to lowercase and will use request.body.slug and fall back to request.params.slug.

Hooks:

  • filter - document-save - Passes in the document.

Kind: instance method of UttoriWiki

Param Type Description
request module:express~Request.<SaveParams, {}, UttoriWikiDocument> The Express Request object.
response module:express~Response The Express Response object.
next module:express~NextFunction The Express Next function.

uttoriWiki.getTaggedDocuments(tag, [limit]) ⇒ Promise.<Array.<UttoriWikiDocument>>

Returns the documents with the provided tag, up to the provided limit. This will exclude any documents that have slugs in the config.ignoreSlugs array.

Hooks:

  • fetch - storage-query - Searched for the tagged documents.

Kind: instance method of UttoriWiki
Returns: Promise.<Array.<UttoriWikiDocument>> - Promise object that resolves to the array of the documents.

Param Type Default Description
tag string The tag to look for in documents.
[limit] number 1024 The maximum number of documents to be returned.

Example

wiki.getTaggedDocuments('example', 10);
 [{ slug: 'example', title: 'Example', content: 'Example content.', tags: ['example'] }]

asyncHandler : AsyncRequestHandler

Kind: global constant

UttoriWikiDocument : object

Kind: global typedef
Properties

Name Type Description
slug string The document slug to be used in the URL and as a unique ID.
title string The document title to be used anywhere a title may be needed.
[image] string An image to represent the document in Open Graph or elsewhere.
[excerpt] string A succinct deescription of the document, think meta description.
content string All text content for the doucment.
[html] string All rendered HTML content for the doucment that will be presented to the user.
createDate number The Unix timestamp of the creation date of the document.
updateDate number The Unix timestamp of the last update date to the document.
tags Array.<string> A collection of tags that represent the document.
[redirects] Array.<string> An array of slug like strings that will redirect to this document. Useful for renaming and keeping links valid or for short form WikiLinks.
[layout] string The layout to use when rendering the document.

UttoriWikiDocumentMetaData : object

Kind: global typedef
Properties

Name Type Description
canonical string ${this.config.publicUrl}/private-document-path
robots string 'no-index'
title string document.title
description string document.excerpt
modified string new Date(document.updateDate).toISOString()
published string new Date(document.createDate).toISOString()
image string OpenGraph Image

Tests

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

npm install
DEBUG=Uttori* npm test

Contributors

License

MIT