@theseus.run/jsx-md 0.1.1 → 0.2.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.
package/src/context.ts CHANGED
@@ -1,13 +1,38 @@
1
1
  /**
2
- * Minimal synchronous context API for jsx-md.
2
+ * Synchronous context API for jsx-md — matches the React context shape.
3
3
  *
4
- * Uses a module-level stack map so context values are available synchronously
5
- * during the render() traversal. Not safe for async or concurrent rendering.
4
+ * createContext(defaultValue) returns a Context object with a Provider
5
+ * component. Wrap a render() call with <Ctx.Provider value={...}> and read
6
+ * the value anywhere in the tree with useContext(Ctx).
7
+ *
8
+ * Uses a module-level stack map so values are available synchronously during
9
+ * the render() traversal. Not safe for async or concurrent rendering.
6
10
  */
7
11
 
12
+ import { render } from './render.ts';
13
+ import type { VNode } from './jsx-runtime.ts';
14
+
8
15
  export interface Context<T> {
9
16
  readonly _id: symbol;
10
17
  readonly _default: T;
18
+ /**
19
+ * JSX provider component — identical usage to React's Context.Provider.
20
+ *
21
+ * Return type is `string` (not `VNode`) because `render()` always returns `string`
22
+ * and this is the honest, precise type. This means `Provider` is not directly
23
+ * assignable to `Component<P>` without a cast, even though `string extends VNode`.
24
+ *
25
+ * React solves this by typing `Provider` as `ProviderExoticComponent` — a distinct
26
+ * type with a `$$typeof: symbol` discriminant — rather than a plain function component.
27
+ * That approach deliberately prevents `Provider` from being used as a generic
28
+ * `Component` argument, and it returns `ReactNode` (broad) rather than a precise type.
29
+ *
30
+ * We keep `=> string` because: (a) no code in this codebase passes `Provider` as a
31
+ * `Component`-typed value, (b) precision is more useful than assignability here,
32
+ * and (c) the exotic-component indirection would be engineering for a non-problem.
33
+ * If that higher-order pattern ever becomes necessary, change this to `=> VNode`.
34
+ */
35
+ readonly Provider: (props: { value: T; children?: VNode }) => string;
11
36
  }
12
37
 
13
38
  /**
@@ -18,7 +43,24 @@ export interface Context<T> {
18
43
  const stack = new Map<symbol, unknown[]>();
19
44
 
20
45
  export function createContext<T>(defaultValue: T): Context<T> {
21
- return { _id: Symbol(), _default: defaultValue };
46
+ const _id = Symbol();
47
+
48
+ function Provider({ value, children }: { value: T; children?: VNode }): string {
49
+ let s = stack.get(_id);
50
+ if (!s) {
51
+ s = [];
52
+ stack.set(_id, s);
53
+ }
54
+ s.push(value as unknown);
55
+ try {
56
+ return render(children ?? null);
57
+ } finally {
58
+ s.pop();
59
+ if (s.length === 0) stack.delete(_id);
60
+ }
61
+ }
62
+
63
+ return { _id, _default: defaultValue, Provider };
22
64
  }
23
65
 
24
66
  export function useContext<T>(ctx: Context<T>): T {
@@ -40,5 +82,6 @@ export function withContext<T>(ctx: Context<T>, value: T, fn: () => string): str
40
82
  return fn();
41
83
  } finally {
42
84
  s.pop();
85
+ if (s.length === 0) stack.delete(ctx._id);
43
86
  }
44
87
  }
package/src/escape.ts CHANGED
@@ -2,21 +2,35 @@
2
2
  * Escapes `&`, `"`, `<`, `>` — for use in XML/HTML attribute values.
3
3
  * Escapes for double-quoted HTML/XML attribute values. Single quotes are not
4
4
  * escaped — do not use this in single-quoted attribute contexts.
5
+ *
6
+ * Single-pass via a lookup map — avoids four sequential .replace() calls.
5
7
  */
