@vectojs/markdown 0.13.0 → 0.14.0

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.
@@ -162,6 +162,34 @@ export interface MarkdownOptions {
162
162
  selectable?: boolean;
163
163
  /** Emit a `vecto:markdown:parse` User Timing measure. Default `false`. */
164
164
  userTiming?: boolean;
165
+ /**
166
+ * Draw copy / download controls in the top-right corner of code blocks and
167
+ * tables. Default `false`.
168
+ *
169
+ * Opt-in rather than on by default because it adds two focusable stops per such
170
+ * block to the tab order, which a document with many code fences would make
171
+ * tedious to navigate past, and because a reader who cannot act on a control
172
+ * (no clipboard permission, no filesystem) is better served by not being
173
+ * offered one.
174
+ */
175
+ blockAffordances?: boolean;
176
+ /**
177
+ * Writes text to the clipboard for the copy controls.
178
+ *
179
+ * Injectable because the real path (`navigator.clipboard.writeText`) is absent
180
+ * in jsdom and, in a browser, rejects a write that did not originate in a user
181
+ * gesture — so a test can only assert the payload, never the platform call.
182
+ * Defaults to `navigator.clipboard.writeText` when available.
183
+ */
184
+ writeClipboard?: (text: string) => void;
185
+ /**
186
+ * Saves a generated file for the download controls.
187
+ *
188
+ * Defaults to an anchor-click download that revokes its object URL. Injectable
189
+ * for the same reason as {@link MarkdownOptions.writeClipboard}: jsdom has no
190
+ * download behaviour to observe.
191
+ */
192
+ saveFile?: (filename: string, content: string, mimeType: string) => void;
165
193
  }
166
194
  /**
167
195
  * Renders Markdown content into a VectoJS entity tree using {@link marked}.
@@ -185,6 +213,18 @@ export declare class Markdown extends UIComponent {
185
213
  theme: Required<MarkdownTheme>;
186
214
  onLinkClick?: (url: string) => void;
187
215
  selectable: boolean;
216
+ /**
217
+ * Whether code blocks and tables carry copy / download controls.
218
+ *
219
+ * Read when a block entity is built, so it affects blocks rendered from here on
220
+ * rather than retroactively; a document does not rebuild to gain or lose an
221
+ * affordance.
222
+ */
223
+ blockAffordances: boolean;
224
+ /** Clipboard writer used by the copy controls. */
225
+ writeClipboard: (text: string) => void;
226
+ /** File saver used by the download controls. */
227
+ saveFile: (filename: string, content: string, mimeType: string) => void;
188
228
  private activeBlockMetrics;
189
229
  /**
190
230
  * Called after a streamed append has re-laid-out the document.
@@ -578,6 +618,28 @@ export declare class Markdown extends UIComponent {
578
618
  * policy for a zero-dimension source is a separate decision from notifying
579
619
  * the scene, which is the actual defect here.
580
620
  */
621
+ /**
622
+ * Wraps a block in its copy / download controls, or returns it untouched.
623
+ *
624
+ * The controls are built lazily through `make` so a document with
625
+ * `blockAffordances` off pays nothing — not the closures, not the measurement
626
+ * `BlockAffordanceButton` does in its constructor.
627
+ */
628
+ private withBlockAffordances;
629
+ /** Copy and download controls for one fenced code block. */
630
+ private codeBlockAffordances;
631
+ /** Copy (as Markdown) and download (as CSV) controls for one table. */
632
+ private tableAffordances;
633
+ /**
634
+ * Button styling for the affordances, derived from the document theme.
635
+ *
636
+ * Themed rather than hardcoded so a light-theme document does not get the dark
637
+ * default palette. `focusColor` is set explicitly from the theme's accent
638
+ * because `Button`'s default cyan is tuned for the dark palette and reads as
639
+ * off-brand elsewhere — while a focus ring is the one affordance a keyboard
640
+ * user cannot do without.
641
+ */
642
+ private affordanceButtonOptions;
581
643
  private paragraphImage;
582
644
  /** One table cell entity, shared by the render arm and the streamed-table path. */
