@videinfra/static-website-builder 2.2.2 → 2.3.1

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/CHANGELOG.md CHANGED
@@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file.
4
4
  The format is based on [Keep a Changelog](http://keepachangelog.com/)
5
5
  and this project adheres to [Semantic Versioning](http://semver.org/).
6
6
 
7
+ ## [2.3.0] - 2026-04-09
8
+ ### Addded
9
+ - Translation support for TWIG
10
+
7
11
  ## [2.2.2] - 2026-04-08
8
12
  ### Fixed
9
13
  - Fixed gulp-sass missing error message
@@ -6,9 +6,6 @@
6
6
  * in each tasks config.js file
7
7
  */
8
8
 
9
- import * as sassPlugin from '@videinfra/static-website-builder/plugins/sass';
10
- import * as twigPlugin from '@videinfra/static-website-builder/plugins/twig';
11
-
12
9
  export const clean = {};
13
10
  export const staticFiles = {};
14
11
  export const html = {};
@@ -20,15 +17,20 @@ export const javascripts = {};
20
17
  export const stylesheets = {};
21
18
  export const browserSync = {};
22
19
  export const sizereport = {};
20
+ export const translations = {};
23
21
 
24
22
  export const plugins = [
25
23
  // Enables SASS engine and .sass and .scss file compilation
26
- sassPlugin,
24
+ (await import('@videinfra/static-website-builder/plugins/sass.js')),
27
25
 
28
26
  // Enables TwigJS engine .twig file compilation
29
- twigPlugin,
30
- ];
27
+ (await import('@videinfra/static-website-builder/plugins/twig.js')),
31
28
 
29
+ // Additional twig plugins
30
+ (await import('@videinfra/static-website-builder/plugins/twig/symfony-filters.js')),
31
+ (await import('@videinfra/static-website-builder/plugins/twig/lodash-filters.js')),
32
+ (await import('@videinfra/static-website-builder/plugins/twig/symfony-functions.js')),
33
+ ];
32
34
 
33
35
  /*
34
36
  * Path configuration
@@ -5,6 +5,7 @@
5
5
  import * as sassPlugin from '../../../plugins/sass.js';
6
6
  import * as twigPlugin from '../../../plugins/twig.js';
7
7
  import * as symfonyFiltersPlugin from '../../../plugins/twig/symfony-filters.js';
8
+ import * as symfonyFunctionsPlugin from '../../../plugins/twig/symfony-functions.js';
8
9
 
9
10
  export const clean = {};
10
11
  export const staticFiles = {};
@@ -46,6 +47,9 @@ export const plugins = [
46
47
 
47
48
  // Enables TWIG Symfony filters
48
49
  symfonyFiltersPlugin,
50
+
51
+ // Enables TWIG Symfony functions
52
+ symfonyFunctionsPlugin,
49
53
  ];
50
54
 
51
55
  export const env = {
@@ -65,6 +69,11 @@ export const env = {
65
69
  },
66
70
  };
67
71
 
72
+ export const translations = {
73
+ locales: ['en', 'de'],
74
+ defaultLocale: 'en',
75
+ };
76
+
68
77
  /*
69
78
  * Path configuration
70
79
  * All options will be merged with defaults, but not replaces whole configuration object
@@ -0,0 +1 @@
1
+ {% include '../translation.twig' %}
@@ -0,0 +1,15 @@
1
+ <p>ROUTE: {{ app.request.attributes.get('_route') }}</p>
2
+ <p>PATH: {{ path(app.request.attributes.get('_route')) }}</p>
3
+ <p>DE_PATH: {{ path(app.request.attributes.get('_route'), { _locale: 'de' }) }}</p>
4
+ <p>EN_PATH: {{ path(app.request.attributes.get('_route'), { _locale: 'en' }) }}</p>
5
+ <p>HOMEPAGE PATH: {{ path('app.homepage') }}</p>
6
+ <p>HOMEPAGE DE_PATH: {{ path('app.homepage', { _locale: 'de' }) }}</p>
7
+ <p>HOMEPAGE EN_PATH: {{ path('app.homepage', { _locale: 'en' }) }}</p>
8
+
9
+ <p>TITLE: {{ 'title'|trans({}, 'test') }}</p>
10
+ <p>CATEGORY: {{ 'category.name'|trans({}, 'test') }}</p>
11
+ <p>MISSING: {{ 'missing'|trans({}, 'test') }}</p>
12
+ <p>INVALID_GROUP: {{ 'invalid_group'|trans({}, 'invalid_group') }}</p>
13
+ <p>MISSING_ATTRIBUTES: {{ 'missing_attributes'|trans }}</p>
14
+
15
+ <p>TITLE 2: {{ 'title'|trans({}, 'test-2') }}</p>
@@ -0,0 +1 @@
1
+ title: Test 2
@@ -0,0 +1,3 @@
1
+ title: 'Titel'
2
+ category:
3
+ name: 'Kategorie'
@@ -0,0 +1,3 @@
1
+ title: 'Title'
2
+ category:
3
+ name: 'Category'
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@videinfra/static-website-builder",
3
- "version": "2.2.2",
3
+ "version": "2.3.1",
4
4
  "description": "Customizable static site project builder",
5
5
  "license": "MIT",
6
6
  "engines": {
@@ -50,6 +50,7 @@
50
50
  "gulp-sizereport": "^1.2.1",
51
51
  "gulp-sourcemaps": "^3.0.0",
52
52
  "gulp-svgstore": "^9.0.0",
53
+ "js-yaml": "^4.1.1",
53
54
  "lodash.clone": "^4.3.2",
54
55
  "lodash.some": "^4.2.2",
55
56
  "minimist": "^1.2.8",
@@ -1,5 +1,6 @@
1
1
  import { getTaskConfig } from '../../../lib/get-config.js';
2
2
  import { loadEnvData } from '../../../tasks/env/get-env.js';
3
+ import getTranslations from '../../../tasks/translations/get-translations.js';
3
4
  import preposition_nbsp from './preposition_nbsp.js';
4
5
 
5
6
  const exports = [];
@@ -99,4 +100,38 @@ exports.push({
99
100
  }
100
101
  });
101
102
 
103
+ /**
104
+ * Translation filter
105
+ *
106
+ * @example
107
+ * {{ 'hello world' | trans }}
108
+ */
109
+ exports.push({
110
+ name: 'trans',
111
+ func: function (data, args) {
112
+ const group = args[1];
113
+ const currentPagePath = this.context.currentPagePath;
114
+ const locales = this.context.app.request.languages;
115
+ let locale = currentPagePath.split('/')[1];
116
+
117
+ if (!locales.includes(locale)) {
118
+ locale = this.context.app.request.defaultLocale;
119
+ }
120
+
121
+ // Find translation
122
+ let translations = getTranslations(locale, group);
123
+ const dataParts = data.split('.');
124
+
125
+ for (let i = 0; i < dataParts.length; i++) {
126
+ if (translations && dataParts[i] in translations) {
127
+ translations = translations[dataParts[i]];
128
+ } else {
129
+ return data;
130
+ }
131
+ }
132
+
133
+ return translations;
134
+ }
135
+ });
136
+
102
137
  export default exports;
@@ -27,5 +27,24 @@ export default [
27
27
  const normalizedPath = (path || path === 0 ? String(path) : '');
28
28
  return applyFilter('version', applyFilter('cdnify', normalizedPath));
29
29
  }
30
- }
30
+ },
31
+
32
+ // Fake "path" filter, it tries to replicate Symfony's path() function
33
+ {
34
+ name: 'path',
35
+ func: function (path, args) {
36
+ // Find locale information
37
+ const locale = args?._locale || this.context.app.request.locale;
38
+ const defaultLocale = this.context.app.request.defaultLocale;
39
+ const pathPrefix = locale === defaultLocale ? '' : `/${locale}`;
40
+
41
+ if (path === 'app.homepage') {
42
+ return `${pathPrefix}/`;
43
+ } else if (path.startsWith('app.')) {
44
+ return `${pathPrefix}/${path.slice(4).replace(/_/g, '-')}`;
45
+ } else {
46
+ return `${pathPrefix}${path}`;
47
+ }
48
+ },
49
+ },
31
50
  ];
