Skip to content

Commit 036ade8

Browse files
Merge pull request #276 from wttech/docs-and-hardening
Frontmatter in code metadata + mermaid graphs
2 parents 0412127 + ef47e87 commit 036ade8

33 files changed

+1891
-281
lines changed

README.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ It works seamlessly across AEM on-premise, AMS, and AEMaaCS environments.
5959
- [Console](#console)
6060
- [Content scripts](#content-scripts)
6161
- [Minimal example](#minimal-example)
62+
- [Conditions](#conditions)
6263
- [Inputs example](#inputs-example)
6364
- [Outputs example](#outputs-example)
6465
- [Console \& logging](#console--logging)
@@ -303,6 +304,43 @@ The `doRun()` method contains the actual code to be executed.
303304
Notice that the script on their own decide when to run without a need to specify any additional metadata. In that way the-sky-is-the-limit. You can run the script once, periodically, or at an exact date and time.
304305
There are many built-in, ready-to-use conditions available in the `conditions` [service](https://github.com/wttech/acm/blob/main/core/src/main/java/dev/vml/es/acm/core/code/Conditions.java).
305306

307+
#### Conditions
308+
309+
Conditions determine when automatic scripts should execute. The `conditions` [service](https://github.com/wttech/acm/blob/main/core/src/main/java/dev/vml/es/acm/core/code/Conditions.java) provides many useful methods:
310+
311+
- `conditions.always()` - Always execute on every trigger. Most commonly used in console and manual scripts where execution is triggered directly by users.
312+
- `conditions.never()` - Never execute. Useful for temporarily disabling scripts.
313+
- `conditions.changed()` - Execute when script content changed or when instance changed after a failure. Automatically retries failed executions after deployments, making it more suitable for production scenarios than `once()`.
314+
- `conditions.contentChanged()` - Execute when script content changed or when never executed before. Does not consider instance state changes.
315+
- `conditions.instanceChanged()` - Execute when instance state changed (OSGi bundle checksums changed or ACM bundle restarted). Useful for detecting deployments or restarts.
316+
- `conditions.retryIfInstanceChanged()` - Execute when instance state changed and previous execution failed. Combines instance change detection with failure retry logic.
317+
- `conditions.once()` - Execute only once, when never executed before. Does not automatically retry after failures. Works well for initialization scripts that should not be repeated.
318+
- `conditions.notSucceeded()` - Execute if previous execution wasn't successful. Retries execution until it succeeds, ignoring script content and instance state changes.
319+
- `conditions.isInstanceAuthor()` / `conditions.isInstancePublish()` - Execute only on specific instance types (author or publish).
320+
- `conditions.isInstanceRunMode("dev")` - Execute only when instance has specific run mode.
321+
- `conditions.isInstanceOnPrem()` - Execute only on on-premise AEM instances.
322+
- `conditions.isInstanceCloud()` - Execute only on cloud-based AEM instances (AEMaaCS).
323+
- `conditions.isInstanceCloudSdk()` - Execute only on AEM Cloud SDK (local development environment).
324+
- `conditions.isInstanceCloudContainer()` - Execute only on AEM Cloud Service containers (non-SDK cloud instances).
325+
326+
**Example usage:**
327+
328+
```groovy
329+
boolean canRun() {
330+
return conditions.once()
331+
}
332+
333+
void doRun() {
334+
out.info "Removing deprecated properties from pages..."
335+
repo.get("/content/acme").query("n.[sling:resourceType=acme/component/page]").each { page ->
336+
page.removeProperty("deprecatedProperty")
337+
}
338+
out.success "Removed deprecated properties successfully."
339+
}
340+
```
341+
342+
For the complete list of available conditions and their behavior, see the [Conditions.java source code](https://github.com/wttech/acm/blob/main/core/src/main/java/dev/vml/es/acm/core/code/Conditions.java).
343+
306344
#### Inputs example
307345

308346
Scripts could accept inputs, which are passed to the script when it is executed.

core/src/main/java/dev/vml/es/acm/core/code/CodeMetadata.java

Lines changed: 43 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
package dev.vml.es.acm.core.code;
22

33
import com.fasterxml.jackson.annotation.JsonAnyGetter;
4+
import dev.vml.es.acm.core.AcmException;
5+
import dev.vml.es.acm.core.util.YamlUtils;
46
import java.io.Serializable;
5-
import java.util.ArrayList;
67
import java.util.LinkedHashMap;
7-
import java.util.List;
88
import java.util.Map;
99
import java.util.regex.Matcher;
1010
import java.util.regex.Pattern;
@@ -18,16 +18,15 @@ public class CodeMetadata implements Serializable {
1818

1919
private static final Logger LOG = LoggerFactory.getLogger(CodeMetadata.class);
2020

21-
private static final Pattern DOC_COMMENT_PATTERN = Pattern.compile("/\\*\\*([^*]|\\*(?!/))*\\*/", Pattern.DOTALL);
22-
private static final Pattern TAG_PATTERN =
23-
Pattern.compile("(?m)^\\s*\\*?\\s*@(\\w+)\\s+(.+?)(?=(?m)^\\s*\\*?\\s*@\\w+|\\*/|$)", Pattern.DOTALL);
21+
private static final Pattern BLOCK_COMMENT_PATTERN =
22+
Pattern.compile("/\\*(?!\\*)([^*]|\\*(?!/))*\\*/", Pattern.DOTALL);
23+
private static final Pattern FRONTMATTER_PATTERN =
24+
Pattern.compile("^---\\s*\\n(.+?)^---\\s*\\n", Pattern.DOTALL | Pattern.MULTILINE);
2425
private static final Pattern NEWLINE_AFTER_COMMENT = Pattern.compile("^\\s*\\n[\\s\\S]*");
2526
private static final Pattern BLANK_LINE_AFTER_COMMENT = Pattern.compile("^\\s*\\n\\s*\\n[\\s\\S]*");
2627
private static final Pattern IMPORT_OR_PACKAGE_BEFORE =
2728
Pattern.compile("[\\s\\S]*(import|package)[\\s\\S]*\\n\\s*\\n\\s*$");
28-
private static final Pattern FIRST_TAG_PATTERN = Pattern.compile("(?m)^\\s*\\*?\\s*@\\w+");
29-
private static final Pattern LEADING_ASTERISK = Pattern.compile("(?m)^\\s*\\*\\s?");
30-
private static final Pattern DOC_MARKERS = Pattern.compile("^/\\*\\*|\\*/$");
29+
private static final Pattern COMMENT_MARKERS = Pattern.compile("^/\\*|\\*/$");
3130

3231
private Map<String, Object> values;
3332

@@ -46,20 +45,21 @@ public static CodeMetadata of(Executable executable) {
4645

4746
public static CodeMetadata parse(String code) {
4847
if (StringUtils.isNotBlank(code)) {
49-
String docComment = findFirstDocComment(code);
50-
if (docComment != null) {
51-
return new CodeMetadata(parseDocComment(docComment));
48+
String blockComment = findFirstBlockComment(code);
49+
if (blockComment != null) {
50+
return new CodeMetadata(parseBlockComment(blockComment));
5251
}
5352
}
5453
return EMPTY;
5554
}
5655

5756
/**
58-
* Finds first JavaDoc/GroovyDoc comment that's properly separated with blank lines,
59-
* or directly attached to describeRun() method.
57+
* Finds first block comment that's properly separated with blank lines.
58+
* Must be followed by a blank line (not directly attached to code).
59+
* Can appear at the start of the file or after import/package statements.
6060
*/
61-
private static String findFirstDocComment(String code) {
62-
Matcher matcher = DOC_COMMENT_PATTERN.matcher(code);
61+
private static String findFirstBlockComment(String code) {
62+
Matcher matcher = BLOCK_COMMENT_PATTERN.matcher(code);
6363

6464
while (matcher.find()) {
6565
String comment = matcher.group();
@@ -72,12 +72,6 @@ private static String findFirstDocComment(String code) {
7272
continue;
7373
}
7474

75-
String trimmedAfter = afterComment.trim();
76-
77-
if (trimmedAfter.startsWith("void describeRun()")) {
78-
return comment;
79-
}
80-
8175
if (!BLANK_LINE_AFTER_COMMENT.matcher(afterComment).matches()) {
8276
continue;
8377
}
@@ -98,63 +92,46 @@ private static String findFirstDocComment(String code) {
9892
}
9993

10094
/**
101-
* Extracts description and @tags from doc comment. Supports multiple values per tag.
95+
* Extracts frontmatter (YAML between triple dashes) and description from block comment.
10296
*/
103-
private static Map<String, Object> parseDocComment(String docComment) {
97+
private static Map<String, Object> parseBlockComment(String blockComment) {
10498
Map<String, Object> result = new LinkedHashMap<>();
99+
if (StringUtils.isBlank(blockComment)) {
100+
return result;
101+
}
105102

106-
String content = DOC_MARKERS.matcher(docComment).replaceAll("");
103+
String content = COMMENT_MARKERS.matcher(blockComment).replaceAll("").trim();
107104

108-
// @ at line start (not in email addresses)
109-
Matcher firstTagMatcher = FIRST_TAG_PATTERN.matcher(content);
105+
Matcher frontmatterMatcher = FRONTMATTER_PATTERN.matcher(content);
106+
String description = content;
110107

111-
if (firstTagMatcher.find()) {
112-
int firstTagIndex = firstTagMatcher.start();
113-
String description = LEADING_ASTERISK
114-
.matcher(content.substring(0, firstTagIndex))
115-
.replaceAll("")
116-
.trim();
117-
if (!description.isEmpty()) {
118-
result.put("description", description);
119-
}
120-
} else {
121-
String description =
122-
LEADING_ASTERISK.matcher(content).replaceAll("").trim();
123-
if (!description.isEmpty()) {
124-
result.put("description", description);
108+
if (frontmatterMatcher.find()) {
109+
String frontmatter = frontmatterMatcher.group(1);
110+
if (frontmatter != null) {
111+
result.putAll(parseFrontmatter(frontmatter));
125112
}
113+
description = content.substring(frontmatterMatcher.end());
126114
}
127115

128-
Matcher tagMatcher = TAG_PATTERN.matcher(content);
129-
130-
while (tagMatcher.find()) {
131-
String tagName = tagMatcher.group(1);
132-
String tagValue = tagMatcher.group(2);
133-
134-
if (tagValue != null && !tagValue.isEmpty()) {
135-
tagValue = LEADING_ASTERISK.matcher(tagValue).replaceAll("").trim();
136-
137-
if (!tagValue.isEmpty()) {
138-
Object existing = result.get(tagName);
139-
140-
if (existing == null) {
141-
result.put(tagName, tagValue);
142-
} else if (existing instanceof List) {
143-
@SuppressWarnings("unchecked")
144-
List<String> list = (List<String>) existing;
145-
list.add(tagValue);
146-
} else {
147-
List<String> list = new ArrayList<>();
148-
list.add((String) existing);
149-
list.add(tagValue);
150-
result.put(tagName, list);
151-
}
152-
}
153-
}
116+
description = description.trim();
117+
118+
if (!description.isEmpty()) {
119+
result.put("description", description);
154120
}
121+
155122
return result;
156123
}
157124

125+
private static Map<String, Object> parseFrontmatter(String frontmatter) {
126+
try {
127+
@SuppressWarnings("unchecked")
128+
Map<String, Object> yamlData = YamlUtils.readFromString(frontmatter, Map.class);
129+
return yamlData != null ? yamlData : new LinkedHashMap<>();
130+
} catch (Exception e) {
131+
throw new AcmException(String.format("Cannot parse frontmatter!\n%s\n", frontmatter), e);
132+
}
133+
}
134+
158135
@JsonAnyGetter
159136
public Map<String, Object> getValues() {
160137
return values;

core/src/test/java/dev/vml/es/acm/core/code/CodeMetadataTest.java

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -67,18 +67,25 @@ void shouldParsePageThumbnailScript() throws IOException {
6767
}
6868

6969
@Test
70-
void shouldParseScriptWithoutDocComment() throws IOException {
70+
void shouldParseScriptWithoutFrontmatter() throws IOException {
7171
String code = readScript("automatic/example/ACME-20_once.groovy");
7272
CodeMetadata metadata = CodeMetadata.parse(code);
7373

74-
assertTrue(metadata.getValues().isEmpty());
74+
assertFalse(metadata.getValues().isEmpty());
75+
assertNotNull(metadata.getValues().get("description"));
76+
String description = (String) metadata.getValues().get("description");
77+
assertTrue(description.contains("conditions.once()"));
7578
}
7679

7780
@Test
7881
void shouldParseMultipleAuthors() {
79-
String code = "/**\n" + " * @author John Doe\n"
80-
+ " * @author Jane Smith\n"
81-
+ " */\n"
82+
String code = "/*\n" + "---\n"
83+
+ "author:\n"
84+
+ " - John Doe\n"
85+
+ " - Jane Smith\n"
86+
+ "---\n"
87+
+ "Multi-author script\n"
88+
+ "*/\n"
8289
+ "\n"
8390
+ "void doRun() {\n"
8491
+ " println \"Hello\"\n"
@@ -96,11 +103,13 @@ void shouldParseMultipleAuthors() {
96103

97104
@Test
98105
void shouldParseCustomTags() {
99-
String code = "/**\n" + " * @description Custom script with metadata\n"
100-
+ " * @version 1.0.0\n"
101-
+ " * @since 2025-01-01\n"
102-
+ " * @category migration\n"
103-
+ " */\n"
106+
String code = "/*\n" + "---\n"
107+
+ "version: 1.0.0\n"
108+
+ "since: 2025-01-01\n"
109+
+ "category: migration\n"
110+
+ "---\n"
111+
+ "Custom script with metadata\n"
112+
+ "*/\n"
104113
+ "\n"
105114
+ "void doRun() {\n"
106115
+ " println \"Hello\"\n"

ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-100_acl.groovy

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,21 @@
1-
/**
2-
* This script creates content author groups for each tenant-country-language combination.
3-
*
4-
* The groups are named in the format: `{tenant}-{country}-{language}-content-authors`.
5-
* Each group is granted read, write, and replicate permissions on the corresponding content and DAM paths.
6-
*/
1+
/*
2+
---
3+
tags: ['security', 'acl']
4+
schedule: Every hour at 10 minutes past the hour
5+
---
6+
Creates content author groups for each tenant-country-language combination.
7+
8+
The groups are named in the format: `{tenant}-{country}-{language}-content-authors`.
9+
Each group is granted read, write, and replicate permissions on the corresponding content and DAM paths.
10+
11+
```mermaid
12+
graph LR
13+
A[Scan Tenants] --> B[Find Countries]
14+
B --> C[Find Languages]
15+
C --> D[Create Author Groups]
16+
D --> E[Grant Permissions]
17+
```
18+
*/
719

820
def scheduleRun() {
921
return schedules.cron("0 10 * ? * * *") // every hour at minute 10
@@ -14,7 +26,11 @@ boolean canRun() {
1426
}
1527

1628
void doRun() {
29+
out.info "ACL setup started"
30+
1731
def tenantPaths = ["/content/acme", "/content/wknd", "/content/we-retail"]
32+
def groupsCreated = 0
33+
1834
for (def tenantRoot : tenantPaths.collect { repo.get(it) }.findAll { it.exists() }) {
1935
def tenant = tenantRoot.name
2036
for (def countryRoot : tenantRoot.children().findAll { isRoot(it) }) {
@@ -27,9 +43,12 @@ void doRun() {
2743
allow { path = "/content/${tenant}/${country}/${language}"; permissions = ["jcr:read", "rep:write", "crx:replicate"] }
2844
allow { path = "/content/dam/${tenant}/${country}/${language}"; permissions = ["jcr:read", "rep:write", "crx:replicate"] }
2945
}
46+
groupsCreated++
3047
}
3148
}
3249
}
50+
51+
out.success "ACL setup completed. Processed ${groupsCreated} content author group(s)."
3352
}
3453

3554
def isRoot(root) {
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
1+
/*
2+
A simple demonstration script that executes only once.
3+
4+
This script uses `conditions.once()` to ensure it runs exactly one time in the instance's lifetime.
5+
Useful for one-time initialization tasks that should never be repeated even if failed.
6+
*/
7+
18
boolean canRun() {
29
return conditions.once()
310
}
411

512
void doRun() {
13+
out.info "One-time task started"
614
println("I should run only once!")
15+
out.success "One-time task completed"
716
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/*
2+
---
3+
version: '1.0'
4+
---
5+
A script that executes when content changes or after deployment failures.
6+
7+
This script uses `conditions.changed()` to run when:
8+
- The script content has been modified since last execution, OR
9+
- The script has never been executed before, OR
10+
- The instance state changed (deployment/restart) and previous execution failed
11+
12+
This makes it ideal for deployment scenarios where you want to automatically
13+
retry failed executions after deployments or configuration changes.
14+
*/
15+
16+
boolean canRun() {
17+
return conditions.changed()
18+
}
19+
20+
void doRun() {
21+
out.info "Content update task started"
22+
println("I should run when content changes or after failed deployment!")
23+
out.success "Content update task completed"
24+
}

ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-32_every-day.groovy

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
/*
2+
A scheduled script that runs daily at 08:00.
3+
4+
This script demonstrates cron-based scheduling with `schedules.cron()` and uses
5+
`conditions.always()` to execute on every scheduled trigger.
6+
*/
7+
18
def scheduleRun() {
29
return schedules.cron("0 0 8 ? * * *") // at 08:00 every day
310
}
@@ -7,5 +14,7 @@ boolean canRun() {
714
}
815

916
void doRun() {
17+
out.info "Daily task started"
1018
println("I should run every day!")
19+
out.success "Daily task completed"
1120
}

0 commit comments

Comments
 (0)