mirror of
https://github.com/LukeHagar/unicorn-utterances.git
synced 2025-12-07 21:07:47 +00:00
migrate tabs markdown component to jsx
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -1 +1 @@
|
||||
export * from "./tabs";
|
||||
export * from "./rehype-transform";
|
||||
|
||||
164
src/utils/markdown/tabs/rehype-transform.ts
Normal file
164
src/utils/markdown/tabs/rehype-transform.ts
Normal 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;
|
||||
};
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { enableTabs } from "./tabs";
|
||||
import { enableTabs } from "./tabs-script";
|
||||
|
||||
const tabsHtml = `
|
||||
<ul role="tablist" class="tabs__tab-list">
|
||||
@@ -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") &&
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
53
src/utils/markdown/tabs/tabs.tsx
Normal file
53
src/utils/markdown/tabs/tabs.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user