Skip to content

Commit df66ab2

Browse files
authored
Find and preprocess markdown links with mustache replacements (#2638)
* Find and preprocess markdown links with mustache replacements * Add tests for UsePropertyReplacment revision * Add test validating url encoding of link content only * Fix whitespace * Revise regex to match and ignore whitespace between blocks * Add tests to validate revised regular expression behavior
1 parent c720b4c commit df66ab2

4 files changed

Lines changed: 182 additions & 2 deletions

File tree

package-lock.json

Lines changed: 49 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@
128128
"@playwright/test": "^1.49.1",
129129
"@testing-library/jest-dom": "^6.4.6",
130130
"@testing-library/react": "^12.1.2",
131+
"@testing-library/react-hooks": "^8.0.1",
131132
"@types/node": "^22.10.5",
132133
"@vitejs/plugin-react-swc": "^3.7.1",
133134
"@vitest/coverage-v8": "^3.1.4",

src/hooks/UsePropertyReplacement/UsePropertyReplacement.js

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ const usePropertyReplacement = (content, properties, allowPropertyReplacement =
1212

1313
useEffect(() => {
1414
if (content && allowPropertyReplacement) {
15+
// Preprocess markdown links that contain mustache replacements in the URL part
16+
if (hasMarkdownLinkMustacheCharacters(content)) {
17+
content = preProcessMarkdownLinks(content, properties);
18+
}
19+
1520
setReplacedContent(replacePropertyTags(content, properties));
1621
} else {
1722
setReplacedContent(content);
@@ -21,12 +26,37 @@ const usePropertyReplacement = (content, properties, allowPropertyReplacement =
2126
return replacedContent;
2227
};
2328

24-
export const replacePropertyTags = (content, properties, errOnMissing = false) => {
29+
// Match and process markdown links that contain mustache {{...}} replacements in the URL part
30+
function preProcessMarkdownLinks(content, properties) {
31+
const urlRegex = /\]\s*\(([^)]*\{\{[^}]*\}\}[^)]*)\)/g;
32+
33+
return content.replace(urlRegex, (match, url) => {
34+
// Get the part before the opening parenthesis
35+
const beforeParen = match.substring(0, match.indexOf('('));
36+
// Transform the URL
37+
const replacedContent = replacePropertyTags(url, properties, false, true);
38+
39+
// Return the unchanged part + transformed URL in parentheses
40+
return `](${replacedContent})`;
41+
});
42+
}
43+
44+
// Lightweight check for markdown links plausibly requiring preprocessing
45+
const hasMarkdownLinkMustacheCharacters = (text) => {
46+
return text.includes('[') && text.includes(']') &&
47+
text.includes('{') && text.includes('}') &&
48+
text.includes('(') && text.includes(')');
49+
};
50+
51+
export const replacePropertyTags = (content, properties, errOnMissing = false, urlEncodeReplacement = false) => {
2552
return content.replace(/(^|[^{])\{\{([^{][^}]*)}}/g, (matched, firstChar, tagName) => {
2653
if (errOnMissing && !properties[tagName]) {
2754
throw new Error(`Missing replacement property: ${tagName}`);
2855
}
29-
return firstChar + _get(properties, tagName, "");
56+
57+
// Conditionally url encode replacement value
58+
const replacement = _get(properties, tagName, "");
59+
return firstChar + (urlEncodeReplacement ? encodeURIComponent(replacement) : replacement);
3060
});
3161
};
3262

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { renderHook } from '@testing-library/react-hooks';
2+
import { describe, expect, it } from "vitest";
3+
import usePropertyReplacement from "./UsePropertyReplacement";
4+
5+
describe("usePropertyReplacement additional tests", () => {
6+
const properties = {
7+
name: "Test Name",
8+
id: "123",
9+
nested: {
10+
property: "nested value",
11+
deep: {
12+
key: "deep value"
13+
}
14+
},
15+
special: "value with special characters: !@#$%^&*()"
16+
};
17+
18+
it("url encodes markdown link replacements", () => {
19+
const content = "[link](https://example.com/{{nested.deep.key}})";
20+
const { result } = renderHook(() => usePropertyReplacement(content, properties));
21+
expect(result.current).toBe("[link](https://example.com/deep%20value)");
22+
});
23+
24+
it("performs basic url encoded replacement in markdown link", () => {
25+
const content = "[link](https://example.com/{{name}})";
26+
const expected = "[link](https://example.com/Test%20Name)";
27+
const { result } = renderHook(() => usePropertyReplacement(content, properties));
28+
expect(result.current).toBe(expected);
29+
});
30+
31+
it("ignores whitespace between link text and URL in markdown link", () => {
32+
const content = "[link] (https://example.com/{{name}})";
33+
const expected = "[link](https://example.com/Test%20Name)";
34+
const { result } = renderHook(() => usePropertyReplacement(content, properties));
35+
expect(result.current).toBe(expected);
36+
});
37+
38+
it("falls through to preexisting replacement behavior for invalid link format", () => {
39+
const content = "[link] hello there (https://example.com/{{name}})";
40+
const expected = "[link] hello there (https://example.com/Test Name)";
41+
const { result } = renderHook(() => usePropertyReplacement(content, properties));
42+
expect(result.current).toBe(expected);
43+
});
44+
45+
it("does not url encode standalone replacements outside markdown links", () => {
46+
const content = "[link](https://example.com/) blab blah {{name}} blah [another link](https://example.com/)";
47+
const expected = "[link](https://example.com/) blab blah Test Name blah [another link](https://example.com/)";
48+
const { result } = renderHook(() => usePropertyReplacement(content, properties));
49+
expect(result.current).toBe(expected);
50+
});
51+
52+
it("does not url encode standalone replacements when no markdown links are present", () => {
53+
const content = "hello there (foo {{name}})";
54+
const expected = "hello there (foo Test Name)";
55+
const { result } = renderHook(() => usePropertyReplacement(content, properties));
56+
expect(result.current).toBe(expected);
57+
});
58+
59+
it("only url encodes replacements in markdown links", () => {
60+
const content = "{{nested.deep.key}} [link](https://example.com/{{nested.deep.key}})";
61+
const { result } = renderHook(() => usePropertyReplacement(content, properties, true, false));
62+
expect(result.current).toBe("deep value [link](https://example.com/deep%20value)");
63+
});
64+
65+
it("handles empty properties object", () => {
66+
const content = "Hello, {{name}}!";
67+
const { result } = renderHook(() => usePropertyReplacement(content, {}));
68+
expect(result.current).toBe("Hello, !");
69+
});
70+
71+
it("honors allowPropertyReplacement = false when data missing", () => {
72+
const content = "Hello, {{name}}!";
73+
const { result } = renderHook(() => usePropertyReplacement(content, {}, false));
74+
expect(result.current).toBe("Hello, {{name}}!");
75+
});
76+
77+
it("honors allowPropertyReplacement = false when data present", () => {
78+
const content = "Hello, {{name}}!";
79+
const { result } = renderHook(() => usePropertyReplacement(content, properties, false));
80+
expect(result.current).toBe("Hello, {{name}}!");
81+
});
82+
83+
it("processes content with multiple nested properties", () => {
84+
const content = "Nested: {{nested.property}}, Deep: {{nested.deep.key}}";
85+
const { result } = renderHook(() => usePropertyReplacement(content, properties));
86+
expect(result.current).toBe("Nested: nested value, Deep: deep value");
87+
});
88+
89+
it("handles content with no mustache tags", () => {
90+
const content = "This is plain text.";
91+
const { result } = renderHook(() => usePropertyReplacement(content, properties));
92+
expect(result.current).toBe(content);
93+
});
94+
95+
it("handles content with special characters in property values", () => {
96+
const content = "Special: {{special}}";
97+
const { result } = renderHook(() => usePropertyReplacement(content, properties));
98+
expect(result.current).toBe("Special: value with special characters: !@#$%^&*()");
99+
});
100+
});

0 commit comments

Comments
 (0)