chore: import upstream snapshot with attribution
i18n - Build Validation / Validate i18n Builds (24) (push) Has been cancelled
CI - Node.js / Lint (24) (push) Has been cancelled
CI - Node.js / Build (24) (push) Has been cancelled
CI - Node.js / Test (24) (push) Has been cancelled
CI - Node.js / Test - Upcoming Changes (24) (push) Has been cancelled
CI - Node.js / Test - i18n (italian, 24) (push) Has been cancelled
CI - Node.js / Test - i18n (portuguese, 24) (push) Has been cancelled
CD - Docker - GHCR Images / Build and Push Images (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 11:55:53 +08:00
commit dde272c4b8
19405 changed files with 2730632 additions and 0 deletions
@@ -0,0 +1,395 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`getSection > should match the hints snapshot 1`] = `
[
{
"children": [
{
"position": {
"end": {
"column": 11,
"line": 19,
"offset": 142,
},
"start": {
"column": 1,
"line": 19,
"offset": 132,
},
},
"type": "text",
"value": "First hint",
},
],
"position": {
"end": {
"column": 11,
"line": 19,
"offset": 142,
},
"start": {
"column": 1,
"line": 19,
"offset": 132,
},
},
"type": "paragraph",
},
{
"lang": "js",
"meta": null,
"position": {
"end": {
"column": 4,
"line": 23,
"offset": 166,
},
"start": {
"column": 1,
"line": 21,
"offset": 144,
},
},
"type": "code",
"value": "// test code",
},
{
"children": [
{
"position": {
"end": {
"column": 18,
"line": 25,
"offset": 185,
},
"start": {
"column": 1,
"line": 25,
"offset": 168,
},
},
"type": "text",
"value": "Second hint with ",
},
{
"position": {
"end": {
"column": 24,
"line": 25,
"offset": 191,
},
"start": {
"column": 18,
"line": 25,
"offset": 185,
},
},
"type": "html",
"value": "<code>",
},
{
"position": {
"end": {
"column": 28,
"line": 25,
"offset": 195,
},
"start": {
"column": 24,
"line": 25,
"offset": 191,
},
},
"type": "text",
"value": "code",
},
{
"position": {
"end": {
"column": 35,
"line": 25,
"offset": 202,
},
"start": {
"column": 28,
"line": 25,
"offset": 195,
},
},
"type": "html",
"value": "</code>",
},
],
"position": {
"end": {
"column": 35,
"line": 25,
"offset": 202,
},
"start": {
"column": 1,
"line": 25,
"offset": 168,
},
},
"type": "paragraph",
},
{
"lang": "js",
"meta": null,
"position": {
"end": {
"column": 4,
"line": 29,
"offset": 231,
},
"start": {
"column": 1,
"line": 27,
"offset": 204,
},
},
"type": "code",
"value": "// more test code",
},
{
"children": [
{
"position": {
"end": {
"column": 7,
"line": 31,
"offset": 239,
},
"start": {
"column": 1,
"line": 31,
"offset": 233,
},
},
"type": "text",
"value": "Third ",
},
{
"children": [
{
"position": {
"end": {
"column": 12,
"line": 31,
"offset": 244,
},
"start": {
"column": 8,
"line": 31,
"offset": 240,
},
},
"type": "text",
"value": "hint",
},
],
"position": {
"end": {
"column": 13,
"line": 31,
"offset": 245,
},
"start": {
"column": 7,
"line": 31,
"offset": 239,
},
},
"type": "emphasis",
},
{
"position": {
"end": {
"column": 19,
"line": 31,
"offset": 251,
},
"start": {
"column": 13,
"line": 31,
"offset": 245,
},
},
"type": "text",
"value": " with ",
},
{
"position": {
"end": {
"column": 25,
"line": 31,
"offset": 257,
},
"start": {
"column": 19,
"line": 31,
"offset": 251,
},
},
"type": "html",
"value": "<code>",
},
{
"position": {
"end": {
"column": 29,
"line": 31,
"offset": 261,
},
"start": {
"column": 25,
"line": 31,
"offset": 257,
},
},
"type": "text",
"value": "code",
},
{
"position": {
"end": {
"column": 36,
"line": 31,
"offset": 268,
},
"start": {
"column": 29,
"line": 31,
"offset": 261,
},
},
"type": "html",
"value": "</code>",
},
{
"position": {
"end": {
"column": 41,
"line": 31,
"offset": 273,
},
"start": {
"column": 36,
"line": 31,
"offset": 268,
},
},
"type": "text",
"value": " and ",
},
{
"position": {
"end": {
"column": 54,
"line": 31,
"offset": 286,
},
"start": {
"column": 41,
"line": 31,
"offset": 273,
},
},
"type": "inlineCode",
"value": "inline code",
},
],
"position": {
"end": {
"column": 54,
"line": 31,
"offset": 286,
},
"start": {
"column": 1,
"line": 31,
"offset": 233,
},
},
"type": "paragraph",
},
{
"lang": "js",
"meta": null,
"position": {
"end": {
"column": 4,
"line": 38,
"offset": 353,
},
"start": {
"column": 1,
"line": 33,
"offset": 288,
},
},
"type": "code",
"value": "// more test code
if(let x of xs) {
console.log(x);
}",
},
]
`;
exports[`getSection > should match the instructions snapshot 1`] = `
[
{
"children": [
{
"position": {
"end": {
"column": 12,
"line": 11,
"offset": 89,
},
"start": {
"column": 1,
"line": 11,
"offset": 78,
},
},
"type": "text",
"value": "Paragraph 0",
},
],
"position": {
"end": {
"column": 12,
"line": 11,
"offset": 89,
},
"start": {
"column": 1,
"line": 11,
"offset": 78,
},
},
"type": "paragraph",
},
{
"lang": "html",
"meta": null,
"position": {
"end": {
"column": 4,
"line": 15,
"offset": 117,
},
"start": {
"column": 1,
"line": 13,
"offset": 91,
},
},
"type": "code",
"value": "code example 0",
},
]
`;
@@ -0,0 +1,21 @@
const find = require('unist-util-find');
const findAllBefore = require('unist-util-find-all-before');
function getAllBefore(tree, marker) {
const start = find(tree, {
type: 'heading',
children: [
{
type: 'text',
value: marker
}
]
});
if (!start) return [];
// reverse because it goes up the tree backwards and adds nodes in that order
return findAllBefore(tree, start).reverse();
}
module.exports = getAllBefore;
@@ -0,0 +1,32 @@
import isArray from 'lodash/isArray';
import { describe, beforeAll, it, expect } from 'vitest';
import parseFixture from '../../__fixtures__/parse-fixture';
import getAllBefore from './before-heading';
describe('before-headings', () => {
let simpleAst;
beforeAll(async () => {
simpleAst = await parseFixture('simple.md');
});
it('should return an array', () => {
expect.assertions(1);
const actual = getAllBefore(simpleAst, '--hints--');
expect(isArray(actual)).toBe(true);
});
it('should return an empty array if the marker is not present', () => {
expect.assertions(2);
const actual = getAllBefore(simpleAst, '--not-a-marker--');
expect(isArray(actual)).toBe(true);
expect(actual.length).toBe(0);
});
it('should include the whole AST before the marker', () => {
expect.assertions(1);
const actual = getAllBefore(simpleAst, '--hints--');
expect(actual).toHaveLength(6);
});
});
@@ -0,0 +1,27 @@
const visit = require('unist-util-visit');
const _ = require('lodash');
/**
* Finds all nodes in a tree that match a given condition. This is a trivial
* extension of `unist-util-find` that returns all matching nodes.
*
* @param {Object} tree - The unist tree to search through.
* @param {Function|Object} condition - The condition to match nodes
* against. This can be a function that accepts a single node argument or an object to match.
* @returns {Array} An array of nodes that match the condition.
*/
function findAll(tree, condition) {
const predicate = _.iteratee(condition);
const results = [];
visit(tree, node => {
if (predicate(node)) {
results.push(node);
}
return visit.CONTINUE;
});
return results;
}
module.exports.findAll = findAll;
@@ -0,0 +1,41 @@
import { describe, it, expect } from 'vitest';
import { findAll } from './find-all';
const testTree = {
type: 'root',
children: [
{
type: 'heading',
depth: 1,
children: [{ type: 'text', value: 'test', testId: 1 }]
},
{
type: 'paragraph',
children: [{ type: 'text', value: 'different', testId: 2 }]
},
{
type: 'heading',
depth: 2,
children: [{ type: 'text', value: 'test', testId: 3 }]
},
{
type: 'heading',
depth: 1,
children: [{ type: 'text', value: 'test', testId: 4 }]
}
]
};
describe('findAll', () => {
it('should return an array', () => {
expect(findAll(testTree, () => false)).toEqual([]);
});
it('should return an array of nodes that match the test', () => {
expect(findAll(testTree, { type: 'text', value: 'test' })).toEqual([
{ type: 'text', value: 'test', testId: 1 },
{ type: 'text', value: 'test', testId: 3 },
{ type: 'text', value: 'test', testId: 4 }
]);
});
});
@@ -0,0 +1,99 @@
const { isEmpty } = require('lodash');
const is = require('unist-util-is');
const position = require('unist-util-position');
const getId = require('./get-id');
const supportedLanguages = [
'js',
'css',
'html',
'jsx',
'py',
'ts',
'tsx',
'json'
];
const longToShortLanguages = {
javascript: 'js',
typescript: 'ts',
python: 'py'
};
function defaultFile(lang, id) {
return {
ext: lang,
name: getFilenames(lang),
contents: '',
id
};
}
function getFilenames(lang) {
const langToFilename = {
js: 'script',
css: 'styles',
py: 'main',
json: 'tsconfig'
};
return langToFilename[lang] ?? 'index';
}
function getFileVisitor(seeds, seedKey, validate) {
return (node, index, parent) => {
if (is(node, 'root')) return;
if (is(node, 'code')) {
codeToData(node, seeds, seedKey, validate);
return;
}
idToData(node, index, parent, seeds);
};
}
function codeToData(node, seeds, seedKey, validate) {
if (validate) validate(node);
const lang = node.lang;
const shortLang = longToShortLanguages[lang] ?? lang;
if (!supportedLanguages.includes(shortLang))
throw Error(`On line ${
position.start(node).line
} '${shortLang}' is not a supported language.
Please use one of js, css, html, jsx, ts, tsx or py
`);
const fileId = `index${shortLang}`;
const id = seeds[fileId] ? seeds[fileId].id : '';
// the contents will be missing if there is an id preceding this code
// block.
if (!seeds[fileId]) {
seeds[fileId] = defaultFile(shortLang, id);
}
seeds[fileId][seedKey] = isEmpty(seeds[fileId][seedKey])
? node.value
: seeds[fileId][seedKey] + '\n' + node.value;
}
function idToData(node, index, parent, seeds) {
const id = getId(node);
// If this is reached, the node type is neither root nor code. If it is not
// an id, there must be a syntax error.
if (!id) {
throw Error(
'Unexpected syntax in seed/solution. Must be ::id{#id} or a code ' +
'block (```) \n'
);
}
const codeNode = parent.children[index + 1];
if (codeNode && is(codeNode, 'code')) {
const shortLang = longToShortLanguages[codeNode.lang] ?? codeNode.lang;
const fileKey = `index${shortLang}`;
if (seeds[fileKey]) throw Error('::id{#id}s must come before code blocks');
seeds[fileKey] = defaultFile(shortLang, id);
} else {
throw Error('::id{#id}s must come before code blocks');
}
}
module.exports.getFileVisitor = getFileVisitor;
module.exports.getFilenames = getFilenames;
@@ -0,0 +1,8 @@
// getId expects the image reference node to be the sole node in a paragraph
function getId(node) {
const { type, name, attributes } = node;
if (type !== 'leafDirective' || name !== 'id' || !attributes) return null;
return attributes.id;
}
module.exports = getId;
@@ -0,0 +1,50 @@
import { describe, beforeAll, it, expect } from 'vitest';
import parseFixture from '../../__fixtures__/parse-fixture';
import getId from './get-id';
describe('get-id', () => {
let idNode, imageNode, multipleChildrenNode;
beforeAll(async () => {
const withIdNode = await parseFixture('with-id-node.md');
idNode = withIdNode.children[0];
imageNode = withIdNode.children[1];
multipleChildrenNode = withIdNode.children[2];
});
it('should return a string', () => {
expect.assertions(1);
const actual = getId(idNode);
expect(typeof actual).toBe('string');
});
it('should get the expected identifier', () => {
expect.assertions(1);
const actual = getId(idNode);
expect(actual).toBe('html-key');
});
it('should return null if the node does contain an id', () => {
expect.assertions(1);
const actual = getId(imageNode);
expect(actual).toBeNull();
});
// TODO: bin this (and the json!) after development (it'll be a silly test
// once we're using directives)
it('should ignore image nodes', () => {
expect.assertions(1);
const actual = getId(imageNode);
expect(actual).toBeNull();
});
// TODO: bin this (and the json!) after development (it'll be a silly test
// once we're using directives)
// TODO: do we want to fail silently? Might it be better to output warnings
// or perhaps even stop the parser? Probably warnings if anything.
it('should ignore paragraphs that contain more than the id element', () => {
expect.assertions(1);
const actual = getId(multipleChildrenNode);
expect(actual).toBeNull();
});
});
@@ -0,0 +1,7 @@
const mdastToHtml = require('./mdast-to-html');
function getParagraphContent(node) {
return node.type === 'paragraph' ? mdastToHtml(node.children) : null;
}
module.exports.getParagraphContent = getParagraphContent;
@@ -0,0 +1,24 @@
import { describe, beforeAll, it, expect } from 'vitest';
import parseFixture from '../../__fixtures__/parse-fixture';
import { getParagraphContent } from './get-paragraph-content';
describe('getParagraphContent', () => {
let simpleAST;
beforeAll(async () => {
simpleAST = await parseFixture('simple.md');
});
it('should return the content of a paragraph node', () => {
const paragraphNode = simpleAST.children[1];
expect(paragraphNode.type).toBe('paragraph');
expect(getParagraphContent(paragraphNode)).toEqual('Paragraph 1');
});
it('should return null if the node is not a paragraph', () => {
const headingNode = simpleAST.children[0];
expect(headingNode.type).toBe('heading');
expect(getParagraphContent(headingNode)).toBeNull();
});
});
@@ -0,0 +1,58 @@
const find = require('unist-util-find');
const findAfter = require('unist-util-find-after');
const findAllAfter = require('unist-util-find-all-after');
const between = require('unist-util-find-all-between');
const { findAll } = require('./find-all');
const isMarker = node => {
if (node.children && node.children[0]) {
const child = node.children[0];
return (
child.type === 'text' &&
child.value.startsWith('--') &&
child.value.endsWith('--') &&
node.type === 'heading'
);
}
return false;
};
function _getSection(tree) {
return start => {
if (!start) return [];
const isEnd = node => {
return node.depth <= start.depth && isMarker(node);
};
const end = findAfter(tree, start, isEnd);
const targetNodes = end
? between(tree, start, end)
: findAllAfter(tree, start);
return targetNodes;
};
}
const startNode = (marker, depth) => ({
type: 'heading',
children: [
{
type: 'text',
value: marker
}
],
...((typeof depth === 'number' && { depth }) || {})
});
function getSection(tree, marker, depth) {
const start = find(tree, startNode(marker, depth));
return _getSection(tree)(start);
}
function getAllSections(tree, marker) {
const starts = findAll(tree, startNode(marker));
return starts.map(_getSection(tree));
}
module.exports = { getSection, getAllSections, isMarker };
@@ -0,0 +1,58 @@
import { describe, beforeAll, it, expect } from 'vitest';
import isArray from 'lodash/isArray';
import { root } from 'mdast-builder';
import find from 'unist-util-find';
import parseFixture from '../../__fixtures__/parse-fixture';
import { getSection } from './get-section';
describe('getSection', () => {
let simpleAst, extraHeadingAst;
beforeAll(async () => {
extraHeadingAst = await parseFixture('with-extra-heading.md');
simpleAst = await parseFixture('simple.md');
});
it('should return an array', () => {
const actual = getSection(simpleAst, '--hints--');
expect(isArray(actual)).toBe(true);
});
it('should return an empty array if the marker is not present', () => {
const actual = getSection(simpleAst, '--not-a-marker--');
expect(isArray(actual)).toBe(true);
expect(actual.length).toBe(0);
});
it('should include any headings without markers', () => {
const actual = getSection(extraHeadingAst, '--description--');
expect(
find(root(actual), {
value: 'this should still be inside --description--'
})
).not.toBeUndefined();
});
it('should include the rest of the AST if there is no end marker', () => {
const actual = getSection(extraHeadingAst, '--solutions--');
expect(actual.length > 0).toBe(true);
expect(
find(root(actual), { value: 'body {\n background: white;\n}' })
).not.toBeUndefined();
});
it('should ignore a marker if the depth is not correct', () => {
const actual = getSection(extraHeadingAst, '--instructions--', 2);
expect(actual).toHaveLength(0);
});
it('should match the hints snapshot', () => {
const actual = getSection(simpleAst, '--hints--');
expect(actual).toMatchSnapshot();
});
it('should match the instructions snapshot', () => {
const actual = getSection(simpleAst, '--instructions--');
expect(actual).toMatchSnapshot();
});
});
@@ -0,0 +1,140 @@
const mdastToHTML = require('./mdast-to-html');
// Captures hanzi (pinyin) pairs (hanzi, optional whitespace, then pinyin parentheses)
const HANZI_PINYIN_PAIR = '([\u4e00-\u9fff]+)\\s*\\(([^)]+)\\)';
// Matches the BLANK placeholder
const BLANK_TOKEN = 'BLANK';
// Matches Chinese and English punctuation
const PUNCTUATION = '[,。?!!?,;:;:、]+';
// Matches Latin text with spaces
const OTHER_TEXT = '([a-zA-Z\\s]+)';
const HANZI_PINYIN_REGEX = new RegExp(
`${HANZI_PINYIN_PAIR}|${BLANK_TOKEN}|${PUNCTUATION}|${OTHER_TEXT}`,
'g'
);
/**
* Parses all hanzi-pinyin pairs from text
* @param {string} text - Text potentially containing multiple hanzi (pinyin) patterns
* @returns {Array<{hanzi: string, pinyin: string}>} Array of parsed pairs
*/
function parseHanziPinyinPairs(text) {
const pairs = [];
const regex = new RegExp(HANZI_PINYIN_REGEX);
let match;
while ((match = regex.exec(text)) !== null) {
if (match[1] && match[2]) {
pairs.push({
hanzi: match[1].trim(),
pinyin: match[2].trim()
});
}
}
return pairs;
}
/**
* Custom handler for Chinese inline code to render as ruby elements
* Matches hanzi-pinyin pairs, BLANK, and punctuation as separate elements
* @param {object} state - The state object from mdast-util-to-hast
* @param {object} node - The inlineCode node
* @returns {object|Array<object>} Hast element node or array of nodes
*/
function chineseInlineCodeHandler(state, node) {
const rubyPairs = parseHanziPinyinPairs(node.value);
if (rubyPairs.length > 0) {
const matches = [...node.value.matchAll(HANZI_PINYIN_REGEX)];
const nodes = matches.map(fullMatch => {
if (fullMatch[1] && fullMatch[2]) {
return {
type: 'element',
tagName: 'ruby',
properties: {},
children: [
{ type: 'text', value: fullMatch[1].trim() },
{
type: 'element',
tagName: 'rp',
properties: {},
children: [{ type: 'text', value: '(' }]
},
{
type: 'element',
tagName: 'rt',
properties: {},
children: [{ type: 'text', value: fullMatch[2].trim() }]
},
{
type: 'element',
tagName: 'rp',
properties: {},
children: [{ type: 'text', value: ')' }]
}
]
};
}
// Other captures (BLANK, punctuation, other text including spaces) should preserve exactly
return { type: 'text', value: fullMatch[0] };
});
return nodes.length === 1 ? nodes[0] : nodes;
}
// If static text, return code
return {
type: 'element',
tagName: 'span',
properties: { className: 'highlighted-text' },
children: [{ type: 'text', value: node.value }]
};
}
/**
* Custom handler for inline code to render as span elements
* @param {object} state - The state object from mdast-util-to-hast
* @param {object} node - The inlineCode node
* @returns {object} Hast element node
*/
function spanInlineCodeHandler(state, node) {
return {
type: 'element',
tagName: 'span',
properties: { className: 'highlighted-text' },
children: [{ type: 'text', value: node.value }]
};
}
const spanOrRubyOptions = {
handlers: {
inlineCode: chineseInlineCodeHandler
}
};
const spanOptions = {
handlers: {
inlineCode: spanInlineCodeHandler
}
};
const createMdastToHtml = lang => {
if (lang === 'zh-CN') {
return x => mdastToHTML(x, spanOrRubyOptions);
} else if (lang === 'en-US' || lang === 'es') {
return x => mdastToHTML(x, spanOptions);
} else {
return mdastToHTML;
}
};
module.exports = {
parseHanziPinyinPairs,
createMdastToHtml
};
@@ -0,0 +1,292 @@
import { describe, it, expect } from 'vitest';
import { createMdastToHtml, parseHanziPinyinPairs } from './i18n-stringify';
describe('parseHanziPinyinPairs', () => {
it('should parse single hanzi-pinyin pair', () => {
const withSpaceSeparator = parseHanziPinyinPairs('你好 (nǐ hǎo)');
expect(withSpaceSeparator).toHaveLength(1);
expect(withSpaceSeparator[0]).toMatchObject({
hanzi: '你好',
pinyin: 'nǐ hǎo'
});
const withoutSpaceSeparator = parseHanziPinyinPairs('你好(nǐ hǎo)');
expect(withoutSpaceSeparator).toHaveLength(1);
expect(withoutSpaceSeparator[0]).toMatchObject({
hanzi: '你好',
pinyin: 'nǐ hǎo'
});
});
it('should parse multiple hanzi-pinyin pairs', () => {
const withSpaceSeparator = parseHanziPinyinPairs(
'你好 (nǐ hǎo),我是王华 (wǒ shì Wang Hua)'
);
expect(withSpaceSeparator).toHaveLength(2);
expect(withSpaceSeparator[0]).toMatchObject({
hanzi: '你好',
pinyin: 'nǐ hǎo'
});
expect(withSpaceSeparator[1]).toMatchObject({
hanzi: '我是王华',
pinyin: 'wǒ shì Wang Hua'
});
const withoutSpaceSeparator = parseHanziPinyinPairs(
'你好(nǐ hǎo),我是王华(wǒ shì Wang Hua)'
);
expect(withoutSpaceSeparator).toHaveLength(2);
expect(withoutSpaceSeparator[0]).toMatchObject({
hanzi: '你好',
pinyin: 'nǐ hǎo'
});
expect(withoutSpaceSeparator[1]).toMatchObject({
hanzi: '我是王华',
pinyin: 'wǒ shì Wang Hua'
});
});
it('should return empty array for text without pairs', () => {
const result = parseHanziPinyinPairs('你好');
expect(result).toHaveLength(0);
});
});
describe('createMdastToHtml', () => {
it('should render Chinese inline code as ruby when lang is zh-CN', () => {
const toHtml = createMdastToHtml('zh-CN');
const nodes = [
{
type: 'paragraph',
children: [
{ type: 'text', value: 'This is ' },
{ type: 'inlineCode', value: '请问 (qǐng wèn)' },
{ type: 'text', value: '.' }
]
}
];
const actual = toHtml(nodes);
expect(actual).toBe(
'<p>This is <ruby>请问<rp>(</rp><rt>qǐng wèn</rt><rp>)</rp></ruby>.</p>'
);
});
it('should render Chinese inline code as ruby with or without space before parenthesis', () => {
const toHtml = createMdastToHtml('zh-CN');
const nodesWithSpace = [
{
type: 'paragraph',
children: [{ type: 'inlineCode', value: '你好 (nǐ hǎo)' }]
}
];
const nodesWithoutSpace = [
{
type: 'paragraph',
children: [{ type: 'inlineCode', value: '你好(nǐ hǎo)' }]
}
];
const expected =
'<p><ruby>你好<rp>(</rp><rt>nǐ hǎo</rt><rp>)</rp></ruby></p>';
expect(toHtml(nodesWithSpace)).toBe(expected);
expect(toHtml(nodesWithoutSpace)).toBe(expected);
});
it('should handle multiple Chinese inline codes in one paragraph', () => {
const toHtml = createMdastToHtml('zh-CN');
const nodes = [
{
type: 'paragraph',
children: [
{ type: 'inlineCode', value: '你好 (nǐ hǎo)' },
{ type: 'text', value: ' and ' },
{ type: 'inlineCode', value: '再见 (zài jiàn)' }
]
}
];
const actual = toHtml(nodes);
expect(actual).toBe(
'<p><ruby>你好<rp>(</rp><rt>nǐ hǎo</rt><rp>)</rp></ruby> and <ruby>再见<rp>(</rp><rt>zài jiàn</rt><rp>)</rp></ruby></p>'
);
});
it('should render BLANK tokens and punctuation marks as plain text', () => {
const toHtml = createMdastToHtml('zh-CN');
const withoutSpacesAroundBlanks = [
{
type: 'paragraph',
children: [
{
type: 'inlineCode',
value:
'你好 (nǐ hǎo)BLANK是王华 (shì Wang Hua),请问你 (qǐng wèn nǐ)BLANK什么名字 (shén me míng zi)'
}
]
}
];
expect(toHtml(withoutSpacesAroundBlanks)).toBe(
'<p><ruby>你好<rp>(</rp><rt>nǐ hǎo</rt><rp>)</rp></ruby>BLANK<ruby>是王华<rp>(</rp><rt>shì Wang Hua</rt><rp>)</rp></ruby><ruby>请问你<rp>(</rp><rt>qǐng wèn nǐ</rt><rp>)</rp></ruby>BLANK<ruby>什么名字<rp>(</rp><rt>shén me míng zi</rt><rp>)</rp></ruby></p>'
);
const withSpacesAroundBlanks = [
{
type: 'paragraph',
children: [
{
type: 'inlineCode',
value:
'你好 (nǐ hǎo) BLANK 是王华 (shì Wang Hua),请问你 (qǐng wèn nǐ) BLANK 什么名字 (shén me míng zi)'
}
]
}
];
expect(toHtml(withSpacesAroundBlanks)).toBe(
'<p><ruby>你好<rp>(</rp><rt>nǐ hǎo</rt><rp>)</rp></ruby> BLANK <ruby>是王华<rp>(</rp><rt>shì Wang Hua</rt><rp>)</rp></ruby><ruby>请问你<rp>(</rp><rt>qǐng wèn nǐ</rt><rp>)</rp></ruby> BLANK <ruby>什么名字<rp>(</rp><rt>shén me míng zi</rt><rp>)</rp></ruby></p>'
);
});
it('should render Latin words as plain text while applying ruby to hanzi-pinyin pairs', () => {
const toHtml = createMdastToHtml('zh-CN');
const nodes = [
{
type: 'paragraph',
children: [
{
type: 'inlineCode',
value: '我是 (wǒ shì) UI 设计师 (shè jì shī)'
}
]
}
];
const actual = toHtml(nodes);
expect(actual).toBe(
'<p><ruby>我是<rp>(</rp><rt>wǒ shì</rt><rp>)</rp></ruby> UI <ruby>设计师<rp>(</rp><rt>shè jì shī</rt><rp>)</rp></ruby></p>'
);
});
it('should handle BLANK token and Latin word mix', () => {
const toHtml = createMdastToHtml('zh-CN');
const nodes = [
{
type: 'paragraph',
children: [
{
type: 'inlineCode',
value: '我 (wǒ) BLANK UI 设计师 (shè jì shī)'
}
]
}
];
const actual = toHtml(nodes);
expect(actual).toBe(
'<p><ruby>我<rp>(</rp><rt>wǒ</rt><rp>)</rp></ruby> BLANK UI <ruby>设计师<rp>(</rp><rt>shè jì shī</rt><rp>)</rp></ruby></p>'
);
});
it('should render multiple adjacent BLANK tokens in Chinese sentence', () => {
const toHtml = createMdastToHtml('zh-CN');
const nodes = [
{
type: 'paragraph',
children: [{ type: 'inlineCode', value: 'BLANK BLANK,你好 (nǐ hǎo)' }]
}
];
const actual = toHtml(nodes);
expect(actual).toBe(
'<p>BLANK BLANK<ruby>你好<rp>(</rp><rt>nǐ hǎo</rt><rp>)</rp></ruby></p>'
);
});
it('should fallback to span element if pattern does not match', () => {
const toHtml = createMdastToHtml('zh-CN');
const nodes = [
{
type: 'paragraph',
children: [
{ type: 'inlineCode', value: '你好' },
{ type: 'text', value: ' and ' },
{ type: 'inlineCode', value: 'nǐ hǎo' }
]
}
];
const actual = toHtml(nodes);
expect(actual).toBe(
'<p><span class="highlighted-text">你好</span> and <span class="highlighted-text">nǐ hǎo</span></p>'
);
});
it('should render inline code as span when lang is en-US', () => {
const toHtml = createMdastToHtml('en-US');
const nodes = [
{
type: 'paragraph',
children: [
{ type: 'text', value: 'This is ' },
{ type: 'inlineCode', value: 'highlighted text' },
{ type: 'text', value: '.' }
]
}
];
const actual = toHtml(nodes);
expect(actual).toBe(
'<p>This is <span class="highlighted-text">highlighted text</span>.</p>'
);
});
it('should render inline code as span when lang is es', () => {
const toHtml = createMdastToHtml('es');
const nodes = [
{
type: 'paragraph',
children: [
{ type: 'text', value: 'Esto texto ' },
{ type: 'inlineCode', value: 'está resaltado' },
{ type: 'text', value: '.' }
]
}
];
const actual = toHtml(nodes);
expect(actual).toBe(
'<p>Esto texto <span class="highlighted-text">está resaltado</span>.</p>'
);
});
// NOTE: The mobile app looks for the 'highlighted-text' class to apply highlighted styles.
// Notify the mobile team if the class name is changed or removed.
it("should include the 'highlighted-text' class when rendering as span", () => {
const toHtml = createMdastToHtml('en-US');
const nodes = [
{
type: 'paragraph',
children: [{ type: 'inlineCode', value: 'highlighted' }]
}
];
const actual = toHtml(nodes);
expect(actual).toMatch(/class="highlighted-text"/);
});
it('should render as regular code when lang is not zh-CN, en-US, or es', () => {
const toHtml = createMdastToHtml('zh');
const nodes = [
{
type: 'paragraph',
children: [{ type: 'inlineCode', value: '请问 (qǐng wèn)' }]
}
];
const actual = toHtml(nodes);
expect(actual).toBe('<p><code>请问 (qǐng wèn)</code></p>');
});
it('should render as regular code when lang is not defined', () => {
const toHtml = createMdastToHtml();
const nodes = [
{
type: 'paragraph',
children: [{ type: 'inlineCode', value: '请问 (qǐng wèn)' }]
}
];
const actual = toHtml(nodes);
expect(actual).toBe('<p><code>请问 (qǐng wèn)</code></p>');
});
});
@@ -0,0 +1,18 @@
const hastToHTML = require('hast-util-to-html');
const { root } = require('mdast-builder');
const mdastToHast = require('mdast-util-to-hast');
function mdastToHTML(nodes, hastOptions = {}) {
if (!Array.isArray(nodes))
throw Error('mdastToHTML expects an array argument');
// - the 'nodes' are children, so first need embedding in a parent
return hastToHTML(
mdastToHast(root(nodes), { allowDangerousHtml: true, ...hastOptions }),
{
allowDangerousHtml: true
}
);
}
module.exports = mdastToHTML;
@@ -0,0 +1,42 @@
import { describe, beforeAll, it, expect } from 'vitest';
import parseFixture from '../../__fixtures__/parse-fixture';
import mdastToHTML from './mdast-to-html';
describe('mdast-to-html', () => {
let mdastMixedNodes, singleNode, inlineHTMLNodes;
beforeAll(async () => {
const mdastMixedAST = await parseFixture('mixed-nodes.md');
const mdastInlineHTMLAST = await parseFixture('inline-html.md');
mdastMixedNodes = mdastMixedAST.children;
singleNode = mdastMixedAST.children[0];
inlineHTMLNodes = mdastInlineHTMLAST.children;
});
it('should return a string', () => {
expect.assertions(1);
const actual = mdastToHTML(mdastMixedNodes);
expect(typeof actual).toBe('string');
});
it('throws if it is not passed an array', () => {
expect.assertions(1);
expect(() => mdastToHTML(singleNode)).toThrow(
'mdastToHTML expects an array argument'
);
});
it('should not escape html', () => {
const actual = mdastToHTML(mdastMixedNodes);
expect(actual).toBe(`<p>Paragraph 1</p>
<p>Third <em>hint</em> with <code>code</code> and <code>inline code</code></p>`);
});
it('should put inline html inside the enclosing paragraph', () => {
const actual = mdastToHTML(inlineHTMLNodes);
expect(actual).toBe(
'<p><code> code in </code> code tags <em>emphasis</em> followed' +
' by <div><span>some nested html </span></div></p>'
);
});
});
@@ -0,0 +1,20 @@
const is = require('unist-util-is');
// TODO: specific tests for this would be nice, even though it is somewhat
// covered by the plugins that use it.
function splitOnThematicBreak(nodes) {
return nodes.reduce(
(prev, curr) => {
if (is(curr, 'thematicBreak')) {
return [...prev, []];
} else {
const first = prev.slice(0, -1);
const last = prev.slice(-1)[0];
return [...first, [...last, curr]];
}
},
[[]]
);
}
exports.splitOnThematicBreak = splitOnThematicBreak;