ESLint

eslint-plugin-textjs: console-safe JSX, cell-width glyphs, frame-paced timers and App Router checks.

eslint-plugin-textjs catches Text.js mistakes in the editor: DOM tags in console components, boxes inside text, glyphs Menlo doesn't have, stray console.log calls and App Router exports that are silently ignored.

Setup

bun add -d eslint eslint-plugin-textjs typescript-eslint
eslint.config.jsjs
import { defineConfig } from "eslint/config";import tseslint from "typescript-eslint";import textjs from "eslint-plugin-textjs";export default defineConfig(tseslint.configs.recommended, textjs.configs.recommended);

The plugin supports ESLint 9 and 10 with flat config. The rules are syntactic and don't need type information or parserOptions.project. Any parser that understands TypeScript and JSX works.

Configs

ConfigWhat it enables
textjs.configs.recommendedEvery correctness rule as an error; console, timer and frame-pacing rules as warnings
textjs.configs.strictEverything as an error, plus prefer-use-keymap as a warning

Pick rules individually instead:

eslint.config.jsjs
import textjs from "eslint-plugin-textjs";export default [  {    plugins: { textjs },    rules: {      "textjs/no-box-in-text": "error",      "textjs/no-console-in-components": ["warn", { allow: ["error"], logger: "log" }],    },  },];

Console files

Most rules only apply to files that render into the console: files that import a component or hook (Box, Text, useInput, …) from @textjs/app or @textjs/react. Files that import react-dom or @textjs/react/dom are page code and are left alone, so a website can mix DOM pages and console apps in one project. Importing a utility like tokenize or parseMarkdown doesn't make a file a console file.

eslint.config.jsjs
import textjs from "eslint-plugin-textjs";export default [  textjs.configs.recommended,  {    settings: {      textjs: {        packages: ["@acme/console-ui"], // also re-exports Text.js components        consoleFiles: "imports", // or "all"      },    },  },];

The App Router rules go by file name (page.tsx, layout.tsx, route.ts, …) inside any app/ directory, including src/app/. If yours has another name, pass { appDir: "routes" } to valid-route-module, valid-metadata and route-handler-exports.

Rules

RuleRecommendedFix
no-dom-elementserrorsuggestion
no-box-in-texterror
no-unsupported-glyphserrorautofix, suggestion
no-console-in-componentswarnsuggestion
prefer-use-intervalwarnsuggestion
no-sub-frame-timerswarn
prefer-use-keymapstrict only
valid-route-moduleerror
valid-metadataerror
route-handler-exportserrorsuggestion

no-dom-elements

<div>, <span>, custom elements and other DOM tags don't exist in the console. The reconciler only knows its own host nodes and throws TX003 on anything else.

<div className="flex-col">       // ✗<Box className="flex-col">       // ✓<strong>Deployed</strong>        // ✗<Text bold>Deployed</Text>       // ✓

The suggestion swaps the tag for the closest component (div → Box, span/p → Text, strong → Text bold, a → Link, input → Input, hr → Divider, …) and adds the import.

no-box-in-text

<Text> is a run of styled spans; it can't contain a box. Nesting <Box> or any box-rooted component (<ScrollBox>, <Input>, <Code>, <ProgressBar>, <Divider>, …) throws TX004 at render time. This rule finds it in the editor instead, including inside &&, ternaries, fragments and .map().

<Text>Status <Box /></Text>                     // ✗<Box className="flex-row gap-1"><Text>Status</Text><Box /></Box>  // ✓<Text>Press <Kbd>⌘K</Kbd> <Spinner /></Text>     // ✓ inline components

no-unsupported-glyphs

Text.js lays out every character in a fixed cell grid. Glyphs that Menlo, the DevTools console font on macOS, doesn't have render in a fallback font that is wider than a cell, so every cell after them drifts.

  • Braille (U+2800–U+28FF, the classic terminal spinner frames): use block or quadrant characters (▘▝▗▖), <Spinner variant="dots"> or marker="quadrant" on charts. Set { allowBraille: true } for apps that only run with text run in a terminal.
  • Miscellaneous Symbols and Arrows (U+2B00–U+2BFF, such as the small square U+2B1D): suggestions offer the Menlo equivalent (·, ●, ←).
  • Emoji presentation on one-cell symbols (▶ or ↩ followed by U+FE0F): the variation selector turns them into two-cell emoji while the grid reserves one. The autofix removes it.

Wide emoji (🚀, ✅) are fine: Text.js measures them as two cells.

no-console-in-components

Every frame is one console.log. Your own logs land between frames, get scrolled away by the next one and are hidden under the stick-to-bottom view. console.clear() wipes the app itself.

"textjs/no-console-in-components": ["warn", { allow: ["error"], logger: "log" }]

Show messages in the UI with useToast() or a log <ScrollBox>, or inspect state in the [▼] Dev Overlay (Ctrl+`). With logger, the suggestion rewrites console.warn(…) to log.warn(…). Test files and @textjs/app/testing renders are skipped.

prefer-use-interval

A raw setInterval in a component needs an effect, a cleanup and a ref to avoid stale state. useInterval does all three, and null stops it.

useEffect(() => {  if (paused) return;  const id = setInterval(tick, 1000);  return () => clearInterval(id);}, [paused]);useInterval(tick, paused ? null : 1000); // the suggestion

For animation, useAnimationFrame follows the renderer's frame pacing and stops while output is paused. See Utility Hooks.

no-sub-frame-timers

Text.js emits at most one frame every 66ms (about 15fps). A 16ms interval renders four times per emitted frame; the extra frames are coalesced and only burn CPU. Reports setInterval/useInterval delays under minInterval (default 66) and useAnimationFrame with fps above 15.

prefer-use-keymap

Comparing e.key by hand works, but the shortcuts are invisible. useKeymap puts labeled bindings in <KeyHelp>, <KeyHints> (bindings with hint: true) and the command palette, handles sequences like g g and stops propagation for matched keys. Reports useInput handlers that compare two or more key literals (minKeys) and skips handlers that read e.input for typing.

useKeymap({  "up, k": { label: "Increment", run: inc, hint: true },  "down, j": { label: "Decrement", run: dec, hint: true },});

See Keyboard Shortcuts.

valid-route-module

  • page, layout, loading, error and not-found files must default-export a component (TX015).
  • export const dynamic must be "auto", "force-static" or "force-dynamic" (TX031). Text.js doesn't support Next.js's "error". It's read from pages, layouts and route handlers. In pages and layouts, write it as a plain string literal: text build reads it from the source without running the module.

valid-metadata

Metadata is merged from layouts, then the page (or not-found). The rule reports what the router would silently ignore:

  • metadata or generateMetadata in loading or error files
  • both exports in one file
  • async generateMetadata: it's called synchronously, so a Promise never sets the title
  • title that isn't a string or { default, template?, absolute? }, or a misspelled key
  • title.template without %s, or in a page, where it has no effect
  • a non-string description

route-handler-exports

route.ts files only answer the HTTP methods they export: GET, HEAD, POST, PUT, PATCH, DELETE and OPTIONS. Lowercase get or Post is never called (requests get 405); the suggestion renames it. Default exports, non-function handlers and other exports are reported, except the dynamic and revalidate route segment config. Move shared code to lib/, or list extra names with { allow: [...] }.

app/api/health/route.tsts
export function get() { … }   // ✗ never calledexport function GET() { … }   // ✓
Edit this page on GitHub