@terrafirmagreg-team/mdx 0.0.0-stage → 1.2.1

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.
@@ -0,0 +1,181 @@
1
+ 'use client';
2
+
3
+ import {
4
+ StoryProvider,
5
+ useStory,
6
+ useStoryArgs,
7
+ type WithControlProps,
8
+ } from '@fumadocs/story';
9
+ import { StoryFields } from './story-fields';
10
+ import { remarkFor, tweakControls } from '../lib/story-remark';
11
+ import { highlightRemark } from '../lib/remark-highlight';
12
+ import { useCopyButton } from '@terrafirmagreg-team/ui/hooks/use-copy-button';
13
+ import { Check, Clipboard } from 'lucide-react';
14
+ import {
15
+ Select,
16
+ SelectContent,
17
+ SelectItem,
18
+ SelectTrigger,
19
+ SelectValue,
20
+ } from '@terrafirmagreg-team/ui/components/ui/select';
21
+ import {
22
+ Component,
23
+ createElement,
24
+ useMemo,
25
+ type ErrorInfo,
26
+ type ReactNode,
27
+ } from 'react';
28
+ import { CircleAlert, RotateCcw } from 'lucide-react';
29
+
30
+ function RemarkHint() {
31
+ const { displayName, preset, variant } = useStory();
32
+ const args = useStoryArgs();
33
+ const text = useMemo(
34
+ () => remarkFor(displayName ?? 'component', args, preset?.controls, variant),
35
+ [displayName, args, preset, variant],
36
+ );
37
+ const tokens = useMemo(() => highlightRemark(text), [text]);
38
+ const { checked, onClick } = useCopyButton(() =>
39
+ navigator.clipboard.writeText(text),
40
+ );
41
+ return (
42
+ <div className="relative border-t">
43
+ <pre className="overflow-x-auto px-4 py-2.5 pe-9 font-mono text-xs leading-relaxed text-fd-muted-foreground whitespace-pre-wrap">
44
+ {tokens.map((t, i) => (
45
+ <span key={i} className={t.cls}>
46
+ {t.text}
47
+ </span>
48
+ ))}
49
+ </pre>
50
+ <button
51
+ type="button"
52
+ aria-label="Copy markdown"
53
+ title="Copy markdown"
54
+ onClick={onClick}
55
+ className="absolute top-1.5 right-1.5 rounded-md p-1.5 text-fd-muted-foreground hover:bg-fd-accent hover:text-fd-foreground"
56
+ >
57
+ {checked ? (
58
+ <Check className="size-3.5" />
59
+ ) : (
60
+ <Clipboard className="size-3.5" />
61
+ )}
62
+ </button>
63
+ </div>
64
+ );
65
+ }
66
+
67
+ class PreviewBoundary extends Component<
68
+ { children: ReactNode; resetKey: unknown },
69
+ { error: Error | null }
70
+ > {
71
+ state = { error: null as Error | null };
72
+ static getDerivedStateFromError(error: Error) {
73
+ return { error };
74
+ }
75
+ componentDidCatch(error: Error, info: ErrorInfo) {
76
+ console.error(error, info);
77
+ }
78
+ componentDidUpdate(prev: { resetKey: unknown }) {
79
+ if (prev.resetKey !== this.props.resetKey && this.state.error)
80
+ this.setState({ error: null });
81
+ }
82
+ render() {
83
+ if (this.state.error)
84
+ return (
85
+ <div role="alert" className="flex items-center gap-2 text-sm">
86
+ <CircleAlert className="size-3.5 shrink-0 text-fd-error" />
87
+ <span className="font-mono text-xs break-all text-fd-muted-foreground">
88
+ {this.state.error.message}
89
+ </span>
90
+ </div>
91
+ );
92
+ return this.props.children;
93
+ }
94
+ }
95
+
96
+ function VariantSelect() {
97
+ const { presets, variant, setVariant } = useStory();
98
+ return (
99
+ <Select value={variant} onValueChange={(v) => v && setVariant(v)}>
100
+ <SelectTrigger className="h-7 w-auto max-w-64 gap-1.5 border-0 bg-transparent px-2 text-xs hover:bg-fd-accent focus:ring-0 focus-visible:ring-2">
101
+ <span className="text-fd-muted-foreground">Variant</span>
102
+ <SelectValue className="truncate font-medium" />
103
+ </SelectTrigger>
104
+ <SelectContent align="end">
105
+ {presets.map((item) => (
106
+ <SelectItem
107
+ key={item.variant}
108
+ value={item.variant}
109
+ className="text-xs"
110
+ >
111
+ {item.variant}
112
+ </SelectItem>
113
+ ))}
114
+ </SelectContent>
115
+ </Select>
116
+ );
117
+ }
118
+
119
+ function Content() {
120
+ const { presets, displayName, preset, variant, setVariant, Component } =
121
+ useStory();
122
+ const args = useStoryArgs();
123
+ const reset = () => setVariant(variant);
124
+ return (
125
+ <div className="not-prose overflow-hidden rounded-xl border bg-fd-card text-fd-card-foreground">
126
+ {(displayName || presets.length > 1) && (
127
+ <div className="flex h-10 items-center justify-end gap-1 border-b ps-4 pe-1.5">
128
+ {displayName && (
129
+ <p className="me-auto truncate font-mono text-[0.8125rem] font-medium">
130
+ {displayName}
131
+ </p>
132
+ )}
133
+ <button
134
+ type="button"
135
+ aria-label="Reset"
136
+ title="Reset"
137
+ onClick={reset}
138
+ className="rounded-md p-1.5 text-fd-muted-foreground hover:bg-fd-accent"
139
+ >
140
+ <RotateCcw className="size-3.5" />
141
+ </button>
142
+ {presets.length > 1 && <VariantSelect />}
143
+ </div>
144
+ )}
145
+ <div className="bg-fd-background p-4 not-last:border-b">
146
+ <PreviewBoundary resetKey={args}>
147
+ {createElement(Component, args)}
148
+ </PreviewBoundary>
149
+ </div>
150
+ {preset && (
151
+ <StoryFields
152
+ key={variant}
153
+ node={preset.controls}
154
+ storyKey={displayName ?? ''}
155
+ />
156
+ )}
157
+ <RemarkHint />
158
+ </div>
159
+ );
160
+ }
161
+
162
+ export function DocsWithControl({ presets, ...rest }: WithControlProps) {
163
+ const tweaked = useMemo(
164
+ () =>
165
+ presets.map((preset) => ({
166
+ ...preset,
167
+ controls: tweakControls(
168
+ rest.displayName ?? '',
169
+ preset.controls,
170
+ preset.defaultValues,
171
+ ),
172
+ })),
173
+ [presets, rest.displayName],
174
+ );
175
+ return (
176
+ <StoryProvider {...rest} presets={tweaked}>
177
+ <Content />
178
+ </StoryProvider>
179
+ );
180
+ }
181
+
@@ -0,0 +1,104 @@
1
+ 'use client';
2
+ import type TurndownService from 'turndown';
3
+
4
+ function decodedUrl(raw: string): string {
5
+ const url = raw.replace(/(?:%[0-9A-Fa-f]{2})+/g, (run) => {
6
+ let decoded: string;
7
+ try {
8
+ decoded = decodeURIComponent(run);
9
+ } catch {
10
+ return run;
11
+ }
12
+ return /[/?#&=%+]/.test(decoded) ? run : decoded;
13
+ });
14
+ return /[\s<>()]/.test(url) ? `<${url}>` : url;
15
+ }
16
+
17
+ let servicePromise: Promise<TurndownService> | undefined;
18
+
19
+ function turndownService(): Promise<TurndownService> {
20
+ servicePromise ??= (async () => {
21
+ const [{ default: TurndownServiceImpl }, { gfm }] = await Promise.all([
22
+ import('turndown'),
23
+ // @ts-expect-error -- no published type declarations
24
+ import('turndown-plugin-gfm'),
25
+ ]);
26
+ const service = new TurndownServiceImpl({
27
+ headingStyle: 'atx',
28
+ codeBlockStyle: 'fenced',
29
+ bulletListMarker: '-',
30
+ });
31
+ service.use(gfm);
32
+ service.remove([
33
+ 'script',
34
+ 'style',
35
+ 'noscript',
36
+ 'template',
37
+ 'button',
38
+ 'select',
39
+ 'nav',
40
+ 'aside',
41
+ 'footer',
42
+ ]);
43
+ service.remove(
44
+ (node) =>
45
+ node.nodeName === 'SVG' ||
46
+ (node instanceof HTMLImageElement &&
47
+ (node.src.startsWith('data:') ||
48
+ (node.width > 0 && node.width <= 48))) ||
49
+ (node instanceof HTMLElement && node.dataset.mdSkip !== undefined),
50
+ );
51
+ service.addRule('decodedLink', {
52
+ filter: 'a',
53
+ replacement(content, node) {
54
+ const anchor = node as HTMLAnchorElement;
55
+ const href = anchor.getAttribute('href');
56
+ if (!href) return content;
57
+ const title = anchor.getAttribute('title');
58
+ const titlePart = title ? ` "${title.replace(/"/g, '\\"')}"` : '';
59
+ return `[${content}](${decodedUrl(href)}${titlePart})`;
60
+ },
61
+ });
62
+ service.addRule('decodedImage', {
63
+ filter: 'img',
64
+ replacement(_content, node) {
65
+ const image = node as HTMLImageElement;
66
+ const src = image.getAttribute('src');
67
+ if (!src) return '';
68
+ const alt = image.getAttribute('alt') ?? '';
69
+ const title = image.getAttribute('title');
70
+ const titlePart = title ? ` "${title.replace(/"/g, '\\"')}"` : '';
71
+ return `![${alt}](${decodedUrl(src)}${titlePart})`;
72
+ },
73
+ });
74
+ return service;
75
+ })();
76
+ return servicePromise;
77
+ }
78
+
79
+ export async function domToMarkdown(
80
+ root: Element | null | undefined,
81
+ ): Promise<string> {
82
+ if (!root) return '';
83
+ return (await turndownService()).turndown(root as HTMLElement);
84
+ }
85
+
86
+ export function pageMarkdown(
87
+ root: Element | null | undefined,
88
+ title: string,
89
+ ): Promise<string> {
90
+ return domToMarkdown(root).then((body) => `# ${title}\n\n${body}`);
91
+ }
92
+
93
+ export async function openPageMarkdown(
94
+ root: Element | null | undefined,
95
+ title: string,
96
+ ): Promise<void> {
97
+ const markdown = await pageMarkdown(root, title);
98
+ const url = URL.createObjectURL(
99
+ new Blob([markdown], { type: 'text/markdown;charset=utf-8' }),
100
+ );
101
+ window.open(url, '_blank', 'noopener');
102
+ setTimeout(() => URL.revokeObjectURL(url), 60_000);
103
+ }
104
+
@@ -0,0 +1,18 @@
1
+ export * from './dom';
2
+ export * from './providers';
3
+
4
+ export async function renderSectionMarkdown(page: {
5
+ data: {
6
+ title?: string;
7
+ content?: string;
8
+ getText?: (type: 'processed' | 'raw') => Promise<string>;
9
+ };
10
+ }) {
11
+ const body = page.data.getText
12
+ ? await page.data.getText('processed')
13
+ : (page.data.content ?? '');
14
+ return `# ${page.data.title}
15
+
16
+ ${body}`;
17
+ }
18
+
@@ -0,0 +1,26 @@
1
+ import type { Lang } from '@terrafirmagreg-team/translate/types/locale';
2
+
3
+ export interface MarkdownProviderCtx {
4
+
5
+ slugs: string[];
6
+ lang: Lang;
7
+ }
8
+
9
+ export type MarkdownProvider = (
10
+ req: Request,
11
+ ctx: MarkdownProviderCtx,
12
+ ) => Promise<Response | undefined>;
13
+
14
+ const providers = new Map<string, MarkdownProvider>();
15
+
16
+ export function registerMarkdownProvider(
17
+ section: string,
18
+ provider: MarkdownProvider,
19
+ ): void {
20
+ providers.set(section, provider);
21
+ }
22
+
23
+ export function markdownProvider(section: string): MarkdownProvider | undefined {
24
+ return providers.get(section);
25
+ }
26
+
@@ -0,0 +1,113 @@
1
+ import { rehypeCode } from 'fumadocs-core/mdx-plugins/rehype-code';
2
+ import {
3
+ rehypeCodeDefaultOptions,
4
+ remarkDirectiveAdmonition,
5
+ remarkMdxFiles,
6
+ remarkMdxMermaid,
7
+ remarkSteps,
8
+ } from 'fumadocs-core/mdx-plugins';
9
+ import { remarkBlockId } from 'fumadocs-core/mdx-plugins/remark-block-id';
10
+ import { transformerTwoslash } from 'fumadocs-twoslash';
11
+ import { fileGenerator, remarkDocGen, remarkShow } from 'fumadocs-docgen';
12
+ import { remarkAutoTypeTable } from 'fumadocs-typescript';
13
+ import remarkDirective from 'remark-directive';
14
+ import remarkMath from 'remark-math';
15
+ import rehypeKatex from 'rehype-katex';
16
+ import rehypeRaw from 'rehype-raw';
17
+ import { remarkAccordions } from './mdx/accordions';
18
+ import { remarkBanner } from './mdx/banner';
19
+ import { remarkDirectives } from './mdx/directives';
20
+ import { remarkHtmlStyle } from './mdx/html-style';
21
+ import { remarkGameExport } from './mdx/game-export';
22
+ import { remarkPreview } from './mdx/preview';
23
+ import { remarkChangelogReferences } from './mdx/references';
24
+ import { remarkTabs } from './mdx/tabs';
25
+ import type { PluggableList } from 'unified';
26
+
27
+ const rehypeCodeOptions = {
28
+ ...rehypeCodeDefaultOptions,
29
+ inline: 'tailing-curly-colon' as const,
30
+ transformers: [
31
+ ...(rehypeCodeDefaultOptions.transformers ?? []),
32
+ transformerTwoslash(),
33
+ {
34
+ name: 'code-lang-title',
35
+ pre(this: { options?: { lang?: string } }, node: {
36
+ properties?: Record<string, unknown>;
37
+ }) {
38
+ node.properties ??= {};
39
+ node.properties.title ??= this.options?.lang;
40
+ },
41
+ },
42
+ ],
43
+ langs: [
44
+ ...(rehypeCodeDefaultOptions.langs ?? []),
45
+ 'java',
46
+ 'json',
47
+ 'yaml',
48
+ 'bash',
49
+ 'md',
50
+ 'toml',
51
+ 'diff',
52
+ ],
53
+ };
54
+
55
+ export const mdOptions = (
56
+ datasetConfig: Record<string, { repo?: string }> = {},
57
+ ) => ({
58
+ mdOptions: {
59
+ remarkPlugins: [
60
+ remarkDirective,
61
+ remarkPreview,
62
+ remarkTabs,
63
+ remarkAccordions,
64
+ remarkBanner,
65
+ remarkDirectives,
66
+ remarkHtmlStyle,
67
+ remarkGameExport,
68
+ [remarkChangelogReferences, { datasetConfig }],
69
+ [remarkDocGen, { generators: [fileGenerator()] }],
70
+ remarkShow,
71
+ remarkAutoTypeTable,
72
+ [
73
+ remarkDirectiveAdmonition,
74
+ {
75
+ types: {
76
+ note: 'note',
77
+ tip: 'tip',
78
+ info: 'info',
79
+ warn: 'warning',
80
+ warning: 'warning',
81
+ danger: 'error',
82
+ success: 'success',
83
+ idea: 'idea',
84
+ },
85
+ },
86
+ ],
87
+ remarkMath,
88
+ remarkSteps,
89
+ remarkMdxFiles,
90
+ remarkMdxMermaid,
91
+ [remarkBlockId, { addDataAttribute: 'feedback' }],
92
+ ] as PluggableList,
93
+ remarkRehypeOptions: { allowDangerousHtml: true },
94
+ rehypeCodeOptions: false,
95
+ rehypePlugins: [
96
+ rehypeKatex,
97
+ [rehypeCode, rehypeCodeOptions],
98
+ [
99
+ rehypeRaw,
100
+ {
101
+ passThrough: [
102
+ 'mdxjsEsm',
103
+ 'mdxJsxFlowElement',
104
+ 'mdxJsxTextElement',
105
+ 'mdxFlowExpression',
106
+ 'mdxTextExpression',
107
+ ],
108
+ },
109
+ ],
110
+ ] as PluggableList,
111
+ } as const,
112
+ });
113
+
package/src/lib/md.ts ADDED
@@ -0,0 +1,20 @@
1
+ export function md(
2
+ strings: TemplateStringsArray,
3
+ ...values: unknown[]
4
+ ): string {
5
+ const raw = String.raw(strings, ...values);
6
+ const lines = raw
7
+ .replace(/^\n/, '')
8
+ .replace(/\s+$/, '')
9
+ .split('\n');
10
+ const indent = Math.min(
11
+ ...lines
12
+ .filter((l) => l.trim())
13
+ .map((l) => (l.match(/^ */)?.[0].length ?? 0)),
14
+ );
15
+ return lines
16
+ .map((l) => l.slice(indent))
17
+ .join('\n')
18
+ .replaceAll('\\`', '`');
19
+ }
20
+
@@ -0,0 +1,102 @@
1
+ import {
2
+ contentChildren,
3
+ estreeBoolean,
4
+ extractDirectiveLabel,
5
+ extractLeafLabel,
6
+ jsxAttr,
7
+ jsxExpression,
8
+ walkContainers,
9
+ type DirectiveNode,
10
+ type JsxAttribute,
11
+ type JsxElement,
12
+ type MdastNode,
13
+ } from './directive-utils.ts';
14
+
15
+ export function remarkAccordions() {
16
+ return (tree: MdastNode) => {
17
+ walkContainers(tree, (directive) => {
18
+ if (directive.name === 'accordion' || directive.name === 'details') {
19
+ wrapSingle(directive);
20
+ return false;
21
+ }
22
+ if (directive.name === 'accordions') {
23
+ toAccordions(directive);
24
+ return false;
25
+ }
26
+ });
27
+ };
28
+ }
29
+
30
+ function toAccordion(node: DirectiveNode): JsxElement {
31
+ return {
32
+ type: 'mdxJsxFlowElement',
33
+ name: 'Accordion',
34
+ attributes: [jsxAttr('title', extractDirectiveLabel(node))],
35
+ children: contentChildren(node),
36
+ };
37
+ }
38
+
39
+ function wrapSingle(node: DirectiveNode): void {
40
+ Object.assign(node, {
41
+ type: 'mdxJsxFlowElement',
42
+ name: 'Accordions',
43
+ attributes: [],
44
+ children: [toAccordion(node)],
45
+ });
46
+ }
47
+
48
+ function accordionsAttributes(node: DirectiveNode): JsxAttribute[] {
49
+ const attrs = node.attributes ?? {};
50
+ const out: JsxAttribute[] = [];
51
+ const isMultiple =
52
+ attrs.multiple === true ||
53
+ attrs.multiple === '' ||
54
+ attrs.type === 'multiple';
55
+
56
+ if (isMultiple) out.push(jsxAttr('multiple', true));
57
+
58
+ for (const [name, value] of Object.entries(attrs)) {
59
+ if (name === 'type' || name === 'multiple') continue;
60
+ out.push({
61
+ type: 'mdxJsxAttribute',
62
+ name,
63
+ value:
64
+ value == null || value === ''
65
+ ? jsxExpression(estreeBoolean(true))
66
+ : value,
67
+ });
68
+ }
69
+
70
+ return out;
71
+ }
72
+
73
+ function toAccordions(node: DirectiveNode): void {
74
+ const items: JsxElement[] = [];
75
+ let current: JsxElement | undefined;
76
+
77
+ for (const item of node.children ?? []) {
78
+ if (
79
+ item.type === 'leafDirective' &&
80
+ (item as { name?: string }).name === 'accordion'
81
+ ) {
82
+ current = {
83
+ type: 'mdxJsxFlowElement',
84
+ name: 'Accordion',
85
+ attributes: [jsxAttr('title', extractLeafLabel(item))],
86
+ children: [],
87
+ };
88
+ items.push(current);
89
+ continue;
90
+ }
91
+
92
+ current?.children.push(item);
93
+ }
94
+
95
+ Object.assign(node, {
96
+ type: 'mdxJsxFlowElement',
97
+ name: 'Accordions',
98
+ attributes: accordionsAttributes(node),
99
+ children: items,
100
+ });
101
+ }
102
+
@@ -0,0 +1,57 @@
1
+ import {
2
+ estreeBoolean,
3
+ estreeStringArray,
4
+ jsxExpression,
5
+ walkContainers,
6
+ type DirectiveNode,
7
+ type MdastNode,
8
+ } from './directive-utils.ts';
9
+
10
+ export function remarkBanner() {
11
+ return (tree: MdastNode) => {
12
+ walkContainers(tree, (directive) => {
13
+ if (directive.name !== 'banner') return;
14
+ Object.assign(directive, {
15
+ type: 'mdxJsxFlowElement',
16
+ name: 'Banner',
17
+ attributes: [
18
+ {
19
+ type: 'mdxJsxAttribute',
20
+ name: 'changeLayout',
21
+ value: jsxExpression(estreeBoolean(false)),
22
+ },
23
+ ...toAttributes(directive),
24
+ ],
25
+ children: directive.children,
26
+ });
27
+ return false;
28
+ });
29
+ };
30
+ }
31
+
32
+ function toAttributes(directive: DirectiveNode) {
33
+ return Object.entries(directive.attributes ?? {})
34
+ .filter(([name]) => name !== 'id')
35
+ .map(([name, value]) => {
36
+ if (name === 'rainbowColors' && typeof value === 'string') {
37
+ const colors = value
38
+ .split(',')
39
+ .map((color) => color.trim())
40
+ .filter(Boolean);
41
+ return {
42
+ type: 'mdxJsxAttribute' as const,
43
+ name,
44
+ value: jsxExpression(estreeStringArray(colors)),
45
+ };
46
+ }
47
+ return {
48
+ type: 'mdxJsxAttribute' as const,
49
+ name,
50
+ value:
51
+ value == null || value === ''
52
+ ? jsxExpression(estreeBoolean(true))
53
+ : value,
54
+ };
55
+ });
56
+ }
57
+