@supertype.ai/foundations 0.1.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +369 -0
- package/bin/foundations.mjs +713 -0
- package/dist/blocks/accordion.d.ts +23 -0
- package/dist/blocks/accordion.js +59 -0
- package/dist/blocks/callout.d.ts +57 -0
- package/dist/blocks/callout.js +61 -0
- package/dist/blocks/card.d.ts +34 -0
- package/dist/blocks/card.js +56 -0
- package/dist/blocks/index.d.ts +7 -0
- package/dist/blocks/index.js +7 -0
- package/dist/blocks/interactive-accordion.d.ts +13 -0
- package/dist/blocks/interactive-accordion.js +27 -0
- package/dist/blocks/segment.d.ts +37 -0
- package/dist/blocks/segment.js +37 -0
- package/dist/blocks/steps.d.ts +10 -0
- package/dist/blocks/steps.js +13 -0
- package/dist/blocks/tabs.d.ts +32 -0
- package/dist/blocks/tabs.js +69 -0
- package/dist/cjs/eslint.js +146 -0
- package/dist/cjs/package.json +3 -0
- package/dist/cn.d.ts +2 -0
- package/dist/cn.js +5 -0
- package/dist/contrast.d.ts +47 -0
- package/dist/contrast.js +255 -0
- package/dist/eslint.d.ts +74 -0
- package/dist/eslint.js +138 -0
- package/dist/essay/contents.d.ts +10 -0
- package/dist/essay/contents.js +17 -0
- package/dist/essay/essay.d.ts +125 -0
- package/dist/essay/essay.js +92 -0
- package/dist/essay/index.d.ts +7 -0
- package/dist/essay/index.js +9 -0
- package/dist/essay/layout.d.ts +72 -0
- package/dist/essay/layout.js +77 -0
- package/dist/essay/rail.d.ts +15 -0
- package/dist/essay/rail.js +26 -0
- package/dist/essay/reading.d.ts +17 -0
- package/dist/essay/reading.js +31 -0
- package/dist/essay/scroll.d.ts +8 -0
- package/dist/essay/scroll.js +78 -0
- package/dist/essay/toc.d.ts +23 -0
- package/dist/essay/toc.js +50 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +33 -0
- package/dist/injection.d.ts +8 -0
- package/dist/injection.js +1 -0
- package/dist/mdx.d.ts +47 -0
- package/dist/mdx.js +68 -0
- package/dist/og.d.ts +18 -0
- package/dist/og.js +50 -0
- package/dist/rehype.d.ts +18 -0
- package/dist/rehype.js +41 -0
- package/dist/seo.d.ts +174 -0
- package/dist/seo.js +152 -0
- package/dist/typography/as.d.ts +15 -0
- package/dist/typography/as.js +8 -0
- package/dist/typography/header.d.ts +44 -0
- package/dist/typography/header.js +119 -0
- package/dist/typography/highlight.d.ts +33 -0
- package/dist/typography/highlight.js +98 -0
- package/dist/typography/index.d.ts +4 -0
- package/dist/typography/index.js +3 -0
- package/dist/typography/paragraph.d.ts +157 -0
- package/dist/typography/paragraph.js +229 -0
- package/llms.txt +125 -0
- package/package.json +140 -0
- package/src/prose.css +12 -0
- package/src/shiki.css +23 -0
- package/src/theme.css +272 -0
- package/src/tokens.css +43 -0
- package/src/type.css +73 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { cn } from "../cn.js";
|
|
4
|
+
import { Rail, RailLink } from "./rail.js";
|
|
5
|
+
import { useReadingProgress, useScrollSpy } from "./scroll.js";
|
|
6
|
+
/**
|
|
7
|
+
* Hairline progress bar, rendered even where the rail is hidden. A CSS
|
|
8
|
+
* transition, not a spring: the value only feeds a transform, and this keeps the
|
|
9
|
+
* essay layer from dragging in an animation runtime.
|
|
10
|
+
*/
|
|
11
|
+
export function ReadingProgressBar({ className }) {
|
|
12
|
+
const progress = useReadingProgress();
|
|
13
|
+
return (_jsx("div", { "aria-hidden": true, className: cn("fixed inset-x-0 top-0 z-[60] h-0.5 origin-left bg-primary", "transition-transform duration-150 ease-out", className), style: { transform: `scaleX(${progress})` } }));
|
|
14
|
+
}
|
|
15
|
+
/** Circular percentage indicator at the head of the rail. */
|
|
16
|
+
function ProgressDonut({ progress }) {
|
|
17
|
+
const radius = 16;
|
|
18
|
+
const circumference = 2 * Math.PI * radius;
|
|
19
|
+
return (_jsxs("div", { className: "relative grid size-10 place-items-center", children: [_jsxs("svg", { viewBox: "0 0 40 40", className: "size-10 -rotate-90", "aria-hidden": true, children: [_jsx("circle", { cx: "20", cy: "20", r: radius, fill: "none", strokeWidth: "3", className: "stroke-border" }), _jsx("circle", { cx: "20", cy: "20", r: radius, fill: "none", strokeWidth: "3", strokeLinecap: "round", className: "stroke-primary transition-[stroke-dashoffset] duration-150 ease-out", strokeDasharray: circumference, strokeDashoffset: circumference * (1 - progress) })] }), _jsx("span", { className: "absolute font-mono text-3xs font-medium tabular-nums text-muted-foreground", children: Math.round(progress * 100) })] }));
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Sticky rail with live scroll-spy. Both it and `ReadingProgressBar` read shared
|
|
23
|
+
* stores, so mounting them together costs one scroll subscription, not two.
|
|
24
|
+
*/
|
|
25
|
+
export function ReadingRail({ headings, className, }) {
|
|
26
|
+
const progress = useReadingProgress();
|
|
27
|
+
const active = useScrollSpy(headings.map((h) => h.id));
|
|
28
|
+
if (headings.length === 0)
|
|
29
|
+
return null;
|
|
30
|
+
return (_jsxs("nav", { "aria-label": "On this page", className: cn("flex flex-col gap-4", className), children: [_jsxs("div", { className: "flex items-center gap-3", children: [_jsx(ProgressDonut, { progress: progress }), _jsx("p", { className: "text-xs font-medium uppercase tracking-widest text-muted-foreground", children: "On this page" })] }), _jsx(Rail, { children: headings.map(({ id, label, depth }) => (_jsx(RailLink, { href: `#${id}`, active: active === id, nested: depth === 3, children: label }, id))) })] }));
|
|
31
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare function useReadingProgress(): number;
|
|
2
|
+
/**
|
|
3
|
+
* One IntersectionObserver across all headings, not one each — the observer
|
|
4
|
+
* already reports what changed. Topmost wins when several are on screen.
|
|
5
|
+
*/
|
|
6
|
+
export declare function useScrollSpy(ids: string[], { rootMargin }?: {
|
|
7
|
+
rootMargin?: string;
|
|
8
|
+
}): string;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useEffect, useState, useSyncExternalStore } from "react";
|
|
3
|
+
/**
|
|
4
|
+
* Reading progress 0→1 from one shared listener: a page mounts both the bar and
|
|
5
|
+
* the rail, and hook-local state would double every subscription. Reads coalesce
|
|
6
|
+
* to a frame, since `scrollHeight` forces layout.
|
|
7
|
+
*/
|
|
8
|
+
let progress = 0;
|
|
9
|
+
let frame = 0;
|
|
10
|
+
const listeners = new Set();
|
|
11
|
+
function measure() {
|
|
12
|
+
frame = 0;
|
|
13
|
+
const scrollable = document.documentElement.scrollHeight - window.innerHeight;
|
|
14
|
+
const next = scrollable > 0 ? window.scrollY / scrollable : 0;
|
|
15
|
+
const clamped = Math.min(1, Math.max(0, next));
|
|
16
|
+
if (clamped === progress)
|
|
17
|
+
return;
|
|
18
|
+
progress = clamped;
|
|
19
|
+
listeners.forEach((l) => l());
|
|
20
|
+
}
|
|
21
|
+
function schedule() {
|
|
22
|
+
if (frame)
|
|
23
|
+
return;
|
|
24
|
+
frame = requestAnimationFrame(measure);
|
|
25
|
+
}
|
|
26
|
+
function subscribe(listener) {
|
|
27
|
+
if (listeners.size === 0) {
|
|
28
|
+
window.addEventListener("scroll", schedule, { passive: true });
|
|
29
|
+
window.addEventListener("resize", schedule);
|
|
30
|
+
measure();
|
|
31
|
+
}
|
|
32
|
+
listeners.add(listener);
|
|
33
|
+
return () => {
|
|
34
|
+
listeners.delete(listener);
|
|
35
|
+
if (listeners.size === 0) {
|
|
36
|
+
window.removeEventListener("scroll", schedule);
|
|
37
|
+
window.removeEventListener("resize", schedule);
|
|
38
|
+
if (frame)
|
|
39
|
+
cancelAnimationFrame(frame);
|
|
40
|
+
frame = 0;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
export function useReadingProgress() {
|
|
45
|
+
// The server snapshot is 0, an unscrolled document. That is also the client's
|
|
46
|
+
// value on first paint, so the two agree.
|
|
47
|
+
return useSyncExternalStore(subscribe, () => progress, () => 0);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* One IntersectionObserver across all headings, not one each — the observer
|
|
51
|
+
* already reports what changed. Topmost wins when several are on screen.
|
|
52
|
+
*/
|
|
53
|
+
export function useScrollSpy(ids, { rootMargin = "-15% 0px -75% 0px" } = {}) {
|
|
54
|
+
const [active, setActive] = useState("");
|
|
55
|
+
// Depend on the joined ids rather than the array: a caller that maps headings
|
|
56
|
+
// inline passes a new array identity every render, which would tear the
|
|
57
|
+
// observer down and rebuild it on each one.
|
|
58
|
+
const key = ids.join("|");
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
const elements = key
|
|
61
|
+
.split("|")
|
|
62
|
+
.filter(Boolean)
|
|
63
|
+
.map((id) => document.getElementById(id))
|
|
64
|
+
.filter((el) => el !== null);
|
|
65
|
+
if (elements.length === 0)
|
|
66
|
+
return;
|
|
67
|
+
const observer = new IntersectionObserver((entries) => {
|
|
68
|
+
const visible = entries
|
|
69
|
+
.filter((e) => e.isIntersecting)
|
|
70
|
+
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
|
|
71
|
+
if (visible[0])
|
|
72
|
+
setActive(visible[0].target.id);
|
|
73
|
+
}, { rootMargin });
|
|
74
|
+
elements.forEach((el) => observer.observe(el));
|
|
75
|
+
return () => observer.disconnect();
|
|
76
|
+
}, [key, rootMargin]);
|
|
77
|
+
return active;
|
|
78
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** A heading in the table of contents. */
|
|
2
|
+
export interface TocHeading {
|
|
3
|
+
/** 2 = h2 (top level), 3 = h3 (nested). */
|
|
4
|
+
depth: 2 | 3;
|
|
5
|
+
/** Matches the id rehype-slug stamps on the heading, so anchors line up. */
|
|
6
|
+
id: string;
|
|
7
|
+
label: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* GitHub's slug algorithm, reimplemented. Must agree exactly with rehype-slug or
|
|
11
|
+
* the rail's anchors point at headings that do not exist.
|
|
12
|
+
*/
|
|
13
|
+
export declare function createSlugger(): (value: string) => string;
|
|
14
|
+
/**
|
|
15
|
+
* h2/h3 from raw markdown. Fenced code is skipped — a `# comment` in a shell
|
|
16
|
+
* block is not a heading. Works on source, so it needs no DOM or compiled MDX.
|
|
17
|
+
*/
|
|
18
|
+
export declare function extractHeadings(markdown: string): TocHeading[];
|
|
19
|
+
/**
|
|
20
|
+
* Computed, never stored: a hand-declared `readingMinutes` is correct once.
|
|
21
|
+
* 200 wpm, code blocks excluded — counting them inflates every technical post.
|
|
22
|
+
*/
|
|
23
|
+
export declare function readingTime(source: string, wordsPerMinute?: number): number;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub's slug algorithm, reimplemented. Must agree exactly with rehype-slug or
|
|
3
|
+
* the rail's anchors point at headings that do not exist.
|
|
4
|
+
*/
|
|
5
|
+
export function createSlugger() {
|
|
6
|
+
const seen = new Map();
|
|
7
|
+
return (value) => {
|
|
8
|
+
const base = value
|
|
9
|
+
.toLowerCase()
|
|
10
|
+
.trim()
|
|
11
|
+
.replace(/[^\w\s-]/g, "")
|
|
12
|
+
.replace(/\s+/g, "-");
|
|
13
|
+
const count = seen.get(base) ?? 0;
|
|
14
|
+
seen.set(base, count + 1);
|
|
15
|
+
return count === 0 ? base : `${base}-${count}`;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* h2/h3 from raw markdown. Fenced code is skipped — a `# comment` in a shell
|
|
20
|
+
* block is not a heading. Works on source, so it needs no DOM or compiled MDX.
|
|
21
|
+
*/
|
|
22
|
+
export function extractHeadings(markdown) {
|
|
23
|
+
const slug = createSlugger();
|
|
24
|
+
const headings = [];
|
|
25
|
+
let inFence = false;
|
|
26
|
+
for (const line of markdown.split("\n")) {
|
|
27
|
+
if (/^\s*(```|~~~)/.test(line)) {
|
|
28
|
+
inFence = !inFence;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (inFence)
|
|
32
|
+
continue;
|
|
33
|
+
const match = /^(#{2,3})\s+(.+?)\s*$/.exec(line);
|
|
34
|
+
if (!match)
|
|
35
|
+
continue;
|
|
36
|
+
const depth = match[1].length;
|
|
37
|
+
const label = match[2].replace(/[*_`]/g, "").trim();
|
|
38
|
+
headings.push({ depth, id: slug(label), label });
|
|
39
|
+
}
|
|
40
|
+
return headings;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Computed, never stored: a hand-declared `readingMinutes` is correct once.
|
|
44
|
+
* 200 wpm, code blocks excluded — counting them inflates every technical post.
|
|
45
|
+
*/
|
|
46
|
+
export function readingTime(source, wordsPerMinute = 200) {
|
|
47
|
+
const prose = source.replace(/```[\s\S]*?```/g, " ");
|
|
48
|
+
const words = prose.trim() ? prose.trim().split(/\s+/).length : 0;
|
|
49
|
+
return Math.max(1, Math.round(words / wordsPerMinute));
|
|
50
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export { cn } from "./cn.js";
|
|
2
|
+
export * from "./typography/index.js";
|
|
3
|
+
// NOTE: blocks, the MDX map, and the Shiki plugin are all deliberately absent
|
|
4
|
+
// from this barrel.
|
|
5
|
+
//
|
|
6
|
+
// A barrel's transitive dependencies are paid by every name it exports. mdx.tsx
|
|
7
|
+
// imports `next/image`, and a bare subpath like that fails to resolve from
|
|
8
|
+
// inside node_modules under a plain Node ESM loader, which is the loader a
|
|
9
|
+
// consumer's test runner uses. Pulling it in here made every test that touches a
|
|
10
|
+
// Typography component fail to import.
|
|
11
|
+
//
|
|
12
|
+
// blocks/ carries the same hazard one dependency over: interactive-accordion.tsx
|
|
13
|
+
// and tabs.tsx pull `@base-ui/react`, so re-exporting them made a bare
|
|
14
|
+
// `import { TypographyH2 }` resolve Base UI. They live at
|
|
15
|
+
// "@supertype.ai/foundations/blocks" for that reason, not for tree-shaking — the
|
|
16
|
+
// bundlers already handle that.
|
|
17
|
+
//
|
|
18
|
+
// What the split does NOT buy is a plain-Node-importable root. Measured, not
|
|
19
|
+
// assumed: `node -e "import('@supertype.ai/foundations')"` from a consumer fails on
|
|
20
|
+
// ERR_MODULE_NOT_FOUND for next/link, because TypographyLink imports
|
|
21
|
+
// `next-view-transitions`, whose dist/index.js:3 imports `next/link` — the same
|
|
22
|
+
// unresolvable bare subpath as next/image above, one package further out. The
|
|
23
|
+
// blocks entry fails identically through card.tsx.
|
|
24
|
+
//
|
|
25
|
+
// This is survivable because the runner that matters resolves it: both consumers'
|
|
26
|
+
// vitest suites import typography freely and pass. Do not "fix" it by reinstating
|
|
27
|
+
// injection — that was tried, and paragraph.tsx documents the call site it
|
|
28
|
+
// silently left unbound. Isolating it would mean a /next subpath for
|
|
29
|
+
// TypographyLink and Card, against ~230 and ~100 call sites.
|
|
30
|
+
//
|
|
31
|
+
// The Shiki plugin has build-time consumers (source.config.ts, next.config) that
|
|
32
|
+
// run in bare Node where React is not resolvable. Import it from
|
|
33
|
+
// "@supertype.ai/foundations/rehype".
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ComponentType } from "react";
|
|
2
|
+
/**
|
|
3
|
+
* A component the app injects — its Link, its Image. Untyped in its props on
|
|
4
|
+
* purpose: no hand-written signature accepts `next/link`, `next/image` and
|
|
5
|
+
* react-router at once, and tightening it only moves the cast to every consumer.
|
|
6
|
+
* What must hold is checked where these are rendered, against a fixed prop set.
|
|
7
|
+
*/
|
|
8
|
+
export type InjectedComponent = ComponentType<any>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/mdx.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { ComponentProps } from "react";
|
|
2
|
+
import { Disclosure, DisclosureGroup } from "./blocks/accordion.js";
|
|
3
|
+
import { Callout } from "./blocks/callout.js";
|
|
4
|
+
import { Card, Cards } from "./blocks/card.js";
|
|
5
|
+
import { Step, Steps } from "./blocks/steps.js";
|
|
6
|
+
import { TabGroup, Tab } from "./blocks/tabs.js";
|
|
7
|
+
/**
|
|
8
|
+
* Rendered from markdown syntax, so there is no call site and no knob — a knob
|
|
9
|
+
* here is one every project turns differently. Headings carry variants and this
|
|
10
|
+
* map binds them; retune in CSS by moving the `--text-*` rung, or with
|
|
11
|
+
* `.editorial` over the subtree.
|
|
12
|
+
*
|
|
13
|
+
* A constant rather than a factory: the router and the image component are the
|
|
14
|
+
* package's now, so there is nothing left for a consumer to inject.
|
|
15
|
+
*/
|
|
16
|
+
export declare const proseMdxComponents: {
|
|
17
|
+
Card: typeof Card;
|
|
18
|
+
Cards: typeof Cards;
|
|
19
|
+
Accordions: typeof DisclosureGroup;
|
|
20
|
+
Accordion: typeof Disclosure;
|
|
21
|
+
Banner: (props: ComponentProps<typeof Callout>) => import("react").JSX.Element;
|
|
22
|
+
Tabs: typeof TabGroup;
|
|
23
|
+
Tab: typeof Tab;
|
|
24
|
+
Steps: typeof Steps;
|
|
25
|
+
Step: typeof Step;
|
|
26
|
+
h1: (props: ComponentProps<"h1">) => import("react").JSX.Element;
|
|
27
|
+
h2: (props: ComponentProps<"h2">) => import("react").JSX.Element;
|
|
28
|
+
h3: (props: ComponentProps<"h3">) => import("react").JSX.Element;
|
|
29
|
+
h4: (props: ComponentProps<"h4">) => import("react").JSX.Element;
|
|
30
|
+
p: (props: ComponentProps<"p">) => import("react").JSX.Element;
|
|
31
|
+
ul: (props: ComponentProps<"ul">) => import("react").JSX.Element;
|
|
32
|
+
ol: (props: ComponentProps<"ol">) => import("react").JSX.Element;
|
|
33
|
+
li: (props: ComponentProps<"li">) => import("react").JSX.Element;
|
|
34
|
+
blockquote: ({ className, ...props }: ComponentProps<"blockquote">) => import("react").JSX.Element;
|
|
35
|
+
a: ({ href, children }: ComponentProps<"a">) => import("react").JSX.Element;
|
|
36
|
+
/**
|
|
37
|
+
* The frame only — never set `color`, or it beats shiki's token spans.
|
|
38
|
+
* `tabIndex` keeps a horizontally scrolling block reachable by keyboard.
|
|
39
|
+
*/
|
|
40
|
+
pre: ({ className, ...props }: ComponentProps<"pre">) => import("react").JSX.Element;
|
|
41
|
+
img: ({ alt, src, width, height, ...props }: ComponentProps<"img">) => import("react").JSX.Element;
|
|
42
|
+
hr: () => import("react").JSX.Element;
|
|
43
|
+
strong: (props: ComponentProps<"strong">) => import("react").JSX.Element;
|
|
44
|
+
table: (props: ComponentProps<"table">) => import("react").JSX.Element;
|
|
45
|
+
th: (props: ComponentProps<"th">) => import("react").JSX.Element;
|
|
46
|
+
td: (props: ComponentProps<"td">) => import("react").JSX.Element;
|
|
47
|
+
};
|
package/dist/mdx.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { TypographyH1, TypographyH2, TypographyH3, TypographyH4, } from "./typography/header.js";
|
|
3
|
+
import { cn } from "./cn.js";
|
|
4
|
+
import Image from "next/image";
|
|
5
|
+
import { Disclosure, DisclosureGroup } from "./blocks/accordion.js";
|
|
6
|
+
import { Callout } from "./blocks/callout.js";
|
|
7
|
+
import { Card, Cards } from "./blocks/card.js";
|
|
8
|
+
import { Step, Steps } from "./blocks/steps.js";
|
|
9
|
+
import { TabGroup, Tab } from "./blocks/tabs.js";
|
|
10
|
+
import { TypographyProse, TypographyProseList, TypographyLink, } from "./typography/paragraph.js";
|
|
11
|
+
/**
|
|
12
|
+
* Rendered from markdown syntax, so there is no call site and no knob — a knob
|
|
13
|
+
* here is one every project turns differently. Headings carry variants and this
|
|
14
|
+
* map binds them; retune in CSS by moving the `--text-*` rung, or with
|
|
15
|
+
* `.editorial` over the subtree.
|
|
16
|
+
*
|
|
17
|
+
* A constant rather than a factory: the router and the image component are the
|
|
18
|
+
* package's now, so there is nothing left for a consumer to inject.
|
|
19
|
+
*/
|
|
20
|
+
export const proseMdxComponents = {
|
|
21
|
+
// Authorable blocks. Unlike the elements below these ARE called by hand in
|
|
22
|
+
// MDX, so they take props — but never a `className`: an author writing
|
|
23
|
+
// <Card> is choosing a component, not restyling one.
|
|
24
|
+
Card,
|
|
25
|
+
Cards,
|
|
26
|
+
Accordions: DisclosureGroup,
|
|
27
|
+
Accordion: Disclosure,
|
|
28
|
+
Banner: (props) => (_jsx(Callout, { density: "editorial", ...props })),
|
|
29
|
+
Tabs: TabGroup,
|
|
30
|
+
Tab,
|
|
31
|
+
Steps,
|
|
32
|
+
Step,
|
|
33
|
+
h1: (props) => (_jsx(TypographyH1, { variant: "display", ...props })),
|
|
34
|
+
h2: (props) => _jsx(TypographyH2, { ...props }),
|
|
35
|
+
h3: (props) => _jsx(TypographyH3, { ...props }),
|
|
36
|
+
h4: (props) => _jsx(TypographyH4, { ...props }),
|
|
37
|
+
p: (props) => _jsx(TypographyProse, { ...props }),
|
|
38
|
+
ul: (props) => _jsx(TypographyProseList, { ...props }),
|
|
39
|
+
ol: (props) => (_jsx(TypographyProseList, { ordered: true, ...props })),
|
|
40
|
+
li: (props) => (_jsx("li", { className: "[&>ul]:mt-2 [&>ol]:mt-2", ...props })),
|
|
41
|
+
// No quotemark glyph: a markdown `>` block is already marked as a quote by
|
|
42
|
+
// its rule and its indent, and a mark on top of that reads as decoration.
|
|
43
|
+
blockquote: ({ className, ...props }) => (_jsx("blockquote", { className: cn("my-6 border-l-[3px] border-border pl-5 text-lg italic leading-relaxed text-foreground", className), ...props })),
|
|
44
|
+
a: ({ href = "", children }) => (_jsx(TypographyLink, { href: href, children: children })),
|
|
45
|
+
/**
|
|
46
|
+
* The frame only — never set `color`, or it beats shiki's token spans.
|
|
47
|
+
* `tabIndex` keeps a horizontally scrolling block reachable by keyboard.
|
|
48
|
+
*/
|
|
49
|
+
pre: ({ className, ...props }) => (_jsx("pre", { tabIndex: 0, className: cn("my-6 overflow-x-auto rounded-xl border border-border p-4 text-sm leading-relaxed", "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", className), ...props })),
|
|
50
|
+
img: ({ alt = "", src, width, height, ...props }) => {
|
|
51
|
+
const className = "my-6 h-auto max-w-full rounded-xl border border-border";
|
|
52
|
+
// `next/image` needs a real src and intrinsic dimensions, which a markdown
|
|
53
|
+
// `` lacks unless a remark plugin measured them. The plain `<img>` is
|
|
54
|
+
// the fallback for when it did not.
|
|
55
|
+
const w = Number(width);
|
|
56
|
+
const h = Number(height);
|
|
57
|
+
if (typeof src === "string" && w > 0 && h > 0) {
|
|
58
|
+
return (_jsx(Image, { src: src, width: w, height: h, alt: alt, className: className, ...props }));
|
|
59
|
+
}
|
|
60
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
61
|
+
return (_jsx("img", { src: typeof src === "string" ? src : undefined, width: width, height: height, alt: alt, className: className, ...props }));
|
|
62
|
+
},
|
|
63
|
+
hr: () => _jsx("hr", { className: "my-12 border-border" }),
|
|
64
|
+
strong: (props) => (_jsx("strong", { className: "font-semibold text-foreground", ...props })),
|
|
65
|
+
table: (props) => (_jsx("div", { className: "mt-6 overflow-x-auto rounded-xl border border-border", children: _jsx("table", { className: "w-full border-collapse text-sm", ...props }) })),
|
|
66
|
+
th: (props) => (_jsx("th", { className: "border-b border-border bg-muted/40 px-4 py-2.5 text-left font-semibold text-foreground", ...props })),
|
|
67
|
+
td: (props) => (_jsx("td", { className: "border-b border-border/60 px-4 py-2.5 align-top text-muted-foreground", ...props })),
|
|
68
|
+
};
|
package/dist/og.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
/** Open Graph card dimensions. The 1.91:1 ratio every major network crops to. */
|
|
3
|
+
export declare const OG_SIZE: {
|
|
4
|
+
readonly width: 1200;
|
|
5
|
+
readonly height: 630;
|
|
6
|
+
};
|
|
7
|
+
export interface OgCardOptions {
|
|
8
|
+
title: string;
|
|
9
|
+
description?: string;
|
|
10
|
+
/** The site or brand name, set small at the foot of the card. */
|
|
11
|
+
site?: string;
|
|
12
|
+
/** Accent colour for the rule and the site name. Any CSS colour. */
|
|
13
|
+
accent?: string;
|
|
14
|
+
background?: string;
|
|
15
|
+
foreground?: string;
|
|
16
|
+
muted?: string;
|
|
17
|
+
}
|
|
18
|
+
export declare function ogCard({ title, description, site, accent, background, foreground, muted, }: OgCardOptions): ReactElement;
|
package/dist/og.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/** Open Graph card dimensions. The 1.91:1 ratio every major network crops to. */
|
|
3
|
+
export const OG_SIZE = { width: 1200, height: 630 };
|
|
4
|
+
/** Measured, not guessed: ~45 chars/line at 62px over three lines, two at 28px. */
|
|
5
|
+
const TITLE_MAX = 135;
|
|
6
|
+
const DESCRIPTION_MAX = 160;
|
|
7
|
+
/**
|
|
8
|
+
* A tree, not an image, so the package never imports `next/og`. Rendered by
|
|
9
|
+
* satori: no stylesheets, no classes, no default block layout — hence inline
|
|
10
|
+
* styles and an explicit `display: flex` on everything. Text is truncated in JS
|
|
11
|
+
* because `-webkit-line-clamp` needs a display mode that contradicts the flex.
|
|
12
|
+
*/
|
|
13
|
+
/** Truncates on a word boundary, so a cut title does not end mid-word. */
|
|
14
|
+
function truncate(text, max) {
|
|
15
|
+
if (text.length <= max)
|
|
16
|
+
return text;
|
|
17
|
+
const cut = text.slice(0, max);
|
|
18
|
+
const lastSpace = cut.lastIndexOf(" ");
|
|
19
|
+
return `${(lastSpace > max * 0.6 ? cut.slice(0, lastSpace) : cut).trimEnd()}…`;
|
|
20
|
+
}
|
|
21
|
+
export function ogCard({ title, description, site, accent = "#b1976b", background = "#0c0a09", foreground = "#fafaf9", muted = "#a8a29e", }) {
|
|
22
|
+
return (_jsxs("div", { style: {
|
|
23
|
+
width: "100%",
|
|
24
|
+
height: "100%",
|
|
25
|
+
display: "flex",
|
|
26
|
+
flexDirection: "column",
|
|
27
|
+
justifyContent: "space-between",
|
|
28
|
+
background,
|
|
29
|
+
padding: "72px",
|
|
30
|
+
fontFamily: "sans-serif",
|
|
31
|
+
}, children: [_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: "28px" }, children: [_jsx("div", { style: { display: "flex", width: "88px", height: "6px", background: accent } }), _jsx("div", { style: {
|
|
32
|
+
display: "flex",
|
|
33
|
+
fontSize: 62,
|
|
34
|
+
fontWeight: 700,
|
|
35
|
+
lineHeight: 1.15,
|
|
36
|
+
letterSpacing: "-0.02em",
|
|
37
|
+
color: foreground,
|
|
38
|
+
}, children: truncate(title, TITLE_MAX) }), description ? (_jsx("div", { style: {
|
|
39
|
+
display: "flex",
|
|
40
|
+
fontSize: 28,
|
|
41
|
+
lineHeight: 1.4,
|
|
42
|
+
color: muted,
|
|
43
|
+
}, children: truncate(description, DESCRIPTION_MAX) })) : null] }), site ? (_jsx("div", { style: {
|
|
44
|
+
display: "flex",
|
|
45
|
+
fontSize: 26,
|
|
46
|
+
fontWeight: 600,
|
|
47
|
+
letterSpacing: "0.04em",
|
|
48
|
+
color: accent,
|
|
49
|
+
}, children: site })) : null] }));
|
|
50
|
+
}
|
package/dist/rehype.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import rehypeShiki, { type RehypeShikiOptions } from "@shikijs/rehype";
|
|
2
|
+
/**
|
|
3
|
+
* Explicit rather than Shiki's full set — every grammar is build-time parsing.
|
|
4
|
+
* Anything unlisted falls back to `text`: content uses labels that are not real
|
|
5
|
+
* grammars (`tree`, `spark-defaults`), and dying over a fence label is a bad trade.
|
|
6
|
+
*/
|
|
7
|
+
export declare const PROSE_LANGS: readonly ["bash", "csv", "diff", "docker", "json", "python", "sql", "toml", "tsx", "typescript", "javascript", "yaml"];
|
|
8
|
+
export declare const PROSE_THEMES: {
|
|
9
|
+
readonly light: "github-light";
|
|
10
|
+
readonly dark: "github-dark";
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* `defaultColor: false` emits `--shiki-light` / `--shiki-dark` per token instead
|
|
14
|
+
* of a baked colour, so one compiled document serves both themes; `shiki.css`
|
|
15
|
+
* maps them. A `[plugin, options]` tuple, so it drops into `rehypePlugins`.
|
|
16
|
+
*/
|
|
17
|
+
export declare const proseCodeOptions: RehypeShikiOptions;
|
|
18
|
+
export declare const rehypeProseCode: [typeof rehypeShiki, RehypeShikiOptions];
|
package/dist/rehype.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import rehypeShiki, {} from "@shikijs/rehype";
|
|
2
|
+
/**
|
|
3
|
+
* Explicit rather than Shiki's full set — every grammar is build-time parsing.
|
|
4
|
+
* Anything unlisted falls back to `text`: content uses labels that are not real
|
|
5
|
+
* grammars (`tree`, `spark-defaults`), and dying over a fence label is a bad trade.
|
|
6
|
+
*/
|
|
7
|
+
export const PROSE_LANGS = [
|
|
8
|
+
"bash",
|
|
9
|
+
"csv",
|
|
10
|
+
"diff",
|
|
11
|
+
"docker",
|
|
12
|
+
"json",
|
|
13
|
+
"python",
|
|
14
|
+
"sql",
|
|
15
|
+
"toml",
|
|
16
|
+
"tsx",
|
|
17
|
+
"typescript",
|
|
18
|
+
"javascript",
|
|
19
|
+
"yaml",
|
|
20
|
+
];
|
|
21
|
+
export const PROSE_THEMES = {
|
|
22
|
+
light: "github-light",
|
|
23
|
+
dark: "github-dark",
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* `defaultColor: false` emits `--shiki-light` / `--shiki-dark` per token instead
|
|
27
|
+
* of a baked colour, so one compiled document serves both themes; `shiki.css`
|
|
28
|
+
* maps them. A `[plugin, options]` tuple, so it drops into `rehypePlugins`.
|
|
29
|
+
*/
|
|
30
|
+
export const proseCodeOptions = {
|
|
31
|
+
themes: PROSE_THEMES,
|
|
32
|
+
defaultColor: false,
|
|
33
|
+
langs: [...PROSE_LANGS],
|
|
34
|
+
fallbackLanguage: "text",
|
|
35
|
+
};
|
|
36
|
+
// Typed as a mutable tuple, not `as const`: unified's `Pluggable` does not
|
|
37
|
+
// accept a readonly tuple, and a readonly one fails to assign in consumers.
|
|
38
|
+
export const rehypeProseCode = [
|
|
39
|
+
rehypeShiki,
|
|
40
|
+
proseCodeOptions,
|
|
41
|
+
];
|