Skip to content

Commit fdb08c2

Browse files
committed
Refactors plugin structure and modularizes image transformers
Improves maintainability by splitting image extraction logic into dedicated transformer modules and utility helpers. Moves Gatsby API files into a separate directory for clarity. Standardizes transformer interfaces and streamlines AST/image node handling, supporting easier future extensions.
1 parent 3a5bb40 commit fdb08c2

File tree

10 files changed

+257
-164
lines changed

10 files changed

+257
-164
lines changed

src/create-schema-customization.ts renamed to src/gatsby-apis/create-schema-customization.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { CreateSchemaCustomizationArgs } from "gatsby";
2-
import type { RemarkStructuredContentTransformer } from "./types.ts"; // adjust path as needed
2+
import type { RemarkStructuredContentTransformer } from "../utils/types.js";
33

44
interface StructuredContentPluginOptions {
55
transformers?: RemarkStructuredContentTransformer[];
Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { SourceNodesArgs, PluginOptions } from "gatsby";
2-
import { RemarkPluginApi } from "./types";
2+
import { RemarkPluginApi } from "../utils/types";
33

44
export async function sourceNodes(
55
gatsbyArgs: SourceNodesArgs,
@@ -10,7 +10,6 @@ export async function sourceNodes(
1010
createContentDigest,
1111
createNodeId,
1212
getNodesByType,
13+
reporter
1314
} = gatsbyArgs;
14-
15-
// ...
1615
}

src/index.ts

Lines changed: 18 additions & 157 deletions
Original file line numberDiff line numberDiff line change
@@ -1,95 +1,10 @@
11
import { createRemoteFileNode } from "gatsby-source-filesystem";
2-
import { visit, EXIT } from "unist-util-visit";
3-
import type { Node as UnistNode, Parent as UnistParent } from "unist";
2+
import { visit } from "unist-util-visit";
3+
import type { Node as UnistNode } from "unist";
44
import type { Image } from "mdast";
5-
import { RemarkPluginApi, RemarkStructuredContentTransformer, StructuredContentPluginOptions as RemarkStructuredContentPluginOptions, TransformerContext } from "./types";
5+
import { RemarkPluginApi, StructuredContentPluginOptions as RemarkStructuredContentPluginOptions, TransformerContext } from "./utils/types";
6+
import { removeNodeFromMdAST } from "utils";
67

7-
/**
8-
* Extract ALL images from the markdown AST and save them to File nodes.
9-
*/
10-
export function createImageExtractorTransformer(): RemarkStructuredContentTransformer<Image> {
11-
return {
12-
createSchemaCustomization: ({ reporter, actions, schema }) => {
13-
const { createTypes } = actions;
14-
15-
reporter.info("Creating schema customization for createImageExtractorTransformer");
16-
17-
const typeDefs = [
18-
`
19-
type MarkdownRemark implements Node {
20-
embeddedImages: [File] @link(by: "fields.imageExtractedFromMarkdownRemarkId", from: "id")
21-
}
22-
`,
23-
]
24-
25-
createTypes(typeDefs);
26-
},
27-
traverse: (markdownAST, _utils, context) => {
28-
getAllImagesFromMarkdownAST(markdownAST).forEach((imageNode) => {
29-
context.collect(imageNode);
30-
});
31-
},
32-
transform: async (context, { createFileNode }, { markdownNode: markdownRemarkGatsbyNode }) => {
33-
for (const node of context.collected) {
34-
await createFileNode(node, { imageExtractedFromMarkdownRemarkId: markdownRemarkGatsbyNode.id });
35-
}
36-
},
37-
};
38-
}
39-
40-
export type CreateThumbnailImageTransformerOptions = {
41-
keepImageInMdAST?: boolean;
42-
};
43-
44-
/**
45-
* Extract a single "thumbnail" image with special rules, then remove it from the AST.
46-
*/
47-
export function createThumbnailImageTransformer(options?: CreateThumbnailImageTransformerOptions): RemarkStructuredContentTransformer<Image> {
48-
const { keepImageInMdAST } = options || {};
49-
50-
const LINK_FIELD_NAME = "thumbnailImage";
51-
52-
return {
53-
createSchemaCustomization: ({ actions, schema }) => {
54-
const { createTypes } = actions;
55-
const typeDefs = `
56-
type MarkdownRemark implements Node {
57-
${LINK_FIELD_NAME}: File @link(from: "fields.${LINK_FIELD_NAME}", by: "id")
58-
}
59-
`;
60-
createTypes(typeDefs);
61-
},
62-
traverse: (markdownAST, _utils, context) => {
63-
const thumbImgNode = getThumbnailImageOnly(markdownAST);
64-
65-
if (thumbImgNode) {
66-
context.collect(thumbImgNode);
67-
}
68-
},
69-
transform: async (context, { createFileNode, removeNodeFromMdAST }, gatsbyApis) => {
70-
const { markdownNode: markdownRemarkGatsbyNode, actions } = gatsbyApis;
71-
72-
const [thumbMdASTNode] = context.collected;
73-
74-
if (!thumbMdASTNode) {
75-
// No thumbnail image found
76-
return;
77-
}
78-
79-
const { createNodeField } = actions;
80-
81-
const thumbImgGatsbyNode = await createFileNode(thumbMdASTNode);
82-
83-
createNodeField({ node: markdownRemarkGatsbyNode, name: LINK_FIELD_NAME, value: thumbImgGatsbyNode.id });
84-
85-
if (keepImageInMdAST === true) {
86-
// do nothing, keep the node in the AST
87-
} else {
88-
await removeNodeFromMdAST(thumbMdASTNode);
89-
}
90-
},
91-
};
92-
}
938

949
/**
9510
* Main remark plugin entrypoint.
@@ -113,21 +28,22 @@ export default async function remarkStructuredContentPlugin(
11328
const { createNode, createNodeField } = actions;
11429
const { transformers } = pluginOptions;
11530

116-
async function createFileNode(
117-
node: Image,
118-
extraFields: Record<string, unknown> = {}
31+
async function createRemoteFileNodeWithFields(
32+
mdastNode: Image,
33+
extraFields: Record<string, unknown> = {},
34+
parentNodeId?: string
11935
) {
120-
reporter.info(`Saving remote file node for image: ${node.url}`);
36+
reporter.info(`Saving remote file node for image: ${mdastNode.url}`);
12137

12238
const fileNode = await createRemoteFileNode({
123-
url: node.url,
124-
parentNodeId: markdownNode.id,
39+
url: mdastNode.url,
40+
parentNodeId: parentNodeId,
12541
getCache,
12642
createNode,
12743
createNodeId,
12844
});
12945

130-
reporter.info(`Created file node with id: ${fileNode?.id} for image: ${node.url}`);
46+
reporter.info(`Created file node with id: ${fileNode?.id} for image: ${mdastNode.url}`);
13147

13248
for (const [key, value] of Object.entries(extraFields)) {
13349
createNodeField({ node: fileNode, name: key, value });
@@ -136,13 +52,6 @@ export default async function remarkStructuredContentPlugin(
13652
return fileNode;
13753
}
13854

139-
async function removeNodeFromMdAST(node: UnistNode): Promise<void> {
140-
// Simple strategy: blank out the node but keep its place in the tree.
141-
(node as any).type = "html";
142-
(node as any).children = [];
143-
(node as any).value = "";
144-
}
145-
14655
for (const transformer of transformers) {
14756
const context: TransformerContext<any> = {
14857
collected: [],
@@ -156,65 +65,17 @@ export default async function remarkStructuredContentPlugin(
15665

15766
await transformer.transform(
15867
context,
159-
{ createFileNode, removeNodeFromMdAST },
68+
{ createRemoteFileNodeWithFields, removeNodeFromMdAST },
16069
remarkPluginApi,
16170
);
16271
}
16372

16473
return markdownAST;
16574
}
16675

167-
/**
168-
* Helpers
169-
*/
170-
171-
function getAllImagesFromMarkdownAST(markdownAST: UnistNode): Image[] {
172-
const images: Image[] = [];
173-
174-
visit(markdownAST, "image", (node) => {
175-
images.push(node as Image);
176-
});
177-
178-
return images;
179-
}
180-
181-
/// Return an image node only if there is no text content before the image
182-
/// or if the content after the image is only whitespace
183-
function getThumbnailImageOnly(markdownAST: UnistNode): Image | null {
184-
let thumbnailImage: Image | null = null;
185-
186-
visit(
187-
markdownAST,
188-
"image",
189-
(node, index, parent) => {
190-
thumbnailImage = node as Image;
191-
return [EXIT];
192-
if (!parent || typeof index !== "number") {
193-
return;
194-
}
195-
196-
const p = parent as UnistParent & { children: UnistNode[] };
197-
const nodesBefore = p.children.slice(0, index);
198-
const nodesAfter = p.children.slice(index + 1);
199-
200-
const hasTextBefore = nodesBefore.some(
201-
(n: any) => n.type === "text" && typeof n.value === "string" && n.value.trim().length > 0
202-
);
203-
const hasTextAfter = nodesAfter.some(
204-
(n: any) => n.type === "text" && typeof n.value === "string" && n.value.trim().length > 0
205-
);
206-
207-
if (!hasTextBefore && !hasTextAfter) {
208-
thumbnailImage = node as Image;
209-
return [EXIT];
210-
}
211-
}
212-
);
213-
214-
return thumbnailImage;
215-
}
21676

217-
export { sourceNodes } from "./source-nodes";
218-
export { onCreateNode } from "./on-create-node";
219-
export { createSchemaCustomization } from "./create-schema-customization";
220-
export { pluginOptionsSchema } from "./plugin-options-schema";
77+
export { sourceNodes } from "./gatsby-apis/source-nodes";
78+
export { onCreateNode } from "./gatsby-apis/on-create-node";
79+
export { createSchemaCustomization } from "./gatsby-apis/create-schema-customization";
80+
export { pluginOptionsSchema } from "./gatsby-apis/plugin-options-schema";
81+
export * from "./transformers/index";
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { createMarkdownRemarkChildRemoteImageNode, getAllImagesFromMarkdownAST } from "utils";
2+
import { RemarkStructuredContentTransformer } from "utils/types";
3+
import type { Image } from "mdast";
4+
5+
/**
6+
* Extract ALL images from the markdown AST and save them to File nodes.
7+
*/
8+
export function createImageExtractorTransformer(): RemarkStructuredContentTransformer<Image> {
9+
const MarkdownRemarkEmbeddedImage = `MarkdownRemarkEmbeddedImage`
10+
return {
11+
createSchemaCustomization: ({ reporter, actions, schema }) => {
12+
const { createTypes } = actions;
13+
14+
reporter.info("Creating schema customization for createImageExtractorTransformer");
15+
16+
const typeDefs = [
17+
`
18+
type MarkdownRemark implements Node {
19+
id: ID!
20+
}
21+
type ${MarkdownRemarkEmbeddedImage} implements Node @infer @childOf(types: ["MarkdownRemark"]) {
22+
id: ID!
23+
url: String
24+
}
25+
type File implements Node @infer @childOf(types: ["${MarkdownRemarkEmbeddedImage}"]) {
26+
id: ID!
27+
}
28+
`,
29+
]
30+
31+
createTypes(typeDefs);
32+
},
33+
traverse: (markdownAST, _utils, context) => {
34+
getAllImagesFromMarkdownAST(markdownAST).forEach((imageMdastNode) => {
35+
context.collect(imageMdastNode);
36+
});
37+
},
38+
transform: async (context, { createRemoteFileNodeWithFields }, gatsbyApis) => {
39+
for (const imageMdastNode of context.collected) {
40+
const { markdownNode: markdownRemarkGatsbyNode } = gatsbyApis
41+
42+
await createMarkdownRemarkChildRemoteImageNode({
43+
createRemoteFileNodeWithFields: createRemoteFileNodeWithFields,
44+
gatsbyApis: gatsbyApis,
45+
mdastNode: imageMdastNode,
46+
nodeType: MarkdownRemarkEmbeddedImage,
47+
parentNode: markdownRemarkGatsbyNode,
48+
});
49+
}
50+
},
51+
};
52+
}
53+
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import type { Image } from "mdast";
2+
import { createMarkdownRemarkChildRemoteImageNode, getThumbnailImageOnly } from "utils";
3+
import { RemarkStructuredContentTransformer } from "utils/types";
4+
5+
export type CreateThumbnailImageTransformerOptions = {
6+
keepImageInMdAST?: boolean;
7+
};
8+
9+
/**
10+
* Extract a single "thumbnail" image with special rules, then remove it from the AST.
11+
*/
12+
export function createThumbnailImageTransformer(options?: CreateThumbnailImageTransformerOptions): RemarkStructuredContentTransformer<Image> {
13+
const { keepImageInMdAST } = options || {};
14+
15+
const MarkdownRemarkThumbnailType = "MarkdownRemarkThumbnail";
16+
17+
return {
18+
createSchemaCustomization: ({ actions, schema }) => {
19+
const { createTypes } = actions;
20+
const typeDefs = `
21+
type MarkdownRemark implements Node {
22+
id: ID!
23+
}
24+
type ${MarkdownRemarkThumbnailType} implements Node @infer @childOf(types: ["MarkdownRemark"]) {
25+
id: ID!
26+
url: String
27+
}
28+
type File implements Node @infer @childOf(types: ["${MarkdownRemarkThumbnailType}"]) {
29+
id: ID!
30+
}
31+
`;
32+
createTypes(typeDefs);
33+
},
34+
traverse: (markdownAST, _utils, context) => {
35+
const thumbImgNode = getThumbnailImageOnly(markdownAST);
36+
37+
if (thumbImgNode) {
38+
context.collect(thumbImgNode);
39+
}
40+
},
41+
transform: async (context, { createRemoteFileNodeWithFields, removeNodeFromMdAST }, gatsbyApis) => {
42+
const { markdownNode: markdownRemarkGatsbyNode } = gatsbyApis;
43+
44+
const [thumbMdASTNode] = context.collected;
45+
46+
if (!thumbMdASTNode) {
47+
// No thumbnail image found
48+
return;
49+
}
50+
51+
await createMarkdownRemarkChildRemoteImageNode({
52+
createRemoteFileNodeWithFields: createRemoteFileNodeWithFields,
53+
gatsbyApis: gatsbyApis,
54+
mdastNode: thumbMdASTNode,
55+
nodeType: MarkdownRemarkThumbnailType,
56+
parentNode: markdownRemarkGatsbyNode,
57+
});
58+
59+
if (keepImageInMdAST === true) {
60+
// do nothing, keep the node in the AST
61+
} else {
62+
await removeNodeFromMdAST(thumbMdASTNode);
63+
}
64+
},
65+
};
66+
}

src/transformers/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from "./embedded-images-transformer"
2+
export * from "./image-thumbnail-transformer"

0 commit comments

Comments
 (0)