@terrafirmagreg-team/mdx 1.2.6 → 1.2.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@terrafirmagreg-team/mdx",
3
- "version": "1.2.6",
3
+ "version": "1.2.8",
4
4
  "type": "module",
5
5
  "description": "MDX/markdown pipeline",
6
6
  "license": "LGPL-3.0-only",
@@ -22,9 +22,8 @@
22
22
  "dependencies": {
23
23
  "@fumadocs/story": "^1.5.3",
24
24
  "@fumari/stf": "^1.1.2",
25
- "@terrafirmagreg-team/translate": "^1.2.7",
26
- "@terrafirmagreg-team/ui": "^1.2.8",
27
- "@types/turndown": "^5.0.6",
25
+ "@terrafirmagreg-team/translate": "^1.2.8",
26
+ "@terrafirmagreg-team/ui": "^1.2.10",
28
27
  "fumadocs-core": "16.17.10",
29
28
  "fumadocs-docgen": "^3.2.0",
30
29
  "fumadocs-twoslash": "^4.0.4",
@@ -38,8 +37,6 @@
38
37
  "remark-gfm": "^4.0.1",
39
38
  "remark-math": "^6.0.0",
40
39
  "remark-parse": "^11.0.0",
41
- "turndown": "^7.2.5",
42
- "turndown-plugin-gfm": "^1.0.2",
43
40
  "unified": "^11.0.5",
44
41
  "unist-util-visit": "^5.2.0"
45
42
  },
@@ -15,8 +15,17 @@ import {
15
15
  SelectValue,
16
16
  } from '@terrafirmagreg-team/ui/components/ui/select';
17
17
  import { cn } from '@terrafirmagreg-team/ui/lib/utils/cn';
18
- import { X } from 'lucide-react';
19
- import { useState } from 'react';
18
+ import { Pin, PinOff, X } from 'lucide-react';
19
+ import { createContext, useContext, useState } from 'react';
20
+
21
+ export const PinnedContext = createContext<{
22
+ pinned: ReadonlySet<string>;
23
+ toggle: (name: string) => void;
24
+ } | null>(null);
25
+
26
+ export function usePinned() {
27
+ return useContext(PinnedContext);
28
+ }
20
29
 
