I'm writing this post in Trilium Notes. It's a personal knowledge base like TiddlyWiki or Notion, but with powerful scripting. I can make a note, set the type to code (backend JS) and click the run button, then it will run that code on the server!

My implementation looks like this:

This is the code that I have for making a new post (always up to date, embedded from real script):

Manage

// Run this script to create a new post.

const POSTS_FOLDER = 'E0sJWbschXAm'

;(async () => {
    const note = await api.createNewNote({
        parentNoteId: POSTS_FOLDER,
        type: 'text',
        title: 'Untitled Post',
        content: ''
    })
    await note.note.setAttribute('label', 'draft', 'true')
})()

The External API is a folder with some HTTP APIs. The site you're on calls these APIs. The first one is Posts:

Posts

// This script is the API for listing posts

const FOLDER_CONTAINING_POSTS_ID = 'E0sJWbschXAm'

// express.js API provided by Trilium
const { req, res } = api

// Modules
// thenby: my favorite micro-library, helps sorting things
const firstBy = require('thenby')

;(async () => {
  if (req.method == 'GET') {
    const root = await api.getNote(FOLDER_CONTAINING_POSTS_ID)
    const children = await root.getChildNotes()

    // Simultaneously process every note, deciding if it should be in the post list
    const filtered = (
      await Promise.all(
        children.map(async (note) => {
          // Drafts are filtered from the post list, but stil accessible from the Post endpoint
          const draft = await note.getAttribute('label', 'draft')
          if (draft && draft.value === 'true') return null

          // The date of the post should be included in the response
          note.date = await note.getAttribute('label', 'date')
          note.date = (note.date && note.date.value) || note.utcDateCreated

          // The tag attribute can be specified multiple times
          note.tags = await note.getAttributes('label', 'tag')
          if (!note.tags) note.tags = []
          note.tags = note.tags.map((tag) => tag.value)

          // The script for creating a new post (Manage) should not be included in the post index
          // This is a more generic way to allow children in the root to not be listed
          const article = await note.getAttribute('label', 'article')
          if (!article) return note
          if (article.value === 'false') return null

          return note
        })
      )
    )
      .filter((noteOrNull) => noteOrNull)
      .sort(firstBy('date', -1))

    // This header is required to tell browsers that my website is allowed to access the response.
    // Documentation on CORS: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
    res.header('Access-Control-Allow-Origin', '*')

    res.send(
      filtered.map((note) => {
        return {
          id: note.noteId,
          title: note.title,
          date: note.date,
          tags: note.tags
        }
      })
    )
  } else {
    // This route should only be accessible by GET.
    res.sendStatus(400)
  }
})()

It returns a list of posts that are available to read (except drafts).

The second one is Post, and that returns the content of a post. The code is:

Post

const { req, res } = api

;(async () => {
  if (req.method == 'GET') {
    const target = req.query.id
    if (!target) return res.sendStatus(404)
    const result = await getContent(target)

    const public_fileserve = await result.note.getAttribute(
      'label',
      'public_fileserve'
    )
    if (!public_fileserve) return res.sendStatus(403)
    if (public_fileserve.value !== 'true') return res.sendStatus(403)

    result.note.date = await result.note.getAttribute('label', 'date')
    result.note.tags = await result.note.getAttributes('label', 'tag')
    result.note.fullwidth = await result.note.getAttribute('label', 'fullwidth')
    console.log('note fullwidth', result.note.fullwidth)
    if (!result.note.tags) result.note.tags = []
    result.note.tags = result.note.tags.map((tag) => tag.value)
    res.header('Access-Control-Allow-Origin', '*')
    res.send({
      title: result.note.title,
      type: result.note.type,
      date:
        (result.note.date && result.note.date.value) ||
        result.note.utcDateCreated,
      fullwidth:
        result.note.fullwidth && result.note.fullwidth.value !== 'false',
      tags: result.note.tags,
      content: result.content
    })
  } else {
    res.sendStatus(400)
  }
})()

