@p-buddy/parkdown 0.0.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/.assets/api-note.md +3 -0
- package/.assets/api.md +34 -0
- package/.assets/authoring.md +69 -0
- package/.assets/code/depopulate.ts +6 -0
- package/.assets/code/inclusions.ts +6 -0
- package/.assets/depopulated.md +25 -0
- package/.assets/invocation.md +16 -0
- package/.assets/populated/block.md +9 -0
- package/.assets/populated/inline.multi.md +5 -0
- package/.assets/populated/inline.single.md +3 -0
- package/.assets/query.md +73 -0
- package/.assets/remap-imports.md +0 -0
- package/.assets/unpopulated/block.md +5 -0
- package/.assets/unpopulated/inline.multi.md +3 -0
- package/.assets/unpopulated/inline.single.md +1 -0
- package/.devcontainer/Dockerfile +16 -0
- package/.devcontainer/devcontainer.json +35 -0
- package/LICENSE +21 -0
- package/README.md +418 -0
- package/dist/cli.js +14 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +396 -0
- package/dist/index.umd.cjs +15 -0
- package/package.json +42 -0
- package/src/api/index.test.ts +32 -0
- package/src/api/index.ts +8 -0
- package/src/api/types.ts +78 -0
- package/src/api/utils.test.ts +132 -0
- package/src/api/utils.ts +161 -0
- package/src/cli.ts +31 -0
- package/src/include.test.ts +369 -0
- package/src/include.ts +252 -0
- package/src/index.ts +35 -0
- package/src/region.test.ts +145 -0
- package/src/region.ts +138 -0
- package/src/remap.test.ts +37 -0
- package/src/remap.ts +72 -0
- package/src/utils.test.ts +238 -0
- package/src/utils.ts +184 -0
- package/src/wrap.ts +61 -0
- package/tsconfig.json +5 -0
- package/vite.cli.config.ts +23 -0
- package/vite.config.ts +20 -0
package/src/include.ts
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { URLSearchParams } from "node:url";
|
|
2
|
+
import { getAllPositionNodes, parse, hasPosition, linkHasNoText, lined, spaced, Html, nodeSort, replaceWithContent, getContentInBetween } from "./utils";
|
|
3
|
+
import { type AstRoot, type Link, type PositionNode, type HasPosition, COMMA_NOT_IN_PARENTHESIS } from "./utils"
|
|
4
|
+
import { dirname, join, basename } from "node:path";
|
|
5
|
+
import { wrap } from "./wrap";
|
|
6
|
+
import { applyRegion, extractContentWithinRegionSpecifiers } from "./region";
|
|
7
|
+
|
|
8
|
+
const specialLinkTargets = ["http", "./", "../"] as const;
|
|
9
|
+
const isSpecialLinkTarget = ({ url }: Link) => specialLinkTargets.some(target => url.startsWith(target));
|
|
10
|
+
|
|
11
|
+
export type SpecialLink = PositionNode<"link">;
|
|
12
|
+
export const isSpecialLink = (node: Link): node is SpecialLink =>
|
|
13
|
+
hasPosition(node) && linkHasNoText(node) && isSpecialLinkTarget(node);
|
|
14
|
+
export const specialLinkText = ({ url }: Pick<SpecialLink, "url">, relative?: string) =>
|
|
15
|
+
`[](${relative ? join(relative, url) : url})` as const;
|
|
16
|
+
|
|
17
|
+
type CommentType = "begin" | "end";
|
|
18
|
+
|
|
19
|
+
export const specialComment = {
|
|
20
|
+
_open: "<!--" as const,
|
|
21
|
+
_close: "-->" as const,
|
|
22
|
+
_flag: "p▼" as const,
|
|
23
|
+
get begin() { return spaced(specialComment._open, specialComment._flag, "BEGIN", specialComment._close) },
|
|
24
|
+
get end() { return spaced(specialComment._open, specialComment._flag, "END", specialComment._close) },
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type SpecialComment<T extends CommentType = CommentType> = PositionNode<"html"> & { value: typeof specialComment[T] };
|
|
28
|
+
|
|
29
|
+
export const isSpecialComment = <T extends CommentType>(type: T) =>
|
|
30
|
+
(node: Html): node is SpecialComment<T> => hasPosition(node) && node.value === specialComment[type];
|
|
31
|
+
|
|
32
|
+
export type ReplacementTarget = {
|
|
33
|
+
url: string;
|
|
34
|
+
headingDepth: number;
|
|
35
|
+
inline: boolean;
|
|
36
|
+
} & HasPosition;
|
|
37
|
+
|
|
38
|
+
const replaceUnpopulated = (
|
|
39
|
+
{ position, url, siblingCount }: SpecialLink, headingDepth: number
|
|
40
|
+
): ReplacementTarget => ({ position, url, headingDepth, inline: siblingCount >= 1 })
|
|
41
|
+
|
|
42
|
+
const replacePopulated = (
|
|
43
|
+
{ position: { start }, url, siblingCount }: SpecialLink, { position: { end } }: SpecialComment<"end">, headingDepth: number
|
|
44
|
+
): ReplacementTarget => ({ position: { start, end }, url, headingDepth, inline: siblingCount >= 1 });
|
|
45
|
+
|
|
46
|
+
export const getReplacementContent = (target: Pick<ReplacementTarget, "url" | "inline">, content: string, relative?: string) =>
|
|
47
|
+
target.inline
|
|
48
|
+
? `${specialLinkText(target, relative)} ${specialComment.begin} ${content} ${specialComment.end}` as const
|
|
49
|
+
: lined(specialLinkText(target, relative), specialComment.begin, content, specialComment.end);
|
|
50
|
+
|
|
51
|
+
export const nodeDepthFinder = (ast: AstRoot) => {
|
|
52
|
+
const headingDepth = getAllPositionNodes(ast, "heading")
|
|
53
|
+
.reduce((acc, { position, depth }) => acc.set(position.start.line, depth), new Map<number, number>())
|
|
54
|
+
return (node: HasPosition) => {
|
|
55
|
+
for (let i = node.position.start.line; i >= 1; i--) {
|
|
56
|
+
const depth = headingDepth.get(i);
|
|
57
|
+
if (depth) return depth;
|
|
58
|
+
}
|
|
59
|
+
return 0;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const error = {
|
|
64
|
+
openingCommentDoesNotFollowLink: ({ position: { start } }: SpecialComment<"begin">) =>
|
|
65
|
+
new Error(`Opening comment (@${start.line}:${start.column}) does not follow link`),
|
|
66
|
+
closingCommentNotMatchedToOpening: ({ position: { start } }: SpecialComment<"end">) =>
|
|
67
|
+
new Error(`Closing comment (@${start.line}:${start.column}) does not match to opening comment`),
|
|
68
|
+
openingCommentNotClosed: ({ position: { start } }: SpecialComment<"begin">) =>
|
|
69
|
+
new Error(`Opening comment (@${start.line}:${start.column}) is not followed by a closing comment`),
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const getTopLevelCommentBlocks = (
|
|
73
|
+
openingComments: SpecialComment<"begin">[], closingComments: SpecialComment<"end">[]
|
|
74
|
+
) => {
|
|
75
|
+
const blocks: { open: SpecialComment<"begin">, close: SpecialComment<"end"> }[] = [];
|
|
76
|
+
|
|
77
|
+
const combined = [
|
|
78
|
+
...openingComments.map(node => ({ node, type: "open" as const })),
|
|
79
|
+
...closingComments.map(node => ({ node, type: "close" as const }))
|
|
80
|
+
].sort((a, b) => nodeSort(a.node, b.node));
|
|
81
|
+
|
|
82
|
+
const stack: (typeof combined[number] & { type: "open" })[] = [];
|
|
83
|
+
|
|
84
|
+
for (const item of combined)
|
|
85
|
+
if (item.type === "open") stack.push(item)
|
|
86
|
+
else {
|
|
87
|
+
const close = item.node as SpecialComment<"end">;
|
|
88
|
+
if (stack.length === 0)
|
|
89
|
+
throw error.closingCommentNotMatchedToOpening(close);
|
|
90
|
+
const open = stack.pop()!.node;
|
|
91
|
+
if (stack.length > 0) continue;
|
|
92
|
+
blocks.push({ open, close });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (stack.length > 0)
|
|
96
|
+
throw error.openingCommentNotClosed(stack[0].node);
|
|
97
|
+
|
|
98
|
+
return blocks;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
type CommentBlocks = ReturnType<typeof getTopLevelCommentBlocks>;
|
|
102
|
+
|
|
103
|
+
export const matchCommentBlocksToLinks = (
|
|
104
|
+
markdown: string, links: SpecialLink[], blocks: CommentBlocks
|
|
105
|
+
) => {
|
|
106
|
+
const linkCandidates = [...links].sort(nodeSort);
|
|
107
|
+
const results: (SpecialLink | [SpecialLink, CommentBlocks[number]])[] = [];
|
|
108
|
+
|
|
109
|
+
[...blocks]
|
|
110
|
+
.sort((a, b) => nodeSort.reverse(a.open, b.open))
|
|
111
|
+
.forEach(block => {
|
|
112
|
+
while (linkCandidates.length > 0) {
|
|
113
|
+
const link = linkCandidates.pop()!;
|
|
114
|
+
if (link.position.start.offset < block.open.position.start.offset) {
|
|
115
|
+
if (getContentInBetween(markdown, link, block.open).trim() !== "")
|
|
116
|
+
throw error.openingCommentDoesNotFollowLink(block.open);
|
|
117
|
+
return results.push([link, block]);
|
|
118
|
+
}
|
|
119
|
+
results.push(link);
|
|
120
|
+
}
|
|
121
|
+
throw error.openingCommentDoesNotFollowLink(block.open);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
results.push(...linkCandidates.reverse());
|
|
125
|
+
return results.reverse();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export const getReplacementTargets = (markdwn: string, ast?: AstRoot): ReplacementTarget[] => {
|
|
129
|
+
ast ??= parse.md(markdwn);
|
|
130
|
+
const findDepth = nodeDepthFinder(ast);
|
|
131
|
+
const specialLinks = getAllPositionNodes(ast, "link").filter(isSpecialLink);
|
|
132
|
+
const htmlNodes = getAllPositionNodes(ast, "html").sort(nodeSort);
|
|
133
|
+
const openingComments = htmlNodes.filter(isSpecialComment("begin"));
|
|
134
|
+
const closingComments = htmlNodes.filter(isSpecialComment("end"));
|
|
135
|
+
const blocks = getTopLevelCommentBlocks(openingComments, closingComments);
|
|
136
|
+
const resolved = matchCommentBlocksToLinks(markdwn, specialLinks, blocks)
|
|
137
|
+
return resolved.map(block => Array.isArray(block)
|
|
138
|
+
? replacePopulated(block[0], block[1].close, findDepth(block[0]))
|
|
139
|
+
: replaceUnpopulated(block, findDepth(block)))
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export type GetRelativePathContent = (path: string) => string;
|
|
143
|
+
|
|
144
|
+
export const extendGetRelativePathContent = (
|
|
145
|
+
getRelativePathContent: GetRelativePathContent, { url }: Pick<ReplacementTarget, "url">
|
|
146
|
+
) => ((path) => getRelativePathContent(join(dirname(url), path))) satisfies GetRelativePathContent;
|
|
147
|
+
|
|
148
|
+
const clampHeadingSum = (...toSum: number[]) => {
|
|
149
|
+
const sum = toSum.reduce((a, b) => a + b, 0);
|
|
150
|
+
return Math.min(Math.max(sum, 1), 6) as 1 | 2 | 3 | 4 | 5 | 6;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export const applyHeadingDepth = (markdown: string, headingDepth: number, ast?: AstRoot) => {
|
|
154
|
+
if (headingDepth === 0) return markdown;
|
|
155
|
+
ast ??= parse.md(markdown);
|
|
156
|
+
const nodes = getAllPositionNodes(ast, "heading");
|
|
157
|
+
const lines = markdown.split("\n");
|
|
158
|
+
for (const node of nodes) {
|
|
159
|
+
const { depth, position: { start, end } } = node;
|
|
160
|
+
const adjusted = clampHeadingSum(depth, headingDepth);
|
|
161
|
+
const text = lines[start.line - 1].slice(depth, end.column);
|
|
162
|
+
const replacement = `#`.repeat(adjusted) + text;
|
|
163
|
+
lines[start.line - 1] = replacement;
|
|
164
|
+
node.depth = adjusted;
|
|
165
|
+
}
|
|
166
|
+
return lines.join("\n");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export const removePopulatedInclusions = (markdown: string) =>
|
|
170
|
+
getReplacementTargets(markdown)
|
|
171
|
+
.reverse()
|
|
172
|
+
.sort(nodeSort.reverse)
|
|
173
|
+
.reduce((md, target) => replaceWithContent(md, specialLinkText(target), target), markdown);
|
|
174
|
+
|
|
175
|
+
export const recursivelyPopulateInclusions = (
|
|
176
|
+
markdown: string,
|
|
177
|
+
headingDepth: number,
|
|
178
|
+
getRelativePathContent: GetRelativePathContent,
|
|
179
|
+
basePath?: string
|
|
180
|
+
) => {
|
|
181
|
+
markdown = removePopulatedInclusions(markdown);
|
|
182
|
+
markdown = applyHeadingDepth(markdown, headingDepth);
|
|
183
|
+
const ast = parse.md(markdown);
|
|
184
|
+
|
|
185
|
+
return getReplacementTargets(markdown, ast)
|
|
186
|
+
.sort(nodeSort)
|
|
187
|
+
.reverse()
|
|
188
|
+
.map(target => {
|
|
189
|
+
const { url, headingDepth } = target;
|
|
190
|
+
const [base, ...splitOnMark] = basename(url).split("?");
|
|
191
|
+
const extension = base.split(".").pop() ?? "";
|
|
192
|
+
const query = splitOnMark.join("?");
|
|
193
|
+
const dir = dirname(url);
|
|
194
|
+
const path = join(dir, base);
|
|
195
|
+
|
|
196
|
+
if (url.startsWith("./") || url.startsWith("../")) {
|
|
197
|
+
let content = getRelativePathContent(path);
|
|
198
|
+
|
|
199
|
+
/** p▼: query */
|
|
200
|
+
const params = new URLSearchParams(query);
|
|
201
|
+
/** p▼: query */
|
|
202
|
+
|
|
203
|
+
/** p▼: query */
|
|
204
|
+
const regions = params.get("region")?.split(COMMA_NOT_IN_PARENTHESIS);
|
|
205
|
+
/** p▼: query */
|
|
206
|
+
content = regions?.reduce((content, region) => applyRegion(content, region), content) ?? content;
|
|
207
|
+
|
|
208
|
+
/** p▼: query */
|
|
209
|
+
const skip = params.has("skip");
|
|
210
|
+
/** p▼: query */
|
|
211
|
+
|
|
212
|
+
/** p▼: query */
|
|
213
|
+
const headingModfiier = params.get("heading") ?? 0;
|
|
214
|
+
/** p▼: query */
|
|
215
|
+
|
|
216
|
+
/** p▼: query */
|
|
217
|
+
const inlineOverride = params.has("inline");
|
|
218
|
+
/** p▼: query */
|
|
219
|
+
|
|
220
|
+
let { inline } = target;
|
|
221
|
+
if (inlineOverride) inline = true;
|
|
222
|
+
|
|
223
|
+
if (!skip)
|
|
224
|
+
/** p▼: Default-Behavior */
|
|
225
|
+
if (extension === "md") {
|
|
226
|
+
/** p▼: ... */
|
|
227
|
+
const getContent = extendGetRelativePathContent(getRelativePathContent, target);
|
|
228
|
+
const relative = basePath ? join(basePath, dir) : dir;
|
|
229
|
+
const depth = clampHeadingSum(headingDepth, Number(headingModfiier));
|
|
230
|
+
/** p▼: ... */
|
|
231
|
+
content = recursivelyPopulateInclusions(content, /** p▼: ... */ depth, getContent, relative /** p▼: ... */);
|
|
232
|
+
}
|
|
233
|
+
else if (/^(js|ts)x?|svelte$/i.test(extension))
|
|
234
|
+
content = wrap(content, "code", /** p▼: ... */ { extension, inline } /** p▼: ... */);
|
|
235
|
+
/** p▼: Default-Behavior */
|
|
236
|
+
|
|
237
|
+
/** p▼: query */
|
|
238
|
+
const wraps = params.get("wrap")?.split(COMMA_NOT_IN_PARENTHESIS);
|
|
239
|
+
/** p▼: query */
|
|
240
|
+
content = wraps
|
|
241
|
+
?.reduce((content, query) => wrap(content, query, { extension, inline }), content)
|
|
242
|
+
?? content;
|
|
243
|
+
|
|
244
|
+
return { target, content: getReplacementContent(target, content, basePath) };
|
|
245
|
+
}
|
|
246
|
+
else if (url.startsWith("http"))
|
|
247
|
+
throw new Error("External web links are not implemented yet");
|
|
248
|
+
else
|
|
249
|
+
throw new Error(`Unsupported link type: ${url}`);
|
|
250
|
+
})
|
|
251
|
+
.reduce((acc, { target, content }) => replaceWithContent(acc, content, target), markdown);
|
|
252
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { recursivelyPopulateInclusions, removePopulatedInclusions } from "./include";
|
|
4
|
+
import { removeQueryParams } from "./utils";
|
|
5
|
+
|
|
6
|
+
const read = (path: string) => readFileSync(path, "utf-8");
|
|
7
|
+
|
|
8
|
+
const tryResolveFile = (file: string) => {
|
|
9
|
+
const path = resolve(file);
|
|
10
|
+
const dir = dirname(path);
|
|
11
|
+
const markdown = read(path);
|
|
12
|
+
return { markdown, dir, path };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const populateMarkdownInclusions = (file: string, writeFile = true) => {
|
|
16
|
+
const { dir, path, markdown } = tryResolveFile(file);
|
|
17
|
+
const getContent = (relative: string) => read(resolve(dir, removeQueryParams(relative)));
|
|
18
|
+
const result = recursivelyPopulateInclusions(markdown, 0, getContent);
|
|
19
|
+
if (writeFile) writeFileSync(path, result);
|
|
20
|
+
return result;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const depopulateMarkdownInclusions = (file: string, writeFile = true) => {
|
|
24
|
+
const { path, markdown } = tryResolveFile(file);
|
|
25
|
+
const result = removePopulatedInclusions(markdown);
|
|
26
|
+
if (writeFile) writeFileSync(path, result);
|
|
27
|
+
return result;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const remapImportSpecifiers = (file: string, writeFile = true) => {
|
|
31
|
+
const { path, markdown } = tryResolveFile(file);
|
|
32
|
+
// const result = remapImports(markdown);
|
|
33
|
+
// if (writeFile) writeFileSync(path, result);
|
|
34
|
+
// return result;
|
|
35
|
+
};
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { dedent } from "ts-dedent";
|
|
3
|
+
import { extractContentWithinRegionSpecifiers, removeContentWithinRegionSpecifiers, replaceContentWithinRegionSpecifier } from "./region";
|
|
4
|
+
|
|
5
|
+
describe(extractContentWithinRegionSpecifiers.name, () => {
|
|
6
|
+
test("basic", () => {
|
|
7
|
+
const code = dedent`
|
|
8
|
+
/* id-1 */
|
|
9
|
+
This content should be extracted
|
|
10
|
+
/* id-1 */
|
|
11
|
+
|
|
12
|
+
This content should not be extracted
|
|
13
|
+
`;
|
|
14
|
+
const result = extractContentWithinRegionSpecifiers(code, "id-1");
|
|
15
|
+
expect(result).toEqual("This content should be extracted");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("nested 1", () => {
|
|
19
|
+
const code = dedent`
|
|
20
|
+
/* id-1 */
|
|
21
|
+
This content should be extracted
|
|
22
|
+
/* id-2 */ This content should also be extracted /* id-2 */
|
|
23
|
+
/* id-1 */
|
|
24
|
+
|
|
25
|
+
This content should not be extracted
|
|
26
|
+
`;
|
|
27
|
+
|
|
28
|
+
expect(extractContentWithinRegionSpecifiers(code, "id-1"))
|
|
29
|
+
.toEqual("This content should be extracted\n/* id-2 */ This content should also be extracted /* id-2 */");
|
|
30
|
+
|
|
31
|
+
expect(extractContentWithinRegionSpecifiers(code, "id-1", "id-2"))
|
|
32
|
+
.toEqual("This content should be extracted\nThis content should also be extracted");
|
|
33
|
+
|
|
34
|
+
expect(extractContentWithinRegionSpecifiers(code, "id-2"))
|
|
35
|
+
.toEqual("This content should also be extracted");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("nested 2", () => {
|
|
39
|
+
const code = dedent`
|
|
40
|
+
/* id-1 */
|
|
41
|
+
This content should be extracted
|
|
42
|
+
/* id-2 */
|
|
43
|
+
This content should also be extracted
|
|
44
|
+
/* id-2 */
|
|
45
|
+
/* id-1 */
|
|
46
|
+
|
|
47
|
+
This content should not be extracted
|
|
48
|
+
`;
|
|
49
|
+
expect(extractContentWithinRegionSpecifiers(code, "id-1"))
|
|
50
|
+
.toEqual("This content should be extracted\n/* id-2 */\nThis content should also be extracted\n/* id-2 */");
|
|
51
|
+
|
|
52
|
+
// NOTE: Nested full-line comments create extra newlines
|
|
53
|
+
expect(extractContentWithinRegionSpecifiers(code, "id-1", "id-2"))
|
|
54
|
+
.toEqual("This content should be extracted\nThis content should also be extracted");
|
|
55
|
+
|
|
56
|
+
expect(extractContentWithinRegionSpecifiers(code, "id-2"))
|
|
57
|
+
.toEqual("This content should also be extracted");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("mixed line and in-line", () => {
|
|
61
|
+
const code = dedent`
|
|
62
|
+
/* id */
|
|
63
|
+
const definitions = [
|
|
64
|
+
"hello",
|
|
65
|
+
"world",
|
|
66
|
+
] /* id */ satisfies string[];`;
|
|
67
|
+
|
|
68
|
+
expect(extractContentWithinRegionSpecifiers(code, "id"))
|
|
69
|
+
.toEqual(dedent`
|
|
70
|
+
const definitions = [
|
|
71
|
+
"hello",
|
|
72
|
+
"world",
|
|
73
|
+
]`
|
|
74
|
+
);
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test("split", () => {
|
|
78
|
+
const code = dedent`
|
|
79
|
+
/* id */
|
|
80
|
+
hello,
|
|
81
|
+
/* id */
|
|
82
|
+
|
|
83
|
+
/* id */
|
|
84
|
+
world
|
|
85
|
+
/* id */
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
/* id */
|
|
89
|
+
!
|
|
90
|
+
/* id */
|
|
91
|
+
`;
|
|
92
|
+
|
|
93
|
+
expect(extractContentWithinRegionSpecifiers(code, "id"))
|
|
94
|
+
.toEqual(dedent`
|
|
95
|
+
hello,
|
|
96
|
+
world
|
|
97
|
+
!`
|
|
98
|
+
);
|
|
99
|
+
})
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe(removeContentWithinRegionSpecifiers.name, () => {
|
|
103
|
+
test("basic", () => {
|
|
104
|
+
const codes = [
|
|
105
|
+
dedent`
|
|
106
|
+
hello
|
|
107
|
+
/* id-1 */
|
|
108
|
+
This content should be removed
|
|
109
|
+
/* id-1 */
|
|
110
|
+
world
|
|
111
|
+
`,
|
|
112
|
+
dedent`
|
|
113
|
+
hello
|
|
114
|
+
/* id-1 */ This content should be removed /* id-1 */
|
|
115
|
+
world
|
|
116
|
+
`
|
|
117
|
+
];
|
|
118
|
+
const expected = "hello\nworld";
|
|
119
|
+
for (const code of codes) {
|
|
120
|
+
const result = removeContentWithinRegionSpecifiers(code, "id-1");
|
|
121
|
+
expect(result).toEqual(expected);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe(replaceContentWithinRegionSpecifier.name, () => {
|
|
128
|
+
test("basic", () => {
|
|
129
|
+
const code = dedent`
|
|
130
|
+
/* id */
|
|
131
|
+
hello
|
|
132
|
+
/* id */
|
|
133
|
+
`;
|
|
134
|
+
const result = replaceContentWithinRegionSpecifier(code, "id", "world");
|
|
135
|
+
expect(result).toEqual("world");
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test("inline", () => {
|
|
139
|
+
const code = dedent`
|
|
140
|
+
func('hello', 'world', /* ... */ 'ignored', /* ... */)
|
|
141
|
+
`;
|
|
142
|
+
const result = replaceContentWithinRegionSpecifier(code, "...");
|
|
143
|
+
expect(result).toEqual("func('hello', 'world', ...)");
|
|
144
|
+
})
|
|
145
|
+
})
|
package/src/region.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { dedent } from "ts-dedent";
|
|
2
|
+
import _extractComments from "extract-comments";
|
|
3
|
+
import { Intervals, sanitize } from "./utils"
|
|
4
|
+
import { createParser, numberedParameters, type MethodDefinition } from "./api/";
|
|
5
|
+
|
|
6
|
+
/** p▼: definition */
|
|
7
|
+
const definitions = [
|
|
8
|
+
"extract(id: string, 0?: string, 1?: string, 2?: string)",
|
|
9
|
+
"remove(id: string, 0?: string, 1?: string, 2?: string)",
|
|
10
|
+
"replace(id: string, with?: string, space?: string)",
|
|
11
|
+
] /** p▼: definition */ satisfies MethodDefinition[];
|
|
12
|
+
|
|
13
|
+
const parse = createParser(definitions);
|
|
14
|
+
|
|
15
|
+
type ExtractedComment = {
|
|
16
|
+
type: 'BlockComment' | 'LineComment',
|
|
17
|
+
value: string,
|
|
18
|
+
range: [number, number],
|
|
19
|
+
loc: {
|
|
20
|
+
start: { line: number, column: number },
|
|
21
|
+
end: { line: number, column: number },
|
|
22
|
+
},
|
|
23
|
+
raw: string,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const extractComments = (content: string) => (_extractComments as any)(content) as ExtractedComment[];
|
|
27
|
+
|
|
28
|
+
const getMatchingComments = (content: string, specifier: string) => extractComments(content)
|
|
29
|
+
.filter(({ value }) => value.includes(specifier))
|
|
30
|
+
.sort((a, b) => a.range[0] - b.range[0]);
|
|
31
|
+
|
|
32
|
+
export const extractContentWithinRegionSpecifiers = (content: string, ...specifiers: string[]) => {
|
|
33
|
+
if (specifiers.length === 0) return content;
|
|
34
|
+
|
|
35
|
+
const slice = ([start, end]: ExtractedComment["range"]) => content.slice(start, end);
|
|
36
|
+
|
|
37
|
+
const comments = extractComments(content);
|
|
38
|
+
|
|
39
|
+
const extraction = new Intervals();
|
|
40
|
+
const markers = new Intervals();
|
|
41
|
+
|
|
42
|
+
for (const specifier of specifiers) {
|
|
43
|
+
const matching = comments
|
|
44
|
+
.filter(({ value }) => value.includes(specifier))
|
|
45
|
+
.sort((a, b) => a.range[0] - b.range[0]);
|
|
46
|
+
|
|
47
|
+
for (let i = 0; i < matching.length - 1; i += 2) {
|
|
48
|
+
const open = matching[i];
|
|
49
|
+
const close = matching[i + 1];
|
|
50
|
+
extraction.push(open.range[1], close.range[0]);
|
|
51
|
+
const [start, ...rest] = slice([open.range[1], close.range[0]]);
|
|
52
|
+
const last = rest[rest.length - 1];
|
|
53
|
+
markers.push(open.range[0], open.range[1] + (Boolean(start.trim()) ? 0 : 1));
|
|
54
|
+
markers.push(close.range[0], close.range[1] + (Boolean(last.trim()) ? 0 : 1));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
extraction.collapse();
|
|
59
|
+
markers.collapse();
|
|
60
|
+
|
|
61
|
+
return dedent(
|
|
62
|
+
extraction.subtract(markers).map(slice).filter(Boolean).join("")
|
|
63
|
+
).trim();
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export const removeContentWithinRegionSpecifiers = (content: string, ...specifiers: string[]) => {
|
|
67
|
+
if (specifiers.length === 0) return content;
|
|
68
|
+
|
|
69
|
+
const slice = ([start, end]: ExtractedComment["range"]) => content.slice(start, end);
|
|
70
|
+
const comments = extractComments(content);
|
|
71
|
+
|
|
72
|
+
const markers = new Intervals();
|
|
73
|
+
|
|
74
|
+
for (const specifier of specifiers) {
|
|
75
|
+
const matching = comments
|
|
76
|
+
.filter(({ value }) => value.includes(specifier))
|
|
77
|
+
.sort((a, b) => a.range[0] - b.range[0]);
|
|
78
|
+
|
|
79
|
+
for (let i = 0; i < matching.length - 1; i += 2) {
|
|
80
|
+
const open = matching[i];
|
|
81
|
+
const close = matching[i + 1];
|
|
82
|
+
const end = slice([close.range[1], close.range[1] + 1]).at(-1);
|
|
83
|
+
markers.push(open.range[0], end === "\n" ? close.range[1] + 1 : close.range[1]);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
markers.collapse();
|
|
88
|
+
|
|
89
|
+
const fullContent = new Intervals();
|
|
90
|
+
fullContent.push(0, content.length);
|
|
91
|
+
fullContent.subtract(markers);
|
|
92
|
+
|
|
93
|
+
return dedent(
|
|
94
|
+
fullContent.collapse().map(slice).filter(Boolean).join("")
|
|
95
|
+
).trim();
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export const replaceContentWithinRegionSpecifier = (content: string, specifier: string, replacement?: string, space?: string) => {
|
|
99
|
+
if (!specifier) return content;
|
|
100
|
+
|
|
101
|
+
const matching = getMatchingComments(content, specifier);
|
|
102
|
+
|
|
103
|
+
if (matching.length < 2) return content;
|
|
104
|
+
|
|
105
|
+
let result = '';
|
|
106
|
+
let lastEnd = 0;
|
|
107
|
+
for (let i = 0; i < matching.length - 1; i += 2) {
|
|
108
|
+
const open = matching[i];
|
|
109
|
+
const close = matching[i + 1];
|
|
110
|
+
result += content.slice(lastEnd, open.range[1]);
|
|
111
|
+
result += sanitize(replacement ?? open.value, space);
|
|
112
|
+
lastEnd = close.range[0];
|
|
113
|
+
}
|
|
114
|
+
result += content.slice(lastEnd);
|
|
115
|
+
|
|
116
|
+
const fullContent = new Intervals();
|
|
117
|
+
fullContent.push(0, result.length);
|
|
118
|
+
fullContent.subtract(
|
|
119
|
+
getMatchingComments(result, specifier)
|
|
120
|
+
.reduce((acc, { range }) => (acc.push(...range), acc), new Intervals())
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
return dedent(
|
|
124
|
+
fullContent.collapse().map(([start, end]) => result.slice(start, end)).filter(Boolean).join("")
|
|
125
|
+
).trim();;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
export const applyRegion = (content: string, query: string) => {
|
|
129
|
+
const result = parse(query);
|
|
130
|
+
switch (result.name) {
|
|
131
|
+
case "extract":
|
|
132
|
+
return extractContentWithinRegionSpecifiers(content, result.id, ...numberedParameters(result));
|
|
133
|
+
case "remove":
|
|
134
|
+
return removeContentWithinRegionSpecifiers(content, result.id, ...numberedParameters(result));
|
|
135
|
+
case "replace":
|
|
136
|
+
return replaceContentWithinRegionSpecifier(content, result.id, result.with, result.space);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { dedent } from "ts-dedent";
|
|
3
|
+
import * as regexp from '@flex-development/import-regex';
|
|
4
|
+
describe("remapImports", () => {
|
|
5
|
+
test("should remap imports", () => {
|
|
6
|
+
const code = dedent`
|
|
7
|
+
import { foo } from "bar";
|
|
8
|
+
import { baz, type Qux } from "qux";
|
|
9
|
+
import type { Foo as Bar } from "bar";
|
|
10
|
+
|
|
11
|
+
const result = () => {
|
|
12
|
+
}
|
|
13
|
+
`;
|
|
14
|
+
|
|
15
|
+
let value = code;
|
|
16
|
+
|
|
17
|
+
[...code.matchAll(regexp.STATIC_IMPORT_REGEX)]
|
|
18
|
+
.sort((a, b) => a.index - b.index)
|
|
19
|
+
.reverse()
|
|
20
|
+
.forEach((match) => {
|
|
21
|
+
const { index, groups } = match;
|
|
22
|
+
if (!groups) return;
|
|
23
|
+
const { type, imports, specifier } = groups;
|
|
24
|
+
const remapped = ["import", type, imports, "from", "dingo"].filter(Boolean).join(" ");
|
|
25
|
+
value = value.slice(0, index) + remapped + value.slice(index + match[0].length);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
/*
|
|
29
|
+
const result = code.replace(regexp.STATIC_IMPORT_REGEX, (match) => {
|
|
30
|
+
const executed = regexp.STATIC_IMPORT_REGEX.exec(code);
|
|
31
|
+
console.log(executed);
|
|
32
|
+
const { type, imports, specifier } = executed?.groups ?? {};
|
|
33
|
+
return `${type ? "import type" : "import"} ${imports} from "${specifier}"`;
|
|
34
|
+
}); */
|
|
35
|
+
|
|
36
|
+
})
|
|
37
|
+
})
|
package/src/remap.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { getAllPositionNodes, nodeSort, parse, replaceWithContent } from "./utils";
|
|
2
|
+
import * as regexp from '@flex-development/import-regex';
|
|
3
|
+
|
|
4
|
+
export const paramaterized = {
|
|
5
|
+
local: "$local",
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
type FromTo = {
|
|
9
|
+
raw?: Partial<Record<string, string>>;
|
|
10
|
+
paramaterized?: Partial<Record<"$local", string>>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const localFilePrefixes = ["./", "../", "$" /** for aliases (will this have unintended consequences?) */];
|
|
14
|
+
const isLocalSpecifier = (specifier: string) => localFilePrefixes.some(prefix => specifier.startsWith(prefix));
|
|
15
|
+
|
|
16
|
+
export const remapImports = (markdown: string, { raw, paramaterized }: FromTo) => {
|
|
17
|
+
const ast = parse.md(markdown);
|
|
18
|
+
const code = getAllPositionNodes(ast, "code").sort(nodeSort);
|
|
19
|
+
|
|
20
|
+
const tryRemapSpecifier = (specifier: string) => {
|
|
21
|
+
if (paramaterized?.$local && isLocalSpecifier(specifier)) return paramaterized.$local;
|
|
22
|
+
if (raw?.[specifier]) return raw[specifier];
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
for (const node of code.reverse()) {
|
|
27
|
+
let { value, lang } = node;
|
|
28
|
+
switch (lang) {
|
|
29
|
+
case "ts":
|
|
30
|
+
case "js":
|
|
31
|
+
case "tsx":
|
|
32
|
+
case "jsx":
|
|
33
|
+
value = remapJsTsImports(value, tryRemapSpecifier);
|
|
34
|
+
break;
|
|
35
|
+
case "svelte":
|
|
36
|
+
value = remapSvelteImports(value, tryRemapSpecifier);
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
markdown = replaceWithContent(markdown, value, node);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return markdown;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type TryRemapSpecifier = (specifier: string) => string | null;
|
|
47
|
+
|
|
48
|
+
export const remapJsTsImports = (code: string, remapSpecifier: TryRemapSpecifier) =>
|
|
49
|
+
[...code.matchAll(regexp.STATIC_IMPORT_REGEX)]
|
|
50
|
+
.sort((a, b) => a.index - b.index)
|
|
51
|
+
.reverse()
|
|
52
|
+
.reduce((acc, match) => {
|
|
53
|
+
const { index, groups } = match;
|
|
54
|
+
if (!groups) return acc;
|
|
55
|
+
const specifier = remapSpecifier(groups.specifier);
|
|
56
|
+
if (!specifier) return acc;
|
|
57
|
+
const { type, imports } = groups;
|
|
58
|
+
const remapped = ["import", type, imports, "from", `"${specifier}"`].filter(Boolean).join(" ");
|
|
59
|
+
return acc.slice(0, index) + remapped + acc.slice(index + match[0].length);
|
|
60
|
+
}, code);
|
|
61
|
+
|
|
62
|
+
const scripTagRegex = () =>
|
|
63
|
+
// Match a script tag with any attributes, capturing the content between opening and closing tags
|
|
64
|
+
// <script[^>]*> - Matches opening script tag with any attributes
|
|
65
|
+
// ([\s\S]*?) - Captures all content (including newlines) between tags (non-greedy)
|
|
66
|
+
// <\/script> - Matches closing script tag
|
|
67
|
+
// g - Global flag to match all occurrences
|
|
68
|
+
/<script[^>]*>([\s\S]*?)<\/script>/g;
|
|
69
|
+
|
|
70
|
+
export const remapSvelteImports = (code: string, remapSpecifier: TryRemapSpecifier) =>
|
|
71
|
+
code.replace(scripTagRegex(), (scriptTag, scriptContent) =>
|
|
72
|
+
scriptTag.replace(scriptContent, remapJsTsImports(scriptContent, remapSpecifier)));
|