21
30
  function resolveNode(node: TypeNode, value: unknown): TypeNode {
22
31
  if (node.type === 'intersection') return resolveNode(node.intersection, value);
@@ -234,6 +243,8 @@ function Field({
234
243
  const [value, setValue] = useFieldValue([name]);
235
244
  const resolved = resolveNode(node, value);
236
245
  const members = selectMembers(resolved);
246
+ const pinnedCtx = usePinned();
247
+ const isPinned = pinnedCtx?.pinned.has(name) ?? false;
237
248
 
238
249
  let editor: React.ReactNode;
239
250
  if (members) {
@@ -275,6 +286,26 @@ function Field({
275
286
  <span className="hidden w-24 shrink-0 truncate px-2 font-mono text-xs text-fd-muted-foreground/70 @3xl:block">
276
287
  {typeToString(resolved)}
277
288
  </span>
289
+ {pinnedCtx && !required && (
290
+ <button
291
+ type="button"
292
+ aria-label={`${isPinned ? 'Unpin' : 'Pin'} ${name}`}
293
+ title={
294
+ isPinned
295
+ ? 'Unpin — reset with preset'
296
+ : 'Pin — keep value across presets'
297
+ }
298
+ onClick={() => pinnedCtx.toggle(name)}
299
+ className={cn(
300
+ 'me-1 flex size-6 shrink-0 items-center justify-center rounded-md hover:bg-fd-accent',
301
+ isPinned
302
+ ? 'text-fd-primary'
303
+ : 'text-fd-muted-foreground hover:text-fd-foreground',
304
+ )}
305
+ >
306
+ {isPinned ? <PinOff className="size-3" /> : <Pin className="size-3" />}
307
+ </button>
308
+ )}
278
309
  {!required && (
279
310
  <button
280
311
  type="button"
@@ -6,8 +6,9 @@ import {
6
6
  useStoryArgs,
7
7
  type WithControlProps,
8
8
  } from '@fumadocs/story';
9
- import { StoryFields } from './story-fields';
10
- import { remarkFor, tweakControls } from '../lib/story-remark';
9
+ import { useDataEngine } from '@fumari/stf';
10
+ import { PinnedContext, StoryFields, usePinned } from './story-fields';
11
+ import { directiveMeta, remarkFor, tweakControls } from '../lib/story-remark';
11
12
  import { highlightRemark } from '../lib/remark-highlight';
12
13
  import { useCopyButton } from '@terrafirmagreg-team/ui/hooks/use-copy-button';
13
14
  import { Check, Clipboard } from 'lucide-react';
@@ -22,22 +23,34 @@ import {
22
23
  Component,
23
24
  createElement,
24
25
  useMemo,
26
+ useState,
25
27
  type ErrorInfo,
26
28
  type ReactNode,
27
29
  } from 'react';
28
- import { CircleAlert, RotateCcw } from 'lucide-react';
30
+ import {
31
+ ChevronLeft,
32
+ ChevronRight,
33
+ CircleAlert,
34
+ RotateCcw,
35
+ } from 'lucide-react';
29
36
 
30
37
  function RemarkHint() {
31
38
  const { displayName, preset, variant } = useStory();
32
39
  const args = useStoryArgs();
40
+ const suppressed =
41
+ directiveMeta(displayName ?? '')?.remark === false;
33
42
  const text = useMemo(
34
- () => remarkFor(displayName ?? 'component', args, preset?.controls, variant),
35
- [displayName, args, preset, variant],
43
+ () =>
44
+ suppressed
45
+ ? ''
46
+ : remarkFor(displayName ?? 'component', args, preset?.controls, variant),
47
+ [suppressed, displayName, args, preset, variant],
36
48
  );
37
49
  const tokens = useMemo(() => highlightRemark(text), [text]);
38
50
  const { checked, onClick } = useCopyButton(() =>
39
51
  navigator.clipboard.writeText(text),
40
52
  );
53
+ if (suppressed) return null;
41
54
  return (
42
55
  <div className="relative border-t">
43
56
  <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">
@@ -93,34 +106,83 @@ class PreviewBoundary extends Component<
93
106
  }
94
107
  }
95
108
 
109
+ function useApplyVariant() {
110
+ const { setVariant } = useStory();
111
+ const engine = useDataEngine();
112
+ const pinned = usePinned();
113
+ return (value: string) => {
114
+ const keep = new Map(
115
+ [...(pinned?.pinned ?? [])].map(
116
+ (name) =>
117
+ [name, (engine.getData() as Record<string, unknown>)[name]] as const,
118
+ ),
119
+ );
120
+ setVariant(value);
121
+ for (const [name, value] of keep) {
122
+ if (value === undefined) engine.delete([name]);
123
+ else if (!engine.update([name], value)) engine.init([name], value);
124
+ }
125
+ };
126
+ }
127
+
96
128
  function VariantSelect() {
97
- const { presets, variant, setVariant } = useStory();
129
+ const { presets, variant } = useStory();
130
+ const applyVariant = useApplyVariant();
131
+ const index = presets.findIndex((p) => p.variant === variant);
132
+ const step = (delta: number) => {
133
+ const next =
134
+ presets[(index + delta + presets.length) % presets.length];
135
+ if (next) applyVariant(next.variant);
136
+ };
137
+ const stepClass =
138
+ 'rounded-md p-1.5 text-fd-muted-foreground hover:bg-fd-accent hover:text-fd-foreground';
98
139
  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>
140
+ <div className="flex items-center gap-0.5">
141
+ <button
142
+ type="button"
143
+ aria-label="Previous variant"
144
+ title="Previous variant"
145
+ onClick={() => step(-1)}
146
+ className={stepClass}
147
+ >
148
+ <ChevronLeft className="size-3.5" />
149
+ </button>
150
+ <Select value={variant} onValueChange={(v) => v && applyVariant(v)}>
151
+ <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">
152
+ <span className="text-fd-muted-foreground">Variant</span>
153
+ <SelectValue className="truncate font-medium" />
154
+ </SelectTrigger>
155
+ <SelectContent align="end">
156
+ {presets.map((item) => (
157
+ <SelectItem
158
+ key={item.variant}
159
+ value={item.variant}
160
+ className="text-xs"
161
+ >
162
+ {item.variant}
163
+ </SelectItem>
164
+ ))}
165
+ </SelectContent>
166
+ </Select>
167
+ <button
168
+ type="button"
169
+ aria-label="Next variant"
170
+ title="Next variant"
171
+ onClick={() => step(1)}
172
+ className={stepClass}
173
+ >
174
+ <ChevronRight className="size-3.5" />
175
+ </button>
176
+ </div>
116
177
  );
117
178
  }
118
179
 
119
180
  function Content() {
120
- const { presets, displayName, preset, variant, setVariant, Component } =
181
+ const { presets, displayName, preset, variant, Component } =
121
182
  useStory();
183
+ const applyVariant = useApplyVariant();
122
184
  const args = useStoryArgs();
123
- const reset = () => setVariant(variant);
185
+ const reset = () => applyVariant(variant);
124
186
  return (
125
187
  <div className="not-prose overflow-hidden rounded-xl border bg-fd-card text-fd-card-foreground">
126
188
  {(displayName || presets.length > 1) && (
@@ -172,9 +234,25 @@ export function DocsWithControl({ presets, ...rest }: WithControlProps) {
172
234
  })),
173
235
  [presets, rest.displayName],
174
236
  );
237
+ const [pinned, setPinned] = useState<ReadonlySet<string>>(new Set());
238
+ const pinApi = useMemo(
239
+ () => ({
240
+ pinned,
241
+ toggle: (name: string) =>
242
+ setPinned((prev) => {
243
+ const next = new Set(prev);
244
+ if (next.has(name)) next.delete(name);
245
+ else next.add(name);
246
+ return next;
247
+ }),
248
+ }),
249
+ [pinned],
250
+ );
175
251
  return (
176
252
  <StoryProvider {...rest} presets={tweaked}>
177
- <Content />
253
+ <PinnedContext.Provider value={pinApi}>
254
+ <Content />
255
+ </PinnedContext.Provider>
178
256
  </StoryProvider>
179
257
  );
180
258
  }
@@ -1,4 +1,3 @@
1
- export * from './dom';
2
1
  export * from './providers';
3
2
 
4
3
  export async function renderSectionMarkdown(page: {
@@ -13,6 +13,7 @@ export type DirectiveMeta = {
13
13
  props?: string[];
14
14
  solo?: boolean;
15
15
  paired?: boolean;
16
+ remark?: boolean;
16
17
  descriptions?: Record<string, string>;
17
18
  raw?: string;
18
19
  variants?: Record<string, Partial<DirectiveMeta>>;
@@ -397,6 +398,27 @@ const DIRECTIVE_META: Record<string, DirectiveMeta> = {
397
398
  Display: { raw: '$$e = mc^2$$' },
398
399
  },
399
400
  },
401
+ og: {
402
+ remark: false,
403
+ props: ['url', 'section', 'dataset', 'slug', 'lang', 'version'],
404
+ descriptions: {
405
+ url: 'Page or image.png URL — overrides the fields below',
406
+ section: 'Site section the page belongs to',
407
+ dataset: 'Dataset id within the section',
408
+ slug: 'Page path inside the dataset, e.g. getting-started/install',
409
+ lang: 'Content language of the rendered card',
410
+ version: 'Dataset version pin, e.g. 0.13.18',
411
+ },
412
+ },
413
+ site: {
414
+ remark: false,
415
+ props: ['view', 'section', 'links'],
416
+ descriptions: {
417
+ view: 'What to show',
418
+ section: 'Limit the listing to one section',
419
+ links: 'URLs to preview in the links view (JSON array)',
420
+ },
421
+ },
400
422
  };
401
423
 
402
424
  export function directiveMeta(key: string): DirectiveMeta | undefined {
@@ -1,104 +0,0 @@
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
-