apostrophe 3.11.0 → 3.12.0

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
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.12.0 - 2022-01-21
4
+
5
+ ### Adds
6
+
7
+ * It is now best practice to deliver namespaced i18n strings as JSON files in module-level subdirectories of `i18n/` named to match the namespace, e.g. `i18n/ourTeam` if the namespace is `ourTeam`. This allows base class modules to deliver phrases to any namespace without conflicting with those introduced at project level. The `i18n` option is now deprecated in favor of the new `i18n` module format section, which is only needed if `browser: true` must be specified for a namespace.
8
+ * Brought back the `nestedModuleSubdirs` feature from A2, which allows modules to be nested in subdirectories if `nestedModuleSubdirs: true` is set in `app.js`. As in A2, module configuration (including activation) can also be grouped in a `modules.js` file in such subdirectories.
9
+
10
+ ### Fixes
11
+
12
+ * Fixes minor inline documentation comments.
13
+ * UI strings that are not registered localization keys will now display properly when they contain a colon (`:`). These were previously interpreted as i18next namespace/key pairs and the "namespace" portion was left out.
14
+ * Fixes a bug where changing the page type immediately after clicking "New Page" would produce a console error. In general, areas and checkboxes now correctly handle their value being changed to `null` by the parent schema after initial startup of the `AposInputArea` or `AposInputCheckboxes` component.
15
+ * It is now best practice to deliver namespaced i18n strings as JSON files in module-level subdirectories of `i18n/` named to match the namespace, e.g. `i18n/ourTeam` if the namespace is `ourTeam`. This allows base class modules to deliver phrases to any namespace without conflicting with those introduced at project level. The `i18n` option is now deprecated in favor of the new `i18n` module format section, which is only needed if `browser: true` must be specified for a namespace.
16
+ * Removes the `@apostrophecms/util` module template helper `indexBy`, which was using a lodash method not included in lodash v4.
17
+ * Removes an unimplemented `csrfExceptions` module section cascade. Use the `csrfExceptions` *option* of any module to set an array of URLs excluded from CSRF protection. More information is forthcoming in the documentation.
18
+ * Fix `[Object Object]` in the console when warning `A permission.can() call was made with a type that has no manager` is printed.
19
+
20
+ ### Changes
21
+
22
+ * Temporarily removes `npm audit` from our automated tests because of a sub-dependency of vue-loader that doesn't actually cause a security vulnerability for apostrophe.
23
+
3
24
  ## 3.11.0 - 2022-01-06
4
25
 
5
26
  ### Adds
@@ -9,6 +30,7 @@
9
30
  ### Fixes
10
31
 
11
32
  * Apostrophe's extension of `req.login` now accounts for the `req.logIn` alias and the skippable `options` parameter, which is relied upon in some `passport` strategies.
33
+ * Apostrophe now warns if a nonexistent widget type is configured for an area field, with special attention to when `-widget` has been erroneously included in the name. For backwards compatibility this is a startup warning rather than a fatal error, as sites generally did operate successfully otherwise with this type of bug present.
12
34
 
13
35
  ### Changes
14
36
 
@@ -16,6 +38,7 @@
16
38
  * Adds deprecation note to `__testDefaults` option. It is not in use, but removing would be a minor BC break we don't need to make.
17
39
  * Allows test modules to use a custom port as an option on the `@apostrophecms/express` module.
18
40
  * Removes the code base pull request template to instead inherit the organization-level template.
41
+ * Adds `npm audit` back to the test scripts.
19
42
 
20
43
  ## 3.10.0 - 2021-12-22
21
44
 
package/index.js CHANGED
@@ -7,6 +7,7 @@ const cluster = require('cluster');
7
7
  const { cpus } = require('os');
8
8
  const process = require('process');
9
9
  const npmResolve = require('resolve');
10
+ const glob = require('glob');
10
11
 
11
12
  let defaults = require('./defaults.js');
12
13
 
@@ -273,6 +274,33 @@ module.exports = async function(options) {
273
274
  return _module;
274
275
  }
275
276
 
