ghost 5.121.0 → 5.123.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 (49) hide show
  1. package/components/tryghost-i18n-5.123.0.tgz +0 -0
  2. package/core/built/admin/assets/admin-x-activitypub/admin-x-activitypub.js +14017 -14035
  3. package/core/built/admin/assets/admin-x-settings/{CodeEditorView-03f7aa4d.mjs → CodeEditorView-aba69ca9.mjs} +2 -2
  4. package/core/built/admin/assets/admin-x-settings/admin-x-settings.js +1 -1
  5. package/core/built/admin/assets/admin-x-settings/{index-eea9098a.mjs → index-87586072.mjs} +4 -4
  6. package/core/built/admin/assets/admin-x-settings/{index-b0484a21.mjs → index-b3cfde97.mjs} +2 -2
  7. package/core/built/admin/assets/admin-x-settings/{modals-85a92c66.mjs → modals-890d62ee.mjs} +1669 -1618
  8. package/core/built/admin/assets/{chunk.383.77dd3c8cbac5f464c6da.js → chunk.383.3dcd1102188f9d5e0467.js} +7 -7
  9. package/core/built/admin/assets/{chunk.524.22c1abc09947848be03d.js → chunk.524.f1c003b0bae2aa6a9805.js} +7 -7
  10. package/core/built/admin/assets/{chunk.582.a7cbc2ce76922c8870bc.js → chunk.582.4d677cde396354a18509.js} +9 -9
  11. package/core/built/admin/assets/{ghost-1c91950af9d6f9cee826820f056e0025.js → ghost-4dac0876cd7426ba504e4143b9772689.js} +141 -132
  12. package/core/built/admin/assets/posts/posts.js +71680 -41896
  13. package/core/built/admin/assets/stats/stats.js +35194 -35499
  14. package/core/built/admin/index.html +4 -4
  15. package/core/frontend/public/ghost-stats.min.js +3 -3
  16. package/core/frontend/src/ghost-stats/ghost-stats.js +0 -12
  17. package/core/server/api/endpoints/previews.js +25 -4
  18. package/core/server/api/endpoints/stats.js +50 -0
  19. package/core/server/data/migrations/versions/5.122/2025-06-03-19-32-57-change-default-for-newsletters-button-color.js +37 -0
  20. package/core/server/data/schema/schema.js +1 -1
  21. package/core/server/data/tinybird/endpoints/api_top_pages.pipe +1 -0
  22. package/core/server/data/tinybird/fixtures/analytics_events.ndjson +31 -31
  23. package/core/server/data/tinybird/pipes/mv_hits.pipe +51 -11
  24. package/core/server/data/tinybird/tests/api_top_pages.yaml +7 -0
  25. package/core/server/models/newsletter.js +1 -0
  26. package/core/server/services/adapter-manager/AdapterManager.js +5 -1
  27. package/core/server/services/comments/CommentsServiceEmails.js +8 -6
  28. package/core/server/services/email-service/EmailRenderer.js +80 -23
  29. package/core/server/services/email-service/email-templates/partials/paywall.hbs +56 -33
  30. package/core/server/services/email-service/email-templates/partials/styles.hbs +129 -4
  31. package/core/server/services/email-service/email-templates/template.hbs +1 -1
  32. package/core/server/services/koenig/node-renderers/button-renderer.js +1 -21
  33. package/core/server/services/koenig/node-renderers/call-to-action-renderer.js +10 -44
  34. package/core/server/services/koenig/node-renderers/header-v2-renderer.js +15 -31
  35. package/core/server/services/koenig/node-renderers/horizontalrule-renderer.js +43 -2
  36. package/core/server/services/koenig/node-renderers/product-renderer.js +0 -1
  37. package/core/server/services/koenig/render-partials/email-button.js +127 -21
  38. package/core/server/services/koenig/render-utils/stylex.js +104 -0
  39. package/core/server/services/stats/MembersStatsService.js +129 -5
  40. package/core/server/services/stats/PostsStatsService.js +281 -1
  41. package/core/server/services/stats/StatsService.js +23 -1
  42. package/core/server/services/stats/utils/tinybird.js +25 -5
  43. package/core/server/web/api/endpoints/admin/routes.js +2 -0
  44. package/package.json +13 -13
  45. package/tsconfig.tsbuildinfo +1 -1
  46. package/yarn.lock +851 -310
  47. package/components/tryghost-i18n-5.121.0.tgz +0 -0
  48. package/core/frontend/src/utils/session-storage.js +0 -68
  49. /package/core/built/admin/assets/{chunk.383.77dd3c8cbac5f464c6da.js.LICENSE.txt → chunk.383.3dcd1102188f9d5e0467.js.LICENSE.txt} +0 -0
