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;