277
+ function nestedModuleSubdirs() {
278
+ if (!options.nestedModuleSubdirs) {
279
+ return;
280
+ }
281
+ const configs = glob.sync(self.localModules + '/**/modules.js');
282
+ _.each(configs, function(config) {
283
+ try {
284
+ _.merge(self.options.modules, require(config));
285
+ } catch (e) {
286
+ console.error(stripIndent`
287
+ When nestedModuleSubdirs is active, any modules.js file beneath:
288
+
289
+ ${self.localModules}
290
+
291
+ must export an object containing configuration for Apostrophe modules.
292
+
293
+ The file:
294
+
295
+ ${config}
296
+
297
+ did not parse.
298
+ `);
299
+ throw e;
300
+ }
301
+ });
302
+ }
303
+
276
304
  function autodetectBundles() {
277
305
  const modules = _.keys(self.options.modules);
278
306
  _.each(modules, function(name) {
@@ -406,7 +434,13 @@ module.exports = async function(options) {
406
434
  localModules: self.localModules,
407
435
  defaultBaseClass: '@apostrophecms/module',
408
436
  sections: [ 'helpers', 'handlers', 'routes', 'apiRoutes', 'restApiRoutes', 'renderRoutes', 'middleware', 'customTags', 'components', 'tasks' ],
409
- unparsedSections: [ 'queries', 'extendQueries', 'icons' ]
437
+ nestedModuleSubdirs: self.options.nestedModuleSubdirs,
438
+ unparsedSections: [
439
+ 'queries',
440
+ 'extendQueries',
441
+ 'icons',
442
+ 'i18n'
443
+ ]
410
444
  });
411
445
 
412
446
  self.synth = synth;
@@ -417,6 +451,8 @@ module.exports = async function(options) {
417
451
  self.redefine = self.synth.redefine;
418
452
  self.create = self.synth.create;
419
453
 
454
+ nestedModuleSubdirs();
455
+
420
456
  _.each(self.options.modules, function(options, name) {
421
457
  synth.define(name, options);
422
458
  });
package/lib/moog.js CHANGED
@@ -369,9 +369,9 @@ module.exports = function(options) {
369
369
  }
370
370
 
371
371
  function capture(section) {
372
- that[section] = {};
372
+ that.__meta[section] = {};
373
373
  for (const step of steps) {
374
- that[section][step.__meta.name] = step[section];
374
+ that.__meta[section][step.__meta.name] = step[section];
375
375
  }
376
376
  }
377
377
 
@@ -675,10 +675,10 @@ module.exports = {
675
675
  },
676
676
 
677
677
  // Insert the given document. Called by `.insert()`. You will usually want to
678
- // call the update method of the appropriate doc type manager instead:
678
+ // call the insert method of the appropriate doc type manager instead:
679
679
  //
680
680
  // ```javascript
681
- // self.apos.doc.getManager(doc.type).update(...)
681
+ // self.apos.doc.getManager(doc.type).insert(...)
682
682
  // ```
683
683
  //
684
684
  // However you can override this method to alter the
@@ -10,6 +10,7 @@ const fs = require('fs');
10
10
  const _ = require('lodash');
11
11
  const { stripIndent } = require('common-tags');
12
12
  const ExpressSessionCookie = require('express-session/session/cookie');
13
+ const path = require('path');
13
14
 
14
15
  const apostropheI18nDebugPlugin = {
15
16
  type: 'postProcessor',
@@ -380,21 +381,65 @@ module.exports = {
380
381
  // Add the i18next resources provided by the specified module,
381
382
  // merging with any existing phrases for the same locales and namespaces
382
383
  addResourcesForModule(module) {
383
- if (!module.options.i18n) {
384
- return;
385
- }
386
- const ns = module.options.i18n.ns || 'default';
384
+ self.addDefaultResourcesForModule(module);
385
+ self.addNamespacedResourcesForModule(module);
386
+ },
387
+ // Automatically adds any localizations found in .json files in the main `i18n` subdirectory
388
+ // of a module.
389
+ //
390
+ // These are added to the `default` namespace, unless the legacy `i18n.ns` option is set
391
+ // for the module (not the preferred way, use namespace subdirectories in new projects).
392
+ addDefaultResourcesForModule(module) {
393
+ const ns = (module.options.i18n && module.options.i18n.ns) || 'default';
387
394
  self.namespaces[ns] = self.namespaces[ns] || {};
388
- self.namespaces[ns].browser = self.namespaces[ns].browser || !!module.options.i18n.browser;
395
+ self.namespaces[ns].browser = self.namespaces[ns].browser || (module.options.i18n && module.options.i18n.browser);
389
396
  for (const entry of module.__meta.chain) {
390
- const localizationsDir = `${entry.dirname}/i18n`;
391
- if (!fs.existsSync(localizationsDir)) {
392
- continue;
397
+ const localizationsDir = path.join(entry.dirname, 'i18n');
398
+ if (!self.defaultLocalizationsDirsAdded.has(localizationsDir)) {
399
+ self.defaultLocalizationsDirsAdded.add(localizationsDir);
400
+ if (!fs.existsSync(localizationsDir)) {
401
+ continue;
402
+ }
403
+ for (const localizationFile of fs.readdirSync(localizationsDir)) {
404
+ if (!localizationFile.endsWith('.json')) {
405
+ // Likely a namespace subdirectory
406
+ continue;
407
+ }
408
+ const data = JSON.parse(fs.readFileSync(path.join(localizationsDir, localizationFile)));
409
+ const locale = localizationFile.replace('.json', '');
410
+ self.i18next.addResourceBundle(locale, ns, data, true, true);
411
+ }
393
412
  }
394
- for (const localizationFile of fs.readdirSync(localizationsDir)) {
395
- const data = JSON.parse(fs.readFileSync(`${localizationsDir}/${localizationFile}`));
396
- const locale = localizationFile.replace('.json', '');
397
- self.i18next.addResourceBundle(locale, ns, data, true, true);
413
+ }
414
+ },
415
+ // Automatically adds any localizations found in subdirectories of the main `i18n`
416
+ // subdirectory of a module. The subdirectory's name is treated as an i18n namespace
417
+ // name.
418
+ addNamespacedResourcesForModule(module) {
419
+ for (const entry of module.__meta.chain) {
420
+ const metadata = module.__meta.i18n[entry.name] || {};
421
+ const localizationsDir = `${entry.dirname}/i18n`;
422
+ if (!self.namespacedLocalizationsDirsAdded.has(localizationsDir)) {
423
+ self.namespacedLocalizationsDirsAdded.add(localizationsDir);
424
+ if (!fs.existsSync(localizationsDir)) {
425
+ continue;
426
+ }
427
+ for (const ns of fs.readdirSync(localizationsDir)) {
428
+ if (ns.endsWith('.json')) {
429
+ // A JSON file for the default namespace, already handled
430
+ continue;
431
+ }
432
+ self.namespaces[ns] = self.namespaces[ns] || {};
433
+ self.namespaces[ns].browser = self.namespaces[ns].browser ||
434
+ (metadata[ns] && metadata[ns].browser);
435
+ const namespaceDir = path.join(localizationsDir, ns);
436
+ for (const localizationFile of fs.readdirSync(namespaceDir)) {
437
+ const fullLocalizationFile = path.join(namespaceDir, localizationFile);
438
+ const data = JSON.parse(fs.readFileSync(fullLocalizationFile));
439
+ const locale = localizationFile.replace('.json', '');
440
+ self.i18next.addResourceBundle(locale, ns, data, true, true);
441
+ }
442
+ }
398
443
  }
399
444
  }
400
445
  },
@@ -402,6 +447,8 @@ module.exports = {
402
447
  // itself, called by init. Later modules call addResourcesForModule(self),
403
448
  // making phrases available gradually as Apostrophe starts up
404
449
  addInitialResources() {
450
+ self.defaultLocalizationsDirsAdded = new Set();
451
+ self.namespacedLocalizationsDirsAdded = new Set();
405
452
  for (const module of Object.values(self.apos.modules)) {
406
453
  self.addResourcesForModule(module);
407
454
  }
@@ -29,10 +29,20 @@ const _ = require('lodash');
29
29
 
30
30
  module.exports = {
31
31
 
32
- cascades: [ 'csrfExceptions' ],
33
-
34
32
  init(self) {
35
33
  self.apos = self.options.apos;
34
+ const capturedSections = [
35
+ 'queries',
36
+ 'extendQueries',
37
+ 'icons'
38
+ ];
39
+ for (const section of capturedSections) {
40
+ // Unparsed sections are now captured in __meta, promote
41
+ // these to the top level to maintain bc. For new unparsed
42
+ // sections we'll leave them in `__meta` to avoid bc breaks
43
+ // with project-level properties of the module
44
+ self[section] = self.__meta[section];
45
+ }
36
46
  // all apostrophe modules are properties of self.apos.modules.
37
47
  // Those with an alias are also properties of self.apos
38
48
  self.apos.modules[self.__meta.name] = self;
@@ -63,7 +63,7 @@ module.exports = {
63
63
  const doc = (docOrType && docOrType._id) ? docOrType : null;
64
64
  const manager = type && self.apos.doc.getManager(type);
65
65
  if (type && !manager) {
66
- self.apos.util.warn(`A permission.can() call was made with a type that has no manager: ${type}`);
66
+ self.apos.util.warn('A permission.can() call was made with a type that has no manager:', type);
67
67
  return false;
68
68
  }
69
69
  if (action === 'view') {
@@ -566,7 +566,7 @@ module.exports = {
566
566
  },
567
567
  //
568
568
  // Update a piece. Convenience wrapper for `apos.doc.insert`.
569
- // Returns the piece. `beforeInsert`, `beforeSave`, `afterInsert`
569
+ // Returns the piece. `beforeUpdate`, `beforeSave`, `afterUpdate`
570
570
  // and `afterSave` async events are emitted by this module.
571
571
  async update(req, piece, options) {
572
572
  return self.apos.doc.update(req, piece, options);
@@ -18,7 +18,7 @@ const _ = require('lodash');
18
18
  const dayjs = require('dayjs');
19
19
  const tinycolor = require('tinycolor2');
20
20
  const { klona } = require('klona');
21
- const { stripIndent } = require('common-tags');
21
+ const { stripIndents } = require('common-tags');
22
22
 
23
23
  module.exports = {
24
24
  options: {
@@ -74,6 +74,22 @@ module.exports = {
74
74
  return true;
75
75
  }
76
76
  return _.isEqual(one[field.name], two[field.name]);
77
+ },
78
+ validate: function (field, options, warn, fail) {
79
+ if (field.options && field.options.widgets) {
80
+ for (const name of Object.keys(field.options.widgets)) {
81
+ if (!self.apos.modules[`${name}-widget`]) {
82
+ if (name.match(/-widget$/)) {
83
+ warn(stripIndents`
84
+ Do not include "-widget" in the name when configuring a widget in an area field.
85
+ Apostrophe will automatically add "-widget" when looking for the right module.
86
+ `);
87
+ } else {
88
+ warn(`Nonexistent widget type name ${name} in area field.`);
89
+ }
90
+ }
91
+ }
92
+ }
77
93
  }
78
94
  });
79
95
 
@@ -2247,7 +2263,7 @@ module.exports = {
2247
2263
  self.apos.util.error(format(s));
2248
2264
  }
2249
2265
  function format(s) {
2250
- return stripIndent`
2266
+ return stripIndents`
2251
2267
  ${options.type} ${options.subtype}, ${field.type} field "${field.name}":
2252
2268
 
2253
2269
  ${s}
@@ -39,11 +39,7 @@ export default {
39
39
  mixins: [ AposInputMixin ],
40
40
  data () {
41
41
  return {
42
- next: this.value.data || {
43
- metaType: 'area',
44
- _id: cuid(),
45
- items: []
46
- },
42
+ next: this.value.data || this.getEmptyValue(),
47
43
  error: false,
48
44
  // This is just meant to be sufficient to prevent unintended collisions
49
45
  // in the UI between id attributes
@@ -66,6 +62,17 @@ export default {
66
62
  }
67
63
  },
68
64
  methods: {
65
+ getEmptyValue() {
66
+ return {
67
+ metaType: 'area',
68
+ _id: cuid(),
69
+ items: []
70
+ };
71
+ },
72
+ watchValue () {
73
+ this.error = this.value.error;
74
+ this.next = this.value.data || this.getEmptyValue();
75
+ },
69
76
  validate(value) {
70
77
  if (this.field.required) {
71
78
  if (!value.items.length) {
@@ -32,6 +32,10 @@ export default {
32
32
  getChoiceId(uid, value) {
33
33
  return uid + value.replace(/\s/g, '');
34
34
  },
35
+ watchValue () {
36
+ this.error = this.value.error;
37
+ this.next = this.value.data || [];
38
+ },
35
39
  validate(values) {
36
40
  // The choices and values should always be arrays.
37
41
  if (!Array.isArray(this.field.choices) || !Array.isArray(values)) {
@@ -23,6 +23,23 @@ export default {
23
23
  debug: i18n.debug,
24
24
  interpolation: {
25
25
  escapeValue: false
26
+ },
27
+ appendNamespaceToMissingKey: true,
28
+ parseMissingKeyHandler (key) {
29
+ // We include namespaces with unrecognized l10n keys using
30
+ // `appendNamespaceToMissingKey: true`. This passes strings containing
31
+ // colons that were never meant to be localized through to the UI.
32
+ //
33
+ // Strings that do not include colons ("Content area") are given the
34
+ // default namespace by i18next ("translation," by default). Here we
35
+ // check if the key starts with that default namespace, meaning it
36
+ // belongs to no other registered namespace, then remove that default
37
+ // namespace before passing this through to be processed and displayed.
38
+ if (key.startsWith(`${this.defaultNS[0]}:`)) {
39
+ return key.slice(this.defaultNS[0].length + 1);
40
+ } else {
41
+ return key;
42
+ }
26
43
  }
27
44
  });
28
45
 
@@ -936,15 +936,6 @@ module.exports = {
936
936
  });
937
937
  },
938
938
 
939
- // If propertyName is _id, then the keys in the returned
940
- // object will be the ids of each object in arr,
941
- // and the values will be the corresponding objects.
942
- // You may index by any property name.
943
-
944
- indexBy: function(arr, propertyName) {
945
- return _.indexBy(arr, propertyName);
946
- },
947
-
948
939
  // Find all the array elements, if any, that have the specified value for
949
940
  // the specified property.
950
941
 
@@ -1061,11 +1052,14 @@ module.exports = {
1061
1052
 
1062
1053
  function groupByArray(items, arrayName) {
1063
1054
  const results = {};
1055
+ // looping over each item in the original array
1064
1056
  _.each(items, function(item) {
1057
+ // looping over each item in the array within the top level item
1065
1058
  _.each(item[arrayName] || [], function(inner) {
1066
1059
  if (!results[inner]) {
1067
1060
  results[inner] = [];
1068
1061
  }
1062
+ // grouping top level items on the sub properties
1069
1063
  results[inner].push(item);
1070
1064
  });
1071
1065
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apostrophe",
3
- "version": "3.11.0",
3
+ "version": "3.12.0",
4
4
  "description": "The Apostrophe Content Management System.",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -0,0 +1,4 @@
1
+ {
2
+ "customTestOne": "Custom Test One From Base Type",
3
+ "customTestTwo": "Custom Test Two From Base Type"
4
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "defaultTestOne": "Default Test One"
3
+ }
@@ -0,0 +1,5 @@
1
+ module.exports = {
2
+ init(self) {
3
+ self.initialized = true;
4
+ }
5
+ };
@@ -0,0 +1,7 @@
1
+ module.exports = {
2
+ example1: {
3
+ options: {
4
+ folderLevelOption: true
5
+ }
6
+ }
7
+ };
@@ -0,0 +1,4 @@
1
+ {
2
+ "customTestOne": "Custom Test One From Subtype",
3
+ "customTestThree": "Custom Test Three From Subtype"
4
+ }
@@ -0,0 +1,7 @@
1
+ module.exports = {
2
+ i18n: {
3
+ custom: {
4
+ browser: true
5
+ }
6
+ }
7
+ };
@@ -33,9 +33,19 @@ describe('static i18n', function() {
33
33
  'apos-fr': {
34
34
  options: {
35
35
  i18n: {
36
+ // Legacy technique must work
36
37
  ns: 'apostrophe'
37
38
  }
38
39
  }
40
+ },
41
+ // A base class that contributes some namespaced phrases in the new style way (subdirs)
42
+ 'base-type': {
43
+ instantiate: false
44
+ },
45
+ // Also contributes namespaced phrases in the new style way (subdirs)
46
+ // plus default locale phrases in the root i18n folder
47
+ subtype: {
48
+ extend: 'base-type'
39
49
  }
40
50
  }
41
51
  });
@@ -60,4 +70,22 @@ describe('static i18n', function() {
60
70
  assert.strictEqual(apos.task.getReq({ locale: 'fr' }).t('apostrophe:richTextAlignCenter'), 'Aligner Le Centre');
61
71
  });
62
72
 
73
+ it('should fetch default locale phrases from main i18n dir with no i18n option necessary', function() {
74
+ assert.strictEqual(apos.task.getReq().t('defaultTestOne'), 'Default Test One');
75
+ });
76
+
77
+ it('should fetch custom locale phrases from corresponding subdir', function() {
78
+ assert.strictEqual(apos.task.getReq().t('custom:customTestTwo'), 'Custom Test Two From Base Type');
79
+ assert.strictEqual(apos.task.getReq().t('custom:customTestThree'), 'Custom Test Three From Subtype');
80
+ });
81
+
82
+ it('last appearance in inheritance + configuration order wins', function() {
83
+ assert.strictEqual(apos.task.getReq().t('custom:customTestOne'), 'Custom Test One From Subtype');
84
+ });
85
+
86
+ it('should honor the browser: true flag in the i18n section of an index.js file', function() {
87
+ const browserData = apos.i18n.getBrowserData(apos.task.getReq());
88
+ assert.strictEqual(browserData.i18n.en.custom.customTestOne, 'Custom Test One From Subtype');
89
+ });
90
+
63
91
  });
@@ -0,0 +1,32 @@
1
+ const t = require('../test-lib/test.js');
2
+ const assert = require('assert');
3
+
4
+ describe('With Nested Module Subdirs', function() {
5
+ this.timeout(t.timeout);
6
+
7
+ let apos;
8
+
9
+ after(function () {
10
+ return t.destroy(apos);
11
+ });
12
+
13
+ /// ///
14
+ // EXISTENCE
15
+ /// ///
16
+
17
+ it('should initialize', async function() {
18
+ apos = await t.create({
19
+ root: module,
20
+ nestedModuleSubdirs: true,
21
+ modules: {
22
+ example1: {}
23
+ }
24
+ });
25
+ assert(apos.modules.example1);
26
+ // With nestedModuleSubdirs switched on, the index.js should be found,
27
+ // and modules.js should be loaded
28
+ assert(apos.modules.example1.options.folderLevelOption);
29
+ assert(apos.modules.example1.initialized);
30
+ });
31
+
32
+ });
@@ -0,0 +1,31 @@
1
+ const t = require('../test-lib/test.js');
2
+ const assert = require('assert');
3
+
4
+ describe('Without Nested Module Subdirs', function() {
5
+ this.timeout(t.timeout);
6
+
7
+ let apos;
8
+
9
+ after(function () {
10
+ return t.destroy(apos);
11
+ });
12
+
13
+ /// ///
14
+ // EXISTENCE
15
+ /// ///
16
+
17
+ it('should initialize', async function() {
18
+ apos = await t.create({
19
+ root: module,
20
+ modules: {
21
+ example1: {}
22
+ }
23
+ });
24
+ assert(apos.modules.example1);
25
+ // Should fail because we didn't turn on nestedModuleSubdirs,
26
+ // so the index.js was not found and modules.js was not loaded
27
+ assert(!apos.modules.example1.options.folderLevelOption);
28
+ assert(!apos.modules.example1.initialized);
29
+ });
30
+
31
+ });