snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-23 16:12:52 +05:00
commit a774ac673a
4541 changed files with 770499 additions and 0 deletions

View File

@@ -0,0 +1,285 @@
import { assert } from '@tldraw/utils'
import type { Database } from 'sqlite'
import type sqlite3 from 'sqlite3'
import { connect } from '@/scripts/lib/connect'
import type {
Article,
ArticleHeading,
ArticleLinks,
Category,
Section,
SidebarContentArticleLink,
SidebarContentCategoryLink,
SidebarContentLink,
SidebarContentList,
} from '../types/content-types'
export class ContentDatabase {
private dbPromise: Promise<Database<sqlite3.Database, sqlite3.Statement>> | null = null
async getDb() {
if (!this.dbPromise) {
this.dbPromise = connect({ mode: 'readonly' })
}
return this.dbPromise
}
async getAllPaths(): Promise<string[]> {
const db = await this.getDb()
const articles = await db.all(`SELECT path FROM articles`)
return articles.map((article) => article.path)
}
async getPageContent(
path: string
): Promise<
| { type: 'section'; section: Section }
| { type: 'category'; category: Category }
| { type: 'article'; article: Article }
| undefined
> {
const db = await this.getDb()
const section = await db.get(`SELECT * FROM sections WHERE sections.path = ?`, path)
if (section) return { type: 'section', section } as const
const category = await db.get(`SELECT * FROM categories WHERE categories.path = ?`, path)
if (category) return { type: 'category', category } as const
const article = await db.get(`SELECT * FROM articles WHERE articles.path = ?`, path)
if (article) return { type: 'article', article } as const
return undefined
}
async getArticleHeadings(articleId: string): Promise<ArticleHeading[]> {
const db = await this.getDb()
const headings = await db.all<ArticleHeading[]>(
`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 getArticleLinks(article: Article): Promise<ArticleLinks> {
const db = await this.getDb()
// 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 db.get<Article>(
`SELECT id, title, categoryId, sectionId, path FROM articles
WHERE articles.sectionId = ?
AND ((articles.groupId = ? AND ? IS NOT NULL) OR (articles.groupId IS NULL AND ? is NULL))
AND articles.sectionIndex < ?
ORDER BY articles.sectionIndex DESC LIMIT 1`,
article.sectionId,
article.groupId,
article.groupId,
article.groupId,
sectionIndex
)
// If there's no next, then get the LAST article from the prev section
if (!prev) {
const { idx } = await db.get(
`SELECT idx FROM sections WHERE sections.id = ?`,
article.sectionId
)
const prevSection = await 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 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 db.get<Article>(
`SELECT id, title, categoryId, sectionId, path FROM articles
WHERE articles.sectionId = ?
AND ((articles.groupId = ? AND ? IS NOT NULL) OR (articles.groupId IS NULL AND ? is NULL))
AND articles.sectionIndex > ?
ORDER BY articles.sectionIndex ASC LIMIT 1`,
article.sectionId,
article.groupId,
article.groupId,
article.groupId,
sectionIndex
)
// If there's no next, then get the FIRST article from the next section
if (!next) {
const { idx } = await db.get(
`SELECT idx FROM sections WHERE sections.id = ?`,
article.sectionId
)
const nextSection = await db.get(`SELECT id FROM sections WHERE sections.idx = ?`, idx + 1)
if (nextSection) {
const { id: nextSectionId } = nextSection
next = await 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 _sidebarLinksBySidebar = new Map<string, SidebarContentLink[]>()
async getSidebarContentList({
sectionId,
categoryId,
articleId,
}: {
sectionId?: string
categoryId?: string
articleId?: string
}): Promise<SidebarContentList> {
let links: SidebarContentLink[]
const sidebar = getSidebarForSection(sectionId)
const cachedLinks = this._sidebarLinksBySidebar.get(sidebar)
if (cachedLinks && process.env.NODE_ENV !== 'development') {
// Use the previously cached sidebar links
links = cachedLinks
} else {
const db = await this.getDb()
// Generate sidebar links and cache them
links = []
const sections = await 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 (getSidebarForSection(section.id) !== sidebar) {
continue
}
if (section.sidebar_behavior === 'show-title') {
links.push({
type: 'article',
title: section.title,
url: section.path,
articleId: section.id,
groupId: null,
})
continue
}
const categoriesForSection = await 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 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.sidebarTitle ?? 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,
})
}
// Only publish the cache once the walk is complete: concurrent callers (the
// force-dynamic /search route, static generation) would otherwise pick up the
// half-built array and render a sidebar missing its later sections.
this._sidebarLinksBySidebar.set(sidebar, links)
}
return {
sectionId: sectionId ?? null,
categoryId: categoryId ?? null,
articleId: articleId ?? null,
links,
}
}
}
// These sections each get a sidebar of their own; everything else shares the docs sidebar.
const STANDALONE_SIDEBAR_SECTIONS = new Set(['reference', 'examples', 'starter-kits'])
function getSidebarForSection(sectionId: string | undefined) {
return sectionId && STANDALONE_SIDEBAR_SECTIONS.has(sectionId) ? sectionId : 'docs'
}
export const db = new ContentDatabase()

View File

@@ -0,0 +1,245 @@
import {
ApiDocumentedItem,
ApiFunction,
ApiItem,
ApiModel,
ApiPackage,
ApiVariable,
ExcerptToken,
ExcerptTokenKind,
} from '@microsoft/api-extractor-model'
import { assert } from '@tldraw/utils'
import { MarkdownWriter } from '@/scripts/lib/utils'
export class TldrawApiModel extends ApiModel {
private reactComponents = new Set<ApiItem>()
private reactComponentProps = new Set<ApiItem>()
nonBlockingErrors: Error[] = []
async preprocessReactComponents() {
for (const packageModel of this.members) {
assert(packageModel instanceof ApiPackage)
const entrypoint = packageModel.entryPoints[0]
for (const member of entrypoint.members) {
assert(member instanceof ApiDocumentedItem)
if (!member.tsdocComment) continue
if (!member.tsdocComment.modifierTagSet.hasTagName('@react')) continue
this.reactComponents.add(member)
try {
const props = this.getReactPropsItem(member)
if (props instanceof ApiDocumentedItem && props.tsdocComment) {
const markdown = await MarkdownWriter.docNodeToMarkdown(
props,
props.tsdocComment.summarySection
)
if (markdown.trim()) {
this.nonBlockingError(
props,
"Component props should not contain documentation as it won't be included in the docs site. Add it to the component instead."
)
}
}
if (props) this.reactComponentProps.add(props)
} catch (e: any) {
this.nonBlockingErrors.push(e)
}
}
}
}
resolveToken(origin: ApiItem, token: ExcerptToken) {
const apiItemResult = this.resolveDeclarationReference(token.canonicalReference!, origin)
if (apiItemResult.errorMessage) {
this.error(origin, apiItemResult.errorMessage)
}
return apiItemResult.resolvedApiItem!
}
tryResolveToken(origin: ApiItem, token: ExcerptToken) {
const apiItemResult = this.resolveDeclarationReference(token.canonicalReference!, origin)
if (apiItemResult.errorMessage) {
return null
}
return apiItemResult.resolvedApiItem!
}
getReactPropsItem(component: ApiItem): ApiItem | null {
if (component instanceof ApiFunction) {
if (component.parameters.length === 0) return null
this.assert(
component,
component.parameters.length === 1,
`Expected 1 parameter for @react component`
)
const propsParam = component.parameters[0]
const tokens = propsParam.parameterTypeExcerpt.spannedTokens
if (tokens.length === 1 && tokens[0].kind === 'Reference') {
return this.resolveToken(component, tokens[0])
} else if (
tokens.length === 2 &&
tokens[0].kind === 'Reference' &&
tokens[1].text.startsWith('<')
) {
return this.resolveToken(component, tokens[0])
}
this.nonBlockingError(
component,
`Expected props parameter to be a simple reference. Rewrite this to use a \`${component.displayName}Props\` interface.\nFound: ${propsParam.parameterTypeExcerpt.text}`
)
return null
} else if (component instanceof ApiVariable) {
const tokens = component.variableTypeExcerpt.spannedTokens
if (
tokens.length === 5 &&
tokens[0].text === 'import("react").' &&
tokens[1].text === 'NamedExoticComponent' &&
tokens[2].text === '<' &&
tokens[3].kind === ExcerptTokenKind.Reference &&
tokens[4].text === '>'
) {
return this.resolveToken(component, tokens[3])
}
if (
tokens.length === 4 &&
tokens[0].text === 'React.NamedExoticComponent' &&
tokens[1].text === '<' &&
tokens[2].kind === ExcerptTokenKind.Reference &&
tokens[3].text === '>'
) {
return this.resolveToken(component, tokens[2])
}
if (
tokens.length === 8 &&
tokens[0].text === 'React.ForwardRefExoticComponent' &&
tokens[1].text === '<' &&
tokens[2].kind === ExcerptTokenKind.Reference &&
tokens[3].text === ' & ' &&
tokens[4].text === 'React.RefAttributes' &&
tokens[5].text === '<' &&
tokens[6].kind === ExcerptTokenKind.Reference &&
tokens[7].text === '>>'
) {
return this.resolveToken(component, tokens[2])
}
if (
tokens.length === 9 &&
tokens[0].text === 'import("react").' &&
tokens[1].text === 'ForwardRefExoticComponent' &&
tokens[2].text === '<' &&
tokens[3].kind === ExcerptTokenKind.Reference &&
tokens[4].text === ' & import("react").' &&
tokens[5].text === 'RefAttributes' &&
tokens[6].text === '<' &&
tokens[7].kind === ExcerptTokenKind.Reference &&
tokens[8].text === '>>'
) {
return this.resolveToken(component, tokens[3])
}
if (component.variableTypeExcerpt.text === 'import("react").NamedExoticComponent<object>') {
// this is a `memo` component with no props
return null
}
const memoProps = this.getMemoComponentPropsToken(tokens)
if (memoProps !== undefined) {
return memoProps ? this.resolveToken(component, memoProps) : null
}
this.nonBlockingError(
component,
`Expected a simple props interface for react component. Got: ${component.variableTypeExcerpt.text}`
)
return null
} else {
this.nonBlockingError(component, `Unknown item kind for @react component: ${component.kind}`)
return null
}
}
/**
* TypeScript 7 doesn't resolve `memo(...)` components to
* `NamedExoticComponent<SomeProps>` like TypeScript 5 did. Instead it keeps the wrapped
* function's signature, e.g. `MemoExoticComponent<({ children }: SomeProps) => JSX.Element>`.
*
* Returns the props reference token, `null` if the component takes no props, or `undefined` if
* this isn't a memo component we know how to read.
*/
private getMemoComponentPropsToken(
tokens: readonly ExcerptToken[]
): ExcerptToken | null | undefined {
const headIndex = tokens.findIndex(
(token) =>
token.kind === ExcerptTokenKind.Reference &&
(token.text === 'MemoExoticComponent' || token.text === 'React.MemoExoticComponent')
)
if (headIndex === -1) return undefined
const typeArgument = tokens[headIndex + 1]
if (!typeArgument?.text.startsWith('<(')) return undefined
// a memo component with no props, e.g. `MemoExoticComponent<() => JSX.Element>`
if (typeArgument.text.startsWith('<()')) return null
// walk back from the end of the parameter list to the reference it annotates. we can't take
// the first reference we find: renamed destructured properties are references too, as in
// `({ onKeyDown: handleKeyDown }: SomeProps)`, and they aren't annotated with `: `.
const closeIndex = tokens.findIndex((token, i) => i > headIndex && token.text.includes(') =>'))
if (closeIndex === -1) return undefined
for (let i = closeIndex - 1; i > headIndex; i--) {
const token = tokens[i]
if (token.kind !== ExcerptTokenKind.Reference) continue
if (!tokens[i - 1].text.endsWith(': ')) continue
return token
}
return undefined
}
isComponent(item: ApiItem): boolean {
return this.reactComponents.has(item)
}
isComponentProps(item: ApiItem): boolean {
return this.reactComponentProps.has(item)
}
private createError(item: ApiItem, message: string) {
const suffix =
'_fileUrlPath' in item && typeof item._fileUrlPath === 'string'
? `\nin ${item._fileUrlPath}`
: ''
return new Error(`${item.displayName}: ${message}${suffix}`)
}
nonBlockingError(item: ApiItem, message: string) {
this.nonBlockingErrors.push(this.createError(item, message))
}
throwEncounteredErrors() {
if (this.nonBlockingErrors.length > 0) {
throw new Error(this.nonBlockingErrors.map((e) => (e as any).message).join('\n\n'))
}
}
error(item: ApiItem, message: string): never {
throw this.createError(item, message)
}
assert(item: ApiItem, condition: unknown, message: string): asserts condition {
if (!condition) {
this.error(item, message)
}
}
}

