Skip to content

Commit 5d08540

Browse files
template: add runtime template (#1501)
1 parent 7948562 commit 5d08540

19 files changed

Lines changed: 411 additions & 18 deletions

File tree

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/*
2+
* Copyright 2026 znai maintainers
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.testingisdocumenting.znai.extensions.urlqueryvalue;
18+
19+
import org.testingisdocumenting.znai.core.ComponentsRegistry;
20+
import org.testingisdocumenting.znai.extensions.PluginParams;
21+
import org.testingisdocumenting.znai.extensions.PluginResult;
22+
import org.testingisdocumenting.znai.extensions.inlinedcode.InlinedCodePlugin;
23+
import org.testingisdocumenting.znai.search.SearchText;
24+
25+
import java.nio.file.Path;
26+
import java.util.HashMap;
27+
import java.util.List;
28+
import java.util.Map;
29+
30+
public class UrlQueryValueInlinedCodePlugin implements InlinedCodePlugin {
31+
@Override
32+
public String id() {
33+
return "url-query-value";
34+
}
35+
36+
@Override
37+
public InlinedCodePlugin create() {
38+
return new UrlQueryValueInlinedCodePlugin();
39+
}
40+
41+
@Override
42+
public PluginResult process(ComponentsRegistry componentsRegistry, Path markupPath, PluginParams pluginParams) {
43+
String queryParam = pluginParams.getFreeParam();
44+
45+
Map<String, Object> props = new HashMap<>(pluginParams.getOpts().toMap());
46+
props.put("queryParam", queryParam);
47+
48+
return PluginResult.docElement("UrlQueryValue", props);
49+
}
50+
51+
@Override
52+
public List<SearchText> textForSearch() {
53+
return List.of();
54+
}
55+
}

znai-core/src/main/java/org/testingisdocumenting/znai/structure/DocUrl.java

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ public class DocUrl {
3535
private String dirName = "";
3636
private String fileNameWithoutExtension = "";
3737
private String anchorId = "";
38+
private String queryString = "";
3839
private String tocItemFilePath = "";
3940

4041
private String url;
@@ -74,11 +75,13 @@ private boolean handleLocalFile(String url) {
7475

7576
private boolean handleBasedOnFilePath(String url) {
7677
String withoutAnchor = UrlUtils.removeAnchor(url);
77-
String extension = FilePathUtils.fileExtension(withoutAnchor);
78+
String withoutAnchorAndQuery = removeQueryString(withoutAnchor);
79+
String extension = FilePathUtils.fileExtension(withoutAnchorAndQuery);
7880

7981
if (extension.startsWith("md")) {
80-
tocItemFilePath = withoutAnchor;
82+
tocItemFilePath = withoutAnchorAndQuery;
8183
anchorId = UrlUtils.extractAnchor(url);
84+
queryString = extractQueryString(withoutAnchor);
8285
return true;
8386
}
8487

@@ -108,7 +111,7 @@ private boolean handleAnchorOnly() {
108111
}
109112

110113
private boolean handleLocal() {
111-
String[] parts = url.split("/");
114+
String[] parts = extractUrlWithoutQueryString().split("/");
112115
if (parts.length != 2 && parts.length != 3) {
113116
throw new IllegalArgumentException("Unexpected url pattern: <" + url + "> " + LINK_TO_SECTION_INSTRUCTION);
114117
}
@@ -172,7 +175,31 @@ public String getAnchorIdWithHash() {
172175
return anchorId.isEmpty() ? "" : "#" + anchorId;
173176
}
174177

178+
public String getQueryAndAnchorSuffix() {
179+
return queryString + getAnchorIdWithHash();
180+
}
181+
175182
public String getUrl() {
176183
return url;
177184
}
185+
186+
private String extractUrlWithoutQueryString() {
187+
int idxOfQuery = url.indexOf('?');
188+
if (idxOfQuery == -1) {
189+
return url;
190+
}
191+
192+
queryString = url.substring(idxOfQuery);
193+
return url.substring(0, idxOfQuery);
194+
}
195+
196+
private static String removeQueryString(String url) {
197+
int idxOfQuery = url.indexOf('?');
198+
return idxOfQuery == -1 ? url : url.substring(0, idxOfQuery);
199+
}
200+
201+
private static String extractQueryString(String url) {
202+
int idxOfQuery = url.indexOf('?');
203+
return idxOfQuery == -1 ? "" : url.substring(idxOfQuery);
204+
}
178205
}

znai-core/src/main/resources/META-INF/services/org.testingisdocumenting.znai.extensions.inlinedcode.InlinedCodePlugin

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,4 @@ org.testingisdocumenting.znai.extensions.file.FileInlinedCodePlugin
1818
org.testingisdocumenting.znai.extensions.inlinedcode.IdentifierInlinedCodePlugin
1919
org.testingisdocumenting.znai.extensions.textbadge.TextBadgeInlinedCodePlugin
2020
org.testingisdocumenting.znai.extensions.latex.LatexInlinedCodePlugin
21+
org.testingisdocumenting.znai.extensions.urlqueryvalue.UrlQueryValueInlinedCodePlugin

znai-core/src/test/groovy/org/testingisdocumenting/znai/structure/DocUrlTest.groovy

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ class DocUrlTest {
3131
url.should == [anchorId: "page-section", tocItemFilePath: "./chapter/name.md", dirName: "", fileNameWithoutExtension: ""]
3232
}
3333

34+
@Test
35+
void "parse with query params"() {
36+
def url = new DocUrl("./chapter/name.md?key1=value1&key2=value2#page-section")
37+
url.should == [anchorId: "page-section", tocItemFilePath: "./chapter/name.md", dirName: "", fileNameWithoutExtension: ""]
38+
}
39+
3440
@Test
3541
void "parse with mdx extension"() {
3642
def url = new DocUrl("./chapter/name.mdx#page-section")
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
# URL Query Value
2+
3+
Use `url-query-value` inline plugin to display a value from URL query parameter.
4+
This can be useful for onboarding pages or runbooks where commands or settings change based on user or some runtime
5+
information, and you can generate a link, providing additional information.
6+
7+
# Inline Syntax
8+
9+
`:url-query-value: officeName {default: "NYC-15"}`
10+
11+
Renders as: `:url-query-value: officeName {default: "NYC-15"}`
12+
13+
The `officeName` is the query parameter name to read from the URL.
14+
15+
Click [SF office](layout/runtime-templates?officeName=SF) to see the value above change.
16+
17+
# Missing Value
18+
19+
When no `default` is specified and the query parameter is not present, an error is displayed:
20+
21+
`:url-query-value: clusterName`
22+
23+
Renders as: `:url-query-value: clusterName`
24+
25+
# Template Syntax In Code Snippets And CLI Commands
26+
27+
Use `${paramName}` or `${paramName:defaultValue}` syntax inside code snippets to substitute values from URL query parameters.
28+
Enable it by setting `templateUseQueryParam: true` on the code block.
29+
30+
```shell {templateUseQueryParam: true}
31+
ssh ${userName:admin}@server-${officeName:NYC}.example.com
32+
```
33+
34+
```shell {templateUseQueryParam: true}
35+
ssh ${userName:admin}@server-${officeName:NYC}.example.com
36+
```
37+
38+
```cli {templateUseQueryParam: true}
39+
kubectl config use-context ${officeName:NYC}-cluster
40+
```
41+
42+
```cli {templateUseQueryParam: true}
43+
kubectl config use-context ${officeName:NYC}-cluster
44+
```
45+
46+
Without query parameters, default values are used. When the page URL contains `?userName=jdoe&officeName=SF`, the substituted values will appear.
47+
48+
Without default values, you get error message when no query parameter is supplied:
49+
50+
```cli {templateUseQueryParam: true}
51+
kubectl config use-context ${myQueryParam}-cluster
52+
```
53+
54+
```cli {templateUseQueryParam: true}
55+
kubectl config use-context ${myQueryParam}-cluster
56+
```
57+
58+
# Tables With Inline Values
59+
60+
You can use inline `url-query-value` anywhere were text is expected, for example in table cells:
61+
62+
```markdown
63+
64+
| Setting | Value |
65+
|---------------|-----------------------------------------------------------------|
66+
| Office | `:url-query-value: officeName {default: "NYC"}` |
67+
| Floor | `:url-query-value: floorNumber {default: "5"}` |
68+
| Wi-Fi Network | `:url-query-value: officeName {default: "NYC"}`-internal |
69+
| VPN Server | vpn-`:url-query-value: officeName {default: "NYC"}`.example.com |
70+
```
71+
| Setting | Value |
72+
|---------------|-----------------------------------------------------------------|
73+
| Office | `:url-query-value: officeName {default: "NYC"}` |
74+
| Floor | `:url-query-value: floorNumber {default: "5"}` |
75+
| Wi-Fi Network | `:url-query-value: officeName {default: "NYC"}`-internal |
76+
| VPN Server | vpn-`:url-query-value: officeName {default: "NYC"}`.example.com |
77+
78+
# Full Example
79+
80+
Click one of the links below to see how values on this page change:
81+
82+
* [SF office, floor 3](layout/runtime-templates?officeName=SF&floorNumber=3&userName=jdoe)
83+
* [NYC office, floor 12](layout/runtime-templates?officeName=NYC&floorNumber=12&userName=admin)
84+
* [London office, floor 7](layout/runtime-templates?officeName=London&floorNumber=7&userName=alice)
85+
* [Default values](layout/runtime-templates)
86+
87+
## Setup Instructions
88+
89+
Welcome to the `:url-query-value: officeName {default: "NYC"}` office!
90+
91+
Connect to the office VPN:
92+
93+
```cli {templateUseQueryParam: true}
94+
sudo vpn connect ${officeName:NYC}-gateway.example.com --user ${userName:admin}
95+
```
96+
97+
Configure your local environment:
98+
99+
```shell {templateUseQueryParam: true}
100+
export OFFICE=${officeName:NYC}
101+
export FLOOR=${floorNumber:5}
102+
export PRINTER=printer-${officeName:NYC}-${floorNumber:5}
103+
```
104+
105+
Print a test page:
106+
107+
```cli {templateUseQueryParam: true}
108+
lp -d printer-${officeName:NYC}-${floorNumber:5} /etc/motd
109+
```
110+
111+
Note: If you have a predetermined set of values, consider using [Page Tabs](layout/page-tabs) or [Tabs](layout/tabs) instead
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
* Add: [Runtime Templates](layout/runtime-templates) to substitute values at runtime

znai-docs/znai/toc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ layout
6363
tables
6464
columns
6565
templates
66+
runtime-templates
6667
two-sides-pages
6768
two-sides-tabs
6869
jupyter-notebook-two-sides

znai-reactjs/src/doc-elements/DefaultElementsLibrary.jsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ import { FootnoteBackLinks } from "./footnote/FootnotesList";
9393
import { EmbeddedHtml } from "./html/EmbeddedHtml";
9494
import { Asciinema } from "./asciinema/Asciinema";
9595
import { ReadMore } from "./read-more/ReadMore.js";
96+
import { UrlQueryValue } from "./url-query-value/UrlQueryValue";
9697
import { withDisplayName } from "./components.ts";
9798

9899
const library = {}
@@ -142,6 +143,7 @@ library.Icon = Icon
142143
library.KeyboardShortcut = KeyboardShortcut
143144

144145
library.TextBadge = TextBadge
146+
library.UrlQueryValue = UrlQueryValue
145147

146148
library.OrderedList = OrderedList
147149

znai-reactjs/src/doc-elements/cli/CliCommand.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import React, { useState, useEffect, useMemo } from "react";
2020
import CliCommandToken from "./CliCommandToken";
2121
import { splitParts } from "../../utils/strings";
2222
import { DocElementPayload } from "../default-elements/DocElement";
23+
import { resolveTemplateText } from "../url-query-value/queryParamTemplate";
2324
import "./CliCommand.css";
2425

2526
interface Token {
@@ -37,6 +38,7 @@ interface CliCommandProps {
3738
threshold?: number;
3839
presentationThreshold?: number;
3940
splitAfter?: string[];
41+
templateUseQueryParam?: boolean;
4042
}
4143

4244
const CliCommand: React.FC<CliCommandProps> = ({
@@ -49,8 +51,10 @@ const CliCommand: React.FC<CliCommandProps> = ({
4951
threshold = 100,
5052
presentationThreshold = 40,
5153
splitAfter = [],
54+
templateUseQueryParam = false,
5255
}) => {
53-
const tokens = useMemo(() => tokenize(command), [command]);
56+
const resolvedCommand = templateUseQueryParam ? resolveTemplateText(command) : command;
57+
const tokens = useMemo(() => tokenize(resolvedCommand), [resolvedCommand]);
5458

5559
const [lastTokenIdx, setLastTokenIdx] = useState(
5660
isPresentation && !isPresentationDisplayed ? 1 : tokens.length
@@ -136,10 +140,10 @@ const CliCommand: React.FC<CliCommandProps> = ({
136140
(isPrevCliCommand ? " prev-present" : "");
137141

138142
return (
139-
<div key={command} className={className}>
143+
<div key={resolvedCommand} className={className}>
140144
<pre>
141145
<span className="prompt">$ </span>
142-
<span key={command}>{renderTokens()}</span>
146+
<span key={resolvedCommand}>{renderTokens()}</span>
143147
</pre>
144148
</div>
145149
);

znai-reactjs/src/doc-elements/code-snippets/Snippet.jsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import { parseCode } from "./codeParser";
3535
import { countNumberOfLines } from "../../utils/strings";
3636

3737
import { SnippetBulletExplanations } from "./explanations/SnippetBulletExplanations";
38+
import { resolveTemplateText } from "../url-query-value/queryParamTemplate";
3839

3940
import "./Snippet.css";
4041

@@ -44,7 +45,8 @@ const BULLETS_COMMENT_TYPE = "inline";
4445
const REMOVE_COMMENT_TYPE = "remove";
4546

4647
const Snippet = (props) => {
47-
const tokensToUse = parseCodeWithCompatibility({ lang: props.lang, snippet: props.snippet, tokens: props.tokens });
48+
const snippet = props.templateUseQueryParam ? resolveTemplateText(props.snippet) : props.snippet;
49+
const tokensToUse = parseCodeWithCompatibility({ lang: props.lang, snippet, tokens: props.tokens });
4850

4951
const renderBulletComments =
5052
props.commentsType === BULLETS_COMMENT_TYPE || (props.callouts && Object.keys(props.callouts).length > 0);

0 commit comments

Comments
 (0)