@octanejs/markdown 0.0.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.
- package/LICENSE +21 -0
- package/README.md +33 -0
- package/UPSTREAM.md +74 -0
- package/package.json +74 -0
- package/src/index.ts +23 -0
- package/src/markdown-async.ts +8 -0
- package/src/markdown-hooks.tsrx +36 -0
- package/src/processor.ts +146 -0
- package/src/project.ts +27 -0
- package/src/types.ts +45 -0
- package/src/url-transform.ts +21 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) Espen Hovlandsdal
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# @octanejs/markdown
|
|
2
|
+
|
|
3
|
+
Octane bindings for [`react-markdown@10.1.0`](https://github.com/remarkjs/react-markdown). The default export and the `MarkdownAsync`, `MarkdownHooks`, and `defaultUrlTransform` named exports preserve the upstream processing and option contracts while producing Octane output.
|
|
4
|
+
|
|
5
|
+
```tsrx
|
|
6
|
+
import Markdown from '@octanejs/markdown';
|
|
7
|
+
import remarkGfm from 'remark-gfm';
|
|
8
|
+
|
|
9
|
+
export function Article() {
|
|
10
|
+
return (
|
|
11
|
+
<Markdown
|
|
12
|
+
remarkPlugins={[remarkGfm]}
|
|
13
|
+
components={{
|
|
14
|
+
strong({ children, node: _node, ...props }) {
|
|
15
|
+
return <strong class="highlight" {...props}>{children}</strong>;
|
|
16
|
+
},
|
|
17
|
+
}}
|
|
18
|
+
>
|
|
19
|
+
{'# Hello\n\nThis is **Octane**.'}
|
|
20
|
+
</Markdown>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Migrate by replacing the `react-markdown` dependency and import root with `@octanejs/markdown`, then apply the ordinary React-to-Octane syntax and type conversion. Plugin values and tuples, component keys and callback props, filtering options, URL transforms, and the sync/async/hooks API shapes remain unchanged.
|
|
26
|
+
|
|
27
|
+
Author `Markdown` in React-style value position (`return <Markdown>{markdown}</Markdown>`) so its static or expression child remains an inspectable Markdown value. Template-position children compile to an opaque render block and are rejected rather than invoked without their runtime scope. In a template body, pass the source explicitly with `<Markdown children={markdown} />`.
|
|
28
|
+
|
|
29
|
+
## Security
|
|
30
|
+
|
|
31
|
+
Raw HTML is rendered as text by default. Adding `rehype-raw` parses raw HTML but does not sanitize it. Unified plugins and component mappings execute as trusted code, and a custom URL transform can broaden the protocols accepted by the default transform. When Markdown is untrusted, add a sanitizer explicitly at the correct point in the rehype pipeline and audit any plugins, components, and URL transform used with it.
|
|
32
|
+
|
|
33
|
+
See [UPSTREAM.md](./UPSTREAM.md) for the pinned source, artifact, test, and license provenance.
|
package/UPSTREAM.md
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# react-markdown upstream contract
|
|
2
|
+
|
|
3
|
+
## Immutable pin
|
|
4
|
+
|
|
5
|
+
| Field | Value |
|
|
6
|
+
| --- | --- |
|
|
7
|
+
| Package | `react-markdown` |
|
|
8
|
+
| Version | `10.1.0` |
|
|
9
|
+
| Supported range | exactly `10.1.0` |
|
|
10
|
+
| Canonical repository | `remarkjs/react-markdown` |
|
|
11
|
+
| Canonical commit | `44d2e4a44b37461ab7778d6870c1a9eb36393ad2` |
|
|
12
|
+
| npm tarball SHA-256 | `205f5c607c68e1e42b8d7a036326bdb3a105ae55e6469ecfcaf998004609d5f7` |
|
|
13
|
+
| Runtime oracle | `react@19.0.0` / `react-dom@19.0.0` |
|
|
14
|
+
| Type oracle | `@types/react@19.2.17` / `@types/react-dom@19.2.3` |
|
|
15
|
+
| License | MIT, retained byte-exact under both evidence boundaries |
|
|
16
|
+
|
|
17
|
+
`upstream/npm/package/` is the unpacked registry artifact. `upstream/source/`
|
|
18
|
+
contains the canonical runtime source, test suite, JSX loader, TypeScript
|
|
19
|
+
configuration, package metadata, and license from the pinned commit. The npm
|
|
20
|
+
artifact does not publish `test.jsx` or `script/load-jsx.js`, so the canonical
|
|
21
|
+
repository supplies that test boundary. The runtime source and license present
|
|
22
|
+
in both artifacts are byte-identical.
|
|
23
|
+
|
|
24
|
+
Run `pnpm --dir packages/markdown upstream:verify`. The verifier locks all
|
|
25
|
+
16 vendored files, package identity and license, the public API, and all 87
|
|
26
|
+
`test.jsx` registrations. Its negative controls must reject source drift,
|
|
27
|
+
license drift, a renamed upstream test, a removed inventory row, and a missing
|
|
28
|
+
runtime export.
|
|
29
|
+
|
|
30
|
+
## Runtime and type export crosswalk
|
|
31
|
+
|
|
32
|
+
| Upstream export | Octane disposition | Evidence |
|
|
33
|
+
| --- | --- | --- |
|
|
34
|
+
| default `Markdown` | Ported to `src/index.ts` | `tests/conformance/public-types.test.ts`, `tests/conformance/sync.server.test.ts`, `typetests/public-api.test-d.ts` |
|
|
35
|
+
| `MarkdownAsync` | Ported to `src/markdown-async.ts`; sync-render divergence recorded as `react-markdown-async-sync-render` | `tests/async/markdown-async.server.test.ts`, `typetests/public-api.test-d.ts` |
|
|
36
|
+
| `MarkdownHooks` | Ported to `src/markdown-hooks.tsrx` | `tests/hooks/markdown-hooks.test.ts`, `typetests/public-api.test-d.ts` |
|
|
37
|
+
| `defaultUrlTransform` | Ported source-near to `src/url-transform.ts` | `tests/differential/url-transform.test.ts`, `typetests/public-api.test-d.ts` |
|
|
38
|
+
| `AllowElement` | Ported React-free to `src/types.ts` | `typetests/public-api.test-d.ts` |
|
|
39
|
+
| `Components` | Ported with Octane intrinsic/component mappings to `src/types.ts` | `typetests/public-api.test-d.ts`, `tests/differential/processor.test.ts` |
|
|
40
|
+
| `ExtraProps` | Ported React-free to `src/types.ts` | `typetests/public-api.test-d.ts` |
|
|
41
|
+
| `HooksOptions` | Ported with `OctaneNode` fallback to `src/types.ts` | `typetests/public-api.test-d.ts`, `tests/hooks/markdown-hooks.test.ts` |
|
|
42
|
+
| `Options` | Ported React-free to `src/types.ts` | `typetests/public-api.test-d.ts`, `tests/validation.test.ts` |
|
|
43
|
+
| `UrlTransform` | Ported React-free to `src/types.ts` | `typetests/public-api.test-d.ts`, `tests/differential/url-transform.test.ts` |
|
|
44
|
+
|
|
45
|
+
`audit/public-api.json` is the machine-readable boundary and export crosswalk.
|
|
46
|
+
`status.json` agrees that the complete root runtime and public type surface is
|
|
47
|
+
ported.
|
|
48
|
+
|
|
49
|
+
## Test boundary
|
|
50
|
+
|
|
51
|
+
The canonical `test.jsx` contains 87 nested `t.test` registrations. They are
|
|
52
|
+
frozen individually in `audit/test-inventory.json`. The pristine lane executes
|
|
53
|
+
that vendored file byte-for-byte with its vendored JSX loader, Node test,
|
|
54
|
+
React/ReactDOM 19.0.0, and the upstream-declared test dependencies. The 87-entry
|
|
55
|
+
`audit/adapted-case-crosswalk.json` binds every pinned source-line identity to a
|
|
56
|
+
concrete adapted test identity and integrity-locks its assertion source.
|
|
57
|
+
|
|
58
|
+
Every port-authored test under `tests/` has exactly one disposition in
|
|
59
|
+
`audit/test-classifications.json`. Repo-authored type probes live under
|
|
60
|
+
`audit/type-probes/` and `typetests/` and are locked by `audit/type-parity.json`
|
|
61
|
+
with assertion-group inventories and mutation controls.
|
|
62
|
+
|
|
63
|
+
## Architecture and adoption evidence
|
|
64
|
+
|
|
65
|
+
`tests/probes/async-component.*` proves the load-bearing server contract: a
|
|
66
|
+
mapped component typed to return `OctaneNode | Promise<OctaneNode>` is invoked
|
|
67
|
+
through an element descriptor, awaited by `prerender`, preserves nested
|
|
68
|
+
children, and propagates rejection.
|
|
69
|
+
|
|
70
|
+
`tests/adoption/` freezes canonical examples from the pinned documentation and
|
|
71
|
+
an Apache-2.0 public application consumer. Its migration ledger separates
|
|
72
|
+
ordinary React-to-Octane edits from changes specific to this package. Those
|
|
73
|
+
files are classified as Octane-only framework contracts, not React-parity
|
|
74
|
+
evidence.
|
package/package.json
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@octanejs/markdown",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=22.22.2"
|
|
9
|
+
},
|
|
10
|
+
"description": "react-markdown 10.1.0 bindings for Octane",
|
|
11
|
+
"publishConfig": {
|
|
12
|
+
"access": "public"
|
|
13
|
+
},
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/octanejs/octane.git",
|
|
17
|
+
"directory": "packages/markdown"
|
|
18
|
+
},
|
|
19
|
+
"main": "src/index.ts",
|
|
20
|
+
"module": "src/index.ts",
|
|
21
|
+
"types": "src/index.ts",
|
|
22
|
+
"files": [
|
|
23
|
+
"src",
|
|
24
|
+
"LICENSE",
|
|
25
|
+
"README.md",
|
|
26
|
+
"UPSTREAM.md"
|
|
27
|
+
],
|
|
28
|
+
"exports": {
|
|
29
|
+
".": "./src/index.ts"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@types/hast": "^3.0.0",
|
|
33
|
+
"@types/mdast": "^4.0.0",
|
|
34
|
+
"devlop": "^1.0.0",
|
|
35
|
+
"hast-util-to-jsx-runtime": "^2.0.0",
|
|
36
|
+
"html-url-attributes": "^3.0.0",
|
|
37
|
+
"mdast-util-to-hast": "^13.0.0",
|
|
38
|
+
"remark-parse": "^11.0.0",
|
|
39
|
+
"remark-rehype": "^11.0.0",
|
|
40
|
+
"unified": "^11.0.0",
|
|
41
|
+
"unist-util-visit": "^5.0.0",
|
|
42
|
+
"vfile": "^6.0.0"
|
|
43
|
+
},
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"octane": "0.1.35"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@testing-library/react": "16.3.2",
|
|
49
|
+
"@types/node": "^24.13.3",
|
|
50
|
+
"@types/react": "19.2.17",
|
|
51
|
+
"@types/react-dom": "19.2.3",
|
|
52
|
+
"@tsrx/react": "^0.2.56",
|
|
53
|
+
"concat-stream": "^2.0.0",
|
|
54
|
+
"global-jsdom": "^26.0.0",
|
|
55
|
+
"jsdom": "^26.0.0",
|
|
56
|
+
"react": "19.0.0",
|
|
57
|
+
"react-dom": "19.0.0",
|
|
58
|
+
"react-markdown": "10.1.0",
|
|
59
|
+
"rehype-raw": "^7.0.0",
|
|
60
|
+
"rehype-starry-night": "^2.0.0",
|
|
61
|
+
"remark-gfm": "^4.0.1",
|
|
62
|
+
"remark-toc": "^9.0.0",
|
|
63
|
+
"vitest": "^4.1.10",
|
|
64
|
+
"@octanejs/testing-library": "0.1.32",
|
|
65
|
+
"octane": "0.1.35"
|
|
66
|
+
},
|
|
67
|
+
"scripts": {
|
|
68
|
+
"test": "pnpm upstream:verify && pnpm test:pristine && vitest run --config vitest.config.ts --root . tests/probes/async-component.server.test.ts tests/adoption/consumer.test.ts tests/adoption/consumer.server.test.ts tests/audit tests/differential tests/conformance tests/async tests/hooks tests/validation.test.ts",
|
|
69
|
+
"test:architecture": "vitest run --config vitest.config.ts --root . tests/probes/async-component.server.test.ts",
|
|
70
|
+
"test:pristine": "node ../../scripts/react-parity/node-full-runner.mjs --root packages/markdown/upstream/source --file packages/markdown/upstream/source/test.jsx --loader packages/markdown/upstream/source/script/load-jsx.js --inventory packages/markdown/audit/pristine-runtime.json",
|
|
71
|
+
"upstream:verify": "node audit/verify-provenance.mjs --self-test",
|
|
72
|
+
"typecheck": "tsrx-tsc --noEmit -p tsconfig.json && tsrx-tsc --noEmit -p typetests/tsconfig.json"
|
|
73
|
+
}
|
|
74
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ElementDescriptor } from 'octane';
|
|
2
|
+
import { processSync } from './processor';
|
|
3
|
+
import { projectTree } from './project';
|
|
4
|
+
import type { Options } from './types';
|
|
5
|
+
|
|
6
|
+
// OCTANE DIVERGENCE[react-markdown-react-image-preload][react-markdown:differential:image-preload]
|
|
7
|
+
// React 19 server rendering may insert an image preload link; Octane renders the image element only.
|
|
8
|
+
export function Markdown(options: Readonly<Options>): ElementDescriptor {
|
|
9
|
+
return projectTree(processSync(options), options.components);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export { Markdown as default };
|
|
13
|
+
export { MarkdownAsync } from './markdown-async';
|
|
14
|
+
export { MarkdownHooks } from './markdown-hooks.tsrx';
|
|
15
|
+
export { defaultUrlTransform } from './url-transform';
|
|
16
|
+
export type {
|
|
17
|
+
AllowElement,
|
|
18
|
+
Components,
|
|
19
|
+
ExtraProps,
|
|
20
|
+
HooksOptions,
|
|
21
|
+
Options,
|
|
22
|
+
UrlTransform,
|
|
23
|
+
} from './types';
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ElementDescriptor } from 'octane';
|
|
2
|
+
import { processAsync } from './processor';
|
|
3
|
+
import { projectTree } from './project';
|
|
4
|
+
import type { Options } from './types';
|
|
5
|
+
|
|
6
|
+
export async function MarkdownAsync(options: Readonly<Options>): Promise<ElementDescriptor> {
|
|
7
|
+
return projectTree(await processAsync(options), options.components);
|
|
8
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Root } from 'hast';
|
|
2
|
+
import { useEffect, useState, type OctaneNode } from 'octane';
|
|
3
|
+
import { createFile, createProcessor, transformTree } from './processor';
|
|
4
|
+
import { projectTree } from './project';
|
|
5
|
+
import type { HooksOptions } from './types';
|
|
6
|
+
|
|
7
|
+
export function MarkdownHooks(options: Readonly<HooksOptions>): OctaneNode {
|
|
8
|
+
const processor = createProcessor(options);
|
|
9
|
+
const [error, setError] = useState<Error | undefined>(undefined);
|
|
10
|
+
const [tree, setTree] = useState<Root | undefined>(undefined);
|
|
11
|
+
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
let cancelled = false;
|
|
14
|
+
const file = createFile(options);
|
|
15
|
+
|
|
16
|
+
processor.run(processor.parse(file), file, (nextError, nextTree) => {
|
|
17
|
+
if (!cancelled) {
|
|
18
|
+
setError(nextError);
|
|
19
|
+
setTree(nextTree);
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
return () => {
|
|
24
|
+
cancelled = true;
|
|
25
|
+
};
|
|
26
|
+
}, [
|
|
27
|
+
options.children,
|
|
28
|
+
options.rehypePlugins,
|
|
29
|
+
options.remarkPlugins,
|
|
30
|
+
options.remarkRehypeOptions,
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
if (error) throw error;
|
|
34
|
+
|
|
35
|
+
return tree ? projectTree(transformTree(tree, options), options.components) : options.fallback;
|
|
36
|
+
}
|
package/src/processor.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { unreachable } from 'devlop';
|
|
2
|
+
import type { Element, Nodes, Parents, Root } from 'hast';
|
|
3
|
+
import { urlAttributes } from 'html-url-attributes';
|
|
4
|
+
import remarkParse from 'remark-parse';
|
|
5
|
+
import remarkRehype from 'remark-rehype';
|
|
6
|
+
import type { PluggableList } from 'unified';
|
|
7
|
+
import { unified } from 'unified';
|
|
8
|
+
import { visit } from 'unist-util-visit';
|
|
9
|
+
import { VFile } from 'vfile';
|
|
10
|
+
import { isChildrenBlock } from 'octane';
|
|
11
|
+
import type { Options } from './types';
|
|
12
|
+
import { defaultUrlTransform } from './url-transform';
|
|
13
|
+
|
|
14
|
+
const changelog = 'https://github.com/remarkjs/react-markdown/blob/main/changelog.md';
|
|
15
|
+
const emptyPlugins: PluggableList = [];
|
|
16
|
+
const emptyRemarkRehypeOptions = { allowDangerousHtml: true } as const;
|
|
17
|
+
|
|
18
|
+
const deprecations = [
|
|
19
|
+
['astPlugins', 'remove-buggy-html-in-markdown-parser'],
|
|
20
|
+
['allowDangerousHtml', 'remove-buggy-html-in-markdown-parser'],
|
|
21
|
+
['allowNode', 'replace-allownode-allowedtypes-and-disallowedtypes', 'allowElement'],
|
|
22
|
+
['allowedTypes', 'replace-allownode-allowedtypes-and-disallowedtypes', 'allowedElements'],
|
|
23
|
+
['className', 'remove-classname'],
|
|
24
|
+
['disallowedTypes', 'replace-allownode-allowedtypes-and-disallowedtypes', 'disallowedElements'],
|
|
25
|
+
['escapeHtml', 'remove-buggy-html-in-markdown-parser'],
|
|
26
|
+
['includeElementIndex', '#remove-includeelementindex'],
|
|
27
|
+
['includeNodeIndex', 'change-includenodeindex-to-includeelementindex'],
|
|
28
|
+
['linkTarget', 'remove-linktarget'],
|
|
29
|
+
['plugins', 'change-plugins-to-remarkplugins', 'remarkPlugins'],
|
|
30
|
+
['rawSourcePos', '#remove-rawsourcepos'],
|
|
31
|
+
['renderers', 'change-renderers-to-components', 'components'],
|
|
32
|
+
['source', 'change-source-to-children', 'children'],
|
|
33
|
+
['sourcePos', '#remove-sourcepos'],
|
|
34
|
+
['transformImageUri', '#add-urltransform', 'urlTransform'],
|
|
35
|
+
['transformLinkUri', '#add-urltransform', 'urlTransform'],
|
|
36
|
+
] as const;
|
|
37
|
+
|
|
38
|
+
export function createProcessor(options: Readonly<Options>) {
|
|
39
|
+
const remarkRehypeOptions = options.remarkRehypeOptions
|
|
40
|
+
? { ...options.remarkRehypeOptions, ...emptyRemarkRehypeOptions }
|
|
41
|
+
: emptyRemarkRehypeOptions;
|
|
42
|
+
|
|
43
|
+
return unified()
|
|
44
|
+
.use(remarkParse)
|
|
45
|
+
.use(options.remarkPlugins || emptyPlugins)
|
|
46
|
+
.use(remarkRehype, remarkRehypeOptions)
|
|
47
|
+
.use(options.rehypePlugins || emptyPlugins);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function createFile(options: Readonly<Options>): VFile {
|
|
51
|
+
const rawChildren = options.children;
|
|
52
|
+
if (isChildrenBlock(rawChildren)) {
|
|
53
|
+
unreachable(
|
|
54
|
+
'Unexpected compiled children block for `children` prop; use value-position JSX (`return <Markdown>…</Markdown>`) or pass an explicit `children` prop',
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
const children = rawChildren || '';
|
|
58
|
+
const file = new VFile();
|
|
59
|
+
|
|
60
|
+
if (typeof children === 'string') file.value = children;
|
|
61
|
+
else {
|
|
62
|
+
unreachable(
|
|
63
|
+
`Unexpected value \`${String(children)}\` for \`children\` prop, expected \`string\``,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return file;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function processSync(options: Readonly<Options>): Root {
|
|
71
|
+
const processor = createProcessor(options);
|
|
72
|
+
const file = createFile(options);
|
|
73
|
+
return transformTree(processor.runSync(processor.parse(file), file), options);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function processAsync(options: Readonly<Options>): Promise<Root> {
|
|
77
|
+
const processor = createProcessor(options);
|
|
78
|
+
const file = createFile(options);
|
|
79
|
+
return transformTree(await processor.run(processor.parse(file), file), options);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function transformTree<T extends Nodes>(tree: T, options: Readonly<Options>): T {
|
|
83
|
+
const allowedElements = options.allowedElements;
|
|
84
|
+
const allowElement = options.allowElement;
|
|
85
|
+
const disallowedElements = options.disallowedElements;
|
|
86
|
+
const skipHtml = options.skipHtml;
|
|
87
|
+
const unwrapDisallowed = options.unwrapDisallowed;
|
|
88
|
+
const urlTransform = options.urlTransform || defaultUrlTransform;
|
|
89
|
+
const optionRecord = options as Readonly<Record<string, unknown>>;
|
|
90
|
+
|
|
91
|
+
for (const [from, id, to] of deprecations) {
|
|
92
|
+
if (Object.hasOwn(optionRecord, from)) {
|
|
93
|
+
unreachable(
|
|
94
|
+
`Unexpected \`${from}\` prop, ${to ? `use \`${to}\` instead` : 'remove it'} (see <${changelog}#${id}> for more info)`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (allowedElements && disallowedElements) {
|
|
100
|
+
unreachable(
|
|
101
|
+
'Unexpected combined `allowedElements` and `disallowedElements`, expected one or the other',
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
visit(tree, (node, index, parent) => {
|
|
106
|
+
if (node.type === 'raw' && parent && typeof index === 'number') {
|
|
107
|
+
parent.children.splice(
|
|
108
|
+
index,
|
|
109
|
+
1,
|
|
110
|
+
...(skipHtml ? [] : [{ type: 'text', value: node.value } as const]),
|
|
111
|
+
);
|
|
112
|
+
return index;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (node.type !== 'element') return;
|
|
116
|
+
|
|
117
|
+
for (const key in urlAttributes) {
|
|
118
|
+
if (Object.hasOwn(urlAttributes, key) && Object.hasOwn(node.properties, key)) {
|
|
119
|
+
const test = urlAttributes[key];
|
|
120
|
+
if (test === null || test.includes(node.tagName)) {
|
|
121
|
+
const value = node.properties[key];
|
|
122
|
+
node.properties[key] = urlTransform(String(value || ''), key, node);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
let remove = allowedElements
|
|
128
|
+
? !allowedElements.includes(node.tagName)
|
|
129
|
+
: disallowedElements
|
|
130
|
+
? disallowedElements.includes(node.tagName)
|
|
131
|
+
: false;
|
|
132
|
+
|
|
133
|
+
if (!remove && allowElement && typeof index === 'number') {
|
|
134
|
+
remove = !allowElement(node, index, parent as Readonly<Parents> | undefined);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (remove && parent && typeof index === 'number') {
|
|
138
|
+
parent.children.splice(index, 1, ...(unwrapDisallowed ? node.children : []));
|
|
139
|
+
return index;
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
return tree;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export type { Element, Options };
|
package/src/project.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Nodes } from 'hast';
|
|
2
|
+
import { type Components as HastRuntimeComponents, toJsxRuntime } from 'hast-util-to-jsx-runtime';
|
|
3
|
+
import { createElement, type ElementDescriptor, Fragment } from 'octane';
|
|
4
|
+
import type { Components } from './types';
|
|
5
|
+
|
|
6
|
+
type RuntimeElementType = Parameters<typeof createElement>[0];
|
|
7
|
+
type RuntimeProps = Record<string, unknown> & { key?: string | number };
|
|
8
|
+
|
|
9
|
+
function jsx(
|
|
10
|
+
type: RuntimeElementType,
|
|
11
|
+
props: RuntimeProps,
|
|
12
|
+
key?: string | number,
|
|
13
|
+
): ElementDescriptor {
|
|
14
|
+
return createElement(type, key === undefined ? props : { ...props, key });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function projectTree(tree: Nodes, components?: Components | null): ElementDescriptor {
|
|
18
|
+
return toJsxRuntime(tree, {
|
|
19
|
+
Fragment,
|
|
20
|
+
components: components as unknown as Partial<HastRuntimeComponents> | undefined,
|
|
21
|
+
ignoreInvalidStyle: true,
|
|
22
|
+
jsx,
|
|
23
|
+
jsxs: jsx,
|
|
24
|
+
passKeys: true,
|
|
25
|
+
passNode: true,
|
|
26
|
+
}) as ElementDescriptor;
|
|
27
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { Element, Parents } from 'hast';
|
|
2
|
+
import type { ComponentBody, OctaneNode } from 'octane';
|
|
3
|
+
import type { Octane } from 'octane/jsx-runtime';
|
|
4
|
+
import type { Options as RemarkRehypeOptions } from 'remark-rehype';
|
|
5
|
+
import type { PluggableList } from 'unified';
|
|
6
|
+
|
|
7
|
+
export type AllowElement = (
|
|
8
|
+
element: Readonly<Element>,
|
|
9
|
+
index: number,
|
|
10
|
+
parent: Readonly<Parents> | undefined,
|
|
11
|
+
) => boolean | null | undefined;
|
|
12
|
+
|
|
13
|
+
export interface ExtraProps {
|
|
14
|
+
node?: Element | undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type Components = {
|
|
18
|
+
[Key in keyof Octane.JSX.IntrinsicElements]?:
|
|
19
|
+
| ComponentBody<Octane.JSX.IntrinsicElements[Key] & ExtraProps>
|
|
20
|
+
| keyof Octane.JSX.IntrinsicElements;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type UrlTransform = (
|
|
24
|
+
url: string,
|
|
25
|
+
key: string,
|
|
26
|
+
node: Readonly<Element>,
|
|
27
|
+
) => string | null | undefined;
|
|
28
|
+
|
|
29
|
+
export interface Options {
|
|
30
|
+
allowElement?: AllowElement | null | undefined;
|
|
31
|
+
allowedElements?: ReadonlyArray<string> | null | undefined;
|
|
32
|
+
children?: string | null | undefined;
|
|
33
|
+
components?: Components | null | undefined;
|
|
34
|
+
disallowedElements?: ReadonlyArray<string> | null | undefined;
|
|
35
|
+
rehypePlugins?: PluggableList | null | undefined;
|
|
36
|
+
remarkPlugins?: PluggableList | null | undefined;
|
|
37
|
+
remarkRehypeOptions?: Readonly<RemarkRehypeOptions> | null | undefined;
|
|
38
|
+
skipHtml?: boolean | null | undefined;
|
|
39
|
+
unwrapDisallowed?: boolean | null | undefined;
|
|
40
|
+
urlTransform?: UrlTransform | null | undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface HooksOptions extends Options {
|
|
44
|
+
fallback?: OctaneNode | null | undefined;
|
|
45
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const safeProtocol = /^(https?|ircs?|mailto|xmpp)$/i;
|
|
2
|
+
|
|
3
|
+
/** Preserve react-markdown's pinned default URL allowlist. */
|
|
4
|
+
export function defaultUrlTransform(value: string): string {
|
|
5
|
+
const colon = value.indexOf(':');
|
|
6
|
+
const questionMark = value.indexOf('?');
|
|
7
|
+
const numberSign = value.indexOf('#');
|
|
8
|
+
const slash = value.indexOf('/');
|
|
9
|
+
|
|
10
|
+
if (
|
|
11
|
+
colon === -1 ||
|
|
12
|
+
(slash !== -1 && colon > slash) ||
|
|
13
|
+
(questionMark !== -1 && colon > questionMark) ||
|
|
14
|
+
(numberSign !== -1 && colon > numberSign) ||
|
|
15
|
+
safeProtocol.test(value.slice(0, colon))
|
|
16
|
+
) {
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return '';
|
|
21
|
+
}
|