///|
extern "js" fn load_shiki() -> @js.Promise[@js.Value] =
#| (async () => {
#| const [
#| { createHighlighterCore },
#| { createJavaScriptRegexEngine },
#| ] = await Promise.all([
#| import('https://esm.sh/shiki@3.23.0/core'),
#| import('https://esm.sh/shiki@3.23.0/engine/javascript'),
#| ])
#|
#| return createHighlighterCore({
#| langs: [],
#| themes: [],
#| engine: createJavaScriptRegexEngine(),
#| })
#| })
///|
extern "js" fn load_langs(
highlighter : @js.Value,
langs : FixedArray[String],
) -> @js.Promise[Unit] =
#| (hl, langs) =>
#| hl.loadLanguage(
#| ...langs.map(lang =>
#| import(/* @vite-ignore */ ('https://esm.sh/shiki@3.23.0/langs/' + lang))
#| )
#| )
///|
extern "js" fn load_themes(
highlighter : @js.Value,
themes : FixedArray[String],
) -> @js.Promise[Unit] =
#| (hl, themes) =>
#| hl.loadTheme(
#| ...themes.map(theme =>
#| import(/* @vite-ignore */ ('https://esm.sh/shiki@3.23.0/themes/' + theme))
#| )
#| )
///|
extern "js" fn code_to_html(
highlighter : @js.Value,
code : String,
lang : String,
theme : String,
) -> String =
#| (hl, code, lang, theme) => {
#| try {
#| return hl.codeToHtml(code, { lang, theme })
#| } catch (e) {
#| console.warn(`[shiki] highlight failed: lang=${lang}, theme=${theme}`, e)
#| return ""
#| }
#| }
///|
extern "js" fn install_static_code_element(highlighter : @js.Value) -> Unit =
#| highlighter => {
#| if (!globalThis.customElements) return
#| const stateKey = Symbol.for('yoorkin.shiki.static-code')
#| const state = globalThis[stateKey] ??= {
#| highlighter,
#| observer: null,
#| pending: new Set(),
#| scheduled: false,
#| }
#| state.highlighter = highlighter
#|
#| const styles = `
#| :host { display: block; min-width: 0; }
#| .shiki {
#| box-sizing: border-box;
#| min-width: max-content;
#| margin: 0;
#| background: transparent !important;
#| padding: var(--yoorkin-shiki-padding, 1.125rem 0);
#| font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
#| font-size: var(--yoorkin-shiki-font-size, 0.8125rem);
#| line-height: var(--yoorkin-shiki-line-height, 1.75);
#| tab-size: 2;
#| white-space: pre;
#| }
#| code { display: grid; counter-reset: yoorkin-shiki-line; }
#| .line {
#| display: block;
#| min-height: 1lh;
#| padding: 0 4rem 0 0;
#| counter-increment: yoorkin-shiki-line;
#| }
#| .line::before {
#| content: counter(yoorkin-shiki-line);
#| display: inline-block;
#| box-sizing: border-box;
#| width: 4rem;
#| padding-right: 1.5rem;
#| color: var(--rui-muted-foreground, currentColor);
#| text-align: right;
#| opacity: 0.65;
#| user-select: none;
#| }
#| `
#|
#| const highlight = element => {
#| if (!element.isConnected) return
#| const lang = element.dataset.lang || ''
#| const lightTheme = element.dataset.lightTheme || ''
#| const darkTheme = element.dataset.darkTheme || ''
#| const code = (element.textContent || '').replace(/\r?\n$/, '')
#| const sourceKey = `${lang}\0${lightTheme}\0${darkTheme}\0${code}`
#| if (
#| element.dataset.shikiHighlighted !== undefined &&
#| element.__yoorkinShikiSourceKey === sourceKey
#| ) return
#| try {
#| const html = state.highlighter.codeToHtml(code, {
#| lang,
#| themes: { light: lightTheme, dark: darkTheme },
#| defaultColor: 'light-dark()',
#| })
#| const shadow = element.shadowRoot || element.attachShadow({ mode: 'open' })
#| shadow.innerHTML = `${html}`
#| element.__yoorkinShikiSourceKey = sourceKey
#| element.dataset.shikiHighlighted = ''
#| } catch (error) {
#| console.warn(
#| `[shiki] dual-theme highlight failed: lang=${lang}, themes=${lightTheme}/${darkTheme}`,
#| error,
#| )
#| }
#| }
#|
#| const schedule = element => {
#| state.pending.add(element)
#| if (state.scheduled) return
#| state.scheduled = true
#| const run = deadline => {
#| state.scheduled = false
#| for (const pending of state.pending) {
#| state.pending.delete(pending)
#| highlight(pending)
#| if (!deadline.didTimeout && deadline.timeRemaining() < 4) break
#| }
#| if (state.pending.size > 0) schedule(state.pending.values().next().value)
#| }
#| if (globalThis.requestIdleCallback) {
#| globalThis.requestIdleCallback(run, { timeout: 250 })
#| } else {
#| globalThis.setTimeout(() => run({ didTimeout: true, timeRemaining: () => 0 }), 0)
#| }
#| }
#|
#| if (!state.observer && globalThis.IntersectionObserver) {
#| state.observer = new IntersectionObserver(entries => {
#| for (const entry of entries) {
#| if (!entry.isIntersecting) continue
#| state.observer.unobserve(entry.target)
#| schedule(entry.target)
#| }
#| }, { rootMargin: '900px 0px' })
#| }
#|
#| if (!customElements.get('yoorkin-shiki-code')) {
#| customElements.define('yoorkin-shiki-code', class extends HTMLElement {
#| static get observedAttributes() {
#| return ['data-lang', 'data-light-theme', 'data-dark-theme']
#| }
#|
#| connectedCallback() {
#| if (!this.__yoorkinShikiObserver) {
#| this.__yoorkinShikiObserver = new MutationObserver(() => highlight(this))
#| this.__yoorkinShikiObserver.observe(this, {
#| childList: true,
#| characterData: true,
#| subtree: true,
#| })
#| }
#| if (state.observer) state.observer.observe(this)
#| else schedule(this)
#| }
#|
#| attributeChangedCallback(_name, previous, next) {
#| if (this.isConnected && previous !== next) highlight(this)
#| }
#|
#| disconnectedCallback() {
#| state.observer?.unobserve(this)
#| state.pending.delete(this)
#| this.__yoorkinShikiObserver?.disconnect()
#| this.__yoorkinShikiObserver = null
#| }
#| })
#| }
#| }
///|
using @rabbita {type Cmd}
///|
extern "js" fn is_highlighter(x : @js.Value) -> Bool =
#| (x) => !!x
#| && typeof x.codeToHtml === 'function'
#| && typeof x.loadLanguage === 'function'
#| && typeof x.loadTheme === 'function'
///|
priv struct Singleton {
highlighter : @js.Value
loaded_langs : Set[String]
loaded_themes : Set[String]
config : Config?
}
///|
impl Eq for Singleton with fn equal(a, b) {
a.loaded_langs == b.loaded_langs &&
a.loaded_themes == b.loaded_themes &&
a.config == b.config
}
///|
let global_shiki_hl : Ref[Singleton?] = Ref(None)
///|
pub fn initialize(
langs~ : FixedArray[String],
themes~ : FixedArray[String],
) -> Unit {
@js.Promise::from_async(() => {
try {
let singleton = match global_shiki_hl.val {
Some(s) => s
None => {
let highlighter = load_shiki().wait()
guard is_highlighter(highlighter) else {
fail("shiki highlighter init failed: invalid object")
}
let singleton = {
highlighter,
loaded_langs: Set([]),
loaded_themes: Set([]),
config: None,
}
global_shiki_hl.val = Some(singleton)
singleton
}
}
load_langs(singleton.highlighter, langs).wait() catch {
e => fail("shiki load langs failed: \{e}")
}
for lang in langs {
singleton.loaded_langs.add(lang)
}
load_themes(singleton.highlighter, themes).wait() catch {
e => fail("shiki load themes failed: \{e}")
}
for theme in themes {
singleton.loaded_themes.add(theme)
}
install_static_code_element(singleton.highlighter)
} catch {
e => println(e)
}
})
|> ignore
}
///|
/// Load a reusable Shiki highlighter before constructing static HTML.
///
/// This is useful for renderers whose component tree must stay synchronous:
/// await this once at the application boundary, then call `static_code` from
/// any number of otherwise ordinary `Html` functions.
pub async fn initialize_static(
langs~ : FixedArray[String],
themes~ : FixedArray[String],
) -> Unit {
let singleton = match global_shiki_hl.val {
Some(s) => s
None => {
let highlighter = load_shiki().wait()
guard is_highlighter(highlighter) else {
fail("shiki highlighter init failed: invalid object")
}
let singleton = {
highlighter,
loaded_langs: Set([]),
loaded_themes: Set([]),
config: None,
}
global_shiki_hl.val = Some(singleton)
singleton
}
}
load_langs(singleton.highlighter, langs).wait() catch {
e => fail("shiki load langs failed: \{e}")
}
for lang in langs {
singleton.loaded_langs.add(lang)
}
load_themes(singleton.highlighter, themes).wait() catch {
e => fail("shiki load themes failed: \{e}")
}
for theme in themes {
singleton.loaded_themes.add(theme)
}
install_static_code_element(singleton.highlighter)
}
///|
/// Render source with two preloaded Shiki themes. The generated foreground
/// colors use CSS `light-dark()`, so a containing `color-scheme` switch updates
/// existing code blocks without rebuilding the Rabbita tree.
#warnings("-alert_xss_vulnerable")
pub fn static_code(
code : String,
lang~ : String,
light_theme~ : String,
dark_theme~ : String,
) -> @html.Html {
let attrs = @html.Attrs::build()
.data_set("shiki-code", "")
.data_set("lang", lang)
.data_set("light-theme", light_theme)
.data_set("dark-theme", dark_theme)
@html.node(
"yoorkin-shiki-code",
attrs,
@html.pre(@html.code(@html.text(code))),
)
}
///|
fn load_shiki_highlight(
lang : String,
theme : String,
inject : @rabbita.Emit[Result[Singleton, Error]],
) -> Cmd {
@rabbita.attempt(inject.0, async fn() {
let singleton = match global_shiki_hl.val {
Some(s) => s
None => {
let highlighter = load_shiki().wait()
guard is_highlighter(highlighter) else {
fail("shiki highlighter init failed: invalid object")
}
let singleton = {
highlighter,
loaded_langs: Set([]),
loaded_themes: Set([]),
config: Some({ lang, theme, }),
}
global_shiki_hl.val = Some(singleton)
singleton
}
}
if !singleton.loaded_langs.contains(lang) {
load_langs(singleton.highlighter, [lang]).wait() catch {
e => fail("shiki load langs failed: \{e}")
}
}
if !singleton.loaded_themes.contains(theme) {
load_themes(singleton.highlighter, [theme]).wait() catch {
e => fail("shiki load themes failed: \{e}")
}
}
singleton
// code_to_html(singleton.highlighter, code, lang, theme)
})
}
///|
struct Config {
lang : String
theme : String
} derive(Eq)
///|
impl @rabbita.Enumerate for Config with fn tag(self : Config) {
"\{self.lang}, \{self.theme}"
}
///|
#warnings("-alert_xss_vulnerable")
pub fn shiki_code(
code : Val[String],
lang~ : Val[String],
theme~ : Val[String],
) -> Val[Html] {
let config = lang.map2(theme, (lang, theme) => { lang, theme, })
let hl = config.switch(config => {
@rabbita.create_resource(inject => {
load_shiki_highlight(config.lang, config.theme, inject)
})
})
code.map3(config, hl, (code, config, x) => {
match x {
Loaded(hl) => {
let str = code_to_html(hl.highlighter, code, config.lang, config.theme)
@html.code(attrs=@html.Attrs::build().inner_html(str), @html.nothing)
}
Pending => @html.div(@html.pre(code))
Failed(_) => @html.div(@html.pre(code))
}
})
}
///|
using @rabbita {type Html, type Val}