From 1959ed96580fbf9efa816692ccd5f78bfe4ebfb7 Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Sat, 19 Sep 2026 15:15:29 -0400 Subject: [PATCH] experiment: highlight landing page with Shiki Move homepage syntax highlighting into its React Router loader and use the official ReScript TextMate grammar with Shiki's JavaScript regex engine. Keep the resulting markup compatible with the existing code block styling without shipping Shiki to the client. --- .../__tests__/LandingPageBehavior_.test.res | 8 +- .../__tests__/visual/LandingPage_.test.res | 2 +- apps/docs/app/routes/LandingPage.res | 45 +- apps/docs/app/routes/LandingPage.resi | 17 +- apps/docs/app/routes/LandingPageRoute.res | 14 +- apps/docs/app/routes/LandingPageRoute.resi | 4 + apps/docs/package.json | 1 + apps/docs/src/bindings/ShikiHighlighter.res | 2 + apps/docs/src/server/ShikiHighlighter.js | 32 + apps/docs/src/server/rescript.tmLanguage.json | 563 ++++++++++++++++++ apps/docs/test-utils/LandingPageFixture.res | 4 + yarn.lock | 151 ++++- 12 files changed, 816 insertions(+), 27 deletions(-) create mode 100644 apps/docs/src/bindings/ShikiHighlighter.res create mode 100644 apps/docs/src/server/ShikiHighlighter.js create mode 100644 apps/docs/src/server/rescript.tmLanguage.json create mode 100644 apps/docs/test-utils/LandingPageFixture.res diff --git a/apps/docs/__tests__/LandingPageBehavior_.test.res b/apps/docs/__tests__/LandingPageBehavior_.test.res index cbe53431c..b21e3b026 100644 --- a/apps/docs/__tests__/LandingPageBehavior_.test.res +++ b/apps/docs/__tests__/LandingPageBehavior_.test.res @@ -29,7 +29,7 @@ let getOpenGraphImageUrl = () => { test("landing page Open Graph image targets its absolute page URL", async () => { let _screen = await render( - + , ) let pageUrl = WebAPI.URL.make(~url=Env.root_url) @@ -52,7 +52,7 @@ test( async () => { let screen = await render( - + , ) @@ -89,7 +89,7 @@ test( test("landing page playground hero renders highlighted code tokens", async () => { let screen = await render( - + , ) @@ -120,7 +120,7 @@ test( let screen = await render( - + , ) diff --git a/apps/docs/__tests__/visual/LandingPage_.test.res b/apps/docs/__tests__/visual/LandingPage_.test.res index fd8d8aabc..81cef0e5c 100644 --- a/apps/docs/__tests__/visual/LandingPage_.test.res +++ b/apps/docs/__tests__/visual/LandingPage_.test.res @@ -6,7 +6,7 @@ let snapshotSection = async (~width, ~height, ~sectionTestId, ~screenshotName) = let screen = await render( - + , ) diff --git a/apps/docs/app/routes/LandingPage.res b/apps/docs/app/routes/LandingPage.res index aab1d4bbc..50ad0b9ee 100644 --- a/apps/docs/app/routes/LandingPage.res +++ b/apps/docs/app/routes/LandingPage.res @@ -34,15 +34,18 @@ module Intro = { } } -module PlaygroundHero = { - type example = { - res: string, - js: string, - } +type example = { + res: string, + js: string, +} - let examples = [ - { - res: `module Button = { +type highlightedExample = { + res: string, + js: string, +} + +let playgroundExample: example = { + res: `module Button = { @react.component let make = (~count) => { let times = switch count { @@ -55,7 +58,7 @@ module PlaygroundHero = { } }`, - js: `import * as JsxRuntime from "react/jsx-runtime"; + js: `import * as JsxRuntime from "react/jsx-runtime"; function Playground$Button(props) { let count = props.count; @@ -75,13 +78,11 @@ let Button = { export { Button, }`, - }, - ] +} +module PlaygroundHero = { @react.component - let make = () => { - let (example, _setExample) = React.useState(_ => examples->Array.getUnsafe(0)) - + let make = (~highlightedExample: highlightedExample) => { //Playground Section & Background
@@ -98,7 +99,10 @@ export { {React.string("Write in ReScript")}
-              {HighlightJs.renderHLJS(~darkmode=true, ~code=example.res, ~lang="res", ())}
+              
             
//Right Side (JavaScript) @@ -109,14 +113,17 @@ export { {React.string("Compile to JavaScript")}
-              {HighlightJs.renderHLJS(~darkmode=true, ~code=example.js, ~lang="js", ())}
+              
             
/* ---Link to Playground--- */ {React.string("Edit this example in Playground")} @@ -683,7 +690,7 @@ module CuratedResources = { } @react.component -let make = (~components=MarkdownComponents.default) => { +let make = (~components=MarkdownComponents.default, ~highlightedExample: highlightedExample) => { <> {
- + diff --git a/apps/docs/app/routes/LandingPage.resi b/apps/docs/app/routes/LandingPage.resi index 65f28d025..fef9f9086 100644 --- a/apps/docs/app/routes/LandingPage.resi +++ b/apps/docs/app/routes/LandingPage.resi @@ -1,2 +1,17 @@ +type example = { + res: string, + js: string, +} + +type highlightedExample = { + res: string, + js: string, +} + +let playgroundExample: example + @react.component -let make: (~components: MarkdownComponents.t=?) => React.element +let make: ( + ~components: MarkdownComponents.t=?, + ~highlightedExample: highlightedExample, +) => React.element diff --git a/apps/docs/app/routes/LandingPageRoute.res b/apps/docs/app/routes/LandingPageRoute.res index bf5446708..c96812b71 100644 --- a/apps/docs/app/routes/LandingPageRoute.res +++ b/apps/docs/app/routes/LandingPageRoute.res @@ -1,5 +1,17 @@ +type loaderData = LandingPage.highlightedExample + +let loader: ReactRouter.Loader.t = async _ => { + let {res, js} = LandingPage.playgroundExample + let highlightedExample: LandingPage.highlightedExample = { + res: ShikiHighlighter.highlight(~code=res, ~language="rescript"), + js: ShikiHighlighter.highlight(~code=js, ~language="javascript"), + } + highlightedExample +} + let default = () => { + let highlightedExample = ReactRouter.useLoaderData() <> - + } diff --git a/apps/docs/app/routes/LandingPageRoute.resi b/apps/docs/app/routes/LandingPageRoute.resi index 5f02c123c..caa64bfd9 100644 --- a/apps/docs/app/routes/LandingPageRoute.resi +++ b/apps/docs/app/routes/LandingPageRoute.resi @@ -1 +1,5 @@ +type loaderData = LandingPage.highlightedExample + +let loader: ReactRouter.Loader.t + let default: unit => React.element diff --git a/apps/docs/package.json b/apps/docs/package.json index 3baad8db8..69da05e2c 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -68,6 +68,7 @@ "remark-gfm": "^4.0.1", "remark-validate-links": "^13.1.0", "rescript": "^12.2.0", + "shiki": "^4.4.3", "tinyglobby": "^0.2.15", "unified": "^11.0.5", "vfile-matter": "^5.0.1" diff --git a/apps/docs/src/bindings/ShikiHighlighter.res b/apps/docs/src/bindings/ShikiHighlighter.res new file mode 100644 index 000000000..6665fb376 --- /dev/null +++ b/apps/docs/src/bindings/ShikiHighlighter.res @@ -0,0 +1,2 @@ +@module("../server/ShikiHighlighter.js") +external highlight: (~code: string, ~language: string) => string = "highlight" diff --git a/apps/docs/src/server/ShikiHighlighter.js b/apps/docs/src/server/ShikiHighlighter.js new file mode 100644 index 000000000..f0280365f --- /dev/null +++ b/apps/docs/src/server/ShikiHighlighter.js @@ -0,0 +1,32 @@ +import { createHighlighterCoreSync, hastToHtml } from "shiki/core"; +import { createJavaScriptRegexEngine } from "shiki/engine/javascript"; +import javascript from "@shikijs/langs/javascript"; +import githubDark from "@shikijs/themes/github-dark"; + +// Vendored from rescript-vscode at commit 76d30468cb05401c10dd1e9dcf5a2993f8fc7140. +// https://github.com/rescript-lang/rescript-vscode/blob/76d30468cb05401c10dd1e9dcf5a2993f8fc7140/grammars/rescript.tmLanguage.json +import rescriptGrammar from "./rescript.tmLanguage.json" with { type: "json" }; + +const highlighter = createHighlighterCoreSync({ + engine: createJavaScriptRegexEngine(), + langs: [ + javascript, + { + ...rescriptGrammar, + name: "rescript", + aliases: ["res"], + }, + ], + themes: [githubDark], +}); + +export const highlight = (code, language) => { + const tree = highlighter.codeToHast(code, { + lang: language, + theme: "github-dark", + }); + const pre = tree.children[0]; + const codeElement = pre.children[0]; + + return codeElement.children.map((child) => hastToHtml(child)).join(""); +}; diff --git a/apps/docs/src/server/rescript.tmLanguage.json b/apps/docs/src/server/rescript.tmLanguage.json new file mode 100644 index 000000000..7a5bade0f --- /dev/null +++ b/apps/docs/src/server/rescript.tmLanguage.json @@ -0,0 +1,563 @@ +{ + "$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json", + "name": "ReScript", + "scopeName": "source.rescript", + "repository": { + "RE_KEYWORD_CONTROL": { + "name": "keyword.control", + "match": "\\b(and|as|assert|async|await|catch|constraint|downto|else|exception|external|for|if|in|lazy|mutable|rec|switch|to|try|when|while|with|private)\\b" + }, + "RE_TO_DOWNTO_AS_LABELS": { + "patterns": [ + { + "match": "~(to|downto)\\s*(=)", + "captures": { + "1": { + "name": "variable" + }, + "2": { + "name": "keyword.operator keyword" + } + } + }, + { + "match": "~(to|downto)\\s+(as)", + "captures": { + "1": { + "name": "variable" + }, + "2": { + "name": "keyword.control" + } + } + } + ] + }, + "RE_CONSTANTS_BOOL": { + "name": "constant.language.boolean", + "match": "\\b(false|true)\\b" + }, + "RE_KEYWORD": { + "name": "storage.type", + "match": "\\b(include|let|module|of|open|type)\\b" + }, + "commentLine": { + "match": "//.*", + "name": "comment.line" + }, + "commentBlock": { + "name": "comment.block", + "begin": "/\\*", + "end": "\\*/", + "patterns": [ + { + "include": "#commentBlock" + } + ] + }, + "punctuation": { + "patterns": [ + { + "match": "~", + "name": "punctuation.definition.keyword" + }, + { + "match": ";", + "name": "punctuation.terminator" + }, + { + "match": "\\.", + "name": "punctuation.accessor" + }, + { + "match": "\\,", + "name": "punctuation.separator" + }, + { + "match": "\\?|:", + "name": "punctuation.separator" + }, + { + "match": "\\|(?!\\|)", + "name": "punctuation.separator" + }, + { + "match": "\\{", + "name": "punctuation.section.braces.begin" + }, + { + "match": "\\}", + "name": "punctuation.section.braces.end" + }, + { + "match": "\\[", + "name": "punctuation.section.brackets.begin" + }, + { + "match": "\\]", + "name": "punctuation.section.brackets.end" + }, + { + "match": "\\(", + "name": "punctuation.section.parens.begin" + }, + { + "match": "\\)", + "name": "punctuation.section.parens.end" + } + ] + }, + "keyword": { + "patterns": [ + { + "include": "#RE_TO_DOWNTO_AS_LABELS" + }, + { + "include": "#RE_KEYWORD_CONTROL" + }, + { + "include": "#RE_KEYWORD" + } + ] + }, + "constant": { + "patterns": [ + { + "include": "#RE_CONSTANTS_BOOL" + } + ] + }, + "string-character-escape": { + "name": "constant.character.escape", + "match": "\\\\(x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4}|u{[0-9A-Fa-f]+}|[0-2][0-7]{0,2}|3[0-6][0-7]?|37[0-7]?|[4-7][0-7]?|.|$)" + }, + "string": { + "patterns": [ + { + "name": "string.quoted.double", + "begin": "\"", + "end": "\"", + "beginCaptures": { + "1": { + "name": "punctuation.definition.string.begin" + } + }, + "endCaptures": { + "1": { + "name": "punctuation.definition.string.end" + } + }, + "patterns": [ + { + "include": "#string-character-escape" + } + ] + }, + { + "name": "string.template", + "begin": "([a-z_][0-9a-zA-Z_]*)?(`)", + "end": "(?", + "name": "storage.type.function keyword.declaration.function" + } + ] + }, + "character": { + "patterns": [ + { + "match": "'[\\x00-\\x7F]'", + "name": "string.quoted.single" + } + ] + }, + "typeParameter": { + "patterns": [ + { + "match": "'[A-Za-z][A-Za-z0-9_]*", + "name": "support.type" + } + ] + }, + "defaultIdIsVariable": { + "patterns": [ + { + "match": "[A-Za-z_][A-Za-z0-9_]*", + "name": "variable" + } + ] + }, + "number": { + "patterns": [ + { + "match": "\\b(0[xX][a-fA-F0-9_]+[Lln]?|0[oO][0-7_]+[Lln]?|0[bB][01_]+[Lln]?|[0-9][0-9_]*([Lln]|(\\.[0-9_]+)?([eE][-+]?[0-9_]+)?)?)\\b", + "name": "constant.numeric" + } + ] + }, + "operator": { + "patterns": [ + { + "match": "->|\\|\\||&&|\\+\\+|\\*\\*|\\+\\.|\\+|-\\.|-|\\*\\.|\\*|/\\.|/|\\.\\.\\.|\\.\\.|===|==|\\^|:=|!|>=(?! *\\?)|<=|=", + "name": "keyword.operator" + }, + { + "match": "\\|>", + "name": "invalid.deprecated" + } + ] + }, + "constructor": { + "patterns": [ + { + "match": "\\b[A-Z][0-9a-zA-Z_]*\\b", + "name": "variable.other.enummember" + }, + { + "match": "(#)\\s*([a-zA-Z][0-9a-zA-Z_]*)\\b", + "captures": { + "1": { + "name": "variable.other.enummember" + }, + "2": { + "name": "variable.other.enummember" + } + } + }, + { + "match": "(#)\\s*(\\.\\.\\.)\\b", + "captures": { + "1": { + "name": "variable.other.enummember" + }, + "2": { + "name": "variable.other.enummember" + } + } + }, + { + "match": "(#)", + "captures": { + "1": { + "name": "variable.other.enummember" + } + } + } + ] + }, + "entity-literal": { + "patterns": [ + { + "match": "\\b(list|dict)(\\{)", + "captures": { + "1": { + "name": "keyword" + }, + "2": { + "name": "punctuation.section.braces.begin" + } + } + }, + { + "match": "\\}", + "name": "punctuation.section.braces.end" + } + ] + }, + "attribute": { + "patterns": [ + { + "match": "(%%?|@@?)([A-Za-z_][A-Za-z0-9_\\.]*)", + "captures": { + "1": { + "name": "punctuation.decorator" + }, + "2": { + "patterns": [ + { + "match": "bs\\.send\\.pipe", + "name": "invalid.deprecated" + }, + { + "match": "splice", + "name": "invalid.illegal" + }, + { + "match": "(bs\\.)?([A-Za-z_][A-Za-z0-9_\\.]*)", + "captures": { + "1": { + "name": "invalid.deprecated" + }, + "2": { + "name": "entity.name.function" + } + } + }, + { + "match": "[A-Za-z_][A-Za-z0-9_\\.]*", + "name": "entity.name.function" + } + ] + } + } + } + ] + }, + "jsx": { + "patterns": [ + { + "match": "<>||", + "name": "punctuation.definition.tag" + }, + { + "match": "