8
+ const HTML_ATTR_MAP: Record<string, string> = {
9
+ '&': '&amp;',
10
+ '"': '&quot;',
11
+ '<': '&lt;',
12
+ '>': '&gt;',
13
+ };
14
+ const HTML_ATTR_RE = /[&"<>]/g;
15
+
6
16
  export function escapeHtmlAttr(s: string): string {
7
- return s
8
- .replace(/&/g, '&amp;')
9
- .replace(/"/g, '&quot;')
10
- .replace(/</g, '&lt;')
11
- .replace(/>/g, '&gt;');
17
+ return s.replace(HTML_ATTR_RE, (c) => HTML_ATTR_MAP[c]!);
12
18
  }
13
19
 
14
- /** Escapes `&`, `<`, `>` — for use in HTML/XML text content (not attributes). */
20
+ /**
21
+ * Escapes `&`, `<`, `>` — for use in HTML/XML text content (not attributes).
22
+ *
23
+ * Single-pass via a lookup map.
24
+ */
25
+ const HTML_CONTENT_MAP: Record<string, string> = {
26
+ '&': '&amp;',
27
+ '<': '&lt;',
28
+ '>': '&gt;',
29
+ };
30
+ const HTML_CONTENT_RE = /[&<>]/g;
31
+
15
32
  export function escapeHtmlContent(s: string): string {
16
- return s
17
- .replace(/&/g, '&amp;')
18
- .replace(/</g, '&lt;')
19
- .replace(/>/g, '&gt;');
33
+ return s.replace(HTML_CONTENT_RE, (c) => HTML_CONTENT_MAP[c]!);
20
34
  }
21
35
 
22
36
  /**
@@ -24,7 +38,7 @@ export function escapeHtmlContent(s: string): string {
24
38
  * Markdown link syntax `[text](url)`.
25
39
  */
26
40
  export function encodeLinkUrl(url: string): string {
27
- return url.replace(/[()]/g, (c) => encodeURIComponent(c));
41
+ return url.replace(/\(/g, '%28').replace(/\)/g, '%29');
28
42
  }
29
43
 
30
44
  /**
@@ -34,3 +48,47 @@ export function encodeLinkUrl(url: string): string {
34
48
  export function encodeLinkLabel(text: string): string {
35
49
  return text.replace(/[[\]]/g, (c) => encodeURIComponent(c));
36
50
  }
51
+
52
+ /**
53
+ * Escapes all CommonMark ASCII punctuation metacharacters in a string so that
54
+ * user-supplied content is treated as literal text by any markdown renderer.
55
+ *
56
+ * Useful when interpolating variable strings — filenames, user input, code
57
+ * identifiers, etc. — into markdown prose where unintended formatting must be
58
+ * suppressed:
59
+ *
60
+ * <P>File: <Escape>{untrustedFilename}</Escape></P>
61
+ * <P>File: {escapeMarkdown(untrustedFilename)}</P>
62
+ *
63
+ * Escaped characters: \ ` * _ [ ] ( ) # + - . ! | ~ < >
64
+ * These cover all CommonMark inline and block trigger characters.
65
+ * HTML entities (&amp; etc.) are intentionally not escaped here — use
66
+ * escapeHtmlContent for HTML attribute / tag contexts.
67
+ */
68
+ const MARKDOWN_ESCAPE_RE = /[\\`*_[\]()#+\-.!|~<>]/g;
69
+
70
+ export function escapeMarkdown(s: string): string {
71
+ return s.replace(MARKDOWN_ESCAPE_RE, '\\$&');
72
+ }
73
+
74
+ /**
75
+ * Returns the minimum backtick fence length needed to safely wrap `content`
76
+ * as a CommonMark inline code span or fenced code block.
77
+ *
78
+ * CommonMark rule: the fence must be a run of N backticks where N is strictly
79
+ * greater than the longest run of consecutive backticks in the content.
80
+ * Minimum is 1 for inline code, 3 for fenced code blocks.
81
+ */
82
+ export function backtickFenceLength(content: string, minimum: number = 1): number {
83
+ let maxRun = 0;
84
+ let currentRun = 0;
85
+ for (const ch of content) {
86
+ if (ch === '`') {
87
+ currentRun++;
88
+ if (currentRun > maxRun) maxRun = currentRun;
89
+ } else {
90
+ currentRun = 0;
91
+ }
92
+ }
93
+ return Math.max(minimum, maxRun + 1);
94
+ }
package/src/index.ts CHANGED
@@ -9,6 +9,9 @@ export { render } from './render.ts';
9
9
  export { createContext, useContext, withContext } from './context.ts';
10
10
  export type { Context } from './context.ts';
11
11
 
12
+ // Escape utilities
13
+ export { escapeMarkdown } from './escape.ts';
14
+
12
15
  // Primitive components