View File

@@ -0,0 +1,167 @@
import console from 'console'
import type { Database } from 'sqlite'
import type sqlite3 from 'sqlite3'
import type { Article, GeneratedContent } from '@/types/content-types'
import { parseMarkdown } from './parse-markdown'
export async function addContentToDb(
db: Database<sqlite3.Database, sqlite3.Statement>,
content: GeneratedContent
) {
// One transaction for the whole batch: each statement is otherwise its own autocommit with
// an fsync, which turns the ~13k inserts of a refresh into seconds of wall time.
await db.exec('BEGIN')
try {
await insertContent(db, content)
await db.exec('COMMIT')
} catch (e) {
// Don't let a failed rollback mask the insert error
await db.exec('ROLLBACK').catch(() => {})
throw e
}
}
async function insertContent(
db: Database<sqlite3.Database, sqlite3.Statement>,
content: GeneratedContent
) {
const sectionInsert = await db.prepare(
`INSERT INTO sections (id, idx, title, description, path, sidebar_behavior) VALUES (?, ?, ?, ?, ?, ?)`
)
const categoryInsert = await db.prepare(
`INSERT INTO categories (id, title, description, sectionId, sectionIndex, path) VALUES (?, ?, ?, ?, ?, ?)`
)
const headingsInsert = await db.prepare(
`INSERT INTO headings (idx, articleId, level, title, slug, path) VALUES (?, ?, ?, ?, ?, ?)`
)
const articleInsert = await db.prepare(
`INSERT INTO articles (
id,
groupIndex,
categoryIndex,
priority,
sectionIndex,
groupId,
categoryId,
sectionId,
authorId,
title,
sidebarTitle,
description,
hero,
thumbnail,
socialImage,
status,
date,
sourceUrl,
componentCode,
componentCodeFilename,
componentCodeFiles,
keywords,
apiTags,
content,
path,
embed,
githubLink
) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
for (let i = 0; i < content.sections.length; i++) {
const section = content.sections[i]
try {
await sectionInsert.run(
section.id,
section.id === 'reference' ? 99999 : i,
section.title,
section.description,
section.path,
section.sidebar_behavior
)
for (let c = 0; c < section.categories.length; c++) {
const category = section.categories[c]
await categoryInsert.run(
category.id,
category.title,
category.description,
section.id,
c,
category.path
)
}
} catch (e: any) {
throw Error(`could not add section to db, ${section.id}: ${e.message}`)
}
}
const articles = Object.values(content.articles) as Article[]
for (let i = 0; i < articles.length; i++) {
const article = articles[i]
if (!article.id) {
throw Error(`hey, article ${article.id} has no id`)
}
try {
await articleInsert.run(
article.id,
article.groupIndex,
article.categoryIndex,
article.priority,
article.sectionIndex,
article.groupId,
article.categoryId,
article.sectionId,
article.author
? typeof article.author === 'string'
? article.author
: article.author.join(', ')
: null,
article.title,
article.sidebarTitle,
article.description,
article.hero,
article.thumbnail,
article.socialImage,
article.status,
article.date,
article.sourceUrl,
article.componentCode,
article.componentCodeFilename,
article.componentCodeFiles,
article.keywords.join(', '),
article.apiTags,
article.content,
article.path,
article.embed,
article.githubLink
)
} catch (e: any) {
console.error(`ERROR: Could not add article with id '${article.id}'`)
throw e
}
await Promise.all(
parseMarkdown(article.content ?? '', article.path ?? article.id).headings.map((heading, i) =>
headingsInsert.run(
i,
article.id,
heading.level,
heading.title,
heading.slug,
heading.slug ? `${article.path}#${heading.slug}` : article.path
)
)
)
}
await Promise.all([
sectionInsert.finalize(),
categoryInsert.finalize(),
headingsInsert.finalize(),
articleInsert.finalize(),
])
}

