@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.
Files changed (43) hide show
  1. package/.assets/api-note.md +3 -0
  2. package/.assets/api.md +34 -0
  3. package/.assets/authoring.md +69 -0
  4. package/.assets/code/depopulate.ts +6 -0
  5. package/.assets/code/inclusions.ts +6 -0
  6. package/.assets/depopulated.md +25 -0
  7. package/.assets/invocation.md +16 -0
  8. package/.assets/populated/block.md +9 -0
  9. package/.assets/populated/inline.multi.md +5 -0
  10. package/.assets/populated/inline.single.md +3 -0
  11. package/.assets/query.md +73 -0
  12. package/.assets/remap-imports.md +0 -0
  13. package/.assets/unpopulated/block.md +5 -0
  14. package/.assets/unpopulated/inline.multi.md +3 -0
  15. package/.assets/unpopulated/inline.single.md +1 -0
  16. package/.devcontainer/Dockerfile +16 -0
  17. package/.devcontainer/devcontainer.json +35 -0
  18. package/LICENSE +21 -0
  19. package/README.md +418 -0
  20. package/dist/cli.js +14 -0
  21. package/dist/index.d.ts +7 -0
  22. package/dist/index.js +396 -0
  23. package/dist/index.umd.cjs +15 -0
  24. package/package.json +42 -0
  25. package/src/api/index.test.ts +32 -0
  26. package/src/api/index.ts +8 -0
  27. package/src/api/types.ts +78 -0
  28. package/src/api/utils.test.ts +132 -0
  29. package/src/api/utils.ts +161 -0
  30. package/src/cli.ts +31 -0
  31. package/src/include.test.ts +369 -0
  32. package/src/include.ts +252 -0
  33. package/src/index.ts +35 -0
  34. package/src/region.test.ts +145 -0
  35. package/src/region.ts +138 -0
  36. package/src/remap.test.ts +37 -0
  37. package/src/remap.ts +72 -0
  38. package/src/utils.test.ts +238 -0
  39. package/src/utils.ts +184 -0
  40. package/src/wrap.ts +61 -0
  41. package/tsconfig.json +5 -0
  42. package/vite.cli.config.ts +23 -0
  43. 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)));