@anyblades/buildawesome-kit 1.3.0-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/.gitmodules +3 -0
  2. package/.prettierrc.json +3 -0
  3. package/LICENSE.md +21 -0
  4. package/README.md +218 -0
  5. package/eleventy.config.js +141 -0
  6. package/package.json +49 -0
  7. package/plugin/features/autoLinkFavicons.js +71 -0
  8. package/plugin/features/autoLinkFavicons.test.js +694 -0
  9. package/plugin/features/mdAutoNl2br.js +22 -0
  10. package/plugin/features/mdAutoNl2br.test.js +68 -0
  11. package/plugin/features/mdAutoRawTags.js +17 -0
  12. package/plugin/features/mdAutoRawTags.test.js +80 -0
  13. package/plugin/features/mdAutoUncommentAttrs.js +26 -0
  14. package/plugin/features/mdAutoUncommentAttrs.test.js +65 -0
  15. package/plugin/features/siteData.js +43 -0
  16. package/plugin/features/siteData.test.js +120 -0
  17. package/plugin/features/virtualPages.js +18 -0
  18. package/plugin/filters/attr_concat.js +55 -0
  19. package/plugin/filters/attr_concat.test.js +205 -0
  20. package/plugin/filters/attr_includes.js +41 -0
  21. package/plugin/filters/attr_includes.test.js +125 -0
  22. package/plugin/filters/attr_set.js +22 -0
  23. package/plugin/filters/attr_set.test.js +71 -0
  24. package/plugin/filters/date.js +11 -0
  25. package/plugin/filters/date.test.js +33 -0
  26. package/plugin/filters/fetch.js +80 -0
  27. package/plugin/filters/if.js +24 -0
  28. package/plugin/filters/if.test.js +63 -0
  29. package/plugin/filters/merge.js +51 -0
  30. package/plugin/filters/merge.test.js +51 -0
  31. package/plugin/filters/remove_tag.js +42 -0
  32. package/plugin/filters/remove_tag.test.js +60 -0
  33. package/plugin/filters/section.js +82 -0
  34. package/plugin/filters/section.test.js +174 -0
  35. package/plugin/filters/split.js +9 -0
  36. package/plugin/filters/split.test.js +39 -0
  37. package/plugin/filters/strip_tag.js +39 -0
  38. package/plugin/filters/strip_tag.test.js +74 -0
  39. package/plugin/filters/unindent.js +11 -0
  40. package/plugin/filters/unindent.test.js +49 -0
  41. package/plugin/node_modules/.package-lock.json +22 -0
  42. package/plugin/package-lock.json +42 -0
  43. package/plugin/package.json +48 -0
  44. package/plugin/plugin.js +53 -0
  45. package/plugin/plugin.test.js +8 -0
  46. package/plugin/scripts/README.md +59 -0
  47. package/plugin/scripts/package.json +16 -0
