diff --git a/data/bitclust/template.offline/layout b/data/bitclust/template.offline/layout index 48474fd..166337b 100644 --- a/data/bitclust/template.offline/layout +++ b/data/bitclust/template.offline/layout @@ -14,6 +14,7 @@ <%=h @title %> (Ruby <%=h ruby_version %> リファレンスマニュアル) + <%= yield %> diff --git a/lib/bitclust/subcommands/statichtml_command.rb b/lib/bitclust/subcommands/statichtml_command.rb index 80b26c6..3607c59 100644 --- a/lib/bitclust/subcommands/statichtml_command.rb +++ b/lib/bitclust/subcommands/statichtml_command.rb @@ -209,6 +209,8 @@ def exec(argv, options) @outputdir.to_s, :verbose => @verbose, :preserve => true) FileUtils.cp(@manager_config[:themedir] + "script.js", @outputdir.to_s, :verbose => @verbose, :preserve => true) + FileUtils.cp(@manager_config[:themedir] + "run.mjs", + @outputdir.to_s, :verbose => @verbose, :preserve => true) FileUtils.cp(@manager_config[:themedir] + @manager_config[:favicon_url], @outputdir.to_s, :verbose => @verbose, :preserve => true) Dir.mktmpdir do |tmpdir| diff --git a/theme/default/run.mjs b/theme/default/run.mjs new file mode 100644 index 0000000..c12db5b --- /dev/null +++ b/theme/default/run.mjs @@ -0,0 +1,131 @@ +const rubyWasmUrl = 'https://cdn.jsdelivr.net/npm/ruby-head-wasm-wasi@latest/dist/ruby.wasm' +const rubyVmUrl = 'https://cdn.jsdelivr.net/npm/ruby-wasm-wasi@latest/dist/browser.esm.js' + +let moduleCache +const loadRubyModule = async () => { + if (moduleCache) { + return moduleCache + } + moduleCache = await WebAssembly.compileStreaming(fetch(rubyWasmUrl)) + return moduleCache +} + +let defaultRubyVMCache +const loadRubyVM = async () => { + if (!defaultRubyVMCache) { + const { DefaultRubyVM } = await import(rubyVmUrl) + defaultRubyVMCache = DefaultRubyVM + } + return await defaultRubyVMCache(await loadRubyModule(), { consolePrint: false }) +} + +const isHighlightElement = (preElement) => { + if (!preElement || preElement.tagName !== 'PRE') return false + + const [highlight, lang] = [...preElement.classList] + return highlight === 'highlight' && lang === 'ruby' +} + +const setupWriteSync = (fs, output) => { + const originalWriteSync = fs.writeSync.bind(fs) + const writeSync = function () { + const fd = arguments[0] + if (fd === 1 || fd === 2) { + const textOrBuffer = arguments[1] + const text = arguments.length === 4 ? textOrBuffer : new TextDecoder('utf-8').decode(textOrBuffer) + output(text) + } + return originalWriteSync(...arguments) + } + fs.writeSync = writeSync +} + +const createOutputTextArea = () => { + const textarea = document.createElement('textarea') + textarea.classList.add('highlight__run-output') + return textarea +} + +const runRuby = async (event) => { + const runButton = event.target + const preElement = runButton.parentElement + if (!isHighlightElement(preElement)) return + if (runButton.dataset.loading) return + + let rubyVM + runButton.dataset.loaderror = false + try { + runButton.dataset.loading = true + runButton.innerText = 'LOADING...' + rubyVM = await loadRubyVM() + } catch (error) { + runButton.dataset.loaderror = true + return + } finally { + runButton.dataset.loading = false + runButton.innerText = 'RUN' + } + + const outputTextarea = createOutputTextArea() + preElement.insertAdjacentElement('afterend', outputTextarea) + const { vm, fs: { fs } } = rubyVM + setupWriteSync(fs, (text) => { outputTextarea.value += text }) + + const codeElement = preElement.querySelector('code') + + const evalSource = () => { + outputTextarea.value = '' + try { + runButton.dataset.running = true + runButton.innerText = 'RUNNING...' + vm.eval(codeElement.textContent) + } catch (error) { + outputTextarea.value = error + } finally { + setTimeout(() => { runButton.dataset.running = false }, 600) + runButton.innerText = 'RUN' + } + } + runButton.onclick = evalSource + runButton.onkeydown = (event) => { + if (event.code === 'Enter' || event.code === 'Space') { + event.stopPropagation() + evalSource() + return false + } + } + + preElement.dataset.editing = true + codeElement.setAttribute('spellcheck', 'off') + codeElement.setAttribute('contenteditable', 'true') + preElement.addEventListener('keydown', (event) => { + if (event.code === 'Enter' && event.ctrlKey) { + event.stopPropagation() + evalSource() + } + }) + + evalSource() +} + +const createRunButton = () => { + const button = document.createElement('span') + button.innerText = 'RUN' + button.setAttribute('role', 'button') + button.setAttribute('class', 'highlight__run-button') + button.setAttribute('tabindex', '0') + button.onclick = runRuby + button.onkeydown = (event) => { + if (event.code === 'Enter' || event.code === 'Space') { + event.stopPropagation() + runRuby(event) + return false + } + } + return button +} + +document.querySelectorAll('.highlight.ruby').forEach((elem) => { + const button = createRunButton() + elem.insertAdjacentElement('afterbegin', button) +}) diff --git a/theme/default/script.js b/theme/default/script.js index ee334c8..b5ad031 100644 --- a/theme/default/script.js +++ b/theme/default/script.js @@ -10,6 +10,8 @@ tempDiv.innerHTML = elem.innerHTML const caption = tempDiv.getElementsByClassName("caption")[0] if (caption) tempDiv.removeChild(caption) + const runButton = tempDiv.getElementsByClassName("highlight__run-button")[0] + if (runButton) tempDiv.removeChild(runButton) // textarea for preserving the copy text const copyText = document.createElement('textarea') diff --git a/theme/default/style.css b/theme/default/style.css index a9e563a..605f18d 100644 --- a/theme/default/style.css +++ b/theme/default/style.css @@ -142,6 +142,45 @@ pre.highlight { position: relative; } +main { + position: relative; +} + +/* for RUN */ +.highlight__run-button { + padding: 0.25em 0.5em; + background-color: #DDD; + opacity: 0.75; + cursor: pointer; + float: right; +} +.highlight__run-button:hover { + background-color: #EE8; + opacity: 1; +} +.highlight__run-button[data-running="true"] { + background-color: #070; + color: white; + opacity: 1; +} + +.highlight__run-output { + width: 100%; + box-sizing: border-box; + background-color: #f2f2f2; + border: 0; +} +pre.highlight.ruby[data-editing="true"]:focus-within { + outline: auto; +} +pre.highlight.ruby[data-editing="true"] > code { + display: inline-block; + width: 100%; +} +pre.highlight.ruby[data-editing="true"] > code:focus { + outline: none; +} + /* for COPY */ .highlight__copy-button { float: right;