Skip to content

docs: Enable twoslash#2312

Open
tylerlaprade wants to merge 11 commits intoflint-fyi:mainfrom
tylerlaprade:feat/site-twoslash-all
Open

docs: Enable twoslash#2312
tylerlaprade wants to merge 11 commits intoflint-fyi:mainfrom
tylerlaprade:feat/site-twoslash-all

Conversation

@tylerlaprade
Copy link

@tylerlaprade tylerlaprade commented Feb 12, 2026

PR Checklist

Overview

  • Enable twoslash globally rather than on per codeblock
  • Wrote a temp script to compile every codeblock using twoslash and determine how to handle it
  • Added missing variable declarations under --cut-- to hide them from view while providing context
  • Overrode specific errors where it made sense to do so (for example, missing import definitions)

Screenshot(s)

Screenshot of the docs site running in the Vercel playground with working type annotation on hover

Appendix

Here is the temp script I used

  // @ts-check
  /**
   * Fix all TypeScript code blocks for twoslash compatibility.
   * Uses actual twoslash compilation to determine what each block needs.
   */
  import { createTwoslasher } from "/Users/tyler/Code/flint/node_modules/.pnpm/twoslash@0.2.12_typescript@5.9.3/node_modules/twoslash/dist/index.mjs";
  import fs from "node:fs";
  import path from "node:path";

  const DOCS_DIR = path.resolve("src/content/docs");

  const twoslasher = createTwoslasher();
  const twoslashCache = twoslasher.getCacheMap();
  // Must match expressive-code-twoslash defaults + ec.config.mjs overrides
  const compilerOptions = {
        // expressive-code-twoslash defaults:
        strict: true,
        target: 9, // ES2022
        exactOptionalPropertyTypes: true,
        downlevelIteration: true,
        skipLibCheck: true,
        lib: ["Bundler", "ES2022", "DOM", "DOM.Iterable"],
        noEmit: true,
        // ec.config.mjs overrides:
        module: 199,
        moduleResolution: 99,
        jsx: 4,
        jsxImportSource: "react",
        noImplicitAny: false,
  };

  function walkDir(dir, ext, files = []) {
        for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
                const full = path.join(dir, entry.name);
                if (entry.isDirectory()) walkDir(full, ext, files);
                else if (entry.name.endsWith(ext)) files.push(full);
        }
        return files;
  }

  /**
   * Run twoslash and return error info.
   */
  function checkWithTwoslash(code, lang) {
        try {
                twoslasher(code, lang, { compilerOptions });
                return { ok: true, codes: [], messages: new Map() };
        } catch (e) {
                const msg = e.message || "";
                const codes = [];
                const messages = new Map();
                const errorLines = msg.match(/\[(\d+)\] \d+ - (.+)/g);
                if (errorLines) {
                        for (const line of errorLines) {
                                const m = line.match(/\[(\d+)\] (\d+) - (.+)/);
                                if (m) {
                                        const code = parseInt(m[1]);
                                        if (!codes.includes(code)) codes.push(code);
                                        if (!messages.has(code)) messages.set(code, []);
                                        messages.get(code).push(m[3]);
                                }
                        }
                }
                return { ok: false, codes, messages, raw: msg };
        }
  }

  /**
   * Extract names that need to be declared from 2304/2552 errors.
   * 2304: Cannot find name 'X'
   * 2552: Cannot find name 'X'. Did you mean 'Y'?
   */
  function extractUndeclaredNames(result) {
        const names = new Set();
        for (const code of [2304, 2552]) {
                const msgs = result.messages.get(code) || [];
                for (const msg of msgs) {
                        const m = msg.match(/Cannot find name '(\w+)'/);
                        if (m) names.add(m[1]);
                }
        }
        return names;
  }

  /**
   * Process a single code block. Returns modified code or null if no change needed.
   */
  function processBlock(code, lang, isIncorrect, relPath, blockIndex) {
        // Skip blocks with @noErrors already
        if (code.includes("// @noErrors")) return null;

        // If block has imports or triple-slash references, use @noErrors
        if (
                /^\s*(import\s|export\s.*from\s|require\s*\(|\/\/\/\s*<reference)/m.test(
                        code,
                )
        ) {
                return `// @noErrors\n${code}`;
        }

        // Check if code compiles as-is
        let result = checkWithTwoslash(code, lang);
        if (result.ok) return null;

        // Iterative fix: add stubs for undeclared names, then check again
        let fixedCode = code;
        let allStubs = new Set();
        let allErrorCodes = new Set();

        for (let iteration = 0; iteration < 5; iteration++) {
                result = checkWithTwoslash(fixedCode, lang);
                if (result.ok) break;

                let madeProgress = false;

                // Collect undeclared names for stubs
                const undeclaredNames = extractUndeclaredNames(result);
                for (const name of undeclaredNames) {
                        if (!allStubs.has(name)) {
                                allStubs.add(name);
                                madeProgress = true;
                        }
                }

                // Collect non-undeclared-name error codes
                const otherCodes = result.codes.filter((c) => c !== 2304 && c !== 2552);
                for (const code of otherCodes) {
                        if (!allErrorCodes.has(code)) {
                                allErrorCodes.add(code);
                                madeProgress = true;
                        }
                }

                // Rebuild fixedCode with current stubs and error codes
                fixedCode = code;
                const parts = [];

                // For incorrect blocks: add all error codes as @errors
                // For correct blocks: add error codes as @errors too (unavoidable TS strictness)
                const codesToAnnotate = [...allErrorCodes].sort((a, b) => a - b);
                if (codesToAnnotate.length > 0) {
                        parts.push(`// @errors: ${codesToAnnotate.join(" ")}`);
                }

                // Add stubs
                if (allStubs.size > 0) {
                        const stubLines = [...allStubs]
                                .sort()
                                .map((n) => `declare const ${n}: any;`);
                        parts.push(...stubLines);
                        parts.push("// ---cut---");
                }

                if (parts.length > 0) {
                        fixedCode = parts.join("\n") + "\n" + code;
                }

                if (!madeProgress) break;
        }

        // Final check
        result = checkWithTwoslash(fixedCode, lang);
        if (result.ok) return fixedCode;

        // Still failing - last resort: @noErrors
        console.log(
                `  WARN: ${relPath} block ${blockIndex} (${isIncorrect ? "incorrect" : "correct"}) falling back to @noErrors. Remaining: ${result.codes.join("
        );
        return `// @noErrors\n${code}`;
  }

  // Main
  const allFiles = walkDir(DOCS_DIR, ".mdx");
  let filesModified = 0;
  let blocksModified = 0;
  let blocksNoErrors = 0;
  let blocksStubs = 0;
  let blocksErrorAnnotation = 0;
  let blocksClean = 0;

  for (const file of allFiles) {
        // Clear twoslash cache between files to prevent state leakage
        twoslashCache.clear();
        const content = fs.readFileSync(file, "utf-8");
        const relPath = path.relative(DOCS_DIR, file);

        const blockRegex = /```(ts|tsx|typescript)\b([^\n]*)\n([\s\S]*?)```/g;
        const replacements = [];

        let match;
        while ((match = blockRegex.exec(content)) !== null) {
                const lang = match[1] === "typescript" ? "ts" : match[1];
                const meta = match[2];
                const code = match[3];
                const fullMatch = match[0];
                const offset = match.index;

                // Determine if in incorrect tab
                const before = content.slice(0, offset);
                const lastIncorrect = before.lastIndexOf('label="❌ Incorrect"');
                const lastCorrect = before.lastIndexOf('label="✅ Correct"');
                const isIncorrect = lastIncorrect > lastCorrect;

                const blockIndex = replacements.length;
                const fixedCode = processBlock(
                        code,
                        lang,
                        isIncorrect,
                        relPath,
                        blockIndex,
                );
                if (fixedCode !== null) {
                        replacements.push({
                                original: fullMatch,
                                replacement: `\`\`\`${match[1]}${meta}\n${fixedCode}\`\`\``,
                        });

                        if (fixedCode.includes("// @noErrors")) blocksNoErrors++;
                        if (fixedCode.includes("// ---cut---")) blocksStubs++;
                        if (fixedCode.includes("// @errors:")) blocksErrorAnnotation++;
                        blocksModified++;
                } else {
                        blocksClean++;
                }
        }

        if (replacements.length > 0) {
                let newContent = content;
                for (const { original, replacement } of replacements) {
                        newContent = newContent.replace(original, replacement);
                }
                fs.writeFileSync(file, newContent, "utf-8");
                filesModified++;
        }
  }

  console.log(`\nDone!`);
  console.log(`Files scanned: ${allFiles.length}`);
  console.log(`Files modified: ${filesModified}`);
  console.log(`Blocks modified: ${blocksModified}`);
  console.log(`  - Clean (no changes): ${blocksClean}`);
  console.log(`  - With stubs + ---cut---: ${blocksStubs}`);
  console.log(`  - With @errors annotation: ${blocksErrorAnnotation}`);
  console.log(`  - Fell back to @noErrors: ${blocksNoErrors}`);

@changeset-bot
Copy link

changeset-bot bot commented Feb 12, 2026

⚠️ No Changeset found

Latest commit: b418dac

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@vercel
Copy link

vercel bot commented Feb 12, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
flint Ready Ready Preview, Comment Mar 3, 2026 9:24pm

Request Review

@github-actions

This comment has been minimized.

Copy link
Member

@lishaduck lishaduck left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Amazing!

Image

Copy link
Member

@lishaduck lishaduck left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks much better, but I'd still ask that this not use any. If we're not declaring the actual types, twoslash isn't really helping us ensure our examples are correct (and it looks less cool 🙃).

<Component isActive={true} />
declare const Component: any;
// ---cut---
<Component isActive={true} />;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The semicolon is annoying me... we can't just add a .prettierrc override for .mdx & semicolons though, that'd mess up the actual ts. Hmmm.

@tylerlaprade
Copy link
Author

@lishaduck, I think I got all the ones we can meaningfully define. I left any for generic things like value: any. Please let me know if you see anything missing!

Copy link
Member

@lishaduck lishaduck left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the first few, this looks really good!

If we could remove the rest of the anys, this'd be great!

e.g.:

rg 'any' -g '*.mdx'

EDIT: Didn't see your comment! I'll go through and comment on specifics 👍🏻

Copy link
Member

@lishaduck lishaduck left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, this does look much better, but there's certainly still some anys hanging out.

I got through about the first maybe quarter of the files, hopefully that's enough to show what I mean?

Comment on lines +22 to +24
declare const RuleContext: any;
declare const _: any;
declare const ruleCreator: any;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can definitely get better types here.

Copy link
Collaborator

@JoshuaKGoldberg JoshuaKGoldberg left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is looking great! +1 to lishaduck's review. My only additional area of requested changes now is to liberally use // @noErrors to avoid adding in unnecessary syntax to what readers see.

Since this PR touches a lot, you're getting a lot of feedback. If you'd prefer to just touch some of the files and leave other areas as followups, that could work too! Whatever's easier for you 🙂.

Thanks for getting started on this - I'm really excited to have the added intellisense-y hovers on the site!


```tsx
// @errors: 2322 -- string "true"/"false" isn't assignable to boolean prop
<button autoFocus="false" />
Copy link
Author

@tylerlaprade tylerlaprade Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

autoFocus="false" triggers TS2322 here because the string "false" isn't assignable to a boolean prop — and that type error is pointing at a real problem. In JSX, autoFocus="false" passes a truthy string, so the browser will still autofocus this element.

isSetToFalse in the rule implementation treats the string "false" as equivalent to boolean false, which looks like a bug in the rule itself. I'll change this example to autoFocus={false} I deleted this example since we already had that exact example included. Should I file a separate issue for the rule? And should we also remove the technically correct but misleading "true" example?

@JoshuaKGoldberg @lishaduck

@github-actions github-actions bot removed the status: waiting for author Needs an action taken by the original poster label Mar 2, 2026
@tylerlaprade
Copy link
Author

@JoshuaKGoldberg, in the course of adding TypeScript rule suppressions, it made me wonder if those Flint rules are redundant with TypeScript. In those cases, it's not possible to trigger the Flint rule without also incurring a TypeScript violation. Given our philosophy of Tooling Coordination, it seems we could simply defer to showing the TypeScript error rather than checking for the same violation on the linting side.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

📝 Documentation: enable twoslash for all typescript code blocks

3 participants