View File

@@ -0,0 +1,83 @@
import { execFileSync } from 'child_process'
import { createHash } from 'crypto'
import { readdirSync } from 'fs'
import { gunzipSync } from 'zlib'
import { describe, expect, it } from 'vitest'
import { PUBLISHED_SKILLS, readPublishedSkill, resolveSkillDir } from './agent-skills'
type SkillKey = keyof typeof PUBLISHED_SKILLS
const keys = Object.keys(PUBLISHED_SKILLS) as SkillKey[]
/** The archive is gzipped tar, so read it back with tar rather than trusting the writer. */
function listArchive(archive: Buffer): string[] {
return execFileSync('tar', ['-tzf', '-'], { input: archive })
.toString()
.split('\n')
.filter(Boolean)
.sort()
}
describe.each(keys)('published skill: %s', (key) => {
const skill = readPublishedSkill(key)
// The reason this is an archive at all. tldraw-migrate shells out to `detect-versions.mjs` in the
// first block an agent runs; publishing `SKILL.md` on its own handed over a skill that failed on
// first invocation. Any skill added later with the same shape fails here instead of in the wild.
it('ships every sibling file its SKILL.md names', () => {
const onDisk = new Set(readdirSync(resolveSkillDir(PUBLISHED_SKILLS[key].sourceDir)))
const referenced = [...skill.markdown.matchAll(/[\w-]+\.(?:mjs|md)/g)]
.map((m) => m[0])
.filter((f) => onDisk.has(f) && f !== 'SKILL.md')
expect(referenced.length).toBeGreaterThan(0)
expect(listArchive(skill.archive)).toEqual(expect.arrayContaining(referenced))
})
// An unpacking client looks for SKILL.md at the root; a wrapper directory would hide it.
it('puts SKILL.md at the archive root, not under a wrapper directory', () => {
const entries = listArchive(skill.archive)
expect(entries).toContain('SKILL.md')
expect(entries.every((e) => !e.includes('/'))).toBe(true)
})
// The index publishes this digest for clients to verify the download against, so it has to be
// taken over the bytes actually served rather than over the markdown or the source directory.
it('publishes a digest over the archive bytes', () => {
expect(skill.digest).toBe(`sha256:${createHash('sha256').update(skill.archive).digest('hex')}`)
})
// Identical sources must produce identical bytes, or the digest churns on every deploy and
// invalidates whatever cached it.
it('builds byte-identical archives from the same source', () => {
expect(readPublishedSkill(key).archive.equals(skill.archive)).toBe(true)
})
// The check above only compares two builds of the same checkout, which is exactly the case that
// stayed green while every CI checkout produced a different archive: `portable: true` keeps
// mtime, and a fresh clone stamps a fresh one. Reading the header asserts the property itself —
// the archive is addressed by its content, not by when the files happened to land on disk.
it('records no modification times, so a fresh checkout produces the same bytes', () => {
const tarball = gunzipSync(skill.archive)
// ustar header fields are octal, NUL-padded: name at 0, size at 124, mtime at 136. `noMtime`
// zeroes the mtime field outright, so it reads as NULs rather than an octal "0".
const octal = (at: number, len: number) =>
parseInt(
tarball
.subarray(at, at + len)
.toString()
.replaceAll('\0', '')
.trim() || '0',
8
)
for (let offset = 0; offset + 512 <= tarball.length; offset += 512) {
const name = tarball
.subarray(offset, offset + 100)
.toString()
.split('\0')[0]
if (!name) break
expect({ name, mtime: octal(offset + 136, 12) }).toEqual({ name, mtime: 0 })
offset += Math.ceil(octal(offset + 124, 12) / 512) * 512
}
})
})

View File

