Files
upstream-tldraw/internal/scripts/generate-tldraw-package-docs.ts
upstream-mirror a774ac673a
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
snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
2026-09-23 16:12:52 +05:00

335 lines
9.8 KiB
TypeScript

import { existsSync, readFileSync, readdirSync, writeFileSync } from 'fs'
import path from 'path'
import { fileURLToPath, pathToFileURL } from 'url'
const DOCS_SECTION_IDS = ['getting-started', 'docs', 'starter-kits', 'sdk-features', 'community']
const RELEASES_SECTION_ID = 'releases'
const RELEASE_NOTES_FILE_NAME = /^v\d+\.\d+\.\d+\.mdx$/
const NEXT_RELEASE_NOTES_FILE_NAME = 'next.mdx'
const UNWRAPPED_MDX_TAGS = new Set(['Callout', 'Feature', 'FocusLines', 'div'])
const OMITTED_MDX_TAGS = new Set(['Image', 'MaskedArticleImage', 'StarterKitBento', 'img'])
interface Article {
content: string
fileName: string
order: number | null
sectionId: string
title: string
version: [number, number, number] | null
}
interface Section {
id: string
title: string
description?: string
}
export function generateTldrawPackageDocs({
sourcePackageDir,
repoRoot = path.resolve(sourcePackageDir, '../..'),
}: {
sourcePackageDir: string
repoRoot?: string
}) {
const packageJsonPath = path.join(sourcePackageDir, 'package.json')
if (!existsSync(packageJsonPath)) {
throw new Error(`No package.json file found in '${sourcePackageDir}'!`)
}
const manifest = JSON.parse(readFileSync(packageJsonPath, 'utf8'))
const packageVersion = manifest.version
const contentDir = path.join(repoRoot, 'apps/docs/content')
const sections = readSections(contentDir)
writeFileSync(
path.join(sourcePackageDir, 'DOCS.md'),
createDocsRollup({ contentDir, packageVersion, sections })
)
writeFileSync(
path.join(sourcePackageDir, 'RELEASE_NOTES.md'),
createReleaseNotesRollup({ contentDir, packageVersion, sections })
)
}
export function createDocsRollup({
contentDir,
packageVersion,
sections,
}: {
contentDir: string
packageVersion: string
sections: Section[]
}) {
const result = [
'# tldraw Documentation',
'',
`Version: \`${packageVersion}\``,
'',
'This file is generated during package publishing from the tldraw docs content for this exact package version.',
]
for (const sectionId of DOCS_SECTION_IDS) {
const section = sectionById(sections, sectionId)
const articles = readArticles(contentDir, sectionId)
if (!articles.length) continue
result.push('', `## ${section.title}`)
if (section.description) result.push('', section.description)
for (const article of articles) {
result.push('', `### ${article.title}`, '', normalizeMdxForMarkdown(article.content, 2))
}
}
return result.join('\n').trimEnd() + '\n'
}
export function createReleaseNotesRollup({
contentDir,
packageVersion,
sections,
}: {
contentDir: string
packageVersion: string
sections: Section[]
}) {
const section = sectionById(sections, RELEASES_SECTION_ID)
const releaseNotes = readReleaseNotesArticles(contentDir, packageVersion)
.filter((article): article is Article & { version: [number, number, number] } =>
Boolean(article.version)
)
.sort((a, b) => compareVersions(b.version, a.version))
const result = [
`# ${section.title}`,
'',
`Version: \`${packageVersion}\``,
'',
'This file is generated during package publishing from the tldraw release notes content for this exact package version.',
]
for (const article of releaseNotes) {
result.push('', `## ${article.title}`, '', normalizeMdxForMarkdown(article.content, 1))
}
return result.join('\n').trimEnd() + '\n'
}
function readReleaseNotesArticles(contentDir: string, packageVersion: string) {
const releaseNotes = readArticles(contentDir, RELEASES_SECTION_ID)
const packageVersionTuple = getStableVersionTuple(packageVersion)
if (packageVersionTuple) {
const packageVersionTitle = `v${packageVersionTuple.join('.')}`
const nextReleaseNotes = readArticle(
path.join(contentDir, RELEASES_SECTION_ID, NEXT_RELEASE_NOTES_FILE_NAME),
RELEASES_SECTION_ID,
NEXT_RELEASE_NOTES_FILE_NAME
)
if (
!releaseNotes.some((article) => article.title === packageVersionTitle) &&
nextReleaseNotes
) {
releaseNotes.push({
...nextReleaseNotes,
fileName: `${packageVersionTitle}.mdx`,
title: packageVersionTitle,
version: packageVersionTuple,
})
}
}
return releaseNotes
}
function readSections(contentDir: string): Section[] {
return JSON.parse(readFileSync(path.join(contentDir, 'sections.json'), 'utf8'))
}
function sectionById(sections: Section[], sectionId: string) {
const section = sections.find((section) => section.id === sectionId)
if (!section) {
throw new Error(`Could not find docs section '${sectionId}'`)
}
return section
}
function readArticles(contentDir: string, sectionId: string) {
const sectionDir = path.join(contentDir, sectionId)
return readdirSync(sectionDir)
.filter((fileName) => fileName.endsWith('.mdx') || fileName.endsWith('.md'))
.filter(
(fileName) => sectionId !== RELEASES_SECTION_ID || RELEASE_NOTES_FILE_NAME.test(fileName)
)
.map((fileName) => readArticle(path.join(sectionDir, fileName), sectionId, fileName))
.filter((article) => article !== null)
.sort(sortArticles)
}
function readArticle(filePath: string, sectionId: string, fileName: string): Article | null {
const { data, content } = parseFrontMatter(readFileSync(filePath, 'utf8'))
if (data.status !== 'published') return null
const title = data.title ?? path.basename(fileName, path.extname(fileName))
const order = data.order ? Number(data.order) : null
const versionMatch = fileName.match(/^v(\d+)\.(\d+)\.(\d+)\.mdx$/)
return {
content,
fileName,
order: Number.isFinite(order) ? order : null,
sectionId,
title,
version: versionMatch
? [Number(versionMatch[1]), Number(versionMatch[2]), Number(versionMatch[3])]
: null,
}
}
function parseFrontMatter(raw: string): { data: Record<string, string>; content: string } {
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/)
if (!match) return { data: {}, content: raw }
const data: Record<string, string> = {}
for (const line of match[1].split(/\r?\n/)) {
const lineMatch = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/)
if (!lineMatch) continue
const [, key, rawValue] = lineMatch
let value = rawValue.trim()
if (
(value.startsWith("'") && value.endsWith("'")) ||
(value.startsWith('"') && value.endsWith('"'))
) {
value = value.slice(1, -1)
}
data[key] = value
}
return { data, content: raw.slice(match[0].length).trim() }
}
function sortArticles(a: Article, b: Article) {
const orderA = a.order ?? Number.MAX_SAFE_INTEGER
const orderB = b.order ?? Number.MAX_SAFE_INTEGER
if (orderA !== orderB) return orderA - orderB
return a.title.localeCompare(b.title)
}
function compareVersions(a: [number, number, number], b: [number, number, number]) {
return a[0] - b[0] || a[1] - b[1] || a[2] - b[2]
}
function getStableVersionTuple(packageVersion: string): [number, number, number] | null {
const match = packageVersion.match(/^(\d+)\.(\d+)\.(\d+)$/)
return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null
}
function normalizeMdxForMarkdown(content: string, headingDemotion: number) {
const result: string[] = []
let inFence = false
let omittedMdxTag: string | null = null
for (const line of content.split(/\r?\n/)) {
const trimmed = line.trim()
if (/^(```|~~~)/.test(trimmed)) {
inFence = !inFence
result.push(line)
continue
}
if (!inFence) {
if (omittedMdxTag) {
if (trimmed.includes('/>') || trimmed.includes(`</${omittedMdxTag}>`)) {
omittedMdxTag = null
}
continue
}
if (/^import\s.+\sfrom\s/.test(trimmed)) continue
if (/^export\s+(const|function|class|type|interface|default|\{)/.test(trimmed)) continue
const mdxLine = normalizeMdxLine(trimmed)
if (mdxLine.kind === 'omit') {
omittedMdxTag = mdxLine.tag
continue
}
if (mdxLine.kind === 'skip') continue
if (mdxLine.kind === 'replace') {
result.push(rewriteLinks(demoteHeading(mdxLine.content, headingDemotion)))
continue
}
result.push(rewriteLinks(demoteHeading(line, headingDemotion)))
} else {
result.push(line)
}
}
return result
.join('\n')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
function normalizeMdxLine(
trimmed: string
):
| { kind: 'keep' }
| { kind: 'skip' }
| { kind: 'omit'; tag: string }
| { kind: 'replace'; content: string } {
const checkItemMatch = trimmed.match(/^<CheckItem>(.*?)<\/CheckItem>$/)
if (checkItemMatch) {
return { kind: 'replace', content: `- ${checkItemMatch[1]}` }
}
const tagName = getMdxTagName(trimmed)
if (!tagName) return { kind: 'keep' }
if (tagName === 'br') return { kind: 'skip' }
if (OMITTED_MDX_TAGS.has(tagName)) {
return trimmed.includes('/>') || trimmed.includes(`</${tagName}>`)
? { kind: 'skip' }
: { kind: 'omit', tag: tagName }
}
if (UNWRAPPED_MDX_TAGS.has(tagName)) {
const title = tagName === 'Feature' ? getMdxStringProp(trimmed, 'title') : null
return title ? { kind: 'replace', content: `**${title}**` } : { kind: 'skip' }
}
if (/^<\/?[A-Z][A-Za-z0-9.]*\b/.test(trimmed)) {
return { kind: 'skip' }
}
return { kind: 'keep' }
}
function getMdxTagName(trimmed: string) {
const match = trimmed.match(/^<\/?([A-Za-z][A-Za-z0-9.]*)\b/)
return match?.[1] ?? null
}
function getMdxStringProp(trimmed: string, propName: string) {
const match = trimmed.match(new RegExp(`\\b${propName}=(?:"([^"]+)"|'([^']+)')`))
return match?.[1] ?? match?.[2] ?? null
}
function demoteHeading(line: string, headingDemotion: number) {
return line.replace(/^(#{1,6})(\s+)/, (_, hashes, spacing) => {
return '#'.repeat(Math.min(6, hashes.length + headingDemotion)) + spacing
})
}
function rewriteLinks(line: string) {
return line
.replace(/\[`?([^[\]]*?)`?\]\(\?\)/g, '`$1`')
.replace(/\]\((\/[^)\s]+)\)/g, '](https://tldraw.dev$1)')
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
generateTldrawPackageDocs({
sourcePackageDir: path.resolve(path.normalize(process.argv[2] ?? process.cwd())),
repoRoot: path.resolve(scriptDir, '../..'),
})
}