Skip to content

Commit 20093b2

Browse files
committed
Expression replacement cleanup:
- make replacements in headers play nicely with the ToC and autolinks - better & more visible error messages - clean up build warnings
1 parent b9a30af commit 20093b2

File tree

6 files changed

+214
-91
lines changed

6 files changed

+214
-91
lines changed

advocacy_docs/playground/1/01_examples/expression-replacement.mdx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,16 @@ This functionality allows for writing documentation when the future (or current)
3535
| `\{{version(pgd).short}}` | {{version(pgd).short}} | filesystem directory version for latest product version |
3636
| `\{{version(pgd).major}}.\{{version(pgd).minor}}` | {{version(pgd).major}}.{{version(pgd).minor}} | frontmatter - `version:` for latest product version. Parsed as semver, truncated at minor version |
3737

38-
## Scope
38+
## Scope of {{name(Expression Replacement).short}}
3939

4040
Expressions should be parsed in all Markdown elements containing rendered content, including code blocks and inline code. Frontmatter-sourced version information is not currently available in code.
4141

42+
Replacements in headings and code (inline and block) are currently limited as follows:
43+
44+
- version replacement cannot use frontmatter-sourced "precise" version or frontmatter-sourced product shortcode to determine context
45+
- product replacement cannot use frontmatter-sourced product shortcode
46+
47+
This effectively means that subsitution will not function in these contexts for "non-versioned" products unless the product shortcode is specified explicitly. E.g., `\{{name(product).ln}}`
48+
4249
Additionally, expressions will be replaced in certain frontmatter-defined values; initially, these include "title", "navTitle", "description" and "displayBanner".
4350

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { products } from "./products.js";
2+
import semver from "semver";
3+
4+
export const expressionRE =
5+
/(?<!\\)\{\{(?<command>\w+)(?:\((?<product>[^)]+)\)){0,1}\.(?<type>[^}]+)\}\}/;
6+
const nameCodes = {
7+
ln: "name",
8+
short: "shortName",
9+
abbr: "abbreviation",
10+
ccn: "commonCommandName",
11+
};
12+
13+
export default function expressionReplacement({
14+
text,
15+
currentProduct,
16+
currentVersion,
17+
currentFullVersion,
18+
productVersions,
19+
filename,
20+
position,
21+
}) {
22+
if (typeof text !== "string" || !text.includes("{{")) return text;
23+
24+
if (!currentProduct && filename)
25+
currentProduct = filename.split("product_docs/docs/")[1]?.split("/")?.at(0);
26+
if (!currentVersion && filename)
27+
currentVersion = filename.split("product_docs/docs/")[1]?.split("/")?.at(1);
28+
29+
let errorContext = filename;
30+
if (errorContext && position)
31+
errorContext += `:${position.start?.line}:${position.start?.column}`;
32+
33+
text = text.replace(
34+
new RegExp(expressionRE, "g"),
35+
(match, p1, p2, p3, offset, s, { command, product, type }) => {
36+
switch (command) {
37+
case "name":
38+
return getProductName({
39+
product: product || currentProduct,
40+
type,
41+
errorContext,
42+
});
43+
case "version":
44+
return getProductVersion({
45+
product,
46+
currentProduct,
47+
currentVersion,
48+
currentFullVersion,
49+
productVersions,
50+
type,
51+
errorContext,
52+
});
53+
default:
54+
break;
55+
}
56+
return match;
57+
},
58+
);
59+
60+
return text;
61+
}
62+
63+
export function getProductName({ product, type, errorContext }) {
64+
if (!product) {
65+
const error = `Product not specified for name lookup - implicit product won't work in code or headings in non-versioned products.`;
66+
console.error(`${errorContext} ${error}.`);
67+
return `[error: ${error}]`;
68+
}
69+
70+
const productDef = products[product];
71+
if (!productDef) {
72+
const error = `Product ${product} not found for name lookup`;
73+
console.error(`${errorContext} ${error}.`);
74+
return `[error: ${error}]`;
75+
}
76+
const nameType = nameCodes[type];
77+
if (!nameType) {
78+
const error = `Unknown name type: ${type} for name lookup.`;
79+
console.error(`${errorContext} ${error}.`);
80+
return `[error: ${error}]`;
81+
}
82+
83+
const name = productDef[nameType];
84+
if (!name) {
85+
const error = `Product ${product} does not have ${nameType} defined for name lookup`;
86+
console.error(`${errorContext} ${error}.`);
87+
return `[error: ${error}]`;
88+
}
89+
return name;
90+
}
91+
92+
export function getProductVersion({
93+
product,
94+
currentProduct,
95+
currentVersion,
96+
currentFullVersion,
97+
productVersions,
98+
type,
99+
errorContext,
100+
}) {
101+
if (!currentVersion && !product && !currentProduct) {
102+
const error = `Product not specified for version lookup - implicit product won't work in code or headings in non-versioned products.`;
103+
console.error(`${errorContext} ${error}.`);
104+
return `[error: ${error}]`;
105+
}
106+
107+
let version = currentVersion;
108+
let fullVersion = currentFullVersion || version;
109+
if (product && product !== currentProduct) {
110+
version = productVersions ? productVersions[product][0] : "";
111+
fullVersion = version;
112+
}
113+
114+
if (type === "full") return fullVersion;
115+
if (type === "short") return version;
116+
const semantic = semver.valid(semver.coerce(fullVersion));
117+
if (!semantic) return version;
118+
switch (type) {
119+
case "major":
120+
return semver.major(semantic);
121+
case "minor":
122+
return semver.minor(semantic);
123+
case "patch":
124+
return semver.patch(semantic);
125+
default:
126+
break;
127+
}
128+
const error = `don't know what you mean by a '${type}' version`;
129+
console.error(`${errorContext} ${error}.`);
130+
return `[error: ${error}]`;
131+
}