@@ -1,34 +1,60 @@
1
1
  const clsx = require('clsx');
2
+ const {textColorForBackgroundColor} = require('@tryghost/color-utils');
2
3
  const {html} = require('../render-utils/tagged-template-fns.js');
4
+ const stylex = require('../render-utils/stylex.js');
3
5
 
4
6
  /**
5
- * @param {Object} options
6
- * @param {string} [options.alignment]
7
- * @param {string} [options.color='accent']
8
- * @param {string} [options.text='']
9
- * @param {string} [options.textColor]
10
- * @param {string} [options.url='']
11
- * @param {string} [options.buttonWidth='']
7
+ * @typedef {Object} EmailButtonOptions
8
+ * @property {string} [url=''] - The URL the button links to
9
+ * @property {string} [text=''] - The text displayed on the button
10
+ * @property {string} [alignment] - The alignment of the button
11
+ * @property {string} [buttonWidth=''] - The width of the button
12
+ * @property {string} [color=''] - The color of the button, no color defaults to newsletter button color setting
13
+ * @property {'fill'|'outline'} [style='fill'] - The style of the button
14
+ */
15
+
16
+ const defaultOptions = {
17
+ url: '',
18
+ text: '',
19
+ alignment: '',
20
+ buttonWidth: '',
21
+ color: '',
22
+ style: /** @type {'fill'|'outline'} */ ('fill')
23
+ };
24
+
25
+ /**
26
+ * @param {EmailButtonOptions} buttonOptions
27
+ * @returns {EmailButtonOptions}
28
+ */
29
+ function _getOptions(buttonOptions = {}) {
30
+ // merge with defaults
31
+ // but we don't want undefined values to override default values
32
+ return {
33
+ ...defaultOptions,
34
+ ...Object.fromEntries(
35
+ Object.entries(buttonOptions).filter(([, value]) => value !== undefined)
36
+ )
37
+ };
38
+ }
39
+
40
+ /**
41
+ * @param {EmailButtonOptions} buttonOptions
12
42
  * @returns {string}
13
43
  */
