ban using @internal items in examples (#3746)

There's been some confusion in the community as our example use a few
`@internal` methods. These things are intended for use inside the tldraw
library, but aren't a part of the public API. That means that when those
examples are copied out of the tldraw repo, those `@internal` references
produce errors.

This diff bans the use of items tagged as `@internal` inside our
examples app by adding an eslint plugin (adapted from the one we already
have that protects against deprecated types) preventing them.

### Change Type
- [x] `docs` — Changes to the documentation, examples, or templates.
- [x] `bugfix` — Bug fix
- [x] `tools` — Changes to infrastructure, CI, internal scripts,
debugging tools, etc.
This commit is contained in:
alex 2024-05-14 09:49:28 +01:00 committed by GitHub
parent 7226afc1ff
commit 5a15c49d63
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 213 additions and 21 deletions

View file

@ -100,6 +100,7 @@ module.exports = {
files: ['apps/examples/**/*'], files: ['apps/examples/**/*'],
rules: { rules: {
'no-restricted-syntax': 'off', 'no-restricted-syntax': 'off',
'local/no-at-internal': 'error',
}, },
}, },
{ {

View file

@ -1,6 +1,5 @@
import { import {
DefaultSizeStyle, DefaultSizeStyle,
SharedStyleMap,
Tldraw, Tldraw,
TldrawUiIcon, TldrawUiIcon,
TLEditorComponents, TLEditorComponents,
@ -27,8 +26,7 @@ const ContextToolbarComponent = track(() => {
if (!selectionRotatedPageBounds) return null if (!selectionRotatedPageBounds) return null
// [2] // [2]
const styles = new SharedStyleMap(editor.getSharedStyles()) const size = editor.getSharedStyles().get(DefaultSizeStyle)
const size = styles.get(DefaultSizeStyle)
if (!size) return null if (!size) return null
const currentSize = size.type === 'shared' ? size.value : undefined const currentSize = size.type === 'shared' ? size.value : undefined

View file

@ -1,6 +1,6 @@
import { PDFDocument } from 'pdf-lib' import { PDFDocument } from 'pdf-lib'
import { useState } from 'react' import { useState } from 'react'
import { Editor, assert, exportToBlob, useEditor } from 'tldraw' import { Editor, exportToBlob, useEditor } from 'tldraw'
import { Pdf } from './PdfPicker' import { Pdf } from './PdfPicker'
export function ExportPdfButton({ pdf }: { pdf: Pdf }) { export function ExportPdfButton({ pdf }: { pdf: Pdf }) {
@ -40,7 +40,9 @@ async function exportPdf(
tickProgress() tickProgress()
const pdfPages = pdf.getPages() const pdfPages = pdf.getPages()
assert(pdfPages.length === pages.length, 'PDF page count mismatch') if (pdfPages.length !== pages.length) {
throw new Error('PDF page count mismatch')
}
const pageShapeIds = new Set(pages.map((page) => page.shapeId)) const pageShapeIds = new Set(pages.map((page) => page.shapeId))
const allIds = Array.from(editor.getCurrentPageShapeIds()).filter((id) => !pageShapeIds.has(id)) const allIds = Array.from(editor.getCurrentPageShapeIds()).filter((id) => !pageShapeIds.has(id))

View file

@ -1,13 +1,11 @@
import { useMemo } from 'react' import { useMemo } from 'react'
import { import {
Box, Box,
DEFAULT_CAMERA_OPTIONS,
SVGContainer, SVGContainer,
TLComponents, TLComponents,
TLImageShape, TLImageShape,
TLShapePartial, TLShapePartial,
Tldraw, Tldraw,
compact,
getIndicesBetween, getIndicesBetween,
react, react,
sortByIndex, sortByIndex,
@ -118,7 +116,6 @@ export function PdfEditor({ pdf }: { pdf: Pdf }) {
function updateCameraBounds(isMobile: boolean) { function updateCameraBounds(isMobile: boolean) {
editor.setCameraOptions({ editor.setCameraOptions({
...DEFAULT_CAMERA_OPTIONS,
constraints: { constraints: {
bounds: targetBounds, bounds: targetBounds,
padding: { x: isMobile ? 16 : 164, y: 64 }, padding: { x: isMobile ? 16 : 164, y: 64 },
@ -153,14 +150,14 @@ const PageOverlayScreen = track(function PageOverlayScreen({ pdf }: { pdf: Pdf }
const viewportPageBounds = editor.getViewportPageBounds() const viewportPageBounds = editor.getViewportPageBounds()
const viewportScreenBounds = editor.getViewportScreenBounds() const viewportScreenBounds = editor.getViewportScreenBounds()
const relevantPageBounds = compact( const relevantPageBounds = pdf.pages
pdf.pages.map((page) => { .map((page) => {
if (!viewportPageBounds.collides(page.bounds)) return null if (!viewportPageBounds.collides(page.bounds)) return null
const topLeft = editor.pageToViewport(page.bounds) const topLeft = editor.pageToViewport(page.bounds)
const bottomRight = editor.pageToViewport({ x: page.bounds.maxX, y: page.bounds.maxY }) const bottomRight = editor.pageToViewport({ x: page.bounds.maxX, y: page.bounds.maxY })
return new Box(topLeft.x, topLeft.y, bottomRight.x - topLeft.x, bottomRight.y - topLeft.y) return new Box(topLeft.x, topLeft.y, bottomRight.x - topLeft.x, bottomRight.y - topLeft.y)
}) })
) .filter((bounds): bounds is Box => bounds !== null)
function pathForPageBounds(bounds: Box) { function pathForPageBounds(bounds: Box) {
return `M ${bounds.x} ${bounds.y} L ${bounds.maxX} ${bounds.y} L ${bounds.maxX} ${bounds.maxY} L ${bounds.x} ${bounds.maxY} Z` return `M ${bounds.x} ${bounds.y} L ${bounds.maxX} ${bounds.y} L ${bounds.maxX} ${bounds.maxY} L ${bounds.x} ${bounds.maxY} Z`

View file

@ -1,5 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import { AssetRecordType, Box, TLAssetId, TLShapeId, assertExists, createShapeId } from 'tldraw' import { AssetRecordType, Box, TLAssetId, TLShapeId, createShapeId } from 'tldraw'
import tldrawPdf from './assets/tldraw.pdf' import tldrawPdf from './assets/tldraw.pdf'
export type PdfPage = { export type PdfPage = {
@ -30,7 +30,8 @@ export function PdfPicker({ onOpenPdf }: { onOpenPdf: (pdf: Pdf) => void }) {
const pages: PdfPage[] = [] const pages: PdfPage[] = []
const canvas = window.document.createElement('canvas') const canvas = window.document.createElement('canvas')
const context = assertExists(canvas.getContext('2d')) const context = canvas.getContext('2d')
if (!context) throw new Error('Failed to create canvas context')
const visualScale = 1.5 const visualScale = 1.5
const scale = window.devicePixelRatio const scale = window.devicePixelRatio

View file

@ -1,6 +1,5 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { import {
DEFAULT_CAMERA_OPTIONS,
Editor, Editor,
TLFrameShape, TLFrameShape,
Tldraw, Tldraw,
@ -39,7 +38,6 @@ function InsideSlidesContext() {
} }
editor.setCameraOptions({ editor.setCameraOptions({
...DEFAULT_CAMERA_OPTIONS,
constraints: { constraints: {
bounds: nextBounds, bounds: nextBounds,
behavior: 'contain', behavior: 'contain',

View file

@ -1,3 +1,4 @@
/* eslint-disable local/no-at-internal -- this is only used for our internal develop endpoint */
import { TLUiEventSource, TLUiOverrides, debugFlags, measureCbDuration, useValue } from 'tldraw' import { TLUiEventSource, TLUiOverrides, debugFlags, measureCbDuration, useValue } from 'tldraw'
export function usePerformance(): TLUiOverrides { export function usePerformance(): TLUiOverrides {

View file

@ -17,7 +17,9 @@ import EndToEnd from './misc/end-to-end'
// we use secret internal `setDefaultAssetUrls` functions to set these at the // we use secret internal `setDefaultAssetUrls` functions to set these at the
// top-level so assets don't need to be passed down in every single example. // top-level so assets don't need to be passed down in every single example.
const assetUrls = getAssetUrlsByMetaUrl() const assetUrls = getAssetUrlsByMetaUrl()
// eslint-disable-next-line local/no-at-internal
setDefaultEditorAssetUrls(assetUrls) setDefaultEditorAssetUrls(assetUrls)
// eslint-disable-next-line local/no-at-internal
setDefaultUiAssetUrls(assetUrls) setDefaultUiAssetUrls(assetUrls)
const gettingStartedExamples = examples.find((e) => e.id === 'Getting started') const gettingStartedExamples = examples.find((e) => e.id === 'Getting started')
if (!gettingStartedExamples) throw new Error('Could not find getting started exmaples') if (!gettingStartedExamples) throw new Error('Could not find getting started exmaples')

View file

@ -1297,7 +1297,7 @@ export function serializeTldrawJson(store: TLStore): Promise<string>;
// @public (undocumented) // @public (undocumented)
export function serializeTldrawJsonBlob(store: TLStore): Promise<Blob>; export function serializeTldrawJsonBlob(store: TLStore): Promise<Blob>;
// @public (undocumented) // @internal (undocumented)
export function setDefaultEditorAssetUrls(assetUrls: TLEditorAssetUrls): void; export function setDefaultEditorAssetUrls(assetUrls: TLEditorAssetUrls): void;
// @internal (undocumented) // @internal (undocumented)

View file

@ -22,7 +22,7 @@ export let defaultEditorAssetUrls: TLEditorAssetUrls = {
}, },
} }
/** @public */ /** @internal */
export function setDefaultEditorAssetUrls(assetUrls: TLEditorAssetUrls) { export function setDefaultEditorAssetUrls(assetUrls: TLEditorAssetUrls) {
defaultEditorAssetUrls = assetUrls defaultEditorAssetUrls = assetUrls
} }

View file

@ -1,9 +1,16 @@
/* eslint-disable @typescript-eslint/no-var-requires */ /* eslint-disable @typescript-eslint/no-var-requires */
// eslint plugins can't use esm // eslint plugins can't use esm
const { ESLintUtils } =
require('@typescript-eslint/utils') as typeof import('@typescript-eslint/utils') // @ts-ignore - no import/require
const { SymbolFlags } = require('typescript') as typeof import('typescript') import ts = require('typescript')
// @ts-ignore - no import/require
import utils = require('@typescript-eslint/utils')
const { isReassignmentTarget } = require('tsutils') as typeof import('tsutils')
const { ESLintUtils } = utils
import TSESTree = utils.TSESTree
exports.rules = { exports.rules = {
'no-export-star': ESLintUtils.RuleCreator.withoutDocs({ 'no-export-star': ESLintUtils.RuleCreator.withoutDocs({
@ -26,7 +33,7 @@ exports.rules = {
// 3. Find all the imported names from the file // 3. Find all the imported names from the file
const importedNames = checker.getExportsOfModule(importedFileSymbol).map((imported) => ({ const importedNames = checker.getExportsOfModule(importedFileSymbol).map((imported) => ({
name: imported.getEscapedName(), name: imported.getEscapedName(),
isType: !(imported.flags & SymbolFlags.Value), isType: !(imported.flags & ts.SymbolFlags.Value),
})) }))
// report the error and offer a fix (listing imported names) // report the error and offer a fix (listing imported names)
@ -103,4 +110,189 @@ exports.rules = {
}, },
defaultOptions: [], defaultOptions: [],
}), }),
'no-at-internal': ESLintUtils.RuleCreator.withoutDocs({
create(context) {
// adapted from https://github.com/gund/eslint-plugin-deprecation
function identifierRule(id: TSESTree.Identifier | TSESTree.JSXIdentifier) {
const services = ESLintUtils.getParserServices(context)
// Don't consider deprecations in certain cases:
// - On JSX closing elements (only flag the opening element)
const isClosingElement =
id.type === 'JSXIdentifier' && id.parent?.type === 'JSXClosingElement'
if (isClosingElement) {
return
}
// - Inside an import
const isInsideImport = context.getAncestors().some((anc) => anc.type.includes('Import'))
if (isInsideImport) {
return
}
const internalMarker = getInternalMarker(id, services)
if (internalMarker) {
context.report({
node: id,
messageId: 'internal',
data: {
name: id.name,
},
})
}
}
function getInternalMarker(
id: TSESTree.Identifier | TSESTree.JSXIdentifier,
services: utils.ParserServices
) {
const tc = services.program.getTypeChecker()
const callExpression = getCallExpression(id)
if (callExpression) {
const tsCallExpression = services.esTreeNodeToTSNodeMap.get(
callExpression
) as ts.CallLikeExpression
const signature = tc.getResolvedSignature(tsCallExpression)
if (signature) {
const deprecation = getJsDocInternal(signature.getJsDocTags())
if (deprecation) {
return deprecation
}
}
}
const symbol = getSymbol(id, services, tc)
if (!symbol) {
return undefined
}
if (callExpression && isFunction(symbol)) {
return undefined
}
return getJsDocInternal(symbol.getJsDocTags())
}
function isFunction(symbol: ts.Symbol) {
const { declarations } = symbol
if (declarations === undefined || declarations.length === 0) {
return false
}
switch (declarations[0].kind) {
case ts.SyntaxKind.MethodDeclaration:
case ts.SyntaxKind.FunctionDeclaration:
case ts.SyntaxKind.FunctionExpression:
case ts.SyntaxKind.MethodSignature:
return true
default:
return false
}
}
function getCallExpression(
id: TSESTree.Node
): TSESTree.CallExpression | TSESTree.TaggedTemplateExpression | undefined {
const ancestors = context.getAncestors()
let callee = id
let parent = ancestors.length > 0 ? ancestors[ancestors.length - 1] : undefined
if (parent && parent.type === 'MemberExpression' && parent.property === id) {
callee = parent
parent = ancestors.length > 1 ? ancestors[ancestors.length - 2] : undefined
}
if (isCallExpression(parent, callee)) {
return parent
}
return undefined
}
function isCallExpression(
node: TSESTree.Node | undefined,
callee: TSESTree.Node
): node is TSESTree.CallExpression | TSESTree.TaggedTemplateExpression {
if (node) {
if (node.type === 'NewExpression' || node.type === 'CallExpression') {
return node.callee === callee
} else if (node.type === 'TaggedTemplateExpression') {
return node.tag === callee
} else if (node.type === 'JSXOpeningElement') {
return node.name === callee
}
}
return false
}
function getJsDocInternal(tags: ts.JSDocTagInfo[]) {
for (const tag of tags) {
if (tag.name === 'internal') {
return { reason: ts.displayPartsToString(tag.text) }
}
}
return undefined
}
function getSymbol(
id: TSESTree.Identifier | TSESTree.JSXIdentifier,
services: utils.ParserServices,
tc: ts.TypeChecker
) {
let symbol: ts.Symbol | undefined
const tsId = services.esTreeNodeToTSNodeMap.get(id as TSESTree.Node) as ts.Identifier
const parent = tsId.parent
if (parent.kind === ts.SyntaxKind.BindingElement) {
symbol = tc.getTypeAtLocation(parent.parent).getProperty(tsId.text)
} else if (
(isPropertyAssignment(parent) && parent.name === tsId) ||
(isShorthandPropertyAssignment(parent) &&
parent.name === tsId &&
isReassignmentTarget(tsId))
) {
try {
symbol = tc.getPropertySymbolOfDestructuringAssignment(tsId)
} catch (e) {
// we are in object literal, not destructuring
// no obvious easy way to check that in advance
symbol = tc.getSymbolAtLocation(tsId)
}
} else {
symbol = tc.getSymbolAtLocation(tsId)
}
if (symbol && (symbol.flags & ts.SymbolFlags.Alias) !== 0) {
symbol = tc.getAliasedSymbol(symbol)
}
return symbol
}
function isPropertyAssignment(node: ts.Node): node is ts.PropertyAssignment {
return node.kind === ts.SyntaxKind.PropertyAssignment
}
function isShorthandPropertyAssignment(
node: ts.Node
): node is ts.ShorthandPropertyAssignment {
return node.kind === ts.SyntaxKind.ShorthandPropertyAssignment
}
return {
Identifier: identifierRule,
JSXIdentifier: identifierRule,
}
},
meta: {
messages: {
internal: '"{{name}}" is internal and can\'t be used publicly.',
},
type: 'problem',
schema: [],
},
defaultOptions: [],
}),
} }