It does quite a lot to the content to make it suitable for the site.

I also have a third endpoint, image. It just returns an image when requested. It's so that images I upload are available on the site.

Image

const { req, res } = api

;(async () => {
  if (req.method == 'GET') {
    const target = req.query.id
    if (!target) return res.sendStatus(404)
    const note = await api.getNote(target)
    if (!note) return res.sendStatus(404)

    if (!['image', 'file', 'code'].includes(note.type))
      return res.sendStatus(400)
    const public_fileserve = await note.getAttribute(
      'label',
      'public_fileserve'
    )
    if (!public_fileserve) return res.sendStatus(403)
    if (public_fileserve.value !== 'true') return res.sendStatus(403)
    const mime = await note.getAttribute('label', 'serve_mime')
    let mimeV = note.mime
    if (mime && mime.value) mimeV = mime.value

    res.header('Content-Type', mimeV)
    res.header('Access-Control-Allow-Origin', '*')
    res.header('Cross-Origin-Resource-Policy', 'cross-origin')
    res.header(
      'Content-Security-Policy',
      'frame-ancestors https://wingysam.xyz;'
    )
    res.header('X-Frame-Options', '')
    const content = await note.getContent()
    res.header('Content-Length', content.length)
    res.send(content)
  } else {
    res.sendStatus(400)
  }
})()

Additionally, I have an endpoint for accessing code notes directly.

Code

const { req, res } = api

;async () => {
  if (req.method == 'GET') {
    const target = req.query.id
    if (!target) return res.sendStatus(404)
    const note = await api.getNote(target)
    if (!note) return res.sendStatus(404)

    if (note.type !== 'code') return res.sendStatus(400)
    const public_fileserve = await note.getAttribute(
      'label',
      'public_fileserve'
    )
    if (!public_fileserve) return res.sendStatus(403)
    if (public_fileserve.value !== 'true') return res.sendStatus(403)

    res.header('Content-Type', note.mime)
    res.header('Access-Control-Allow-Origin', '*')
    res.header(
      'Content-Security-Policy',
      'frame-ancestors https://wingysam.xyz;'
    )
    res.header('X-Frame-Options', '')
    const content = await note.getContent()
    res.header('Content-Length', content.length)
    res.send(content)
  } else {
    res.sendStatus(400)
  }
}

In the wingysam.xyz note, I have an inherited attribute that allows files or notes to be served by the API.

You may have also noticed that I use getContent and it's not defined anywhere. I have it defined in a code note child in the endpoints:

getContent

const cheerio = require('cheerio')
const firstBy = require('thenby')

const CLASSMAP = {
  'language-application-javascript-env-backend': 'language-js',
  'language-application-javascript-env-frontend': 'language-js',
  'language-text-x-rustsrc': 'language-rust'
}
const MIMEMAP = {
  'application/javascript;env=backend': 'language-js',
  'application/javascript;env=frontend': 'language-js'
}

