migrate tabs markdown component to jsx

This commit is contained in:
James Fenn
2023-04-09 22:25:54 -04:00
parent 01973a7d01
commit 1cb2f73e22
11 changed files with 232 additions and 231 deletions

View File

@@ -68,7 +68,7 @@ import SignUp from "src/page-components/collections/framework-field-guide/segmen
enableColorChangeListeners();
</script>
<script>
import { enableTabs } from "../../../utils/markdown/scripts/tabs";
import { enableTabs } from "../../../utils/markdown/tabs/tabs-script";
enableTabs();
</script>
<script>

View File

@@ -1,8 +1,8 @@
import { GetPictureResult } from "@astrojs/image/dist/lib/get-picture";
export interface IFramePlaceholderProps {
width: number;
height: number;
width: string;
height: string;
src: string;
pageTitle: string;
pageIcon: GetPictureResult;

View File

@@ -9,6 +9,7 @@ export const iFrameClickToRun = () => {
[...iframeButtons].forEach((el) => {
el.addEventListener("click", () => {
const iframe = document.createElement("iframe");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(iframe as any).loading = "lazy";
iframe.src = el.parentElement.dataset.iframeurl;
iframe.style.width = el.parentElement.style.width;

View File

@@ -1,4 +1,4 @@
import { Root } from "hast";
import { Root, Element } from "hast";
import { Plugin } from "unified";
import { visit } from "unist-util-visit";
@@ -141,8 +141,8 @@ export const rehypeUnicornIFrameClickToRun: Plugin<
Root
> = () => {
return async (tree) => {
const iframeNodes: any[] = [];
visit(tree, (node: any) => {
const iframeNodes: Element[] = [];
visit(tree, (node: Element) => {
if (node.tagName === "iframe") {
iframeNodes.push(node);
}
@@ -153,13 +153,13 @@ export const rehypeUnicornIFrameClickToRun: Plugin<
const width = iframeNode.properties.width ?? EMBED_SIZE.w;
const height = iframeNode.properties.height ?? EMBED_SIZE.h;
const info: PageInfo = (await fetchPageInfo(
iframeNode.properties.src
iframeNode.properties.src.toString()
).catch(() => null)) || { icon: await fetchDefaultPageIcon() };
const iframeReplacement = IFramePlaceholder({
width,
height,
src: iframeNode.properties.src,
width: width.toString(),
height: height.toString(),
src: iframeNode.properties.src.toString(),
pageTitle: info.title || "",
pageIcon: info.icon,
});

View File

@@ -1 +1 @@
export * from "./tabs";
export * from "./rehype-transform";

View File

@@ -0,0 +1,164 @@
import { Root } from "hast";
import replaceAllBetween from "unist-util-replace-all-between";
import { Plugin } from "unified";
import { getHeaderNodeId, slugs } from "rehype-slug-custom-id";
import { Element, Node, Text } from "hast";
import { TabInfo, Tabs } from "./tabs";
import { toString } from "hast-util-to-string";
const isNodeHeading = (n: Element) =>
n.type === "element" && /h[1-6]/.exec(n.tagName);
const findLargestHeading = (nodes: Element[]) => {
let largestSize = Infinity;
for (const node of nodes) {
if (!isNodeHeading(node)) continue;
const size = parseInt(node.tagName.substring(1), 10);
largestSize = Math.min(largestSize, size);
}
return largestSize;
};
const isNodeLargestHeading = (n: Element, largestSize: number) =>
isNodeHeading(n) && parseInt(n.tagName.substring(1), 10) === largestSize;
const getApproxLineCount = (nodes: Node[], inParagraph?: boolean): number => {
let lines = 0;
for (const n of nodes) {
const isInParagraph =
inParagraph || (n.type === "element" && (n as Element).tagName === "p");
// recurse through child nodes
if ("children" in n) {
lines += getApproxLineCount(n.children as Node[], isInParagraph);
}
// assume that any div/p/br causes a line break
if (
n.type === "element" &&
["div", "p", "br"].includes((n as Element).tagName)
)
lines++;
// assume that any image or embed could add ~10 lines
if (
n.type === "element" &&
["img", "svg", "iframe"].includes((n as Element).tagName)
)
lines += 10;
// approximate line wraps in <p> tag, assuming ~100 chars per line
if (
isInParagraph &&
n.type === "text" &&
typeof (n as Text).value === "string"
)
lines += Math.floor((n as Text).value.length / 100);
}
return lines;
};
export interface RehypeTabsProps {
injectSubheaderProps?: boolean;
tabSlugifyProps?: Parameters<typeof getHeaderNodeId>[1];
}
/**
* Plugin to add Docsify's tab support.
* @see https://jhildenbiddle.github.io/docsify-tabs/
*
* Given that syntax, output the following:
* ```
* <div class="tabs">
* <ul role="tablist">
* <li role="tab">Header Contents</li>
* </ul>
* <div role="tabpanel">Body contents</div>
* </div>
* ```
*
* To align with React Tabs package:
* @see https://github.com/reactjs/react-tabs
*/
export const rehypeTabs: Plugin<[RehypeTabsProps | never], Root> = ({
injectSubheaderProps = false,
tabSlugifyProps = {},
}) => {
return (tree) => {
const replaceTabNodes = (nodes: Node[]) => {
let sectionStarted = false;
const largestSize = findLargestHeading(nodes as Element[]);
const tabs: TabInfo[] = [];
for (const localNode of nodes as Element[]) {
if (!sectionStarted && !isNodeLargestHeading(localNode, largestSize)) {
continue;
}
sectionStarted = true;
// If this is a heading, start a new tab entry...
if (isNodeLargestHeading(localNode, largestSize)) {
// Make sure that all tabs labeled "thing" aren't also labeled "thing2"
slugs.reset();
const { id: headerSlug } = getHeaderNodeId(
localNode,
tabSlugifyProps
);
tabs.push({
slug: headerSlug,
name: toString(localNode),
contents: [],
headers: [],
});
continue;
}
// For any other heading found in the tab contents, append to the nested headers array
if (isNodeHeading(localNode) && injectSubheaderProps) {
const lastTab = tabs.at(-1);
// Store the related tab ID in the attributes of the header
localNode.properties["data-tabname"] = lastTab.slug;
// Add header ID to array
tabs.at(-1).headers.push(localNode.properties.id.toString());
}
// Otherwise, append the node as tab content
tabs.at(-1).contents.push(localNode);
}
// Determine if the set of tabs should use a constant height (via the "tabs-small" class)
const tabHeights = tabs.map(({ contents }) =>
getApproxLineCount(contents)
);
const isSmall =
// all tabs must be <= 30 approx. lines (less than the height of most desktop viewports)
Math.max(...tabHeights) <= 30 &&
// the max difference between tab heights must be under 15 lines
Math.max(...tabHeights) - Math.min(...tabHeights) <= 15;
return [
Tabs({
tabs,
isSmall,
}),
] as Node[];
};
replaceAllBetween(
tree,
{ type: "raw", value: "<!-- tabs:start -->" } as never,
{ type: "raw", value: "<!-- tabs:end -->" } as never,
replaceTabNodes
);
replaceAllBetween(
tree,
{ type: "comment", value: " tabs:start " } as never,
{ type: "comment", value: " tabs:end " } as never,
replaceTabNodes
);
return tree;
};
};

View File

@@ -1,4 +1,4 @@
import { enableTabs } from "./tabs";
import { enableTabs } from "./tabs-script";
const tabsHtml = `
<ul role="tablist" class="tabs__tab-list">

View File

@@ -142,7 +142,7 @@ export const enableTabs = () => {
if (!heading) return;
for (const tabEntry of tabEntries)
for (const [_, tab] of tabEntry) {
for (const [, tab] of tabEntry) {
// If the tab is hidden and the heading is contained within the tab
if (
tab.panel.hasAttribute("aria-hidden") &&

View File

@@ -1,217 +0,0 @@
import { Root } from "hast";
import replaceAllBetween from "unist-util-replace-all-between";
import { Parent, Node } from "unist";
import { Plugin } from "unified";
import { getHeaderNodeId, slugs } from "rehype-slug-custom-id";
interface ElementNode extends Parent {
tagName: string;
properties: any;
}
interface TextNode extends Node {
value: string;
}
const isNodeHeading = (n: ElementNode) =>
n.type === "element" && /h[1-6]/.exec(n.tagName);
const findLargestHeading = (nodes: ElementNode[]) => {
let largestSize = Infinity;
for (const node of nodes) {
if (!isNodeHeading(node)) continue;
const size = parseInt(node.tagName.substr(1), 10);
largestSize = Math.min(largestSize, size);
}
return largestSize;
};
const isNodeLargestHeading = (n: ElementNode, largestSize: number) =>
isNodeHeading(n) && parseInt(n.tagName.substr(1), 10) === largestSize;
const getApproxLineCount = (n: Node, inParagraph?: boolean): number => {
inParagraph ||= n.type === "element" && (n as ElementNode).tagName === "p";
let lines = 0;
// recurse through child nodes
if ("children" in n) {
for (const child of (n as Parent).children)
lines += getApproxLineCount(child, inParagraph);
}
// assume that any div/p/br causes a line break
if (
n.type === "element" &&
["div", "p", "br"].includes((n as ElementNode).tagName)
)
lines++;
// assume that any image or embed could add ~10 lines
if (
n.type === "element" &&
["img", "svg", "iframe"].includes((n as ElementNode).tagName)
)
lines += 10;
// approximate line wraps in <p> tag, assuming ~100 chars per line
if (
inParagraph &&
n.type === "text" &&
typeof (n as TextNode).value === "string"
)
lines += Math.floor((n as TextNode).value.length / 100);
return lines;
};
export interface RehypeTabsProps {
injectSubheaderProps?: boolean;
tabSlugifyProps?: Parameters<typeof getHeaderNodeId>[1];
}
/**
* Plugin to add Docsify's tab support.
* @see https://jhildenbiddle.github.io/docsify-tabs/
*
* Given that syntax, output the following:
* ```
* <div class="tabs">
* <ul role="tablist">
* <li role="tab">Header Contents</li>
* </ul>
* <div role="tabpanel">Body contents</div>
* </div>
* ```
*
* To align with React Tabs package:
* @see https://github.com/reactjs/react-tabs
*/
export const rehypeTabs: Plugin<[RehypeTabsProps | never], Root> = ({
injectSubheaderProps = false,
tabSlugifyProps = {},
}) => {
return (tree) => {
const replaceTabNodes = (nodes: Node[]) => {
let sectionStarted = false;
const largestSize = findLargestHeading(nodes as ElementNode[]);
const tabsContainer = {
type: "element",
tagName: "div",
properties: {
class: "tabs",
},
children: [
{
type: "element",
tagName: "ul",
properties: {
role: "tablist",
class: "tabs__tab-list",
},
children: [] as ElementNode[],
},
],
};
for (const localNode of nodes as ElementNode[]) {
if (!sectionStarted && !isNodeLargestHeading(localNode, largestSize)) {
continue;
}
sectionStarted = true;
if (isNodeLargestHeading(localNode, largestSize)) {
// Make sure that all tabs labeled "thing" aren't also labeled "thing2"
slugs.reset();
const { id: headerSlug } = getHeaderNodeId(
localNode,
tabSlugifyProps
);
// - 1 because the tabs are part of the header
const idx = tabsContainer.children.length - 1;
const header = {
type: "element",
tagName: "li",
children: localNode.children,
properties: {
role: "tab",
class: "tabs__tab",
"data-tabname": headerSlug,
"aria-selected": idx === 0 ? "true" : "false",
"aria-controls": `panel-${idx}`,
id: `tab-${idx}`,
tabIndex: idx === 0 ? "0" : "-1",
},
};
const contents = {
type: "element",
tagName: "div",
children: [],
properties: {
id: `panel-${idx}`,
role: "tabpanel",
class: "tabs__tab-panel",
tabindex: 0,
"aria-labelledby": `tab-${idx}`,
...(idx === 0 ? {} : { "aria-hidden": "true" }),
},
};
tabsContainer.children[0].children.push(header);
tabsContainer.children.push(contents);
continue;
}
if (isNodeHeading(localNode) && injectSubheaderProps) {
// This is `tagName: tab`
const lastTab =
tabsContainer.children[0].children[
tabsContainer.children[0].children.length - 1
];
// Store the related tab ID in the attributes of the header
localNode.properties["data-tabname"] =
// Get the last tab's `data-tabname` property
lastTab.properties["data-tabname"];
// Add header ID to array
lastTab.properties["data-headers"] = JSON.stringify(
JSON.parse(lastTab.properties["data-headers"] ?? "[]").concat(
localNode.properties.id
)
);
}
// Push into last `tab-panel`
tabsContainer.children[tabsContainer.children.length - 1].children.push(
localNode
);
}
// Determine if all tabs contain <=30 lines
// if so, "tabs-small" class makes the container use a constant height
const isSmallTab = tabsContainer.children.every(
(n) => getApproxLineCount(n) <= 30
);
if (isSmallTab) tabsContainer.properties.class += " tabs-small";
return [tabsContainer];
};
replaceAllBetween(
tree,
{ type: "raw", value: "<!-- tabs:start -->" } as never,
{ type: "raw", value: "<!-- tabs:end -->" } as never,
replaceTabNodes
);
replaceAllBetween(
tree,
{ type: "comment", value: " tabs:start " } as never,
{ type: "comment", value: " tabs:end " } as never,
replaceTabNodes
);
return tree;
};
};

View File

@@ -0,0 +1,53 @@
import classNames from "classnames";
import { Node } from "hast";
export interface TabInfo {
slug: string;
name: string;
contents: Node[];
// array of header slugs that are inside the header contents, for URL hash behavior
headers: string[];
}
interface TabsProps {
tabs: TabInfo[];
isSmall: boolean;
};
/** @jsxImportSource hastscript */
export function Tabs({ tabs, isSmall }: TabsProps) {
return (
<div class={classNames("tabs", isSmall && "tabs-small")}>
<ul role="tablist" class="tabs__tab-list">
{tabs.map(({ slug, name, headers }, index) => (
<li
id={`tab-${index}`}
role="tab"
class="tabs__tab"
data-tabname={slug}
data-headers={JSON.stringify(headers)}
aria-selected={index === 0}
aria-controls={`panel-${index}`}
tabIndex={index === 0 ? 0 : -1}
>
{name}
</li>
))}
</ul>
{tabs.map(({ contents }, index) => (
<div
id={`panel-${index}`}
role="tabpanel"
class="tabs__tab-panel"
tabIndex={0}
aria-labelledby={`tab-${index}`}
aria-hidden={index === 0 ? undefined : true}
>
{contents}
</div>
))}
</div>
)
}

View File

@@ -52,7 +52,7 @@ if (post.collection && post.order) {
mediumZoom(".post-body img:not([data-nozoom])");
</script>
<script>
import { enableTabs } from "../../utils/markdown/scripts/tabs";
import { enableTabs } from "../../utils/markdown/tabs/tabs-script";
enableTabs();
</script>
<script>