13
16
  export {
14
17
  H1, H2, H3, H4, H5, H6,
@@ -18,6 +21,7 @@ export {
18
21
  Blockquote,
19
22
  Li, Ul, Ol,
20
23
  Bold, Code, Italic, Strikethrough, Link, Img,
24
+ Br, Sup, Sub, Kbd, Escape,
21
25
  Md,
22
26
  Table, Tr, Th, Td,
23
27
  TaskList, Task,
@@ -26,4 +30,4 @@ export {
26
30
  Details,
27
31
  } from './primitives.tsx';
28
32
 
29
- export type { CalloutType } from './primitives.tsx';
33
+ export type { CalloutType, ColAlign } from './primitives.tsx';
@@ -8,21 +8,34 @@
8
8
  * Call `render(node)` from `./render.ts` to produce the final markdown string.
9
9
  * All markdown primitives live in primitives.tsx as named components.
10
10
  *
11
- * Fragment circular-dep resolution: Fragment needs render() to flatten children,
12
- * but render.ts imports types from this file. We use _render-registry.ts as a
13
- * shared side-channel: render.ts calls registerRender(render) on module init,
14
- * Fragment defers to callRender(). Any entry point that imports render will
15
- * register it before rendering starts.
11
+ * Fragment is a Symbol. render.ts imports this Symbol and handles it explicitly,
12
+ * keeping the dependency one-way: render.ts jsx-runtime.ts (no cycle).
16
13
  */
17
14
 
18
- import { callRender } from './_render-registry.ts';
19
-
20
15
  // ---------------------------------------------------------------------------
21
16
  // VNode types
22
17
  // ---------------------------------------------------------------------------
23
18
 
19
+ /**
20
+ * The concrete runtime shape of a JSX element — what the `jsx()` factory always produces.
21
+ *
22
+ * Useful for structural inspection of a VNode tree (e.g. testing whether a node is an
23
+ * element rather than a string, null, or array). The `isVNodeElement` predicate in
24
+ * render.ts narrows to this type.
25
+ *
26
+ * @remarks **Do not use as a component return-type annotation.** TypeScript infers
27
+ * `JSX.Element` (= `VNode`) as the return type of JSX expressions, not `VNodeElement`.
28
+ * Annotating a component as `(): VNodeElement` causes TS2322 because `VNode` (the
29
+ * inferred type) is not assignable to the narrower `VNodeElement`. Use `VNode` instead:
30
+ * ```ts
31
+ * // Wrong — TS2322
32
+ * function MyComp(): VNodeElement { return <P>hi</P>; }
33
+ * // Correct
34
+ * function MyComp(): VNode { return <P>hi</P>; }
35
+ * ```
36
+ */
24
37
  export type VNodeElement = {
25
- readonly type: Component | string;
38
+ readonly type: Component | string | typeof Fragment;
26
39
  readonly props: Record<string, unknown>;
27
40
  };
28
41
 
@@ -36,7 +49,7 @@ export type VNode =
36
49
  | ReadonlyArray<VNode>;
37
50
 
38
51
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
39
- type Component<P = any> = (props: P) => string;
52
+ type Component<P = any> = (props: P) => VNode;
40
53
 
41
54
  // ---------------------------------------------------------------------------
42
55
  // JSX namespace
@@ -45,10 +58,10 @@ type Component<P = any> = (props: P) => string;
45
58
  // eslint-disable-next-line @typescript-eslint/no-namespace
46
59
  export namespace JSX {
47
60
  /**
48
- * JSX.Element is VNode (not just VNodeElement) so that string-returning
49
- * components pass TypeScript's component return-type check. The jsx() factory
50
- * always produces VNodeElement at runtime; the wider union here is only needed
51
- * to satisfy TypeScript's component-validity check.
61
+ * JSX.Element is VNode so that components returning any valid VNode
62
+ * (string, VNodeElement, Fragment, null, etc.) satisfy TypeScript's
63
+ * component return-type check. The jsx() factory always produces
64
+ * VNodeElement at runtime; the wider union is only for type-checking.
52
65
  */
53
66
  export type Element = VNode;
54
67
 
@@ -63,12 +76,16 @@ export namespace JSX {
63
76
  }
64
77
 
65
78
  // ---------------------------------------------------------------------------
66
- // Render registration (avoids circular dep with render.ts)
79
+ // Fragment symbol
67
80
  // ---------------------------------------------------------------------------
68
81
 
69
- // Registration and dispatch are handled by _render-registry.ts.
70
- // render.ts calls registerRender(render) on module init.
71
- // Fragment calls callRender() to evaluate children.
82
+ /**
83
+ * Fragment a unique Symbol used as the `type` of JSX fragment VNodes.
84
+ * render.ts detects this Symbol and renders children directly, with no wrapper.
85
+ * Using a Symbol (rather than a function) eliminates the circular dependency
86
+ * that previously required _render-registry.ts.
87
+ */
88
+ export const Fragment = Symbol('Fragment');
72
89
 
73
90
  // ---------------------------------------------------------------------------
74
91
  // JSX factory
@@ -77,18 +94,16 @@ export namespace JSX {
77
94
  /**
78
95
  * JSX factory — called by Bun's compiled JSX. Builds VNode tree; no evaluation.
79
96
  *
80
- * `type` is a function component or a string tag name. String tags are rendered
81
- * as XML blocks by render.ts: `<tag attrs>\ncontent\n</tag>\n` (or self-closing
82
- * when the inner content is empty).
97
+ * `type` is a function component, a string tag name, or the Fragment symbol.
98
+ * String tags are rendered as XML blocks by render.ts: `<tag attrs>\ncontent\n</tag>\n`
99
+ * (or self-closing when the inner content is empty).
83
100
  */
84
- export function jsx(type: Component | string, props: Record<string, unknown>): VNodeElement {
101
+ export function jsx(
102
+ type: Component | string | typeof Fragment,
103
+ props: Record<string, unknown>,
104
+ ): VNodeElement {
85
105
  return { type, props };
86
106
  }
87
107
 
88
108
  /** jsxs — same as jsx, used when there are multiple children (children is array) */
89
109
  export const jsxs = jsx;
90
-
91
- /** Fragment — renders children. Defers to _render-registry to avoid circular imports. */
92
- export function Fragment({ children }: { children?: VNode }): string {
93
- return callRender(children ?? null);
94
- }