@anydigital/11ty-bricks 1.0.0-alpha → 1.0.0-alpha.11

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.
package/src/bricks.js ADDED
@@ -0,0 +1,125 @@
1
+ export function bricks(eleventyConfig) {
2
+
3
+ // Brick Registry System
4
+ // Global registry to track dependencies per page
5
+ const brickRegistry = new Map();
6
+
7
+ // Helper to get or create page registry
8
+ function getPageRegistry(page) {
9
+ const pageUrl = page.url || page.outputPath || 'default';
10
+ if (!brickRegistry.has(pageUrl)) {
11
+ brickRegistry.set(pageUrl, {
12
+ dependencies: new Set(), // Raw dependencies (URLs) - categorized later
13
+ inlineStyles: new Set(),
14
+ inlineScripts: new Set()
15
+ });
16
+ }
17
+ return brickRegistry.get(pageUrl);
18
+ }
19
+
20
+ // Clear registry before each build
21
+ eleventyConfig.on("eleventy.before", async () => {
22
+ brickRegistry.clear();
23
+ });
24
+
25
+ // brick shortcode: registers and renders a brick component
26
+ eleventyConfig.addShortcode("brick", function(brickModule, ...args) {
27
+ const registry = getPageRegistry(this.page);
28
+
29
+ if (!brickModule) return '';
30
+
31
+ // Register external dependencies (categorized later in transform)
32
+ if (brickModule.dependencies) {
33
+ brickModule.dependencies.forEach(dep => {
34
+ registry.dependencies.add(dep);
35
+ });
36
+ }
37
+
38
+ // Register inline styles directly from style variable
39
+ if (brickModule.style && brickModule.style.trim()) {
40
+ registry.inlineStyles.add(brickModule.style);
41
+ }
42
+
43
+ // Register inline scripts directly from script variable
44
+ if (brickModule.script && brickModule.script.trim()) {
45
+ registry.inlineScripts.add(brickModule.script);
46
+ }
47
+
48
+ // Render the brick using render() macro
49
+ if (brickModule.render && typeof brickModule.render === 'function') {
50
+ return brickModule.render(...args);
51
+ }
52
+
53
+ return '';
54
+ });
55
+
56
+ // bricksRegistry shortcode: outputs placeholder and base dependencies
57
+ eleventyConfig.addShortcode("bricksDependencies", function(dependencies = []) {
58
+ const registry = getPageRegistry(this.page);
59
+
60
+ // Register root dependencies if provided (categorized later in transform)
61
+ if (dependencies && Array.isArray(dependencies)) {
62
+ dependencies.forEach(dep => {
63
+ registry.dependencies.add(dep);
64
+ });
65
+ }
66
+
67
+ // Return placeholder comment that will be replaced by transform
68
+ return '<!-- BRICK_DEPENDENCIES_PLACEHOLDER -->';
69
+ });
70
+
71
+ // Transform to inject collected dependencies
72
+ eleventyConfig.addTransform("injectBrickDependencies", function(content, outputPath) {
73
+ if (!outputPath || !outputPath.endsWith(".html")) {
74
+ return content;
75
+ }
76
+
77
+ const pageUrl = this.page?.url || this.page?.outputPath || outputPath;
78
+ const registry = brickRegistry.get(pageUrl);
79
+
80
+ if (!registry || !content.includes('<!-- BRICK_DEPENDENCIES_PLACEHOLDER -->')) {
81
+ return content;
82
+ }
83
+
84
+ // Categorize dependencies by type
85
+ const externalStyles = [];
86
+ const externalScripts = [];
87
+
88
+ registry.dependencies.forEach(dep => {
89
+ // Categorize by type
90
+ if (dep.endsWith('.css') || dep.includes('.css?')) {
91
+ externalStyles.push(dep);
92
+ } else if (dep.endsWith('.js') || dep.includes('.js?')) {
93
+ externalScripts.push(dep);
94
+ }
95
+ });
96
+
97
+ // Build HTML for dependencies
98
+ let dependenciesHtml = '\n';
99
+
100
+ // Add external CSS links
101
+ externalStyles.forEach(href => {
102
+ dependenciesHtml += ` <link rel="stylesheet" href="${href}">\n`;
103
+ });
104
+
105
+ // Add inline styles
106
+ registry.inlineStyles.forEach(style => {
107
+ dependenciesHtml += ` <style>${style}</style>\n`;
108
+ });
109
+
110
+ // Add external script links
111
+ externalScripts.forEach(src => {
112
+ dependenciesHtml += ` <script src="${src}"></script>\n`;
113
+ });
114
+
115
+ // Add inline scripts
116
+ registry.inlineScripts.forEach(script => {
117
+ dependenciesHtml += ` <script>${script}</script>\n`;
118
+ });
119
+
120
+ dependenciesHtml += ' ';
121
+
122
+ // Replace placeholder with actual dependencies
123
+ return content.replace('<!-- BRICK_DEPENDENCIES_PLACEHOLDER -->', dependenciesHtml);
124
+ });
125
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * byAttr filter - Filter collection items by attribute value
3
+ *
4
+ * This filter takes a collection, an attribute name, and a target value,
5
+ * and returns items where the attribute matches the target value.
6
+ * If the attribute is an array, it checks if the array includes the target value.
7
+ *
8
+ * @param {Object} eleventyConfig - The Eleventy configuration object
9
+ */
10
+ export function byAttrFilter(eleventyConfig) {
11
+ eleventyConfig.addFilter("byAttr", function(collection, attrName, targetValue) {
12
+ if (!collection || !Array.isArray(collection)) {
13
+ return [];
14
+ }
15
+
16
+ return collection.filter(item => {
17
+ // Get the attribute value from the item's data
18
+ const attrValue = item?.data?.[attrName] ?? item?.[attrName];
19
+
20
+ // If attribute doesn't exist, skip this item
21
+ if (attrValue === undefined || attrValue === null) {
22
+ return false;
23
+ }
24
+
25
+ // If the attribute is an array, check if it includes the target value
26
+ if (Array.isArray(attrValue)) {
27
+ return attrValue.includes(targetValue);
28
+ }
29
+
30
+ // Otherwise, do a direct comparison
31
+ return attrValue === targetValue;
32
+ });
33
+ });
34
+ }
35
+
@@ -0,0 +1,105 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import { byAttrFilter } from './byAttrFilter.js';
4
+
5
+ describe('byAttr filter', () => {
6
+ let filterFn;
7
+
8
+ // Mock eleventyConfig to capture the filter function
9
+ const mockEleventyConfig = {
10
+ addFilter(name, fn) {
11
+ if (name === 'byAttr') {
12
+ filterFn = fn;
13
+ }
14
+ }
15
+ };
16
+
17
+ // Register the filter
18
+ byAttrFilter(mockEleventyConfig);
19
+
20
+ it('should filter items by exact attribute match', () => {
21
+ const collection = [
22
+ { data: { category: 'blog' }, title: 'Post 1' },
23
+ { data: { category: 'news' }, title: 'Post 2' },
24
+ { data: { category: 'blog' }, title: 'Post 3' }
25
+ ];
26
+
27
+ const result = filterFn(collection, 'category', 'blog');
28
+ assert.strictEqual(result.length, 2);
29
+ assert.strictEqual(result[0].title, 'Post 1');
30
+ assert.strictEqual(result[1].title, 'Post 3');
31
+ });
32
+
33
+ it('should filter items when attribute is an array (includes check)', () => {
34
+ const collection = [
35
+ { data: { tags: ['javascript', 'tutorial'] }, title: 'Post 1' },
36
+ { data: { tags: ['python', 'tutorial'] }, title: 'Post 2' },
37
+ { data: { tags: ['javascript', 'advanced'] }, title: 'Post 3' }
38
+ ];
39
+
40
+ const result = filterFn(collection, 'tags', 'javascript');
41
+ assert.strictEqual(result.length, 2);
42
+ assert.strictEqual(result[0].title, 'Post 1');
43
+ assert.strictEqual(result[1].title, 'Post 3');
44
+ });
45
+
46
+ it('should return empty array when collection is not an array', () => {
47
+ const result = filterFn(null, 'category', 'blog');
48
+ assert.strictEqual(result.length, 0);
49
+ });
50
+
51
+ it('should filter out items without the specified attribute', () => {
52
+ const collection = [
53
+ { data: { category: 'blog' }, title: 'Post 1' },
54
+ { data: {}, title: 'Post 2' },
55
+ { data: { category: 'blog' }, title: 'Post 3' }
56
+ ];
57
+
58
+ const result = filterFn(collection, 'category', 'blog');
59
+ assert.strictEqual(result.length, 2);
60
+ });
61
+
62
+ it('should work with attribute directly on item (not in data)', () => {
63
+ const collection = [
64
+ { category: 'blog', title: 'Post 1' },
65
+ { category: 'news', title: 'Post 2' },
66
+ { category: 'blog', title: 'Post 3' }
67
+ ];
68
+
69
+ const result = filterFn(collection, 'category', 'blog');
70
+ assert.strictEqual(result.length, 2);
71
+ });
72
+
73
+ it('should handle mixed data structures', () => {
74
+ const collection = [
75
+ { data: { category: 'blog' }, title: 'Post 1' },
76
+ { category: 'blog', title: 'Post 2' },
77
+ { data: { category: 'news' }, title: 'Post 3' }
78
+ ];
79
+
80
+ const result = filterFn(collection, 'category', 'blog');
81
+ assert.strictEqual(result.length, 2);
82
+ });
83
+
84
+ it('should handle array that does not include target value', () => {
85
+ const collection = [
86
+ { data: { tags: ['python', 'tutorial'] }, title: 'Post 1' },
87
+ { data: { tags: ['ruby', 'guide'] }, title: 'Post 2' }
88
+ ];
89
+
90
+ const result = filterFn(collection, 'tags', 'javascript');
91
+ assert.strictEqual(result.length, 0);
92
+ });
93
+
94
+ it('should handle different value types', () => {
95
+ const collection = [
96
+ { data: { priority: 1 }, title: 'Post 1' },
97
+ { data: { priority: 2 }, title: 'Post 2' },
98
+ { data: { priority: 1 }, title: 'Post 3' }
99
+ ];
100
+
101
+ const result = filterFn(collection, 'priority', 1);
102
+ assert.strictEqual(result.length, 2);
103
+ });
104
+ });
105
+
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { readFile, writeFile, mkdir } from 'fs/promises';
4
+ import { dirname, resolve, join } from 'path';
5
+
6
+ /**
7
+ * Parse command line arguments
8
+ *
9
+ * @returns {Object} Parsed arguments
10
+ */
11
+ function parseArgs() {
12
+ const args = process.argv.slice(2);
13
+ const parsed = {
14
+ outputDir: null
15
+ };
16
+
17
+ for (let i = 0; i < args.length; i++) {
18
+ const arg = args[i];
19
+
20
+ if (arg === '--output' || arg === '-o') {
21
+ if (i + 1 < args.length) {
22
+ parsed.outputDir = args[i + 1];
23
+ i++; // Skip next argument
24
+ } else {
25
+ throw new Error(`${arg} requires a directory path`);
26
+ }
27
+ }
28
+ }
29
+
30
+ return parsed;
31
+ }
32
+
33
+ /**
34
+ * Downloads files specified in package.json's _downloadFiles field
35
+ *
36
+ * @param {string|null} outputDir - Optional output directory to prepend to all paths
37
+ * @returns {Promise<boolean>} True if all downloads succeeded, false if any failed
38
+ */
39
+ async function download(outputDir = null) {
40
+ try {
41
+ // Find and read package.json from the current working directory
42
+ const packageJsonPath = resolve(process.cwd(), 'package.json');
43
+ const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf-8'));
44
+
45
+ const downloadFiles = packageJson._downloadFiles;
46
+
47
+ if (!downloadFiles || typeof downloadFiles !== 'object') {
48
+ console.log('No _downloadFiles field found in package.json');
49
+ return true;
50
+ }
51
+
52
+ const entries = Object.entries(downloadFiles);
53
+
54
+ if (entries.length === 0) {
55
+ console.log('No files to download (_downloadFiles is empty)');
56
+ return true;
57
+ }
58
+
59
+ console.log(`Starting download of ${entries.length} file(s)...\n`);
60
+
61
+ let hasErrors = false;
62
+
63
+ // Process all downloads
64
+ for (const entry of entries) {
65
+ const url = entry[0];
66
+ let localPath = entry[1];
67
+
68
+ try {
69
+ console.log(`Downloading: ${url}`);
70
+ console.log(` To: ${localPath}`);
71
+
72
+ // Download the file
73
+ const response = await fetch(url);
74
+
75
+ if (!response.ok) {
76
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
77
+ }
78
+
79
+ // Get the file content
80
+ const content = await response.arrayBuffer();
81
+ const buffer = Buffer.from(content);
82
+
83
+ // Prepend output directory to local path if specified
84
+ if (outputDir) {
85
+ localPath = join(outputDir, localPath);
86
+ }
87
+
88
+ // Resolve the full path
89
+ const fullPath = resolve(process.cwd(), localPath);
90
+
91
+ // Create directory if it doesn't exist
92
+ const dir = dirname(fullPath);
93
+ await mkdir(dir, { recursive: true });
94
+
95
+ // Write the file
96
+ await writeFile(fullPath, buffer);
97
+
98
+ console.log(` Success: ${localPath}\n`);
99
+ } catch (error) {
100
+ hasErrors = true;
101
+ console.error(` Error: ${error.message}`);
102
+ console.error(` URL: ${url}\n`);
103
+ }
104
+ }
105
+
106
+ // Summary
107
+ if (hasErrors) {
108
+ console.log('Download completed with errors');
109
+ return false;
110
+ } else {
111
+ console.log('All downloads completed successfully');
112
+ return true;
113
+ }
114
+
115
+ } catch (error) {
116
+ console.error(`Fatal error: ${error.message}`);
117
+ return false;
118
+ }
119
+ }
120
+
121
+ /**
122
+ * CLI entry point
123
+ */
124
+ async function main() {
125
+ try {
126
+ const args = parseArgs();
127
+ const success = await download(args.outputDir);
128
+ process.exit(success ? 0 : 1);
129
+ } catch (error) {
130
+ console.error(`Error: ${error.message}`);
131
+ process.exit(1);
132
+ }
133
+ }
134
+
135
+ main();
136
+
@@ -0,0 +1,34 @@
1
+ import { readFileSync } from "fs";
2
+ import { join } from "path";
3
+
4
+ /**
5
+ * fragment shortcode - Include content from fragments
6
+ *
7
+ * This shortcode reads a file from the _fragments directory and includes
8
+ * its content. The content will be processed by the template engine.
9
+ *
10
+ * @param {Object} eleventyConfig - The Eleventy configuration object
11
+ */
12
+ export function fragments(eleventyConfig) {
13
+ eleventyConfig.addShortcode("fragment", function(path) {
14
+ // Get the input directory from Eleventy's context
15
+ const inputDir = this.page?.inputPath
16
+ ? join(process.cwd(), eleventyConfig.dir?.input || ".")
17
+ : process.cwd();
18
+
19
+ // Construct the full path to the fragment file
20
+ const fragmentPath = join(inputDir, "_fragments", path);
21
+
22
+ try {
23
+ // Read the fragment file
24
+ const content = readFileSync(fragmentPath, "utf8");
25
+
26
+ // Return content to be processed by the template engine
27
+ return content;
28
+ } catch (error) {
29
+ console.error(`Error reading fragment at ${fragmentPath}:`, error.message);
30
+ return `<!-- Fragment not found: ${path} -->`;
31
+ }
32
+ });
33
+ }
34
+
package/src/index.cjs CHANGED
@@ -9,9 +9,18 @@ module.exports = async function eleventyBricksPlugin(eleventyConfig, options) {
9
9
  return plugin(eleventyConfig, options);
10
10
  };
11
11
 
12
- // Export individual helpers
13
- module.exports.autoRaw = async function(eleventyConfig) {
14
- const { autoRaw } = await import('./index.js');
15
- return autoRaw(eleventyConfig);
16
- };
12
+ // Export individual helpers for granular usage
13
+ ['bricks', 'mdAutoRawTags', 'mdAutoNl2br', 'fragments', 'setAttrFilter', 'byAttrFilter', 'siteData'].forEach(name => {
14
+ module.exports[name] = async (eleventyConfig) => {
15
+ const module = await import('./index.js');
16
+ return module[name](eleventyConfig);
17
+ };
18
+ });
17
19
 
20
+ // Export transform functions for advanced usage
21
+ ['transformAutoRaw', 'transformNl2br'].forEach(name => {
22
+ module.exports[name] = async (content) => {
23
+ const module = await import('./index.js');
24
+ return module[name](content);
25
+ };
26
+ });
package/src/index.js CHANGED
@@ -1,4 +1,9 @@
1
- import { autoRaw } from "./autoRaw.js";
1
+ import { bricks } from "./bricks.js";
2
+ import { mdAutoRawTags, mdAutoNl2br, transformAutoRaw, transformNl2br } from "./markdown.js";
3
+ import { fragments } from "./fragments.js";
4
+ import { setAttrFilter } from "./setAttrFilter.js";
5
+ import { byAttrFilter } from "./byAttrFilter.js";
6
+ import { siteData } from "./siteData.js";
2
7
 
3
8
  /**
4
9
  * 11ty Bricks Plugin
@@ -8,17 +13,21 @@ import { autoRaw } from "./autoRaw.js";
8
13
  *
9
14
  * @param {Object} eleventyConfig - The Eleventy configuration object
10
15
  * @param {Object} options - Plugin options
11
- * @param {boolean} options.autoRaw - Enable autoRaw preprocessor (default: false)
16
+ * @param {boolean} options.bricks - Enable bricks system with dependencies injection (default: false)
17
+ * @param {boolean} options.mdAutoRawTags - Enable mdAutoRawTags preprocessor (default: false)
18
+ * @param {boolean} options.mdAutoNl2br - Enable mdAutoNl2br for \n to <br> conversion (default: false)
19
+ * @param {boolean} options.fragments - Enable fragment shortcode (default: false)
20
+ * @param {boolean} options.setAttrFilter - Enable setAttr filter (default: false)
21
+ * @param {boolean} options.byAttrFilter - Enable byAttr filter (default: false)
22
+ * @param {boolean} options.siteData - Enable site.year and site.isProd global data (default: false)
12
23
  */
13
24
  export default function eleventyBricksPlugin(eleventyConfig, options = {}) {
14
- const { autoRaw: enableAutoRaw = false } = options;
15
-
16
- // Register helpers based on options
17
- if (enableAutoRaw) {
18
- autoRaw(eleventyConfig);
19
- }
25
+ const plugins = { bricks, mdAutoRawTags, mdAutoNl2br, fragments, setAttrFilter, byAttrFilter, siteData };
26
+ Object.entries(options).forEach(([key, enabled]) => enabled && plugins[key]?.(eleventyConfig));
20
27
  }
21
28
 
22
29
  // Export individual helpers for granular usage
23
- export { autoRaw };
30
+ export { bricks, mdAutoRawTags, mdAutoNl2br, fragments, setAttrFilter, byAttrFilter, siteData };
24
31
 
32
+ // Export transform functions for advanced usage
33
+ export { transformAutoRaw, transformNl2br };
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Transform Nunjucks syntax in content by wrapping it with raw tags
3
+ *
4
+ * This function wraps Nunjucks syntax ({{, }}, {%, %}) with {% raw %} tags
5
+ * to prevent them from being processed by the template engine.
6
+ *
7
+ * @param {string} content - The content to transform
8
+ * @returns {string} The transformed content with Nunjucks syntax wrapped
9
+ */
10
+ export function transformAutoRaw(content) {
11
+ // This regex looks for {{, }}, {%, or %} individually and wraps them
12
+ return content.replace(/({{|}}|{%|%})/g, "{% raw %}$1{% endraw %}");
13
+ }
14
+
15
+ /**
16
+ * mdAutoRawTags - Forbid Nunjucks processing in Markdown files
17
+ *
18
+ * This preprocessor wraps Nunjucks syntax ({{, }}, {%, %}) with {% raw %} tags
19
+ * to prevent them from being processed by the template engine in Markdown files.
20
+ *
21
+ * @param {Object} eleventyConfig - The Eleventy configuration object
22
+ */
23
+ export function mdAutoRawTags(eleventyConfig) {
24
+ eleventyConfig.addPreprocessor("mdAutoRawTags", "md", (data, content) => {
25
+ return transformAutoRaw(content);
26
+ });
27
+ }
28
+
29
+ /**
30
+ * Transform \n sequences to <br> tags
31
+ *
32
+ * This function converts literal \n sequences (double backslash + n) to HTML <br> tags.
33
+ * It handles both double \n\n and single \n sequences, processing double ones first.
34
+ *
35
+ * @param {string} content - The content to transform
36
+ * @returns {string} The transformed content with \n converted to <br>
37
+ */
38
+ export function transformNl2br(content) {
39
+ // Replace double \n\n first, then single \n to avoid double conversion
40
+ return content.replace(/\\n\\n/g, '<br>').replace(/\\n/g, '<br>');
41
+ }
42
+
43
+ /**
44
+ * mdAutoNl2br - Auto convert \n to <br> in markdown (especially tables)
45
+ *
46
+ * This function amends the markdown library to automatically convert \n
47
+ * to <br> tags in text content, which is particularly useful for line breaks
48
+ * inside markdown tables where standard newlines don't work.
49
+ *
50
+ * @param {Object} eleventyConfig - The Eleventy configuration object
51
+ */
52
+ export function mdAutoNl2br(eleventyConfig) {
53
+ eleventyConfig.amendLibrary("md", mdLib => {
54
+ mdLib.renderer.rules.text = (tokens, idx) => {
55
+ return transformNl2br(tokens[idx].content);
56
+ };
57
+ });
58
+ }
@@ -1,6 +1,6 @@
1
1
  import { describe, it } from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { transformAutoRaw } from "./autoRaw.js";
3
+ import { transformAutoRaw, transformNl2br } from "./markdown.js";
4
4
 
5
5
  describe("transformAutoRaw", () => {
6
6
  it("should wrap opening double curly braces with raw tags", () => {
@@ -85,3 +85,68 @@ Some text
85
85
  });
86
86
  });
87
87
 
88
+ describe("transformNl2br", () => {
89
+ it("should convert single \\n to <br>", () => {
90
+ const input = "Line 1\\nLine 2";
91
+ const expected = "Line 1<br>Line 2";
92
+ assert.equal(transformNl2br(input), expected);
93
+ });
94
+
95
+ it("should convert double \\n\\n to <br>", () => {
96
+ const input = "Line 1\\n\\nLine 2";
97
+ const expected = "Line 1<br>Line 2";
98
+ assert.equal(transformNl2br(input), expected);
99
+ });
100
+
101
+ it("should convert multiple \\n sequences", () => {
102
+ const input = "Line 1\\nLine 2\\nLine 3";
103
+ const expected = "Line 1<br>Line 2<br>Line 3";
104
+ assert.equal(transformNl2br(input), expected);
105
+ });
106
+
107
+ it("should handle mixed single and double \\n", () => {
108
+ const input = "Line 1\\n\\nLine 2\\nLine 3";
109
+ const expected = "Line 1<br>Line 2<br>Line 3";
110
+ assert.equal(transformNl2br(input), expected);
111
+ });
112
+
113
+ it("should handle text without \\n", () => {
114
+ const input = "Just plain text";
115
+ assert.equal(transformNl2br(input), input);
116
+ });
117
+
118
+ it("should handle empty content", () => {
119
+ assert.equal(transformNl2br(""), "");
120
+ });
121
+
122
+ it("should handle content with only \\n", () => {
123
+ const input = "\\n\\n\\n";
124
+ const expected = "<br><br>";
125
+ assert.equal(transformNl2br(input), expected);
126
+ });
127
+
128
+ it("should handle markdown table cell content with \\n", () => {
129
+ const input = "Cell 1\\nCell 1 Line 2\\n\\nCell 1 Line 3";
130
+ const expected = "Cell 1<br>Cell 1 Line 2<br>Cell 1 Line 3";
131
+ assert.equal(transformNl2br(input), expected);
132
+ });
133
+
134
+ it("should handle multiple consecutive double \\n\\n", () => {
135
+ const input = "Line 1\\n\\n\\n\\nLine 2";
136
+ const expected = "Line 1<br><br>Line 2";
137
+ assert.equal(transformNl2br(input), expected);
138
+ });
139
+
140
+ it("should preserve actual newlines (not literal \\n)", () => {
141
+ const input = "Line 1\nLine 2";
142
+ const expected = "Line 1\nLine 2";
143
+ assert.equal(transformNl2br(input), expected);
144
+ });
145
+
146
+ it("should only convert literal backslash-n sequences", () => {
147
+ const input = "Text with\\nbackslash-n and\nreal newline";
148
+ const expected = "Text with<br>backslash-n and\nreal newline";
149
+ assert.equal(transformNl2br(input), expected);
150
+ });
151
+ });
152
+
@@ -0,0 +1,17 @@
1
+ /**
2
+ * setAttr filter - Override an attribute and return the object
3
+ *
4
+ * This filter takes an object, a key, and a value, and returns a new object
5
+ * with the specified attribute set to the given value.
6
+ *
7
+ * @param {Object} eleventyConfig - The Eleventy configuration object
8
+ */
9
+ export function setAttrFilter(eleventyConfig) {
10
+ eleventyConfig.addFilter("setAttr", function(obj, key, value) {
11
+ return {
12
+ ...obj,
13
+ [key]: value
14
+ };
15
+ });
16
+ }
17
+
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Add site.year and site.isProd global data
3
+ * - site.isProd: Boolean indicating if running in production mode (build) vs development (serve)
4
+ * - site.year: Sets the current year to be available in all templates as {{ site.year }}
5
+ *
6
+ * @param {Object} eleventyConfig - The Eleventy configuration object
7
+ */
8
+ export function siteData(eleventyConfig) {
9
+ eleventyConfig.addGlobalData("site.isProd", () => process.env.ELEVENTY_RUN_MODE === "build");
10
+ eleventyConfig.addGlobalData("site.year", () => new Date().getFullYear());
11
+ }
12
+
@@ -0,0 +1,13 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <link rel="dns-prefetch" href="https://unpkg.com">
7
+ <title>Sveltia CMS</title>
8
+ </head>
9
+ <body>
10
+ <!-- Include the script that builds the page and powers Sveltia CMS -->
11
+ <script src="https://unpkg.com/@sveltia/cms/dist/sveltia-cms.js" type="module"></script>
12
+ </body>
13
+ </html>