async function getContent(id, recursionDepth) {
  // Options Sanity Check
  if (!id) throw new Error('No ID provided')
  if (!recursionDepth) recursionDepth = 0

  // Setup
  const note = await api.getNote(id)
  const cNote = note
  const content = await note.getContent()

  // --- Files Special Case---
  if (note.type === 'file') {
    let resultingContent = `This is a ${note.mime} file. There is currently not support for displaying ${note.mine} files.`
    switch (note.mime) {
      case 'audio/mp4':
        resultingContent = `<audio controls autoplay src="https://samwing.dev/posts/image/${id}">`
        break
      case 'application/pdf':
        resultingContent = `<a href="https://samwing.dev/posts/image/${id}">View PDF</a><embed src="https://samwing.dev/posts/image/${id}" style='width: 100%; height: 20em; margin-top: 1em;'/>`
        break
    }
    return { note, content: resultingContent }
  }
  // --- End Files ---

  let $ = cheerio.load(content, { xml: { decodeEntities: false } })

  // Transforms
  if (note.type === 'text') {
    // Convert images to use the public file API instead of trying to use the internal trilium link
    // The links look like:
    // api/attachments/<id>/image/image.png
    // or:
    // api/images/<id>/image.png
    $('[src]').map((i, el) => {
      const e = $(el)
      try {
          e.attr(
            'src',
            'https://samwing.dev/posts/image/' +
              e.attr('src').split('attachments/')[1].split('/')[0]
          )
      } catch {
          e.attr(
            'src',
            'https://samwing.dev/posts/image/' +
              e.attr('src').split('images/')[1].split('/')[0]
          )
      }
      e.css('width', '100%')
      e.css('height', 'auto')
    })
    // Mostly for converting code classes to prism.js ones
    for (const classMapEntry in CLASSMAP) {
      replaceClass(classMapEntry, CLASSMAP[classMapEntry])
    }
    // Convert local links to post links
    $('a').each((i, el) => {
      let href = $(el).attr('href')
      if (href.startsWith('#root/'))
        href = '/posts/' + href.split('/').reverse()[0]
      $(el).attr('href', href)
    })
    // Put card data on tables
    $('figure.table > table').each((_0, table) => {
      let items = []
      $(table)
        .find('thead > tr > th')
        .each((i, td) => {
          items[i] = $(td).text()
        })
      $(table)
        .find('tbody > tr')
        .each((_1, tr) => {
          $(tr)
            .find('td')
            .each((i, td) => {
              $(td).attr('data-label', items[i])
              // Have you ever seen hackier code?
              // Puts td content inside a div so that it doesn't break in display: flex
              $(td).html(`<div>${$(td).html()}</div>`)
            })
        })
    })
    // Remove inline CSS from tables
    $('figure.table').each((_, table) => {
      $(table).attr('style', '')
    })
    await Promise.all(
      $('code.language-text-plain')
        .map(async (_, codeEl) => {
          codeEl = $(codeEl)
          let text = require('he').decode(codeEl.html())
          if (!text.startsWith('{{') || !text.endsWith('}}')) return
          text = text.substr(1, text.length - 2)
          const el = codeEl.parent().wrap('<div>').parent()
          const data = JSON.parse(text)
          switch (data.template) {
            case 'subnotes':
              el.html('<ul></ul>')
              const children = await note.getChildNotes()
              if (data.sort === true) children.sort(firstBy('title', -1))
              for (const child of children) {
                if (child.isDeleted) continue
                if (data.startsWith && !child.title.startsWith(data.startsWith))
                  continue
                el.find('ul').append(
                  `<li class="subnotes-${child.noteId}"><a/></li>`
                )
                const a = el.find(`li.subnotes-${child.noteId} > a`)
                a.attr('href', '/posts/' + child.noteId)
                a.text(child.title)
              }
              break
            case 'rawhtml':
              el.replaceWith(data.html)
              break
            default:
              el.html('<p>Unknown Template</p>')
              break
          }
        })
        .get()
    )
  } else if (note.type === 'code') {
    // bit hacky, seems to work ok though
    $ = cheerio.load('<pre><code></code></pre>', { xml: true })
    const mimeClass = MIMEMAP[note.mime]
    if (mimeClass) $('code').addClass(mimeClass)
    $('code').text(content)
  }

  // Recursion
  if (recursionDepth < 3)
    await Promise.all(
      $('.include-note[data-note-id]')
        .map(async (i, el) => {
          try {
            const { note, content } = await getContent(
              $(el).attr('data-note-id'),
              recursionDepth + 1
            )
            const $2 = cheerio.load(content)
            const $all = cheerio.load('<div></div>')
            const all = $all('div')
            const section = await cNote.getAttribute(
              'label',
              `includeNoteLink_${note.noteId}_section`
            )
            const quote = (await cNote.getAttribute(
              'label',
              `includeNoteLink_${note.noteId}_quote`
            )) || { value: section ? 'false' : 'true' }
            const head = (await cNote.getAttribute(
              'label',
              `includeNoteLink_${note.noteId}_head`
            )) || { value: section ? 'false' : 'true' }
            if (section) {
              const sectionName = section.value
              const header = $2('h1, h2, h3, h4, h5, h6').filter(
                (_, h) => $2(h).text() === sectionName
              )
              all.append(header.nextUntil(header.prop('tagName')))
              $(el).html(all.html())
            } else $(el).html(content)
            if (!head || head.value !== 'false')
              $(el).html(`<h2>${note.title}</h2>${$(el).html()}`)
            if (!quote || quote.value !== 'false')
              $(el).html(`<blockquote>${$(el).html()}</blockquote>`)
          } catch {}
        })
        .get()
    )

  // Return
  return { note, content: $.html() }

  // Utilities
  function replaceClass(oldClass, newClass) {
    $(`.${oldClass}`).each((i, el) => {
      $(el).removeClass(oldClass)
      $(el).addClass(newClass)
    })
  }
}

