humanonlyweb/writer

Storing documents

Store the Markdown, render a document parsed on save.

Markdown

The short version

Store the Markdown: it's the source of truth. Parse it on the server when saving, store that too, and render it. Visitors then download neither the parser nor the editor.

ColumnTypeDescription
bodytextWhat the editor produces.
body_documentjsonOptional cache: the parsed document, for rendering.
body_parser_versiontextwriterParserVersion when parsed: tells you when to re-parse.
// server/api/posts/[id].put.ts
await db.posts.update(id, {
  body: input.body,
  body_document: await parseWriterDocument(input.body),
  body_parser_version: writerParserVersion,
  word_count: countWords(input.body),
});
Copy code
<WriterContent :value="post.body_document" />
Copy code

Outside Nuxt, createParser(options) from @humanonlyweb/writer/parse with the options your app renders with.

Re-parsing

The parsed document is derived, so it can go stale. writerParserVersion is this package's version plus a hash of your parser options. When a stored one differs, parse the body again: on read, or in a batch after deploying. Outside Nuxt, do the same with the package version and your options.

Drafts and published versions

On a real site, an autosave shouldn't change the live page. Keep the working copy on the article, and freeze what readers see as a revision when publishing:

-- The working copy the editor autosaves. Readers see published_revision_id.
CREATE TABLE articles (
  id TEXT PRIMARY KEY,
  slug TEXT NOT NULL UNIQUE,
  section_id TEXT NOT NULL REFERENCES sections (id),
  byline_id TEXT NOT NULL REFERENCES personas (id),
  headline TEXT NOT NULL,
  dek TEXT NOT NULL,
  body TEXT NOT NULL, -- the editor's Markdown
  updated_at TEXT NOT NULL,
  published_revision_id TEXT REFERENCES revisions (id),
  published_at TEXT
);

-- Published versions, frozen: everything readers see.
CREATE TABLE revisions (
  id TEXT PRIMARY KEY,
  article_id TEXT NOT NULL REFERENCES articles (id) ON DELETE CASCADE,
  section_id TEXT NOT NULL REFERENCES sections (id),
  byline_id TEXT NOT NULL REFERENCES personas (id),
  headline TEXT NOT NULL,
  dek TEXT NOT NULL,
  body TEXT NOT NULL,
  body_document TEXT NOT NULL, -- JSON: parseWriterDocument(body)
  body_parser_version TEXT NOT NULL, -- writerParserVersion when parsed
  word_count INTEGER NOT NULL,
  created_at TEXT NOT NULL
);

-- Uploads. The files live in storage; the Markdown holds their url.
CREATE TABLE assets (
  id TEXT PRIMARY KEY,
  url TEXT NOT NULL UNIQUE,
  type TEXT NOT NULL,
  bytes INTEGER NOT NULL,
  created_at TEXT NOT NULL
);
Copy code

Publishing parses the working copy into a new revision, then points the article at it:

async function publish(article: Article) {
  const revision = {
    id: crypto.randomUUID(),
    articleId: article.id,
    // …section, byline, headline, dek and body, copied from the article
    body: article.body,
    bodyDocument: await parseWriterDocument(article.body),
    bodyParserVersion: writerParserVersion,
    wordCount: countWords(article.body),
    createdAt: new Date().toISOString(),
  };
  await db.revisions.insert(revision);
  await db.articles.update(article.id, {
    publishedRevisionId: revision.id,
    publishedAt: article.publishedAt ?? revision.createdAt,
  });
}
Copy code

Before saving or publishing, wait for the editor's uploading to be 0: until then the Markdown holds placeholders.

Keep metadata out of the body

Headlines, slugs, dates and authors go in their own columns, not frontmatter, so you can query and index them. Things templates add around the body (related links, ads) stay out of it too. If editors place something by hand mid-article, make it a block.