583
645
  private tableCellRichText;
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Take-away affordances for block content: copy to the clipboard, download as a
3
+ * file.
4
+ *
5
+ * Split out of `Markdown.ts` rather than added to it because none of this is
6
+ * parsing or rendering — it is serialization plus two platform primitives — and
7
+ * that file is already 4.8k lines.
8
+ *
9
+ * The reference implementation is `streamdown` (clone `e5deed3`,
10
+ * `packages/streamdown/lib/`), read and recorded in
11
+ * `vectojs-docs/forge/findings/upstream/README.md`. Two details of its `save()`
12
+ * (`lib/utils.ts:35`) are borrowed deliberately: revoking the object URL, and the
13
+ * UTF-8 BOM for CSV. Both are commonly omitted and each is a real defect when
14
+ * missing — the first leaks a blob for the document's lifetime, the second makes
15
+ * Excel on Windows read the file in the system ANSI codepage and corrupt every
16
+ * non-ASCII cell.
17
+ */
18
+ import type { A11yAttributes, Entity, LayoutControlledProperty } from '@vectojs/core';
19
+ import { Button, type ButtonOptions, UIComponent } from '@vectojs/ui';
20
+ /** Extension for a fence info string, without the dot. `txt` when unrecognised. */
21
+ export declare function extensionForLanguage(lang: string): string;
22
+ /** MIME type for a downloaded code block. */
23
+ export declare function mimeForLanguage(lang: string): string;
24
+ /**
25
+ * Escapes one CSV field per RFC 4180.
26
+ *
27
+ * A field containing a comma, a quote or a newline is wrapped in quotes, and an
28
+ * internal quote is doubled. Anything else is returned unchanged, so the common
29
+ * case allocates nothing.
30
+ */
31
+ export declare function escapeCsvField(value: string): string;
32
+ /**
33
+ * Escapes one Markdown table cell.
34
+ *
35
+ * Backslash first, then pipe: doing it the other way escapes the backslash that
36
+ * the pipe escape just introduced, which is why `streamdown` documents the order
37
+ * at `lib/table/utils.ts:137`.
38
+ */
39
+ export declare function escapeMarkdownTableCell(cell: string): string;
40
+ /** Column alignment as `marked` reports it on a table token. */
41
+ export type TableAlign = 'left' | 'center' | 'right' | null;
42
+ /** Plain-text table content, extracted from the token before entities are built. */
43
+ export interface TableContent {
44
+ headers: string[];
45
+ rows: string[][];
46
+ align: readonly TableAlign[];
47
+ }
48
+ /**
49
+ * Serializes a table as CSV, prefixed with a UTF-8 BOM.
50
+ *
51
+ * Rows are `\r\n`-separated per RFC 4180.
52
+ *
53
+ * The BOM lives here rather than in {@link defaultSaveFile} — where `streamdown`
54
+ * puts it — so that the guarantee survives a caller supplying its own
55
+ * `saveFile`. Excel on Windows reads a BOM-less file in the system ANSI codepage
56
+ * and corrupts every non-ASCII cell, and that would be a silent,
57
+ * locale-dependent defect for anyone who replaced only the platform primitive.
58
+ * A property of the CSV belongs to the CSV.
59
+ */
60
+ export declare function tableToCsv(table: TableContent): string;
61
+ /**
62
+ * Serializes a table back to GitHub-flavoured Markdown.
63
+ *
64
+ * The alignment row is reproduced from the token's own `align`, so a copied table
65
+ * re-lexes to the same alignment rather than silently becoming left-aligned.
66
+ */
67
+ export declare function tableToMarkdown(table: TableContent): string;
68
+ /** Writes text to the clipboard, where the platform offers one. */
69
+ export declare function defaultWriteClipboard(text: string): void;
70
+ /**
71
+ * Downloads generated content as a file.
72
+ *
73
+ * Mirrors `streamdown`'s `save()` in revoking the object URL once the click has
74
+ * been dispatched. The other borrowed detail, the UTF-8 BOM for CSV, lives in
75
+ * {@link tableToCsv} instead — see there for why.
76
+ */
77
+ export declare function defaultSaveFile(filename: string, content: string, mimeType: string): void;
78
+ /**
79
+ * A copy or download control drawn in a block's top-right corner.
80
+ *
81
+ * Extends `@vectojs/ui`'s `Button` rather than hand-rolling an a11y hotspot: that
82
+ * class already projects `tag: 'button'` with a label, drives its focus ring from
83
+ * real DOM focus/blur, and handles hover and the disabled state. The repo rule
84
+ * against reimplementing what a `@vectojs/*` package provides applies to
85
+ * affordances too, and a bespoke hotspot would have to re-earn focus-ring
86
+ * behaviour `Button` already has.
87
+ *
88
+ * What this adds is transient success feedback. The label changes to a
89
+ * confirmation for `FEEDBACK_MS` and then reverts, which is the only signal a copy
90
+ * gives — nothing else about the document changes, so without it a reader cannot
91
+ * tell a working control from a broken one.
92
+ */
93
+ export declare class BlockAffordanceButton extends Button {
94
+ private readonly act;
95
+ /** How long the confirmation label stays up, in ms. */
96
+ static readonly FEEDBACK_MS = 1600;
97
+ private readonly restingLabel;
98
+ private readonly successLabel;
99
+ private feedbackTimer;
100
+ constructor(label: string, successLabel: string, act: () => void, opts?: ButtonOptions);
101
+ /**
102
+ * Runs the action, then shows the confirmation.
103
+ *
104
+ * The action runs first and a throw propagates: a clipboard write the browser
105
+ * rejected must not be reported as a success.
106
+ */
107
+ private run;
108
+ private setTransientLabel;
109
+ /**
110
+ * The label a reader hears is the one they see, transient confirmation
111
+ * included, so an AT user gets the same feedback a sighted user does.
112
+ */
113
+ getA11yAttributes(): A11yAttributes;
114
+ /** Clears the pending revert so a destroyed block leaves no timer behind. */
115
+ destroy(): void;
116
+ }
117
+ /**
118
+ * Wraps one block and positions its affordances in the top-right corner.
119
+ *
120
+ * A wrapper is necessary rather than adding the buttons to the block directly,
121
+ * because both candidate parents already own their children's geometry: `Stack`
122
+ * positions each child in flow, and `Table` recomputes `x`/`y`/`width`/`height`
123
+ * for every child from its column widths (`Table.ts:66`). A button added to
124
+ * either would be moved on the next layout. This owns only its own children's
125
+ * placement and delegates its size to the block, so the surrounding document
126
+ * lays out exactly as it did before.
127
+ */
128
+ export declare class BlockWithAffordances extends UIComponent {
129
+ readonly block: Entity;
130
+ private readonly controls;
131
+ /** Gap between the block's edges and the controls, in px. */
132
+ private static readonly INSET;
133
+ /** Gap between adjacent controls, in px. */
134
+ private static readonly GAP;
135
+ constructor(block: Entity, controls: readonly BlockAffordanceButton[]);
136
+ /**
137
+ * Places the controls right-aligned along the block's top edge.
138
+ *
139
+ * Laid out right-to-left from the block's right edge so the first control in
140
+ * the list ends up leftmost, which keeps DOM order (and therefore tab order and
141
+ * the a11y reading order) matching the visual order.
142
+ */
143
+ private layoutAffordances;
144
+ /**
145
+ * Re-places the controls after the block's own box changed.
146
+ *
147
+ * Called by the owner when a block is resized or its content grew; the controls
148
+ * are anchored to the right edge, so a width change moves them.
149
+ */
150
+ refreshAffordances(): void;
151
+ /** The wrapper is a pass-through: its size is the block's size. */
152
+ getLayoutControlledProperties(): ReadonlyArray<LayoutControlledProperty>;
153
+ /**
154
+ * Projected as a group so assistive technology reports one labelled region
155
+ * containing the block and its controls, rather than two unrelated siblings.
156
+ */
157
+ getA11yAttributes(): A11yAttributes;
158
+ render(): void;
159
+ }
160
+ /**
161
+ * Extracts plain-text table content from a `marked` table token.
162
+ *
163
+ * Reads the token rather than the built `Table` entity because the entity holds
164
+ * `RichText` children, not strings — reconstructing cell text from spans would
165
+ * have to reverse the inline formatting, and the token still has the source.
166
+ *
167
+ * `cell.text` is the cell's raw inline Markdown, which is what a copy should
168
+ * preserve: `**bold**` copied out of a table and pasted into another Markdown
169
+ * document should still be bold.
170
+ */
171
+ export declare function tableContentOf(token: {
172
+ header: ReadonlyArray<{
173
+ text: string;
174
+ }>;
175
+ rows: ReadonlyArray<ReadonlyArray<{
176
+ text: string;
177
+ }>>;
178
+ align: readonly TableAlign[];
179
+ }): TableContent;
package/dist/index.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  export * from './Markdown';
2
+ export { BlockAffordanceButton, BlockWithAffordances, escapeCsvField, escapeMarkdownTableCell, extensionForLanguage, mimeForLanguage, tableContentOf, tableToCsv, tableToMarkdown, } from './blockAffordances';
3
+ export type { TableAlign, TableContent } from './blockAffordances';
2
4
  export { parseFrontMatterFields, scanFrontMatter } from './frontMatter';
3
5
  export type { FrontMatterScan } from './frontMatter';
4
6
  export type { IncompleteMarkdownMode, StreamController, StreamControllerOptions, StreamControllerState, StreamPacingOptions, } from './StreamController';