@pterodoc/core 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/LICENCE.md +10 -0
- package/README.md +12 -0
- package/lib/chunks/capture-BhdvHwrN.js +428 -0
- package/lib/chunks/capture-BhdvHwrN.js.map +1 -0
- package/lib/chunks/issues-DBsPh6ek.js +55 -0
- package/lib/chunks/issues-DBsPh6ek.js.map +1 -0
- package/lib/chunks/mime-2SUyOolJ.js +62 -0
- package/lib/chunks/mime-2SUyOolJ.js.map +1 -0
- package/lib/chunks/paths-Bh6UsRI9.js +177 -0
- package/lib/chunks/paths-Bh6UsRI9.js.map +1 -0
- package/lib/config/load.d.ts +123 -0
- package/lib/config/load.d.ts.map +1 -0
- package/lib/config/types.d.ts +136 -0
- package/lib/config/types.d.ts.map +1 -0
- package/lib/errors.d.ts +53 -0
- package/lib/errors.d.ts.map +1 -0
- package/lib/index.d.ts +23 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +807 -0
- package/lib/index.js.map +1 -0
- package/lib/model/capture.d.ts +37 -0
- package/lib/model/capture.d.ts.map +1 -0
- package/lib/model/index.d.ts +13 -0
- package/lib/model/index.d.ts.map +1 -0
- package/lib/model/index.js +7 -0
- package/lib/model/index.js.map +1 -0
- package/lib/model/reader.d.ts +25 -0
- package/lib/model/reader.d.ts.map +1 -0
- package/lib/model/tree.d.ts +81 -0
- package/lib/model/tree.d.ts.map +1 -0
- package/lib/model/types.d.ts +166 -0
- package/lib/model/types.d.ts.map +1 -0
- package/lib/render/admonitions.d.ts +26 -0
- package/lib/render/admonitions.d.ts.map +1 -0
- package/lib/render/blocks.d.ts +39 -0
- package/lib/render/blocks.d.ts.map +1 -0
- package/lib/render/code.d.ts +37 -0
- package/lib/render/code.d.ts.map +1 -0
- package/lib/render/components.d.ts +49 -0
- package/lib/render/components.d.ts.map +1 -0
- package/lib/render/excerpt.d.ts +9 -0
- package/lib/render/excerpt.d.ts.map +1 -0
- package/lib/render/images.d.ts +58 -0
- package/lib/render/images.d.ts.map +1 -0
- package/lib/render/index.d.ts +80 -0
- package/lib/render/index.d.ts.map +1 -0
- package/lib/render/index.js +1336 -0
- package/lib/render/index.js.map +1 -0
- package/lib/render/inline.d.ts +25 -0
- package/lib/render/inline.d.ts.map +1 -0
- package/lib/render/links.d.ts +45 -0
- package/lib/render/links.d.ts.map +1 -0
- package/lib/render/mdx.d.ts +37 -0
- package/lib/render/mdx.d.ts.map +1 -0
- package/lib/render/page.d.ts +85 -0
- package/lib/render/page.d.ts.map +1 -0
- package/lib/render/parse.d.ts +27 -0
- package/lib/render/parse.d.ts.map +1 -0
- package/lib/render/references.d.ts +20 -0
- package/lib/render/references.d.ts.map +1 -0
- package/lib/render/renderers.d.ts +57 -0
- package/lib/render/renderers.d.ts.map +1 -0
- package/lib/render/slug.d.ts +34 -0
- package/lib/render/slug.d.ts.map +1 -0
- package/lib/render/theme.d.ts +62 -0
- package/lib/render/theme.d.ts.map +1 -0
- package/lib/sync/artifacts.d.ts +46 -0
- package/lib/sync/artifacts.d.ts.map +1 -0
- package/lib/sync/plan.d.ts +59 -0
- package/lib/sync/plan.d.ts.map +1 -0
- package/lib/sync/run.d.ts +39 -0
- package/lib/sync/run.d.ts.map +1 -0
- package/lib/target/index.d.ts +8 -0
- package/lib/target/index.d.ts.map +1 -0
- package/lib/target/index.js +2 -0
- package/lib/target/index.js.map +1 -0
- package/lib/target/target.d.ts +152 -0
- package/lib/target/target.d.ts.map +1 -0
- package/lib/util/hash.d.ts +10 -0
- package/lib/util/hash.d.ts.map +1 -0
- package/lib/util/index.d.ts +14 -0
- package/lib/util/index.d.ts.map +1 -0
- package/lib/util/index.js +7 -0
- package/lib/util/index.js.map +1 -0
- package/lib/util/issues.d.ts +52 -0
- package/lib/util/issues.d.ts.map +1 -0
- package/lib/util/mime.d.ts +16 -0
- package/lib/util/mime.d.ts.map +1 -0
- package/lib/util/paths.d.ts +42 -0
- package/lib/util/paths.d.ts.map +1 -0
- package/lib/version.d.ts +6 -0
- package/lib/version.d.ts.map +1 -0
- package/package.json +43 -0
- package/src/config/load.ts +387 -0
- package/src/config/types.ts +137 -0
- package/src/errors.ts +77 -0
- package/src/globals.d.ts +10 -0
- package/src/index.ts +31 -0
- package/src/model/capture.ts +92 -0
- package/src/model/index.ts +25 -0
- package/src/model/reader.ts +37 -0
- package/src/model/tree.ts +416 -0
- package/src/model/types.ts +167 -0
- package/src/render/admonitions.ts +74 -0
- package/src/render/blocks.ts +79 -0
- package/src/render/code.ts +112 -0
- package/src/render/components.ts +230 -0
- package/src/render/excerpt.ts +16 -0
- package/src/render/images.ts +113 -0
- package/src/render/index.ts +129 -0
- package/src/render/inline.ts +62 -0
- package/src/render/links.ts +94 -0
- package/src/render/mdx.ts +178 -0
- package/src/render/page.ts +289 -0
- package/src/render/parse.ts +65 -0
- package/src/render/references.ts +69 -0
- package/src/render/renderers.ts +346 -0
- package/src/render/slug.ts +63 -0
- package/src/render/theme.ts +92 -0
- package/src/sync/artifacts.ts +70 -0
- package/src/sync/plan.ts +69 -0
- package/src/sync/run.ts +641 -0
- package/src/target/index.ts +18 -0
- package/src/target/target.ts +146 -0
- package/src/util/hash.ts +14 -0
- package/src/util/index.ts +23 -0
- package/src/util/issues.ts +86 -0
- package/src/util/mime.ts +47 -0
- package/src/util/paths.ts +101 -0
- package/src/version.ts +31 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finding and resolving the files a document references.
|
|
3
|
+
*
|
|
4
|
+
* Images are found wherever they sit — in a paragraph, behind a reference,
|
|
5
|
+
* inside raw HTML — because the one place they never sit is at the top level
|
|
6
|
+
* of a document, which is where an earlier version of this code looked.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { visit } from 'unist-util-visit';
|
|
12
|
+
import type { Image, Root } from 'mdast';
|
|
13
|
+
import { parseMarkdown, type MarkdownFormat } from './parse';
|
|
14
|
+
import { resolveReferences } from './references';
|
|
15
|
+
import { isAbsoluteUrl } from './links';
|
|
16
|
+
|
|
17
|
+
/** An image a document points at. */
|
|
18
|
+
export interface ImageReference {
|
|
19
|
+
/** The URL exactly as written in the source. */
|
|
20
|
+
url: string;
|
|
21
|
+
/** Alternative text. */
|
|
22
|
+
alt: string;
|
|
23
|
+
/** Title, which becomes a caption. */
|
|
24
|
+
title?: string | undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Where an image URL actually points. */
|
|
28
|
+
export type ResolvedImage =
|
|
29
|
+
| { kind: 'external'; url: string }
|
|
30
|
+
| { kind: 'file'; url: string; file: string }
|
|
31
|
+
| { kind: 'missing'; url: string };
|
|
32
|
+
|
|
33
|
+
/** Where to look when resolving a relative or absolute reference. */
|
|
34
|
+
export interface ImageResolutionContext {
|
|
35
|
+
/** Absolute path of the document's own source file. */
|
|
36
|
+
sourceAbsolutePath: string;
|
|
37
|
+
/** The version's content directory. */
|
|
38
|
+
contentPath: string;
|
|
39
|
+
/** The localised content directory, preferred when the file exists in both. */
|
|
40
|
+
contentPathLocalized: string;
|
|
41
|
+
/** Absolute paths of the site's static directories. */
|
|
42
|
+
staticDirs: string[];
|
|
43
|
+
/** The site's base URL, stripped from absolute references when present. */
|
|
44
|
+
baseUrl: string;
|
|
45
|
+
/** The Docusaurus site directory, for `@site/` references. */
|
|
46
|
+
siteDir: string;
|
|
47
|
+
/** Injected so resolution can be tested without a filesystem. */
|
|
48
|
+
exists?: (file: string) => boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Every image a document references, including ones behind a reference definition. */
|
|
52
|
+
export function collectImages(markdown: string, format: MarkdownFormat = 'md'): ImageReference[] {
|
|
53
|
+
const root: Root = parseMarkdown(markdown, format);
|
|
54
|
+
// Reference-style images become ordinary ones first, so they are not missed.
|
|
55
|
+
resolveReferences(root);
|
|
56
|
+
|
|
57
|
+
const found: ImageReference[] = [];
|
|
58
|
+
const seen = new Set<string>();
|
|
59
|
+
visit(root, 'image', (node: Image) => {
|
|
60
|
+
if (seen.has(node.url)) return;
|
|
61
|
+
seen.add(node.url);
|
|
62
|
+
found.push({
|
|
63
|
+
url: node.url,
|
|
64
|
+
alt: node.alt ?? '',
|
|
65
|
+
...(node.title != null ? { title: node.title } : {}),
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
return found;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Work out which file an image URL points at.
|
|
73
|
+
*
|
|
74
|
+
* The order matches how Docusaurus itself resolves them: an absolute URL is
|
|
75
|
+
* left alone, `@site/` is site-relative, a leading slash is served from a
|
|
76
|
+
* static directory, and anything else is relative to the document — preferring
|
|
77
|
+
* the localised copy when there is one.
|
|
78
|
+
*/
|
|
79
|
+
export function resolveImage(url: string, ctx: ImageResolutionContext): ResolvedImage {
|
|
80
|
+
const exists = ctx.exists ?? ((file: string) => fs.existsSync(file));
|
|
81
|
+
|
|
82
|
+
if (!url || url.startsWith('data:') || isAbsoluteUrl(url)) return { kind: 'external', url };
|
|
83
|
+
|
|
84
|
+
if (url.startsWith('@site/')) {
|
|
85
|
+
const file = path.resolve(ctx.siteDir, url.slice('@site/'.length));
|
|
86
|
+
return exists(file) ? { kind: 'file', url, file } : { kind: 'missing', url };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (url.startsWith('/')) {
|
|
90
|
+
const base = ctx.baseUrl.replace(/\/+$/, '');
|
|
91
|
+
const withoutBase = base && url.startsWith(`${base}/`) ? url.slice(base.length) : url;
|
|
92
|
+
for (const dir of ctx.staticDirs) {
|
|
93
|
+
for (const candidate of [path.join(dir, url), path.join(dir, withoutBase)]) {
|
|
94
|
+
if (exists(candidate)) return { kind: 'file', url, file: candidate };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return { kind: 'missing', url };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const sourceDir = path.dirname(ctx.sourceAbsolutePath);
|
|
101
|
+
const direct = path.resolve(sourceDir, url);
|
|
102
|
+
if (exists(direct)) return { kind: 'file', url, file: direct };
|
|
103
|
+
|
|
104
|
+
// The document may be the unlocalised original while its assets sit beside
|
|
105
|
+
// the translated copy, or the other way round.
|
|
106
|
+
if (ctx.contentPath && ctx.contentPathLocalized && ctx.contentPath !== ctx.contentPathLocalized) {
|
|
107
|
+
const relative = path.relative(ctx.contentPath, direct);
|
|
108
|
+
const twin = path.resolve(ctx.contentPathLocalized, relative);
|
|
109
|
+
if (!relative.startsWith('..') && exists(twin)) return { kind: 'file', url, file: twin };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return { kind: 'missing', url };
|
|
113
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rendering a document: parse, resolve, and serialise to blocks.
|
|
3
|
+
*
|
|
4
|
+
* This layer is pure. It never reaches the network, never loads Docusaurus and
|
|
5
|
+
* knows nothing about the target beyond the URLs a resolver hands back.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import matter from 'gray-matter';
|
|
9
|
+
import { parseMarkdown, detectFormat, type MarkdownFormat } from './parse';
|
|
10
|
+
import { resolveReferences } from './references';
|
|
11
|
+
import { lowerMdx, type UnknownPolicy } from './mdx';
|
|
12
|
+
import { rewriteLinks, type LinkResolver } from './links';
|
|
13
|
+
import { createRenderContext, renderBody, type RenderContext } from './renderers';
|
|
14
|
+
import { createSlugger } from './slug';
|
|
15
|
+
import { excerptFrom } from './excerpt';
|
|
16
|
+
import { DEFAULT_ADMONITION_KEYWORDS } from './admonitions';
|
|
17
|
+
import type { Theme } from './theme';
|
|
18
|
+
import { IssueCollector } from '../util/issues';
|
|
19
|
+
|
|
20
|
+
export { createTheme, DEFAULT_STRINGS } from './theme';
|
|
21
|
+
export type { Theme, Strings, BlockVocabulary } from './theme';
|
|
22
|
+
export { parseMarkdown, detectFormat } from './parse';
|
|
23
|
+
export type { MarkdownFormat } from './parse';
|
|
24
|
+
export { renderBlock, renderBody, createRenderContext } from './renderers';
|
|
25
|
+
export type { RenderContext } from './renderers';
|
|
26
|
+
export { excerptFrom } from './excerpt';
|
|
27
|
+
export { serializeBlock, serializeVoidBlock, serializeAttrs, escapeCode, escapeText, joinBlocks } from './blocks';
|
|
28
|
+
export { renderInline } from './inline';
|
|
29
|
+
export { headingIdFor, createSlugger } from './slug';
|
|
30
|
+
export { resolveReferences } from './references';
|
|
31
|
+
export { rewriteLinks, toInternalPath, isAbsoluteUrl } from './links';
|
|
32
|
+
export type { LinkResolver, ResolvedLink } from './links';
|
|
33
|
+
export { DEFAULT_ADMONITION_KEYWORDS } from './admonitions';
|
|
34
|
+
export { lowerMdx, isTranslatable, KNOWN_COMPONENTS } from './mdx';
|
|
35
|
+
export type { UnknownPolicy } from './mdx';
|
|
36
|
+
export { renderJsxBlock, attribute } from './components';
|
|
37
|
+
export { collectImages, resolveImage } from './images';
|
|
38
|
+
export type { ImageReference, ResolvedImage } from './images';
|
|
39
|
+
export { composePage, DEFAULT_LAYOUT, renderNavigationStub, renderVersionBanner } from './page';
|
|
40
|
+
export type { ComposePageInput, PageLayout, PageLike } from './page';
|
|
41
|
+
|
|
42
|
+
/** What to render, and how. */
|
|
43
|
+
export interface RenderDocInput {
|
|
44
|
+
/** The document body, front matter included or not. */
|
|
45
|
+
markdown: string;
|
|
46
|
+
/** Absolute or site-relative path, used for issue positions and format detection. */
|
|
47
|
+
file?: string;
|
|
48
|
+
/** Permalink of this document, which relative links resolve against. */
|
|
49
|
+
permalink: string;
|
|
50
|
+
/** Which flavour to parse as; detected from `file` when omitted. */
|
|
51
|
+
format?: MarkdownFormat;
|
|
52
|
+
/** Class names and strings. */
|
|
53
|
+
theme: Theme;
|
|
54
|
+
/** Where each link should point. */
|
|
55
|
+
resolveLink?: LinkResolver;
|
|
56
|
+
/** Directive names that mean "admonition" on this site. */
|
|
57
|
+
admonitionKeywords?: Iterable<string>;
|
|
58
|
+
/** Whether Docusaurus keeps heading case when generating anchors. */
|
|
59
|
+
maintainCase?: boolean;
|
|
60
|
+
/** Drop a leading H1 that repeats the page title. */
|
|
61
|
+
dedupeTitle?: boolean;
|
|
62
|
+
/** Uploaded media, keyed by the URL as written in the source. */
|
|
63
|
+
media?: Map<string, { id: number; url: string }>;
|
|
64
|
+
/** What to do about JSX with no translation. */
|
|
65
|
+
onUnknownJsx?: UnknownPolicy;
|
|
66
|
+
/** Collector to record issues into; a fresh one is made when omitted. */
|
|
67
|
+
issues?: IssueCollector;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A rendered document body and what was learned while rendering it. */
|
|
71
|
+
export interface RenderedDoc {
|
|
72
|
+
/** The block markup. */
|
|
73
|
+
body: string;
|
|
74
|
+
/** Tree paths of the pages this document links to. */
|
|
75
|
+
links: Set<string>;
|
|
76
|
+
/** Plain text of the first paragraph, for an excerpt. */
|
|
77
|
+
firstParagraph: string;
|
|
78
|
+
/** Everything worth telling the user about this document. */
|
|
79
|
+
issues: IssueCollector;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Render one document to Gutenberg block markup.
|
|
84
|
+
*/
|
|
85
|
+
export function renderDoc(input: RenderDocInput): RenderedDoc {
|
|
86
|
+
const issues = input.issues ?? new IssueCollector();
|
|
87
|
+
const parsed = matter(input.markdown);
|
|
88
|
+
const source = parsed.content.replace(/\r\n/g, '\n').replace(/^\n+/, '');
|
|
89
|
+
|
|
90
|
+
const format = input.format ?? (input.file ? detectFormat(input.file) : 'md');
|
|
91
|
+
const root = parseMarkdown(source, format);
|
|
92
|
+
|
|
93
|
+
if (format === 'mdx') {
|
|
94
|
+
lowerMdx(root, {
|
|
95
|
+
source,
|
|
96
|
+
file: input.file,
|
|
97
|
+
issues,
|
|
98
|
+
onUnknown: input.onUnknownJsx ?? 'report',
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
resolveReferences(root);
|
|
103
|
+
const links = input.resolveLink
|
|
104
|
+
? rewriteLinks(root, input.permalink, input.resolveLink, issues)
|
|
105
|
+
: new Set<string>();
|
|
106
|
+
|
|
107
|
+
const ctx: RenderContext = createRenderContext({
|
|
108
|
+
theme: input.theme,
|
|
109
|
+
slugger: createSlugger(),
|
|
110
|
+
issues,
|
|
111
|
+
maintainCase: input.maintainCase ?? false,
|
|
112
|
+
admonitionKeywords: new Set(input.admonitionKeywords ?? DEFAULT_ADMONITION_KEYWORDS),
|
|
113
|
+
file: input.file,
|
|
114
|
+
source,
|
|
115
|
+
media: input.media,
|
|
116
|
+
onUnknownJsx: input.onUnknownJsx ?? 'report',
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const { body, firstParagraph } = renderBody(root, ctx, {
|
|
120
|
+
dedupeTitle: input.dedupeTitle ?? true,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
return { body, links, firstParagraph, issues };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Build an excerpt from a description, falling back to the opening paragraph. */
|
|
127
|
+
export function excerptFor(description: string, firstParagraph: string, max = 160): string {
|
|
128
|
+
return excerptFrom(description || firstParagraph || '', max);
|
|
129
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phrasing content to HTML.
|
|
3
|
+
*
|
|
4
|
+
* The whole run of nodes is converted in one pass, so `mdast-util-to-hast`
|
|
5
|
+
* keeps whatever state it needs, and the conversion happens inside a synthetic
|
|
6
|
+
* paragraph so it does not separate the children with newlines the way it does
|
|
7
|
+
* for a root.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { toHast } from 'mdast-util-to-hast';
|
|
11
|
+
import { toHtml } from 'hast-util-to-html';
|
|
12
|
+
import type { PhrasingContent } from 'mdast';
|
|
13
|
+
import type { Element, Node as HastNode, Parent as HastParent, RootContent, Text } from 'hast';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Escape shortcode brackets inside text, leaving tags and attributes alone.
|
|
17
|
+
*
|
|
18
|
+
* Done on the tree rather than on the serialised string: a `>` inside an
|
|
19
|
+
* attribute value would fool any regex that tries to find tag boundaries.
|
|
20
|
+
* A `raw` node carries the entity through serialisation unescaped.
|
|
21
|
+
*/
|
|
22
|
+
export function escapeShortcodesInHast(nodes: RootContent[]): RootContent[] {
|
|
23
|
+
const out: RootContent[] = [];
|
|
24
|
+
for (const node of nodes) {
|
|
25
|
+
if (node.type === 'text' && (node as Text).value.includes('[')) {
|
|
26
|
+
const parts = (node as Text).value.split('[');
|
|
27
|
+
parts.forEach((part, index) => {
|
|
28
|
+
if (index > 0) out.push({ type: 'raw', value: '[' } as unknown as RootContent);
|
|
29
|
+
if (part !== '') out.push({ type: 'text', value: part } as Text);
|
|
30
|
+
});
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if ('children' in node && Array.isArray((node as HastParent).children)) {
|
|
34
|
+
(node as HastParent).children = escapeShortcodesInHast(
|
|
35
|
+
(node as HastParent).children as RootContent[],
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
out.push(node);
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Convert phrasing content to the hast nodes that back it. */
|
|
44
|
+
export function toInlineHast(nodes: PhrasingContent[]): RootContent[] {
|
|
45
|
+
const paragraph = toHast(
|
|
46
|
+
{ type: 'paragraph', children: nodes },
|
|
47
|
+
{ allowDangerousHtml: true },
|
|
48
|
+
) as Element | undefined;
|
|
49
|
+
const children = (paragraph?.children ?? []) as RootContent[];
|
|
50
|
+
return escapeShortcodesInHast(children);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Serialise hast nodes to HTML. */
|
|
54
|
+
export function hastToHtml(nodes: RootContent[] | HastNode): string {
|
|
55
|
+
const tree = Array.isArray(nodes) ? { type: 'root' as const, children: nodes } : nodes;
|
|
56
|
+
return toHtml(tree as never, { allowDangerousHtml: true });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Render phrasing content to HTML. */
|
|
60
|
+
export function renderInline(nodes: PhrasingContent[]): string {
|
|
61
|
+
return hastToHtml(toInlineHast(nodes));
|
|
62
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Link rewriting.
|
|
3
|
+
*
|
|
4
|
+
* A documentation link can be relative to the source file, absolute against
|
|
5
|
+
* the site's base URL, or an anchor. Whichever it is, it has to end up
|
|
6
|
+
* pointing at the right page on the target — or, when the target has no such
|
|
7
|
+
* page, somewhere honest.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { visit } from 'unist-util-visit';
|
|
12
|
+
import type { Link, Root } from 'mdast';
|
|
13
|
+
import type { IssueCollector } from '../util/issues';
|
|
14
|
+
|
|
15
|
+
/** What a link should become. */
|
|
16
|
+
export interface ResolvedLink {
|
|
17
|
+
/** The replacement URL, or null to keep the text and drop the link. */
|
|
18
|
+
href: string | null;
|
|
19
|
+
/** Target page's tree path, when the link points at a published page. */
|
|
20
|
+
path?: string | undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Decides where a link should point. */
|
|
24
|
+
export type LinkResolver = (href: string, fromPermalink: string) => ResolvedLink;
|
|
25
|
+
|
|
26
|
+
/** True for a URL that already names its own scheme or host. */
|
|
27
|
+
export function isAbsoluteUrl(href: string): boolean {
|
|
28
|
+
return /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(href) || href.startsWith('//');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resolve a documentation link to the URL path it refers to.
|
|
33
|
+
*
|
|
34
|
+
* Relative targets are resolved against the linking document's own permalink,
|
|
35
|
+
* which is what makes this work regardless of where the docs are mounted.
|
|
36
|
+
*
|
|
37
|
+
* @param href The link as written.
|
|
38
|
+
* @param fromPermalink Permalink of the document containing the link.
|
|
39
|
+
* @returns The URL path and fragment, or undefined when the link is not internal.
|
|
40
|
+
*/
|
|
41
|
+
export function toInternalPath(
|
|
42
|
+
href: string,
|
|
43
|
+
fromPermalink: string,
|
|
44
|
+
): { urlPath: string; hash: string } | undefined {
|
|
45
|
+
if (!href || href.startsWith('#') || isAbsoluteUrl(href)) return undefined;
|
|
46
|
+
|
|
47
|
+
const hashIndex = href.indexOf('#');
|
|
48
|
+
const hash = hashIndex === -1 ? '' : href.slice(hashIndex);
|
|
49
|
+
const target = hashIndex === -1 ? href : href.slice(0, hashIndex);
|
|
50
|
+
|
|
51
|
+
const withoutExtension = target.replace(/\.mdx?$/, '');
|
|
52
|
+
const base = fromPermalink.endsWith('/') ? fromPermalink : `${fromPermalink}/`;
|
|
53
|
+
|
|
54
|
+
const urlPath = withoutExtension.startsWith('/')
|
|
55
|
+
? path.posix.normalize(withoutExtension)
|
|
56
|
+
: path.posix.normalize(path.posix.join(base, '..', withoutExtension));
|
|
57
|
+
|
|
58
|
+
return { urlPath, hash };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Rewrite every link in a document.
|
|
63
|
+
*
|
|
64
|
+
* @param root The document, modified in place.
|
|
65
|
+
* @param fromPermalink Permalink of the document.
|
|
66
|
+
* @param resolve Where each link should point.
|
|
67
|
+
* @returns Tree paths of the pages this document links to.
|
|
68
|
+
*/
|
|
69
|
+
export function rewriteLinks(
|
|
70
|
+
root: Root,
|
|
71
|
+
fromPermalink: string,
|
|
72
|
+
resolve: LinkResolver,
|
|
73
|
+
_issues?: IssueCollector,
|
|
74
|
+
): Set<string> {
|
|
75
|
+
const linked = new Set<string>();
|
|
76
|
+
|
|
77
|
+
visit(root, 'link', (node: Link, index, parent) => {
|
|
78
|
+
const resolved = resolve(node.url, fromPermalink);
|
|
79
|
+
if (resolved.path !== undefined) linked.add(resolved.path);
|
|
80
|
+
|
|
81
|
+
if (resolved.href === null) {
|
|
82
|
+
// The target is not published, so keep the words and drop the link.
|
|
83
|
+
if (parent && index !== undefined) {
|
|
84
|
+
parent.children.splice(index, 1, ...(node.children as never[]));
|
|
85
|
+
return index;
|
|
86
|
+
}
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
node.url = resolved.href;
|
|
90
|
+
return;
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
return linked;
|
|
94
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lowering MDX into something a Gutenberg page can hold.
|
|
3
|
+
*
|
|
4
|
+
* MDX is JavaScript, and a WordPress page is not. What can be translated is
|
|
5
|
+
* translated by the component table; the rest is reported with its position
|
|
6
|
+
* rather than dropped, because a page that quietly loses a third of its
|
|
7
|
+
* content is worse than one that tells you it did.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { visit } from 'unist-util-visit';
|
|
11
|
+
import type { Root, RootContent } from 'mdast';
|
|
12
|
+
import type { IssueCollector, Severity } from '../util/issues';
|
|
13
|
+
|
|
14
|
+
/** What lowering learned on the way through. */
|
|
15
|
+
export interface MdxLoweringResult {
|
|
16
|
+
/** Bindings from `import X from './y.png'`, so `src={X}` can be resolved. */
|
|
17
|
+
imports: Map<string, string>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** How to treat content that cannot be translated. */
|
|
21
|
+
export type UnknownPolicy = 'report' | 'placeholder' | 'error';
|
|
22
|
+
|
|
23
|
+
/** Elements that are plain HTML and can be carried through as written. */
|
|
24
|
+
const HTML_ELEMENTS = new Set([
|
|
25
|
+
'a', 'abbr', 'b', 'br', 'code', 'div', 'em', 'figcaption', 'figure', 'h1', 'h2', 'h3',
|
|
26
|
+
'h4', 'h5', 'h6', 'hr', 'i', 'img', 'kbd', 'li', 'mark', 'ol', 'p', 'pre', 's', 'samp',
|
|
27
|
+
'small', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr',
|
|
28
|
+
'u', 'ul', 'var',
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
/** Components the renderer knows how to turn into blocks. */
|
|
32
|
+
export const KNOWN_COMPONENTS = new Set([
|
|
33
|
+
'Tabs',
|
|
34
|
+
'TabItem',
|
|
35
|
+
'Details',
|
|
36
|
+
'details',
|
|
37
|
+
'summary',
|
|
38
|
+
'CodeBlock',
|
|
39
|
+
'Admonition',
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
/** A node with a position we can quote from the source. */
|
|
43
|
+
interface Positioned {
|
|
44
|
+
type: string;
|
|
45
|
+
position?: { start: { line: number; column: number; offset?: number }; end: { offset?: number } };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The original text a node was parsed from. */
|
|
49
|
+
function sourceOf(node: Positioned, source: string): string {
|
|
50
|
+
const start = node.position?.start.offset;
|
|
51
|
+
const end = node.position?.end.offset;
|
|
52
|
+
return start !== undefined && end !== undefined ? source.slice(start, end) : '';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Read the import specifiers out of an ESM block, without evaluating it. */
|
|
56
|
+
function readImports(value: string, into: Map<string, string>): void {
|
|
57
|
+
const pattern = /import\s+([A-Za-z_$][\w$]*)\s+from\s+['"]([^'"]+)['"]/g;
|
|
58
|
+
for (const match of value.matchAll(pattern)) into.set(match[1]!, match[2]!);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** True for an expression that holds nothing but a comment. */
|
|
62
|
+
function isCommentOnly(node: { value?: string; data?: { estree?: { body?: unknown[]; comments?: unknown[] } } }): boolean {
|
|
63
|
+
const estree = node.data?.estree;
|
|
64
|
+
if (estree) return (estree.body?.length ?? 0) === 0 && (estree.comments?.length ?? 0) > 0;
|
|
65
|
+
return /^\s*\/[/*]/.test(node.value ?? '');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Replace MDX-only nodes so nothing downstream has to know about them.
|
|
70
|
+
*
|
|
71
|
+
* @param root The document, modified in place.
|
|
72
|
+
* @param ctx Where to report, and what to do about the untranslatable.
|
|
73
|
+
*/
|
|
74
|
+
export function lowerMdx(
|
|
75
|
+
root: Root,
|
|
76
|
+
ctx: {
|
|
77
|
+
source: string;
|
|
78
|
+
file?: string | undefined;
|
|
79
|
+
issues: IssueCollector;
|
|
80
|
+
onUnknown: UnknownPolicy;
|
|
81
|
+
},
|
|
82
|
+
): MdxLoweringResult {
|
|
83
|
+
const imports = new Map<string, string>();
|
|
84
|
+
const severity: Severity = ctx.onUnknown === 'error' ? 'error' : 'warning';
|
|
85
|
+
|
|
86
|
+
const report = (node: Positioned, message: string, code: string): RootContent | undefined => {
|
|
87
|
+
ctx.issues.add({
|
|
88
|
+
code,
|
|
89
|
+
severity,
|
|
90
|
+
message,
|
|
91
|
+
file: ctx.file,
|
|
92
|
+
line: node.position?.start.line,
|
|
93
|
+
column: node.position?.start.column,
|
|
94
|
+
});
|
|
95
|
+
if (ctx.onUnknown !== 'placeholder') return undefined;
|
|
96
|
+
return { type: 'html', value: `<!-- pterodoc: ${message} -->` } as RootContent;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const replacements: { parent: { children: RootContent[] }; index: number; with: RootContent | undefined }[] = [];
|
|
100
|
+
|
|
101
|
+
visit(root, (node, index, parent) => {
|
|
102
|
+
if (!parent || index === undefined) return;
|
|
103
|
+
const typed = node as Positioned;
|
|
104
|
+
|
|
105
|
+
switch (typed.type) {
|
|
106
|
+
case 'mdxjsEsm': {
|
|
107
|
+
readImports((node as { value?: string }).value ?? '', imports);
|
|
108
|
+
replacements.push({ parent: parent as never, index, with: undefined });
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
case 'mdxFlowExpression':
|
|
113
|
+
case 'mdxTextExpression': {
|
|
114
|
+
const expression = node as { value?: string };
|
|
115
|
+
if (isCommentOnly(expression)) {
|
|
116
|
+
replacements.push({ parent: parent as never, index, with: undefined });
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
replacements.push({
|
|
120
|
+
parent: parent as never,
|
|
121
|
+
index,
|
|
122
|
+
with: report(
|
|
123
|
+
typed,
|
|
124
|
+
`An MDX expression ({${(expression.value ?? '').trim().slice(0, 40)}}) has no fixed value outside the site, so it was left out.`,
|
|
125
|
+
'mdx-expression',
|
|
126
|
+
),
|
|
127
|
+
});
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
case 'mdxJsxTextElement': {
|
|
132
|
+
const element = node as { name?: string | null; children?: RootContent[] };
|
|
133
|
+
const name = element.name ?? '';
|
|
134
|
+
if (name && HTML_ELEMENTS.has(name)) {
|
|
135
|
+
// Plain HTML: keep exactly what the author wrote.
|
|
136
|
+
replacements.push({
|
|
137
|
+
parent: parent as never,
|
|
138
|
+
index,
|
|
139
|
+
with: { type: 'html', value: sourceOf(typed, ctx.source) } as RootContent,
|
|
140
|
+
});
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (name === '') {
|
|
144
|
+
// A fragment: keep the children, drop the wrapper.
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
replacements.push({
|
|
148
|
+
parent: parent as never,
|
|
149
|
+
index,
|
|
150
|
+
with: report(
|
|
151
|
+
typed,
|
|
152
|
+
`<${name}> is a React component, which a page cannot run, so it was left out.`,
|
|
153
|
+
'mdx-unknown-component',
|
|
154
|
+
),
|
|
155
|
+
});
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
default:
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// Applied afterwards so the walk is not disturbed by its own edits.
|
|
165
|
+
for (const replacement of replacements.reverse()) {
|
|
166
|
+
if (replacement.with) replacement.parent.children.splice(replacement.index, 1, replacement.with);
|
|
167
|
+
else replacement.parent.children.splice(replacement.index, 1);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return { imports };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** True for an element name pterodoc can translate into blocks. */
|
|
174
|
+
export function isTranslatable(name: string): boolean {
|
|
175
|
+
return KNOWN_COMPONENTS.has(name) || HTML_ELEMENTS.has(name);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export { HTML_ELEMENTS };
|