module.exports = getContent

Update: I've added a feed endpoint. It's available at https://trilium.home.wingysam.xyz/custom/wingysam.xyz-feed.

Update 2024-09-22: In an effort to harden my security posture, Trilium is no longer accessible except through a VPN. samwing.dev now proxies it at https://samwing.dev/posts/feed.

Feed

// This script is the API for getting an RSS feed

// express.js API
const { req, res } = api

// Modules
// thenby: my favorite micro-library, helps sorting things
const firstBy = require('thenby')
// feed: generates an RSS/Atom feed.
const { Feed } = require('feed')

;(async () => {
  if (req.method == 'GET') {
    // X0jZjTNA58Yy is the folder with my posts in it
    const targetParentNoteId = 'X0jZjTNA58Yy'
    const parent = await api.getNote(targetParentNoteId)
    const children = await parent.getChildNotes()
    // I don't want drafts in the post list. Should probably extract some things to (... months later, no idea what i was trying to write there)
    const filtered = (
      await Promise.all(
        children.map(async (note) => {
          note.con = (await getContent(note.noteId)).content
          console.log('AB', note.con)
          const draft = await note.getAttribute('label', 'draft')
          if (draft && draft.value === 'true') return null
          note.date = await note.getAttribute('label', 'date')
          note.date = (note.date && note.date.value) || note.utcDateCreated
          note.tags = await note.getAttributes('label', 'tag')
          if (!note.tags) note.tags = []
          note.tags = note.tags.map((tag) => tag.value)
          note.description = await note.getAttribute('label', 'description')
          if (!note.description)
            note.description = { value: 'No description provided' }
          note.description = note.description.value
          const article = await note.getAttribute('label', 'article')
          if (!article) return note
          if (article.value === 'false') return null
          return note
        })
      )
    )
      .filter((noteOrNull) => noteOrNull)
      .sort(firstBy('date', -1))
    res.header('Access-Control-Allow-Origin', '*')
    const author = {
      name: 'Wingy',
      email: 'feed@wingysam.xyz',
      link: 'https://wingysam.xyz'
    }
    const feed = new Feed({
      title: 'Wingy',
      description: 'Student developer who creates things',
      id: 'https://wingysam.xyz',
      link: 'https://wingysam.xyz/posts',
      language: 'en',
      image: 'https://wingysam.xyz/wingy.svg',
      favicon: 'https://wingysam.xyz/favicon.ico',
      copyright: 'All rights reseved 2020, Wingy',
      feedLinks: {},
      author
    })

    filtered.forEach((note) => {
      feed.addItem({
        title: note.title,
        id: note.noteId,
        link: `https://wingysam.xyz/posts/${note.noteId}`,
        description: note.description,
        author: [author],
        date: new Date(note.date),
        content: note.con || 'none'
      })
    })

    res.header('Content-Type', 'application/xml')
    res.send(feed.rss2())
  } else {
    res.sendStatus(400)
  }
})()