ghost 5.13.0 → 5.14.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.
Files changed (121) hide show
  1. package/README.md +1 -1
  2. package/components/tryghost-adapter-manager-5.14.0.tgz +0 -0
  3. package/components/{tryghost-api-framework-5.13.0.tgz → tryghost-api-framework-5.14.0.tgz} +0 -0
  4. package/components/{tryghost-api-version-compatibility-service-5.13.0.tgz → tryghost-api-version-compatibility-service-5.14.0.tgz} +0 -0
  5. package/components/{tryghost-bootstrap-socket-5.13.0.tgz → tryghost-bootstrap-socket-5.14.0.tgz} +0 -0
  6. package/components/tryghost-constants-5.14.0.tgz +0 -0
  7. package/components/{tryghost-custom-theme-settings-service-5.13.0.tgz → tryghost-custom-theme-settings-service-5.14.0.tgz} +0 -0
  8. package/components/tryghost-domain-events-5.14.0.tgz +0 -0
  9. package/components/{tryghost-email-analytics-provider-mailgun-5.13.0.tgz → tryghost-email-analytics-provider-mailgun-5.14.0.tgz} +0 -0
  10. package/components/tryghost-email-analytics-service-5.14.0.tgz +0 -0
  11. package/components/tryghost-email-content-generator-5.14.0.tgz +0 -0
  12. package/components/tryghost-express-dynamic-redirects-5.14.0.tgz +0 -0
  13. package/components/tryghost-extract-api-key-5.14.0.tgz +0 -0
  14. package/components/{tryghost-html-to-plaintext-5.13.0.tgz → tryghost-html-to-plaintext-5.14.0.tgz} +0 -0
  15. package/components/{tryghost-job-manager-5.13.0.tgz → tryghost-job-manager-5.14.0.tgz} +0 -0
  16. package/components/{tryghost-magic-link-5.13.0.tgz → tryghost-magic-link-5.14.0.tgz} +0 -0
  17. package/components/{tryghost-mailgun-client-5.13.0.tgz → tryghost-mailgun-client-5.14.0.tgz} +0 -0
  18. package/components/{tryghost-member-analytics-service-5.13.0.tgz → tryghost-member-analytics-service-5.14.0.tgz} +0 -0
  19. package/components/tryghost-member-attribution-5.14.0.tgz +0 -0
  20. package/components/tryghost-member-events-5.14.0.tgz +0 -0
  21. package/components/tryghost-members-analytics-ingress-5.14.0.tgz +0 -0
  22. package/components/tryghost-members-api-5.14.0.tgz +0 -0
  23. package/components/{tryghost-members-csv-5.13.0.tgz → tryghost-members-csv-5.14.0.tgz} +0 -0
  24. package/components/tryghost-members-events-service-5.14.0.tgz +0 -0
  25. package/components/tryghost-members-importer-5.14.0.tgz +0 -0
  26. package/components/{tryghost-members-offers-5.13.0.tgz → tryghost-members-offers-5.14.0.tgz} +0 -0
  27. package/components/tryghost-members-payments-5.14.0.tgz +0 -0
  28. package/components/{tryghost-members-ssr-5.13.0.tgz → tryghost-members-ssr-5.14.0.tgz} +0 -0
  29. package/components/{tryghost-members-stripe-service-5.13.0.tgz → tryghost-members-stripe-service-5.14.0.tgz} +0 -0
  30. package/components/{tryghost-minifier-5.13.0.tgz → tryghost-minifier-5.14.0.tgz} +0 -0
  31. package/components/tryghost-mw-api-version-mismatch-5.14.0.tgz +0 -0
  32. package/components/tryghost-mw-cache-control-5.14.0.tgz +0 -0
  33. package/components/{tryghost-mw-error-handler-5.13.0.tgz → tryghost-mw-error-handler-5.14.0.tgz} +0 -0
  34. package/components/tryghost-mw-session-from-token-5.14.0.tgz +0 -0
  35. package/components/tryghost-mw-update-user-last-seen-5.14.0.tgz +0 -0
  36. package/components/tryghost-mw-vhost-5.14.0.tgz +0 -0
  37. package/components/{tryghost-oembed-service-5.13.0.tgz → tryghost-oembed-service-5.14.0.tgz} +0 -0
  38. package/components/{tryghost-package-json-5.13.0.tgz → tryghost-package-json-5.14.0.tgz} +0 -0
  39. package/components/tryghost-security-5.14.0.tgz +0 -0
  40. package/components/{tryghost-session-service-5.13.0.tgz → tryghost-session-service-5.14.0.tgz} +0 -0
  41. package/components/tryghost-settings-path-manager-5.14.0.tgz +0 -0
  42. package/components/tryghost-staff-service-5.14.0.tgz +0 -0
  43. package/components/tryghost-update-check-service-5.14.0.tgz +0 -0
  44. package/components/{tryghost-verification-trigger-5.13.0.tgz → tryghost-verification-trigger-5.14.0.tgz} +0 -0
  45. package/components/{tryghost-version-notifications-data-service-5.13.0.tgz → tryghost-version-notifications-data-service-5.14.0.tgz} +0 -0
  46. package/core/boot.js +2 -0
  47. package/core/built/admin/assets/{chunk.143.e96aad00fdf7196692c7.js → chunk.143.53c5e3490ffdae025d84.js} +6 -6
  48. package/core/built/admin/assets/{chunk.174.8b8a64726921ecfda41b.js → chunk.174.2edaa0869bfc2d88cf90.js} +173 -172
  49. package/core/built/admin/assets/{chunk.178.1188f8d61af173f8c246.js → chunk.178.a31590ec7388630cd0d0.js} +4 -4
  50. package/core/built/admin/assets/{chunk.763.9a285d7351e1f4415f8d.js → chunk.579.2de3f4300baf25f9a0db.js} +22 -28
  51. package/core/built/admin/assets/{chunk.763.9a285d7351e1f4415f8d.js.LICENSE.txt → chunk.579.2de3f4300baf25f9a0db.js.LICENSE.txt} +0 -0
  52. package/core/built/admin/assets/ghost-40adc8310dcdd0be163cbf7b9d89c59a.css +1 -0
  53. package/core/built/admin/assets/{ghost-3203510c519d3195f1e71a34e9eecc59.js → ghost-84a4336c7d5c1f3fba00868b0a5237cd.js} +817 -915
  54. package/core/built/admin/assets/ghost-dark-13b669d50f494edf24d832b32ece2177.css +1 -0
  55. package/core/built/admin/assets/img/logos/orb-pink-3-a66abc6df2b6ab64d1459a6535b725cd.png +0 -0
  56. package/core/built/admin/assets/{vendor-ab4b5dfdf8b86f24d726115ac7de0980.js → vendor-22a37451d7619a2b641310ecbcca4c05.js} +84 -81
  57. package/core/built/admin/index.html +6 -6
  58. package/core/frontend/helpers/ghost_head.js +3 -2
  59. package/core/frontend/helpers/url.js +1 -1
  60. package/core/frontend/services/data/fetch-data.js +0 -1
  61. package/core/frontend/src/admin-auth/message-handler.js +4 -4
  62. package/core/server/adapters/cache/Memory.js +1 -1
  63. package/core/server/api/endpoints/comments-members.js +4 -64
  64. package/core/server/api/endpoints/utils/serializers/input/pages.js +3 -0
  65. package/core/server/api/endpoints/utils/serializers/input/posts.js +3 -0
  66. package/core/server/api/endpoints/utils/serializers/input/utils/clean.js +12 -0
  67. package/core/server/api/endpoints/utils/serializers/output/mappers/comments.js +1 -1
  68. package/core/server/data/migrations/versions/5.14/2022-09-02-12-55-rename-members-bio-to-expertise.js +34 -0
  69. package/core/server/data/schema/schema.js +1 -1
  70. package/core/server/models/post.js +6 -0
  71. package/core/server/services/adapter-manager/index.js +3 -3
  72. package/core/server/services/adapter-manager/options-resolver.js +30 -9
  73. package/core/server/services/comments/controller.js +52 -1
  74. package/core/server/services/comments/email-templates/new-comment-reply.hbs +1 -1
  75. package/core/server/services/comments/email-templates/new-comment.hbs +1 -1
  76. package/core/server/services/comments/email-templates/report.hbs +1 -1
  77. package/core/server/services/comments/emails.js +3 -3
  78. package/core/server/services/comments/service.js +52 -0
  79. package/core/server/services/mega/post-email-serializer.js +371 -368
  80. package/core/server/services/mega/template.js +4 -8
  81. package/core/server/services/member-attribution/index.js +4 -18
  82. package/core/server/services/members/middleware.js +1 -1
  83. package/core/server/services/members/service.js +0 -12
  84. package/core/server/services/members/utils.js +1 -1
  85. package/core/server/services/members-events/index.js +40 -0
  86. package/core/server/services/nft-oembed.js +5 -3
  87. package/core/shared/config/defaults.json +4 -6
  88. package/core/shared/labs.js +1 -1
  89. package/core/shared/settings-cache/cache.js +1 -1
  90. package/package.json +96 -94
  91. package/yarn.lock +275 -207
  92. package/components/tryghost-adapter-manager-5.13.0.tgz +0 -0
  93. package/components/tryghost-constants-5.13.0.tgz +0 -0
  94. package/components/tryghost-domain-events-5.13.0.tgz +0 -0
  95. package/components/tryghost-email-analytics-service-5.13.0.tgz +0 -0
  96. package/components/tryghost-email-content-generator-5.13.0.tgz +0 -0
  97. package/components/tryghost-express-dynamic-redirects-5.13.0.tgz +0 -0
  98. package/components/tryghost-extract-api-key-5.13.0.tgz +0 -0
  99. package/components/tryghost-member-attribution-5.13.0.tgz +0 -0
  100. package/components/tryghost-member-events-5.13.0.tgz +0 -0
  101. package/components/tryghost-members-analytics-ingress-5.13.0.tgz +0 -0
  102. package/components/tryghost-members-api-5.13.0.tgz +0 -0
  103. package/components/tryghost-members-events-service-5.13.0.tgz +0 -0
  104. package/components/tryghost-members-importer-5.13.0.tgz +0 -0
  105. package/components/tryghost-members-payments-5.13.0.tgz +0 -0
  106. package/components/tryghost-mw-api-version-mismatch-5.13.0.tgz +0 -0
  107. package/components/tryghost-mw-cache-control-5.13.0.tgz +0 -0
  108. package/components/tryghost-mw-session-from-token-5.13.0.tgz +0 -0
  109. package/components/tryghost-mw-update-user-last-seen-5.13.0.tgz +0 -0
  110. package/components/tryghost-mw-vhost-5.13.0.tgz +0 -0
  111. package/components/tryghost-security-5.13.0.tgz +0 -0
  112. package/components/tryghost-settings-path-manager-5.13.0.tgz +0 -0
  113. package/components/tryghost-staff-service-5.13.0.tgz +0 -0
  114. package/components/tryghost-update-check-service-5.13.0.tgz +0 -0
  115. package/content/themes/casper/assets/built/portal.min.js +0 -3
  116. package/core/built/admin/assets/ghost-647c9a79282265a4d29bf273c44f72c0.css +0 -1
  117. package/core/built/admin/assets/ghost-dark-d84a2701166840b73bbbbe657879b65e.css +0 -1
  118. package/core/built/admin/assets/img/logos/orb-pink-3-a2c52eb9fda9f2401ea706c3f24976ff.png +0 -0
  119. package/core/server/adapters/cache/Base.js +0 -12
  120. package/core/server/adapters/cache/ImageSizesCacheSyncInMemory.js +0 -7
  121. package/core/server/adapters/cache/SettingsCacheSyncInMemory.js +0 -7