@@ -0,0 +1,22 @@
1
+ /*<!--section:docs-->
2
+
3
+ `mdAutoNl2br` feature amends the markdown library to automatically convert `\n`
4
+ to `<br>` tags in text content, which is particularly useful for line breaks
5
+ inside markdown tables where standard newlines don't work.
6
+
7
+ > **NOTE:** This processes literal `\n` sequences (backslash followed by 'n'), not actual newline characters. Type `\n` in your source files where you want line breaks.
8
+
9
+ <!--section:code-->```js */
10
+ export function transformNl2br(content) {
11
+ // Replace double \n\n first, then single \n to avoid double conversion
12
+ return content.replace(/\\n\\n/g, "<br>").replace(/\\n/g, "<br>");
13
+ }
14
+
15
+ export default function mdAutoNl2br(eleventyConfig) {
16
+ eleventyConfig.amendLibrary("md", (mdLib) => {
17
+ mdLib.renderer.rules.text = (tokens, idx) => {
18
+ return transformNl2br(tokens[idx].content);
19
+ };
20
+ });
21
+ }
22
+ //```
@@ -0,0 +1,68 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { transformNl2br } from "./mdAutoNl2br.js";
4
+
5
+ describe("transformNl2br", () => {
6
+ it("should convert single \\n to <br>", () => {
7
+ const input = "Line 1\\nLine 2";
8
+ const expected = "Line 1<br>Line 2";
9
+ assert.equal(transformNl2br(input), expected);
10
+ });
11
+
12
+ it("should convert double \\n\\n to <br>", () => {
13
+ const input = "Line 1\\n\\nLine 2";
14
+ const expected = "Line 1<br>Line 2";
15
+ assert.equal(transformNl2br(input), expected);
16
+ });
17
+
18
+ it("should convert multiple \\n sequences", () => {
19
+ const input = "Line 1\\nLine 2\\nLine 3";
20
+ const expected = "Line 1<br>Line 2<br>Line 3";
21
+ assert.equal(transformNl2br(input), expected);
22
+ });
23
+
24
+ it("should handle mixed single and double \\n", () => {
25
+ const input = "Line 1\\n\\nLine 2\\nLine 3";
26
+ const expected = "Line 1<br>Line 2<br>Line 3";
27
+ assert.equal(transformNl2br(input), expected);
28
+ });
29
+
30
+ it("should handle text without \\n", () => {
31
+ const input = "Just plain text";
32
+ assert.equal(transformNl2br(input), input);
33
+ });
34
+
35
+ it("should handle empty content", () => {
36
+ assert.equal(transformNl2br(""), "");
37
+ });
38
+
39
+ it("should handle content with only \\n", () => {
40
+ const input = "\\n\\n\\n";
41
+ const expected = "<br><br>";
42
+ assert.equal(transformNl2br(input), expected);
43
+ });
44
+
45
+ it("should handle markdown table cell content with \\n", () => {
46
+ const input = "Cell 1\\nCell 1 Line 2\\n\\nCell 1 Line 3";
47
+ const expected = "Cell 1<br>Cell 1 Line 2<br>Cell 1 Line 3";
48
+ assert.equal(transformNl2br(input), expected);
49
+ });
50
+
51
+ it("should handle multiple consecutive double \\n\\n", () => {
52
+ const input = "Line 1\\n\\n\\n\\nLine 2";
53
+ const expected = "Line 1<br><br>Line 2";
54
+ assert.equal(transformNl2br(input), expected);
55
+ });
56
+
57
+ it("should preserve actual newlines (not literal \\n)", () => {
58
+ const input = "Line 1\nLine 2";
59
+ const expected = "Line 1\nLine 2";
60
+ assert.equal(transformNl2br(input), expected);
61
+ });
62
+
63
+ it("should only convert literal backslash-n sequences", () => {
64
+ const input = "Text with\\nbackslash-n and\nreal newline";
65
+ const expected = "Text with<br>backslash-n and\nreal newline";
66
+ assert.equal(transformNl2br(input), expected);
67
+ });
68
+ });
@@ -0,0 +1,17 @@
1
+ /*<!--section:docs-->
2
+
3
+ `mdAutoRawTags` feature wraps template syntax `{{, }}, {%, %}` with `{% raw %}` tags
4
+ to prevent them from being processed by the template engine in Markdown files.
5
+
6
+ <!--section:code-->```js */
7
+ export function transformAutoRaw(content) {
8
+ // This regex looks for {{, }}, {%, or %} individually and wraps them
9
+ return content.replace(/({{|}}|{%|%})/g, "{% raw %}$1{% endraw %}");
10
+ }
11
+
12
+ export default function mdAutoRawTags(eleventyConfig) {
13
+ eleventyConfig.addPreprocessor("mdAutoRawTags", "md", (data, content) => {
14
+ return transformAutoRaw(content);
15
+ });
16
+ }
17
+ //```
@@ -0,0 +1,80 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { transformAutoRaw } from "./mdAutoRawTags.js";
4
+
5
+ describe("transformAutoRaw", () => {
6
+ it("should wrap opening double curly braces with raw tags", () => {
7
+ const input = "Use {{ variable }} to output.";
8
+ const expected = "Use {% raw %}{{{% endraw %} variable {% raw %}}}{% endraw %} to output.";
9
+ assert.equal(transformAutoRaw(input), expected);
10
+ });
11
+
12
+ it("should wrap closing double curly braces with raw tags", () => {
13
+ const input = "{{ name }}";
14
+ const expected = "{% raw %}{{{% endraw %} name {% raw %}}}{% endraw %}";
15
+ assert.equal(transformAutoRaw(input), expected);
16
+ });
17
+
18
+ it("should wrap opening template tags with raw tags", () => {
19
+ const input = "{% if condition %}";
20
+ const expected = "{% raw %}{%{% endraw %} if condition {% raw %}%}{% endraw %}";
21
+ assert.equal(transformAutoRaw(input), expected);
22
+ });
23
+
24
+ it("should wrap closing template tags with raw tags", () => {
25
+ const input = "{% endif %}";
26
+ const expected = "{% raw %}{%{% endraw %} endif {% raw %}%}{% endraw %}";
27
+ assert.equal(transformAutoRaw(input), expected);
28
+ });
29
+
30
+ it("should handle multiple Nunjucks patterns in one string", () => {
31
+ const input = "{{ var1 }} and {% if test %} something {% endif %}";
32
+ const expected =
33
+ "{% raw %}{{{% endraw %} var1 {% raw %}}}{% endraw %} and {% raw %}{%{% endraw %} if test {% raw %}%}{% endraw %} something {% raw %}{%{% endraw %} endif {% raw %}%}{% endraw %}";
34
+ assert.equal(transformAutoRaw(input), expected);
35
+ });
36
+
37
+ it("should handle multiline content with Nunjucks syntax", () => {
38
+ const input = `# Title
39
+ {{ variable }}
40
+ Some text
41
+ {% for item in items %}
42
+ {{ item }}
43
+ {% endfor %}`;
44
+ const expected = `# Title
45
+ {% raw %}{{{% endraw %} variable {% raw %}}}{% endraw %}
46
+ Some text
47
+ {% raw %}{%{% endraw %} for item in items {% raw %}%}{% endraw %}
48
+ {% raw %}{{{% endraw %} item {% raw %}}}{% endraw %}
49
+ {% raw %}{%{% endraw %} endfor {% raw %}%}{% endraw %}`;
50
+ assert.equal(transformAutoRaw(input), expected);
51
+ });
52
+
53
+ it("should return unchanged content when no Nunjucks syntax is present", () => {
54
+ const input = "This is just plain text with no templates.";
55
+ assert.equal(transformAutoRaw(input), input);
56
+ });
57
+
58
+ it("should handle empty string", () => {
59
+ assert.equal(transformAutoRaw(""), "");
60
+ });
61
+
62
+ it("should handle content with only Nunjucks syntax", () => {
63
+ const input = "{{}}";
64
+ const expected = "{% raw %}{{{% endraw %}{% raw %}}}{% endraw %}";
65
+ assert.equal(transformAutoRaw(input), expected);
66
+ });
67
+
68
+ it("should handle consecutive Nunjucks patterns", () => {
69
+ const input = "{{{{}}}}";
70
+ const expected = "{% raw %}{{{% endraw %}{% raw %}{{{% endraw %}{% raw %}}}{% endraw %}{% raw %}}}{% endraw %}";
71
+ assert.equal(transformAutoRaw(input), expected);
72
+ });
73
+
74
+ it("should wrap each delimiter individually", () => {
75
+ const input = "Show {{ and }} and {% and %}";
76
+ const expected =
77
+ "Show {% raw %}{{{% endraw %} and {% raw %}}}{% endraw %} and {% raw %}{%{% endraw %} and {% raw %}%}{% endraw %}";
78
+ assert.equal(transformAutoRaw(input), expected);
79
+ });
80
+ });
@@ -0,0 +1,26 @@
1
+ /*<!--section:docs-->
2
+
3
+ `mdAutoUncommentAttrs` feature amends the markdown library to automatically expand
4
+ HTML-comment-wrapped attribute blocks `<!—-{...}-->` to their raw form
5
+ `{...}`, which is useful when attribute syntax needs to be hidden from
6
+ HTML parsers but expanded before markdown-it processes them.
7
+
8
+ Implemented as a core rule so the transformation runs on the raw source
9
+ before markdown-it-attrs (or any other plugin) parses the content.
10
+
11
+ <!--section:code-->```js */
12
+ export function transformUncommentAttrs(content) {
13
+ if (content.includes("<!--{")) {
14
+ content = content.replace(/<!--(\{[^}]*\})-->/g, "$1");
15
+ }
16
+ return content;
17
+ }
18
+
19
+ export default function mdAutoUncommentAttrs(eleventyConfig) {
20
+ eleventyConfig.amendLibrary("md", (mdLib) => {
21
+ mdLib.core.ruler.before("normalize", "uncomment_attrs", (state) => {
22
+ state.src = transformUncommentAttrs(state.src);
23
+ });
24
+ });
25
+ }
26
+ //```
@@ -0,0 +1,65 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { transformUncommentAttrs } from "./mdAutoUncommentAttrs.js";
4
+
5
+ describe("transformUncommentAttrs", () => {
6
+ it("should expand a single <!--{...}--> to {...}", () => {
7
+ const input = "Some text <!--{.class}-->";
8
+ const expected = "Some text {.class}";
9
+ assert.equal(transformUncommentAttrs(input), expected);
10
+ });
11
+
12
+ it("should expand multiple <!--{...}--> occurrences", () => {
13
+ const input = "<!--{.foo}--> text <!--{.bar}-->";
14
+ const expected = "{.foo} text {.bar}";
15
+ assert.equal(transformUncommentAttrs(input), expected);
16
+ });
17
+
18
+ it("should handle attributes with spaces inside braces", () => {
19
+ const input = "Heading <!--{ .class #id }-->";
20
+ const expected = "Heading { .class #id }";
21
+ assert.equal(transformUncommentAttrs(input), expected);
22
+ });
23
+
24
+ it("should handle multiline content", () => {
25
+ const input = `# Title <!--{.heading}-->
26
+ Some paragraph.
27
+ > Blockquote <!--{.note}-->`;
28
+ const expected = `# Title {.heading}
29
+ Some paragraph.
30
+ > Blockquote {.note}`;
31
+ assert.equal(transformUncommentAttrs(input), expected);
32
+ });
33
+
34
+ it("should leave regular HTML comments untouched", () => {
35
+ const input = "<!-- this is a comment -->";
36
+ assert.equal(transformUncommentAttrs(input), input);
37
+ });
38
+
39
+ it("should leave <!--{...}--> intact when the content contains a closing brace inside", () => {
40
+ // The regex [^}]* stops at the first }, so nested } prevents a full match
41
+ const input = "<!--{{nested}}-->";
42
+ assert.equal(transformUncommentAttrs(input), input);
43
+ });
44
+
45
+ it("should not touch plain text with no comments", () => {
46
+ const input = "Just plain text with {.class} already exposed.";
47
+ assert.equal(transformUncommentAttrs(input), input);
48
+ });
49
+
50
+ it("should handle empty string", () => {
51
+ assert.equal(transformUncommentAttrs(""), "");
52
+ });
53
+
54
+ it("should handle back-to-back patterns without separator", () => {
55
+ const input = "<!--{.a}--><!--{.b}-->";
56
+ const expected = "{.a}{.b}";
57
+ assert.equal(transformUncommentAttrs(input), expected);
58
+ });
59
+
60
+ it("should preserve surrounding markdown content", () => {
61
+ const input = "| Cell 1 <!--{.highlight}--> | Cell 2 |";
62
+ const expected = "| Cell 1 {.highlight} | Cell 2 |";
63
+ assert.equal(transformUncommentAttrs(input), expected);
64
+ });
65
+ });
@@ -0,0 +1,43 @@
1
+ /*<!--section:docs-->
2
+
3
+ `siteData` adds global `site` data via `eleventyComputed`. All scalar fields from `pkg.site` (`package.json`) and `data.site` (`_data/site.*`) are spread onto `site`; page-level wins over pkg. The asset keys below are **merged** (pkg → site) rather than overridden.
4
+
5
+ | Variable | Value / Description |
6
+ | -------------------- | ---------------------------------------------------------------------------- |
7
+ | `{{ site.year }}` | Current year (e.g. `2026`) |
8
+ | `{{ site.prod }}` | `true` for `eleventy build`, `false` for `eleventy serve` |
9
+ | `{{ site.styles }}` | Merged array of stylesheet URLs |
10
+ | `{{ site.scripts }}` | Merged array of script URLs |
11
+ | `{{ site.head_extras }}` | Merged array of custom head HTML/strings |
12
+ | `{{ site.body_extras }}` | Merged array of custom body HTML/strings |
13
+
14
+ The named export `siteData(data)` is also usable directly (e.g. as RSS feed metadata).
15
+
16
+ <!--section:code-->```js */
17
+ const MERGED_KEYS = ["styles", "scripts", "head_extras", "body_extras"];
18
+
19
+ // Lodash style
20
+ export const castArray = (val) => (val == null ? [] : Array.isArray(val) ? val : [val]);
21
+
22
+ export const concatUnique = (a, b) => [...new Set([...castArray(a), ...castArray(b)])];
23
+
24
+ export const siteData = (data) => {
25
+ const pkgSite = data.pkg?.site ?? {};
26
+ const dataSite = data.site ?? {};
27
+ return {
28
+ ...pkgSite,
29
+ ...dataSite,
30
+ ...Object.fromEntries(MERGED_KEYS.map((key) => [key, concatUnique(pkgSite[key], dataSite[key])])),
31
+ };
32
+ };
33
+
34
+ export default function (eleventyConfig) {
35
+ eleventyConfig.addGlobalData("eleventyComputed", {
36
+ site: (data) => ({
37
+ ...siteData(data),
38
+ prod: process.env.ELEVENTY_RUN_MODE === "build",
39
+ year: new Date().getFullYear(),
40
+ }),
41
+ });
42
+ }
43
+ //```
@@ -0,0 +1,120 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { siteData, castArray, concatUnique } from "./siteData.js";
4
+
5
+ describe("siteData", () => {
6
+ it("should merge keys from pkg.site and site, ignoring page-level data[key]", () => {
7
+ const data = {
8
+ pkg: {
9
+ site: {
10
+ title: "My Site",
11
+ head_extras: ["<meta name='pkg-meta' />"],
12
+ body_extras: ["<script src='pkg-script.js'></script>"],
13
+ styles: ["pkg.css"],
14
+ scripts: ["pkg.js"],
15
+ }
16
+ },
17
+ site: {
18
+ title: "Override Site",
19
+ head_extras: ["<meta name='site-meta' />"],
20
+ body_extras: ["<script src='site-script.js'></script>"],
21
+ styles: ["site.css"],
22
+ scripts: ["site.js"],
23
+ },
24
+ head_extras: ["<meta name='page-meta' />"],
25
+ body_extras: ["<script src='page-script.js'></script>"],
26
+ styles: ["page.css"],
27
+ scripts: ["page.js"],
28
+ };
29
+
30
+ const result = siteData(data);
31
+
32
+ assert.equal(result.title, "Override Site");
33
+ assert.deepEqual(result.head_extras, ["<meta name='pkg-meta' />", "<meta name='site-meta' />"]);
34
+ assert.deepEqual(result.body_extras, ["<script src='pkg-script.js'></script>", "<script src='site-script.js'></script>"]);
35
+ assert.deepEqual(result.styles, ["pkg.css", "site.css"]);
36
+ assert.deepEqual(result.scripts, ["pkg.js", "site.js"]);
37
+ });
38
+
39
+ it("should handle missing keys gracefully", () => {
40
+ const data = {
41
+ pkg: {},
42
+ site: {},
43
+ };
44
+ const result = siteData(data);
45
+ assert.deepEqual(result.head_extras, []);
46
+ assert.deepEqual(result.body_extras, []);
47
+ assert.deepEqual(result.styles, []);
48
+ assert.deepEqual(result.scripts, []);
49
+ });
50
+
51
+ it("should deduplicate merged keys using Set", () => {
52
+ const data = {
53
+ pkg: {
54
+ site: {
55
+ styles: ["pkg.css", "duplicate.css"],
56
+ }
57
+ },
58
+ site: {
59
+ styles: ["duplicate.css", "site.css"],
60
+ }
61
+ };
62
+ const result = siteData(data);
63
+ assert.deepEqual(result.styles, ["pkg.css", "duplicate.css", "site.css"]);
64
+ });
65
+
66
+ it("should support string values instead of arrays for merged keys", () => {
67
+ const data = {
68
+ pkg: {
69
+ site: {
70
+ styles: "pkg.css",
71
+ scripts: ["pkg.js"],
72
+ }
73
+ },
74
+ site: {
75
+ styles: ["site.css"],
76
+ scripts: "site.js",
77
+ }
78
+ };
79
+ const result = siteData(data);
80
+ assert.deepEqual(result.styles, ["pkg.css", "site.css"]);
81
+ assert.deepEqual(result.scripts, ["pkg.js", "site.js"]);
82
+ });
83
+ });
84
+
85
+ describe("castArray", () => {
86
+ it("should return an empty array for null or undefined", () => {
87
+ assert.deepEqual(castArray(null), []);
88
+ assert.deepEqual(castArray(undefined), []);
89
+ });
90
+
91
+ it("should return the same array reference if given an array", () => {
92
+ const arr = [1, 2, 3];
93
+ assert.equal(castArray(arr), arr);
94
+ });
95
+
96
+ it("should wrap non-array, non-null values in an array", () => {
97
+ assert.deepEqual(castArray("hello"), ["hello"]);
98
+ assert.deepEqual(castArray(123), [123]);
99
+ assert.deepEqual(castArray({ a: 1 }), [{ a: 1 }]);
100
+ });
101
+ });
102
+
103
+ describe("concatUnique", () => {
104
+ it("should concatenate and deduplicate arrays", () => {
105
+ assert.deepEqual(concatUnique([1, 2], [2, 3]), [1, 2, 3]);
106
+ });
107
+
108
+ it("should handle null or undefined arguments gracefully", () => {
109
+ assert.deepEqual(concatUnique(null, [1, 2]), [1, 2]);
110
+ assert.deepEqual(concatUnique([1, 2], undefined), [1, 2]);
111
+ assert.deepEqual(concatUnique(null, undefined), []);
112
+ });
113
+
114
+ it("should handle single values or mixed types", () => {
115
+ assert.deepEqual(concatUnique("a", "b"), ["a", "b"]);
116
+ assert.deepEqual(concatUnique("a", ["a", "b"]), ["a", "b"]);
117
+ });
118
+ });
119
+
120
+
@@ -0,0 +1,18 @@
1
+ /*<!--section:docs-->
2
+
3
+ `virtualPages` generates Eleventy pages from a `pages.yaml` file in the input directory. Each entry's front-matter data is passed directly to `addTemplate`; if the entry has a `permalink` field it is used to derive the virtual slug (e.g. `permalink: /about/` → `.about/index.md`), otherwise the array index is used.
4
+
5
+ <!--section:code-->```js */
6
+ import { readFileSync } from "fs";
7
+ import YAML from "js-yaml";
8
+
9
+ export default function (eleventyConfig) {
10
+ // Virtual pages
11
+ const pages = YAML.load(readFileSync(eleventyConfig.directories.input + "/pages.yaml", "utf8"));
12
+ for (const [index, data] of pages.entries()) {
13
+ const virtualSlug = data.permalink ? data.permalink + "index" : index;
14
+ // console.log(data, virtualSlug);
15
+ eleventyConfig.addTemplate("." + virtualSlug + ".md", "", data);
16
+ }
17
+ }
18
+ //```
@@ -0,0 +1,55 @@
1
+ /*<!--section:docs-->
2
+
3
+ A filter that concatenates values to an attribute array, returning a new object with the combined array. Useful for adding items to arrays like tags, classes, or other list-based attributes.
4
+
5
+ <!--section:code-->```js */
6
+ export default function (obj, attr, values) {
7
+ // Get the existing attribute value, default to empty array if not present
8
+ const existingArray = obj?.[attr] || [];
9
+
10
+ // Check if existing value is an array, convert if not
11
+ if (!Array.isArray(existingArray)) {
12
+ console.error(`attrConcat: Expected ${attr} to be an array, got ${typeof existingArray}`);
13
+ }
14
+
15
+ // Process the values argument
16
+ let valuesToAdd = [];
17
+ if (Array.isArray(values)) {
18
+ valuesToAdd = values;
19
+ } else if (typeof values === "string" && values.length >= 2 && values.at(0) == "[" && values.at(-1) == "]") {
20
+ // Try to parse as JSON array
21
+ try {
22
+ const parsed = JSON.parse(values);
23
+ if (Array.isArray(parsed)) {
24
+ valuesToAdd = parsed;
25
+ } else {
26
+ valuesToAdd = [values];
27
+ }
28
+ } catch {
29
+ // Not valid JSON, treat as single value
30
+ valuesToAdd = [values];
31
+ }
32
+ } else {
33
+ // If it's a single value, wrap it in an array
34
+ valuesToAdd = [values];
35
+ }
36
+
37
+ // Combine arrays and remove duplicates using Set
38
+ const combinedArray = [...new Set([...existingArray, ...valuesToAdd])];
39
+
40
+ // Return a new object with the combined array
41
+ return {
42
+ ...obj,
43
+ [attr]: combinedArray,
44
+ };
45
+ }
46
+ /*```<!--section:docs-->
47
+
48
+ ### Examples
49
+
50
+ Add tags to a post object in `.njk`:
51
+
52
+ ```jinja2
53
+ {% set enhancedPost = post | attr_concat('tags', ['featured', 'popular']) %}
54
+ ```
55
+ */