Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 21 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,17 @@ struct ZoomableMarkdownView: View {

### Code Block Highlighting

Code fences default to the theme’s monospaced font. To customize styling or plug in a syntax highlighter, set `codeBlockTheme` and `codeHighlighter` on `MarkdownRenderTheme`:
Syntax highlighting is **on by default**. Fenced code blocks are tokenized by a bundled [Prism.js](https://prismjs.com) build running in JavaScriptCore (no WebView) and colored with a GitHub-flavored palette that adapts to light/dark mode. Roughly 65 languages are bundled — including Swift, Python, JavaScript/TypeScript, C/C++, C#, Java, Kotlin, Go, Rust, Ruby, PHP, SQL, YAML, Bash, and classics like BASIC and Pascal — and fence info strings are normalized before lookup, so `C++`, `objective-c`, `golang`, `vb.net`, and `swift title=example.swift` all resolve. Unknown languages render as plain monospaced text.

While a block streams, it re-highlights live; blocks larger than 16 KB render plain until the closing fence arrives, then get a single full highlight pass.

Built-in `CodeBlockTheme` presets:

- `.gitHub()` — GitHub Primer colors, light + dark (default)
- `.prismDefault()` — the previous Prism-flavored palette
- `.monospaced()` — no token coloring

To swap palettes, disable highlighting, or plug in a different engine entirely, use `withCodeHighlighting` on the theme. `CodeSyntaxHighlighter` is the extension point — any engine that can turn `(code, language)` into an `AttributedString` works (Splash, tree-sitter, a native highlighter, …):

```swift
import PicoMarkdownView
Expand All @@ -145,24 +155,29 @@ struct SplashCodeHighlighter: CodeSyntaxHighlighter {
self.splash = SyntaxHighlighter(format: TextOutputFormat(theme: theme))
}

func highlight(_ code: String, language: String?, theme: CodeBlockTheme) -> AttributedString {
func highlight(_ code: String, language: String?, theme: CodeBlockTheme) async -> AttributedString {
guard language != nil else {
return PlainCodeSyntaxHighlighter().highlight(code, language: language, theme: theme)
return await PlainCodeSyntaxHighlighter().highlight(code, language: language, theme: theme)
}

return AttributedString(splash.highlight(code))
}
}

var themed = MarkdownRenderTheme.default()
themed.codeBlockTheme = CodeBlockTheme.monospaced()
themed.codeHighlighter = AnyCodeSyntaxHighlighter(SplashCodeHighlighter(theme: .midnight(withFont: Splash.Font(size: 14))))
let themed = MarkdownRenderTheme.default().withCodeHighlighting(
codeBlockTheme: .monospaced(),
codeHighlighter: AnyCodeSyntaxHighlighter(SplashCodeHighlighter(theme: .midnight(withFont: Splash.Font(size: 14))))
)

var body: some View {
PicoMarkdownView(markdown, theme: themed)
}
```

Conformances must be `Sendable`; `highlight` is called off the main actor while blocks render.

The bundled grammar set is regenerated with `Scripts/bundle-prism.sh` (pins the Prism version, resolves component dependencies, and smoke-tests the output before writing). Never edit `Sources/PicoMarkdownView/Resources/prism-bundle.js` by hand — change the language list in `Scripts/bundle-prism.js` and rerun the script.

### Resetting Content

To replace content, pass a new string/chunks/stream so the view creates a fresh input:
Expand Down
181 changes: 181 additions & 0 deletions Scripts/bundle-prism.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
#!/usr/bin/env node
// Invoked by Scripts/bundle-prism.sh — do not run directly unless you pass
// the paths it expects:
//
// node bundle-prism.js <extracted-prism-package-dir> <wrapper.js> <output.js>
//
// Resolves the curated language list against Prism's own components.json
// (topological sort over `require` dependencies — hand-ordered lists are how
// the original bundle shipped `sparql` without `turtle` and died at load
// time), concatenates core + components + wrapper, then smoke-tests the
// result by evaluating it and tokenizing sample code in every language.

'use strict';

const fs = require('fs');
const path = require('path');

// Curated, GitHub-common language set. Dependencies (e.g. clike, turtle,
// markup-templating) are pulled in automatically — list only what users
// write after ```.
const LANGUAGES = [
// Web
'markup', 'css', 'less', 'scss', 'sass', 'javascript', 'typescript',
'jsx', 'tsx', 'json', 'graphql', 'php',
// Systems & compiled
'c', 'cpp', 'objectivec', 'swift', 'rust', 'go', 'zig', 'csharp',
'java', 'kotlin', 'scala', 'groovy', 'dart', 'wasm', 'wgsl', 'nasm', 'armasm',
// Functional
'haskell', 'ocaml', 'fsharp', 'elixir', 'erlang', 'reason',
// Scripting
'python', 'ruby', 'crystal', 'perl', 'lua', 'r', 'julia', 'matlab',
'bash', 'powershell', 'batch',
// Classic
'basic', 'vbnet', 'pascal',
// Data, config & build
'yaml', 'toml', 'ini', 'sql', 'sparql', 'protobuf', 'hcl',
'makefile', 'cmake', 'docker', 'nginx',
// Text & misc
'markdown', 'latex', 'diff', 'git', 'http', 'regex',
];

const MAX_BUNDLE_BYTES = 400 * 1024;

const [packageDir, wrapperPath, outputPath] = process.argv.slice(2);
if (!packageDir || !wrapperPath || !outputPath) {
console.error('usage: node bundle-prism.js <prism-package-dir> <wrapper.js> <output.js>');
process.exit(1);
}

const components = JSON.parse(
fs.readFileSync(path.join(packageDir, 'components.json'), 'utf8')
).languages;

// --- Resolve load order (depth-first over `require`) ---------------------

const order = [];
const seen = new Set();

function visit(name, stack) {
if (seen.has(name)) return;
if (stack.includes(name)) {
throw new Error(`dependency cycle: ${[...stack, name].join(' > ')}`);
}
const meta = components[name];
if (!meta) throw new Error(`unknown Prism component: ${name}`);
const requires = meta.require
? (Array.isArray(meta.require) ? meta.require : [meta.require])
: [];
for (const dep of requires) visit(dep, [...stack, name]);
seen.add(name);
order.push(name);
}

for (const lang of LANGUAGES) visit(lang, []);

// --- Concatenate ----------------------------------------------------------

function componentSource(name) {
const file = path.join(packageDir, 'components', `prism-${name}.min.js`);
return `/* prism-${name} */\n${fs.readFileSync(file, 'utf8').trim()}`;
}

const prismVersion = JSON.parse(
fs.readFileSync(path.join(packageDir, 'package.json'), 'utf8')
).version;

const header = [
`// Prism.js v${prismVersion} — generated by Scripts/bundle-prism.sh on ${new Date().toISOString().slice(0, 10)}`,
'// DO NOT EDIT — regenerate with: Scripts/bundle-prism.sh',
`// Languages: ${LANGUAGES.join(', ')}`,
`// Load order (incl. resolved dependencies): ${order.join(', ')}`,
].join('\n');

const bundle = [
header,
componentSource('core'),
...order.map(componentSource),
fs.readFileSync(wrapperPath, 'utf8').trim(),
'',
].join('\n');

// --- Smoke test: never ship a bundle that fails to evaluate ---------------

const SAMPLES = {
markup: '<a href="x">y</a>', css: 'a { color: red; }', less: '@x: 1; a { color: @x; }',
scss: '$x: 1; a { color: $x; }', sass: '$x: 1', javascript: 'const x = 1; // c',
typescript: 'const x: number = 1;', jsx: 'const a = <b c="d">{e}</b>;',
tsx: 'const a: X = <b/>;', json: '{"a": [1, true]}', graphql: 'query { a b }',
php: '<?php $x = 1; ?>', c: 'int main(void) { return 0; }',
cpp: '#include <vector>\nint main() { return 0; }',
objectivec: '@interface Foo : NSObject\n@end', swift: 'let x = "hi" // c',
rust: 'fn main() { let x = 1; }', go: 'func main() { x := 1 }',
zig: 'const x = @import("std");', csharp: 'var x = new List<int>();',
java: 'public class A { int x = 1; }', kotlin: 'fun main() { val x = 1 }',
scala: 'val x: Int = 1', groovy: 'def x = 1', dart: 'void main() { var x = 1; }',
wasm: '(module (func $f))', wgsl: 'fn main() { let x: f32 = 1.0; }',
nasm: 'mov eax, 1', armasm: 'MOV r0, #1', haskell: 'main = print 1 -- c',
ocaml: 'let x = 1 (* c *)', fsharp: 'let x = 1 // c', elixir: 'def foo, do: 1',
erlang: 'foo() -> ok.', reason: 'let x = 1;', python: 'def f():\n return 1 # c',
ruby: 'def f; 1 end # c', crystal: 'def f; 1 end', perl: 'my $x = 1; # c',
lua: 'local x = 1 -- c', r: 'x <- c(1, 2) # c', julia: 'function f() 1 end # c',
matlab: 'x = 1; % c', bash: 'echo "$HOME" # c', powershell: '$x = Get-Item # c',
batch: '@echo off\nset X=1', basic: "10 PRINT \"HI\"\n20 GOTO 10",
vbnet: 'Dim x As Integer = 1', pascal: "begin writeln('hi'); end.",
yaml: 'a: [1, 2]', toml: 'a = 1', ini: '[section]\nkey=value',
sql: 'SELECT a FROM b WHERE c = 1;', sparql: 'SELECT ?a WHERE { ?a ?b ?c }',
protobuf: 'message Foo { int32 a = 1; }', hcl: 'resource "a" "b" { c = 1 }',
makefile: 'all: foo\n\tgcc -o foo foo.c', cmake: 'add_executable(foo foo.c)',
docker: 'FROM alpine\nRUN echo hi', nginx: 'server { listen 80; }',
markdown: '# Title\n**bold**', latex: '\\section{a}', diff: '--- a\n+++ b\n+add\n-del',
git: '$ git commit -m "x"\n# On branch main', http: 'GET / HTTP/1.1\nHost: x',
regex: '\\d+[a-z]*',
};

let api;
try {
api = new Function(
`${bundle}\n;return { tokenizeCode: tokenizeCode, Prism: Prism };`
)();
} catch (e) {
console.error(`FATAL: generated bundle throws during evaluation: ${e.message}`);
process.exit(1);
}

const failures = [];
for (const lang of LANGUAGES) {
const grammarNames = [lang, ...(components[lang].alias
? (Array.isArray(components[lang].alias) ? components[lang].alias : [components[lang].alias])
: [])];
for (const name of grammarNames) {
if (!api.Prism.languages[name]) failures.push(`grammar missing: ${name}`);
}
const sample = SAMPLES[lang];
if (!sample) { failures.push(`no smoke sample for: ${lang}`); continue; }
const tokens = api.tokenizeCode(sample, lang);
const roundTrip = tokens.map((t) => t.content).join('');
if (roundTrip !== sample) failures.push(`round-trip mismatch: ${lang}`);
const types = new Set(tokens.map((t) => t.type));
if (types.size < 2) failures.push(`no highlighting for: ${lang} (types: ${[...types]})`);
}

// Wrapper contract checks.
if (api.tokenizeCode('let x = 1', 'SWIFT ').length < 2) failures.push('case/whitespace normalization broken');
const unknown = api.tokenizeCode('let x', 'not-a-language');
if (unknown.length !== 1 || unknown[0].type !== 'plain') failures.push('unknown-language fallback broken');
const iniTokens = api.tokenizeCode('[s]\nkey=value', 'ini');
if (!iniTokens.some((t) => t.alias === 'attr-name')) failures.push('alias emission broken (ini key should alias attr-name)');

if (failures.length > 0) {
console.error(`FATAL: smoke test failed:\n - ${failures.join('\n - ')}`);
process.exit(1);
}

const bytes = Buffer.byteLength(bundle, 'utf8');
if (bytes > MAX_BUNDLE_BYTES) {
console.error(`FATAL: bundle is ${bytes} bytes (limit ${MAX_BUNDLE_BYTES})`);
process.exit(1);
}

fs.writeFileSync(outputPath, bundle);
console.log(`prism-bundle.js: ${bytes} bytes, ${order.length} components, smoke test passed`);
34 changes: 34 additions & 0 deletions Scripts/bundle-prism.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Regenerates Sources/PicoMarkdownView/Resources/prism-bundle.js from a
# pinned prismjs release. The language list lives in Scripts/bundle-prism.js;
# dependency resolution, concatenation, and a load/tokenize smoke test happen
# there. Requires: curl, tar, node.
#
# Usage: Scripts/bundle-prism.sh
set -euo pipefail

PRISM_VERSION="1.29.0"

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(dirname "$SCRIPT_DIR")"
OUTPUT="$REPO_ROOT/Sources/PicoMarkdownView/Resources/prism-bundle.js"

command -v node >/dev/null 2>&1 || {
echo "error: node is required (it resolves component dependencies and smoke-tests the bundle)" >&2
exit 1
}

WORK_DIR="$(mktemp -d)"
trap 'rm -rf "$WORK_DIR"' EXIT

echo "Fetching prismjs@$PRISM_VERSION ..."
curl -fsSL -o "$WORK_DIR/prismjs.tgz" \
"https://registry.npmjs.org/prismjs/-/prismjs-$PRISM_VERSION.tgz"
tar -xzf "$WORK_DIR/prismjs.tgz" -C "$WORK_DIR"

node "$SCRIPT_DIR/bundle-prism.js" \
"$WORK_DIR/package" \
"$SCRIPT_DIR/prism-wrapper.js" \
"$OUTPUT"

echo "Wrote $OUTPUT"
64 changes: 64 additions & 0 deletions Scripts/prism-wrapper.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// Appended verbatim to prism-bundle.js by Scripts/bundle-prism.sh.
// Bridges Prism's nested token tree to the flat {content, type, alias?}
// objects consumed by PrismTokenizer (JavaScriptCore) in
// Sources/PicoMarkdownView/Renderer/PrismCodeHighlighter.swift.

// Flatten Prism tokens into simple objects. Nested tokens inherit the
// enclosing token's type/alias; empty strings are dropped. `alias` (the
// first one, when Prism provides any) carries standardized semantics for
// grammar-specific types — e.g. INI `key` aliases `attr-name` — letting
// themes color languages they never heard of.
function flattenPrismTokens(tokens) {
var result = [];

function firstAlias(alias) {
if (!alias) { return undefined; }
if (Array.isArray(alias)) { return alias.length > 0 ? String(alias[0]) : undefined; }
return String(alias);
}

function push(content, type, alias) {
if (content.length === 0) { return; }
var token = { content: content, type: type || 'plain' };
if (alias) { token.alias = alias; }
result.push(token);
}

function flatten(token, parentType, parentAlias) {
if (typeof token === 'string') {
push(token, parentType, parentAlias);
} else if (Array.isArray(token)) {
token.forEach(function (t) { flatten(t, parentType, parentAlias); });
} else if (token && typeof token === 'object') {
var type = token.type || parentType || 'plain';
var alias = firstAlias(token.alias) || parentAlias;
if (typeof token.content === 'string') {
push(token.content, type, alias);
} else if (Array.isArray(token.content)) {
token.content.forEach(function (t) { flatten(t, type, alias); });
} else if (token.content && typeof token.content === 'object') {
flatten(token.content, type, alias);
}
}
}

tokens.forEach(function (token) { flatten(token, null, undefined); });
return result;
}

// Entry point called from Swift. Full language-alias normalization
// (c++ -> cpp, golang -> go, ...) lives in PrismLanguageNormalizer.swift;
// the lowercasing/first-word here is belt-and-braces for direct callers.
function tokenizeCode(code, language) {
try {
var name = String(language || '').trim().split(/\s+/)[0].toLowerCase();
var grammar = Prism.languages[name];
if (!grammar) {
return [{ content: code, type: 'plain' }];
}
var tokens = Prism.tokenize(code, grammar);
return flattenPrismTokens(tokens);
} catch (e) {
return [{ content: code, type: 'plain' }];
}
}
Loading
Loading