tldraw/apps/docs/utils/ContentDatabase.ts
Dan Groshev 86cce6d161
Unbiome (#2776)
Biome as it is now didn't work out for us 😢 

Summary for posterity:

* it IS much, much faster, fast enough to skip any sort of caching
* we couldn't fully replace Prettier just yet. We use Prettier
programmatically to format code in docs, and Biome's JS interface is
officially alpha and [had legacy peer deps
set](https://github.com/biomejs/biome/pull/1756) (which would fail our
CI build as we don't allow installation warnings)
* ternary formatting differs from Prettier, leading to a large diff
https://github.com/biomejs/biome/issues/1661
* import sorting differs from Prettier's
`prettier-plugin-organize-imports`, making the diff even bigger
* the deal breaker is a multi-second delay on saving large files (for us
it's
[Editor.ts](https://github.com/tldraw/tldraw/blob/main/packages/editor/src/lib/editor/Editor.ts))
in VSCode when import sorting is enabled. There is a seemingly relevant
Biome issue where I posted a small summary of our findings:
https://github.com/biomejs/biome/issues/1569#issuecomment-1930411623

Further actions:

* reevaluate in a few months as Biome matures

### Change Type

- [x] `internal` — Any other changes that don't affect the published
package
2024-02-07 16:02:22 +00:00

305 lines
9.1 KiB
TypeScript

import { connect } from '@/scripts/functions/connect'
import { Database } from 'sqlite'
import sqlite3 from 'sqlite3'
import {
Article,
ArticleHeadings,
ArticleLinks,
Category,
Section,
SidebarContentArticleLink,
SidebarContentCategoryLink,
SidebarContentLink,
SidebarContentList,
} from '../types/content-types'
import { assert } from './assert'
export class ContentDatabase {
constructor(public db: Database<sqlite3.Database, sqlite3.Statement>) {}
async getArticle(articleId: string): Promise<Article> {
const article = await this.db.get<Article>(
`SELECT *, NULL as content FROM articles WHERE articles.id = ?`,
articleId
)
assert(article, `Could not find a article with articleId ${articleId}`)
return article
}
async getSection(sectionId: string, opts = {} as { optional?: boolean }) {
const section = await this.db.get('SELECT * FROM sections WHERE id = ?', sectionId)
if (!opts.optional) assert(section, `Could not find a section with sectionId ${sectionId}`)
return section
}
async getCategory(categoryId: string, opts = {} as { optional?: boolean }) {
const category = await this.db.get('SELECT * FROM categories WHERE id = ?', categoryId)
if (!opts.optional) assert(category, `Could not find a category with categoryId ${categoryId}`)
return category
}
async getArticleHeadings(articleId: string): Promise<ArticleHeadings> {
const headings = await this.db.all<ArticleHeadings>(
`SELECT * FROM headings WHERE headings.articleId = ? ORDER BY idx ASC`,
articleId
)
assert(headings, `Could not find headings for an article with articleId ${articleId}`)
return headings
}
async getCategoriesForSection(sectionId: string, opts = {} as { optional?: boolean }) {
const categories = await this.db.all<Category[]>(
'SELECT * FROM categories WHERE sectionId = ?',
sectionId
)
if (!opts.optional) assert(categories, `Could not find categories for sectionId ${sectionId}`)
return categories
}
async getCategoryArticles(sectionId: string, categoryId: string) {
const articles = await this.db.all<Article[]>(
'SELECT id, title, sectionId, categoryId, path FROM articles WHERE sectionId = ? AND categoryId = ?',
sectionId,
categoryId
)
assert(articles, `Could not find articles for category with categoryId ${categoryId}`)
return articles
}
async getCategoryArticlesCount(sectionId: string, categoryId: string) {
const res = await this.db.get<{ count: number }>(
'SELECT COUNT(*) AS count FROM articles WHERE sectionId = ? AND categoryId = ?',
sectionId,
categoryId
)
assert(res, `Could not find count of articles for category with categoryId ${categoryId}`)
return res.count
}
async getArticleLinks(article: Article): Promise<ArticleLinks> {
// and the article with the same section but next sectionIndex
const { sectionIndex } = article
// the prev is the article with the same section but one less sectionIndex
let prev = await this.db.get<Article>(
`SELECT id, title, categoryId, sectionId, path FROM articles WHERE articles.sectionId = ? AND articles.sectionIndex = ?`,
article.sectionId,
sectionIndex - 1
)
// If there's no next, then get the LAST article from the prev section
if (!prev) {
const { idx } = await this.db.get(
`SELECT idx FROM sections WHERE sections.id = ?`,
article.sectionId
)
const prevSection = await this.db.get(
`SELECT id FROM sections WHERE sections.idx = ?`,
idx - 1
)
if (prevSection) {
const { id: prevSectionId } = prevSection
// get the article with the section id and the highest section index
prev = await this.db.get<Article>(
// here we only need certian info for the link
`SELECT id, title, categoryId, sectionId, path FROM articles WHERE articles.sectionId = ? ORDER BY articles.sectionIndex DESC LIMIT 1`,
prevSectionId
)
}
}
// the next is the article with the same section but next sectionIndex
let next = await this.db.get<Article>(
`SELECT id, title, categoryId, sectionId, path FROM articles WHERE articles.sectionId = ? AND articles.sectionIndex = ?`,
article.sectionId,
sectionIndex + 1
)
// If there's no next, then get the FIRST article from the next section
if (!next) {
const { idx } = await this.db.get(
`SELECT idx FROM sections WHERE sections.id = ?`,
article.sectionId
)
const nextSection = await this.db.get(
`SELECT id FROM sections WHERE sections.idx = ?`,
idx + 1
)
if (nextSection) {
const { id: nextSectionId } = nextSection
next = await this.db.get<Article>(
`SELECT id, title, categoryId, sectionId, path FROM articles WHERE articles.sectionId = ? ORDER BY articles.sectionIndex ASC LIMIT 1`,
nextSectionId
)
}
}
return { prev: prev ?? null, next: next ?? null }
}
// TODO(mime): make this more generic, not per docs area
private _sidebarContentLinks: SidebarContentLink[] | undefined
private _sidebarReferenceContentLinks: SidebarContentLink[] | undefined
private _sidebarExamplesContentLinks: SidebarContentLink[] | undefined
async getSidebarContentList({
sectionId,
categoryId,
articleId,
}: {
sectionId?: string
categoryId?: string
articleId?: string
}): Promise<SidebarContentList> {
let links: SidebarContentLink[]
const cachedLinks =
sectionId === 'examples'
? this._sidebarExamplesContentLinks
: sectionId === 'reference'
? this._sidebarReferenceContentLinks
: this._sidebarContentLinks
if (cachedLinks && process.env.NODE_ENV !== 'development') {
// Use the previously cached sidebar links
links = cachedLinks
} else {
// Generate sidebar links and cache them
links = []
const sections = await this.db.all<Section[]>('SELECT * FROM sections ORDER BY idx ASC')
for (const section of sections) {
if (!section.path) continue
const children: SidebarContentLink[] = []
if (section.sidebar_behavior === 'hidden') {
continue
}
if (
(sectionId === 'reference' && section.id !== 'reference') ||
(sectionId !== 'reference' && section.id === 'reference')
) {
continue
}
if (
(sectionId === 'examples' && section.id !== 'examples') ||
(sectionId !== 'examples' && section.id === 'examples')
) {
continue
}
if (section.sidebar_behavior === 'show-title') {
links.push({
type: 'article',
title: section.title,
url: section.path,
articleId: section.id,
groupId: null,
})
continue
}
// If the article is in the getting-started section
// ... we place it at the top level of the sidebar
// ... so let's simplify its URL to reflect that
const categoriesForSection = await this.db.all<Category[]>(
`SELECT * FROM categories WHERE categories.sectionId = ? ORDER BY sectionIndex ASC`,
section.id
)
const ucg: SidebarContentLink[] = []
for (const category of categoriesForSection) {
const articlesForCategory = await this.db.all<Article[]>(
`SELECT * FROM articles WHERE articles.categoryId = ? ORDER BY categoryIndex ASC`,
category.id
)
if (category.id === section.id + '_ucg') {
// Push uncategorized articles to the child of the section
for (const article of articlesForCategory) {
if (!article.path) continue
const sidebarArticleLink: SidebarContentArticleLink = {
type: 'article',
articleId: article.id,
title: article.title,
url: article.path,
groupId: article.groupId,
}
ucg.push(sidebarArticleLink)
}
} else {
if (!category.path) continue
// Push a category together to the section's children
const sidebarCategoryLink: SidebarContentCategoryLink = {
type: 'category',
title: category.title,
url: category.path,
children: [],
}
children.push(sidebarCategoryLink)
for (const article of articlesForCategory) {
if (!article.path) continue
// Add the category's child articles to the category
const sidebarArticleLink: SidebarContentArticleLink = {
type: 'article' as const,
articleId: article.id,
groupId: article.groupId,
title: article.title,
url: article.path,
}
sidebarCategoryLink.children.push(sidebarArticleLink)
}
}
}
// Add the uncategorized articles to the end of the section
children.push(...ucg)
// Push the section to the sidebar
links.push({ type: 'section', title: section.title, url: section.path, children })
// Cache the links structure for next time
if (sectionId === 'examples') {
this._sidebarExamplesContentLinks = links
} else if (sectionId === 'reference') {
this._sidebarReferenceContentLinks = links
} else {
this._sidebarContentLinks = links
}
}
}
return {
sectionId: sectionId ?? null,
categoryId: categoryId ?? null,
articleId: articleId ?? null,
links,
}
}
}
let contentDatabase: ContentDatabase | null = null
export async function getDb(opts = {} as { reset?: boolean }) {
if (!contentDatabase || opts.reset) {
const db = await connect(opts)
contentDatabase = new ContentDatabase(db)
}
return contentDatabase
}