apostrophe 3.55.0 → 3.56.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,6 +1,29 @@
1
1
  # Changelog
2
2
 
3
- ## 3.55.0
3
+ ## 3.56.0 (2023-09-13)
4
+
5
+ ### Adds
6
+
7
+ * Add ability for custom tiptap extensions to access the options passed to rich text widgets at the area level.
8
+ * Add support for [npm workspaces](https://docs.npmjs.com/cli/v10/configuring-npm/package-json#workspaces) dependencies. A workspace dependency can now be used as an Apostrophe module even if it is not a direct dependency of the Apostrophe project. Only direct workspaces dependencies of the Apostrophe project are supported, meaning this will only work with workspaces set in the Apostrophe project. Workspaces set in npm modules are not supported, please use [`bundle`](https://v3.docs.apostrophecms.org/reference/module-api/module-overview.html#bundle) instead. For instance, I have an Apostrophe project called `website`. `website` is set with two [npm workspaces](https://docs.npmjs.com/cli/v10/using-npm/workspaces), `workspace-a` & `workspace-b`. `workspace-a` `package.json` contains a module named `blog` as a dependency. `website` can reference `blog` as enabled in the Apostrophe `modules` configuration.
9
+ * The actual invocation of `renderPageForModule` by the `sendPage` method of all modules has been
10
+ factored out to `renderPage`, which is no longer deprecated. This provides a convenient override point
11
+ for those who wish to substitute something else for Nunjucks or just wrap the HTML in a larger data
12
+ structure. For consistent results, one might also choose to override the `renderWidget` and `render`
13
+ methods of the `@apostrophecms/area` module, which are used to render content while editing.
14
+ Thanks to Michelin for their support of this work.
15
+ * Add `@apostrophecms/rich-text-widget:lint-fix-figure` task to wrap text nodes in paragraph tags when next to figure tags. Figure tags are not valid children of paragraph tags.
16
+ * Add `@apostrophecms/rich-text-widget:remove-empty-paragraph` task to remove empty paragraphs from all existing rich-texts.
17
+
18
+ ## 3.55.1 (2023-09-11)
19
+
20
+ ### Fixes
21
+
22
+ * The structured logging for API routes now responds properly if an API route throws a `string` as an exception, rather than
23
+ a politely `Error`-derived object with a `stack` property. Previously this resulted in an error message about the logging
24
+ system itself, which was not useful for debugging the original exception.
25
+
26
+ ## 3.55.0 (2023-08-30)
4
27
 
5
28
  ### Adds
6
29
 
@@ -186,10 +186,13 @@ module.exports = function(options) {
186
186
  const initialFolder = path.dirname(self.root.filename);
187
187
  let folder = initialFolder;
188
188
  while (true) {
189
- const file = `${folder}/package.json`;
189
+ const file = path.resolve(folder, 'package.json');
190
190
  if (fs.existsSync(file)) {
191
191
  const info = JSON.parse(fs.readFileSync(file, 'utf8'));
192
- self.validPackages = new Set([ ...Object.keys(info.dependencies || {}), ...Object.keys(info.devDependencies || {}) ]);
192
+ self.validPackages = new Set([
193
+ ...getDependencies(info),
194
+ ...getWorkspacesDependencies(folder)(info)
195
+ ]);
193
196
  break;
194
197
  } else {
195
198
  folder = path.dirname(folder);
@@ -231,7 +234,36 @@ module.exports = function(options) {
231
234
  }
232
235
  self.symlinksCache.set(type, link);
233
236
  return link;
234
- };
237
+ }
238
+
239
+ function getDependencies({
240
+ dependencies = {},
241
+ devDependencies = {}
242
+ } = {}) {
243
+ return [
244
+ ...Object.keys(dependencies || {}),
245
+ ...Object.keys(devDependencies || {})
246
+ ];
247
+ }
248
+
249
+ function getWorkspacesDependencies(folder) {
250
+ return ({ workspaces = [] } = {}) => {
251
+ if (workspaces.length === 0) {
252
+ return [];
253
+ }
254
+
255
+ // Ternary is required because glob expects at least 2 entries when using curly braces
256
+ const pattern = workspaces.length === 1 ? workspaces[0] : `{${workspaces.join(',')}}`;
257
+ const packagePath = path.resolve(folder, pattern, 'package.json');
258
+ const workspacePackages = glob.sync(packagePath, { follow: true });
259
+
260
+ return workspacePackages.flatMap(packagePath => {
261
+ const info = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
262
+
263
+ return getDependencies(info);
264
+ });
265
+ };
266
+ }
235
267
 
236
268
  self.isImprovement = function(name) {
237
269
  return _.has(self.improvements, name);
@@ -258,7 +258,7 @@ module.exports = {
258
258
  self.logError(req, `api-error${typeTrail}`, msg, {
259
259
  name: response.name,
260
260
  status: response.code,
261
- stack: error.stack.split('\n').slice(1).map(line => line.trim()),
261
+ stack: (error.stack || '').split('\n').slice(1).map(line => line.trim()),
262
262
  errorPath: response.path,
263
263
  data: response.data
264
264
  });
@@ -418,13 +418,11 @@ module.exports = {
418
418
  // `data.query` (req.query)
419
419
  //
420
420
  // This method is async in 3.x and must be awaited.
421
+ //
422
+ // No longer deprecated because it is a useful override point
423
+ // for this part of the behavior of sendPage.
421
424
 
422
425
  async renderPage(req, template, data) {
423
- // TODO Remove in next major version.
424
- self.apos.util.warnDevOnce(
425
- 'deprecate-renderPage',
426
- 'self.renderPage() is deprecated. Use self.sendPage() instead.'
427
- );
428
426
  return self.apos.template.renderPageForModule(req, template, data, self);
429
427
  },
430
428
 
@@ -482,9 +480,8 @@ module.exports = {
482
480
  try {
483
481
  await self.apos.page.emit('beforeSend', req);
484
482
  await self.apos.area.loadDeferredWidgets(req);
485
- req.res.send(
486
- await self.apos.template.renderPageForModule(req, template, data, self)
487
- );
483
+ const result = await self.renderPage(req, template, data);
484
+ req.res.send(result);
488
485
  span.setStatus({ code: telemetry.api.SpanStatusCode.OK });
489
486
  } catch (err) {
490
487
  telemetry.handleError(span, err);
@@ -739,5 +739,164 @@ module.exports = {
739
739
  return finalData;
740
740
  }
741
741
  };
742
+ },
743
+ tasks(self) {
744
+ const confirm = async (isConfirmed) => {
745
+ if (isConfirmed) {
746
+ return true;
747
+ }
748
+
749
+ console.log('This task will perform an update on all existing rich-text widget. You should manually backup your database before running this command in case it becomes necessary to revert the changes. You can add --confirm to the command to skip this message and run the command');
750
+
751
+ return false;
752
+ };
753
+
754
+ return {
755
+ 'remove-empty-paragraph': {
756
+ usage: 'Usage: node app @apostrophecms/rich-text-widget:remove-empty-paragraph --confirm\n\nRemove empty paragraph. If a paragraph contains no visible text or only blank characters, it will be removed.\n',
757
+ task: async (argv) => {
758
+ const iterator = async (doc, widget, dotPath) => {
759
+ if (widget.type !== self.name) {
760
+ return;
761
+ }
762
+
763
+ const updates = {};
764
+ if (widget.content.includes('<p>')) {
765
+ const dom = cheerio.load(widget.content);
766
+ const paragraph = dom('body').find('p');
767
+
768
+ paragraph.each((index, element) => {
769
+ const isEmpty = /^(\s|&nbsp;)*$/.test(dom(element).text());
770
+ isEmpty && dom(element).remove();
771
+
772
+ if (isEmpty) {
773
+ updates[dotPath] = {
774
+ ...widget,
775
+ content: dom('body').html()
776
+ };
777
+ }
778
+ });
779
+ }
780
+
781
+ if (Object.keys(updates).length) {
782
+ await self.apos.doc.db.updateOne(
783
+ { _id: doc._id },
784
+ { $set: updates }
785
+ );
786
+ self.apos.util.log(`Document ${doc._id} rich-texts have been updated`);
787
+ }
788
+ };
789
+
790
+ const isConfirmed = await confirm(argv.confirm);
791
+
792
+ return isConfirmed && self.apos.migration.eachWidget({}, iterator);
793
+ }
794
+ },
795
+ 'lint-fix-figure': {
796
+ usage: 'Usage: node app @apostrophecms/rich-text-widget:lint-fix-figure --confirm\n\nFigure tags is allowed inside paragraph. This task will look for figure tag next to empty paragraph and wrap the text node around inside paragraph.\n',
797
+ task: async (argv) => {
798
+ const blockNodes = [
799
+ 'address',
800
+ 'article',
801
+ 'aside',
802
+ 'blockquote',
803
+ 'canvas',
804
+ 'dd',
805
+ 'div',
806
+ 'dl',
807
+ 'dt',
808
+ 'fieldset',
809
+ 'figcaption',
810
+ 'figure',
811
+ 'footer',
812
+ 'form',
813
+ 'h1',
814
+ 'h2',
815
+ 'h3',
816
+ 'h4',
817
+ 'h5',
818
+ 'h6',
819
+ 'header',
820
+ 'hr',
821
+ 'li',
822
+ 'main',
823
+ 'nav',
824
+ 'noscript',
825
+ 'ol',
826
+ 'p',
827
+ 'pre',
828
+ 'section',
829
+ 'table',
830
+ 'tfoot',
831
+ 'ul',
832
+ 'video'
833
+ ];
834
+ const append = ({
835
+ dom,
836
+ wrapper,
837
+ element
838
+ }) => {
839
+ return wrapper
840
+ ? wrapper.append(element) && wrapper
841
+ : dom(element).wrap('<p></p>').parent();
842
+ };
843
+
844
+ const iterator = async (doc, widget, dotPath) => {
845
+ if (widget.type !== self.name) {
846
+ return;
847
+ }
848
+
849
+ const updates = {};
850
+ if (widget.content.includes('<figure')) {
851
+ const dom = cheerio.load(widget.content);
852
+ // reference: https://stackoverflow.com/questions/28855070/css-select-element-without-text-inside
853
+ const figure = dom('body').find('p:not(:has(:not(:empty)))+figure,figure+p:not(:has(:not(:empty)))');
854
+ const parent = figure.parent().contents();
855
+
856
+ let wrapper = null;
857
+
858
+ parent.each((index, element) => {
859
+ const isFigure = element.type === 'tag' && element.name === 'figure';
860
+ isFigure && (wrapper = null);
861
+
862
+ const isNonWhitespaceTextNode = element.type === 'text' && /^\s*$/.test(element.data) === false;
863
+ isNonWhitespaceTextNode && (wrapper = append({
864
+ dom,
865
+ wrapper,
866
+ element
867
+ }));
868
+
869
+ const isInlineNode = element.type === 'tag' && blockNodes.includes(element.name) === false;
870
+ isInlineNode && (wrapper = append({
871
+ dom,
872
+ wrapper,
873
+ element
874
+ }));
875
+
876
+ const hasUpdate = isNonWhitespaceTextNode || isInlineNode;
877
+ if (hasUpdate) {
878
+ updates[dotPath] = {
879
+ ...widget,
880
+ content: dom('body').html()
881
+ };
882
+ }
883
+ });
884
+ }
885
+
886
+ if (Object.keys(updates).length) {
887
+ await self.apos.doc.db.updateOne(
888
+ { _id: doc._id },
889
+ { $set: updates }
890
+ );
891
+ self.apos.util.log(`Document ${doc._id} rich-texts have been updated`);
892
+ }
893
+ };
894
+
895
+ const isConfirmed = await confirm(argv.confirm);
896
+
897
+ return isConfirmed && self.apos.migration.eachWidget({}, iterator);
898
+ }
899
+ }
900
+ };
742
901
  }
743
902
  };
@@ -545,6 +545,7 @@ export default {
545
545
  aposTiptapExtensions() {
546
546
  return (apos.tiptapExtensions || [])
547
547
  .map(extension => extension({
548
+ ...this.editorOptions,
548
549
  styles: this.editorOptions.styles.map(this.localizeStyle),
549
550
  types: this.tiptapTypes
550
551
  }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apostrophe",
3
- "version": "3.55.0",
3
+ "version": "3.56.0",
4
4
  "description": "The Apostrophe Content Management System.",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -0,0 +1,81 @@
1
+ const t = require('../test-lib/test.js');
2
+ const assert = require('assert');
3
+
4
+ describe('Don\'t crash on weird API errors', function() {
5
+
6
+ after(async function() {
7
+ return t.destroy(apos);
8
+ });
9
+
10
+ this.timeout(t.timeout);
11
+
12
+ let apos;
13
+
14
+ it('should initialize apos', async function() {
15
+ apos = await t.create({
16
+ root: module,
17
+ modules: {
18
+ 'api-test': {
19
+ apiRoutes(self) {
20
+ return {
21
+ get: {
22
+ fetchItFine(req) {
23
+ return {
24
+ nifty: true
25
+ };
26
+ },
27
+ fetchItFailWeird(req) {
28
+ throw 'not-an-error-object';
29
+ },
30
+ fetchItFailNormal(req) {
31
+ throw new Error('normal error');
32
+ }
33
+ }
34
+ };
35
+ }
36
+ }
37
+ }
38
+ });
39
+ });
40
+ it('should fetch fine in the normal case', async function() {
41
+ const body = await apos.http.get('/api/v1/api-test/fetch-it-fine', {});
42
+ assert(typeof body === 'object');
43
+ assert.strictEqual(body.nifty, true);
44
+ });
45
+ it('should fail politely in the weird case of a non-Error exception', async function() {
46
+ let msgWas;
47
+ const consoleError = console.error;
48
+ console.error = msg => {
49
+ msgWas = msg;
50
+ };
51
+ try {
52
+ await apos.http.get('/api/v1/api-test/fetch-it-fail-weird', {});
53
+ // Should not get here
54
+ assert(false);
55
+ } catch (e) {
56
+ // Make sure the logging system itself is not at fault
57
+ assert(!msgWas.toString().includes('Structured logging error'));
58
+ } finally {
59
+ console.error = consoleError;
60
+ console.error(msgWas);
61
+ }
62
+ });
63
+ it('should fail politely in the normal case of an Error exception', async function() {
64
+ let msgWas;
65
+ const consoleError = console.error;
66
+ console.error = msg => {
67
+ msgWas = msg;
68
+ };
69
+ try {
70
+ await apos.http.get('/api/v1/api-test/fetch-it-fail-normal', {});
71
+ // Should not get here
72
+ assert(false);
73
+ } catch (e) {
74
+ // Make sure the logging system itself is not at fault
75
+ assert(!msgWas.toString().includes('Structured logging error'));
76
+ } finally {
77
+ console.error = consoleError;
78
+ console.error(msgWas);
79
+ }
80
+ });
81
+ });
@@ -0,0 +1,16 @@
1
+ module.exports = {
2
+ root: module,
3
+ shortName: 'workspaces-project',
4
+ modules: {
5
+ '@apostrophecms/express': {
6
+ options: {
7
+ address: '127.0.0.1'
8
+ }
9
+ },
10
+ '@apostrophecms/sitemap': {
11
+ options: {
12
+ baseUrl: 'http://localhost:3000'
13
+ }
14
+ }
15
+ }
16
+ };
@@ -0,0 +1,40 @@
1
+ const createLogger = () => {
2
+ const messages = {
3
+ debug: [],
4
+ info: [],
5
+ warn: [],
6
+ error: []
7
+ };
8
+
9
+ return {
10
+ debug: (...args) => {
11
+ console.debug(...args);
12
+ messages.debug.push(...args);
13
+ },
14
+ info: (...args) => {
15
+ console.info(...args);
16
+ messages.info.push(...args);
17
+ },
18
+ warn: (...args) => {
19
+ console.warn(...args);
20
+ messages.warn.push(...args);
21
+ },
22
+ error: (...args) => {
23
+ console.error(...args);
24
+ messages.error.push(...args);
25
+ },
26
+ destroy: () => {
27
+ delete messages.debug;
28
+ delete messages.info;
29
+ delete messages.warn;
30
+ delete messages.error;
31
+ },
32
+ getMessages: () => messages
33
+ };
34
+ };
35
+
36
+ module.exports = {
37
+ options: {
38
+ logger: createLogger()
39
+ }
40
+ };
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "workspace-project",
3
+ "version": "1.0.0",
4
+ "description": "",
5
+ "main": "app.js",
6
+ "scripts": {
7
+ "test": "echo \"Error: no test specified\" && exit 1"
8
+ },
9
+ "keywords": [],
10
+ "author": "",
11
+ "license": "ISC",
12
+ "dependencies": {
13
+ "apostrophe": "file:../../."
14
+ },
15
+ "workspaces": [
16
+ "workspace-a"
17
+ ]
18
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "workspace-a",
3
+ "version": "1.0.0",
4
+ "description": "",
5
+ "main": "index.js",
6
+ "scripts": {
7
+ "test": "echo \"Error: no test specified\" && exit 1"
8
+ },
9
+ "keywords": [],
10
+ "author": "",
11
+ "license": "ISC",
12
+ "dependencies": {
13
+ "@apostrophecms/sitemap": "^1.0.2"
14
+ }
15
+ }
@@ -0,0 +1,38 @@
1
+ const assert = require('node:assert').strict;
2
+ const util = require('node:util');
3
+ const { exec } = require('node:child_process');
4
+ const path = require('node:path');
5
+ const t = require('../test-lib/test.js');
6
+ const app = require('./workspaces-project/app.js');
7
+
8
+ describe('workspaces dependencies', function() {
9
+ this.timeout(t.timeout);
10
+
11
+ before(async function() {
12
+ await util.promisify(exec)('npm install', { cwd: path.resolve(process.cwd(), 'test/workspaces-project') });
13
+ });
14
+
15
+ it('should allow workspaces dependency in the project', async function() {
16
+ let apos;
17
+
18
+ try {
19
+ apos = await t.create(app);
20
+ const { server } = apos.modules['@apostrophecms/express'];
21
+ const { address, port } = server.address();
22
+
23
+ const actual = apos.util.logger.getMessages();
24
+ const expected = {
25
+ debug: [],
26
+ info: [ `Listening at http://${address}:${port}` ],
27
+ warn: [],
28
+ error: []
29
+ };
30
+
31
+ assert.deepEqual(actual, expected);
32
+ } catch (error) {
33
+ assert.fail('Should have found @apostrophecms/sitemap hidden in workspace-a as a valid dependency. '.concat(error.message));
34
+ } finally {
35
+ apos && await t.destroy(apos);
36
+ }
37
+ });
38
+ });