src/constants/expression-replacement.mjs

Lines changed: 0 additions & 77 deletions
This file was deleted.

src/constants/gatsby-utils.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
const fs = require("fs");
22
const asyncFs = require("fs/promises");
33
const path = require("path");
4-
let expressionReplacement = import("./expression-replacement.mjs").then(
5-
(module) => (expressionReplacement = module.default),
6-
);
4+
const {
5+
default: expressionReplacement,
6+
} = require("./expression-replacement.js");
77

88
const ghBranch = process.env.GITHUB_HEAD_REF || process.env.GITHUB_REF;
99
const isGHBuild = !!ghBranch;

src/constants/products.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import IconNames from "../components/icon/iconNames.js";
1+
const IconNames = require("../components/icon/iconNames.js");
22

3-
export const products = {
3+
const products = {
44
"Expression Replacement": {
55
name: "Name and Version Expression Replacement Syntax",
66
shortName: "Expression Replacement",
@@ -120,3 +120,5 @@ export const products = {
120120
iconName: IconNames.EDB_TRANSPORTER,
121121
},
122122
};
123+
124+
module.exports = { products };

src/plugins/replacement-expression.js

Lines changed: 68 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,81 @@
11
const visit = require("unist-util-visit-parents");
2-
const { expressionRE } = require("../constants/expression-replacement.mjs");
3-
let replacer = null;
4-
let shortcodeRE = null;
5-
import("../constants/expression-replacement.mjs").then(
6-
(module) => (
7-
(shortcodeRE = module.expressionRE), (replacer = module.default)
8-
),
9-
);
2+
const {
3+
default: replacer,
4+
expressionRE,
5+
} = require("../constants/expression-replacement.js");
106

117
// This plugin replaces shortcodes in the form {{name([product]).<type>}} with the actual name of the product
128
// The type of name must be one defined in products.js
139
function replaceExpressions() {
10+
const pipeline = this;
11+
attachParser(pipeline.Parser);
12+
13+
// special-case for headers: we need to replace the expressions before the ToC is generated
14+
// annoyingly, the ToC is generated after parse, but before transformation - so the rest of this
15+
// plugin won't run in time.
16+
function attachParser(parser) {
17+
if (
18+
!parser ||
19+
!parser.prototype ||
20+
!parser.prototype.blockTokenizers ||
21+
!parser.prototype.blockMethods
22+
)
23+
return;
24+
25+
// replace the heading tokenizer with one that keeps track of when we're in a heading,
26+
// and allow us to replace expressions in headings before the ToC is generated
27+
28+
const parseHeading = (original) => {
29+
return function (eat, value, silent) {
30+
this.inHeading = true;
31+
const ret = original.call(this, eat, value, silent);
32+
this.inHeading = false;
33+
return ret;
34+
};
35+
};
36+
37+
const parseText = (original) => {
38+
return function (eat, value, silent) {
39+
if (!this.inHeading || silent) {
40+
return original.call(this, eat, value, silent);
41+
}
42+
43+
replacerEat.now = () => eat.now.apply(eat);
44+
replacerEat.file = eat.file;
45+
46+
return original.call(this, replacerEat, value, silent);
47+
48+
function replacerEat(value) {
49+
const apply = eat(value);
50+
return (node, parent) => {
51+
node.value = replacer({
52+
text: value,
53+
filename: eat.file.path,
54+
position: node.position,
55+
});
56+
return apply(node, parent);
57+
};
58+
}
59+
};
60+
};
61+
62+
parser = parser.prototype;
63+
parser.blockTokenizers.setextHeading = parseHeading(
64+
parser.blockTokenizers.setextHeading,
65+
);
66+
parser.blockTokenizers.atxHeading = parseHeading(
67+
parser.blockTokenizers.atxHeading,
68+
);
69+
parser.inlineTokenizers.text = parseText(parser.inlineTokenizers.text);
70+
}
71+
1472
return (tree, file) => {
1573
visit(tree, ["text", "code", "inlineCode"], visitor);
1674

1775
function visitor(node, ancestors) {
1876
if (!node.value?.includes("{{")) return;
77+
// special-case for code:
78+
// we don't parse JSX components inside code, so need to replace now
1979
if (["code", "inlineCode"].includes(node.type)) {
2080
node.value = replacer({
2181
text: node.value,

0 commit comments

Comments
 (0)