Skip to content

Commit 1b5feac

Browse files
authored
Merge pull request #221 from znz/copy-button-all-pre
言語指定なしのコードブロックにも COPY ボタンを付ける
2 parents 91a4ab9 + c7f46b7 commit 1b5feac

3 files changed

Lines changed: 183 additions & 1 deletion

File tree

Rakefile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ desc "run JS tests (requires qjs / QuickJS)"
1919
namespace :test do
2020
task :js do
2121
sh 'qjs', 'test/js/test_run.mjs'
22+
sh 'qjs', 'test/js/test_script.mjs'
2223
end
2324
end
2425

test/js/test_script.mjs

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
// QuickJS-based tests for theme/default/script.js (COPY button setup).
2+
// Run with: qjs test/js/test_script.mjs (see the "test:js" rake task)
3+
// script.js is a classic (non-module) script, so it is evaluated with eval()
4+
// against a minimal fake DOM defined on globalThis.
5+
import * as std from 'std'
6+
7+
let failures = 0
8+
function assert(cond, message) {
9+
if (cond) {
10+
print('ok: ' + message)
11+
} else {
12+
failures++
13+
print('FAIL: ' + message)
14+
}
15+
}
16+
17+
// --- minimal fake DOM -------------------------------------------------
18+
// script.js only assigns elem.innerHTML across elements and to a fresh
19+
// textarea, so innerHTML is modeled as an opaque snapshot (string or
20+
// {ownText, children}) instead of parsed HTML.
21+
class FakeElement {
22+
constructor(tag, className = '', text = '') {
23+
this.tagName = tag.toUpperCase()
24+
this.className = className
25+
this.ownText = text
26+
this.children = []
27+
this.onclick = null
28+
}
29+
setAttribute(name, value) {
30+
if (name === 'class') this.className = value
31+
}
32+
appendChild(child) {
33+
this.children.push(child)
34+
return child
35+
}
36+
insertBefore(child, ref) {
37+
const i = this.children.indexOf(ref)
38+
if (ref == null || i < 0) this.children.push(child)
39+
else this.children.splice(i, 0, child)
40+
return child
41+
}
42+
removeChild(child) {
43+
const i = this.children.indexOf(child)
44+
if (i >= 0) this.children.splice(i, 1)
45+
return child
46+
}
47+
get firstChild() {
48+
return this.children.length > 0 ? this.children[0] : null
49+
}
50+
get textContent() {
51+
return this.ownText + this.children.map(c => c.textContent).join('')
52+
}
53+
get innerHTML() {
54+
return { ownText: this.ownText, children: this.children.slice() }
55+
}
56+
set innerHTML(value) {
57+
if (typeof value === 'string') {
58+
this.ownText = value
59+
this.children = []
60+
} else {
61+
this.ownText = value.ownText
62+
this.children = value.children.slice()
63+
}
64+
}
65+
getElementsByClassName(name) {
66+
const found = []
67+
const walk = (el) => {
68+
for (const c of el.children) {
69+
if (c.className.split(' ').indexOf(name) >= 0) found.push(c)
70+
walk(c)
71+
}
72+
}
73+
walk(this)
74+
return found
75+
}
76+
classesOfChildren() {
77+
return this.children.map(c => c.className)
78+
}
79+
childByClass(name) {
80+
return this.children.find(c => c.className.split(' ').indexOf(name) >= 0) || null
81+
}
82+
}
83+
84+
function makeDocument(elements) {
85+
return {
86+
_elements: elements,
87+
createElement(tag) { return new FakeElement(tag) },
88+
getElementsByClassName(name) {
89+
return this._elements.filter(e => e.className.split(' ').indexOf(name) >= 0)
90+
},
91+
querySelectorAll(selector) {
92+
return this._elements.filter(e => e.tagName === selector.toUpperCase())
93+
},
94+
execCommand() { return true },
95+
}
96+
}
97+
98+
// --- load script.js against the fake DOM ------------------------------
99+
const here = import.meta.url.replace(/^file:\/\//, '').replace(/\/[^/]*$/, '')
100+
const source = std.loadFile(here + '/../../theme/default/script.js')
101+
102+
function runOnload(elements) {
103+
globalThis.document = makeDocument(elements)
104+
globalThis.window = { setTimeout() { return 0 } }
105+
;(0, eval)(source)
106+
globalThis.window.onload()
107+
return elements
108+
}
109+
110+
// A ruby sample keeps getting the COPY button (regression)
111+
{
112+
const pre = new FakeElement('pre', 'highlight ruby')
113+
pre.appendChild(new FakeElement('code', '', 'puts 1\n'))
114+
runOnload([pre])
115+
assert(pre.childByClass('highlight__copy-button') !== null,
116+
'pre.highlight.ruby gets a COPY button')
117+
assert(pre.firstChild === pre.childByClass('highlight__copy-button'),
118+
'COPY button is prepended as the first child')
119+
const copyText = pre.childByClass('highlight__copy-text')
120+
assert(copyText !== null && copyText.textContent === 'puts 1\n',
121+
'copy text preserves the sample code')
122+
}
123+
124+
// A plain <pre> (no language fence, //emlist{ origin) also gets the button
125+
{
126+
const pre = new FakeElement('pre', '', 'ary = []\n')
127+
runOnload([pre])
128+
assert(pre.childByClass('highlight__copy-button') !== null,
129+
'plain <pre> without highlight class gets a COPY button')
130+
const copyText = pre.childByClass('highlight__copy-text')
131+
assert(copyText !== null && copyText.textContent === 'ary = []\n',
132+
'plain <pre> copy text preserves the block content')
133+
}
134+
135+
// A non-ruby language fence keeps the button
136+
{
137+
const pre = new FakeElement('pre', 'highlight c')
138+
pre.appendChild(new FakeElement('code', '', 'VALUE v;\n'))
139+
runOnload([pre])
140+
assert(pre.childByClass('highlight__copy-button') !== null,
141+
'pre.highlight.c gets a COPY button')
142+
}
143+
144+
// Non-pre elements are left alone
145+
{
146+
const div = new FakeElement('div', 'highlight')
147+
const pre = new FakeElement('pre', '')
148+
runOnload([div, pre])
149+
assert(div.childByClass('highlight__copy-button') === null,
150+
'non-pre element does not get a COPY button')
151+
}
152+
153+
// The caption is excluded from the copied text
154+
{
155+
const pre = new FakeElement('pre', 'highlight ruby')
156+
pre.appendChild(new FakeElement('span', 'caption', '例'))
157+
pre.appendChild(new FakeElement('code', '', 'p 42\n'))
158+
runOnload([pre])
159+
const copyText = pre.childByClass('highlight__copy-text')
160+
assert(copyText !== null && copyText.textContent === 'p 42\n',
161+
'caption text is excluded from the copy text')
162+
assert(pre.childByClass('caption') !== null,
163+
'caption itself stays visible in the sample')
164+
}
165+
166+
// Leading blank lines are stripped, trailing ones squeezed to one
167+
{
168+
const pre = new FakeElement('pre', '', '\n\nputs 1\n\n\n')
169+
runOnload([pre])
170+
const copyText = pre.childByClass('highlight__copy-text')
171+
assert(copyText !== null && copyText.textContent === 'puts 1\n',
172+
'copy text is trimmed (leading newlines dropped, trailing squeezed)')
173+
}
174+
175+
if (failures > 0) {
176+
print(failures + ' failure(s)')
177+
std.exit(1)
178+
}
179+
print('all passed')

theme/default/script.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
(function() {
22
window.onload = function() {
3-
const elems = document.getElementsByClassName('highlight')
3+
// 言語指定なしのコードブロックは class を持たない素の <pre> になるため、
4+
// highlight クラスではなく pre 要素全体に COPY ボタンを付ける
5+
const elems = document.querySelectorAll('pre')
46

57
let tempDiv = document.createElement('div')
68

0 commit comments

Comments
 (0)