@@ -1,5 +1,6 @@
1
1
  import dataLoaderJs from './data-loader-js.js';
2
2
  import dataLoaderJson from './data-loader-json.js';
3
+ import dataLoaderYml from './data-loader-yml.js';
3
4
 
4
5
  /**
5
6
  * Data loading for HTML task
@@ -13,6 +14,7 @@ export const data = {
13
14
  loaders: {
14
15
  js: dataLoaderJs,
15
16
  json: dataLoaderJson,
17
+ yml: dataLoaderYml,
16
18
  },
17
19
 
18
20
  // Glob list of files, which to ignore, relative to the data source folder
@@ -0,0 +1,6 @@
1
+ import fs from 'fs';
2
+ import yaml from 'js-yaml';
3
+
4
+ export default function dataLoaderYML(fileName) {
5
+ return yaml.load(fs.readFileSync(fileName, 'utf8'));
6
+ }
@@ -80,6 +80,29 @@ export default function (options) {
80
80
  const build = options && !!options.build;
81
81
  const htmlSourceFolders = getSourcePaths('html');
82
82
 
83
+ // Find locale information
84
+ const translationConfig = getTaskConfig('translations');
85
+ const locales = translationConfig.locales;
86
+ const defaultLocale = translationConfig.defaultLocale;
87
+
88
+ /**
89
+ * Create symfony request parameter
90
+ * @param {object} values
91
+ * @returns {object}
92
+ */
93
+ function symfonyRequestProperty(values) {
94
+ values = values || {};
95
+ return {
96
+ all: values,
97
+ get: function (name) {
98
+ return name in values ? values[name] : null;
99
+ },
100
+ has: function (name) {
101
+ return !!(name in values);
102
+ },
103
+ };
104
+ }
105
+
83
106
  /**
84
107
  * Calculate current page path based on file path which is being processed
85
108
  * @param {*} file
@@ -109,27 +132,85 @@ export default function (options) {
109
132
  return currentPagePath;
110
133
  }
111
134
 
135
+ const getRouteFromPath = function (path) {
136
+ if (!path || path === '/') {
137
+ return 'app.homepage';
138
+ } else {
139
+ return path;
140
+ }
141
+ }
142
+
112
143
  return function (file) {
113
144
  // We expose `currentPagePath` to Twig templates
114
145
  const currentPagePath = getCurrentPagePath(file);
115
146
 
147
+ // Resolve locales
148
+ let currentPagePathWithoutLocale = currentPagePath;
149
+ let locale = currentPagePath.split('/')[1];
150
+
151
+ if (!locales.includes(locale)) {
152
+ locale = defaultLocale;
153
+ } else {
154
+ currentPagePathWithoutLocale = currentPagePath.replace(`/${locale}`, '') || '/';
155
+ }
156
+
157
+ const symonyAppData = {
158
+ app: {
159
+ environment: global.production ? 'prod' : 'dev',
160
+ debug: false,
161
+
162
+ request: {
163
+ content: null,
164
+ languages: locales,
165
+ charsets: null,
166
+ encodings: null,
167
+ acceptableContentTypes: null,
168
+ pathInfo: currentPagePath,
169
+ requestUri: currentPagePath,
170
+ baseUrl: '',
171
+ basePath: null,
172
+ method: 'GET',
173
+ format: null,
174
+ locale: locale,
175
+ defaultLocale: defaultLocale,
176
+
177
+ attributes: symfonyRequestProperty({
178
+ _locale: locale,
179
+ _route: getRouteFromPath(currentPagePathWithoutLocale),
180
+ _route_params: {
181
+ _locale: locale,
182
+ },
183
+ }),
184
+ query: symfonyRequestProperty(),
185
+ server: symfonyRequestProperty(),
186
+ files: symfonyRequestProperty(),
187
+ cookies: symfonyRequestProperty(),
188
+ headers: symfonyRequestProperty(),
189
+
190
+ get: function () {
191
+ return null;
192
+ },
193
+ }
194
+ }
195
+ };
196
+
116
197
  if (build) {
117
198
  // Cache during full build
118
199
  if (!cache) {
119
200
  cache = getData();
120
201
  }
121
202
 
122
- return {
203
+ return merge({
123
204
  currentPagePath,
124
- ...cache
125
- };
205
+ ...cache,
206
+ }, symonyAppData);
126
207
  } else {
127
208
  // Don't cache during watch build
128
209
  cache = null;
129
- return {
210
+ return merge({
130
211
  currentPagePath,
131
212
  ...getData(),
132
- };
213
+ }, symonyAppData);
133
214
  }
134
215
  };
135
216
  }
@@ -0,0 +1,29 @@
1
+ import preprocessTranslationsConfig from './preprocess-config.js';
2
+
3
+ /**
4
+ * Translations loading for HTML task
5
+ * This task doesn't have actual task, instead it's used by 'html' task
6
+ */
7
+
8
+ export const translations = {
9
+ // List of locales, must be set only if there is more than 1 locale
10
+ locales: [],
11
+
12
+ // Default locale
13
+ defaultLocale: '',
14
+ };
15
+
16
+ /**
17
+ * Paths relative to the global src and dest folders
18
+ */
19
+ export const paths = {
20
+ translations: {
21
+ src: 'translations',
22
+ },
23
+ };
24
+
25
+ export const preprocess = {
26
+ translations: [
27
+ preprocessTranslationsConfig,
28
+ ]
29
+ };
@@ -0,0 +1,49 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+
4
+ import merge from '../../lib/merge.js';
5
+ import { getSourcePaths, getPathConfig } from '../../lib/get-path.js';
6
+ import logError from '../../lib/log-error.js';
7
+ import dataLoaderYml from '../data/data-loader-yml.js';
8
+
9
+ let cache = {};
10
+ let cacheTime = null;
11
+
12
+ export default function getTranslations(locale, group) {
13
+ const build = global.production;
14
+
15
+ // Invalidate cache after 1 second
16
+ if (!build && cacheTime && Date.now() - cacheTime > 1000) {
17
+ cache = {};
18
+ }
19
+
20
+ cacheTime = Date.now();
21
+
22
+ if (cache[locale] && cache[locale][group]) {
23
+ return cache[locale][group];
24
+ } else {
25
+ const folders = getSourcePaths('translations');
26
+
27
+ folders.forEach((folder) => {
28
+ const fullFilePath = path.resolve(folder, `${group}.${locale}.yml`);
29
+
30
+ if (fs.existsSync(fullFilePath)) {
31
+ try {
32
+ const fileData = dataLoaderYml(fullFilePath);
33
+ cache = merge(cache, { [locale]: { [group]: fileData }});
34
+ } catch (err) {
35
+ logError({
36
+ message: `Failed to parse "${path.join(getPathConfig().src, getPathConfig().data.src, fileName)}"`,
37
+ plugin: 'data',
38
+ });
39
+ }
40
+ }
41
+ });
42
+
43
+ if (cache[locale] && cache[locale][group]) {
44
+ return cache[locale][group];
45
+ } else {
46
+ return {};
47
+ }
48
+ }
49
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Modify configuration
3
+ *
4
+ * @param {object} config Translations configuration
5
+ * @returns {object} Transformed Translations configuration
6
+ */
7
+ export default function preprocessTranslationsConfig (config = {}) {
8
+ if ((!config.locales || !config.locales.length) && config.defaultLocale) {
9
+ config.locales = [config.defaultLocale];
10
+ } else if (config.locales && config.locales.length && !config.defaultLocale) {
11
+ config.defaultLocale = config.locales[0];
12
+ } else if ((!config.locales || !config.locales.length) && !config.defaultLocale) {
13
+ // Assume "en" as default
14
+ config.locales = ['en'];
15
+ config.defaultLocale = 'en';
16
+ }
17
+
18
+ return config;
19
+ }
@@ -0,0 +1,57 @@
1
+ import path from 'path';
2
+ import { fileURLToPath } from 'node:url';
3
+ import fsPromises from 'fs/promises';
4
+
5
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
6
+ const publicPath = path.resolve(__dirname, 'public');
7
+
8
+ test('Translation EN paths', () => {
9
+ return fsPromises.readFile(path.resolve(publicPath, 'translation.html'), { encoding: 'utf8' }).then((html) => {
10
+ expect(html.indexOf('<p>ROUTE: /translation</p>')).not.toBe(-1);
11
+ expect(html.indexOf('<p>PATH: /translation</p>')).not.toBe(-1);
12
+ expect(html.indexOf('<p>DE_PATH: /de/translation</p>')).not.toBe(-1);
13
+ expect(html.indexOf('<p>EN_PATH: /translation</p>')).not.toBe(-1);
14
+
15
+ expect(html.indexOf('<p>HOMEPAGE PATH: /</p>')).not.toBe(-1);
16
+ expect(html.indexOf('<p>HOMEPAGE DE_PATH: /de/</p>')).not.toBe(-1);
17
+ expect(html.indexOf('<p>HOMEPAGE EN_PATH: /</p>')).not.toBe(-1);
18
+ });
19
+ });
20
+
21
+ test('Translation DE paths', () => {
22
+ return fsPromises.readFile(path.resolve(publicPath, 'de/translation.html'), { encoding: 'utf8' }).then((html) => {
23
+ expect(html.indexOf('<p>ROUTE: /translation</p>')).not.toBe(-1);
24
+ expect(html.indexOf('<p>PATH: /de/translation</p>')).not.toBe(-1);
25
+ expect(html.indexOf('<p>DE_PATH: /de/translation</p>')).not.toBe(-1);
26
+ expect(html.indexOf('<p>EN_PATH: /translation</p>')).not.toBe(-1);
27
+
28
+ expect(html.indexOf('<p>HOMEPAGE PATH: /de/</p>')).not.toBe(-1);
29
+ expect(html.indexOf('<p>HOMEPAGE DE_PATH: /de/</p>')).not.toBe(-1);
30
+ expect(html.indexOf('<p>HOMEPAGE EN_PATH: /</p>')).not.toBe(-1);
31
+ });
32
+ });
33
+
34
+ test('Translation EN', () => {
35
+ return fsPromises.readFile(path.resolve(publicPath, 'translation.html'), { encoding: 'utf8' }).then((html) => {
36
+ expect(html.indexOf('<p>TITLE: Title</p>')).not.toBe(-1);
37
+ expect(html.indexOf('<p>CATEGORY: Category</p>')).not.toBe(-1);
38
+
39
+ // Missing translation file
40
+ expect(html.indexOf('<p>TITLE 2: Test 2</p>')).not.toBe(-1);
41
+
42
+ // Missing translations just produce same string
43
+ expect(html.indexOf('<p>MISSING: missing</p>')).not.toBe(-1);
44
+ expect(html.indexOf('<p>INVALID_GROUP: invalid_group</p>')).not.toBe(-1);
45
+ expect(html.indexOf('<p>MISSING_ATTRIBUTES: missing_attributes</p>')).not.toBe(-1);
46
+ });
47
+ });
48
+
49
+ test('Translation DE', () => {
50
+ return fsPromises.readFile(path.resolve(publicPath, 'de/translation.html'), { encoding: 'utf8' }).then((html) => {
51
+ expect(html.indexOf('<p>TITLE: Titel</p>')).not.toBe(-1);
52
+ expect(html.indexOf('<p>CATEGORY: Kategorie</p>')).not.toBe(-1);
53
+
54
+ // Missing translation file
55
+ expect(html.indexOf('<p>TITLE 2: title</p>')).not.toBe(-1);
56
+ });
57
+ });