Storing documents
Store the Markdown, render a document parsed on save.
MarkdownThe 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.
| Column | Type | Description |
|---|---|---|
body | text | What the editor produces. |
body_document | json | Optional cache: the parsed document, for rendering. |
body_parser_version | text | writerParserVersion 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 codeOutside 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 codePublishing 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 codeBefore 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.