@@ -17,427 +17,430 @@ const urlService = require('../../services/url');
17
17
 
18
18
  const ALLOWED_REPLACEMENTS = ['first_name'];
19
19
 
20
- // Format a full html document ready for email by inlining CSS, adjusting links,
21
- // and performing any client-specific fixes
22
- const formatHtmlForEmail = function formatHtmlForEmail(html) {
23
- const juiceOptions = {inlinePseudoElements: true};
20
+ const PostEmailSerializer = {
21
+
22
+ // Format a full html document ready for email by inlining CSS, adjusting links,
23
+ // and performing any client-specific fixes
24
+ formatHtmlForEmail(html) {
25
+ const juiceOptions = {inlinePseudoElements: true};
24
26
 
25
- const juice = require('juice');
26
- let juicedHtml = juice(html, juiceOptions);
27
+ const juice = require('juice');
28
+ let juicedHtml = juice(html, juiceOptions);
27
29
 
28
- // convert juiced HTML to a DOM-like interface for further manipulation
29
- // happens after inlining of CSS so we can change element types without worrying about styling
30
+ // convert juiced HTML to a DOM-like interface for further manipulation
31
+ // happens after inlining of CSS so we can change element types without worrying about styling
30
32
 
31
- const cheerio = require('cheerio');
32
- const _cheerio = cheerio.load(juicedHtml);
33
+ const cheerio = require('cheerio');
34
+ const _cheerio = cheerio.load(juicedHtml);
33
35
 
34
- // force all links to open in new tab
35
- _cheerio('a').attr('target', '_blank');
36
- // convert figure and figcaption to div so that Outlook applies margins
37
- _cheerio('figure, figcaption').each((i, elem) => !!(elem.tagName = 'div'));
36
+ // force all links to open in new tab
37
+ _cheerio('a').attr('target', '_blank');
38
+ // convert figure and figcaption to div so that Outlook applies margins
39
+ _cheerio('figure, figcaption').each((i, elem) => !!(elem.tagName = 'div'));
38
40
 
39
- juicedHtml = _cheerio.html();
41
+ juicedHtml = _cheerio.html();
40
42
 
41
- // Fix any unsupported chars in Outlook
42
- juicedHtml = juicedHtml.replace(/'/g, ''');
43
+ // Fix any unsupported chars in Outlook
44
+ juicedHtml = juicedHtml.replace(/'/g, ''');
43
45
 
44
- return juicedHtml;
45
- };
46
+ return juicedHtml;
47
+ },
46
48
 
47
- const getSite = () => {
48
- const publicSettings = settingsCache.getPublic();
49
- return Object.assign({}, publicSettings, {
50
- url: urlUtils.urlFor('home', true),
51
- iconUrl: publicSettings.icon ? urlUtils.urlFor('image', {image: publicSettings.icon}, true) : null
52
- });
53
- };
49
+ getSite() {
50
+ const publicSettings = settingsCache.getPublic();
51
+ return Object.assign({}, publicSettings, {
52
+ url: urlUtils.urlFor('home', true),
53
+ iconUrl: publicSettings.icon ? urlUtils.urlFor('image', {image: publicSettings.icon}, true) : null
54
+ });
55
+ },
56
+
57
+ /**
58
+ * createUnsubscribeUrl
59
+ *
60
+ * Takes a member and newsletter uuid. Returns the url that should be used to unsubscribe
61
+ * In case of no member uuid, generates the preview unsubscribe url - `?preview=1`
62
+ *
63
+ * @param {string} uuid post uuid
64
+ * @param {Object} [options]
65
+ * @param {string} [options.newsletterUuid] newsletter uuid
66
+ * @param {boolean} [options.comments] Unsubscribe from comment emails
67
+ */
68
+ createUnsubscribeUrl(uuid, options = {}) {
69
+ const siteUrl = urlUtils.getSiteUrl();
70
+ const unsubscribeUrl = new URL(siteUrl);
71
+ unsubscribeUrl.pathname = `${unsubscribeUrl.pathname}/unsubscribe/`.replace('//', '/');
72
+ if (uuid) {
73
+ unsubscribeUrl.searchParams.set('uuid', uuid);
74
+ } else {
75
+ unsubscribeUrl.searchParams.set('preview', '1');
76
+ }
77
+ if (options.newsletterUuid) {
78
+ unsubscribeUrl.searchParams.set('newsletter', options.newsletterUuid);
79
+ }
80
+ if (options.comments) {
81
+ unsubscribeUrl.searchParams.set('comments', '1');
82
+ }
54
83
 
55
- /**
56
- * createUnsubscribeUrl
57
- *
58
- * Takes a member and newsletter uuid. Returns the url that should be used to unsubscribe
59
- * In case of no member uuid, generates the preview unsubscribe url - `?preview=1`
60
- *
61
- * @param {string} uuid post uuid
62
- * @param {Object} [options]
63
- * @param {string} [options.newsletterUuid] newsletter uuid
64
- * @param {boolean} [options.comments] Unsubscribe from comment emails
65
- */
66
- const createUnsubscribeUrl = (uuid, options = {}) => {
67
- const siteUrl = urlUtils.getSiteUrl();
68
- const unsubscribeUrl = new URL(siteUrl);
69
- unsubscribeUrl.pathname = `${unsubscribeUrl.pathname}/unsubscribe/`.replace('//', '/');
70
- if (uuid) {
71
- unsubscribeUrl.searchParams.set('uuid', uuid);
72
- } else {
73
- unsubscribeUrl.searchParams.set('preview', '1');
74
- }
75
- if (options.newsletterUuid) {
76
- unsubscribeUrl.searchParams.set('newsletter', options.newsletterUuid);
77
- }
78
- if (options.comments) {
79
- unsubscribeUrl.searchParams.set('comments', '1');
80
- }
84
+ return unsubscribeUrl.href;
85
+ },
86
+
87
+ /**
88
+ * createPostSignupUrl
89
+ *
90
+ * Takes a post object. Returns the url that should be used to signup from newsletter
91
+ *
92
+ * @param {Object} post post object
93
+ */
94
+ createPostSignupUrl(post) {
95
+ let url = urlService.getUrlByResourceId(post.id, {absolute: true});
96
+
97
+ // For email-only posts, use site url as base
98
+ if (post.status !== 'published' && url.match(/\/404\//)) {
99
+ url = urlUtils.getSiteUrl();
100
+ }
81
101
 
82
- return unsubscribeUrl.href;
83
- };
102
+ const signupUrl = new URL(url);
103
+ signupUrl.hash = `/portal/signup`;
84
104
 
85
- /**
86
- * createPostSignupUrl
87
- *
88
- * Takes a post object. Returns the url that should be used to signup from newsletter
89
- *
90
- * @param {Object} post post object
91
- */
92
- const createPostSignupUrl = (post) => {
93
- let url = urlService.getUrlByResourceId(post.id, {absolute: true});
94
-
95
- // For email-only posts, use site url as base
96
- if (post.status !== 'published' && url.match(/\/404\//)) {
97
- url = urlUtils.getSiteUrl();
98
- }
105
+ return signupUrl.href;
106
+ },
99
107
 
100
- const signupUrl = new URL(url);
101
- signupUrl.hash = `/portal/signup`;
108
+ // NOTE: serialization is needed to make sure we do post transformations such as image URL transformation from relative to absolute
109
+ async serializePostModel(model) {
110
+ // fetch mobiledoc rather than html and plaintext so we can render email-specific contents
111
+ const frame = {options: {context: {user: true}, formats: 'mobiledoc'}};
112
+ const docName = 'posts';
102
113
 
103
- return signupUrl.href;
104
- };
114
+ await apiFramework
115
+ .serializers
116
+ .handle
117
+ .output(model, {docName: docName, method: 'read'}, api.serializers.output, frame);
105
118
 
106
- // NOTE: serialization is needed to make sure we do post transformations such as image URL transformation from relative to absolute
107
- const serializePostModel = async (model) => {
108
- // fetch mobiledoc rather than html and plaintext so we can render email-specific contents
109
- const frame = {options: {context: {user: true}, formats: 'mobiledoc'}};
110
- const docName = 'posts';
119
+ return frame.response[docName][0];
120
+ },
111
121
 
112
- await apiFramework
113
- .serializers
114
- .handle
115
- .output(model, {docName: docName, method: 'read'}, api.serializers.output, frame);
122
+ // removes %% wrappers from unknown replacement strings in email content
123
+ normalizeReplacementStrings(email) {
124
+ // we don't want to modify the email object in-place
125
+ const emailContent = _.pick(email, ['html', 'plaintext']);
116
126
 
117
- return frame.response[docName][0];
118
- };
127
+ const EMAIL_REPLACEMENT_REGEX = /%%(\{.*?\})%%/g;
128
+ const REPLACEMENT_STRING_REGEX = /\{(?<recipientProperty>\w*?)(?:,? *(?:"|&quot;)(?<fallback>.*?)(?:"|&quot;))?\}/;
119
129
 
120
- // removes %% wrappers from unknown replacement strings in email content
121
- const normalizeReplacementStrings = (email) => {
122
- // we don't want to modify the email object in-place
123
- const emailContent = _.pick(email, ['html', 'plaintext']);
130
+ ['html', 'plaintext'].forEach((format) => {
131
+ emailContent[format] = emailContent[format].replace(EMAIL_REPLACEMENT_REGEX, (replacementMatch, replacementStr) => {
132
+ const match = replacementStr.match(REPLACEMENT_STRING_REGEX);
124
133
 
125
- const EMAIL_REPLACEMENT_REGEX = /%%(\{.*?\})%%/g;
126
- const REPLACEMENT_STRING_REGEX = /\{(?<recipientProperty>\w*?)(?:,? *(?:"|&quot;)(?<fallback>.*?)(?:"|&quot;))?\}/;
134
+ if (match) {
135
+ const {recipientProperty} = match.groups;
127
136
 
128
- ['html', 'plaintext'].forEach((format) => {
129
- emailContent[format] = emailContent[format].replace(EMAIL_REPLACEMENT_REGEX, (replacementMatch, replacementStr) => {
130
- const match = replacementStr.match(REPLACEMENT_STRING_REGEX);
137
+ if (ALLOWED_REPLACEMENTS.includes(recipientProperty)) {
138
+ // keeps wrapping %% for later replacement with real data
139
+ return replacementMatch;
140
+ }
141
+ }
131
142
 
132
- if (match) {
133
- const {recipientProperty} = match.groups;
143
+ // removes %% so output matches user supplied content
144
+ return replacementStr;
145
+ });
146
+ });
134
147
 
135
- if (ALLOWED_REPLACEMENTS.includes(recipientProperty)) {
136
- // keeps wrapping %% for later replacement with real data
137
- return replacementMatch;
148
+ return emailContent;
149
+ },
150
+
151
+ /**
152
+ * Parses email content and extracts an array of replacements with desired fallbacks
153
+ *
154
+ * @param {Object} email
155
+ * @param {string} email.html
156
+ * @param {string} email.plaintext
157
+ *
158
+ * @returns {Object[]} replacements
159
+ */
160
+ parseReplacements(email) {
161
+ const EMAIL_REPLACEMENT_REGEX = /%%(\{.*?\})%%/g;
162
+ const REPLACEMENT_STRING_REGEX = /\{(?<recipientProperty>\w*?)(?:,? *(?:"|&quot;)(?<fallback>.*?)(?:"|&quot;))?\}/;
163
+
164
+ const replacements = [];
165
+
166
+ ['html', 'plaintext'].forEach((format) => {
167
+ let result;
168
+ while ((result = EMAIL_REPLACEMENT_REGEX.exec(email[format])) !== null) {
169
+ const [replacementMatch, replacementStr] = result;
170
+ const match = replacementStr.match(REPLACEMENT_STRING_REGEX);
171
+
172
+ if (match) {
173
+ const {recipientProperty, fallback} = match.groups;
174
+
175
+ if (ALLOWED_REPLACEMENTS.includes(recipientProperty)) {
176
+ const id = `replacement_${replacements.length + 1}`;
177
+
178
+ replacements.push({
179
+ format,
180
+ id,
181
+ match: replacementMatch,
182
+ recipientProperty: `member_${recipientProperty}`,
183
+ fallback
184
+ });
185
+ }
138
186
  }
139
187
  }
140
-
141
- // removes %% so output matches user supplied content
142
- return replacementStr;
143
188
  });
144
- });
145
189
 
146
- return emailContent;
147
- };
148
-
149
- /**
150
- * Parses email content and extracts an array of replacements with desired fallbacks
151
- *
152
- * @param {Object} email
153
- * @param {string} email.html
154
- * @param {string} email.plaintext
155
- *
156
- * @returns {Object[]} replacements
157
- */
158
- const parseReplacements = (email) => {
159
- const EMAIL_REPLACEMENT_REGEX = /%%(\{.*?\})%%/g;
160
- const REPLACEMENT_STRING_REGEX = /\{(?<recipientProperty>\w*?)(?:,? *(?:"|&quot;)(?<fallback>.*?)(?:"|&quot;))?\}/;
161
-
162
- const replacements = [];
163
-
164
- ['html', 'plaintext'].forEach((format) => {
165
- let result;
166
- while ((result = EMAIL_REPLACEMENT_REGEX.exec(email[format])) !== null) {
167
- const [replacementMatch, replacementStr] = result;
168
- const match = replacementStr.match(REPLACEMENT_STRING_REGEX);
169
-
170
- if (match) {
171
- const {recipientProperty, fallback} = match.groups;
172
-
173
- if (ALLOWED_REPLACEMENTS.includes(recipientProperty)) {
174
- const id = `replacement_${replacements.length + 1}`;
175
-
176
- replacements.push({
177
- format,
178
- id,
179
- match: replacementMatch,
180
- recipientProperty: `member_${recipientProperty}`,
181
- fallback
182
- });
190
+ return replacements;
191
+ },
192
+
193
+ async getTemplateSettings(newsletter) {
194
+ const accentColor = settingsCache.get('accent_color');
195
+ const adjustedAccentColor = accentColor && darkenToContrastThreshold(accentColor, '#ffffff', 2).hex();
196
+ const adjustedAccentContrastColor = accentColor && textColorForBackgroundColor(adjustedAccentColor).hex();
197
+
198
+ const templateSettings = {
199
+ headerImage: newsletter.get('header_image'),
200
+ showHeaderIcon: newsletter.get('show_header_icon') && settingsCache.get('icon'),
201
+ showHeaderTitle: newsletter.get('show_header_title'),
202
+ showFeatureImage: newsletter.get('show_feature_image'),
203
+ titleFontCategory: newsletter.get('title_font_category'),
204
+ titleAlignment: newsletter.get('title_alignment'),
205
+ bodyFontCategory: newsletter.get('body_font_category'),
206
+ showBadge: newsletter.get('show_badge'),
207
+ footerContent: newsletter.get('footer_content'),
208
+ showHeaderName: newsletter.get('show_header_name'),
209
+ accentColor,
210
+ adjustedAccentColor,
211
+ adjustedAccentContrastColor
212
+ };
213
+
214
+ if (templateSettings.headerImage) {
215
+ if (isUnsplashImage(templateSettings.headerImage)) {
216
+ // Unsplash images have a minimum size so assuming 1200px is safe
217
+ const unsplashUrl = new URL(templateSettings.headerImage);
218
+ unsplashUrl.searchParams.set('w', '1200');
219
+
220
+ templateSettings.headerImage = unsplashUrl.href;
221
+ templateSettings.headerImageWidth = 600;
222
+ } else {
223
+ const {imageSize} = require('../../lib/image');
224
+ try {
225
+ const size = await imageSize.getImageSizeFromUrl(templateSettings.headerImage);
226
+
227
+ if (size.width >= 600) {
228
+ // keep original image, just set a fixed width
229
+ templateSettings.headerImageWidth = 600;
230
+ }
231
+
232
+ if (isLocalContentImage(templateSettings.headerImage, urlUtils.getSiteUrl())) {
233
+ // we can safely request a 1200px image - Ghost will serve the original if it's smaller
234
+ templateSettings.headerImage = templateSettings.headerImage.replace(/\/content\/images\//, '/content/images/size/w1200/');
235
+ }
236
+ } catch (err) {
237
+ // log and proceed. Using original header image without fixed width isn't fatal.
238
+ logging.error(err);
183
239
  }
184
240
  }
185
241
  }
186
- });
187
242
 
188
- return replacements;
189
- };
243
+ return templateSettings;
244
+ },
190
245
 
191
- const getTemplateSettings = async (newsletter) => {
192
- const accentColor = settingsCache.get('accent_color');
193
- const adjustedAccentColor = accentColor && darkenToContrastThreshold(accentColor, '#ffffff', 2).hex();
194
- const adjustedAccentContrastColor = accentColor && textColorForBackgroundColor(adjustedAccentColor).hex();
195
-
196
- const templateSettings = {
197
- headerImage: newsletter.get('header_image'),
198
- showHeaderIcon: newsletter.get('show_header_icon') && settingsCache.get('icon'),
199
- showHeaderTitle: newsletter.get('show_header_title'),
200
- showFeatureImage: newsletter.get('show_feature_image'),
201
- titleFontCategory: newsletter.get('title_font_category'),
202
- titleAlignment: newsletter.get('title_alignment'),
203
- bodyFontCategory: newsletter.get('body_font_category'),
204
- showBadge: newsletter.get('show_badge'),
205
- footerContent: newsletter.get('footer_content'),
206
- showHeaderName: newsletter.get('show_header_name'),
207
- accentColor,
208
- adjustedAccentColor,
209
- adjustedAccentContrastColor
210
- };
211
-
212
- if (templateSettings.headerImage) {
213
- if (isUnsplashImage(templateSettings.headerImage)) {
214
- // Unsplash images have a minimum size so assuming 1200px is safe
215
- const unsplashUrl = new URL(templateSettings.headerImage);
216
- unsplashUrl.searchParams.set('w', '1200');
217
-
218
- templateSettings.headerImage = unsplashUrl.href;
219
- templateSettings.headerImageWidth = 600;
220
- } else {
221
- const {imageSize} = require('../../lib/image');
222
- try {
223
- const size = await imageSize.getImageSizeFromUrl(templateSettings.headerImage);
246
+ async serialize(postModel, newsletter, options = {isBrowserPreview: false}) {
247
+ const post = await this.serializePostModel(postModel);
224
248
 
225
- if (size.width >= 600) {
226
- // keep original image, just set a fixed width
227
- templateSettings.headerImageWidth = 600;
228
- }
249
+ const timezone = settingsCache.get('timezone');
250
+ const momentDate = post.published_at ? moment(post.published_at) : moment();
251
+ post.published_at = momentDate.tz(timezone).format('DD MMM YYYY');
229
252
 
230
- if (isLocalContentImage(templateSettings.headerImage, urlUtils.getSiteUrl())) {
231
- // we can safely request a 1200px image - Ghost will serve the original if it's smaller
232
- templateSettings.headerImage = templateSettings.headerImage.replace(/\/content\/images\//, '/content/images/size/w1200/');
233
- }
234
- } catch (err) {
235
- // log and proceed. Using original header image without fixed width isn't fatal.
236
- logging.error(err);
253
+ if (post.authors) {
254
+ if (post.authors.length <= 2) {
255
+ post.authors = post.authors.map(author => author.name).join(' & ');
256
+ } else if (post.authors.length > 2) {
257
+ post.authors = `${post.authors[0].name} & ${post.authors.length - 1} others`;
237
258
  }
238
259
  }
239
- }
240
-
241
- return templateSettings;
242
- };
243
260
 
244
- const serialize = async (postModel, newsletter, options = {isBrowserPreview: false}) => {
245
- const post = await serializePostModel(postModel);
246
-
247
- const timezone = settingsCache.get('timezone');
248
- const momentDate = post.published_at ? moment(post.published_at) : moment();
249
- post.published_at = momentDate.tz(timezone).format('DD MMM YYYY');
250
-
251
- if (post.authors) {
252
- if (post.authors.length <= 2) {
253
- post.authors = post.authors.map(author => author.name).join(' & ');
254
- } else if (post.authors.length > 2) {
255
- post.authors = `${post.authors[0].name} & ${post.authors.length - 1} others`;
261
+ if (post.posts_meta) {
262
+ post.email_subject = post.posts_meta.email_subject;
256
263
  }
257
- }
258
-
259
- if (post.posts_meta) {
260
- post.email_subject = post.posts_meta.email_subject;
261
- }
262
-
263
- // we use post.excerpt as a hidden piece of text that is picked up by some email
264
- // clients as a "preview" when listing emails. Our current plaintext/excerpt
265
- // generation outputs links as "Link [https://url/]" which isn't desired in the preview
266
- if (!post.custom_excerpt && post.excerpt) {
267
- post.excerpt = post.excerpt.replace(/\s\[http(.*?)\]/g, '');
268
- }
269
264
 
270
- post.html = mobiledocLib.mobiledocHtmlRenderer.render(
271
- JSON.parse(post.mobiledoc), {target: 'email', postUrl: post.url}
272
- );
273
-
274
- // perform any email specific adjustments to the mobiledoc->HTML render output
275
- // body wrapper is required so we can get proper top-level selections
276
- const cheerio = require('cheerio');
277
- let _cheerio = cheerio.load(`<body>${post.html}</body>`);
278
- // remove leading/trailing HRs
279
- _cheerio(`
280
- body > hr:first-child,
281
- body > hr:last-child,
282
- body > div:first-child > hr:first-child,
283
- body > div:last-child > hr:last-child
284
- `).remove();
285
- post.html = _cheerio('body').html();
286
-
287
- post.plaintext = htmlToPlaintext.email(post.html);
288
-
289
- // Outlook will render feature images at full-size breaking the layout.
290
- // Content images fix this by rendering max 600px images - do the same for feature image here
291
- if (post.feature_image) {
292
- if (isUnsplashImage(post.feature_image)) {
293
- // Unsplash images have a minimum size so assuming 1200px is safe
294
- const unsplashUrl = new URL(post.feature_image);
295
- unsplashUrl.searchParams.set('w', '1200');
296
-
297
- post.feature_image = unsplashUrl.href;
298
- post.feature_image_width = 600;
299
- } else {
300
- const {imageSize} = require('../../lib/image');
301
- try {
302
- const size = await imageSize.getImageSizeFromUrl(post.feature_image);
303
-
304
- if (size.width >= 600) {
305
- // keep original image, just set a fixed width
306
- post.feature_image_width = 600;
307
- }
265
+ // we use post.excerpt as a hidden piece of text that is picked up by some email
266
+ // clients as a "preview" when listing emails. Our current plaintext/excerpt
267
+ // generation outputs links as "Link [https://url/]" which isn't desired in the preview
268
+ if (!post.custom_excerpt && post.excerpt) {
269
+ post.excerpt = post.excerpt.replace(/\s\[http(.*?)\]/g, '');
270
+ }
308
271
 
309
- if (isLocalContentImage(post.feature_image, urlUtils.getSiteUrl())) {
310
- // we can safely request a 1200px image - Ghost will serve the original if it's smaller
311
- post.feature_image = post.feature_image.replace(/\/content\/images\//, '/content/images/size/w1200/');
272
+ post.html = mobiledocLib.mobiledocHtmlRenderer.render(
273
+ JSON.parse(post.mobiledoc), {target: 'email', postUrl: post.url}
274
+ );
275
+
276
+ // perform any email specific adjustments to the mobiledoc->HTML render output
277
+ // body wrapper is required so we can get proper top-level selections
278
+ const cheerio = require('cheerio');
279
+ let _cheerio = cheerio.load(`<body>${post.html}</body>`);
280
+ // remove leading/trailing HRs
281
+ _cheerio(`
282
+ body > hr:first-child,
283
+ body > hr:last-child,
284
+ body > div:first-child > hr:first-child,
285
+ body > div:last-child > hr:last-child
286
+ `).remove();
287
+ post.html = _cheerio('body').html();
288
+
289
+ post.plaintext = htmlToPlaintext.email(post.html);
290
+
291
+ // Outlook will render feature images at full-size breaking the layout.
292
+ // Content images fix this by rendering max 600px images - do the same for feature image here
293
+ if (post.feature_image) {
294
+ if (isUnsplashImage(post.feature_image)) {
295
+ // Unsplash images have a minimum size so assuming 1200px is safe
296
+ const unsplashUrl = new URL(post.feature_image);
297
+ unsplashUrl.searchParams.set('w', '1200');
298
+
299
+ post.feature_image = unsplashUrl.href;
300
+ post.feature_image_width = 600;
301
+ } else {
302
+ const {imageSize} = require('../../lib/image');
303
+ try {
304
+ const size = await imageSize.getImageSizeFromUrl(post.feature_image);
305
+
306
+ if (size.width >= 600) {
307
+ // keep original image, just set a fixed width
308
+ post.feature_image_width = 600;
309
+ }
310
+
311
+ if (isLocalContentImage(post.feature_image, urlUtils.getSiteUrl())) {
312
+ // we can safely request a 1200px image - Ghost will serve the original if it's smaller
313
+ post.feature_image = post.feature_image.replace(/\/content\/images\//, '/content/images/size/w1200/');
314
+ }
315
+ } catch (err) {
316
+ // log and proceed. Using original feature_image without fixed width isn't fatal.
317
+ logging.error(err);
312
318
  }
313
- } catch (err) {
314
- // log and proceed. Using original feature_image without fixed width isn't fatal.
315
- logging.error(err);
316
319
  }
317
320
  }
318
- }
319
-
320
- const templateSettings = await getTemplateSettings(newsletter);
321
321
 
322
- const render = template;
322
+ const templateSettings = await this.getTemplateSettings(newsletter);
323
323
 
324
- let htmlTemplate = render({post, site: getSite(), templateSettings, newsletter: newsletter.toJSON()});
324
+ const render = template;
325
325
 
326
- if (options.isBrowserPreview) {
327
- const previewUnsubscribeUrl = createUnsubscribeUrl(null);
328
- htmlTemplate = htmlTemplate.replace('%recipient.unsubscribe_url%', previewUnsubscribeUrl);
329
- }
326
+ let htmlTemplate = render({post, site: this.getSite(), templateSettings, newsletter: newsletter.toJSON()});
330
327
 
331
- // Clean up any unknown replacements strings to get our final content
332
- const {html, plaintext} = normalizeReplacementStrings({
333
- html: formatHtmlForEmail(htmlTemplate),
334
- plaintext: post.plaintext
335
- });
336
- const data = {
337
- subject: post.email_subject || post.title,
338
- html,
339
- plaintext
340
- };
341
- if (labs.isSet('newsletterPaywall')) {
342
- data.post = post;
343
- }
344
- return data;
345
- };
328
+ if (options.isBrowserPreview) {
329
+ const previewUnsubscribeUrl = this.createUnsubscribeUrl(null);
330
+ htmlTemplate = htmlTemplate.replace('%recipient.unsubscribe_url%', previewUnsubscribeUrl);
331
+ }
346
332
 
347
- /**
348
- * renderPaywallCTA
349
- *
350
- * outputs html for rendering paywall CTA in newsletter
351
- *
352
- * @param {Object} post Post Object
353
- */
354
-
355
- function renderPaywallCTA(post) {
356
- const accentColor = settingsCache.get('accent_color');
357
- const siteTitle = settingsCache.get('title') || 'Ghost';
358
- const signupUrl = createPostSignupUrl(post);
359
-
360
- return `<div class="align-center" style="text-align: center;">
361
- <hr
362
- style="position: relative; display: block; width: 100%; margin: 3em 0; padding: 0; height: 1px; border: 0; border-top: 1px solid #e5eff5;">
363
- <h2
364
- style="margin-top: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; line-height: 1.11em; font-weight: 700; text-rendering: optimizeLegibility; margin: 1.5em 0 0.5em 0; font-size: 26px;">
365
- Subscribe to <span style="white-space: nowrap; font-size: 26px !important;">continue reading.</span></h2>
366
- <p style="margin: 0 auto 1.5em auto; line-height: 1.6em; max-width: 440px;">Become a paid member of ${siteTitle} to get access to all
367
- <span style="white-space: nowrap;">subscriber-only content.</span></p>
368
- <div class="btn btn-accent" style="box-sizing: border-box; width: 100%; display: table;">
369
- <table border="0" cellspacing="0" cellpadding="0" align="center"
370
- style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: auto;">
371
- <tbody>
372
- <tr>
373
- <td align="center"
374
- style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; vertical-align: top; text-align: center; border-radius: 5px;"
375
- valign="top" bgcolor="${accentColor}">
376
- <a href="${signupUrl}"
377
- style="overflow-wrap: anywhere; border: solid 1px #3498db; border-radius: 5px; box-sizing: border-box; cursor: pointer; display: inline-block; font-size: 14px; font-weight: bold; margin: 0; padding: 12px 25px; text-decoration: none; background-color: ${accentColor}; border-color: ${accentColor}; color: #FFFFFF;"
378
- target="_blank">Subscribe
379
- </a>
380
- </td>
381
- </tr>
382
- </tbody>
383
- </table>
384
- </div>
385
- <p style="margin: 0 0 1.5em 0; line-height: 1.6em;"></p>
386
- </div>`;
387
- }
388
-
389
- function renderEmailForSegment(email, memberSegment) {
390
- const cheerio = require('cheerio');
391
-
392
- const result = {...email};
393
-
394
- /** Checks and hides content for newsletter behind paywall card
395
- * based on member's status and post access
396
- * Adds CTA in case content is hidden.
397
- */
398
- if (labs.isSet('newsletterPaywall')) {
399
- const paywallIndex = (result.html || '').indexOf('<!--members-only-->');
400
- if (paywallIndex !== -1 && memberSegment && result.post) {
401
- let statusFilter = memberSegment === 'status:free' ? {status: 'free'} : {status: 'paid'};
402
- const postVisiblity = result.post.visibility;
403
-
404
- // For newsletter paywall, specific tiers visibility is considered on par to paid tiers
405
- result.post.visibility = postVisiblity === 'tiers' ? 'paid' : postVisiblity;
406
-
407
- const memberHasAccess = membersService.contentGating.checkPostAccess(result.post, statusFilter);
408
-
409
- if (!memberHasAccess) {
410
- const postContentEndIdx = result.html.search(/[\s\n\r]+?<!-- POST CONTENT END -->/);
411
- result.html = result.html.slice(0, paywallIndex) + renderPaywallCTA(result.post) + result.html.slice(postContentEndIdx);
412
- result.plaintext = htmlToPlaintext.excerpt(result.html);
333
+ // Clean up any unknown replacements strings to get our final content
334
+ const {html, plaintext} = this.normalizeReplacementStrings({
335
+ html: this.formatHtmlForEmail(htmlTemplate),
336
+ plaintext: post.plaintext
337
+ });
338
+ const data = {
339
+ subject: post.email_subject || post.title,
340
+ html,
341
+ plaintext
342
+ };
343
+ if (labs.isSet('newsletterPaywall')) {
344
+ data.post = post;
345
+ }
346
+ return data;
347
+ },
348
+
349
+ /**
350
+ * renderPaywallCTA
351
+ *
352
+ * outputs html for rendering paywall CTA in newsletter
353
+ *
354
+ * @param {Object} post Post Object
355
+ */
356
+ renderPaywallCTA(post) {
357
+ const accentColor = settingsCache.get('accent_color');
358
+ const siteTitle = settingsCache.get('title') || 'Ghost';
359
+ const signupUrl = this.createPostSignupUrl(post);
360
+
361
+ return `<div class="align-center" style="text-align: center;">
362
+ <hr
363
+ style="position: relative; display: block; width: 100%; margin: 3em 0; padding: 0; height: 1px; border: 0; border-top: 1px solid #e5eff5;">
364
+ <h2
365
+ style="margin-top: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; line-height: 1.11em; font-weight: 700; text-rendering: optimizeLegibility; margin: 1.5em 0 0.5em 0; font-size: 26px;">
366
+ Subscribe to <span style="white-space: nowrap; font-size: 26px !important;">continue reading.</span></h2>
367
+ <p style="margin: 0 auto 1.5em auto; line-height: 1.6em; max-width: 440px;">Become a paid member of ${siteTitle} to get access to all
368
+ <span style="white-space: nowrap;">subscriber-only content.</span></p>
369
+ <div class="btn btn-accent" style="box-sizing: border-box; width: 100%; display: table;">
370
+ <table border="0" cellspacing="0" cellpadding="0" align="center"
371
+ style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: auto;">
372
+ <tbody>
373
+ <tr>
374
+ <td align="center"
375
+ style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; vertical-align: top; text-align: center; border-radius: 5px;"
376
+ valign="top" bgcolor="${accentColor}">
377
+ <a href="${signupUrl}"
378
+ style="overflow-wrap: anywhere; border: solid 1px #3498db; border-radius: 5px; box-sizing: border-box; cursor: pointer; display: inline-block; font-size: 14px; font-weight: bold; margin: 0; padding: 12px 25px; text-decoration: none; background-color: ${accentColor}; border-color: ${accentColor}; color: #FFFFFF;"
379
+ target="_blank">Subscribe
380
+ </a>
381
+ </td>
382
+ </tr>
383
+ </tbody>
384
+ </table>
385
+ </div>
386
+ <p style="margin: 0 0 1.5em 0; line-height: 1.6em;"></p>
387
+ </div>`;
388
+ },
389
+
390
+ renderEmailForSegment(email, memberSegment) {
391
+ const cheerio = require('cheerio');
392
+
393
+ const result = {...email};
394
+
395
+ /** Checks and hides content for newsletter behind paywall card
396
+ * based on member's status and post access
397
+ * Adds CTA in case content is hidden.
398
+ */
399
+ if (labs.isSet('newsletterPaywall')) {
400
+ const paywallIndex = (result.html || '').indexOf('<!--members-only-->');
401
+ if (paywallIndex !== -1 && memberSegment && result.post) {
402
+ let statusFilter = memberSegment === 'status:free' ? {status: 'free'} : {status: 'paid'};
403
+ const postVisiblity = result.post.visibility;
404
+
405
+ // For newsletter paywall, specific tiers visibility is considered on par to paid tiers
406
+ result.post.visibility = postVisiblity === 'tiers' ? 'paid' : postVisiblity;
407
+
408
+ const memberHasAccess = membersService.contentGating.checkPostAccess(result.post, statusFilter);
409
+
410
+ if (!memberHasAccess) {
411
+ const postContentEndIdx = result.html.search(/[\s\n\r]+?<!-- POST CONTENT END -->/);
412
+ result.html = result.html.slice(0, paywallIndex) + this.renderPaywallCTA(result.post) + result.html.slice(postContentEndIdx);
413
+ result.plaintext = htmlToPlaintext.excerpt(result.html);
414
+ }
413
415
  }
414
416
  }
415
- }
416
417
 
417
- const $ = cheerio.load(result.html);
418
+ const $ = cheerio.load(result.html);
418
419
 
419
- $('[data-gh-segment]').get().forEach((node) => {
420
- if (node.attribs['data-gh-segment'] !== memberSegment) { //TODO: replace with NQL interpretation
421
- $(node).remove();
422
- } else {
423
- // Getting rid of the attribute for a cleaner html output
424
- $(node).removeAttr('data-gh-segment');
425
- }
426
- });
420
+ $('[data-gh-segment]').get().forEach((node) => {
421
+ if (node.attribs['data-gh-segment'] !== memberSegment) { //TODO: replace with NQL interpretation
422
+ $(node).remove();
423
+ } else {
424
+ // Getting rid of the attribute for a cleaner html output
425
+ $(node).removeAttr('data-gh-segment');
426
+ }
427
+ });
427
428
 
428
- result.html = formatHtmlForEmail($.html());
429
- result.plaintext = htmlToPlaintext.email(result.html);
430
- delete result.post;
429
+ result.html = this.formatHtmlForEmail($.html());
430
+ result.plaintext = htmlToPlaintext.email(result.html);
431
+ delete result.post;
431
432
 
432
- return result;
433
- }
433
+ return result;
434
+ }
435
+ };
434
436
 
435
437
  module.exports = {
436
- serialize,
437
- createUnsubscribeUrl,
438
- createPostSignupUrl,
439
- renderEmailForSegment,
440
- parseReplacements,
438
+ serialize: PostEmailSerializer.serialize.bind(PostEmailSerializer),
439
+ createUnsubscribeUrl: PostEmailSerializer.createUnsubscribeUrl.bind(PostEmailSerializer),
440
+ createPostSignupUrl: PostEmailSerializer.createPostSignupUrl.bind(PostEmailSerializer),
441
+ renderEmailForSegment: PostEmailSerializer.renderEmailForSegment.bind(PostEmailSerializer),
442
+ parseReplacements: PostEmailSerializer.parseReplacements.bind(PostEmailSerializer),
441
443
  // Export for tests
442
- _getTemplateSettings: getTemplateSettings
444
+ _getTemplateSettings: PostEmailSerializer.getTemplateSettings.bind(PostEmailSerializer),
445
+ _PostEmailSerializer: PostEmailSerializer
443
446
  };