14
- function renderEmailButton({
15
- alignment = '',
16
- color = 'accent',
17
- text = '',
18
- url = '',
19
- buttonWidth = ''
20
- } = {}) {
21
- const buttonClasses = clsx(
22
- 'btn',
23
- color === 'accent' && 'btn-accent'
24
- );
44
+ function renderEmailButton(buttonOptions = {}) {
45
+ const options = _getOptions(buttonOptions);
46
+ const {url, text, alignment, buttonWidth} = options;
47
+
48
+ const buttonClasses = _getButtonClasses(options);
49
+ const buttonStyle = _getButtonStyle(options);
50
+ const linkStyle = _getLinkStyle(options);
25
51
 
26
52
  return html`
27
53
  <table class="${buttonClasses}" border="0" cellspacing="0" cellpadding="0"${alignment ? ` align="${alignment}"` : ''}>
28
54
  <tbody>
29
55
  <tr>
30
- <td align="center"${buttonWidth ? ` width="${buttonWidth}"` : ''}>
31
- <a href="${url}">${text}</a>
56
+ <td align="center"${buttonWidth ? ` width="${buttonWidth}"` : ''}${buttonStyle ? ` style="${buttonStyle}"` : ''}>
57
+ <a href="${url}"${linkStyle ? ` style="${linkStyle}"` : ''}>${text}</a>
32
58
  </td>
33
59
  </tr>
34
60
  </tbody>
@@ -36,6 +62,86 @@ function renderEmailButton({
36
62
  `;
37
63
  }
38
64
 
65
+ /**
66
+ * @param {EmailButtonOptions} options
67
+ * @returns {boolean}
68
+ */
69
+ function _isColoredFill({color, style}) {
70
+ return color && color !== 'accent' && color !== 'transparent' && style === 'fill';
71
+ }
72
+
73
+ /**
74
+ * @param {EmailButtonOptions} options
75
+ * @returns {boolean}
76
+ */
77
+ function _isColoredOutline({color, style}) {
78
+ return color && color !== 'accent' && style === 'outline';
79
+ }
80
+
81
+ /**
82
+ * @param {EmailButtonOptions} options
83
+ * @returns {string}
84
+ */
85
+ function _getTextColor({color, style}) {
86
+ if (_isColoredFill({color, style})) {
87
+ return textColorForBackgroundColor(color).hex();
88
+ }
89
+
90
+ return '';
91
+ }
92
+
93
+ /**
94
+ * @param {EmailButtonOptions} options
95
+ * @returns {string}
96
+ */
97
+ function _getButtonClasses({color}) {
98
+ return clsx(
99
+ 'btn',
100
+ color === 'accent' && 'btn-accent'
101
+ );
102
+ }
103
+
104
+ /**
105
+ * @param {EmailButtonOptions} options
106
+ * @returns {string}
107
+ */
108
+ function _getButtonStyle({color, style}) {
109
+ return stylex(
110
+ _isColoredFill({color, style}) && {
111
+ backgroundColor: color
112
+ },
113
+ _isColoredOutline({color, style}) && {
114
+ border: `1px solid ${color}`,
115
+ backgroundColor: 'transparent',
116
+ color: `${color} !important`
117
+ }
118
+ );
119
+ }
120
+
121
+ /**
122
+ * @param {EmailButtonOptions} options
123
+ * @returns {string}
124
+ */
125
+ function _getLinkStyle({color, style}) {
126
+ const textColor = _getTextColor({color, style});
127
+
128
+ return stylex(
129
+ _isColoredFill({color, style}) && textColor && {
130
+ color: `${textColor} !important`
131
+ },
132
+ _isColoredOutline({color, style}) && {
133
+ color: `${color} !important`
134
+ }
135
+ );
136
+ }
137
+
39
138
  module.exports = {
40
- renderEmailButton
139
+ renderEmailButton,
140
+ _getOptions,
141
+ _isColoredFill,
142
+ _isColoredOutline,
143
+ _getTextColor,
144
+ _getButtonClasses,
145
+ _getButtonStyle,
146
+ _getLinkStyle
41
147
  };
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Converts a camelCase string to kebab-case
3
+ * @param {string} str - The string to convert
4
+ * @returns {string} The kebab-case string
5
+ */
6
+ function toKebabCase(str) {
7
+ return str.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
8
+ }
9
+
10
+ /**
11
+ * Formats a CSS value, adding units where necessary
12
+ * @param {string} key - The CSS property name
13
+ * @param {*} value - The value to format
14
+ * @returns {string} The formatted CSS value
15
+ */
16
+ function formatValue(key, value) {
17
+ if (value === null || value === undefined || value === '') {
18
+ return '';
19
+ }
20
+
21
+ // Properties that should get px units for numeric values
22
+ const needsPx = /^(width|height|top|right|bottom|left|margin|padding|fontSize|borderRadius|marginTop|marginRight|marginBottom|marginLeft|paddingTop|paddingRight|paddingBottom|paddingLeft|borderTopWidth|borderRightWidth|borderBottomWidth|borderLeftWidth)$/;
23
+
24
+ // Properties that should never get px units
25
+ const noPx = /^(lineHeight|opacity|zIndex|flexGrow|flexShrink|order)$/;
26
+
27
+ if (typeof value === 'number') {
28
+ if (needsPx.test(key)) {
29
+ return `${value}px`;
30
+ }
31
+ if (!noPx.test(key)) {
32
+ return `${value}px`;
33
+ }
34
+ }
35
+
36
+ return String(value);
37
+ }
38
+
39
+ /**
40
+ * Parses a CSS string into an object of style properties
41
+ * @param {string} cssString - The CSS string to parse
42
+ * @returns {Object} Object containing style properties
43
+ */
44
+ function parseCssString(cssString) {
45
+ if (typeof cssString !== 'string') {
46
+ return {};
47
+ }
48
+
49
+ const styles = {};
50
+ const declarations = cssString.split(';').filter(Boolean);
51
+
52
+ declarations.forEach((declaration) => {
53
+ const [property, value] = declaration.split(':').map(part => part.trim());
54
+ if (property && value) {
55
+ // Convert kebab-case to camelCase for consistency
56
+ const camelCaseProperty = property.replace(/-([a-z])/g, g => g[1].toUpperCase());
57
+ styles[camelCaseProperty] = value;
58
+ }
59
+ });
60
+
61
+ return styles;
62
+ }
63
+
64
+ /**
65
+ * Combines multiple style objects and CSS strings into a single CSS string
66
+ * @param {...(Object|string)} styles - Style objects or CSS strings to combine
67
+ * @returns {string} Combined CSS string
68
+ */
69
+ function stylex(...styles) {
70
+ const mergedStyles = {};
71
+
72
+ // Process each style argument
73
+ styles.forEach((style) => {
74
+ if (!style) {
75
+ return;
76
+ }
77
+
78
+ if (typeof style === 'string') {
79
+ // Handle CSS strings
80
+ const parsedStyles = parseCssString(style);
81
+ Object.assign(mergedStyles, parsedStyles);
82
+ } else if (typeof style === 'object') {
83
+ // Handle style objects
84
+ Object.entries(style).forEach(([key, value]) => {
85
+ if (value !== null && value !== false) {
86
+ mergedStyles[key] = value;
87
+ }
88
+ });
89
+ }
90
+ });
91
+
92
+ // Convert to CSS string with spaces after semicolons
93
+ const styleString = Object.entries(mergedStyles)
94
+ .map(([key, value]) => {
95
+ const formattedValue = formatValue(key, value);
96
+ return formattedValue ? `${toKebabCase(key)}: ${formattedValue}` : '';
97
+ })
98
+ .filter(Boolean)
99
+ .join('; ');
100
+
101
+ return styleString ? styleString + ';' : '';
102
+ }
103
+
104
+ module.exports = stylex;
@@ -65,7 +65,7 @@ class MembersStatsService {
65
65
  ) as free_delta`))
66
66
  .where('created_at', '>=', formattedStartDate)
67
67
  .groupByRaw('DATE(created_at)');
68
- return rows;
68
+ return /** @type {MemberStatusDelta[]} */ (/** @type {unknown} */ (rows));
69
69
  }
70
70
 
71
71
  /**
@@ -79,14 +79,138 @@ class MembersStatsService {
79
79
 
80
80
  // Fetch current total amounts and start counting from there
81
81
  const totals = await this.getCount();
82
- let {paid, free, comped} = totals;
83
82
 
84
- // Get today in UTC (default timezone)
85
- const today = moment().format('YYYY-MM-DD');
83
+ // Get today in UTC (consistent with frontend)
84
+ const today = moment.utc().format('YYYY-MM-DD');
85
+
86
+ // When startDate is provided, always return complete range
87
+ if (options.startDate) {
88
+ return this._generateCompleteRange(rows, totals, options.startDate, today);
89
+ }
90
+
91
+ // Use original sparse logic only for default case (no startDate)
92
+ return this._generateSparseRange(rows, totals, today);
93
+ }
94
+
95
+ /**
96
+ * Generate complete date range with forward-filling for frontend
97
+ * @param {Array} rows - Event data from database
98
+ * @param {Object} totals - Current member totals
99
+ * @param {string|Date} startDate - Start date for range
100
+ * @param {string} today - Today's date in YYYY-MM-DD format
101
+ * @returns {Object} Complete date range data
102
+ */
103
+ _generateCompleteRange(rows, totals, startDate, today) {
104
+ const startDateMoment = moment.utc(startDate).startOf('day');
105
+ const endDateMoment = moment.utc(today).startOf('day');
106
+
107
+ // Create a map of events by date for fast lookup
108
+ const eventsMap = new Map();
109
+ rows.forEach((row) => {
110
+ const date = moment(row.date).format('YYYY-MM-DD');
111
+ eventsMap.set(date, row);
112
+ });
113
+
114
+ // Sort rows chronologically to calculate historical totals
115
+ rows.sort((a, b) => moment(a.date).valueOf() - moment(b.date).valueOf());
116
+
117
+ // Work backwards from current totals to build historical data for event dates
118
+ let runningPaid = totals.paid;
119
+ let runningFree = totals.free;
120
+ let runningComped = totals.comped;
86
121
 
122
+ const historicalTotalsMap = new Map();
123
+
124
+ // Calculate totals for each event date by working backwards
125
+ for (let i = rows.length - 1; i >= 0; i -= 1) {
126
+ const row = rows[i];
127
+ const date = moment(row.date).format('YYYY-MM-DD');
128
+
129
+ if (date > today) {
130
+ continue; // Skip future dates
131
+ }
132
+
133
+ // Store the totals for this date
134
+ historicalTotalsMap.set(date, {
135
+ paid: Math.max(0, runningPaid),
136
+ free: Math.max(0, runningFree),
137
+ comped: Math.max(0, runningComped),
138
+ paid_subscribed: row.paid_subscribed,
139
+ paid_canceled: row.paid_canceled
140
+ });
141
+
142
+ // Update running totals for previous days
143
+ runningPaid -= row.paid_subscribed - row.paid_canceled;
144
+ runningFree -= row.free_delta;
145
+ runningComped -= row.comped_delta;
146
+ }
147
+
148
+ // Generate complete date range from day before startDate to today (includes baseline)
149
+ const results = [];
150
+ const currentDate = moment(startDateMoment).subtract(1, 'day');
151
+
152
+ // Track the last known values for forward-filling
153
+ let lastKnownTotals = {
154
+ paid: Math.max(0, runningPaid), // Historical baseline before all events
155
+ free: Math.max(0, runningFree),
156
+ comped: Math.max(0, runningComped)
157
+ };
158
+
159
+ while (currentDate.isSameOrBefore(endDateMoment)) {
160
+ const dateStr = currentDate.format('YYYY-MM-DD');
161
+
162
+ if (historicalTotalsMap.has(dateStr)) {
163
+ // Use actual event data and update our last known totals
164
+ const eventData = historicalTotalsMap.get(dateStr);
165
+ lastKnownTotals = {
166
+ paid: eventData.paid,
167
+ free: eventData.free,
168
+ comped: eventData.comped
169
+ };
170
+ results.push({
171
+ date: dateStr,
172
+ paid: eventData.paid,
173
+ free: eventData.free,
174
+ comped: eventData.comped,
175
+ paid_subscribed: eventData.paid_subscribed,
176
+ paid_canceled: eventData.paid_canceled
177
+ });
178
+ } else {
179
+ // Forward-fill with last known totals (no events on this day)
180
+ results.push({
181
+ date: dateStr,
182
+ paid: lastKnownTotals.paid,
183
+ free: lastKnownTotals.free,
184
+ comped: lastKnownTotals.comped,
185
+ paid_subscribed: 0,
186
+ paid_canceled: 0
187
+ });
188
+ }
189
+
190
+ currentDate.add(1, 'day');
191
+ }
192
+
193
+ return {
194
+ data: results,
195
+ meta: {
196
+ totals
197
+ }
198
+ };
199
+ }
200
+
201
+ /**
202
+ * Generate sparse date range (original behavior for backward compatibility)
203
+ * @param {Array} rows - Event data from database
204
+ * @param {Object} totals - Current member totals
205
+ * @param {string} today - Today's date in YYYY-MM-DD format
206
+ * @returns {Object} Sparse date range data
207
+ */
208
+ _generateSparseRange(rows, totals, today) {
209
+ let {paid, free, comped} = totals;
87
210
  const cumulativeResults = [];
88
211
 
89
- rows.sort((a, b) => new Date(a.date) - new Date(b.date));
212
+ rows.sort((a, b) => moment(a.date).valueOf() - moment(b.date).valueOf());
213
+
90
214
  // Loop in reverse order (needed to have correct sorted result)
91
215
  for (let i = rows.length - 1; i >= 0; i -= 1) {
92
216
  const row = rows[i];