Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 0 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -101,10 +101,8 @@
"tinyglobby": "^0.2.17",
"unified": "^11.0.5",
"unist-builder": "^4.0.0",
"unist-util-find-after": "^5.0.0",
"unist-util-position": "^5.0.0",
"unist-util-remove": "^4.0.0",
"unist-util-select": "^5.1.0",
"unist-util-visit": "^5.1.0",
"yaml": "^2.9.0"
}
Expand Down
75 changes: 57 additions & 18 deletions src/generators/jsx-ast/utils/plugins/alerts.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,13 @@ import { ALERT_MARKER, GITHUB_ALERT_TYPES } from '../../constants.mjs';
import { createJSXElement } from '../ast.mjs';

/**
* Converts a marker keyword into a human-readable title (e.g. `NOTE` -> `Note`).
* @param {string} type - The uppercase alert keyword
* Converts a marker keyword into a human-readable title.
*
* Example:
* NOTE -> Note
*
* @param {string} type
* @returns {string}
*/
const toTitle = type => type[0] + type.slice(1).toLowerCase();

Expand All @@ -17,46 +22,80 @@ const toTitle = type => type[0] + type.slice(1).toLowerCase();
*/
const transformer = tree => {
visit(tree, 'blockquote', (node, index, parent) => {
// The marker must be the leading text of the blockquote's first paragraph
const paragraph = node.children[0];
/**
* Only root-level replacements need a parent.
*/
if (!parent || index === undefined) {
return;
}

const children = node.children;
const paragraph = children[0];

/**
* GitHub alerts must start with a paragraph.
*/
if (paragraph?.type !== 'paragraph') {
return;
}

const text = paragraph.children[0];
const paragraphChildren = paragraph.children;
const firstChild = paragraphChildren[0];

if (text?.type !== 'text') {
/**
* The marker must be plain text.
*/
if (firstChild?.type !== 'text') {
return;
}

const match = text.value.match(ALERT_MARKER);
const match = ALERT_MARKER.exec(firstChild.value);

if (!match) {
return;
}

// Strip the marker (and its trailing line break) from the leading text,
// dropping the now-empty text node — and its paragraph — if nothing remains.
text.value = text.value.slice(match[0].length);
/**
* Remove the alert marker.
*
* Example:
*
* "[!NOTE]\nhello"
*
* becomes:
*
* "hello"
*/
firstChild.value = firstChild.value.slice(match[0].length);

if (text.value === '') {
paragraph.children.shift();
/**
* Remove empty nodes created by stripping the marker.
*/
if (firstChild.value === '') {
paragraphChildren.shift();
}

if (paragraph.children.length === 0) {
node.children.shift();
if (paragraphChildren.length === 0) {
children.shift();
}

/**
* Replace the blockquote with AlertBox JSX.
*/
parent.children[index] = createJSXElement(JSX_IMPORTS.AlertBox.name, {
inline: false,
children: node.children,
children,
level: GITHUB_ALERT_TYPES[match[1]],
title: toTitle(match[1]),
});

// Skip the (now detached) blockquote's children, but revisit this index so
// the new AlertBox is descended into, allowing nested alerts to transform.
/**
* Skip the detached blockquote.
*
* Returning index causes the new AlertBox at this
* position to be visited again, allowing nested
* alerts to transform.
*/
return [SKIP, index];
});
};
Expand All @@ -69,6 +108,6 @@ const transformer = tree => {
* > [!NOTE]
* > Highlights information that users should take into account.
*
* @see https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts
* @see https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-syntax#alerts
*/
export default () => transformer;
134 changes: 92 additions & 42 deletions src/generators/jsx-ast/utils/plugins/transformer.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ import { visit } from 'unist-util-visit';
import { TAG_TRANSFORMS } from '../../constants.mjs';

/**
* Checks whether a HAST node is the generated GFM footnotes section.
* @param {import('hast').Element} node
* Checks whether a node is the generated GFM footnotes section.
*
* @param {import('hast').Node} node
*/
const isFootnotesSection = node =>
node?.type === 'element' &&
Expand All @@ -14,69 +15,118 @@ const isFootnotesSection = node =>
node.properties?.className?.includes('footnotes'));

/**
* Finds the generated page Layout node.
* @param {import('hast').Root} tree
* Checks whether a node is the generated Layout component.
*
* @param {import('unist').Node} node
*/
const isLayout = node =>
node?.name === 'Layout' && Array.isArray(node.children);

/**
* Adds responsive labels to table cells.
*
* @param {import('hast').Element} table
*/
const findLayout = tree =>
tree.children.find(node => node.name === 'Layout' && node.children);
const transformTable = table => {
const thead = table.children.find(node => node.tagName === 'thead');

if (!thead) {
return;
}

const headerRow = thead.children?.[0];

if (!headerRow?.children) {
return;
}

const headers = headerRow.children.map(toString);

const tbody = table.children.find(node => node.tagName === 'tbody');

if (!tbody?.children) {
return;
}

for (const row of tbody.children) {
for (const [index, cell] of (row.children ?? []).entries()) {
if (cell.tagName === 'td') {
cell.properties ??= {};
cell.properties['data-label'] = headers[index];
}
}
}
};

/**
* @template {import('unist').Node} T
* @param {T} tree
* @returns {T}
*/
const transformer = tree => {
visit(tree, 'element', (node, index, parent) => {
node.tagName = TAG_TRANSFORMS[node.tagName] || node.tagName;
let layout = null;

// Wrap <table> in a <div class="table-container">, and apply responsive
// data attributes
if (node.tagName === 'table') {
if (parent) {
parent.children[index] = {
type: 'element',
tagName: 'div',
properties: { className: ['overflow-container'] },
children: [node],
};
}
/**
* Transform element nodes and locate Layout.
*
* We intentionally visit every node because MDX JSX nodes
* are not HAST "element" nodes.
*/
visit(tree, node => {
/**
* Find Layout regardless of node type.
*/
if (isLayout(node)) {
layout = node;
}

// Not every table will have a header, so only do this on tables
// with them.
const thead = node.children.find(el => el.tagName === 'thead');

if (thead) {
// TODO(@avivkeller): These are only strings afaict, so a `toString` dependency
// might not actually be needed.
const headers = thead.children[0].children.map(toString);
const tbody = node.children.find(el => el.tagName === 'tbody');

visit(
tbody,
node => node.tagName === 'td',
(node, index) => (node.properties['data-label'] = headers[index])
);
}
/**
* Only HAST elements have tagName.
*/
if (node.type !== 'element') {
return;
}

/**
* Normalize HTML tags.
*/
const transformedTag = TAG_TRANSFORMS[node.tagName];

if (transformedTag) {
node.tagName = transformedTag;
}

/**
* Tables need special handling.
*/
if (node.tagName === 'table') {
transformTable(node);
Comment thread
AugustinMauroy marked this conversation as resolved.
Outdated
}
});

const index = tree.children.findLastIndex(isFootnotesSection);
/**
* Find generated footnotes directly among root children.
*
* This is faster and more reliable than looking during
* the recursive traversal because footnotes are always
* generated at the document root.
*/
const footnotesIndex = tree.children.findLastIndex(isFootnotesSection);

if (index !== -1) {
const [section] = tree.children.splice(index, 1);
const layout = findLayout(tree);
if (footnotesIndex !== -1) {
const [footnotes] = tree.children.splice(footnotesIndex, 1);

if (layout) {
layout.children.push(section);
layout.children.push(footnotes);
} else {
tree.children.push(section);
tree.children.push(footnotes);
}
}
};

/**
* Transforms elements in a syntax tree by replacing tag names according to the mapping.
*
* Also moves any generated root section into its proper location in the AST.
* Also moves generated footnotes sections into Layout.
*/
export default () => transformer;
Loading
Loading