Skip to content
Open
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
24 changes: 24 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: test

on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]

jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [22.x, 24.x]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- run: npm ci
- run: npm run test
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@
.RData
.Ruserdata
.Rproj.user/
node_modules/
116 changes: 116 additions & 0 deletions __tests__/flourish.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
const fs = require('fs');
const path = require('path');
const vm = require('vm');

function runScripts(files, baseCtx = {}) {
const root = path.resolve(__dirname, '..');
const ctx = vm.createContext({ console, window, document, module: {}, exports: {}, ...baseCtx });
for (const f of files) {
const candidates = [path.join(root, f), path.join(root, '_extensions', 'flourish', f)];
let code, used;
for (const p of candidates) {
try { code = fs.readFileSync(p, 'utf8'); used = p; break; } catch { }
}
if (!code) throw new Error(`Not found: ${candidates.join(' | ')}`);
vm.runInContext(code, ctx, { filename: used });
}
return ctx;
}

const nonOutputCodes = () =>
[...document.querySelectorAll('.cell pre code')].filter(
el => !el.closest('.cell-output') && !el.closest('.cell-output-stdout')
);

const rxTag = s => Object.prototype.toString.call(s) === '[object RegExp]';

describe('parseDataFlourish + addStyle + DOMContentLoaded pipeline', () => {
beforeEach(() => {
document.head.innerHTML = '';
document.body.innerHTML = `
<div class="cell" data-flourish='[{"target":"&lt;"}]'>
<pre><code>1 &lt; 2 &amp; 3</code></pre>
</div>

<div class="cell" data-flourish='[{"target":"foo","style":"font-weight:bold;"}]'>
<pre><code>foo bar</code></pre>
</div>

<div class="cell" data-flourish='[{"target":"secret","mask":true}]'>
<div class="cell-output"><pre><code>secret in output</code></pre></div>
<pre><code>secret here</code></pre>
</div>

<div class="cell" data-flourish='[{"target":"skip-me"}]'>
<div class="cell-output-stdout"><pre><code>skip-me</code></pre></div>
</div>

<div class="cell" data-flourish='[{"target-rx":["ba.","fo+"],"flags":"g"}]'>
<pre><code>foo bar baz</code></pre>
</div>
`;
});

test('parseDataFlourish builds correct regex and defaults', () => {
const ctx = runScripts(['injectFlourishes.js', 'flourish.js']);
const attr = JSON.stringify([
{ target: ['A < B', { source: 'C&D', flags: 'gi' }], mask: true },
{ 'target-rx': ['b..z', { source: 'q(u|x)', flags: 'i' }] },
]);
const parsed = ctx.parseDataFlourish(attr);

expect(parsed[0]).toMatchObject({ type: 'target', mask: true });
expect(rxTag(parsed[0].regex)).toBe(true); // cross-realm safe
expect(parsed[0].regex.flags).toMatch(/g/);

const rxEntry = parsed.find(e => e.type === 'target-rx');
expect(rxTag(rxEntry.regex)).toBe(true);
expect(rxEntry.regex.flags).toMatch(/i/);
});

test('addStyle injects default style once and custom style classes when used', () => {
runScripts(['injectFlourishes.js', 'flourish.js']);
document.dispatchEvent(new Event('DOMContentLoaded'));

const css = [...document.head.querySelectorAll('style')].map(s => s.textContent).join('\n');
expect(css).toContain('.flr-default'); // default style
expect(css).toMatch(/\.(flr-custom-1)\s*\{/); // first custom style
});
test('pipeline flourishes code blocks and respects HTML-escaped targets', () => {
runScripts(['injectFlourishes.js', 'flourish.js']);
document.dispatchEvent(new Event('DOMContentLoaded'));

const codes = nonOutputCodes();
const code1 = codes[0]; // first cell
expect(code1.innerHTML).toBe('1 <span class="flr-default">&lt;</span> 2 &amp; 3');

const code2 = codes[1]; // second cell
expect(code2.innerHTML).toBe('<span class="flr-custom-1">foo</span> bar');

const code3 = codes[2]; // third cell, outside outputs
expect(code3.innerHTML).toBe('<span class="flr-default"> </span> here');

expect(document.querySelector('.cell-output code').innerHTML).toBe('secret in output');
expect(document.querySelector('.cell-output-stdout code').innerHTML).toBe('skip-me');
});

test('target-rx wraps multiple patterns in the same block', () => {
runScripts(['injectFlourishes.js', 'flourish.js']);
document.dispatchEvent(new Event('DOMContentLoaded'));

const codes = nonOutputCodes();
const node = codes[3]; // fifth cell's code block
const spans = [...node.querySelectorAll('span.flr-default')].map(s => s.textContent);
expect(spans).toEqual(['foo', 'bar', 'baz']);
});


test('idempotent on repeated DOMContentLoaded', () => {
runScripts(['injectFlourishes.js', 'flourish.js']);
document.dispatchEvent(new Event('DOMContentLoaded'));
const before = document.body.innerHTML;
document.dispatchEvent(new Event('DOMContentLoaded'));
const after = document.body.innerHTML;
expect(after).toBe(before); // no double-wrapping
});
});
107 changes: 107 additions & 0 deletions __tests__/injectFlourishes.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
const fs = require('fs');
const path = require('path');
const vm = require('vm');

// test utils
function loadIntoContext(filename, baseCtx = {}) {
const root = path.resolve(__dirname, '..');
const candidates = [
path.join(root, filename),
path.join(root, '_extensions', 'flourish', filename),
];
let code, used;
for (const p of candidates) {
try { code = fs.readFileSync(p, 'utf8'); used = p; break; } catch { }
}
if (!code) throw new Error(`Not found: ${candidates.join(' | ')}`);
const ctx = vm.createContext({ console, window, document, module: {}, exports: {}, ...baseCtx });
vm.runInContext(code, ctx, { filename: used });
return ctx;
}


describe('injectFlourishes.js helpers', () => {
let ctx;
beforeAll(() => {
ctx = loadIntoContext('_extensions/flourish/injectFlourishes.js');
});

test('getCumSum sums progressively', () => {
expect(ctx.getCumSum([1, 2, 3, 4])).toEqual([1, 3, 6, 10]);
});

test('getSplitInfo classifies tags vs text and reports spans', () => {
const rows = ctx.getSplitInfo(['ab', '<em>', 'cd', '</em>']);
const types = rows.map(r => r.type);
expect(types).toEqual(['text', 'tags', 'text', 'tags']);
expect(rows[0]).toMatchObject({ original: 'ab', start: 0, end: 2 });
expect(rows[2]).toMatchObject({ original: 'cd' });
});

test('findTargets returns match ranges for regex', () => {
const hits = ctx.findTargets('abc abc', /ab/g);
expect(hits).toEqual([
{ match: 'ab', start: 0, end: 2 },
{ match: 'ab', start: 4, end: 6 },
]);
});
});

describe('injectFlourishes core', () => {
let ctx;
beforeAll(() => {
ctx = loadIntoContext('_extensions/flourish/injectFlourishes.js');
});

test('wraps a simple match with span and class', () => {
const html = 'abc def xyz';
const out = ctx.injectFlourishes(html, /def/g, 'flr-x');
expect(out).toBe('abc <span class="flr-x">def</span> xyz');
});

test('handles matches crossing tag boundaries', () => {
const html = 'ab<em>c</em>d';
const out = ctx.injectFlourishes(html, /bcd/g, 'flr-x');
expect(out).toBe(
'a<span class="flr-x">b</span><em><span class="flr-x">c</span></em><span class="flr-x">d</span>'
);
});

test('mask=true replaces matched content with spaces of equal length', () => {
const html = 'hello';
const out = ctx.injectFlourishes(html, /ell/g, 'flr-x', true);
expect(out).toBe('h<span class="flr-x"> </span>o');
});

test('non-text (tags) remain unchanged', () => {
const html = '<code>abc</code>';
const out = ctx.injectFlourishes(html, /b/g, 'flr-x');
expect(out).toBe('<code>a<span class="flr-x">b</span>c</code>');
});

test('overlapping-like sequence by running twice', () => {
let out = ctx.injectFlourishes('aba', /aba/g, 'x');
out = ctx.injectFlourishes(out, /ba/g, 'y');
expect(out).toBe('<span class="x">a<span class="y">ba</span></span>');
});

test('greedy vs non-greedy regex respected', () => {
const html = 'a1b2c';
const greedy = ctx.injectFlourishes(html, /a.*c/g, 'g1');
const nongreedy = ctx.injectFlourishes(html, /a.*?b/g, 'g2');
expect(greedy).toBe('<span class="g1">a1b2c</span>');
expect(nongreedy).toBe('<span class="g2">a1b</span>2c');
});

test('respects tag boundaries across inner tags', () => {
const html = 'ab<em>c</em>d';
const out = ctx.injectFlourishes(html, /bcd/g, 'z');
expect(out).toBe('a<span class="z">b</span><em><span class="z">c</span></em><span class="z">d</span>');
});

test('special chars in pattern escaped correctly', () => {
const html = 'price is $5.00';
const out = ctx.injectFlourishes(html, /\$5\.00/g, 'p');
expect(out).toBe('price is <span class="p">$5.00</span>');
});
});
3 changes: 3 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module.exports = {
testEnvironment: 'jsdom',
};
Loading
Loading