@@ -0,0 +1,145 @@
import { createHash } from 'crypto'
import { existsSync, readFileSync, readdirSync } from 'fs'
import { dirname, join } from 'path'
import { create } from 'tar'
// The skills tldraw publishes for agents to discover, per the Agent Skills Discovery RFC. Served
// from this app but reached at tldraw.dev/.well-known/agent-skills/* through the dotdev proxy, the
// same arrangement as the llms*.txt bundles.
//
// `skills/` in this repo is mostly workflows for contributing *to* tldraw, which are no use to
// somebody building *with* it. Only skills that stand alone for an outside reader belong here.
/** Where these documents are served from, which is not where this app is deployed. */
export const AGENT_SKILLS_BASE_URL = 'https://tldraw.dev/.well-known/agent-skills'
export const AGENT_SKILLS_INDEX_SCHEMA =
'https://schemas.agentskills.io/discovery/0.2.0/schema.json'
export interface PublishedSkill {
/** The skill's directory, relative to the repo root. */
sourceDir: string
}
/**
* Keyed by the skill's name, which is also the route segment it is served under. Adding a skill here
* means adding `app/.well-known/agent-skills/<name>.tar.gz/route.ts` (what the index points at) and
* `app/.well-known/agent-skills/<name>/SKILL.md/route.ts` (readable form);
* {@link readPublishedSkill} fails the build if the name and the routes ever disagree.
*/
export const PUBLISHED_SKILLS = {
'tldraw-migrate': { sourceDir: 'skills/tldraw-migrate' },
} satisfies Record<string, PublishedSkill>
export interface SkillDocument {
name: string
description: string
/** `SKILL.md` on its own, for reading. Not what the index publishes — see {@link archive}. */
markdown: string
/**
* The whole skill directory as a gzipped tarball, which is what an agent installs.
*
* `SKILL.md` alone is not installable: tldraw-migrate shells out to `detect-versions.mjs` and
* `detect-target.mjs` in the first block an agent runs, and reads `fetch-release-notes.mjs` and
* `type-errors.md` later. Publishing the markdown by itself hands over a skill that fails on its
* first invocation, with nothing in the index hinting at why.
*/
archive: Buffer
/** `sha256:<hex>` over the archive bytes, as the index format requires. */
digest: string
url: string
}
/** Reads from disk, so every route that calls it must stay `force-static`. */
export function readPublishedSkill(key: keyof typeof PUBLISHED_SKILLS): SkillDocument {
const { sourceDir } = PUBLISHED_SKILLS[key]
const skillDir = resolveSkillDir(sourceDir)
const markdown = readFileSync(join(skillDir, 'SKILL.md'), 'utf8')
const name = readFrontmatterField(markdown, 'name', sourceDir)
const description = readFrontmatterField(markdown, 'description', sourceDir)
// The index advertises a URL built from this name, and the route serving that URL is a directory
// in `app/`. A rename upstream would publish a link to a 404 — silently, since nothing else reads
// both halves.
if (name !== key) {
throw new Error(
`${sourceDir}/SKILL.md is named \`${name}\`, but it is published as \`${key}\`. Rename the route directory to match, or drop it from PUBLISHED_SKILLS.`
)
}
const archive = buildSkillArchive(sourceDir)
return {
name,
description,
markdown,
archive,
digest: `sha256:${createHash('sha256').update(archive).digest('hex')}`,
url: `${AGENT_SKILLS_BASE_URL}/${name}.tar.gz`,
}
}
/**
* The skill directory as a `.tar.gz`, with its files at the archive root rather than under a wrapper
* directory — the layout the discovery RFC requires, and the one that makes `SKILL.md` land where an
* unpacking client looks for it.
*
* `portable: true` strips uids and gids; `noMtime` is the one that matters, because `portable`
* deliberately keeps mtime and every CI checkout stamps a fresh one. Without it the digest changed
* on every deploy for byte-identical content, invalidating anything that had cached it.
*
* Dotfiles are skipped: the only one is a `.gitignore` covering the `references/` cache the skill
* fetches at runtime, which means nothing outside this repo.
*/
function buildSkillArchive(sourceDir: string): Buffer {
const cwd = resolveSkillDir(sourceDir)
const entries = readdirSync(cwd)
.filter((f) => !f.startsWith('.'))
.sort()
// `sync: true` with no `file` returns a pack whose contents can be read straight out as a buffer.
const pack = create(
{ gzip: true, portable: true, noMtime: true, cwd, sync: true },
entries
) as unknown as {
read(): Buffer
}
return pack.read()
}
/**
* Locates a skill by walking up from the working directory until the path resolves.
*
* Which directory that starts from depends on the caller: `next build` runs in the app, while the
* root `vitest run` starts at the repo root. A fixed number of `..` segments is therefore right for
* one caller and wrong for the other — and when it was wrong it read from above the checkout
* entirely, so the failure was an ENOENT naming a path outside the repo rather than anything that
* pointed here.
*/
export function resolveSkillDir(sourceDir: string): string {
for (let dir = process.cwd(); ; dir = dirname(dir)) {
const candidate = join(dir, sourceDir)
if (existsSync(join(candidate, 'SKILL.md'))) return candidate
if (dirname(dir) === dir) {
throw new Error(`No ${sourceDir}/SKILL.md in any directory above ${process.cwd()}`)
}
}
}
/**
* Pulls one scalar out of the skill's own YAML frontmatter rather than restating it here, so the
* published index cannot describe a skill differently from the skill itself.
*
* Deliberately not a YAML parser: the fields taken are single-line scalars, and a build that fails
* loudly on an unreadable one is better than a dependency added to read six lines.
*/
function readFrontmatterField(markdown: string, field: string, sourceDir: string): string {
const frontmatter = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---/)
if (!frontmatter) throw new Error(`${sourceDir}/SKILL.md has no frontmatter`)
const match = frontmatter[1].match(new RegExp(`^${field}:\\s*(.+)$`, 'm'))
if (!match) throw new Error(`${sourceDir}/SKILL.md frontmatter has no \`${field}\``)
return match[1].trim().replace(/^['"]|['"]$/g, '')
}

View File

@@ -0,0 +1,53 @@
export interface SearchEntry {
objectID: string
/** Path do this article (not including slug) */
path: string
/** The nicely-formatted title of this entry. mostly for display. */
title: string
/** `title`, but with the heading placed first, so it matches with higher priority */
titleHeadingFirst: string
/** Any additional keywords specified for this entry */
keywords: string[]
/** A short description of the entry */
description: string | null
/** Plain-text entry content */
content: string
/** Name of the section this entry belongs to */
section: string
/** how relevant is that section? higher is better */
sectionPriority: number
/** Title of the article this entry belongs to. Same as `title` if `heading` is null */
article: string
/** Position of the article within the section */
articleIndex: number
/** The heading within an article this is an entry for */
heading: string | null
/** The heading slug - how to locate the heading within the artice */
headingSlug: string | null
/** Position of the heading within the article */
headingIndex: number
/** Manual rank adjustment for e.g. inherited entries */
rankAdjust: number
}
export interface SearchEntryWithIndex extends SearchEntry {
index: SearchIndexName
}
export type SearchIndexName = 'docs' | 'blog'
const vercelEnv = process.env.NEXT_PUBLIC_VERCEL_ENV ?? 'development'
const tldrawEnv =
vercelEnv === 'preview' && process.env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_REF === 'main'
? 'staging'
: vercelEnv
export function getSearchIndexName(name: SearchIndexName) {
if (tldrawEnv === 'production') return name
return `${name}-${tldrawEnv}`
}

View File

@@ -0,0 +1,165 @@
import { describe, expect, it } from 'vitest'
import { assetUrl } from './asset-url'
describe('assetUrl', () => {
it('returns the original path when no origin env is set', () => {
const prev = saveEnv()
try {
clearAssetEnv()
expect(assetUrl('/favicon.svg')).toBe('/favicon.svg')
} finally {
restoreEnv(prev)
}
})
it('returns the original path when ASSET_PREFIX is empty/whitespace', () => {
const prev = saveEnv()
try {
clearAssetEnv()
process.env.ASSET_PREFIX = ' '
expect(assetUrl('/favicon.svg')).toBe('/favicon.svg')
} finally {
restoreEnv(prev)
}
})
it('prefixes root-relative paths with ASSET_PREFIX', () => {
const prev = saveEnv()
try {
clearAssetEnv()
process.env.ASSET_PREFIX = 'https://docs.example.com'
expect(assetUrl('/images/foo.png')).toBe('https://docs.example.com/images/foo.png')
} finally {
restoreEnv(prev)
}
})
it('strips a trailing slash from the prefix', () => {
const prev = saveEnv()
try {
clearAssetEnv()
process.env.ASSET_PREFIX = 'https://docs.example.com/'
expect(assetUrl('/images/foo.png')).toBe('https://docs.example.com/images/foo.png')
} finally {
restoreEnv(prev)
}
})
it('uses VERCEL_URL when ASSET_PREFIX is unset', () => {
const prev = saveEnv()
try {
clearAssetEnv()
process.env.VERCEL_URL = 'tldraw-docs-abc123.vercel.app'
expect(assetUrl('/images/foo.png')).toBe(
'https://tldraw-docs-abc123.vercel.app/images/foo.png'
)
} finally {
restoreEnv(prev)
}
})
it('uses NEXT_PUBLIC_VERCEL_URL when other origins unset', () => {
const prev = saveEnv()
try {
clearAssetEnv()
process.env.NEXT_PUBLIC_VERCEL_URL = 'preview.vercel.app'
expect(assetUrl('/x')).toBe('https://preview.vercel.app/x')
} finally {
restoreEnv(prev)
}
})
it('prefers ASSET_PREFIX over NEXT_PUBLIC_DOCS_ASSET_PREFIX', () => {
const prev = saveEnv()
try {
clearAssetEnv()
process.env.ASSET_PREFIX = 'https://primary.example.com'
process.env.NEXT_PUBLIC_DOCS_ASSET_PREFIX = 'https://ignored.example.com'
expect(assetUrl('/favicon.svg')).toBe('https://primary.example.com/favicon.svg')
} finally {
restoreEnv(prev)
}
})
it('uses NEXT_PUBLIC_DOCS_ASSET_PREFIX when ASSET_PREFIX unset', () => {
const prev = saveEnv()
try {
clearAssetEnv()
process.env.NEXT_PUBLIC_DOCS_ASSET_PREFIX = 'https://docs-public.example.com'
expect(assetUrl('/images/a.png')).toBe('https://docs-public.example.com/images/a.png')
} finally {
restoreEnv(prev)
}
})
it('uses NEXT_PUBLIC_ASSET_PREFIX when earlier keys unset', () => {
const prev = saveEnv()
try {
clearAssetEnv()
process.env.NEXT_PUBLIC_ASSET_PREFIX = 'https://np.example.com'
expect(assetUrl('/b.png')).toBe('https://np.example.com/b.png')
} finally {
restoreEnv(prev)
}
})
it('prefers ASSET_PREFIX over VERCEL_URL', () => {
const prev = saveEnv()
try {
clearAssetEnv()
process.env.ASSET_PREFIX = 'https://explicit.example.com'
process.env.VERCEL_URL = 'tldraw-docs-abc123.vercel.app'
expect(assetUrl('/favicon.svg')).toBe('https://explicit.example.com/favicon.svg')
} finally {
restoreEnv(prev)
}
})
it('does not change https URLs or relative filenames', () => {
const prev = saveEnv()
try {
clearAssetEnv()
process.env.ASSET_PREFIX = 'https://docs.example.com'
expect(assetUrl('https://cdn.example.com/a.png')).toBe('https://cdn.example.com/a.png')
expect(assetUrl('relative.png')).toBe('relative.png')
} finally {
restoreEnv(prev)
}
})
it('does not prefix protocol-relative URLs', () => {
const prev = saveEnv()
try {
clearAssetEnv()
process.env.ASSET_PREFIX = 'https://docs.example.com'
expect(assetUrl('//cdn.example.com/x.png')).toBe('//cdn.example.com/x.png')
} finally {
restoreEnv(prev)
}
})
})
function saveEnv() {
return {
ASSET_PREFIX: process.env.ASSET_PREFIX,
NEXT_PUBLIC_ASSET_PREFIX: process.env.NEXT_PUBLIC_ASSET_PREFIX,
NEXT_PUBLIC_DOCS_ASSET_PREFIX: process.env.NEXT_PUBLIC_DOCS_ASSET_PREFIX,
VERCEL_URL: process.env.VERCEL_URL,
NEXT_PUBLIC_VERCEL_URL: process.env.NEXT_PUBLIC_VERCEL_URL,
}
}
function restoreEnv(prev: ReturnType<typeof saveEnv>) {
for (const [k, v] of Object.entries(prev)) {
if (v === undefined) delete process.env[k as keyof typeof prev]
else process.env[k as keyof typeof prev] = v
}
}
function clearAssetEnv() {
delete process.env.ASSET_PREFIX
delete process.env.NEXT_PUBLIC_ASSET_PREFIX
delete process.env.NEXT_PUBLIC_DOCS_ASSET_PREFIX
delete process.env.VERCEL_URL
delete process.env.NEXT_PUBLIC_VERCEL_URL
}

View File

@@ -0,0 +1,40 @@
/**
* Resolves root-relative static URLs (`/images/...`, `/favicon.svg`) to absolute URLs when the
* docs app is deployed with `assetPrefix` (e.g. proxied via tldraw.dev per RUNBOOK). Next applies
* `assetPrefix` to `/_next/*` automatically; strings in MDX remain plain paths unless we prefix them.
*
* Precedence (first hit wins):
* 1. `ASSET_PREFIX` — server/build (Vercel / CI)
* 2. `NEXT_PUBLIC_ASSET_PREFIX` — same value inlined for client bundles when needed
* 3. `NEXT_PUBLIC_DOCS_ASSET_PREFIX` — alias for ops clarity (same behavior as #2)
* 4. `VERCEL_URL` — preview deployments
* 5. `NEXT_PUBLIC_VERCEL_URL` — client-side fallback for preview hostname
*/
function resolveAssetOrigin(): string | undefined {
const explicit =
process.env.ASSET_PREFIX?.trim() ||
process.env.NEXT_PUBLIC_ASSET_PREFIX?.trim() ||
process.env.NEXT_PUBLIC_DOCS_ASSET_PREFIX?.trim()
if (explicit) return explicit.replace(/\/$/, '')
const vercelUrl = process.env.VERCEL_URL?.trim() || process.env.NEXT_PUBLIC_VERCEL_URL?.trim()
if (vercelUrl) {
const host = vercelUrl.replace(/^https?:\/\//, '')
return `https://${host}`
}
return undefined
}
/**
* Prefix a root-relative public/static path for the current docs deployment.
* Use for MDX images, icons in metadata, and any `/…` URL that must load from the docs origin
* when the site is served behind a proxy or alternate host.
*/
export function assetUrl(path: string): string {
if (path.startsWith('//')) return path
if (!path.startsWith('/')) return path
const origin = resolveAssetOrigin()
if (!origin) return path
return `${origin}${path}`
}

View File

@@ -0,0 +1,73 @@
import { Database } from 'sqlite'
import sqlite3 from 'sqlite3'
/* eslint-disable no-useless-escape */
import { Article } from '@/types/content-types'
export async function autoLinkDocs(db: Database<sqlite3.Database, sqlite3.Statement>) {
// replace [TLEditor](?) with [TLEditor](/reference/editor/TLEditor)?
// not sure how we would get there but finding an article with the same title
const articles = await db.all(
'SELECT id, content, path FROM articles WHERE sectionId != ?',
'reference'
)
await Promise.all(articles.map((a) => autoLinkDocsForArticle(db, a)))
}
const regex = /\[`?([^\[\]]*?)`?\]\(\?\)/g
export async function autoLinkDocsForArticle(
db: Database<sqlite3.Database, sqlite3.Statement>,
{ id, content, path }: Pick<Article, 'id' | 'content' | 'path'>
) {
if (!content) return
let didChange = false
let result = content
const matches = content.matchAll(regex)
if (!matches) return
for (const match of Array.from(matches)) {
const [hit, _title] = match
const [title, heading] = _title.split('#')
const article = await db.get(
'SELECT id FROM articles WHERE title = ? AND sectionId = ?',
title,
'reference'
)
// Get the LOC of the error
const loc = content.split('\n').reduce((acc, line, i) => {
if (line.includes(hit)) {
acc = i
}
return acc
}, 0)
if (!article) {
throw Error(`💥 Could not find article for ${_title} (${title}) in ${path} at line ${loc}`)
}
let str = ''
if (heading) {
// scoped to the article: the same slug (`children`, `dispose`...) exists on many pages
const headingRow = await db.get(
'SELECT slug FROM headings WHERE articleId = ? AND slug = ?',
article.id,
heading
)
if (!headingRow) throw Error(`Could not find heading for ${_title} (${heading}) in ${id}`)
str = `[\`${title}.${heading}\`](/${article.id}#${headingRow.slug})`
} else {
str = `[\`${title}\`](/${article.id})`
}
result = result.replaceAll(hit, str)
didChange = true
}
if (didChange) {
await db.run('UPDATE articles SET content = ? WHERE id = ?', result, id)
}
}

6
apps/docs/utils/cn.ts Normal file
View File

@@ -0,0 +1,6 @@
import { ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@@ -0,0 +1 @@
export const TABLE_OF_CONTENTS_CLASSNAME = 'article__table-of-contents'

View File

@@ -0,0 +1,4 @@
export function nicelog(...args: any[]) {
// eslint-disable-next-line no-console
console.log(...args)
}

View File

@@ -0,0 +1,425 @@
import { parseMarkdown } from './parse-markdown'
test('approximately', () => {
expect(
parseMarkdown(
`
Whether two [numbers](/link) numbers a and b are approximately equal.
<CodeLinks links={{}}>
\`\`\`ts
function approximately(a: number, b: number, precision?: number): boolean
\`\`\`
</CodeLinks>
<ApiHeading>Parameters</ApiHeading>
<ParametersTable>
<ParametersTableRow>
<ParametersTableName>
\`a\`
</ParametersTableName>
<ParametersTableDescription>
<CodeLinks links={{}}>
\`\`\`ts
number
\`\`\`
</CodeLinks>
The first point.
</ParametersTableDescription>
</ParametersTableRow>
<ParametersTableRow>
<ParametersTableName>
\`b\`
</ParametersTableName>
<ParametersTableDescription>
<CodeLinks links={{}}>
\`\`\`ts
number
\`\`\`
</CodeLinks>
The second point.
</ParametersTableDescription>
</ParametersTableRow>
<ParametersTableRow>
<ParametersTableName>
\`precision\`
</ParametersTableName>
<ParametersTableDescription>
<CodeLinks links={{}}>
\`\`\`ts
number
\`\`\`
</CodeLinks>
</ParametersTableDescription>
</ParametersTableRow>
</ParametersTable>
<ApiHeading>Returns</ApiHeading>
<CodeLinks links={{}}>
\`\`\`ts
boolean
\`\`\`
</CodeLinks>
`,
'test'
)
).toMatchInlineSnapshot(`
{
"allContentText": "Whether two numbers numbers a and b are approximately equal. Parameters: a (The first point), b (The second point), precision. ",
"headings": [],
"initialContentText": "Whether two numbers numbers a and b are approximately equal. Parameters: a (The first point), b (The second point), precision. ",
}
`)
})
test('BaseRecord', () => {
expect(
parseMarkdown(
`
<details className="article__table-of-contents">
<summary>Table of contents</summary>
- [id](#id)
- [typeName](#typeName)
- [Properties](#properties)
</details>
The base record that all records must extend.
<CodeLinks links={{"RecordId":"/reference/store/RecordId","UnknownRecord":"/reference/store/UnknownRecord"}}>
\`\`\`ts
interface BaseRecord<
TypeName extends string,
Id extends RecordId<UnknownRecord>,
> {}
\`\`\`
</CodeLinks>
---
## Properties
<ApiMemberTitle tags={["readonly"]} inherited={null}>
### id
</ApiMemberTitle>
<CodeLinks links={{}}>
\`\`\`ts
readonly id: Id
\`\`\`
</CodeLinks>
---
<ApiMemberTitle tags={["readonly"]} inherited={null}>
### typeName
</ApiMemberTitle>
<CodeLinks links={{}}>
\`\`\`ts
readonly typeName: TypeName
\`\`\`
</CodeLinks>
---
`,
'test'
)
).toMatchInlineSnapshot(`
{
"allContentText": "The base record that all records must extend. Properties id typeName",
"headings": [
{
"contentText": "",
"isInherited": false,
"level": 2,
"slug": "Properties",
"title": "Properties",
},
{
"contentText": "",
"isInherited": false,
"level": 3,
"slug": "id",
"title": "id",
},
{
"contentText": "",
"isInherited": false,
"level": 3,
"slug": "typeName",
"title": "typeName",
},
],
"initialContentText": "The base record that all records must extend.",
}
`)
})
test('TLAssetStore', () => {
expect(
parseMarkdown(
`
<details className="article__table-of-contents">
<summary>Table of contents</summary>
- [Methods](#methods)
- [resolve](#resolve)
- [upload](#upload)
</details>
A \`TLAssetStore\` sits alongside the main [TLStore](/reference/tlschema/TLStore) and is responsible for storing and
retrieving large assets such as images. Generally, this should be part of a wider sync system:
- By default, the store is in-memory only, so \`TLAssetStore\` converts images to data URLs
- When using
[\`persistenceKey\`](/reference/editor/TldrawEditorWithoutStoreProps#persistenceKey), the
store is synced to the browser's local IndexedDB, so \`TLAssetStore\` stores images there too
- When using a multiplayer sync server, you would implement \`TLAssetStore\` to upload images to
e.g. an S3 bucket.
<CodeLinks links={{}}>
\`\`\`ts
interface TLAssetStore {}
\`\`\`
</CodeLinks>
---
## Methods
<ApiMemberTitle tags={["optional"]} inherited={null}>
### resolve
</ApiMemberTitle>
Resolve an asset to a URL. This is used when rendering the asset in the editor. By default,
this will just use \`asset.props.src\`, the URL returned by \`upload()\`. This can be used to
rewrite that URL to add access credentials, or optimized the asset for how it's currently
being displayed using the [information provided](/reference/tlschema/TLAssetContext).
<ApiHeading>Parameters</ApiHeading>
<ParametersTable>
<ParametersTableRow>
<ParametersTableName>
\`asset\`
</ParametersTableName>
<ParametersTableDescription>
<CodeLinks links={{"TLAsset":"/reference/tlschema/TLAsset"}}>
\`\`\`ts
TLAsset
\`\`\`
</CodeLinks>
the asset being resolved
</ParametersTableDescription>
</ParametersTableRow>
<ParametersTableRow>
<ParametersTableName>
\`ctx\`
</ParametersTableName>
<ParametersTableDescription>
<CodeLinks links={{"TLAssetContext":"/reference/tlschema/TLAssetContext"}}>
\`\`\`ts
TLAssetContext
\`\`\`
</CodeLinks>
information about the current environment and where the asset is being used
</ParametersTableDescription>
</ParametersTableRow>
</ParametersTable>
<ApiHeading>Returns</ApiHeading>
<CodeLinks links={{}}>
\`\`\`ts
null | Promise<null | string> | string
\`\`\`
</CodeLinks>
The URL of the resolved asset, or \`null\` if the asset is not available
---
<ApiMemberTitle tags={[]} inherited={null}>
### upload
</ApiMemberTitle>
Upload an asset to your storage, returning a URL that can be used to refer to the asset
long-term.
<ApiHeading>Parameters</ApiHeading>
<ParametersTable>
<ParametersTableRow>
<ParametersTableName>
\`asset\`
</ParametersTableName>
<ParametersTableDescription>
<CodeLinks links={{"TLAsset":"/reference/tlschema/TLAsset"}}>
\`\`\`ts
TLAsset
\`\`\`
</CodeLinks>
Information & metadata about the asset being uploaded
</ParametersTableDescription>
</ParametersTableRow>
<ParametersTableRow>
<ParametersTableName>
\`file\`
</ParametersTableName>
<ParametersTableDescription>
<CodeLinks links={{}}>
\`\`\`ts
File
\`\`\`
</CodeLinks>
The \`File\` to be uploaded
</ParametersTableDescription>
</ParametersTableRow>
</ParametersTable>
<ApiHeading>Returns</ApiHeading>
<CodeLinks links={{}}>
\`\`\`ts
Promise<string>
\`\`\`
</CodeLinks>
A promise that resolves to the URL of the uploaded asset
---
`,
'test'
)
).toMatchInlineSnapshot(`
{
"allContentText": "A TLAssetStore sits alongside the main TLStore and is responsible for storing and retrieving large assets such as images. Generally, this should be part of a wider sync system: By default, the store is in-memory only, so TLAssetStore converts images to data URLs When using persistenceKey, the store is synced to the browser's local IndexedDB, so TLAssetStore stores images there too When using a multiplayer sync server, you would implement TLAssetStore to upload images to e.g. an S3 bucket. Methods resolve Resolve an asset to a URL. This is used when rendering the asset in the editor. By default, this will just use asset.props.src, the URL returned by upload(). This can be used to rewrite that URL to add access credentials, or optimized the asset for how it's currently being displayed using the information provided. Parameters: asset (the asset being resolved), ctx (information about the current environment and where the asset is being used). Returns: The URL of the resolved asset, or null if the asset is not available upload Upload an asset to your storage, returning a URL that can be used to refer to the asset long-term. Parameters: asset (Information & metadata about the asset being uploaded), file (The File to be uploaded). Returns: A promise that resolves to the URL of the uploaded asset",
"headings": [
{
"contentText": "",
"isInherited": false,
"level": 2,
"slug": "Methods",
"title": "Methods",
},
{
"contentText": "Resolve an asset to a URL. This is used when rendering the asset in the editor. By default, this will just use asset.props.src, the URL returned by upload(). This can be used to rewrite that URL to add access credentials, or optimized the asset for how it's currently being displayed using the information provided. Parameters: asset (the asset being resolved), ctx (information about the current environment and where the asset is being used). Returns: The URL of the resolved asset, or null if the asset is not available",
"isInherited": false,
"level": 3,
"slug": "resolve",
"title": "resolve",
},
{
"contentText": "Upload an asset to your storage, returning a URL that can be used to refer to the asset long-term. Parameters: asset (Information & metadata about the asset being uploaded), file (The File to be uploaded). Returns: A promise that resolves to the URL of the uploaded asset",
"isInherited": false,
"level": 3,
"slug": "upload",
"title": "upload",
},
],
"initialContentText": "A TLAssetStore sits alongside the main TLStore and is responsible for storing and retrieving large assets such as images. Generally, this should be part of a wider sync system: By default, the store is in-memory only, so TLAssetStore converts images to data URLs When using persistenceKey, the store is synced to the browser's local IndexedDB, so TLAssetStore stores images there too When using a multiplayer sync server, you would implement TLAssetStore to upload images to e.g. an S3 bucket.",
}
`)
})

View File

@@ -0,0 +1,276 @@
import { assert, assertExists } from '@tldraw/utils'
import GithubSlugger from 'github-slugger'
import { Nodes, Root } from 'mdast'
import { MdxJsxFlowElement, MdxJsxTextElement } from 'mdast-util-mdx'
import { remark } from 'remark'
import remarkMdx from 'remark-mdx'
import { TABLE_OF_CONTENTS_CLASSNAME } from './config'
const BREAK = '\n\n'
// Add nodes to mdast content.
declare module 'mdast' {
interface BlockContentMap {
/**
* MDX JSX element node, occurring in flow (block).
*/
mdxJsxFlowElement: MdxJsxFlowElement
}
interface PhrasingContentMap {
/**
* MDX JSX element node, occurring in text (phrasing).
*/
mdxJsxTextElement: MdxJsxTextElement
}
interface RootContentMap {
/**
* MDX JSX element node, occurring in flow (block).
*/
mdxJsxFlowElement: MdxJsxFlowElement
/**
* MDX JSX element node, occurring in text (phrasing).
*/
mdxJsxTextElement: MdxJsxTextElement
}
}
export interface ParsedHeading {
level: number
title: string
slug: string
contentText: string
isInherited: boolean
}
/**
* Walk the MDX AST of `content` and extract headings & their (plain text) content as we go.
*
* This returns an array of heading objects containing the heading info, and the text below it
* (before the next heading). It also returns the initial content text before the first heading.
*
* Heading extraction should be reliable, but content extraction is pretty best-effort. It's useful
* for a search index (which is what we use it for!), but might not be a perfect representation of
* everything on the page.
*/
export function parseMarkdown(
content: string,
debugContext: string
): {
headings: ParsedHeading[]
allContentText: string
initialContentText: string
} {
// the headings found so far. we start with a dummy heading at the beginning to hold the initial
// content text, but we'll remove it at the end.
const headings: Omit<ParsedHeading, 'slug'>[] = [
{
level: 0,
title: '',
contentText: '',
isInherited: false,
},
]
// all the text we've seen so far, including headings
let allContentText = ''
interface State {
isInHeading: boolean
isInherited: boolean
}
function visitChildren(node: Nodes, state: State) {
if ('children' in node) {
for (const child of node.children) {
visit(child, state)
}
}
}
function currentHeading() {
const currentHeading = headings[headings.length - 1]
return currentHeading
}
function addText(text: string, state: State) {
if (state.isInHeading) {
currentHeading().title += text
} else {
currentHeading().contentText += text
}
allContentText += text
}
// add a paragraph break if needed:
function breakText(state: State) {
assert(!state.isInHeading)
if (!currentHeading().contentText.endsWith(BREAK)) {
currentHeading().contentText += BREAK
}
if (!allContentText.endsWith(BREAK)) {
allContentText += BREAK
}
}
function visit(node: Nodes, state: State) {
switch (node.type) {
case 'heading': {
const { depth } = node
allContentText += BREAK
headings.push({
level: depth,
title: '',
contentText: '',
isInherited: state.isInherited,
})
visitChildren(node, { ...state, isInHeading: true })
break
}
case 'text':
case 'inlineCode':
addText(node.value, state)
break
case 'blockquote':
case 'paragraph':
breakText(state)
visitChildren(node, state)
break
case 'code':
// skip these entirely
break
case 'mdxJsxFlowElement':
case 'mdxJsxTextElement': {
const { name, attributes } = node
if (node.type === 'mdxJsxFlowElement') {
breakText(state)
}
if (
// Remove table of contents
name === 'details' &&
attributes.some(
(attr) =>
'name' in attr &&
attr.name === 'className' &&
attr.value === TABLE_OF_CONTENTS_CLASSNAME
)
) {
break
}
if (name === 'ApiMemberTitle') {
// each ApiMemberTitle, which is used for the title of everything in the API
// reference docs, should have an `inherited` attribute:
const inheritedAttr = attributes.find(
(attr) => attr.type === 'mdxJsxAttribute' && attr.name === 'inherited'
)
if (!inheritedAttr) {
throw new Error(`ApiMemberTitle missing inherited attribute (in ${debugContext})`)
}
if (
!inheritedAttr.value ||
typeof inheritedAttr.value !== 'object' ||
inheritedAttr.type !== 'mdxJsxAttribute'
) {
throw new Error(
`Unknown format for ApiMemberTitle inherited attribute (in ${debugContext})`
)
}
// if it doesn't have the value `null`, then it's inherited. otherwise, we can
// visit its children normally.
if (inheritedAttr.value.value !== 'null') {
visitChildren(node, { ...state, isInherited: true })
break
}
}
if (name === 'ParametersTableDescription') {
addText('(', state)
}
visitChildren(node, state)
if (name === 'ApiHeading') {
addText(':', state)
breakText(state)
}
if (name === 'ParametersTableDescription') {
addText(')', state)
}
if (name === 'ParametersTableRow') {
addText(',', state)
}
if (name === 'ParametersTable') {
breakText(state)
addText('.', state)
}
break
}
default:
visitChildren(node, state)
}
}
remark()
.use(remarkMdx)
.use(() => (tree: Root) => {
visit(tree, { isInHeading: false, isInherited: false })
})
.processSync(content)
const initial = assertExists(headings.shift())
const slugs = new GithubSlugger()
return {
initialContentText: postprocessText(initial.contentText),
allContentText: postprocessText(allContentText),
headings: headings.map((heading) => ({
...heading,
slug: slugs.slug(heading.title, true),
contentText: postprocessText(heading.contentText),
})),
}
}
function postprocessText(text: string) {
return (
text
// remove trailing/leading whitespace in each line:
.replace(/^\s+/gm, '')
.replace(/\s+$/gm, '')
// remove empty lines:
.replace(/^\s*$/gm, '')
// place on a single line:
.replace(/\s+/g, ' ')
// empty brackets:
.replace(/\( \)/g, '')
// bracket spacing:
.replace(/\( /g, '(')
.replace(/ \)/g, ')')
// comma leading space:
.replace(/\s+,/g, ',')
// comma + period:
.replace(/,\s*\./g, '.')
// blank Returns:
.replace(/Returns:$/, '')
// weird punctuation:
.replace(/\.\),/g, '),')
)
}

View File

@@ -0,0 +1,6 @@
import algoliasearch from 'algoliasearch'
export const searchClient = algoliasearch(
process.env.NEXT_PUBLIC_ALGOLIA_APP_ID!,
process.env.NEXT_PUBLIC_ALGOLIA_SEARCH_KEY!
)

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import { canonicalDocsPageUrl, canonicalizeDocsSitemapPath } from './sitemap-canonical-paths'
describe('canonicalizeDocsSitemapPath', () => {
it('maps duplicate-content alias paths to canonical URLs', () => {
expect(canonicalizeDocsSitemapPath('/getting-started/quick-start')).toBe('/quick-start')
expect(canonicalizeDocsSitemapPath('/getting-started/installation')).toBe('/installation')
expect(canonicalizeDocsSitemapPath('/releases-versioning')).toBe('/releases')
})
it('leaves canonical paths unchanged', () => {
expect(canonicalizeDocsSitemapPath('/quick-start')).toBe('/quick-start')
expect(canonicalizeDocsSitemapPath('/releases')).toBe('/releases')
})
})
describe('canonicalDocsPageUrl', () => {
it('emits production-absolute canonical for alias and canonical paths', () => {
expect(canonicalDocsPageUrl('/getting-started/quick-start')).toBe(
'https://tldraw.dev/quick-start'
)
expect(canonicalDocsPageUrl('/quick-start')).toBe('https://tldraw.dev/quick-start')
})
})

View File

@@ -0,0 +1,36 @@
/** Public marketing apex — canonical `<link>` and sitemap `<loc>` URLs. */
export const PRODUCTION_SITE_ORIGIN = 'https://tldraw.dev'
/**
* Paths that 301 or rewrite to a canonical URL. Sitemap and `<link rel="canonical">`
* must use the canonical path so alias URLs do not dilute link equity.
*/
const DOCS_SITEMAP_PATH_ALIASES: Record<string, string> = {
'/getting-started/quick-start': '/quick-start',
'/getting-started/installation': '/installation',
'/getting-started/releases': '/releases',
'/releases-versioning': '/releases',
'/examples': '/examples/basic',
'/starter-kits': '/starter-kits/overview',
}
function normalizePathname(path: string): string {
const trimmed = path.trim()
if (!trimmed || trimmed === '/') return '/'
const withLeading = trimmed.startsWith('/') ? trimmed : `/${trimmed}`
return withLeading.replace(/\/+$/, '') || '/'
}
export function canonicalizeDocsSitemapPath(path: string): string {
const normalized = normalizePathname(path)
return DOCS_SITEMAP_PATH_ALIASES[normalized] ?? normalized
}
export function canonicalizeDocsSitemapPaths(paths: string[]): string[] {
return Array.from(new Set(paths.map(canonicalizeDocsSitemapPath))).sort()
}
/** Absolute canonical URL for a docs article path (alias or canonical). */
export function canonicalDocsPageUrl(pathname: string): string {
return `${PRODUCTION_SITE_ORIGIN}${canonicalizeDocsSitemapPath(pathname)}`
}

View File

@@ -0,0 +1,40 @@
import { getFromLocalStorage, setInLocalStorage } from '@tldraw/utils'
import { useCallback, useLayoutEffect, useState } from 'react'
/**
* Helper for dealing with values stored in local storage
*
* @param key - The key of the state we are interested in.
* @param defaultValue - The default value to use if there is no value in local storage.
* @param initialValue - The initial value to use before reading from local storage. `undefined` is not supported.
* @returns A tuple containing the current state and a function to update the state.
*
* @public
*/
export function useLocalStorageState<T = any>(key: string, defaultValue: T) {
const [state, setState] = useState<T>(defaultValue)
useLayoutEffect(() => {
const value = getFromLocalStorage(key)
if (value) {
try {
setState(JSON.parse(value))
} catch {
console.error(`Could not restore value ${key} from local storage.`)
}
}
}, [key])
const updateValue = useCallback(
(setter: T | ((value: T) => T)) => {
setState((s) => {
const value = typeof setter === 'function' ? (setter as any)(s) : setter
setInLocalStorage(key, JSON.stringify(value))
return value
})
},
[key]
)
return [state, updateValue] as const
}