diff --git a/znai-docs/znai/release-notes/1.92/add-2026-08-31-ocaml-module-function-highlight.md b/znai-docs/znai/release-notes/1.92/add-2026-08-31-ocaml-module-function-highlight.md new file mode 100644 index 000000000..aeec9b656 --- /dev/null +++ b/znai-docs/znai/release-notes/1.92/add-2026-08-31-ocaml-module-function-highlight.md @@ -0,0 +1,2 @@ +* Add: [OCaml](snippets/ocaml) syntax highlighting of module paths like `My_module.my_function` and function definition names +* Add: More legible class name highlight color in light mode diff --git a/znai-reactjs/src/App.css b/znai-reactjs/src/App.css index 572d06247..909be363a 100644 --- a/znai-reactjs/src/App.css +++ b/znai-reactjs/src/App.css @@ -224,7 +224,7 @@ --znai-open-api-sub-header-color: var(--znai-light-black); --znai-default-code-block-color: var(--znai-color-black); - --znai-code-class-color: #39397d; + --znai-code-class-color: #6f42c1; --znai-code-constant-color: #c58fe0; --znai-code-function-color: #a66f2b; --znai-code-keyword-color: #038091; diff --git a/znai-reactjs/src/doc-elements/code-snippets/CodeSnippetSyntaxHighlight.demo.jsx b/znai-reactjs/src/doc-elements/code-snippets/CodeSnippetSyntaxHighlight.demo.jsx index 29072b050..3feb1d6e1 100644 --- a/znai-reactjs/src/doc-elements/code-snippets/CodeSnippetSyntaxHighlight.demo.jsx +++ b/znai-reactjs/src/doc-elements/code-snippets/CodeSnippetSyntaxHighlight.demo.jsx @@ -26,6 +26,7 @@ export function syntaxHighlightSnippetDemo(registry) { .add('graphql', () => ) .add('cypher', () => ) .add('sql', () => ) + .add('ocaml', () => ) .add('diff', () => ) .add('diff-javascript', () => ) } @@ -44,6 +45,21 @@ function sqlCode() { "WHERE condition;" } +function ocamlCode() { + return '(* circle area calculation *)\n' + + 'open Base\n' + + '\n' + + 'module Circle = struct\n' + + ' let pi = 3.14159\n' + + ' let area radius = pi *. radius *. radius\n' + + 'end\n' + + '\n' + + 'let () =\n' + + ' let area = Circle.area 5.0 in\n' + + ' Stdio.printf "area: %f\\n" area;\n' + + ' List.iter [ 1; 2; 3 ] ~f:(fun x -> Stdio.printf "%d\\n" x)' +} + function yamlCode() { return 'invoice: 34843\n' + 'date : 2001-01-23\n' + diff --git a/znai-reactjs/src/doc-elements/code-snippets/codeParser.js b/znai-reactjs/src/doc-elements/code-snippets/codeParser.js index 60309308e..2719c8ed9 100644 --- a/znai-reactjs/src/doc-elements/code-snippets/codeParser.js +++ b/znai-reactjs/src/doc-elements/code-snippets/codeParser.js @@ -36,7 +36,7 @@ import 'prismjs/components/prism-diff' import 'prismjs/components/prism-graphql' import 'prismjs/components/prism-cypher' import 'prismjs/components/prism-sql' -import 'prismjs/components/prism-ocaml' +import './prismOcaml' import 'prismjs/plugins/autoloader/prism-autoloader' import 'prismjs/plugins/diff-highlight/prism-diff-highlight' diff --git a/znai-reactjs/src/doc-elements/code-snippets/codeUtils.test.jsx b/znai-reactjs/src/doc-elements/code-snippets/codeUtils.test.jsx index 077d2aeea..25c54b17d 100644 --- a/znai-reactjs/src/doc-elements/code-snippets/codeUtils.test.jsx +++ b/znai-reactjs/src/doc-elements/code-snippets/codeUtils.test.jsx @@ -251,6 +251,80 @@ describe("codeUtils", () => { ]) }); + it("should tokenize ocaml module path into module and function parts", () => { + const tokens = parseCode("ocaml", "let () = My_module.my_function 42") + + expect(tokens).toEqual([ + { type: 'keyword', content: 'let' }, + ' ', + { type: 'punctuation', content: '(' }, + { type: 'punctuation', content: ')' }, + ' ', + { type: 'operator', content: '=' }, + ' ', + { type: 'class-name', content: 'My_module' }, + { type: 'punctuation', content: '.' }, + { type: 'function', content: 'my_function' }, + ' ', + { type: 'number', content: '42' } + ]) + }); + + it("should tokenize ocaml nested module path", () => { + const tokens = parseCode("ocaml", "Foo.Bar.baz") + + expect(tokens).toEqual([ + { type: 'class-name', content: 'Foo' }, + { type: 'punctuation', content: '.' }, + { type: 'class-name', content: 'Bar' }, + { type: 'punctuation', content: '.' }, + { type: 'function', content: 'baz' } + ]) + }); + + it("should tokenize ocaml module name after open and module keywords", () => { + const openTokens = parseCode("ocaml", "open My_module") + expect(openTokens).toEqual([ + { type: 'keyword', content: 'open' }, + ' ', + { type: 'class-name', content: 'My_module' } + ]) + + const moduleTokens = parseCode("ocaml", "module Circle = struct") + expect(moduleTokens).toEqual([ + { type: 'keyword', content: 'module' }, + ' ', + { type: 'class-name', content: 'Circle' }, + ' ', + { type: 'operator', content: '=' }, + ' ', + { type: 'keyword', content: 'struct' } + ]) + }); + + it("should tokenize ocaml function definition name but not value binding name", () => { + const functionTokens = parseCode("ocaml", "let area radius = radius *. radius") + expect(functionTokens).toEqual([ + { type: 'keyword', content: 'let' }, + ' ', + { type: 'function', content: 'area' }, + ' radius ', + { type: 'operator', content: '=' }, + ' radius ', + { type: 'operator', content: '*.' }, + ' radius' + ]) + + const valueTokens = parseCode("ocaml", "let pi = 3.14159") + expect(valueTokens).toEqual([ + { type: 'keyword', content: 'let' }, + ' pi ', + { type: 'operator', content: '=' }, + ' ', + { type: 'number', content: '3.14159' } + ]) + }); + it("splits java multi line comment into separate lines", () => { const tokens = parseCode('java', ` /** hello multi line diff --git a/znai-reactjs/src/doc-elements/code-snippets/prismOcaml.js b/znai-reactjs/src/doc-elements/code-snippets/prismOcaml.js new file mode 100644 index 000000000..e8cd6e10d --- /dev/null +++ b/znai-reactjs/src/doc-elements/code-snippets/prismOcaml.js @@ -0,0 +1,54 @@ +/* + * Copyright 2026 znai maintainers + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as Prism from 'prismjs' + +import 'prismjs/components/prism-ocaml' + +// prism ocaml grammar leaves module paths like My_module.my_function untokenized. +// add rules to render module names and functions in different colors. +// note: rule names must match tokens.css classes directly since normalizeToken in codeParser drops prism aliases. +// order matters: "function" needs the full "My_module.my_function" text present for its lookbehind, +// so it must run before "class-name" consumes the module part. +// insert before "label" so that in definitions like: let g ~label = ... +// the parameters are still present for the function definition lookahead. +Prism.languages.insertBefore('ocaml', 'label', { + 'function': [ + { + // lowercase identifier accessed through a module path, e.g. my_function in My_module.my_function + pattern: /(\b[A-Z][\w']*\s*\.\s*)[a-z_][\w']*/, + lookbehind: true + }, + { + // function definition name followed by parameters, e.g. area in: let area radius = ... + // value bindings like: let pi = 3.14 are left as is. + // (?!lazy\b) keeps the keyword in lazy patterns like: let lazy v = ... rendered as keyword + pattern: /(\b(?:and|let)\s+(?:rec\s+)?)(?!lazy\b)[a-z_][\w']*(?=\s+[a-z_~?(])/, + lookbehind: true + } + ], + 'class-name': [ + { + // module name in a module path, e.g. My_module in My_module.my_function + pattern: /\b[A-Z][\w']*(?=\s*\.)/ + }, + { + // module name after module related keywords, e.g. open My_module + pattern: /(\b(?:include|module|open)\s+(?:type\s+)?(?:rec\s+)?)[A-Z][\w']*/, + lookbehind: true + } + ] +})