From 2121d7341d0a5ea61fa5229e9d0df4e7076cebe7 Mon Sep 17 00:00:00 2001 From: Cathy Sarisky Date: Thu, 31 Oct 2024 13:46:32 -0400 Subject: [PATCH 01/12] Squashed commit of the following: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit commit 361d88bd48e645d8e113f2546b219b90ab9d4aaa Merge: fcc6314395 4c79887b79 Author: Cathy Sarisky <42299862+cathysarisky@users.noreply.github.com> Date: Thu Oct 31 13:23:45 2024 -0400 Merge branch 'main' into theme-i18n commit 4c79887b79da71f999770a6f02f4dbd7f7673bd1 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Oct 31 16:39:05 2024 +0000 Update dependency compression to v1.7.5 commit fe2cff4e1d51911608d02a589ba63308929dcef9 Author: Hannah Wolfe Date: Thu Oct 31 16:36:44 2024 +0000 Moved search i18n behind labs flag (#21488) - When we added i18n for search we missed gating it behind the i18n flag. - There aren't that many translations for search yet, so it's likely not many have noticed yet - We'll remove the flag soon, but until then, adding the flag for consistency :) commit 3ecfe08e1d6c69167c32e681af04b014e5c10fb3 Author: Michael Barrett Date: Thu Oct 31 16:34:53 2024 +0000 Added failure state for reply in admin-x-activitypub (#21487) no refs Added a failure state for when a reply fails to be sent commit f601ab3fda9c2e73543ac849fe34ddf1a5b24e6a Author: Cathy Sarisky <42299862+cathysarisky@users.noreply.github.com> Date: Thu Oct 31 11:32:34 2024 -0400 ✨ Added "exclude" option for customizing {{ghost_head}} (#21229) no ref {{ghost_head}} is huge, and some power-users and theme creators want the ability to customize what it contains. This PR makes it easier for a theme to write custom schema, or to load a custom version of portal/comments/search/etc, or to minimize load times by not loading scripts where they aren't needed, in a theme-specific way. Because ghost_head is controlled at the theme level, this gives folks in managed hosting the new ability to load a different version of the included app scripts (by preventing ghost_head from writing them and adding them in manually). Usage example: ` {{ghost_head exclude="search,portal"}} ` (empty array) No changes to current behavior search The built-in sodo-search script Includes adding the click event listener on buttons, generating the search index, and the UI. portal The portal script Handles sign-in and sign-up, payments, tips, memberships, etc, and all the portal data-attributes. announcement The announcement bar javascript If you'd like to use the announcement bar admin settings but not have it [mess up your CLS metric](https://www.spectralwebservices.com/blog/announcement-bar-a-review/), this is for you. metadata Skips HTML tags for meta description, favicon, canonical url, robots, referrer Important for SEO schema The LD+JSON schema Important for SEO card_assets Loads cards.min.css and .js Needed on any page with a post body, unless your theme replaces them all. Assets can also be selectively loaded with the [card_assets override](https://ghost.org/docs/themes/content/?ref=spectralwebservices.com#editor-cards) comment_counts Loads the comment_counts helper Needed if the page is using {{comments}} or data-ghost-comment-count attribute social_data Produces the og: and twitter: attributes for social media sharing and previews Required for good social media cards cta_styles Removes the call to action (CTA) styles Used for member signup and CTA cards - may be overwritten by your theme already commit 7e50a4051fc6ecf3b3a149d927b004a3e9408483 Author: Kevin Ansfield Date: Thu Oct 31 14:10:31 2024 +0000 Improved error log when Twitter enhanced oembed fails ref https://linear.app/ghost/issue/ONC-506 - adding `context` with the returned API response makes the logged error much more useful as without it we only log the status code which misses any details for why the failure occurred commit 1d429b8b09ec807dabe8228085646fdb34700255 Author: Cathy Sarisky <42299862+cathysarisky@users.noreply.github.com> Date: Thu Oct 31 09:41:39 2024 -0400 🌐✨Added i18n for newsletter strings (#21433) no issue This PR adds the ability to translate the strings that appear in the newsletter as boilerplate text, using i18next. Variables are in single mustaches ( `{date}` ) in the translation strings (rather than `{{date}}`), because these strings occur both the email template.hbs and also .js files. That necessitated a separate namespace. This PR also includes changes to the newsletter button ("more like this", "less like this", "comment") that were previously delivered on desktop as images that included the text. @sanne-san provided a rework that removed text-as-image from the desktop buttons, and allows more shared code between the two layouts, along with making the buttons translatable. Example usage - handlebars ```

{{t 'Keep reading'}}

{{{t 'By {authors}' authors=post.authors }}} ``` (NOTE: triple { required because of possible & ) Example usage - javascript ``` getValue: (member) => { if (member.status === 'comped') { return t('complimentary'); } if (this.isMemberTrialing(member)) { return t('trialing'); } // other possible statuses: t('free'), t('paid') // return t(member.status); } ``` --------- Co-authored-by: Sanne de Vries Co-authored-by: Steve Larson <9larsons@gmail.com> commit dd2ed27d9d0582a0ccbf10ec12235cd619574c49 Author: Michael Barrett Date: Thu Oct 31 11:40:41 2024 +0000 Removed unused `useAllActivitiesForUser` hook and related code in admin-x-activitypub app (#21482) no refs Removed unused `useAllActivitiesForUser` hook and related code. This was a left over from when we added paginated activities commit 085afdeb747b9b78c124709736a0e63a176a1bbf Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Oct 31 10:53:43 2024 +0000 Pin dependency clsx to 2.1.1 commit 45711e197c0706cc0949c9a9a2edb4912ddf548c Author: Djordje Vlaisavljevic Date: Thu Oct 31 10:50:51 2024 +0000 AP design bugs (#21395) - Fixed links in profile description - Stripped post content - Fixed grey bg in Avatars - Installed `clsx` --------- Co-authored-by: Michael Barrett commit 4f4662490f1aacd60d778bc983ff612d026c5dab Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Oct 31 10:11:27 2024 +0000 Update Koenig packages (#21480) commit 824efc7f100d3e92e641ee214cc2b49f4b5b39e3 Author: Djordje Vlaisavljevic Date: Thu Oct 31 10:07:52 2024 +0000 Added UUID as data attribute to posts in all views (#21470) ref https://linear.app/ghost/issue/AP-404/add-uuid-as-a-data-attribute-in-the-dom-for-easier-db-lookup - This will allow us to find posts in the DB Co-authored-by: Michael Barrett commit becfd1314175d7d064128c0707eb777ed3792740 Author: Djordje Vlaisavljevic Date: Thu Oct 31 09:58:47 2024 +0000 Refactored handleViewContent so it can be reused (#21468) ref https://linear.app/ghost/issue/AP-540/clicking-comment-icon-on-posts-and-likes-tabs-of-your-profile-doesnt - We want to open posts in the drawer from multiple views (Inbox, Profile etc.) and this change allows us to do so by pulling `handleViewContent` from `Inbox.tsx` into a utility function. At the same time, we’ve simplified the function so it uses less props to achieve the same functionality. - Also added a simple fix for scrolling the reply-box into view when opening a long `article` by clicking on the reply icon. We probably still need to figure out a more robust solution, because the height of the `iframe` and the fact it takes some time to load it sometimes gets in the way. Co-authored-by: Michael Barrett commit 5f59ddaacc30c72df5f05b7c536c13329f5fcb3f Author: Michael Barrett Date: Thu Oct 31 09:38:52 2024 +0000 Updated replies implementation to use thread mechanism in admin-x-activitypub (#21465) refs: - https://linear.app/ghost/issue/AP-439/seeing-parent-post-for-replies - https://linear.app/ghost/issue/AP-481/reply-ui - https://linear.app/ghost/issue/AP-482/replies-to-a-post-should-be-visible-when-opening-it Updated the replies implementation to make use of the new thread mechanism. This allows for a more sane approach to handling replies as well as making it possible to show the parent of a reply in the UI --------- Co-authored-by: Djordje Vlaisavljevic commit ea6d3a0f26b7f48e745f1c43f8d84e3e47b03970 Author: Daniel Lockyer Date: Thu Oct 31 09:45:14 2024 +0100 ⚡️ Optimized fetching strings from the settings cache fix https://linear.app/ghost/issue/ENG-1105/settingscacheget-is-slow - through profiling and flamegraphs, we can see that `_doGet` is one of the bottlenecks during high traffic times, sometimes taking up to 20% of the CPU time when hammering Ghost with `wrk` - this is because, for the majority of settings cache lookup, we're running `JSON.parse`, which blocks the main thread - whilst we're only parsing small strings, we're doing it a LOT, sometimes hundreds of times per request, which adds up - this code just throws most deserializing at `JSON.parse`, so if we can stop it from doing that, it'd be a huge win - my initial attempts here were to convert the _doGet function to a smarter deserializing, by looking up `cacheEntry.type` and acting accordingly - however, it became a bit of a logical nightmare, and difficult to reason about for now (i still think we should do it) - therefore, I'm just doing to add a hotpath fix to catch 99% of usecases, which is checking the type of the cache entry and returning the value if it's a string - on a trivial benchmark locally, this causes Ghost to return 30% more requests per second!! commit fa31176621f3e3233bb645bfc1a784ba8f1bb368 Author: Sodbileg Gansukh Date: Thu Oct 31 13:43:39 2024 +0800 Removed Prata font (#21478) ref DES-926 commit e01b952ed2875b5411999dc038fe4c69fb07e870 Author: Sodbileg Gansukh Date: Thu Oct 31 13:30:25 2024 +0800 Made the tabs sticky in design and newsletter settings (#21477) ref DES-927, DES-928 commit 87e24f64039fdd4de674993280a402809c025630 Author: Ronald Langeveld Date: Thu Oct 31 12:28:44 2024 +0900 Revert "Enhanced Comments Ordering for Best Liked Sorting (#21473)" (#21475) This reverts commit fd18a392389e0c6dae5cdcff9d9ff2d3a890e3b1. commit fd18a392389e0c6dae5cdcff9d9ff2d3a890e3b1 Author: Ronald Langeveld Date: Thu Oct 31 10:44:15 2024 +0900 Enhanced Comments Ordering for Best Liked Sorting (#21473) ref PLG-220 - Improved `getBestComments` service to paginate correctly since we're using a custom query to determine the top comments that goes beyond the scope of what `findPage` is capable of. - Updated CommentsController and CommentsService to support custom order parameters. - Added tests commit fe9b01910d8e5909fadbc36c94408243feb07b72 Author: Chris Raible Date: Wed Oct 30 12:39:41 2024 -0700 Cleaned up browser test output in CI (#21462) no issue - The browser test output in CI is really noisy, because the `NX_DAEMON` doens't run in CI, but we're trying to use NX to watch and rebuild the typescript modules. This is outputting a ton of "NX Daemon is not running" type of errors, which make it difficult to sift through the actual test results. - We don't actually need to watch the typescript files, we just need to build them once before starting. This is defined as an NX dependency for the browser tests target, so we don't need to explicitly build the TS packages at all. Removing the typescript watch & build command removes the noisy errors, without impacting how the tests actually run. commit 30fc2f3d50239181c1612a58b7ef9f174ba59eab Author: Chris Raible Date: Wed Oct 30 11:56:10 2024 -0700 Added `ps` dependency to Dockerfile (#21471) no issue - When stopping `yarn dev` with ctrl+c in the dev container, you'd get an error because the container doens't have `ps` installed, which is used by node under the hood. Adding this dependency fixed the error so `yarn dev` (and other commands) exit cleanly commit fbad9f114a4bb6deb8e8b5e2ed43394f753bc8f2 Author: matsbst Date: Wed Oct 30 16:38:46 2024 +0100 🌐 Added new and improved Norwegian translation (#21452) - New and improved Norwegian translation. All strings translated. commit 97e756ec3b61aa067c5534bd44eb9be484d422d0 Author: Steve Larson <9larsons@gmail.com> Date: Wed Oct 30 09:18:06 2024 -0500 Bumped Portal and search packages (#21467) no ref These had new minors shipped without a bump in Ghost core. commit fcc63143951afb16d91f1c7a4fc000bb9b85031c Merge: 2ed00b6cc4 98c06f8126 Author: Cathy Sarisky <42299862+cathysarisky@users.noreply.github.com> Date: Wed Oct 30 09:35:24 2024 -0400 Merge branch 'main' into theme-i18n commit 98c06f8126ee0798d9e9e6b335c40b6b5bf2f9eb Author: Kevin Ansfield Date: Wed Oct 30 11:47:15 2024 +0000 Fixed removal of event tracker requests in Sentry no issue - filtering was previously added to breadcrumbs but that wasn't enough to clean up Sentry reports - added filtering to the `beforeSend` hook too so reports don't get cluttered with unhelpful XHR noise commit cca6a38e53831b194dc79ff4c4383e2db051e87e Author: Ronald Langeveld Date: Wed Oct 30 19:06:11 2024 +0900 Patched Comments UI v0.20.1 (#21464) no issue --------- Co-authored-by: Sanne de Vries <65487235+sanne-san@users.noreply.github.com> commit 119a913ce5486e67ecf410d73ded522066718e6b Author: Sanne de Vries <65487235+sanne-san@users.noreply.github.com> Date: Wed Oct 30 09:56:42 2024 +0100 Fixed comment form being cut off at the top (#21463) No ref commit d4d45de3dee871fbcc751ad43c6b0c969b39e0c5 Author: Chris Raible Date: Tue Oct 29 19:22:58 2024 -0700 Added github-cli and some zsh plugins to dev container (#21460) no issue - The Dev Container didn't have the Github CLI installed, so this adds that using Dev Container "[features](https://containers.dev/implementors/features/)" - It also adds oh-my-zsh and a few plugins that are nice to have when developing. commit eb9483abb6b70e0da1611b8409f2bbb1359038c2 Author: Chris Raible Date: Tue Oct 29 15:21:46 2024 -0700 Fixed git remote configuration in dev container (#21459) no issue - When creating a dev container, the onCreateCommand script will try to setup the git remotes according to the environment variables defined for e.g. `$GHOST_UPSTREAM`. There was a bug that caused the `origin` remote to successfully be renamed to `$GHOST_UPSTREAM`, but the `origin` remote was not being created successfully on the first try. This commit fixes that bug. commit 1d24b2c8c08b30108dbdb94001fc664d6b189b2d Author: Chris Raible Date: Tue Oct 29 14:52:44 2024 -0700 Added configuration of git remotes to dev container setup (#21458) no issue - Added function to Dev Container onCreateCommand to setup git remotes when creating the Dev Container - If `$GHOST_UPSTREAM` is set, it will rename the default `origin` remote to its value - It will also update the remotes for the submodules, otherwise `yarn main` will fail - If `$GHOST_FORK_REMOTE_URL` is set, it will add it as `origin`, or the value of `$GHOST_FORK_REMOTE_NAME` if set. - If `$GHOST_FORCE_SSH` is set to `true`, it will change all remotes URL's to use ssh instead of https commit c29905155f884ac3be91760369d2f99d4e5e0ea7 Author: Djordje Vlaisavljevic Date: Tue Oct 29 21:55:30 2024 +0100 Fixed incorrect actor passed from like notifications (#21451) ref https://linear.app/ghost/issue/AP-541/incorrect-object-loaded-on-like-notifications - We were passing the wrong `actor` info from `like` notifications. We should be using `activity.object.attributedTo` instead. commit 71fb9f8b345cc88eddd7bc334f9644bebf1313a6 Author: Chris Raible Date: Tue Oct 29 13:03:49 2024 -0700 Converted dev container's onCreateCommand to JavaScript (#21457) no issue - The onCreateCommand was previously a bash script, which made it a bit more challenging to read and make changes to it. This commit converts it to JavaScript so it will be easier to make updates to it. commit 2ed00b6cc45d0c2069f0a9ace7933c90b193805d Author: Cathy Sarisky Date: Tue Oct 29 14:38:53 2024 -0400 bad in exactly every way, but a start! commit 856dd1fc2b421ea81ba035974b29981fbc49e676 Author: Kevin Ansfield Date: Tue Oct 29 17:46:35 2024 +0000 🐛 Fixed "Access Denied" error when accepting staff invite ref https://app.incident.io/ghost/incidents/117 - the authenticate call made as part of signup was missed as part of the update when we adjusted the params for `cookie` authenticator's `authenticate` method in Admin so it could switch behaviour for 2fa - fixed the authenticate call params and updated our mocked `/session` endpoint to check for expected POST data which would have let tests catch this error commit 28a9a431db548074415cd0be32f7a0d4741b52fe Author: Michael Barrett Date: Tue Oct 29 17:07:01 2024 +0000 Prevent replies from being shown on profile page in admin-x-activitypub (#21454) refs [AP-543](https://linear.app/ghost/issue/AP-543/posts-on-profile-should-not-include-replies) Filtered the posts on the profile page to only show `Create` activities that are not replies. commit cc2fc1c77d0ee4202fd182081c128328ac411207 Author: Cathy Sarisky Date: Tue Oct 29 09:42:39 2024 -0400 more snapshots. commit e66ff34394282ae4b3b4626eea647a750f3167df Author: Cathy Sarisky Date: Tue Oct 29 09:31:28 2024 -0400 fix snapshots in places I didn't know we had snapshots. commit 3cc0380a2a6561c00f57ae876cfba61f03d1f508 Author: Cathy Sarisky Date: Tue Oct 29 09:19:39 2024 -0400 more broken tests commit 8456170bf98953394a554790fa65e30a69243d7f Author: Cathy Sarisky Date: Tue Oct 29 09:07:27 2024 -0400 fix broken test commit 253608a829d19eed0071bf5904b8aa4dc3758aa5 Author: Steve Larson <9larsons@gmail.com> Date: Tue Oct 29 07:49:25 2024 -0500 Cover last lines in unit test commit 2fc2c6ac981ddb18462d48c3252bece885051ac8 Author: Sanne de Vries Date: Tue Oct 29 13:17:05 2024 +0100 Updated feedback button styling No issue - Removed all mobile feedback button code - Updated styling of feedback buttons to display in a single row on desktop and column on mobile commit 4a8da45895d6f94252636d68b5da12b2977f2432 Author: Daniël van der Winden Date: Tue Oct 29 12:03:43 2024 +0100 Fixed searchbar's X button overlapping with Settings' X button (#21449) fixes https://linear.app/ghost/issue/DES-316/adminx-settings-search-overlaps-with-modal-close-x-when-in-mobile-view Regression, solved by removing an overexcited media query. commit 156775b3acbff61a13132f0e9a548626c4df5384 Author: Cathy Sarisky Date: Tue Oct 29 06:46:45 2024 -0400 test tweaks commit 456af29607fddc7fea3347e6bc610c886a4e3e11 Author: Sanne de Vries <65487235+sanne-san@users.noreply.github.com> Date: Tue Oct 29 11:44:00 2024 +0100 Fixed avatar initials being broken in comment form (#21448) REF PLG-248 commit cc8a36cc12a7b397f24c6d3e6e89c7fe5edbfb75 Author: Djordje Vlaisavljevic Date: Tue Oct 29 11:26:27 2024 +0100 Fixed articles getting cut off in the drawer (#21447) ref https://linear.app/ghost/issue/AP-542/articles-are-cut-off-when-viewed-in-sidebar - We now wait for the content of the iframe to load completely before determining the height needed to display it commit ba306844b7ce6b161001f4d5bbd27839104d9352 Author: Cathy Sarisky Date: Tue Oct 29 05:56:08 2024 -0400 and the snap commit fbe72506652d2829db45c1cc297c3be3775fbca5 Author: Cathy Sarisky Date: Tue Oct 29 05:53:13 2024 -0400 date localization problem fixed? commit e6df621436ec010a000adbaa025822aa5c5fe3d8 Author: Michael Barrett Date: Tue Oct 29 09:46:43 2024 +0000 Updated ActivityPub collection retrieval to accommodate pagination (#21393) refs [AP-526](https://linear.app/ghost/issue/AP-526/implement-pagination-for-fedify-collections) Updated followers, following, outbox and liked collection retrieval to accommodate pagination commit 4b32a3d9c335ec8d222195da1d6965618c67ded9 Author: Sodbileg Gansukh Date: Tue Oct 29 15:31:29 2024 +0800 Fixed signup card button height (#21446) ref DES-923 commit d6639fdf972fa566538f3edc0f6405eb2ca1db9b Author: Cathy Sarisky Date: Mon Oct 28 23:41:38 2024 -0400 added a creation date - my mock is too good, and needs one to make the snapshots work! commit f8ef2a1cb6d9590789398e6cc2650cb2e35d015d Author: Fabien 'egg' O'Carroll Date: Tue Oct 29 03:17:57 2024 +0000 Fixed layout state sync issues (#21443) refs https://linear.app/ghost/issue/AP-544 useState was still called twice, we should have pulled that out - but instead passing values down for now commit 4d35d318818c647c62ee725811181795e55d463d Merge: abc4d7b3dc 1c51718009 Author: Cathy Sarisky Date: Mon Oct 28 23:09:05 2024 -0400 Merge branch 'newsletter-t' of https://github.com/cathysarisky/Ghost into newsletter-t commit abc4d7b3dc0bad3ed2c8527b578d71da4ba0ed12 Author: Cathy Sarisky Date: Mon Oct 28 23:09:01 2024 -0400 fix dates... again. commit 1c517180091b49c9853e686e45c8fd1af166137f Author: Cathy Sarisky <42299862+cathysarisky@users.noreply.github.com> Date: Mon Oct 28 22:54:54 2024 -0400 argh, package.json... commit 071cf48918fc51b03db1ce628654665926f707b4 Author: Cathy Sarisky Date: Mon Oct 28 22:51:52 2024 -0400 preliminary update of snapshots for translatable buttons commit bde17559d3a581036b1b4a6c31052f77b863ec44 Author: Cathy Sarisky Date: Mon Oct 28 22:24:49 2024 -0400 make buttons translatable commit 7922bcd64ae40f0082cad980e68610f690e62268 Author: Cathy Sarisky Date: Mon Oct 28 21:58:33 2024 -0400 lint is hard commit 2d29b78bae57936540661964b0f12addfd45dcc2 Author: Cathy Sarisky Date: Mon Oct 28 21:53:53 2024 -0400 better test commit 6c0adf32f13eca8009a0653c3c85a651375fe8d5 Author: Cathy Sarisky Date: Mon Oct 28 21:50:42 2024 -0400 test wrangling commit 6240f250c1a8ddd9c71cad44dd9133753623eafa Author: Cathy Sarisky Date: Mon Oct 28 21:18:14 2024 -0400 test fussing commit 869ea0bcefd06ec05393aef63591107fbf784bf4 Author: Cathy Sarisky Date: Mon Oct 28 21:03:43 2024 -0400 e2e test fix? commit 01e5bdb0b6c2da3398f969c277f6566441cc0f73 Author: Cathy Sarisky Date: Mon Oct 28 20:21:17 2024 -0400 t fun. commit 0cf65a08215826060d4dbc37d9dc12c5572a23a7 Author: Cathy Sarisky Date: Mon Oct 28 20:12:10 2024 -0400 only one failing test? commit d0bf80b7184e330566b5ec0c6b9f292333fc4c5a Author: Chris Raible Date: Mon Oct 28 16:14:04 2024 -0700 Added `from` address to Dev Container's auto configuration (#21442) refs https://linear.app/ghost/issue/ENG-1686/mail-auto-configuration-doesnt-work - Previously the `mail` configuration that is auto-generated didn't work because the `from` address wasn't being set, and requests were consequently failing due to some Mailgun internal validation. - This change requires the `MAILGUN_FROM_ADDRESS` environment variable to be set for it to automatically configure the `mail` block, and if it is, it adds it as `mail.from`. Now with all three of `MAILGUN_SMTP_AUTH`, `MAILGUN_SMTP_PASS` and `MAILGUN_FROM_ADDRESS` setup, transactional emails work out of the box in the Dev Container. commit c1100c87a358506c2db5e53b54b3b039b9d86b36 Author: Cathy Sarisky Date: Mon Oct 28 17:27:02 2024 -0400 try building, probably fail lint. commit 75948c6d45b92fc43b832967abaa85d445b8f27d Merge: 5581695b02 2c7de4e29a Author: Ghost CI <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon Oct 28 20:53:37 2024 +0000 Merged v5.98.1 into main commit 2c7de4e29a030e896438ecc686a601107fce3787 Author: Ghost CI <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon Oct 28 20:53:35 2024 +0000 v5.98.1 commit 00bd31a718342e4a0a7e719f69ccf413834aac2f Author: Steve Larson <9larsons@gmail.com> Date: Mon Oct 28 09:58:09 2024 -0500 🐛 Fixed malformed `unsubscribe_url` in members api response (#21437) no ref commit 2b2981205ed795668591c74fb9c624319c351dce Author: Sodbileg Gansukh Date: Mon Oct 28 18:36:38 2024 +0800 Fixed banner text color in dark mode (#21427) ref DES-908 commit 3afc8187df18bbf3b23dc06ed6669ed839c8740b Author: Cathy Sarisky Date: Mon Oct 28 15:33:57 2024 -0400 hating importing. commit 49aec5177bf4060a44ced1adcbf6796ca9dfc74e Author: Cathy Sarisky Date: Mon Oct 28 15:13:59 2024 -0400 tweak imports again? commit 35a639ad5302b860ac6b2cd26f71c1db58cf3db8 Author: Cathy Sarisky Date: Mon Oct 28 15:03:11 2024 -0400 build error? commit cb401ed71c5bd1acbbc5b17df0b57b3dd8f75cec Author: Cathy Sarisky Date: Mon Oct 28 14:48:36 2024 -0400 import issues commit d8d5de537721f9ef0a0a0495d56780ecc0d7e5d4 Author: Cathy Sarisky Date: Mon Oct 28 14:35:36 2024 -0400 issues with imports? commit d09575058f0181bf5d9618c00d20a89891cfce3a Author: Cathy Sarisky Date: Mon Oct 28 13:51:47 2024 -0400 belly button lint? commit d1eabc76c4d50d3a0ff410b13ea0807903cb3eff Merge: ea617f497a 1774caacd2 Author: Cathy Sarisky Date: Mon Oct 28 13:47:30 2024 -0400 Merge branch 'newsletter-t' of https://github.com/cathysarisky/Ghost into newsletter-t commit ea617f497a6a769415ebf6b379cd32dc2041dd02 Author: Cathy Sarisky Date: Mon Oct 28 13:47:28 2024 -0400 date i18n commit 1774caacd2e42f3f14ab49bdfa510dfd6b6ee077 Merge: ea627faad4 5581695b02 Author: Cathy Sarisky <42299862+cathysarisky@users.noreply.github.com> Date: Mon Oct 28 12:55:16 2024 -0400 Merge branch 'main' into newsletter-t commit ea627faad45c1b6197275598b3707cab350d7bb5 Author: Cathy Sarisky Date: Mon Oct 28 09:32:23 2024 -0400 add a test commit 7f66b145e73fcf52ffa073f2825fac0ff2e95817 Author: Cathy Sarisky Date: Mon Oct 28 09:19:11 2024 -0400 stray space = failed test :( commit e88c57cd8af3193439b1b82203a301e9f23ee71e Author: Cathy Sarisky Date: Mon Oct 28 09:14:27 2024 -0400 dangit lint commit 8834ef65082a0139652b7b4f6a4898952d9331dc Merge: 6ae2844bea 3c3aa2787a Author: Cathy Sarisky Date: Mon Oct 28 09:10:20 2024 -0400 Merge branch 'newsletter-t' of https://github.com/cathysarisky/Ghost into newsletter-t commit 6ae2844bea1d8b4c947b27ed998e5e0796640731 Author: Cathy Sarisky Date: Mon Oct 28 09:10:10 2024 -0400 ah lint commit 3c3aa2787af5fe775a370020718ed2c2abb387c4 Merge: 3a7c50ffb8 bf714ac22f Author: Cathy Sarisky <42299862+cathysarisky@users.noreply.github.com> Date: Mon Oct 28 09:00:10 2024 -0400 Merge branch 'main' into newsletter-t commit 3a7c50ffb8294289f1fde740369d7c4b36021ca5 Author: Cathy Sarisky Date: Mon Oct 28 08:43:57 2024 -0400 fixed! commit 4960f84c411a7fce66da0d6ebaa218529e5babce Author: Cathy Sarisky Date: Sun Oct 27 19:12:47 2024 -0400 almost there! commit 4d60bcb7bedc94a9eb84d8bdb432c36c8854c632 Author: Cathy Sarisky Date: Sun Oct 27 16:52:37 2024 -0400 initial commit iwth date error --- .devcontainer/.docker/Dockerfile | 1 + .devcontainer/createLocalConfig.js | 92 -- .devcontainer/devcontainer.json | 23 +- .devcontainer/onCreateCommand.js | 243 ++++ .devcontainer/onCreateCommand.sh | 18 - .github/scripts/dev.js | 2 +- apps/admin-x-activitypub/package.json | 7 +- .../src/api/activitypub.test.ts | 395 ++--- .../src/api/activitypub.ts | 245 ++-- .../src/components/Activities.tsx | 22 +- .../src/components/Inbox.tsx | 70 +- .../src/components/Profile.tsx | 28 +- .../components/activities/ActivityItem.tsx | 2 + .../src/components/articleBodyStyles.ts | 60 +- .../src/components/feed/ArticleModal.tsx | 237 ++- .../src/components/feed/FeedItem.tsx | 37 +- .../src/components/global/APAvatar.tsx | 15 +- .../src/components/global/APReplyBox.tsx | 10 + .../components/global/ViewProfileModal.tsx | 2 +- .../components/navigation/MainNavigation.tsx | 10 +- apps/admin-x-activitypub/src/hooks/layout.ts | 2 +- .../src/hooks/useActivityPubQueries.ts | 94 +- apps/admin-x-activitypub/src/styles/index.css | 10 +- .../src/utils/content-handlers.ts | 43 + .../src/global/TabView.tsx | 15 +- apps/admin-x-settings/package.json | 2 +- .../src/components/Sidebar.tsx | 2 +- .../newsletters/NewsletterDetailModal.tsx | 4 +- .../components/settings/site/DesignModal.tsx | 8 +- .../test/acceptance/site/design.test.ts | 14 +- apps/comments-ui/package.json | 2 +- .../src/components/content/Avatar.tsx | 5 +- .../src/components/content/forms/MainForm.tsx | 2 +- ghost/admin/app/controllers/signup.js | 2 +- ghost/admin/app/utils/sentry.js | 23 +- ghost/admin/mirage/config/authentication.js | 7 +- ghost/admin/package.json | 4 +- ghost/admin/tests/unit/utils/sentry-test.js | 30 + .../core/core/frontend/helpers/ghost_head.js | 112 +- ghost/core/core/frontend/helpers/t.js | 2 +- .../services/theme-engine/i18n/I18n.js | 24 +- .../core/frontend/src/cards/css/signup.css | 1 + .../email-service/EmailServiceWrapper.js | 7 +- ghost/core/core/server/services/i18n.js | 2 +- .../services/oembed/TwitterOEmbedProvider.js | 8 + ghost/core/core/shared/config/defaults.json | 4 +- .../shared/settings-cache/CacheManager.js | 9 + ghost/core/package.json | 4 +- .../__snapshots__/email-previews.test.js.snap | 75 +- .../__snapshots__/batch-sending.test.js.snap | 288 ++-- .../__snapshots__/cards.test.js.snap | 52 +- .../email-service/batch-sending.test.js | 16 +- .../__snapshots__/ghost_head.test.js.snap | 1275 ++++++++++++++++- .../unit/frontend/helpers/ghost_head.test.js | 217 ++- .../core/test/unit/frontend/helpers/t.test.js | 2 +- .../services/theme-engine/i18n.test.js | 2 +- .../services/theme-engine/theme-i18n.test.js | 2 +- .../services/oembed/twitter-embed.test.js | 83 +- ghost/custom-fonts/src/index.ts | 7 +- ghost/email-service/lib/EmailRenderer.js | 89 +- .../partials/feedback-button-mobile.hbs | 6 - .../partials/feedback-button.hbs | 4 +- .../lib/email-templates/partials/styles.hbs | 27 +- .../lib/email-templates/template.hbs | 39 +- .../lib/helpers/register-helpers.js | 6 +- .../email-service/test/email-helpers.test.js | 77 + .../email-service/test/email-renderer.test.js | 141 +- ghost/i18n/lib/i18n.js | 44 +- ghost/i18n/locales/af/newsletter.json | 24 + ghost/i18n/locales/ar/newsletter.json | 24 + ghost/i18n/locales/bg/newsletter.json | 24 + ghost/i18n/locales/bn/newsletter.json | 24 + ghost/i18n/locales/bs/newsletter.json | 24 + ghost/i18n/locales/ca/newsletter.json | 24 + ghost/i18n/locales/context.json | 17 + ghost/i18n/locales/cs/newsletter.json | 24 + ghost/i18n/locales/da/newsletter.json | 24 + ghost/i18n/locales/de-CH/newsletter.json | 24 + ghost/i18n/locales/de/newsletter.json | 24 + ghost/i18n/locales/el/newsletter.json | 24 + ghost/i18n/locales/en/newsletter.json | 24 + ghost/i18n/locales/en/theme.json | 3 + ghost/i18n/locales/eo/newsletter.json | 24 + ghost/i18n/locales/es/newsletter.json | 24 + ghost/i18n/locales/et/newsletter.json | 24 + ghost/i18n/locales/fa/newsletter.json | 24 + ghost/i18n/locales/fi/newsletter.json | 24 + ghost/i18n/locales/fr/newsletter.json | 24 + ghost/i18n/locales/fr/theme.json | 3 + ghost/i18n/locales/gd/newsletter.json | 24 + ghost/i18n/locales/hi/newsletter.json | 24 + ghost/i18n/locales/hr/newsletter.json | 24 + ghost/i18n/locales/hu/newsletter.json | 24 + ghost/i18n/locales/hu/search.json | 2 +- ghost/i18n/locales/id/newsletter.json | 24 + ghost/i18n/locales/is/newsletter.json | 24 + ghost/i18n/locales/it/newsletter.json | 24 + ghost/i18n/locales/ja/newsletter.json | 24 + ghost/i18n/locales/ko/newsletter.json | 24 + ghost/i18n/locales/kz/newsletter.json | 24 + ghost/i18n/locales/lt/newsletter.json | 24 + ghost/i18n/locales/mk/newsletter.json | 24 + ghost/i18n/locales/mn/newsletter.json | 24 + ghost/i18n/locales/ms/newsletter.json | 24 + ghost/i18n/locales/nl/newsletter.json | 24 + ghost/i18n/locales/nn/newsletter.json | 24 + ghost/i18n/locales/no/comments.json | 140 +- ghost/i18n/locales/no/ghost.json | 26 +- ghost/i18n/locales/no/newsletter.json | 24 + ghost/i18n/locales/no/portal.json | 202 +-- ghost/i18n/locales/no/search.json | 14 +- ghost/i18n/locales/no/signup-form.json | 14 +- ghost/i18n/locales/pl/newsletter.json | 24 + ghost/i18n/locales/pt-BR/newsletter.json | 24 + ghost/i18n/locales/pt/newsletter.json | 24 + ghost/i18n/locales/ro/newsletter.json | 24 + ghost/i18n/locales/ru/newsletter.json | 24 + ghost/i18n/locales/si/newsletter.json | 24 + ghost/i18n/locales/sk/newsletter.json | 24 + ghost/i18n/locales/sl/newsletter.json | 24 + ghost/i18n/locales/sq/newsletter.json | 24 + ghost/i18n/locales/sr-Cyrl/newsletter.json | 24 + ghost/i18n/locales/sr/newsletter.json | 24 + ghost/i18n/locales/sv/newsletter.json | 24 + ghost/i18n/locales/sw/newsletter.json | 24 + ghost/i18n/locales/ta/newsletter.json | 24 + ghost/i18n/locales/th/newsletter.json | 24 + ghost/i18n/locales/tr/newsletter.json | 24 + ghost/i18n/locales/uk/newsletter.json | 24 + ghost/i18n/locales/ur/newsletter.json | 24 + ghost/i18n/locales/uz/newsletter.json | 24 + ghost/i18n/locales/vi/newsletter.json | 24 + ghost/i18n/locales/zh-Hant/newsletter.json | 24 + ghost/i18n/locales/zh/newsletter.json | 24 + ghost/i18n/package.json | 5 +- ghost/i18n/test/i18n.test.js | 6 + yarn.lock | 48 +- 137 files changed, 4807 insertions(+), 1454 deletions(-) delete mode 100644 .devcontainer/createLocalConfig.js create mode 100644 .devcontainer/onCreateCommand.js delete mode 100755 .devcontainer/onCreateCommand.sh create mode 100644 apps/admin-x-activitypub/src/utils/content-handlers.ts delete mode 100644 ghost/email-service/lib/email-templates/partials/feedback-button-mobile.hbs create mode 100644 ghost/i18n/locales/af/newsletter.json create mode 100644 ghost/i18n/locales/ar/newsletter.json create mode 100644 ghost/i18n/locales/bg/newsletter.json create mode 100644 ghost/i18n/locales/bn/newsletter.json create mode 100644 ghost/i18n/locales/bs/newsletter.json create mode 100644 ghost/i18n/locales/ca/newsletter.json create mode 100644 ghost/i18n/locales/cs/newsletter.json create mode 100644 ghost/i18n/locales/da/newsletter.json create mode 100644 ghost/i18n/locales/de-CH/newsletter.json create mode 100644 ghost/i18n/locales/de/newsletter.json create mode 100644 ghost/i18n/locales/el/newsletter.json create mode 100644 ghost/i18n/locales/en/newsletter.json create mode 100644 ghost/i18n/locales/en/theme.json create mode 100644 ghost/i18n/locales/eo/newsletter.json create mode 100644 ghost/i18n/locales/es/newsletter.json create mode 100644 ghost/i18n/locales/et/newsletter.json create mode 100644 ghost/i18n/locales/fa/newsletter.json create mode 100644 ghost/i18n/locales/fi/newsletter.json create mode 100644 ghost/i18n/locales/fr/newsletter.json create mode 100644 ghost/i18n/locales/fr/theme.json create mode 100644 ghost/i18n/locales/gd/newsletter.json create mode 100644 ghost/i18n/locales/hi/newsletter.json create mode 100644 ghost/i18n/locales/hr/newsletter.json create mode 100644 ghost/i18n/locales/hu/newsletter.json create mode 100644 ghost/i18n/locales/id/newsletter.json create mode 100644 ghost/i18n/locales/is/newsletter.json create mode 100644 ghost/i18n/locales/it/newsletter.json create mode 100644 ghost/i18n/locales/ja/newsletter.json create mode 100644 ghost/i18n/locales/ko/newsletter.json create mode 100644 ghost/i18n/locales/kz/newsletter.json create mode 100644 ghost/i18n/locales/lt/newsletter.json create mode 100644 ghost/i18n/locales/mk/newsletter.json create mode 100644 ghost/i18n/locales/mn/newsletter.json create mode 100644 ghost/i18n/locales/ms/newsletter.json create mode 100644 ghost/i18n/locales/nl/newsletter.json create mode 100644 ghost/i18n/locales/nn/newsletter.json create mode 100644 ghost/i18n/locales/no/newsletter.json create mode 100644 ghost/i18n/locales/pl/newsletter.json create mode 100644 ghost/i18n/locales/pt-BR/newsletter.json create mode 100644 ghost/i18n/locales/pt/newsletter.json create mode 100644 ghost/i18n/locales/ro/newsletter.json create mode 100644 ghost/i18n/locales/ru/newsletter.json create mode 100644 ghost/i18n/locales/si/newsletter.json create mode 100644 ghost/i18n/locales/sk/newsletter.json create mode 100644 ghost/i18n/locales/sl/newsletter.json create mode 100644 ghost/i18n/locales/sq/newsletter.json create mode 100644 ghost/i18n/locales/sr-Cyrl/newsletter.json create mode 100644 ghost/i18n/locales/sr/newsletter.json create mode 100644 ghost/i18n/locales/sv/newsletter.json create mode 100644 ghost/i18n/locales/sw/newsletter.json create mode 100644 ghost/i18n/locales/ta/newsletter.json create mode 100644 ghost/i18n/locales/th/newsletter.json create mode 100644 ghost/i18n/locales/tr/newsletter.json create mode 100644 ghost/i18n/locales/uk/newsletter.json create mode 100644 ghost/i18n/locales/ur/newsletter.json create mode 100644 ghost/i18n/locales/uz/newsletter.json create mode 100644 ghost/i18n/locales/vi/newsletter.json create mode 100644 ghost/i18n/locales/zh-Hant/newsletter.json create mode 100644 ghost/i18n/locales/zh/newsletter.json diff --git a/.devcontainer/.docker/Dockerfile b/.devcontainer/.docker/Dockerfile index 1c41e3c2a58..42428812fec 100644 --- a/.devcontainer/.docker/Dockerfile +++ b/.devcontainer/.docker/Dockerfile @@ -21,6 +21,7 @@ RUN curl -s https://packages.stripe.dev/api/security/keypair/stripe-cli-gpg/publ git \ stripe \ zsh \ + procps \ default-mysql-client && \ npx -y playwright@1.46.1 install --with-deps diff --git a/.devcontainer/createLocalConfig.js b/.devcontainer/createLocalConfig.js deleted file mode 100644 index 49c3098a586..00000000000 --- a/.devcontainer/createLocalConfig.js +++ /dev/null @@ -1,92 +0,0 @@ -const fs = require('fs'); -const path = require('path'); -const assert = require('node:assert/strict'); - -// Reads the config.local.json file and updates it with environments variables for devcontainer setup -const configBasePath = path.join(__dirname, '..', 'ghost', 'core'); -const configFile = path.join(configBasePath, 'config.local.json'); -let originalConfig = {}; -if (fs.existsSync(configFile)) { - try { - // Backup the user's config.local.json file just in case - // This won't be used by Ghost but can be useful to switch back to local development - const backupFile = path.join(configBasePath, 'config.local-backup.json'); - fs.copyFileSync(configFile, backupFile); - - // Read the current config.local.json file into memory - const fileContent = fs.readFileSync(configFile, 'utf8'); - originalConfig = JSON.parse(fileContent); - } catch (error) { - console.error('Error reading or parsing config file:', error); - process.exit(1); - } -} else { - console.log('Config file does not exist. Creating a new one.'); -} - -let newConfig = {}; -// Change the url if we're in a codespace -if (process.env.CODESPACES === 'true') { - assert.ok(process.env.CODESPACE_NAME, 'CODESPACE_NAME is not defined'); - assert.ok(process.env.GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN, 'GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN is not defined'); - const url = `https://${process.env.CODESPACE_NAME}-2368.${process.env.GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN}`; - newConfig.url = url; -} - -newConfig.database = { - client: 'mysql2', - connection: { - host: 'mysql', - user: 'root', - password: 'root', - database: 'ghost' - } -} -newConfig.adapters = { - Redis: { - host: 'redis', - port: 6379 - } -} - - -// Only update the mail settings if they aren't already set -if (!originalConfig.mail && process.env.MAILGUN_SMTP_PASS && process.env.MAILGUN_SMTP_USER) { - newConfig.mail = { - transport: 'SMTP', - options: { - service: 'Mailgun', - host: 'smtp.mailgun.org', - secure: true, - port: 465, - auth: { - user: process.env.MAILGUN_SMTP_USER, - pass: process.env.MAILGUN_SMTP_PASS - } - } - } -} - -// Only update the bulk email settings if they aren't already set -if (!originalConfig.bulkEmail && process.env.MAILGUN_API_KEY && process.env.MAILGUN_DOMAIN) { - newConfig.bulkEmail = { - mailgun: { - baseUrl: 'https://api.mailgun.net/v3', - apiKey: process.env.MAILGUN_API_KEY, - domain: process.env.MAILGUN_DOMAIN, - tag: 'bulk-email' - } - } -} - -// Merge the original config with the new config -const config = {...originalConfig, ...newConfig}; - -// Write the updated config.local.json file -try { - fs.writeFileSync(configFile, JSON.stringify(config, null, 2)); - console.log('Config file updated successfully.'); -} catch (error) { - console.error('Error writing config file:', error); - process.exit(1); -} \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 634b0769dcc..fc15a5bcdc4 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,10 +1,16 @@ { "name": "Ghost Local DevContainer", + "features": { + "ghcr.io/devcontainers/features/github-cli:1": {}, + "ghcr.io/nils-geistmann/devcontainers-features/zsh:0": { + "plugins": "git yarn gh" + } + }, "dockerComposeFile": ["./.docker/base.compose.yml", "./.docker/base-devcontainer.compose.yml"], "service": "ghost", "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", "shutdownAction": "stopCompose", - "onCreateCommand": ["./.devcontainer/onCreateCommand.sh"], + "onCreateCommand": ["node", "./.devcontainer/onCreateCommand.js"], "updateContentCommand": ["git", "submodule", "update", "--init", "--recursive"], "postCreateCommand": ["yarn", "knex-migrator", "init"], "remoteEnv": { @@ -14,8 +20,13 @@ "STRIPE_ACCOUNT_ID": "${localEnv:STRIPE_ACCOUNT_ID}", "MAILGUN_SMTP_USER": "${localEnv:MAILGUN_SMTP_USER}", "MAILGUN_SMTP_PASS": "${localEnv:MAILGUN_SMTP_PASS}", + "MAILGUN_FROM_ADDRESS": "${localEnv:MAILGUN_FROM_ADDRESS}", "MAILGUN_API_KEY": "${localEnv:MAILGUN_API_KEY}", - "MAILGUN_DOMAIN": "${localEnv:MAILGUN_DOMAIN}" + "MAILGUN_DOMAIN": "${localEnv:MAILGUN_DOMAIN}", + "GHOST_UPSTREAM": "${localEnv:GHOST_UPSTREAM}", + "GHOST_FORK_REMOTE_URL": "${localEnv:GHOST_FORK_REMOTE_URL}", + "GHOST_FORK_REMOTE_NAME": "${localEnv:GHOST_FORK_REMOTE_NAME}", + "GHOST_FORCE_SSH": "${localEnv:GHOST_FORCE_SSH}" }, "forwardPorts": [2368,4200], "portsAttributes": { @@ -127,13 +138,17 @@ "description": "Your Mailgun account's SMTP password", "documentationUrl": "https://app.mailgun.com/mg/sending/domains" }, + "MAILGUN_FROM_ADDRESS": { + "description": "The email address that will be used as the `from` address when sending emails via Mailgun", + "documentationUrl": "https://app.mailgun.com/mg/sending/domains" + }, "MAILGUN_API_KEY": { "description": "Your Mailgun account's API key", - "documentationUrl": "" + "documentationUrl": "https://app.mailgun.com/mg/sending/domains" }, "MAILGUN_DOMAIN": { "description": "Your Mailgun account's domain, e.g. sandbox1234567890.mailgun.org", - "documentationUrl": "" + "documentationUrl": "https://app.mailgun.com/mg/sending/domains" } } } diff --git a/.devcontainer/onCreateCommand.js b/.devcontainer/onCreateCommand.js new file mode 100644 index 00000000000..594d711a728 --- /dev/null +++ b/.devcontainer/onCreateCommand.js @@ -0,0 +1,243 @@ +// This script is run in the Dev Container right after it is created +// No dependencies are installed at this point so we can't use any npm packages +const fs = require('fs'); +const path = require('path'); +const assert = require('node:assert/strict'); +const { execSync } = require('child_process'); + +// Main function that runs all the setup steps +function main() { + setupGitRemotes(); + setupLocalConfig(); + runCleanHard(); + runInstall(); + runSubmoduleUpdate(); + runTypescriptBuild(); +} + +// Basic color constants for console output +const colors = { + reset: '\x1b[0m', + bright: '\x1b[1m', + dim: '\x1b[2m', + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + magenta: '\x1b[35m', + cyan: '\x1b[36m', +}; + +function log(message, color = colors.reset) { + console.log(`${color}${message}${colors.reset}`); +} + +function logError(message, error) { + console.error(`${colors.red}${message}${colors.reset}`, error); +} + +// Sets up the git remotes for the dev container based on environment variables +function setupGitRemotes() { + log('Configuring git remotes...', colors.blue); + try { + const GHOST_UPSTREAM = process.env.GHOST_UPSTREAM; + const GHOST_FORK_REMOTE_URL = process.env.GHOST_FORK_REMOTE_URL; + const GHOST_FORK_REMOTE_NAME = process.env.GHOST_FORK_REMOTE_NAME; + const GHOST_FORCE_SSH = process.env.GHOST_FORCE_SSH; + let remotes = execSync('git remote').toString().trim().split('\n'); + + if (GHOST_UPSTREAM) { + // Check if the upstream remote already exists + if (!remotes.includes(GHOST_UPSTREAM) && remotes.includes('origin')) { + log(`Renaming the default remote from origin to ${GHOST_UPSTREAM}...`, colors.blue); + execSync(`git remote rename origin ${GHOST_UPSTREAM}`); + } + } + + remotes = execSync('git remote').toString().trim().split('\n'); + if (GHOST_FORK_REMOTE_URL) { + const remoteName = GHOST_FORK_REMOTE_NAME || 'origin'; + // Check if the fork remote already exists + if (!remotes.includes(remoteName)) { + log(`Adding fork remote ${GHOST_FORK_REMOTE_URL} as ${remoteName}...`, colors.blue); + execSync(`git remote add ${remoteName} ${GHOST_FORK_REMOTE_URL}`); + } + } + + if (GHOST_FORCE_SSH) { + log('Forcing SSH for all remotes...', colors.blue); + // Get all remotes + remotes = execSync('git remote').toString().trim().split('\n'); + + for (const remote of remotes) { + // Get the current URL for this remote + const url = execSync(`git remote get-url ${remote}`).toString().trim(); + + // Only convert if it's an HTTPS URL + if (url.startsWith('https://')) { + // Convert HTTPS to SSH format + // https://github.com/user/repo.git -> git@github.com:user/repo.git + const sshUrl = url + .replace(/^https:\/\//, 'git@') + .replace(/\//, ':'); + + log(`Converting ${remote} from HTTPS to SSH...`, colors.dim); + execSync(`git remote set-url ${remote} ${sshUrl}`); + } + } + } + + } catch (error) { + logError('Error setting up git remotes:', error); + } +} + +// Creates config.local.json file with the correct values for the devcontainer +function setupLocalConfig() { + log('Setting up local config file...', colors.blue); + // Reads the config.local.json file and updates it with environments variables for devcontainer setup + const configBasePath = path.join(__dirname, '..', 'ghost', 'core'); + const configFile = path.join(configBasePath, 'config.local.json'); + let originalConfig = {}; + if (fs.existsSync(configFile)) { + try { + // Backup the user's config.local.json file just in case + // This won't be used by Ghost but can be useful to switch back to local development + const backupFile = path.join(configBasePath, 'config.local-backup.json'); + fs.copyFileSync(configFile, backupFile); + + // Read the current config.local.json file into memory + const fileContent = fs.readFileSync(configFile, 'utf8'); + originalConfig = JSON.parse(fileContent); + } catch (error) { + logError('Error reading or parsing config file:', error); + process.exit(1); + } + } else { + log('Config file does not exist. Creating a new one.', colors.dim); + } + + let newConfig = {}; + // Change the url if we're in a codespace + if (process.env.CODESPACES === 'true') { + assert.ok(process.env.CODESPACE_NAME, 'CODESPACE_NAME is not defined'); + assert.ok(process.env.GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN, 'GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN is not defined'); + const url = `https://${process.env.CODESPACE_NAME}-2368.${process.env.GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN}`; + newConfig.url = url; + } + + newConfig.database = { + client: 'mysql2', + connection: { + host: 'mysql', + user: 'root', + password: 'root', + database: 'ghost' + } + } + newConfig.adapters = { + Redis: { + host: 'redis', + port: 6379 + } + } + + // Only update the mail settings if they aren't already set + if (!originalConfig.mail && process.env.MAILGUN_SMTP_PASS && process.env.MAILGUN_SMTP_USER && process.env.MAILGUN_FROM_ADDRESS) { + newConfig.mail = { + transport: 'SMTP', + from: process.env.MAILGUN_FROM_ADDRESS, + options: { + service: 'Mailgun', + host: 'smtp.mailgun.org', + secure: true, + port: 465, + auth: { + user: process.env.MAILGUN_SMTP_USER, + pass: process.env.MAILGUN_SMTP_PASS + } + } + } + } + + // Only update the bulk email settings if they aren't already set + if (!originalConfig.bulkEmail && process.env.MAILGUN_API_KEY && process.env.MAILGUN_DOMAIN) { + newConfig.bulkEmail = { + mailgun: { + baseUrl: 'https://api.mailgun.net/v3', + apiKey: process.env.MAILGUN_API_KEY, + domain: process.env.MAILGUN_DOMAIN, + tag: 'bulk-email' + } + } + } + + // Merge the original config with the new config + const config = {...originalConfig, ...newConfig}; + + // Write the updated config.local.json file + try { + fs.writeFileSync(configFile, JSON.stringify(config, null, 2)); + log('Config file updated successfully.', colors.dim); + } catch (error) { + logError('Error writing config file:', error); + process.exit(1); + } +} + +// Deletes node_modules and clears yarn & nx caches +function runCleanHard() { + try { + log('Cleaning up node_modules and yarn/nx caches...', colors.blue); + execSync('yarn clean:hard', { stdio: 'inherit' }); + log('Successfully ran yarn clean:hard', colors.dim); + } catch (error) { + logError('Error running yarn clean:hard:', error); + process.exit(1); + } +} + +// Installs dependencies +function runInstall() { + try { + log('Installing dependencies...', colors.blue); + execSync('yarn install --frozen-lockfile', { stdio: 'inherit' }); + log('Successfully ran yarn install', colors.dim); + } catch (error) { + logError('Error running yarn install:', error); + process.exit(1); + } +} + +// Initializes and updates git submodules +function runSubmoduleUpdate() { + try { + log('Updating git submodules...', colors.blue); + execSync('git submodule update --init --recursive', { stdio: 'inherit' }); + // Rename the default remote to $GHOST_UPSTREAM if it's set + // Otherwise `yarn main:submodules` will fail + const GHOST_UPSTREAM = process.env.GHOST_UPSTREAM; + if (GHOST_UPSTREAM) { + execSync(`git submodule foreach "git remote | grep -q '^${GHOST_UPSTREAM}$' || (git remote | grep -q '^origin$' && git remote rename origin ${GHOST_UPSTREAM})"`); + } + + log('Successfully ran git submodule update', colors.dim); + } catch (error) { + logError('Error running git submodule update:', error); + process.exit(1); + } +} + +// Builds the typescript packages +function runTypescriptBuild() { + try { + log('Building typescript packages...', colors.blue); + execSync('yarn nx run-many -t build:ts', { stdio: 'inherit' }); + log('Successfully ran yarn nx run-many -t build:ts', colors.dim); + } catch (error) { + logError('Error running yarn nx run-many -t build:ts:', error); + process.exit(1); + } +} + +main(); \ No newline at end of file diff --git a/.devcontainer/onCreateCommand.sh b/.devcontainer/onCreateCommand.sh deleted file mode 100755 index a227848dc5b..00000000000 --- a/.devcontainer/onCreateCommand.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/bin/bash - -set -e - -echo "Setting up local config file..." -node .devcontainer/createLocalConfig.js - -echo "Cleaning up any previous installs..." -yarn clean:hard - -echo "Installing dependencies..." -yarn install - -echo "Updating git submodules..." -git submodule update --init --recursive - -echo "Building typescript packages..." -yarn nx run-many -t build:ts \ No newline at end of file diff --git a/.github/scripts/dev.js b/.github/scripts/dev.js index 10c42f0abe7..a6a7b96f49d 100644 --- a/.github/scripts/dev.js +++ b/.github/scripts/dev.js @@ -95,7 +95,7 @@ if (DASH_DASH_ARGS.includes('ghost')) { } else if (DASH_DASH_ARGS.includes('admin')) { commands = [COMMAND_ADMIN, ...COMMANDS_ADMINX]; } else if (DASH_DASH_ARGS.includes('browser-tests')) { - commands = [COMMAND_BROWSERTESTS, COMMAND_TYPESCRIPT]; + commands = [COMMAND_BROWSERTESTS]; } else { commands = [COMMAND_GHOST, COMMAND_TYPESCRIPT, COMMAND_ADMIN, ...COMMANDS_ADMINX]; } diff --git a/apps/admin-x-activitypub/package.json b/apps/admin-x-activitypub/package.json index 2e432a0c3a5..327e8e7845e 100644 --- a/apps/admin-x-activitypub/package.json +++ b/apps/admin-x-activitypub/package.json @@ -1,6 +1,6 @@ { "name": "@tryghost/admin-x-activitypub", - "version": "0.3.0", + "version": "0.3.8", "license": "MIT", "repository": { "type": "git", @@ -65,10 +65,11 @@ }, "dependencies": { "@radix-ui/react-form": "0.0.3", - "use-debounce": "10.0.4", "@tryghost/admin-x-design-system": "0.0.0", "@tryghost/admin-x-framework": "0.0.0", + "clsx": "2.1.1", "react": "18.3.1", - "react-dom": "18.3.1" + "react-dom": "18.3.1", + "use-debounce": "10.0.4" } } diff --git a/apps/admin-x-activitypub/src/api/activitypub.test.ts b/apps/admin-x-activitypub/src/api/activitypub.test.ts index 8eacb186ec4..6e7a8e70eff 100644 --- a/apps/admin-x-activitypub/src/api/activitypub.test.ts +++ b/apps/admin-x-activitypub/src/api/activitypub.test.ts @@ -199,7 +199,7 @@ describe('ActivityPubAPI', function () { }, response: JSONResponse({ type: 'Collection', - items: [] + orderedItems: [] }) } }); @@ -224,8 +224,13 @@ describe('ActivityPubAPI', function () { }, 'https://activitypub.api/.ghost/activitypub/outbox/index': { response: JSONResponse({ - type: 'Collection', - items: [] + type: 'OrderedCollection', + first: 'https://activitypub.api/.ghost/activitypub/outbox/index?cursor=0' + }) + }, + 'https://activitypub.api/.ghost/activitypub/outbox/index?cursor=0': { + response: JSONResponse({ + type: 'OrderedCollection' }) } }); @@ -242,7 +247,7 @@ describe('ActivityPubAPI', function () { expect(actual).toEqual(expected); }); - test('Returns all the items array when the outbox is not empty', async function () { + test('Recursively retrieves all items and returns them when the outbox is not empty', async function () { const fakeFetch = Fetch({ 'https://auth.api/': { response: JSONResponse({ @@ -254,14 +259,32 @@ describe('ActivityPubAPI', function () { 'https://activitypub.api/.ghost/activitypub/outbox/index': { response: JSONResponse({ - type: 'Collection', - orderedItems: [{ - type: 'Create', - object: { - type: 'Note' - } - }] + type: 'OrderedCollection', + first: 'https://activitypub.api/.ghost/activitypub/outbox/index?cursor=0' }) + }, + 'https://activitypub.api/.ghost/activitypub/outbox/index?cursor=0': { + response: JSONResponse({ + type: 'OrderedCollection', + next: 'https://activitypub.api/.ghost/activitypub/outbox/index?cursor=1', + orderedItems: [{ + type: 'Create', + object: { + type: 'Note' + } + }] + }) + }, + 'https://activitypub.api/.ghost/activitypub/outbox/index?cursor=1': { + response: JSONResponse({ + type: 'OrderedCollection', + orderedItems: [{ + type: 'Create', + object: { + type: 'Article' + } + }] + }) } }); @@ -279,48 +302,11 @@ describe('ActivityPubAPI', function () { object: { type: 'Note' } - } - ]; - - expect(actual).toEqual(expected); - }); - - test('Returns an array when the orderedItems key is a single object', async function () { - const fakeFetch = Fetch({ - 'https://auth.api/': { - response: JSONResponse({ - identities: [{ - token: 'fake-token' - }] - }) }, - 'https://activitypub.api/.ghost/activitypub/outbox/index': { - response: - JSONResponse({ - type: 'Collection', - orderedItems: { - type: 'Create', - object: { - type: 'Note' - } - } - }) - } - }); - - const api = new ActivityPubAPI( - new URL('https://activitypub.api'), - new URL('https://auth.api'), - 'index', - fakeFetch - ); - - const actual = await api.getOutbox(); - const expected: Activity[] = [ { type: 'Create', object: { - type: 'Note' + type: 'Article' } } ]; @@ -371,8 +357,13 @@ describe('ActivityPubAPI', function () { }, 'https://activitypub.api/.ghost/activitypub/following/index': { response: JSONResponse({ - type: 'Collection', - items: [] + type: 'OrderedCollection', + first: 'https://activitypub.api/.ghost/activitypub/following/index?cursor=0' + }) + }, + 'https://activitypub.api/.ghost/activitypub/following/index?cursor=0': { + response: JSONResponse({ + type: 'OrderedCollection' }) } }); @@ -389,7 +380,7 @@ describe('ActivityPubAPI', function () { expect(actual).toEqual(expected); }); - test('Returns all the items array when the following is not empty', async function () { + test('Recursively retrieves all items and returns them when the following is not empty', async function () { const fakeFetch = Fetch({ 'https://auth.api/': { response: JSONResponse({ @@ -401,48 +392,26 @@ describe('ActivityPubAPI', function () { 'https://activitypub.api/.ghost/activitypub/following/index': { response: JSONResponse({ - type: 'Collection', - orderedItems: [{ - type: 'Person' - }] + type: 'OrderedCollection', + first: 'https://activitypub.api/.ghost/activitypub/following/index?cursor=0' }) - } - }); - - const api = new ActivityPubAPI( - new URL('https://activitypub.api'), - new URL('https://auth.api'), - 'index', - fakeFetch - ); - - const actual = await api.getFollowing(); - const expected: Activity[] = [ - { - type: 'Person' - } - ]; - - expect(actual).toEqual(expected); - }); - - test('Returns an array when the items key is a single object', async function () { - const fakeFetch = Fetch({ - 'https://auth.api/': { + }, + 'https://activitypub.api/.ghost/activitypub/following/index?cursor=0': { response: JSONResponse({ - identities: [{ - token: 'fake-token' + type: 'OrderedCollection', + next: 'https://activitypub.api/.ghost/activitypub/following/index?cursor=1', + orderedItems: [{ + type: 'Person' }] }) }, - 'https://activitypub.api/.ghost/activitypub/following/index': { - response: - JSONResponse({ - type: 'Collection', - items: { - type: 'Person' - } - }) + 'https://activitypub.api/.ghost/activitypub/following/index?cursor=1': { + response: JSONResponse({ + type: 'OrderedCollection', + orderedItems: [{ + type: 'Group' + }] + }) } }); @@ -457,6 +426,9 @@ describe('ActivityPubAPI', function () { const expected: Activity[] = [ { type: 'Person' + }, + { + type: 'Group' } ]; @@ -465,7 +437,7 @@ describe('ActivityPubAPI', function () { }); describe('getFollowers', function () { - test('It passes the token to the followers endpoint', async function () { + test('It passes the token to the following endpoint', async function () { const fakeFetch = Fetch({ 'https://auth.api/': { response: JSONResponse({ @@ -506,8 +478,13 @@ describe('ActivityPubAPI', function () { }, 'https://activitypub.api/.ghost/activitypub/followers/index': { response: JSONResponse({ - type: 'Collection', - orderedItems: [] + type: 'OrderedCollection', + first: 'https://activitypub.api/.ghost/activitypub/followers/index?cursor=0' + }) + }, + 'https://activitypub.api/.ghost/activitypub/followers/index?cursor=0': { + response: JSONResponse({ + type: 'OrderedCollection' }) } }); @@ -524,7 +501,7 @@ describe('ActivityPubAPI', function () { expect(actual).toEqual(expected); }); - test('Returns all the items array when the followers is not empty', async function () { + test('Recursively retrieves all items and returns them when the followers is not empty', async function () { const fakeFetch = Fetch({ 'https://auth.api/': { response: JSONResponse({ @@ -536,11 +513,26 @@ describe('ActivityPubAPI', function () { 'https://activitypub.api/.ghost/activitypub/followers/index': { response: JSONResponse({ - type: 'Collection', - orderedItems: [{ - type: 'Person' - }] + type: 'OrderedCollection', + first: 'https://activitypub.api/.ghost/activitypub/followers/index?cursor=0' }) + }, + 'https://activitypub.api/.ghost/activitypub/followers/index?cursor=0': { + response: JSONResponse({ + type: 'OrderedCollection', + next: 'https://activitypub.api/.ghost/activitypub/followers/index?cursor=1', + orderedItems: [{ + type: 'Person' + }] + }) + }, + 'https://activitypub.api/.ghost/activitypub/followers/index?cursor=1': { + response: JSONResponse({ + type: 'OrderedCollection', + orderedItems: [{ + type: 'Group' + }] + }) } }); @@ -555,6 +547,9 @@ describe('ActivityPubAPI', function () { const expected: Activity[] = [ { type: 'Person' + }, + { + type: 'Group' } ]; @@ -562,8 +557,8 @@ describe('ActivityPubAPI', function () { }); }); - describe('getFollowersExpanded', function () { - test('It passes the token to the followers endpoint', async function () { + describe('getLiked', function () { + test('It passes the token to the liked endpoint', async function () { const fakeFetch = Fetch({ 'https://auth.api/': { response: JSONResponse({ @@ -572,7 +567,7 @@ describe('ActivityPubAPI', function () { }] }) }, - 'https://activitypub.api/.ghost/activitypub/followers-expanded/index': { + 'https://activitypub.api/.ghost/activitypub/liked/index': { async assert(_resource, init) { const headers = new Headers(init?.headers); expect(headers.get('Authorization')).toContain('fake-token'); @@ -590,10 +585,10 @@ describe('ActivityPubAPI', function () { fakeFetch ); - await api.getFollowersExpanded(); + await api.getLiked(); }); - test('Returns an empty array when the followers is empty', async function () { + test('Returns an empty array when the liked collection is empty', async function () { const fakeFetch = Fetch({ 'https://auth.api/': { response: JSONResponse({ @@ -602,10 +597,15 @@ describe('ActivityPubAPI', function () { }] }) }, - 'https://activitypub.api/.ghost/activitypub/followers-expanded/index': { + 'https://activitypub.api/.ghost/activitypub/liked/index': { response: JSONResponse({ - type: 'Collection', - orderedItems: [] + type: 'OrderedCollection', + first: 'https://activitypub.api/.ghost/activitypub/liked/index?cursor=0' + }) + }, + 'https://activitypub.api/.ghost/activitypub/liked/index?cursor=0': { + response: JSONResponse({ + type: 'OrderedCollection' }) } }); @@ -616,13 +616,13 @@ describe('ActivityPubAPI', function () { fakeFetch ); - const actual = await api.getFollowersExpanded(); + const actual = await api.getLiked(); const expected: never[] = []; expect(actual).toEqual(expected); }); - test('Returns all the items array when the followers is not empty', async function () { + test('Recursively retrieves all items and returns them when the liked collection is not empty', async function () { const fakeFetch = Fetch({ 'https://auth.api/': { response: JSONResponse({ @@ -631,14 +631,35 @@ describe('ActivityPubAPI', function () { }] }) }, - 'https://activitypub.api/.ghost/activitypub/followers-expanded/index': { + 'https://activitypub.api/.ghost/activitypub/liked/index': { response: JSONResponse({ - type: 'Collection', - orderedItems: [{ - type: 'Person' - }] + type: 'OrderedCollection', + first: 'https://activitypub.api/.ghost/activitypub/liked/index?cursor=0' }) + }, + 'https://activitypub.api/.ghost/activitypub/liked/index?cursor=0': { + response: JSONResponse({ + type: 'OrderedCollection', + next: 'https://activitypub.api/.ghost/activitypub/liked/index?cursor=1', + orderedItems: [{ + type: 'Create', + object: { + type: 'Note' + } + }] + }) + }, + 'https://activitypub.api/.ghost/activitypub/liked/index?cursor=1': { + response: JSONResponse({ + type: 'OrderedCollection', + orderedItems: [{ + type: 'Create', + object: { + type: 'Article' + } + }] + }) } }); @@ -649,10 +670,19 @@ describe('ActivityPubAPI', function () { fakeFetch ); - const actual = await api.getFollowersExpanded(); + const actual = await api.getLiked(); const expected: Activity[] = [ { - type: 'Person' + type: 'Create', + object: { + type: 'Note' + } + }, + { + type: 'Create', + object: { + type: 'Article' + } } ]; @@ -689,143 +719,6 @@ describe('ActivityPubAPI', function () { }); }); - describe('getAllActivities', function () { - test('It fetches all activities navigating pagination', async function () { - const fakeFetch = Fetch({ - 'https://auth.api/': { - response: JSONResponse({ - identities: [{ - token: 'fake-token' - }] - }) - }, - 'https://activitypub.api/.ghost/activitypub/activities/index?limit=50': { - response: JSONResponse({ - items: [{type: 'Create', object: {type: 'Note'}}], - nextCursor: 'next-cursor' - }) - }, - 'https://activitypub.api/.ghost/activitypub/activities/index?limit=50&cursor=next-cursor': { - response: JSONResponse({ - items: [{type: 'Announce', object: {type: 'Article'}}], - nextCursor: null - }) - } - }); - - const api = new ActivityPubAPI( - new URL('https://activitypub.api'), - new URL('https://auth.api'), - 'index', - fakeFetch - ); - - const actual = await api.getAllActivities(); - const expected: Activity[] = [ - {type: 'Create', object: {type: 'Note'}}, - {type: 'Announce', object: {type: 'Article'}} - ]; - - expect(actual).toEqual(expected); - }); - - test('It fetches a user\'s own activities', async function () { - const fakeFetch = Fetch({ - 'https://auth.api/': { - response: JSONResponse({ - identities: [{ - token: 'fake-token' - }] - }) - }, - 'https://activitypub.api/.ghost/activitypub/activities/index?limit=50&includeOwn=true': { - response: JSONResponse({ - items: [{type: 'Create', object: {type: 'Note'}}], - nextCursor: null - }) - } - }); - - const api = new ActivityPubAPI( - new URL('https://activitypub.api'), - new URL('https://auth.api'), - 'index', - fakeFetch - ); - - const actual = await api.getAllActivities(true); - const expected: Activity[] = [ - {type: 'Create', object: {type: 'Note'}} - ]; - - expect(actual).toEqual(expected); - }); - - test('It fetches activities with replies', async function () { - const fakeFetch = Fetch({ - 'https://auth.api/': { - response: JSONResponse({ - identities: [{ - token: 'fake-token' - }] - }) - }, - 'https://activitypub.api/.ghost/activitypub/activities/index?limit=50&includeReplies=true': { - response: JSONResponse({ - items: [{type: 'Create', object: {type: 'Note'}}], - nextCursor: null - }) - } - }); - - const api = new ActivityPubAPI( - new URL('https://activitypub.api'), - new URL('https://auth.api'), - 'index', - fakeFetch - ); - - const actual = await api.getAllActivities(false, true); - const expected: Activity[] = [ - {type: 'Create', object: {type: 'Note'}} - ]; - - expect(actual).toEqual(expected); - }); - - test('It fetches filtered activities', async function () { - const fakeFetch = Fetch({ - 'https://auth.api/': { - response: JSONResponse({ - identities: [{ - token: 'fake-token' - }] - }) - }, - [`https://activitypub.api/.ghost/activitypub/activities/index?limit=50&filter=%7B%22type%22%3A%5B%22Create%3ANote%22%5D%7D`]: { - response: JSONResponse({ - items: [{type: 'Create', object: {type: 'Note'}}], - nextCursor: null - }) - } - }); - - const api = new ActivityPubAPI( - new URL('https://activitypub.api'), - new URL('https://auth.api'), - 'index', - fakeFetch - ); - - const actual = await api.getAllActivities(false, false, {type: ['Create:Note']}); - const expected: Activity[] = [ - {type: 'Create', object: {type: 'Note'}} - ]; - - expect(actual).toEqual(expected); - }); - }); - describe('search', function () { test('It returns the results of the search', async function () { const fakeFetch = Fetch({ diff --git a/apps/admin-x-activitypub/src/api/activitypub.ts b/apps/admin-x-activitypub/src/api/activitypub.ts index 3f70a540da4..922f5d7ffbb 100644 --- a/apps/admin-x-activitypub/src/api/activitypub.ts +++ b/apps/admin-x-activitypub/src/api/activitypub.ts @@ -32,6 +32,10 @@ export interface GetFollowingForProfileResponse { next: string | null; } +export interface ActivityThread { + items: Activity[]; +} + export class ActivityPubAPI { constructor( private readonly apiUrl: URL, @@ -92,35 +96,77 @@ export class ActivityPubAPI { } async getOutbox(): Promise { - const json = await this.fetchJSON(this.outboxApiUrl); - if (json === null) { + const fetchOutboxPage = async (url: URL): Promise => { + const json = await this.fetchJSON(url); + + if (json === null) { + return []; + } + + let items: Activity[] = []; + + if ('orderedItems' in json) { + items = Array.isArray(json.orderedItems) ? json.orderedItems : [json.orderedItems]; + } + + if ('next' in json && typeof json.next === 'string') { + const nextUrl = new URL(json.next); + const nextItems = await fetchOutboxPage(nextUrl); + + items = items.concat(nextItems); + } + + return items; + }; + + const initialJson = await this.fetchJSON(this.outboxApiUrl); + + if (initialJson === null || !('first' in initialJson) || typeof initialJson.first !== 'string') { return []; } - if ('orderedItems' in json) { - return Array.isArray(json.orderedItems) ? json.orderedItems : [json.orderedItems]; - } - if ('items' in json) { - return Array.isArray(json.items) ? json.items : [json.items]; - } - return []; + + const firstPageUrl = new URL(initialJson.first); + + return fetchOutboxPage(firstPageUrl); } get followingApiUrl() { return new URL(`.ghost/activitypub/following/${this.handle}`, this.apiUrl); } - async getFollowing(): Promise { - const json = await this.fetchJSON(this.followingApiUrl); - if (json === null) { + async getFollowing(): Promise { + const fetchFollowingPage = async (url: URL): Promise => { + const json = await this.fetchJSON(url); + + if (json === null) { + return []; + } + + let items: Actor[] = []; + + if ('orderedItems' in json) { + items = Array.isArray(json.orderedItems) ? json.orderedItems : [json.orderedItems]; + } + + if ('next' in json && typeof json.next === 'string') { + const nextUrl = new URL(json.next); + const nextItems = await fetchFollowingPage(nextUrl); + + items = items.concat(nextItems); + } + + return items; + }; + + const initialJson = await this.fetchJSON(this.followingApiUrl); + + if (initialJson === null || !('first' in initialJson) || typeof initialJson.first !== 'string') { return []; } - if ('orderedItems' in json) { - return Array.isArray(json.orderedItems) ? json.orderedItems : [json.orderedItems]; - } - if ('items' in json) { - return Array.isArray(json.items) ? json.items : [json.items]; - } - return []; + + const firstPageUrl = new URL(initialJson.first); + + return fetchFollowingPage(firstPageUrl); } async getFollowingCount(): Promise { @@ -138,15 +184,39 @@ export class ActivityPubAPI { return new URL(`.ghost/activitypub/followers/${this.handle}`, this.apiUrl); } - async getFollowers(): Promise { - const json = await this.fetchJSON(this.followersApiUrl); - if (json === null) { + async getFollowers(): Promise { + const fetchFollowersPage = async (url: URL): Promise => { + const json = await this.fetchJSON(url); + + if (json === null) { + return []; + } + + let items: Actor[] = []; + + if ('orderedItems' in json) { + items = Array.isArray(json.orderedItems) ? json.orderedItems : [json.orderedItems]; + } + + if ('next' in json && typeof json.next === 'string') { + const nextUrl = new URL(json.next); + const nextItems = await fetchFollowersPage(nextUrl); + + items = items.concat(nextItems); + } + + return items; + }; + + const initialJson = await this.fetchJSON(this.followersApiUrl); + + if (initialJson === null || !('first' in initialJson) || typeof initialJson.first !== 'string') { return []; } - if ('orderedItems' in json) { - return Array.isArray(json.orderedItems) ? json.orderedItems : [json.orderedItems]; - } - return []; + + const firstPageUrl = new URL(initialJson.first); + + return fetchFollowersPage(firstPageUrl); } async getFollowersCount(): Promise { @@ -160,21 +230,6 @@ export class ActivityPubAPI { return 0; } - get followersExpandedApiUrl() { - return new URL(`.ghost/activitypub/followers-expanded/${this.handle}`, this.apiUrl); - } - - async getFollowersExpanded(): Promise { - const json = await this.fetchJSON(this.followersExpandedApiUrl); - if (json === null) { - return []; - } - if ('orderedItems' in json) { - return Array.isArray(json.orderedItems) ? json.orderedItems : [json.orderedItems]; - } - return []; - } - async getFollowersForProfile(handle: string, next?: string): Promise { const url = new URL(`.ghost/activitypub/profile/${handle}/followers`, this.apiUrl); if (next) { @@ -252,14 +307,38 @@ export class ActivityPubAPI { } async getLiked() { - const json = await this.fetchJSON(this.likedApiUrl); - if (json === null) { + const fetchLikedPage = async (url: URL): Promise => { + const json = await this.fetchJSON(url); + + if (json === null) { + return []; + } + + let items: Activity[] = []; + + if ('orderedItems' in json) { + items = Array.isArray(json.orderedItems) ? json.orderedItems : [json.orderedItems]; + } + + if ('next' in json && typeof json.next === 'string') { + const nextUrl = new URL(json.next); + const nextItems = await fetchLikedPage(nextUrl); + + items = items.concat(nextItems); + } + + return items; + }; + + const initialJson = await this.fetchJSON(this.likedApiUrl); + + if (initialJson === null || !('first' in initialJson) || typeof initialJson.first !== 'string') { return []; } - if ('orderedItems' in json) { - return Array.isArray(json.orderedItems) ? json.orderedItems : [json.orderedItems]; - } - return []; + + const firstPageUrl = new URL(initialJson.first); + + return fetchLikedPage(firstPageUrl); } async like(id: string): Promise { @@ -328,72 +407,6 @@ export class ActivityPubAPI { }; } - async getAllActivities( - includeOwn: boolean = false, - includeReplies: boolean = false, - filter: {type?: string[]} | null = null - ): Promise { - const LIMIT = 50; - - const fetchActivities = async (url: URL): Promise => { - const json = await this.fetchJSON(url); - - // If the response is null, return early - if (json === null) { - return []; - } - - // If the response doesn't have an items array, return early - if (!('items' in json)) { - return []; - } - - // If the response has an items property, but it's not an array - // use an empty array - const items = Array.isArray(json.items) ? json.items : []; - - // If the response has a nextCursor property, fetch the next page - // recursively and concatenate the results - if ('nextCursor' in json && typeof json.nextCursor === 'string') { - const nextUrl = new URL(url); - - nextUrl.searchParams.set('cursor', json.nextCursor); - nextUrl.searchParams.set('limit', LIMIT.toString()); - if (includeOwn) { - nextUrl.searchParams.set('includeOwn', includeOwn.toString()); - } - if (includeReplies) { - nextUrl.searchParams.set('includeReplies', includeReplies.toString()); - } - if (filter) { - nextUrl.searchParams.set('filter', JSON.stringify(filter)); - } - - const nextItems = await fetchActivities(nextUrl); - - return items.concat(nextItems); - } - - return items; - }; - - // Make a copy of the activities API URL and set the limit - const url = new URL(this.activitiesApiUrl); - url.searchParams.set('limit', LIMIT.toString()); - if (includeOwn) { - url.searchParams.set('includeOwn', includeOwn.toString()); - } - if (includeReplies) { - url.searchParams.set('includeReplies', includeReplies.toString()); - } - if (filter) { - url.searchParams.set('filter', JSON.stringify(filter)); - } - - // Fetch the activities - return fetchActivities(url); - } - async reply(id: string, content: string) { const url = new URL(`.ghost/activitypub/actions/reply/${encodeURIComponent(id)}`, this.apiUrl); const response = await this.fetchJSON(url, 'POST', {content}); @@ -434,4 +447,10 @@ export class ActivityPubAPI { const json = await this.fetchJSON(url); return json as Profile; } + + async getThread(id: string): Promise { + const url = new URL(`.ghost/activitypub/thread/${btoa(id)}`, this.apiUrl); + const json = await this.fetchJSON(url); + return json as ActivityThread; + } } diff --git a/apps/admin-x-activitypub/src/components/Activities.tsx b/apps/admin-x-activitypub/src/components/Activities.tsx index 66b45f7de54..d8c9907a843 100644 --- a/apps/admin-x-activitypub/src/components/Activities.tsx +++ b/apps/admin-x-activitypub/src/components/Activities.tsx @@ -1,6 +1,7 @@ import React, {useEffect, useRef} from 'react'; import NiceModal from '@ebay/nice-modal-react'; +import {ActorProperties} from '@tryghost/admin-x-framework/api/activitypub'; import {LoadingIndicator, NoValueLabel} from '@tryghost/admin-x-design-system'; import APAvatar, {AvatarBadge} from './global/APAvatar'; @@ -11,6 +12,7 @@ import MainNavigation from './navigation/MainNavigation'; import ViewProfileModal from './global/ViewProfileModal'; import getUsername from '../utils/get-username'; +import stripHtml from '../utils/strip-html'; import {useActivitiesForUser} from '../hooks/useActivityPubQueries'; // import {useFollowersForUser} from '../MainContent'; @@ -37,7 +39,7 @@ const getActivityDescription = (activity: Activity): string => { if (activity.object && activity.object.type === 'Article') { return `Liked your article "${activity.object.name}"`; } else if (activity.object && activity.object.type === 'Note') { - return `${activity.object.content}`; + return `${stripHtml(activity.object.content)}`; } } @@ -90,13 +92,7 @@ const getActivityBadge = (activity: Activity): AvatarBadge => { const Activities: React.FC = ({}) => { const user = 'index'; - const { - data, - fetchNextPage, - hasNextPage, - isFetchingNextPage, - isLoading - } = useActivitiesForUser({ + const {getActivitiesQuery} = useActivitiesForUser({ handle: user, includeOwn: true, includeReplies: true, @@ -104,7 +100,7 @@ const Activities: React.FC = ({}) => { type: ['Follow', 'Like', `Create:Note:isReplyToOwn`] } }); - + const {data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading} = getActivitiesQuery; const activities = (data?.pages.flatMap(page => page.data) ?? []); const observerRef = useRef(null); @@ -143,16 +139,16 @@ const Activities: React.FC = ({}) => { switch (activity.type) { case ACTVITY_TYPE.CREATE: NiceModal.show(ArticleModal, { + activityId: activity.id, object: activity.object, - actor: activity.actor, - comments: activity.object.replies + actor: activity.actor }); break; case ACTVITY_TYPE.LIKE: NiceModal.show(ArticleModal, { + activityId: activity.id, object: activity.object, - actor: activity.actor, - comments: activity.object.replies + actor: activity.object.attributedTo as ActorProperties }); break; case ACTVITY_TYPE.FOLLOW: diff --git a/apps/admin-x-activitypub/src/components/Inbox.tsx b/apps/admin-x-activitypub/src/components/Inbox.tsx index 3a359e6075e..57ca95703f8 100644 --- a/apps/admin-x-activitypub/src/components/Inbox.tsx +++ b/apps/admin-x-activitypub/src/components/Inbox.tsx @@ -1,15 +1,14 @@ import APAvatar from './global/APAvatar'; -import ActivityItem, {type Activity} from './activities/ActivityItem'; +import ActivityItem from './activities/ActivityItem'; import ActivityPubWelcomeImage from '../assets/images/ap-welcome.png'; -import ArticleModal from './feed/ArticleModal'; import FeedItem from './feed/FeedItem'; import MainNavigation from './navigation/MainNavigation'; import NiceModal from '@ebay/nice-modal-react'; -import React, {useEffect, useRef, useState} from 'react'; +import React, {useEffect, useRef} from 'react'; import ViewProfileModal from './global/ViewProfileModal'; import getUsername from '../utils/get-username'; -import {ActorProperties, ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub'; import {Button, Heading, LoadingIndicator} from '@tryghost/admin-x-design-system'; +import {handleViewContent} from '../utils/content-handlers'; import {useActivitiesForUser, useSuggestedProfiles} from '../hooks/useActivityPubQueries'; import {useLayout} from '../hooks/layout'; import {useRouting} from '@tryghost/admin-x-framework/routing'; @@ -17,24 +16,16 @@ import {useRouting} from '@tryghost/admin-x-framework/routing'; interface InboxProps {} const Inbox: React.FC = ({}) => { - const [, setArticleContent] = useState(null); - const [, setArticleActor] = useState(null); - const {layout} = useLayout(); + const {layout, setFeed, setInbox} = useLayout(); - const { - data, - fetchNextPage, - hasNextPage, - isFetchingNextPage, - isLoading - } = useActivitiesForUser({ + const {getActivitiesQuery, updateActivity} = useActivitiesForUser({ handle: 'index', - includeReplies: true, excludeNonFollowers: true, filter: { - type: ['Create:Article:notReply', 'Create:Note:notReply', 'Announce:Note'] + type: ['Create:Article', 'Create:Note', 'Announce:Note'] } }); + const {data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading} = getActivitiesQuery; const {updateRoute} = useRouting(); @@ -45,36 +36,6 @@ const Inbox: React.FC = ({}) => { return !activity.object.inReplyTo; }); - const handleViewContent = (object: ObjectProperties, actor: ActorProperties, comments: Activity[], focusReply = false) => { - setArticleContent(object); - setArticleActor(actor); - NiceModal.show(ArticleModal, {object, actor, comments, focusReply}); - }; - - function getContentAuthor(activity: Activity) { - const actor = activity.actor; - const attributedTo = activity.object.attributedTo; - - if (!attributedTo) { - return actor; - } - - if (typeof attributedTo === 'string') { - return actor; - } - - if (Array.isArray(attributedTo)) { - const found = attributedTo.find(item => typeof item !== 'string'); - if (found) { - return found; - } else { - return actor; - } - } - - return attributedTo; - } - // Intersection observer to fetch more activities when the user scrolls // to the bottom of the page const observerRef = useRef(null); @@ -104,7 +65,7 @@ const Inbox: React.FC = ({}) => { return ( <> - +
{isLoading ? ( @@ -120,24 +81,15 @@ const Inbox: React.FC = ({}) => {
  • handleViewContent( - activity.object, - getContentAuthor(activity), - activity.object.replies - )} + onClick={() => handleViewContent(activity, false, updateActivity)} > handleViewContent( - activity.object, - getContentAuthor(activity), - activity.object.replies, - true - )} + onCommentClick={() => handleViewContent(activity, true, updateActivity)} /> {index < activities.length - 1 && (
    diff --git a/apps/admin-x-activitypub/src/components/Profile.tsx b/apps/admin-x-activitypub/src/components/Profile.tsx index 7edad06225f..ceab6edf52d 100644 --- a/apps/admin-x-activitypub/src/components/Profile.tsx +++ b/apps/admin-x-activitypub/src/components/Profile.tsx @@ -1,5 +1,5 @@ import APAvatar from './global/APAvatar'; -import ActivityItem, {type Activity} from './activities/ActivityItem'; +import ActivityItem from './activities/ActivityItem'; import FeedItem from './feed/FeedItem'; import MainNavigation from './navigation/MainNavigation'; import NiceModal from '@ebay/nice-modal-react'; @@ -7,12 +7,12 @@ import React, {useEffect, useRef, useState} from 'react'; import getUsername from '../utils/get-username'; import {ActorProperties} from '@tryghost/admin-x-framework/api/activitypub'; -import ArticleModal from './feed/ArticleModal'; import ViewProfileModal from './global/ViewProfileModal'; import {Button, Heading, List, NoValueLabel, Tab, TabView} from '@tryghost/admin-x-design-system'; +import {handleViewContent} from '../utils/content-handlers'; import { useFollowersCountForUser, - useFollowersExpandedForUser, + useFollowersForUser, useFollowingCountForUser, useFollowingForUser, useLikedForUser, @@ -26,9 +26,11 @@ const Profile: React.FC = ({}) => { const {data: followersCount = 0} = useFollowersCountForUser('index'); const {data: followingCount = 0} = useFollowingCountForUser('index'); const {data: following = []} = useFollowingForUser('index'); - const {data: followers = []} = useFollowersExpandedForUser('index'); + const {data: followers = []} = useFollowersForUser('index'); const {data: liked = []} = useLikedForUser('index'); - const {data: posts = []} = useOutboxForUser('index'); + const {data: outboxPosts = []} = useOutboxForUser('index'); + + const posts = outboxPosts.filter(post => post.type === 'Create' && !post.object.inReplyTo); // Replace 'index' with the actual handle of the user const {data: userProfile} = useUserDataForUser('index') as {data: ActorProperties | null}; @@ -73,14 +75,6 @@ const Profile: React.FC = ({}) => { }); }; - const handlePostClick = (activity: Activity) => { - NiceModal.show(ArticleModal, { - object: activity.object, - actor: activity.actor, - comments: activity.object.replies || [] - }); - }; - const tabs = [ { id: 'posts', @@ -97,14 +91,14 @@ const Profile: React.FC = ({}) => {
  • handlePostClick(activity)} + onClick={() => handleViewContent(activity, false)} > {}} + onCommentClick={() => handleViewContent(activity, true)} /> {index < posts.length - 1 && (
    @@ -142,14 +136,14 @@ const Profile: React.FC = ({}) => {
  • handlePostClick(activity)} + onClick={() => handleViewContent(activity, false)} > {}} + onCommentClick={() => handleViewContent(activity, true)} /> {index < liked.length - 1 && (
    diff --git a/apps/admin-x-activitypub/src/components/activities/ActivityItem.tsx b/apps/admin-x-activitypub/src/components/activities/ActivityItem.tsx index 0b40b69c876..a5ecbbfc402 100644 --- a/apps/admin-x-activitypub/src/components/activities/ActivityItem.tsx +++ b/apps/admin-x-activitypub/src/components/activities/ActivityItem.tsx @@ -3,11 +3,13 @@ import React, {ReactNode} from 'react'; import {ActorProperties, ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub'; export type Activity = { + id: string, type: string, actor: ActorProperties, object: ObjectProperties & { inReplyTo: ObjectProperties | string | null replies: Activity[] + replyCount: number } } diff --git a/apps/admin-x-activitypub/src/components/articleBodyStyles.ts b/apps/admin-x-activitypub/src/components/articleBodyStyles.ts index 72a3babdf11..183d957036c 100644 --- a/apps/admin-x-activitypub/src/components/articleBodyStyles.ts +++ b/apps/admin-x-activitypub/src/components/articleBodyStyles.ts @@ -204,7 +204,7 @@ a:hover { line-height: 1; color: var(--color-white); cursor: pointer; - background-color: var(--ghost-accent-color); + background-color: rgb(29 78 216); border: 0; border-radius: 100px; } @@ -533,7 +533,7 @@ button.gh-form-input { } .gh-navigation.has-accent-color .gh-navigation-actions { - background-color: var(--ghost-accent-color); + background-color: rgb(29 78 216); } .gh-navigation-members { @@ -582,7 +582,7 @@ button.gh-form-input { /* 6.1. Navigation styles */ .gh-navigation.has-accent-color { - background-color: var(--ghost-accent-color); + background-color: rgb(29 78 216); } .gh-navigation.has-accent-color .gh-button { @@ -2017,21 +2017,23 @@ Search LOGO Login Subscribe font-weight: 500; letter-spacing: 0.01em; text-transform: uppercase; - color: var(--ghost-accent-color); + color: rgb(29 78 216); } .gh-article-title { - font-size: calc(clamp(3.4rem, 1.36vw + 2.85rem, 4.6rem) * var(--factor, 1)); - line-height: 1.1; - letter-spacing: -0.022em; + font-size: 2.9rem; + line-height: 1.2; + letter-spacing: -0.87px; + font-weight: 600; + text-wrap: pretty; } .gh-article-excerpt { - margin-top: clamp(12px, 0.45vw + 10.18px, 16px); - max-width: 720px; - font-size: clamp(1.5rem, 0.45vw + 1.32rem, 1.9rem); - line-height: 1.4; - letter-spacing: -0.018em; + margin-top: 16px; + font-size: 2.1rem; + line-height: 1.25; + letter-spacing: -0.63px; + text-wrap: pretty; } .gh-article-meta { @@ -2200,7 +2202,7 @@ unless a heading is the very first element in the post content */ } .gh-content a { - color: var(--ghost-accent-color); + color: rgb(29 78 216); text-decoration: underline; } @@ -2386,7 +2388,7 @@ unless a heading is the very first element in the post content */ blockquote:not([class]) { padding-left: 2rem; - border-left: 4px solid var(--ghost-accent-color); + border-left: 4px solid rgb(29 78 216); } blockquote.kg-blockquote-alt { @@ -2506,7 +2508,7 @@ figcaption { } figcaption a { - color: var(--ghost-accent-color); + color: rgb(29 78 216); text-decoration: underline; } @@ -2592,7 +2594,7 @@ figcaption a { } .author-template .gh-article-title { - font-size: 3.6rem; + font-size: 2.9rem; } .gh-author-meta { @@ -2785,7 +2787,7 @@ figcaption a { /* 21.1 Footer styles */ .gh-footer.has-accent-color { - background-color: var(--ghost-accent-color); + background-color: rgb(29 78 216); } .gh-footer.has-accent-color .gh-footer-bar { @@ -3420,7 +3422,7 @@ figcaption a { } .kg-callout-card-accent { - background: var(--ghost-accent-color); + background: rgb(29 78 216); color: #fff; } @@ -3476,7 +3478,7 @@ figcaption a { } .kg-audio-thumbnail.placeholder { - background: var(--ghost-accent-color); + background: rgb(29 78 216); } .kg-audio-thumbnail.placeholder svg { @@ -3950,7 +3952,7 @@ figcaption a { } .kg-button-card a.kg-btn-accent { - background-color: var(--ghost-accent-color); + background-color: rgb(29 78 216); color: #fff; } @@ -4280,7 +4282,7 @@ p.kg-collection-card-post-excerpt { .kg-file-card-icon svg { width: 24px; height: 24px; - color: var(--ghost-accent-color); + color: rgb(29 78 216); } /* Size variations */ @@ -4403,7 +4405,7 @@ p.kg-collection-card-post-excerpt { } .kg-header-card.kg-style-accent { - background-color: var(--ghost-accent-color); + background-color: rgb(29 78 216); } .kg-header-card.kg-style-image { @@ -4486,7 +4488,7 @@ p.kg-collection-card-post-excerpt { .kg-header-card h2.kg-header-card-header a, .kg-header-card h3.kg-header-card-subheader a { - color: var(--ghost-accent-color); + color: rgb(29 78 216); } .kg-header-card.kg-style-accent h2.kg-header-card-header a, @@ -4555,7 +4557,7 @@ p.kg-collection-card-post-excerpt { } .kg-header-card.kg-style-light a.kg-header-card-button { - background: var(--ghost-accent-color); + background: rgb(29 78 216); color: #fff; } @@ -4821,7 +4823,7 @@ p.kg-collection-card-post-excerpt { } .kg-product-card a.kg-product-card-btn-accent { - background-color: var(--ghost-accent-color); + background-color: rgb(29 78 216); color: #fff; } @@ -4840,7 +4842,7 @@ p.kg-collection-card-post-excerpt { } .kg-signup-card.kg-style-accent { - background-color: var(--ghost-accent-color); + background-color: rgb(29 78 216); } .kg-layout-split .kg-signup-card-content { @@ -5067,7 +5069,7 @@ p.kg-collection-card-post-excerpt { } .kg-signup-card-button.kg-style-accent { - background-color: var(--ghost-accent-color); + background-color: rgb(29 78 216); } .kg-signup-card h2 + .kg-signup-card-button, @@ -5659,7 +5661,7 @@ p.kg-collection-card-post-excerpt { } .kg-header-card.kg-style-accent.kg-v2 { - background-color: var(--ghost-accent-color); + background-color: rgb(29 78 216); } .kg-header-card-content { @@ -5830,7 +5832,7 @@ p.kg-collection-card-post-excerpt { } .kg-header-card.kg-v2 .kg-header-card-button.kg-style-accent { - background-color: var(--ghost-accent-color); + background-color: rgb(29 78 216); } .kg-header-card.kg-v2 h2 + .kg-header-card-button, diff --git a/apps/admin-x-activitypub/src/components/feed/ArticleModal.tsx b/apps/admin-x-activitypub/src/components/feed/ArticleModal.tsx index accff00d5ab..89600497e70 100644 --- a/apps/admin-x-activitypub/src/components/feed/ArticleModal.tsx +++ b/apps/admin-x-activitypub/src/components/feed/ArticleModal.tsx @@ -2,20 +2,28 @@ import React, {useEffect, useRef, useState} from 'react'; import NiceModal, {useModal} from '@ebay/nice-modal-react'; import {ActorProperties, ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub'; -import {Button, Modal} from '@tryghost/admin-x-design-system'; +import {Button, LoadingIndicator, Modal} from '@tryghost/admin-x-design-system'; import {useBrowseSite} from '@tryghost/admin-x-framework/api/site'; -import FeedItem from './FeedItem'; +import {type Activity} from '../activities/ActivityItem'; import APReplyBox from '../global/APReplyBox'; +import FeedItem from './FeedItem'; import articleBodyStyles from '../articleBodyStyles'; -import {type Activity} from '../activities/ActivityItem'; + +import {useThreadForUser} from '../../hooks/useActivityPubQueries'; interface ArticleModalProps { + activityId: string; object: ObjectProperties; actor: ActorProperties; - comments: Activity[]; focusReply: boolean; + updateActivity: (id: string, updated: Partial) => void; + history: { + activityId: string; + object: ObjectProperties; + actor: ActorProperties; + }[]; } const ArticleBody: React.FC<{heading: string, image: string|undefined, excerpt: string|undefined, html: string}> = ({heading, image, excerpt, html}) => { @@ -38,10 +46,45 @@ const ArticleBody: React.FC<{heading: string, image: string|undefined, excerpt: } `; + } + if (!excludeList.has('cta_styles')) { + membersHelper += (``); } - const dataAttributes = getDataAttributes(attributes); - - let membersHelper = ``; - membersHelper += (``); if (settingsCache.get('paid_members_enabled')) { // disable fraud detection for e2e tests to reduce waiting time const isFraudSignalsEnabled = process.env.NODE_ENV === 'testing-browser' ? '?advancedFraudSignals=false' : ''; @@ -91,7 +94,7 @@ function getSearchHelper(frontendKey) { key: frontendKey, styles: stylesUrl, 'sodo-search': adminUrl, - locale: settingsCache.get('locale') || 'en' + locale: labs.isSet('i18n') ? (settingsCache.get('locale') || 'en') : undefined }; const dataAttrs = getDataAttributes(attrs); let helper = ``; @@ -198,12 +201,11 @@ function getTinybirdTrackerScript(dataRoot) { // We use the name ghost_head to match the helper for consistency: module.exports = async function ghost_head(options) { // eslint-disable-line camelcase debug('begin'); - // if server error page do nothing if (options.data.root.statusCode >= 500) { return; } - + const excludeList = new Set(options?.hash?.exclude?.split(',') || []); const head = []; const dataRoot = options.data.root; const context = dataRoot._locals.context ? dataRoot._locals.context : null; @@ -234,25 +236,26 @@ module.exports = async function ghost_head(options) { // eslint-disable-line cam debug('end fetch'); if (context) { - // head is our main array that holds our meta data - if (meta.metaDescription && meta.metaDescription.length > 0) { - head.push(''); - } + if (!excludeList.has('metadata')) { + // head is our main array that holds our meta data + if (meta.metaDescription && meta.metaDescription.length > 0) { + head.push(''); + } - // no output in head if a publication icon is not set - if (settingsCache.get('icon')) { - head.push(''); - } + // no output in head if a publication icon is not set + if (settingsCache.get('icon')) { + head.push(''); + } - head.push(''); + head.push(''); - if (_.includes(context, 'preview')) { - head.push(writeMetaTag('robots', 'noindex,nofollow', 'name')); - head.push(writeMetaTag('referrer', 'same-origin', 'name')); - } else { - head.push(writeMetaTag('referrer', referrerPolicy, 'name')); + if (_.includes(context, 'preview')) { + head.push(writeMetaTag('robots', 'noindex,nofollow', 'name')); + head.push(writeMetaTag('referrer', 'same-origin', 'name')); + } else { + head.push(writeMetaTag('referrer', referrerPolicy, 'name')); + } } - // show amp link in post when 1. we are not on the amp page and 2. amp is enabled if (_.includes(context, 'post') && !_.includes(context, 'amp') && settingsCache.get('amp')) { head.push('\n' + JSON.stringify(meta.schema, null, ' ') + '\n \n'); } } } - head.push(''); - head.push(''); - // no code injection for amp context!!! if (!_.includes(context, 'amp')) { - head.push(getMembersHelper(options.data, frontendKey)); - head.push(getSearchHelper(frontendKey)); - head.push(getAnnouncementBarHelper(options.data)); + head.push(getMembersHelper(options.data, frontendKey, excludeList)); // controlling for excludes within the function + if (!excludeList.has('search')) { + head.push(getSearchHelper(frontendKey)); + } + if (!excludeList.has('announcement')) { + head.push(getAnnouncementBarHelper(options.data)); + } try { head.push(getWebmentionDiscoveryLink()); } catch (err) { @@ -301,14 +307,17 @@ module.exports = async function ghost_head(options) { // eslint-disable-line cam } // @TODO do this in a more "frameworky" way - if (cardAssets.hasFile('js')) { - head.push(``); - } - if (cardAssets.hasFile('css')) { - head.push(``); + + if (!excludeList.has('card_assets')) { + if (cardAssets.hasFile('js')) { + head.push(``); + } + if (cardAssets.hasFile('css')) { + head.push(``); + } } - if (settingsCache.get('comments_enabled') !== 'off') { + if (!excludeList.has('comment_counts') && settingsCache.get('comments_enabled') !== 'off') { head.push(``); } @@ -327,7 +336,6 @@ module.exports = async function ghost_head(options) { // eslint-disable-line cam head.push(styleTag); } } - if (!_.isEmpty(globalCodeinjection)) { head.push(globalCodeinjection); } diff --git a/ghost/core/core/frontend/helpers/t.js b/ghost/core/core/frontend/helpers/t.js index 281c18c08c4..cd4cdcc40d3 100644 --- a/ghost/core/core/frontend/helpers/t.js +++ b/ghost/core/core/frontend/helpers/t.js @@ -17,7 +17,7 @@ module.exports = function t(text, options = {}) { // no-op: translation key is missing, return an empty string return ''; } - + console.log('t got', text, options); const bindings = {}; let prop; for (prop in options.hash) { diff --git a/ghost/core/core/frontend/services/theme-engine/i18n/I18n.js b/ghost/core/core/frontend/services/theme-engine/i18n/I18n.js index a6287fc47b3..800f5d58932 100644 --- a/ghost/core/core/frontend/services/theme-engine/i18n/I18n.js +++ b/ghost/core/core/frontend/services/theme-engine/i18n/I18n.js @@ -10,6 +10,8 @@ const isEqual = require('lodash/isEqual'); const isNil = require('lodash/isNil'); const merge = require('lodash/merge'); const get = require('lodash/get'); +const i18nLib = require('@tryghost/i18n'); +const i18n = require('@tryghost/i18n/lib/i18n'); class I18n { /** @@ -66,11 +68,13 @@ class I18n { * @param {object} [bindings] * @returns {string} */ - t(translationPath, bindings) { + /*t(translationPath, bindings) { let string; let msg; - string = this._findString(translationPath); + //string = this._findString(translationPath); + + return i18n.t(translationPath, bindings); // If the path returns an array (as in the case with anything that has multiple paragraphs such as emails), then // loop through them and return an array of translated/formatted strings. Otherwise, just return the normal @@ -92,11 +96,21 @@ class I18n { * - Load proper language file into memory */ init() { - this._strings = this._loadStrings(); - this._initializeIntl(); - } + //this._strings = this._loadStrings(); + //this._initializeIntl(); + } + t(key, hash) { + const i18nLib = require('@tryghost/i18n'); + const i18nLanguage = this.locale() || 'en'; + const i18n = i18nLib(i18nLanguage, 'theme'); + i18n.init({ + lng: this.locale(), + ns: 'theme' + }); + return(i18n.t(key, hash)); + } /** * Attempt to load strings from a file * diff --git a/ghost/core/core/frontend/src/cards/css/signup.css b/ghost/core/core/frontend/src/cards/css/signup.css index 264fa79d8bf..b6e5e83656e 100644 --- a/ghost/core/core/frontend/src/cards/css/signup.css +++ b/ghost/core/core/frontend/src/cards/css/signup.css @@ -223,6 +223,7 @@ align-items: center; height: 2.9em; min-height: 46px; + height: 100%; padding: 0 1.2em; outline: none; border: none; diff --git a/ghost/core/core/server/services/email-service/EmailServiceWrapper.js b/ghost/core/core/server/services/email-service/EmailServiceWrapper.js index badd32dd605..ab36d370edf 100644 --- a/ghost/core/core/server/services/email-service/EmailServiceWrapper.js +++ b/ghost/core/core/server/services/email-service/EmailServiceWrapper.js @@ -27,7 +27,7 @@ class EmailServiceWrapper { const limitService = require('../limits'); const labs = require('../../../shared/labs'); const emailAddressService = require('../email-address'); - + const i18nLib = require('@tryghost/i18n'); const mobiledocLib = require('../../lib/mobiledoc'); const lexicalLib = require('../../lib/lexical'); const urlUtils = require('../../../shared/url-utils'); @@ -49,6 +49,8 @@ class EmailServiceWrapper { const mailgunClient = new MailgunClient({ config: configService, settings: settingsCache }); + const i18nLanguage = settingsCache.get('locale') || 'en'; + const i18n = i18nLib(i18nLanguage, 'newsletter'); const mailgunEmailProvider = new MailgunEmailProvider({ mailgunClient, @@ -73,7 +75,8 @@ class EmailServiceWrapper { outboundLinkTagger: memberAttribution.outboundLinkTagger, emailAddressService: emailAddressService.service, labs, - models: {Post} + models: {Post}, + t: i18n.t }); const sendingService = new SendingService({ diff --git a/ghost/core/core/server/services/i18n.js b/ghost/core/core/server/services/i18n.js index aa15648a266..ee52fa3d35e 100644 --- a/ghost/core/core/server/services/i18n.js +++ b/ghost/core/core/server/services/i18n.js @@ -33,4 +33,4 @@ module.exports.init = function () { i18nInstance.changeLanguage(model.get('value')); } }); -}; +}; \ No newline at end of file diff --git a/ghost/core/core/server/services/oembed/TwitterOEmbedProvider.js b/ghost/core/core/server/services/oembed/TwitterOEmbedProvider.js index f280f9d5387..78cf0750b2d 100644 --- a/ghost/core/core/server/services/oembed/TwitterOEmbedProvider.js +++ b/ghost/core/core/server/services/oembed/TwitterOEmbedProvider.js @@ -71,6 +71,14 @@ class TwitterOEmbedProvider { oembedData.tweet_data = body.data; oembedData.tweet_data.includes = body.includes; } catch (err) { + if (err.response?.body) { + try { + const parsed = JSON.parse(err.response.body); + err.context = parsed; + } catch (e) { + err.context = err.response.body; + } + } logging.error(err); } } diff --git a/ghost/core/core/shared/config/defaults.json b/ghost/core/core/shared/config/defaults.json index b992c3f1c6d..5ae20baeda2 100644 --- a/ghost/core/core/shared/config/defaults.json +++ b/ghost/core/core/shared/config/defaults.json @@ -197,12 +197,12 @@ }, "portal": { "url": "https://cdn.jsdelivr.net/ghost/portal@~{version}/umd/portal.min.js", - "version": "2.45" + "version": "2.46" }, "sodoSearch": { "url": "https://cdn.jsdelivr.net/ghost/sodo-search@~{version}/umd/sodo-search.min.js", "styles": "https://cdn.jsdelivr.net/ghost/sodo-search@~{version}/umd/main.css", - "version": "1.4" + "version": "1.5" }, "announcementBar": { "url": "https://cdn.jsdelivr.net/ghost/announcement-bar@~{version}/umd/announcement-bar.min.js", diff --git a/ghost/core/core/shared/settings-cache/CacheManager.js b/ghost/core/core/shared/settings-cache/CacheManager.js index ed2b6f05bb6..c67f5267043 100644 --- a/ghost/core/core/shared/settings-cache/CacheManager.js +++ b/ghost/core/core/shared/settings-cache/CacheManager.js @@ -64,6 +64,10 @@ class CacheManager { return cacheEntry; } + // TODO: I think we should be a little smarter here and deserialize the value based on the type + // rather than trying to parse everything as JSON, which is very slow when we do it hundreds + // of times per request. + // Default behavior is to try to resolve the value and return that try { // CASE: handle literal false @@ -71,6 +75,11 @@ class CacheManager { return false; } + // CASE: hotpath early return for strings which are already strings + if (cacheEntry.type === 'string' && typeof cacheEntry.value === 'string') { + return cacheEntry.value || null; + } + // CASE: if a string contains a number e.g. "1", JSON.parse will auto convert into integer if (!isNaN(Number(cacheEntry.value))) { return cacheEntry.value || null; diff --git a/ghost/core/package.json b/ghost/core/package.json index f6ed725678f..67c40d68b4a 100644 --- a/ghost/core/package.json +++ b/ghost/core/package.json @@ -1,6 +1,6 @@ { "name": "ghost", - "version": "5.98.0", + "version": "5.98.1", "description": "The professional publishing platform", "author": "Ghost Foundation", "homepage": "https://ghost.org", @@ -173,7 +173,7 @@ "chalk": "4.1.2", "cheerio": "0.22.0", "common-tags": "1.8.2", - "compression": "1.7.4", + "compression": "1.7.5", "connect-slashes": "1.4.0", "cookie-session": "2.1.0", "cors": "2.8.5", diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/email-previews.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/email-previews.test.js.snap index 88762c1ab3d..cc5d7cf83d6 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/email-previews.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/email-previews.test.js.snap @@ -312,16 +312,19 @@ table.body .footer a { } table.feedback-buttons { - display: none !important; - } - - table.feedback-buttons-mobile { display: table !important; width: 100% !important; max-width: 390px; } - table.body .feedback-button-mobile-text { + table.feedback-buttons img { + display: inherit !important; + } + + table.body .feedback-button-text { + display: block !important; + padding-top: 4px !important; + padding-left: 0px !important; font-size: 13px !important; } @@ -779,7 +782,7 @@ exports[`Email Preview API Read can read post email preview with email card and Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "24776", + "content-length": "24870", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, @@ -1005,16 +1008,19 @@ table.body .footer a { } table.feedback-buttons { - display: none !important; - } - - table.feedback-buttons-mobile { display: table !important; width: 100% !important; max-width: 390px; } - table.body .feedback-button-mobile-text { + table.feedback-buttons img { + display: inherit !important; + } + + table.body .feedback-button-text { + display: block !important; + padding-top: 4px !important; + padding-left: 0px !important; font-size: 13px !important; } @@ -1494,7 +1500,7 @@ exports[`Email Preview API Read can read post email preview with fields 4: [head Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "29556", + "content-length": "29650", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, @@ -1751,16 +1757,19 @@ table.body .footer a { } table.feedback-buttons { - display: none !important; - } - - table.feedback-buttons-mobile { display: table !important; width: 100% !important; max-width: 390px; } - table.body .feedback-button-mobile-text { + table.feedback-buttons img { + display: inherit !important; + } + + table.body .feedback-button-text { + display: block !important; + padding-top: 4px !important; + padding-left: 0px !important; font-size: 13px !important; } @@ -2225,7 +2234,7 @@ exports[`Email Preview API Read has custom content transformations for email com Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "24530", + "content-length": "24624", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, @@ -2811,16 +2820,19 @@ table.body .footer a { } table.feedback-buttons { - display: none !important; - } - - table.feedback-buttons-mobile { display: table !important; width: 100% !important; max-width: 390px; } - table.body .feedback-button-mobile-text { + table.feedback-buttons img { + display: inherit !important; + } + + table.body .feedback-button-text { + display: block !important; + padding-top: 4px !important; + padding-left: 0px !important; font-size: 13px !important; } @@ -3299,7 +3311,7 @@ exports[`Email Preview API Read uses the newsletter provided through ?newsletter Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "25313", + "content-length": "25407", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, @@ -3911,16 +3923,19 @@ table.body .footer a { } table.feedback-buttons { - display: none !important; - } - - table.feedback-buttons-mobile { display: table !important; width: 100% !important; max-width: 390px; } - table.body .feedback-button-mobile-text { + table.feedback-buttons img { + display: inherit !important; + } + + table.body .feedback-button-text { + display: block !important; + padding-top: 4px !important; + padding-left: 0px !important; font-size: 13px !important; } @@ -4399,7 +4414,7 @@ exports[`Email Preview API Read uses the posts newsletter by default 4: [headers Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "25313", + "content-length": "25407", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, diff --git a/ghost/core/test/integration/services/email-service/__snapshots__/batch-sending.test.js.snap b/ghost/core/test/integration/services/email-service/__snapshots__/batch-sending.test.js.snap index 70c0b062e01..7e6e9e89d02 100644 --- a/ghost/core/test/integration/services/email-service/__snapshots__/batch-sending.test.js.snap +++ b/ghost/core/test/integration/services/email-service/__snapshots__/batch-sending.test.js.snap @@ -205,16 +205,19 @@ table.body .footer a { } table.feedback-buttons { - display: none !important; - } - - table.feedback-buttons-mobile { display: table !important; width: 100% !important; max-width: 390px; } - table.body .feedback-button-mobile-text { + table.feedback-buttons img { + display: inherit !important; + } + + table.body .feedback-button-text { + display: block !important; + padding-top: 4px !important; + padding-left: 0px !important; font-size: 13px !important; } @@ -887,16 +890,19 @@ table.body .footer a { } table.feedback-buttons { - display: none !important; - } - - table.feedback-buttons-mobile { display: table !important; width: 100% !important; max-width: 390px; } - table.body .feedback-button-mobile-text { + table.feedback-buttons img { + display: inherit !important; + } + + table.body .feedback-button-text { + display: block !important; + padding-top: 4px !important; + padding-left: 0px !important; font-size: 13px !important; } @@ -1545,16 +1551,19 @@ table.body .footer a { } table.feedback-buttons { - display: none !important; - } - - table.feedback-buttons-mobile { display: table !important; width: 100% !important; max-width: 390px; } - table.body .feedback-button-mobile-text { + table.feedback-buttons img { + display: inherit !important; + } + + table.body .feedback-button-text { + display: block !important; + padding-top: 4px !important; + padding-left: 0px !important; font-size: 13px !important; } @@ -2203,16 +2212,19 @@ table.body .footer a { } table.feedback-buttons { - display: none !important; - } - - table.feedback-buttons-mobile { display: table !important; width: 100% !important; max-width: 390px; } - table.body .feedback-button-mobile-text { + table.feedback-buttons img { + display: inherit !important; + } + + table.body .feedback-button-text { + display: block !important; + padding-top: 4px !important; + padding-left: 0px !important; font-size: 13px !important; } @@ -2861,16 +2873,19 @@ table.body .footer a { } table.feedback-buttons { - display: none !important; - } - - table.feedback-buttons-mobile { display: table !important; width: 100% !important; max-width: 390px; } - table.body .feedback-button-mobile-text { + table.feedback-buttons img { + display: inherit !important; + } + + table.body .feedback-button-text { + display: block !important; + padding-top: 4px !important; + padding-left: 0px !important; font-size: 13px !important; } @@ -3467,16 +3482,19 @@ table.body .footer a { } table.feedback-buttons { - display: none !important; - } - - table.feedback-buttons-mobile { display: table !important; width: 100% !important; max-width: 390px; } - table.body .feedback-button-mobile-text { + table.feedback-buttons img { + display: inherit !important; + } + + table.body .feedback-button-text { + display: block !important; + padding-top: 4px !important; + padding-left: 0px !important; font-size: 13px !important; } @@ -4125,16 +4143,19 @@ table.body .footer a { } table.feedback-buttons { - display: none !important; - } - - table.feedback-buttons-mobile { display: table !important; width: 100% !important; max-width: 390px; } - table.body .feedback-button-mobile-text { + table.feedback-buttons img { + display: inherit !important; + } + + table.body .feedback-button-text { + display: block !important; + padding-top: 4px !important; + padding-left: 0px !important; font-size: 13px !important; } @@ -4421,38 +4442,22 @@ table.body h2 span { - - - -
    - - \\"More - - - - \\"Less - - - - \\"Comment\\" - -
    - +
    -
    + - \\"More -

    More like this

    + \\"More +

    More like this

    -
    + - \\"Less -

    Less like this

    + \\"Less +

    Less like this

    -
    + - \\"Comment\\" -

    Comment

    + \\"Comment\\" +

    Comment

    @@ -4592,24 +4597,6 @@ Hello world -http://127.0.0.1:2369/this-is-a-test-post-title-4/#/feedback/post-id/1/?uuid=member-uuid&key=xxxxxx - - - -http://127.0.0.1:2369/this-is-a-test-post-title-4/#/feedback/post-id/0/?uuid=member-uuid&key=xxxxxx - - - -http://127.0.0.1:2369/this-is-a-test-post-title-4/#ghost-comments - - - - - - - - - More like this @@ -4877,16 +4864,19 @@ table.body .footer a { } table.feedback-buttons { - display: none !important; - } - - table.feedback-buttons-mobile { display: table !important; width: 100% !important; max-width: 390px; } - table.body .feedback-button-mobile-text { + table.feedback-buttons img { + display: inherit !important; + } + + table.body .feedback-button-text { + display: block !important; + padding-top: 4px !important; + padding-left: 0px !important; font-size: 13px !important; } @@ -5173,20 +5163,12 @@ table.body h2 span { - +
    - -
    + - \\"Comment\\" - -
    - - -
    - - \\"Comment\\" -

    Comment

    + \\"Comment\\" +

    Comment

    @@ -5326,16 +5308,6 @@ Hello world -http://127.0.0.1:2369/this-is-a-test-post-title-3/#ghost-comments - - - - - - - - - Comment @@ -6949,16 +6921,19 @@ table.body .footer a { } table.feedback-buttons { - display: none !important; - } - - table.feedback-buttons-mobile { display: table !important; width: 100% !important; max-width: 390px; } - table.body .feedback-button-mobile-text { + table.feedback-buttons img { + display: inherit !important; + } + + table.body .feedback-button-text { + display: block !important; + padding-top: 4px !important; + padding-left: 0px !important; font-size: 13px !important; } @@ -7752,16 +7727,19 @@ table.body .footer a { } table.feedback-buttons { - display: none !important; - } - - table.feedback-buttons-mobile { display: table !important; width: 100% !important; max-width: 390px; } - table.body .feedback-button-mobile-text { + table.feedback-buttons img { + display: inherit !important; + } + + table.body .feedback-button-text { + display: block !important; + padding-top: 4px !important; + padding-left: 0px !important; font-size: 13px !important; } @@ -8465,16 +8443,19 @@ table.body .footer a { } table.feedback-buttons { - display: none !important; - } - - table.feedback-buttons-mobile { display: table !important; width: 100% !important; max-width: 390px; } - table.body .feedback-button-mobile-text { + table.feedback-buttons img { + display: inherit !important; + } + + table.body .feedback-button-text { + display: block !important; + padding-top: 4px !important; + padding-left: 0px !important; font-size: 13px !important; } @@ -9178,16 +9159,19 @@ table.body .footer a { } table.feedback-buttons { - display: none !important; - } - - table.feedback-buttons-mobile { display: table !important; width: 100% !important; max-width: 390px; } - table.body .feedback-button-mobile-text { + table.feedback-buttons img { + display: inherit !important; + } + + table.body .feedback-button-text { + display: block !important; + padding-top: 4px !important; + padding-left: 0px !important; font-size: 13px !important; } @@ -9891,16 +9875,19 @@ table.body .footer a { } table.feedback-buttons { - display: none !important; - } - - table.feedback-buttons-mobile { display: table !important; width: 100% !important; max-width: 390px; } - table.body .feedback-button-mobile-text { + table.feedback-buttons img { + display: inherit !important; + } + + table.body .feedback-button-text { + display: block !important; + padding-top: 4px !important; + padding-left: 0px !important; font-size: 13px !important; } @@ -10604,16 +10591,19 @@ table.body .footer a { } table.feedback-buttons { - display: none !important; - } - - table.feedback-buttons-mobile { display: table !important; width: 100% !important; max-width: 390px; } - table.body .feedback-button-mobile-text { + table.feedback-buttons img { + display: inherit !important; + } + + table.body .feedback-button-text { + display: block !important; + padding-top: 4px !important; + padding-left: 0px !important; font-size: 13px !important; } @@ -11317,16 +11307,19 @@ table.body .footer a { } table.feedback-buttons { - display: none !important; - } - - table.feedback-buttons-mobile { display: table !important; width: 100% !important; max-width: 390px; } - table.body .feedback-button-mobile-text { + table.feedback-buttons img { + display: inherit !important; + } + + table.body .feedback-button-text { + display: block !important; + padding-top: 4px !important; + padding-left: 0px !important; font-size: 13px !important; } @@ -11977,16 +11970,19 @@ table.body .footer a { } table.feedback-buttons { - display: none !important; - } - - table.feedback-buttons-mobile { display: table !important; width: 100% !important; max-width: 390px; } - table.body .feedback-button-mobile-text { + table.feedback-buttons img { + display: inherit !important; + } + + table.body .feedback-button-text { + display: block !important; + padding-top: 4px !important; + padding-left: 0px !important; font-size: 13px !important; } diff --git a/ghost/core/test/integration/services/email-service/__snapshots__/cards.test.js.snap b/ghost/core/test/integration/services/email-service/__snapshots__/cards.test.js.snap index 689089d581c..b77e112173f 100644 --- a/ghost/core/test/integration/services/email-service/__snapshots__/cards.test.js.snap +++ b/ghost/core/test/integration/services/email-service/__snapshots__/cards.test.js.snap @@ -205,16 +205,19 @@ table.body .footer a { } table.feedback-buttons { - display: none !important; - } - - table.feedback-buttons-mobile { display: table !important; width: 100% !important; max-width: 390px; } - table.body .feedback-button-mobile-text { + table.feedback-buttons img { + display: inherit !important; + } + + table.body .feedback-button-text { + display: block !important; + padding-top: 4px !important; + padding-left: 0px !important; font-size: 13px !important; } @@ -863,16 +866,19 @@ table.body .footer a { } table.feedback-buttons { - display: none !important; - } - - table.feedback-buttons-mobile { display: table !important; width: 100% !important; max-width: 390px; } - table.body .feedback-button-mobile-text { + table.feedback-buttons img { + display: inherit !important; + } + + table.body .feedback-button-text { + display: block !important; + padding-top: 4px !important; + padding-left: 0px !important; font-size: 13px !important; } @@ -1521,16 +1527,19 @@ table.body .footer a { } table.feedback-buttons { - display: none !important; - } - - table.feedback-buttons-mobile { display: table !important; width: 100% !important; max-width: 390px; } - table.body .feedback-button-mobile-text { + table.feedback-buttons img { + display: inherit !important; + } + + table.body .feedback-button-text { + display: block !important; + padding-top: 4px !important; + padding-left: 0px !important; font-size: 13px !important; } @@ -2714,16 +2723,19 @@ table.body .footer a { } table.feedback-buttons { - display: none !important; - } - - table.feedback-buttons-mobile { display: table !important; width: 100% !important; max-width: 390px; } - table.body .feedback-button-mobile-text { + table.feedback-buttons img { + display: inherit !important; + } + + table.body .feedback-button-text { + display: block !important; + padding-top: 4px !important; + padding-left: 0px !important; font-size: 13px !important; } diff --git a/ghost/core/test/integration/services/email-service/batch-sending.test.js b/ghost/core/test/integration/services/email-service/batch-sending.test.js index 27cdc9b1422..8d0152e8a8e 100644 --- a/ghost/core/test/integration/services/email-service/batch-sending.test.js +++ b/ghost/core/test/integration/services/email-service/batch-sending.test.js @@ -710,7 +710,8 @@ describe('Batch sending tests', function () { newsletters: [{ id: fixtureManager.get('newsletters', 0).id }], - email_disabled: false + email_disabled: false, + created_at: '2024-10-11T23:45:54.000Z' }); const {html, plaintext} = await sendEmail(agent, { @@ -743,7 +744,8 @@ describe('Batch sending tests', function () { newsletters: [{ id: fixtureManager.get('newsletters', 0).id }], - email_disabled: false + email_disabled: false, + created_at: '2024-10-11T23:45:54.000Z' }); const {html, plaintext} = await sendEmail(agent, { @@ -829,7 +831,7 @@ describe('Batch sending tests', function () { }); // Currently the link is not present in plaintext version (because no text) - assert.equal(html.match(/#ghost-comments/g).length, 2, 'Every email should have 2 buttons to comments'); + assert.equal(html.match(/#ghost-comments/g).length, 1, 'Every email should have 1 button to comments'); await matchEmailSnapshot(); }); @@ -848,7 +850,7 @@ describe('Batch sending tests', function () { }); // Currently the link is not present in plaintext version (because no text) - assert.equal(html.match(/#ghost-comments/g).length, 2, 'Every email should have 2 buttons to comments'); + assert.equal(html.match(/#ghost-comments/g).length, 1, 'Every email should have 1 button to comments'); await matchEmailSnapshot(); } finally { // undo @@ -917,7 +919,8 @@ describe('Batch sending tests', function () { newsletters: [{ id: fixtureManager.get('newsletters', 0).id }], - email_disabled: false + email_disabled: false, + created_at: '2024-10-11T23:45:54.000Z' }); mockSetting('email_track_clicks', false); // Disable link replacement for this test @@ -951,7 +954,8 @@ describe('Batch sending tests', function () { id: fixtureManager.get('newsletters', 0).id }], status: 'comped', - email_disabled: false + email_disabled: false, + created_at: '2024-10-11T23:45:54.000Z' }); mockSetting('email_track_clicks', false); // Disable link replacement for this test diff --git a/ghost/core/test/unit/frontend/helpers/__snapshots__/ghost_head.test.js.snap b/ghost/core/test/unit/frontend/helpers/__snapshots__/ghost_head.test.js.snap index b3fc1e298c0..69d581faad4 100644 --- a/ghost/core/test/unit/frontend/helpers/__snapshots__/ghost_head.test.js.snap +++ b/ghost/core/test/unit/frontend/helpers/__snapshots__/ghost_head.test.js.snap @@ -131,7 +131,7 @@ Object { .gh-post-upgrade-cta a.gh-btn:hover { opacity: 0.92; } - + ", @@ -275,7 +275,7 @@ Object { - + ", } @@ -286,7 +286,7 @@ Object { "rendered": " - + @@ -299,7 +299,7 @@ Object { "rendered": " - + ", } @@ -373,7 +373,7 @@ Object { - + ", } @@ -446,7 +446,7 @@ Object { - + ", } @@ -843,7 +843,7 @@ Object { .gh-post-upgrade-cta a.gh-btn:hover { opacity: 0.92; } - + ", @@ -958,7 +958,7 @@ Object { .gh-post-upgrade-cta a.gh-btn:hover { opacity: 0.92; } - + ", } @@ -1032,7 +1032,7 @@ Object { - + ", } @@ -1106,7 +1106,7 @@ Object { - + ", } @@ -1180,7 +1180,7 @@ Object { - + ", } @@ -1254,7 +1254,7 @@ Object { - + ", } @@ -1403,7 +1403,7 @@ Object { - + ", @@ -1553,7 +1553,7 @@ Object { - + ", @@ -1623,7 +1623,7 @@ Object { - + ", @@ -1693,7 +1693,7 @@ Object { - + ", @@ -1745,7 +1745,7 @@ Object { - + ", @@ -1797,7 +1797,7 @@ Object { - + ", @@ -1912,7 +1912,7 @@ Object { .gh-post-upgrade-cta a.gh-btn:hover { opacity: 0.92; } - + ", } @@ -2026,7 +2026,7 @@ Object { .gh-post-upgrade-cta a.gh-btn:hover { opacity: 0.92; } - + ", @@ -2141,7 +2141,7 @@ Object { .gh-post-upgrade-cta a.gh-btn:hover { opacity: 0.92; } - + ", } @@ -2255,7 +2255,7 @@ Object { .gh-post-upgrade-cta a.gh-btn:hover { opacity: 0.92; } - + ", @@ -2307,7 +2307,7 @@ Object { - + ", } @@ -2421,13 +2421,1172 @@ Object { .gh-post-upgrade-cta a.gh-btn:hover { opacity: 0.92; } - + + + + ", +} +`; + +exports[`{{ghost_head}} helper respects values from excludes: can handle multiple excludes 1 1`] = ` +Object { + "rendered": " + + + + + + + + + + + + + + + + + + + + + + + ", +} +`; + +exports[`{{ghost_head}} helper respects values from excludes: does not load card assets when excluded with card_assets 1 1`] = ` +Object { + "rendered": " + + + + + + + + + + + + + + + + + + + + + + + ", +} +`; + +exports[`{{ghost_head}} helper respects values from excludes: does not load cta styles when excluded with cta_styles 1 1`] = ` +Object { + "rendered": " + + + + + + + + + + + + + + + + + + + + + ", } `; +exports[`{{ghost_head}} helper respects values from excludes: does not load meta tags when excluded with metadata 1 1`] = ` +Object { + "rendered": " + + + + + + + + + + + + + + + + + + + ", +} +`; + +exports[`{{ghost_head}} helper respects values from excludes: does not load og: or twitter: attributes when excludd with social_data 1 1`] = ` +Object { + "rendered": " + + + + + + + + + + ", +} +`; + +exports[`{{ghost_head}} helper respects values from excludes: does not load schema when excluded with schema 1 1`] = ` +Object { + "rendered": " + + + + + + + + + + + + + + + + + + + + + ", +} +`; + +exports[`{{ghost_head}} helper respects values from excludes: does not load the comments script when exclude contains comment_counts 1 1`] = ` +Object { + "rendered": " + + + + + + + + + + + + + + + + + + + + + + + ", +} +`; + +exports[`{{ghost_head}} helper respects values from excludes: does not show the announcement when exclude contains announcement 1 1`] = ` +Object { + "rendered": " + + + + + + + + + + + + + + + + + + + + + + + ", +} +`; + +exports[`{{ghost_head}} helper respects values from excludes: loads card assets when not excluded 1 1`] = ` +Object { + "rendered": " + + + + + + + + + + + + + + + + + + + + + + + + + ", +} +`; + +exports[`{{ghost_head}} helper respects values from excludes: shows the announcement when exclude does not contain announcement 1 1`] = ` +Object { + "rendered": " + + + + + + + + + + + + + + + + + + + + + + + + ", +} +`; + +exports[`{{ghost_head}} helper respects values from excludes: when exclude contains portal 1 1`] = ` +Object { + "rendered": " + + + + + + + + + + + + + + + + + + + + + + + + ", +} +`; + +exports[`{{ghost_head}} helper respects values from excludes: when exclude contains search 1 1`] = ` +Object { + "rendered": " + + + + + + + + + + + + + + + + + + + + + + + ", +} +`; + +exports[`{{ghost_head}} helper respects values from excludes: when excludes is empty 1 1`] = ` +Object { + "rendered": " + + + + + + + + + + + + + + + + + + + + + + + + ", +} +`; + +exports[`{{ghost_head}} helper search scripts does not incldue locale in search when i18n is disabled 1 1`] = ` +Object { + "rendered": " + + + + + + + + + + + + + + + + + + + + + + + ", +} +`; + +exports[`{{ghost_head}} helper search scripts includes locale in search when i18n is enabled 1 1`] = ` +Object { + "rendered": " + + + + + + + + + + + + + + + + + + + + + + + ", +} +`; + +exports[`{{ghost_head}} helper search scripts includes search 1 1`] = ` +Object { + "rendered": " + + + + + + + + + + + + + + + + + + + + + + + ", +} +`; + exports[`{{ghost_head}} helper search scripts includes search when labs flag enabled 1 1`] = ` Object { "rendered": " @@ -2487,7 +3646,7 @@ Object { - + ", } @@ -2501,7 +3660,7 @@ Object { - + ", @@ -2516,7 +3675,7 @@ Object { - + ", @@ -2531,7 +3690,7 @@ Object { - + @@ -2547,7 +3706,7 @@ Object { - + ", @@ -2632,7 +3791,7 @@ Object { - + ", } @@ -2648,7 +3807,7 @@ Object { - + ", } @@ -2674,7 +3833,7 @@ Object { - + ", } @@ -2725,7 +3884,7 @@ Object { - + ", } @@ -2738,7 +3897,7 @@ Object { - + ", } @@ -2751,7 +3910,7 @@ Object { - + ", } @@ -2812,7 +3971,7 @@ Object { - + ", @@ -2864,7 +4023,7 @@ Object { - + ", @@ -2887,7 +4046,7 @@ Object { - + ", } @@ -2952,7 +4111,7 @@ Object { - + ", } @@ -3003,7 +4162,7 @@ Object { - + ", } @@ -3025,7 +4184,7 @@ Object { - + ", } @@ -3038,7 +4197,7 @@ Object { - + ", } @@ -3053,7 +4212,7 @@ Object { - + ", } @@ -3068,7 +4227,7 @@ Object { - + ", } @@ -3120,7 +4279,7 @@ Object { - + ", } @@ -3170,7 +4329,7 @@ Object { - + ", } @@ -3251,7 +4410,7 @@ Object { - + ", } @@ -3378,7 +4537,7 @@ Object { - + ", } @@ -3459,7 +4618,7 @@ Object { - + ", } @@ -3540,7 +4699,7 @@ Object { - + ", } @@ -3609,7 +4768,7 @@ Object { - + ", } @@ -3682,7 +4841,7 @@ Object { - + ", } @@ -3755,7 +4914,7 @@ Object { - + ", } @@ -3828,7 +4987,7 @@ Object { - + ", } @@ -3902,7 +5061,7 @@ Object { - + ", } @@ -3968,7 +5127,7 @@ Object { - + ", } @@ -4016,7 +5175,7 @@ Object { - + ", } @@ -4068,7 +5227,7 @@ Object { - + ", } diff --git a/ghost/core/test/unit/frontend/helpers/ghost_head.test.js b/ghost/core/test/unit/frontend/helpers/ghost_head.test.js index fb88e0d52d3..839e24f9bc2 100644 --- a/ghost/core/test/unit/frontend/helpers/ghost_head.test.js +++ b/ghost/core/test/unit/frontend/helpers/ghost_head.test.js @@ -10,6 +10,7 @@ const models = require('../../../../core/server/models'); const imageLib = require('../../../../core/server/lib/image'); const routing = require('../../../../core/frontend/services/routing'); const urlService = require('../../../../core/server/services/url'); +const {cardAssets} = require('../../../../core/frontend/services/assets-minification'); const logging = require('@tryghost/logging'); const ghost_head = require('../../../../core/frontend/helpers/ghost_head'); @@ -1350,16 +1351,44 @@ describe('{{ghost_head}} helper', function () { }); describe('search scripts', function () { - it('includes search when labs flag enabled', async function () { - sinon.stub(labs, 'isSet').returns(true); + it('includes search', async function () { + const rendered = await testGhostHead(testUtils.createHbsResponse({ + locals: { + relativeUrl: '/', + context: ['home', 'index'], + safeVersion: '4.3' + } + })); - await testGhostHead(testUtils.createHbsResponse({ + rendered.should.match(/sodo-search@/); + }); + + it('includes locale in search when i18n is enabled', async function () { + sinon.stub(labs, 'isSet').withArgs('i18n').returns(true); + + const rendered = await testGhostHead(testUtils.createHbsResponse({ + locals: { + relativeUrl: '/', + context: ['home', 'index'], + safeVersion: '4.3' + } + })); + + rendered.should.match(/sodo-search@[^>]*?data-locale="en"/); + }); + + it('does not incldue locale in search when i18n is disabled', async function () { + sinon.stub(labs, 'isSet').withArgs('i18n').returns(false); + + const rendered = await testGhostHead(testUtils.createHbsResponse({ locals: { relativeUrl: '/', context: ['home', 'index'], safeVersion: '4.3' } })); + + rendered.should.not.match(/sodo-search@[^>]*?data-locale="en"/); }); }); @@ -1466,4 +1495,186 @@ describe('{{ghost_head}} helper', function () { })); }); }); + describe('respects values from excludes: ', function () { + it('when excludes is empty', async function () { + settingsCache.get.withArgs('members_enabled').returns(true); + settingsCache.get.withArgs('paid_members_enabled').returns(true); + + let rendered = await testGhostHead({hash: {exclude: ''}, ...testUtils.createHbsResponse({ + locals: { + relativeUrl: '/', + context: ['home', 'index'], + safeVersion: '4.3' + } + })}); + rendered.should.match(/portal@/); + rendered.should.match(/sodo-search@/); + rendered.should.match(/js.stripe.com/); + }); + it('when exclude contains search', async function () { + settingsCache.get.withArgs('members_enabled').returns(true); + settingsCache.get.withArgs('paid_members_enabled').returns(true); + + let rendered = await testGhostHead({hash: {exclude: 'search'}, ...testUtils.createHbsResponse({ + locals: { + relativeUrl: '/', + context: ['home', 'index'], + safeVersion: '4.3' + } + })}); + rendered.should.not.match(/sodo-search@/); + rendered.should.match(/portal@/); + rendered.should.match(/js.stripe.com/); + }); + it('when exclude contains portal', async function () { + settingsCache.get.withArgs('members_enabled').returns(true); + settingsCache.get.withArgs('paid_members_enabled').returns(true); + + let rendered = await testGhostHead({hash: {exclude: 'portal'}, ...testUtils.createHbsResponse({ + locals: { + relativeUrl: '/', + context: ['home', 'index'], + safeVersion: '4.3' + } + })}); + rendered.should.match(/sodo-search@/); + rendered.should.not.match(/portal@/); + rendered.should.match(/js.stripe.com/); + }); + it('can handle multiple excludes', async function () { + settingsCache.get.withArgs('members_enabled').returns(true); + settingsCache.get.withArgs('paid_members_enabled').returns(true); + + let rendered = await testGhostHead({hash: {exclude: 'portal,search'}, ...testUtils.createHbsResponse({ + locals: { + relativeUrl: '/', + context: ['home', 'index'], + safeVersion: '4.3' + } + })}); + rendered.should.not.match(/sodo-search@/); + rendered.should.not.match(/portal@/); + rendered.should.match(/js.stripe.com/); + }); + + it('shows the announcement when exclude does not contain announcement', async function () { + settingsCache.get.withArgs('members_enabled').returns(true); + settingsCache.get.withArgs('paid_members_enabled').returns(true); + settingsCache.get.withArgs('announcement_content').returns('Hello world'); + settingsCache.get.withArgs('announcement_visibility').returns('visitors'); + + let rendered = await testGhostHead({hash: {exclude: ''}, ...testUtils.createHbsResponse({ + locals: { + relativeUrl: '/', + context: ['home', 'index'], + safeVersion: '4.3' + } + })}); + rendered.should.match(/sodo-search@/); + rendered.should.match(/portal@/); + rendered.should.match(/js.stripe.com/); + rendered.should.match(/announcement-bar@/); + }); + it('does not show the announcement when exclude contains announcement', async function () { + settingsCache.get.withArgs('members_enabled').returns(true); + settingsCache.get.withArgs('paid_members_enabled').returns(true); + settingsCache.get.withArgs('announcement_content').returns('Hello world'); + settingsCache.get.withArgs('announcement_visibility').returns('visitors'); + + let rendered = await testGhostHead({hash: {exclude: 'announcement'}, ...testUtils.createHbsResponse({ + locals: { + relativeUrl: '/', + context: ['home', 'index'], + safeVersion: '4.3' + } + })}); + rendered.should.match(/sodo-search@/); + rendered.should.match(/portal@/); + rendered.should.match(/js.stripe.com/); + rendered.should.match(/generator/); + rendered.should.not.match(/announcement-bar@/); + }); + + it('does not load the comments script when exclude contains comment_counts', async function () { + settingsCache.get.withArgs('comments_enabled').returns('all'); + let rendered = await testGhostHead({hash: {exclude: 'comment_counts'}, ...testUtils.createHbsResponse({ + locals: { + relativeUrl: '/', + context: ['home', 'index'], + safeVersion: '0.3' + } + })}); + rendered.should.not.match(/comment-counts.min.js/); + }); + + it('loads card assets when not excluded', async function () { + // mock the card assets cardAssets.hasFile('js', 'cards.min.js').returns(true); + sinon.stub(cardAssets, 'hasFile').returns(true); + + let rendered = await testGhostHead({...testUtils.createHbsResponse({ + locals: { + relativeUrl: '/', + context: ['home', 'index'], + safeVersion: '0.3' + } + })}); + rendered.should.match(/cards.min.js/); + rendered.should.match(/cards.min.css/); + }); + it('does not load card assets when excluded with card_assets', async function () { + sinon.stub(cardAssets, 'hasFile').returns(true); + let rendered = await testGhostHead({hash: {exclude: 'card_assets'}, ...testUtils.createHbsResponse({ + locals: { + relativeUrl: '/', + context: ['home', 'index'], + safeVersion: '0.3' + } + })}); + rendered.should.not.match(/cards.min.js/); + rendered.should.not.match(/cards.min.css/); + }); + it('does not load meta tags when excluded with metadata', async function () { + let rendered = await testGhostHead({hash: {exclude: 'metadata'}, ...testUtils.createHbsResponse({ + locals: { + relativeUrl: '/', + context: ['home', 'index'], + safeVersion: '0.3' + } + })}); + rendered.should.not.match(/ { + return x; +}; + const messages = { subscriptionStatus: { free: '', - expired: 'Your subscription has expired.', - canceled: 'Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.', - active: 'Your subscription will renew on {date}.', - trial: 'Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.', - complimentaryExpires: 'Your subscription will expire on {date}.', + expired: t('Your subscription has expired.'), + canceled: t('Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.'), + active: t('Your subscription will renew on {date}.'), + trial: t('Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.'), + complimentaryExpires: t('Your subscription will expire on {date}.'), complimentaryInfinite: '' } }; +// this is required to trigger the t parser +/* +t('Your subscription has expired.'); +t('Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.'); +t('Your subscription will renew on {date}.'); +t('Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.'); +t('Your subscription will expire on {date}.'); +*/ + function escapeHtml(unsafe) { return unsafe .replace(/&/g, '&') @@ -33,8 +47,11 @@ function escapeHtml(unsafe) { .replace(/'/g, '''); } -function formatDateLong(date, timezone) { - return DateTime.fromJSDate(date).setZone(timezone).setLocale('en-gb').toLocaleString({ +function formatDateLong(date, timezone, locale = 'en-gb') { + if (locale === 'en') { + locale = 'en-gb'; + } + return DateTime.fromJSDate(date).setZone(timezone).setLocale(locale).toLocaleString({ year: 'numeric', month: 'long', day: 'numeric' @@ -119,6 +136,7 @@ class EmailRenderer { #emailAddressService; #labs; #models; + #t; /** * @param {object} dependencies @@ -139,6 +157,7 @@ class EmailRenderer { * @param {object} dependencies.outboundLinkTagger * @param {object} dependencies.labs * @param {{Post: object}} dependencies.models + * @param {Function} dependencies.t */ constructor({ settingsCache, @@ -155,7 +174,8 @@ class EmailRenderer { emailAddressService, outboundLinkTagger, labs, - models + models, + t }) { this.#settingsCache = settingsCache; this.#settingsHelpers = settingsHelpers; @@ -172,8 +192,9 @@ class EmailRenderer { this.#outboundLinkTagger = outboundLinkTagger; this.#labs = labs; this.#models = models; + this.#t = t; } - + getSubject(post, isTestEmail = false) { const subject = post.related('posts_meta')?.get('email_subject') || post.get('title'); return isTestEmail ? `[TEST] ${subject}` : subject; @@ -549,9 +570,13 @@ class EmailRenderer { * @returns {string} */ getMemberStatusText(member) { + const t = this.#t; + + const locale = this.#settingsCache.get('locale'); + if (member.status === 'free') { // Not really used, but as a backup - return tpl(messages.subscriptionStatus.free); + return t(messages.subscriptionStatus.free); } // Do we have an active subscription? @@ -564,12 +589,12 @@ class EmailRenderer { if (!activeSubscription && !member.tiers.length) { // No subscription? - return tpl(messages.subscriptionStatus.expired); + return t(messages.subscriptionStatus.expired); } if (!activeSubscription) { if (!member.tiers[0]?.expiry_at) { - return tpl(messages.subscriptionStatus.complimentaryInfinite); + return t(messages.subscriptionStatus.complimentaryInfinite); } // Create one manually that is expiring activeSubscription = { @@ -580,30 +605,28 @@ class EmailRenderer { }; } const timezone = this.#settingsCache.get('timezone'); - // Translate to a human readable string if (activeSubscription.trial_end_at && activeSubscription.trial_end_at > new Date() && activeSubscription.status === 'trialing') { - const date = formatDateLong(activeSubscription.trial_end_at, timezone); - return tpl(messages.subscriptionStatus.trial, {date}); + const date = formatDateLong(activeSubscription.trial_end_at, timezone, locale); + return t(messages.subscriptionStatus.trial, {date}); } - const date = formatDateLong(activeSubscription.current_period_end, timezone); + const date = formatDateLong(activeSubscription.current_period_end, timezone, locale); if (activeSubscription.cancel_at_period_end) { - return tpl(messages.subscriptionStatus.canceled, {date}); + return t(messages.subscriptionStatus.canceled, {date}); } - - return tpl(messages.subscriptionStatus.active, {date}); + return t(messages.subscriptionStatus.active, {date}); } const expires = member.tiers[0]?.expiry_at ?? null; if (expires) { const timezone = this.#settingsCache.get('timezone'); - const date = formatDateLong(expires, timezone); - return tpl(messages.subscriptionStatus.complimentaryExpires, {date}); + const date = formatDateLong(expires, timezone, locale); + return t(messages.subscriptionStatus.complimentaryExpires, {date}); } - return tpl(messages.subscriptionStatus.complimentaryInfinite); + return t(messages.subscriptionStatus.complimentaryInfinite); } /** @@ -611,6 +634,10 @@ class EmailRenderer { * @returns {ReplacementDefinition[]} */ buildReplacementDefinitions({html, newsletterUuid}) { + const t = this.#t; // es-lint-disable-line no-shadow + + const locale = this.#settingsCache.get('locale'); + const baseDefinitions = [ { id: 'unsubscribe_url', @@ -664,22 +691,24 @@ class EmailRenderer { id: 'created_at', getValue: (member) => { const timezone = this.#settingsCache.get('timezone'); - return member.createdAt ? formatDateLong(member.createdAt, timezone) : ''; + return member.createdAt ? formatDateLong(member.createdAt, timezone, locale) : ''; } }, { id: 'status', getValue: (member) => { if (member.status === 'comped') { - return 'complimentary'; + return t('complimentary'); } if (this.isMemberTrialing(member)) { - return 'trialing'; + return t('trialing'); } - return member.status; + // other possible statuses: t('free'), t('paid') // + return t(member.status); } }, { + //TODO i18n id: 'status_text', getValue: (member) => { return this.getMemberStatusText(member); @@ -748,7 +777,6 @@ class EmailRenderer { } delete replacement.originalId; } - return replacements; } @@ -761,7 +789,7 @@ class EmailRenderer { this.#handlebars = require('handlebars').create(); // Register helpers - registerHelpers(this.#handlebars, labs); + registerHelpers(this.#handlebars, labs, this.#t); // Partials const cssPartialSource = await fs.readFile(path.join(__dirname, './email-templates/partials/', `styles.hbs`), 'utf8'); @@ -773,9 +801,6 @@ class EmailRenderer { const feedbackButtonPartial = await fs.readFile(path.join(__dirname, './email-templates/partials/', `feedback-button.hbs`), 'utf8'); this.#handlebars.registerPartial('feedbackButton', feedbackButtonPartial); - const feedbackButtonMobilePartial = await fs.readFile(path.join(__dirname, './email-templates/partials/', `feedback-button-mobile.hbs`), 'utf8'); - this.#handlebars.registerPartial('feedbackButtonMobile', feedbackButtonMobilePartial); - const latestPostsPartial = await fs.readFile(path.join(__dirname, './email-templates/partials/', `latest-posts.hbs`), 'utf8'); this.#handlebars.registerPartial('latestPosts', latestPostsPartial); diff --git a/ghost/email-service/lib/email-templates/partials/feedback-button-mobile.hbs b/ghost/email-service/lib/email-templates/partials/feedback-button-mobile.hbs deleted file mode 100644 index 8064f20b5b1..00000000000 --- a/ghost/email-service/lib/email-templates/partials/feedback-button-mobile.hbs +++ /dev/null @@ -1,6 +0,0 @@ - - - {{buttonText}} - - - \ No newline at end of file diff --git a/ghost/email-service/lib/email-templates/partials/feedback-button.hbs b/ghost/email-service/lib/email-templates/partials/feedback-button.hbs index 232f2f7f99e..352c01f5906 100644 --- a/ghost/email-service/lib/email-templates/partials/feedback-button.hbs +++ b/ghost/email-service/lib/email-templates/partials/feedback-button.hbs @@ -1,5 +1,7 @@ - + {{buttonText}} + + {{!-- Button text possible values: {{t 'More like this'}} {{t 'Less like this'}} {{t 'Comment'}} --}} \ No newline at end of file diff --git a/ghost/email-service/lib/email-templates/partials/styles.hbs b/ghost/email-service/lib/email-templates/partials/styles.hbs index 07cb1170f7f..b1e9be7a77b 100644 --- a/ghost/email-service/lib/email-templates/partials/styles.hbs +++ b/ghost/email-service/lib/email-templates/partials/styles.hbs @@ -504,16 +504,20 @@ figure blockquote p { ------------------------------------- */ .feedback-buttons { - width: auto !important; + /*width: auto !important;*/ + width: 100% !important; margin: auto; } -.feedback-buttons-mobile { - display: none !important; - mso-hide: all !important; +.feedback-buttons img { + display: inline-block; + vertical-align: middle; } -.feedback-button-mobile-text { +.feedback-button-text { + display: inline; + padding-left: 8px; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; color: #15212A; font-size: 13px !important; font-weight: 500; @@ -1476,16 +1480,19 @@ a[data-flickr-embed] img { } table.feedback-buttons { - display: none !important; - } - - table.feedback-buttons-mobile { display: table !important; width: 100% !important; max-width: 390px; } - table.body .feedback-button-mobile-text { + table.feedback-buttons img { + display: inherit !important; + } + + table.body .feedback-button-text { + display: block !important; + padding-top: 4px !important; + padding-left: 0px !important; font-size: 13px !important; } diff --git a/ghost/email-service/lib/email-templates/template.hbs b/ghost/email-service/lib/email-templates/template.hbs index f99551134fc..8d332426e47 100644 --- a/ghost/email-service/lib/email-templates/template.hbs +++ b/ghost/email-service/lib/email-templates/template.hbs @@ -91,15 +91,15 @@ @@ -157,22 +157,11 @@ {{#if feedbackButtons }} - {{> feedbackButton feedbackButtons href=feedbackButtons.likeHref buttonText='More like this' iconUrl="https://static.ghost.org/v5.0.0/images/more-like-this.png" width="145" height="36" }} - {{> feedbackButton feedbackButtons href=feedbackButtons.dislikeHref buttonText='Less like this' iconUrl="https://static.ghost.org/v5.0.0/images/less-like-this.png" width="142" height="36" }} + {{> feedbackButton feedbackButtons href=feedbackButtons.likeHref buttonText='More like this' iconUrl="https://static.ghost.org/v5.0.0/images/more-like-this-mobile.png" width="42" height="42" }} + {{> feedbackButton feedbackButtons href=feedbackButtons.dislikeHref buttonText='Less like this' iconUrl="https://static.ghost.org/v5.0.0/images/less-like-this-mobile.png" width="42" height="42" }} {{/if}} {{#if newsletter.showCommentCta}} - {{> feedbackButton href=post.commentUrl buttonText='Comment' iconUrl="https://static.ghost.org/v5.0.0/images/comment.png" width="122" height="36" }} - {{/if}} - - - - - {{#if feedbackButtons }} - {{> feedbackButtonMobile feedbackButtons href=feedbackButtons.likeHref buttonText='More like this' iconUrl="https://static.ghost.org/v5.0.0/images/more-like-this-mobile.png" width="42" height="42" }} - {{> feedbackButtonMobile feedbackButtons href=feedbackButtons.dislikeHref buttonText='Less like this' iconUrl="https://static.ghost.org/v5.0.0/images/less-like-this-mobile.png" width="42" height="42" }} - {{/if}} - {{#if newsletter.showCommentCta}} - {{> feedbackButtonMobile href=post.commentUrl buttonText='Comment' iconUrl="https://static.ghost.org/v5.0.0/images/comment-mobile.png" width="42" height="42" }} + {{> feedbackButton href=post.commentUrl buttonText='Comment' iconUrl="https://static.ghost.org/v5.0.0/images/comment-mobile.png" width="42" height="42"}} {{/if}} @@ -183,7 +172,7 @@ {{#if latestPosts.length}} -

    Keep reading

    +

    {{t 'Keep reading'}}

    {{> latestPosts}} @@ -192,19 +181,19 @@ {{#if newsletter.showSubscriptionDetails}} -

    Subscription details

    +

    {{t 'Subscription details'}}

    - You are receiving this because you are a %%{status}%% subscriber to {{site.title}}. %%{status_text}%% + {{{t "You are receiving this because you are a %%{status}%% subscriber to {site}." site=site.title }}} %%{status_text}%%

    -

    Name: %%{name, "not provided"}%%

    -

    Email: %%{email}%%

    -

    Member since: %%{created_at}%%

    +

    {{t 'Name'}}: %%{name, "not provided"}%%

    +

    {{t 'Email'}}: %%{email}%%

    +

    {{t 'Member since'}}: %%{created_at}%%

    - Manage subscription → + {{t 'Manage subscription'}} →
    @@ -219,7 +208,7 @@ {{{footerContent}}} {{/if}} - {{site.title}} © {{year}} – Unsubscribe + {{site.title}} © {{year}} – {{t 'Unsubscribe'}} {{#if showBadge }} diff --git a/ghost/email-service/lib/helpers/register-helpers.js b/ghost/email-service/lib/helpers/register-helpers.js index 8d097f251a8..395df95bef7 100644 --- a/ghost/email-service/lib/helpers/register-helpers.js +++ b/ghost/email-service/lib/helpers/register-helpers.js @@ -1,5 +1,5 @@ module.exports = { - registerHelpers(handlebars, labs) { + registerHelpers(handlebars, labs, thist) { handlebars.registerHelper('if', function (conditional, options) { if (conditional) { return options.fn(this); @@ -51,5 +51,9 @@ module.exports = { return options.inverse(this); } }); + handlebars.registerHelper('t', function (key, options) { + let hash = options?.hash; + return thist(key, hash || options || {}); + }); } }; diff --git a/ghost/email-service/test/email-helpers.test.js b/ghost/email-service/test/email-helpers.test.js index 58afe2362b5..5349ffb23e3 100644 --- a/ghost/email-service/test/email-helpers.test.js +++ b/ghost/email-service/test/email-helpers.test.js @@ -1,6 +1,14 @@ const assert = require('assert/strict'); const {registerHelpers} = require('../lib/helpers/register-helpers'); +// load the i18n module +const i18nLib = require('@tryghost/i18n'); +const i18n = i18nLib('fr', 'newsletter'); + +const t = (key, options) => { + return i18n.t(key, options); +}; + describe('registerHelpers', function () { it('registers helpers', function () { const handlebars = { @@ -20,6 +28,7 @@ describe('registerHelpers', function () { assert.ok(handlebars.not); assert.ok(handlebars.or); assert.ok(handlebars.hasFeature); + assert.ok(handlebars.t); }); it('if helper returns true', function () { @@ -139,4 +148,72 @@ describe('registerHelpers', function () { assert.equal(result, false); }); + it('t helper returns key', function () { + const labs = { + isSet: function () { + return false; + } + }; + const handlebars = { + registerHelper: function (name, fn) { + this[name] = fn; + } + }; + + registerHelpers(handlebars, labs, t); + + const result = handlebars.t('test'); + assert.equal(result, 'test'); + }); + it('t helper returns translation', function () { + const labs = { + isSet: function () { + return false; + } + }; + const handlebars = { + registerHelper: function (name, fn) { + this[name] = fn; + } + }; + + registerHelpers(handlebars, labs, t); + + const result = handlebars.t('Name'); + assert.equal(result, 'Nom'); + }); + it('t helper returns translation with hash', function () { + const labs = { + isSet: function () { + return false; + } + }; + const handlebars = { + registerHelper: function (name, fn) { + this[name] = fn; + } + }; + + registerHelpers(handlebars, labs, t); + + const result = handlebars.t('By {authors}', {hash: {authors: 'fred'}}); + assert.equal(result, 'Par fred'); + }); + it('t helper returns translation with options', function () { + const labs = { + isSet: function () { + return false; + } + }; + const handlebars = { + registerHelper: function (name, fn) { + this[name] = fn; + } + }; + + registerHelpers(handlebars, labs, t); + + const result = handlebars.t('By {authors}', {authors: 'fred'}); + assert.equal(result, 'Par fred'); + }); }); diff --git a/ghost/email-service/test/email-renderer.test.js b/ghost/email-service/test/email-renderer.test.js index f8171ca858c..5dd0018ab7c 100644 --- a/ghost/email-service/test/email-renderer.test.js +++ b/ghost/email-service/test/email-renderer.test.js @@ -68,6 +68,21 @@ const createUnsubscribeUrl = (uuid) => { const getMembersValidationKey = () => { return 'members-key'; }; +// stub the t function so that we don't need to load the i18n module +// actually, no, this is a terrible option, because then we don't actually get interpolation. +// we should probably just load the i18n module + +// load the i18n module +const i18nLib = require('@tryghost/i18n'); +const i18n = i18nLib('en', 'newsletter'); +const t = (key, options) => { + return i18n.t(key, options); +}; + +const i18nFr = i18nLib('fr', 'newsletter'); +const tFr = (key, options) => { + return i18nFr.t(key, options); +}; describe('Email renderer', function () { let logStub; @@ -102,7 +117,8 @@ describe('Email renderer', function () { } } }, - settingsHelpers: {getMembersValidationKey,createUnsubscribeUrl} + settingsHelpers: {getMembersValidationKey,createUnsubscribeUrl}, + t: t }); newsletter = createModel({ uuid: 'newsletteruuid' @@ -114,7 +130,7 @@ describe('Email renderer', function () { email: 'test@example.com', createdAt: new Date(2023, 2, 13, 12, 0), status: 'free' - }; + }; }); it('returns the unsubscribe header replacement by default', function () { @@ -338,6 +354,108 @@ describe('Email renderer', function () { }); }); + describe('buildReplacementDefinitions with locales', function () { + let emailRenderer; + let newsletter; + let member; + let labsEnabled = true; + beforeEach(function () { + labsEnabled = false; + emailRenderer = new EmailRenderer({ + urlUtils: { + urlFor: () => 'http://example.com/subdirectory/' + }, + labs: { + isSet: () => labsEnabled + }, + settingsCache: { + get: (key) => { + if (key === 'timezone') { + return 'UTC'; + } + } + }, + settingsHelpers: {getMembersValidationKey,createUnsubscribeUrl}, + t: t + }); + newsletter = createModel({ + uuid: 'newsletteruuid' + }); + member = { + id: '456', + uuid: 'myuuid', + name: 'Test User', + email: 'test@example.com', + createdAt: new Date(2023, 2, 13, 12, 0), + status: 'free' + }; + }); + + it('handles dates when the locale is en-gb (default)', function () { + const html = '%%{created_at}%%'; + const replacements = emailRenderer.buildReplacementDefinitions({html, newsletterUuid: newsletter.get('uuid')}); + assert.equal(replacements.length, 2); + assert.equal(replacements[0].token.toString(), '/%%\\{created_at\\}%%/g'); + assert.equal(replacements[0].id, 'created_at'); + assert.equal(replacements[0].getValue(member), '13 March 2023'); + }); + it('handles dates when the locale is fr', function () { + emailRenderer = new EmailRenderer({ + urlUtils: { + urlFor: () => 'http://example.com/subdirectory/' + }, + labs: { + isSet: () => labsEnabled + }, + settingsCache: { + get: (key) => { + if (key === 'timezone') { + return 'UTC'; + } + if (key === 'locale') { + return 'fr'; + } + } + }, + settingsHelpers: {getMembersValidationKey,createUnsubscribeUrl}, + t: tFr + }); + const html = '%%{created_at}%%'; + const replacements = emailRenderer.buildReplacementDefinitions({html, newsletterUuid: newsletter.get('uuid')}); + assert.equal(replacements.length, 2); + assert.equal(replacements[0].token.toString(), '/%%\\{created_at\\}%%/g'); + assert.equal(replacements[0].id, 'created_at'); + assert.equal(replacements[0].getValue(member), '13 mars 2023'); + }); + it('handles dates when the locale is en (US)', function () { + emailRenderer = new EmailRenderer({ + urlUtils: { + urlFor: () => 'http://example.com/subdirectory/' + }, + labs: { + isSet: () => labsEnabled + }, + settingsCache: { + get: (key) => { + if (key === 'timezone') { + return 'UTC'; + } + if (key === 'locale') { + return 'en'; + } + } + }, + settingsHelpers: {getMembersValidationKey,createUnsubscribeUrl}, + t: t + }); + const html = '%%{created_at}%%'; + const replacements = emailRenderer.buildReplacementDefinitions({html, newsletterUuid: newsletter.get('uuid')}); + assert.equal(replacements.length, 2); + assert.equal(replacements[0].token.toString(), '/%%\\{created_at\\}%%/g'); + assert.equal(replacements[0].id, 'created_at'); + assert.equal(replacements[0].getValue(member), '13 March 2023'); + }); + }); describe('isMemberTrialing', function () { let emailRenderer; @@ -355,7 +473,8 @@ describe('Email renderer', function () { return 'UTC'; } } - } + }, + t: t }); }); @@ -458,7 +577,8 @@ describe('Email renderer', function () { return 'UTC'; } } - } + }, + t: t }); }); @@ -1059,7 +1179,8 @@ describe('Email renderer', function () { return labsEnabled; } - } + }, + t: t }); }); @@ -1258,7 +1379,6 @@ describe('Email renderer', function () { segment, options ); - assert.match(response.html, /By A & 2 others/); assert.match(response.plaintext, /By A & 2 others/); }); @@ -1470,8 +1590,6 @@ describe('Email renderer', function () { '#', `http://feedback-link.com/?score=1&uuid=%%{uuid}%%&key=%%{key}%%`, `http://feedback-link.com/?score=0&uuid=%%{uuid}%%&key=%%{key}%%`, - `http://feedback-link.com/?score=1&uuid=%%{uuid}%%&key=%%{key}%%`, - `http://feedback-link.com/?score=0&uuid=%%{uuid}%%&key=%%{key}%%`, `%%{unsubscribe_url}%%`, `https://ghost.org/?via=pbg-newsletter&source_tracking=site` ]); @@ -1527,8 +1645,6 @@ describe('Email renderer', function () { '#', 'http://feedback-link.com/?score=1&uuid=%%{uuid}%%&key=%%{key}%%', 'http://feedback-link.com/?score=0&uuid=%%{uuid}%%&key=%%{key}%%', - 'http://feedback-link.com/?score=1&uuid=%%{uuid}%%&key=%%{key}%%', - 'http://feedback-link.com/?score=0&uuid=%%{uuid}%%&key=%%{key}%%', '%%{unsubscribe_url}%%', 'https://ghost.org/?via=pbg-newsletter' ]); @@ -1584,8 +1700,6 @@ describe('Email renderer', function () { `http://tracked-link.com/?m=%%{uuid}%%&url=https%3A%2F%2Fexample.com%2F%3Fref%3D123%26source_tracking%3DTest%2BNewsletter%26post_tracking%3Dadded`, `http://feedback-link.com/?score=1&uuid=%%{uuid}%%&key=%%{key}%%`, `http://feedback-link.com/?score=0&uuid=%%{uuid}%%&key=%%{key}%%`, - `http://feedback-link.com/?score=1&uuid=%%{uuid}%%&key=%%{key}%%`, - `http://feedback-link.com/?score=0&uuid=%%{uuid}%%&key=%%{key}%%`, `%%{unsubscribe_url}%%`, `https://ghost.org/?via=pbg-newsletter&source_tracking=site` ]); @@ -1924,7 +2038,8 @@ describe('Email renderer', function () { } ] }) - } + }, + t: t }); }); diff --git a/ghost/i18n/lib/i18n.js b/ghost/i18n/lib/i18n.js index 6bac99fb224..8b8886ebc6f 100644 --- a/ghost/i18n/lib/i18n.js +++ b/ghost/i18n/lib/i18n.js @@ -62,10 +62,40 @@ const SUPPORTED_LOCALES = [ /** * @param {string} [lng] - * @param {'ghost'|'portal'|'test'|'signup-form'|'comments'|'search'} ns + * @param {'ghost'|'portal'|'test'|'signup-form'|'comments'|'search'|'newsletter'|'theme'} ns */ module.exports = (lng = 'en', ns = 'portal') => { const i18nextInstance = i18next.createInstance(); + let interpolation = {}; + if (ns === 'newsletter') { + interpolation = { + prefix: '{', + suffix: '}' + }; + } + let resources; + if (ns !== 'theme') { + resources = SUPPORTED_LOCALES.reduce((acc, locale) => { + const res = require(`../locales/${locale}/${ns}.json`); + + // Note: due some random thing in TypeScript, 'requiring' a JSON file with a space in a key name, only adds it to the default export + // If changing this behaviour, please also check the comments and signup-form apps in another language (mainly sentences with a space in them) + acc[locale] = { + [ns]: {...res, ...(res.default && typeof res.default === 'object' ? res.default : {})} + }; + return acc; + }, {}); + } else { + resources = { + "en": { + "theme": require(`../locales/en/theme.json`) + }, + "fr": { + "theme": require(`../locales/fr/theme.json`) + } + } + } + i18nextInstance.init({ lng, @@ -82,16 +112,10 @@ module.exports = (lng = 'en', ns = 'portal') => { ns: ns, defaultNS: ns, - resources: SUPPORTED_LOCALES.reduce((acc, locale) => { - const res = require(`../locales/${locale}/${ns}.json`); + // separators + interpolation, - // Note: due some random thing in TypeScript, 'requiring' a JSON file with a space in a key name, only adds it to the default export - // If changing this behaviour, please also check the comments and signup-form apps in another language (mainly sentences with a space in them) - acc[locale] = { - [ns]: {...res, ...(res.default && typeof res.default === 'object' ? res.default : {})} - }; - return acc; - }, {}) + resources }); return i18nextInstance; diff --git a/ghost/i18n/locales/af/newsletter.json b/ghost/i18n/locales/af/newsletter.json new file mode 100644 index 00000000000..bd24d1dc525 --- /dev/null +++ b/ghost/i18n/locales/af/newsletter.json @@ -0,0 +1,24 @@ +{ + "By {authors}": "", + "Comment": "", + "complimentary": "", + "Email": "", + "free": "", + "Keep reading": "", + "Less like this": "", + "Manage subscription": "", + "Member since": "", + "More like this": "", + "Name": "", + "paid": "", + "Subscription details": "", + "trialing": "", + "Unsubscribe": "", + "View in browser": "", + "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", + "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", + "Your subscription has expired.": "", + "Your subscription will expire on {date}.": "", + "Your subscription will renew on {date}.": "" +} diff --git a/ghost/i18n/locales/ar/newsletter.json b/ghost/i18n/locales/ar/newsletter.json new file mode 100644 index 00000000000..bd24d1dc525 --- /dev/null +++ b/ghost/i18n/locales/ar/newsletter.json @@ -0,0 +1,24 @@ +{ + "By {authors}": "", + "Comment": "", + "complimentary": "", + "Email": "", + "free": "", + "Keep reading": "", + "Less like this": "", + "Manage subscription": "", + "Member since": "", + "More like this": "", + "Name": "", + "paid": "", + "Subscription details": "", + "trialing": "", + "Unsubscribe": "", + "View in browser": "", + "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", + "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", + "Your subscription has expired.": "", + "Your subscription will expire on {date}.": "", + "Your subscription will renew on {date}.": "" +} diff --git a/ghost/i18n/locales/bg/newsletter.json b/ghost/i18n/locales/bg/newsletter.json new file mode 100644 index 00000000000..bd24d1dc525 --- /dev/null +++ b/ghost/i18n/locales/bg/newsletter.json @@ -0,0 +1,24 @@ +{ + "By {authors}": "", + "Comment": "", + "complimentary": "", + "Email": "", + "free": "", + "Keep reading": "", + "Less like this": "", + "Manage subscription": "", + "Member since": "", + "More like this": "", + "Name": "", + "paid": "", + "Subscription details": "", + "trialing": "", + "Unsubscribe": "", + "View in browser": "", + "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", + "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", + "Your subscription has expired.": "", + "Your subscription will expire on {date}.": "", + "Your subscription will renew on {date}.": "" +} diff --git a/ghost/i18n/locales/bn/newsletter.json b/ghost/i18n/locales/bn/newsletter.json new file mode 100644 index 00000000000..bd24d1dc525 --- /dev/null +++ b/ghost/i18n/locales/bn/newsletter.json @@ -0,0 +1,24 @@ +{ + "By {authors}": "", + "Comment": "", + "complimentary": "", + "Email": "", + "free": "", + "Keep reading": "", + "Less like this": "", + "Manage subscription": "", + "Member since": "", + "More like this": "", + "Name": "", + "paid": "", + "Subscription details": "", + "trialing": "", + "Unsubscribe": "", + "View in browser": "", + "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", + "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", + "Your subscription has expired.": "", + "Your subscription will expire on {date}.": "", + "Your subscription will renew on {date}.": "" +} diff --git a/ghost/i18n/locales/bs/newsletter.json b/ghost/i18n/locales/bs/newsletter.json new file mode 100644 index 00000000000..bd24d1dc525 --- /dev/null +++ b/ghost/i18n/locales/bs/newsletter.json @@ -0,0 +1,24 @@ +{ + "By {authors}": "", + "Comment": "", + "complimentary": "", + "Email": "", + "free": "", + "Keep reading": "", + "Less like this": "", + "Manage subscription": "", + "Member since": "", + "More like this": "", + "Name": "", + "paid": "", + "Subscription details": "", + "trialing": "", + "Unsubscribe": "", + "View in browser": "", + "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", + "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", + "Your subscription has expired.": "", + "Your subscription will expire on {date}.": "", + "Your subscription will renew on {date}.": "" +} diff --git a/ghost/i18n/locales/ca/newsletter.json b/ghost/i18n/locales/ca/newsletter.json new file mode 100644 index 00000000000..bd24d1dc525 --- /dev/null +++ b/ghost/i18n/locales/ca/newsletter.json @@ -0,0 +1,24 @@ +{ + "By {authors}": "", + "Comment": "", + "complimentary": "", + "Email": "", + "free": "", + "Keep reading": "", + "Less like this": "", + "Manage subscription": "", + "Member since": "", + "More like this": "", + "Name": "", + "paid": "", + "Subscription details": "", + "trialing": "", + "Unsubscribe": "", + "View in browser": "", + "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", + "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", + "Your subscription has expired.": "", + "Your subscription will expire on {date}.": "", + "Your subscription will renew on {date}.": "" +} diff --git a/ghost/i18n/locales/context.json b/ghost/i18n/locales/context.json index 70430f00e75..f1351a98bc6 100644 --- a/ghost/i18n/locales/context.json +++ b/ghost/i18n/locales/context.json @@ -22,6 +22,7 @@ "Become a paid member of {{publication}} to start commenting.": "A call to action letting people know they need to become paid members before commenting", "Billing info": "A label for the user billing information section", "Black Friday": "An example offer name", + "By {authors}": "", "Cancel": "Button text", "Cancel anytime.": "A label explaining that the trial can be cancelled at any time", "Cancel subscription": "A button to cancel a paid subscription", @@ -123,12 +124,15 @@ "Jamie Larson": "An unisex name of a person we use in examples", "Join the discussion": "Placeholder value of the comments input box", "Just now": "Time indication when a comment has been posted 'just now'", + "Keep reading": "", "Less like this": "A label for the thumbs-down response in member feedback at the bottom of emails", "Local resident": "Example of an expertise of a person used in comments when editing your expertise", "Make sure emails aren't accidentally ending up in the Spam or Promotions folders of your inbox. If they are, click on \"Mark as not spam\" and/or \"Move to inbox\".": "Paragraph in the email receiving FAQ", "Manage": "A button for managing settings", + "Manage subscription": "", "Maybe later": "An informal phrase to dismiss or close a popup", "Member discussion": "Default title for the comments section on a post", + "Member since": "", "Memberships unavailable, contact the owner for access.": "Inform a user that memberships are only available by contacting the site owner", "Monthly": "A label to indicate a monthly payment cadence", "More like this": "A label for the thumbs-up response in member feedback at the bottom of emails", @@ -207,6 +211,7 @@ "Submit feedback": "A button for submitting member feedback", "Subscribe": "Title of a section for subscribing to a newsletter", "Subscribed": "Status of a newsletter which a member has subscribed to", + "Subscription details": "", "Subscription plan updated successfully": "", "Success": "Status indicator for a notification", "Success! Check your email for magic link to sign-in.": "Notification text when the user has been sent a magic link to sign-in", @@ -246,6 +251,7 @@ "Try free for {{amount}} days, then {{originalPrice}}.": "A label for an offer with a free trial", "Unable to initiate checkout session": "", "Unlock access to all newsletters by becoming a paid subscriber.": "A message to encourage members to upgrade to a paid subscription", + "Unsubscribe": "", "Unsubscribe from all emails": "A button on the unsubscribe page, offering a shortcut to unsubscribe from every newsletter at the same time", "Unsubscribed": "Status of a newsletter which a user has not subscribed to", "Unsubscribed from all emails.": "", @@ -255,6 +261,7 @@ "Upgrade now": "Button text in the comments section, when you need to be a paid member in order to post a comment", "Verification link sent, check your inbox": "Instruction to check email for recommendation verification", "Verify your email address is correct": "A section title in the email receiving FAQ", + "View in browser": "", "View plans": "A button to view available plans", "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "An error message when an unsubscribe link is clicked, but we don't have any record of the address being unsubscribed. Probably because the email address on the account has been changed.", "Welcome back to {{siteTitle}}!": "A login confirmation message", @@ -266,6 +273,7 @@ "Why has my email been disabled?": "A section title from the email suppression FAQ", "Yearly": "A label indicating an annual payment cadence", "Yesterday": "Time a comment was placed", + "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", "You can also copy & paste this URL into your browser:": "Descriptive text displayed underneath the buttons in login/signup emails, right before authentication URLs which can be copy/pasted", "You currently have a free membership, upgrade to a paid subscription for full access.": "A message indicating that the member is using a free subcription, and could access more content with a paid subscription", "You have been successfully resubscribed": "A confirmation message when a member has had emails turned off, but they have been successfully turned back on", @@ -281,14 +289,23 @@ "Your account": "A label indicating member account details", "Your email address": "Placeholder text in an input field", "Your email has failed to resubscribe, please try again": "", + "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", "Your input helps shape what gets published.": "Descriptive text displayed on the member feedback UI, telling people how their feedback is used", "Your request will be sent to the owner of this site.": "Descriptive text displayed in the report comment modal", + "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", + "Your subscription has expired.": "", + "Your subscription will expire on {date}.": "", "Your subscription will expire on {{expiryDate}}": "A message indicating when the member's subscription will expire", + "Your subscription will renew on {date}.": "", "Your subscription will renew on {{renewalDate}}": "A message indicating when the member's subscription will be renewed", "Your subscription will start on {{subscriptionStart}}": "A message for trial users indicating when their subscription will start", + "complimentary": "", "edited": "", + "free": "", "jamie@example.com": "Placeholder for email input field", "month": "the subscription interval (monthly), following the /", + "paid": "", + "trialing": "", "year": "the subscription interval (monthly), following the /", "{{amount}} characters left": "Characters left, shown above the input field, when editing your expertise in the comments app", "{{amount}} comments": "Amount of comments on a post", diff --git a/ghost/i18n/locales/cs/newsletter.json b/ghost/i18n/locales/cs/newsletter.json new file mode 100644 index 00000000000..bd24d1dc525 --- /dev/null +++ b/ghost/i18n/locales/cs/newsletter.json @@ -0,0 +1,24 @@ +{ + "By {authors}": "", + "Comment": "", + "complimentary": "", + "Email": "", + "free": "", + "Keep reading": "", + "Less like this": "", + "Manage subscription": "", + "Member since": "", + "More like this": "", + "Name": "", + "paid": "", + "Subscription details": "", + "trialing": "", + "Unsubscribe": "", + "View in browser": "", + "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", + "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", + "Your subscription has expired.": "", + "Your subscription will expire on {date}.": "", + "Your subscription will renew on {date}.": "" +} diff --git a/ghost/i18n/locales/da/newsletter.json b/ghost/i18n/locales/da/newsletter.json new file mode 100644 index 00000000000..bd24d1dc525 --- /dev/null +++ b/ghost/i18n/locales/da/newsletter.json @@ -0,0 +1,24 @@ +{ + "By {authors}": "", + "Comment": "", + "complimentary": "", + "Email": "", + "free": "", + "Keep reading": "", + "Less like this": "", + "Manage subscription": "", + "Member since": "", + "More like this": "", + "Name": "", + "paid": "", + "Subscription details": "", + "trialing": "", + "Unsubscribe": "", + "View in browser": "", + "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", + "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", + "Your subscription has expired.": "", + "Your subscription will expire on {date}.": "", + "Your subscription will renew on {date}.": "" +} diff --git a/ghost/i18n/locales/de-CH/newsletter.json b/ghost/i18n/locales/de-CH/newsletter.json new file mode 100644 index 00000000000..bd24d1dc525 --- /dev/null +++ b/ghost/i18n/locales/de-CH/newsletter.json @@ -0,0 +1,24 @@ +{ + "By {authors}": "", + "Comment": "", + "complimentary": "", + "Email": "", + "free": "", + "Keep reading": "", + "Less like this": "", + "Manage subscription": "", + "Member since": "", + "More like this": "", + "Name": "", + "paid": "", + "Subscription details": "", + "trialing": "", + "Unsubscribe": "", + "View in browser": "", + "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", + "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", + "Your subscription has expired.": "", + "Your subscription will expire on {date}.": "", + "Your subscription will renew on {date}.": "" +} diff --git a/ghost/i18n/locales/de/newsletter.json b/ghost/i18n/locales/de/newsletter.json new file mode 100644 index 00000000000..bd24d1dc525 --- /dev/null +++ b/ghost/i18n/locales/de/newsletter.json @@ -0,0 +1,24 @@ +{ + "By {authors}": "", + "Comment": "", + "complimentary": "", + "Email": "", + "free": "", + "Keep reading": "", + "Less like this": "", + "Manage subscription": "", + "Member since": "", + "More like this": "", + "Name": "", + "paid": "", + "Subscription details": "", + "trialing": "", + "Unsubscribe": "", + "View in browser": "", + "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", + "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", + "Your subscription has expired.": "", + "Your subscription will expire on {date}.": "", + "Your subscription will renew on {date}.": "" +} diff --git a/ghost/i18n/locales/el/newsletter.json b/ghost/i18n/locales/el/newsletter.json new file mode 100644 index 00000000000..bd24d1dc525 --- /dev/null +++ b/ghost/i18n/locales/el/newsletter.json @@ -0,0 +1,24 @@ +{ + "By {authors}": "", + "Comment": "", + "complimentary": "", + "Email": "", + "free": "", + "Keep reading": "", + "Less like this": "", + "Manage subscription": "", + "Member since": "", + "More like this": "", + "Name": "", + "paid": "", + "Subscription details": "", + "trialing": "", + "Unsubscribe": "", + "View in browser": "", + "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", + "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", + "Your subscription has expired.": "", + "Your subscription will expire on {date}.": "", + "Your subscription will renew on {date}.": "" +} diff --git a/ghost/i18n/locales/en/newsletter.json b/ghost/i18n/locales/en/newsletter.json new file mode 100644 index 00000000000..bd24d1dc525 --- /dev/null +++ b/ghost/i18n/locales/en/newsletter.json @@ -0,0 +1,24 @@ +{ + "By {authors}": "", + "Comment": "", + "complimentary": "", + "Email": "", + "free": "", + "Keep reading": "", + "Less like this": "", + "Manage subscription": "", + "Member since": "", + "More like this": "", + "Name": "", + "paid": "", + "Subscription details": "", + "trialing": "", + "Unsubscribe": "", + "View in browser": "", + "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", + "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", + "Your subscription has expired.": "", + "Your subscription will expire on {date}.": "", + "Your subscription will renew on {date}.": "" +} diff --git a/ghost/i18n/locales/en/theme.json b/ghost/i18n/locales/en/theme.json new file mode 100644 index 00000000000..f17ed711e3b --- /dev/null +++ b/ghost/i18n/locales/en/theme.json @@ -0,0 +1,3 @@ +{ + "Read more": "Read more" +} \ No newline at end of file diff --git a/ghost/i18n/locales/eo/newsletter.json b/ghost/i18n/locales/eo/newsletter.json new file mode 100644 index 00000000000..bd24d1dc525 --- /dev/null +++ b/ghost/i18n/locales/eo/newsletter.json @@ -0,0 +1,24 @@ +{ + "By {authors}": "", + "Comment": "", + "complimentary": "", + "Email": "", + "free": "", + "Keep reading": "", + "Less like this": "", + "Manage subscription": "", + "Member since": "", + "More like this": "", + "Name": "", + "paid": "", + "Subscription details": "", + "trialing": "", + "Unsubscribe": "", + "View in browser": "", + "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", + "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", + "Your subscription has expired.": "", + "Your subscription will expire on {date}.": "", + "Your subscription will renew on {date}.": "" +} diff --git a/ghost/i18n/locales/es/newsletter.json b/ghost/i18n/locales/es/newsletter.json new file mode 100644 index 00000000000..bd24d1dc525 --- /dev/null +++ b/ghost/i18n/locales/es/newsletter.json @@ -0,0 +1,24 @@ +{ + "By {authors}": "", + "Comment": "", + "complimentary": "", + "Email": "", + "free": "", + "Keep reading": "", + "Less like this": "", + "Manage subscription": "", + "Member since": "", + "More like this": "", + "Name": "", + "paid": "", + "Subscription details": "", + "trialing": "", + "Unsubscribe": "", + "View in browser": "", + "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", + "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", + "Your subscription has expired.": "", + "Your subscription will expire on {date}.": "", + "Your subscription will renew on {date}.": "" +} diff --git a/ghost/i18n/locales/et/newsletter.json b/ghost/i18n/locales/et/newsletter.json new file mode 100644 index 00000000000..bd24d1dc525 --- /dev/null +++ b/ghost/i18n/locales/et/newsletter.json @@ -0,0 +1,24 @@ +{ + "By {authors}": "", + "Comment": "", + "complimentary": "", + "Email": "", + "free": "", + "Keep reading": "", + "Less like this": "", + "Manage subscription": "", + "Member since": "", + "More like this": "", + "Name": "", + "paid": "", + "Subscription details": "", + "trialing": "", + "Unsubscribe": "", + "View in browser": "", + "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", + "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", + "Your subscription has expired.": "", + "Your subscription will expire on {date}.": "", + "Your subscription will renew on {date}.": "" +} diff --git a/ghost/i18n/locales/fa/newsletter.json b/ghost/i18n/locales/fa/newsletter.json new file mode 100644 index 00000000000..bd24d1dc525 --- /dev/null +++ b/ghost/i18n/locales/fa/newsletter.json @@ -0,0 +1,24 @@ +{ + "By {authors}": "", + "Comment": "", + "complimentary": "", + "Email": "", + "free": "", + "Keep reading": "", + "Less like this": "", + "Manage subscription": "", + "Member since": "", + "More like this": "", + "Name": "", + "paid": "", + "Subscription details": "", + "trialing": "", + "Unsubscribe": "", + "View in browser": "", + "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", + "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", + "Your subscription has expired.": "", + "Your subscription will expire on {date}.": "", + "Your subscription will renew on {date}.": "" +} diff --git a/ghost/i18n/locales/fi/newsletter.json b/ghost/i18n/locales/fi/newsletter.json new file mode 100644 index 00000000000..bd24d1dc525 --- /dev/null +++ b/ghost/i18n/locales/fi/newsletter.json @@ -0,0 +1,24 @@ +{ + "By {authors}": "", + "Comment": "", + "complimentary": "", + "Email": "", + "free": "", + "Keep reading": "", + "Less like this": "", + "Manage subscription": "", + "Member since": "", + "More like this": "", + "Name": "", + "paid": "", + "Subscription details": "", + "trialing": "", + "Unsubscribe": "", + "View in browser": "", + "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", + "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", + "Your subscription has expired.": "", + "Your subscription will expire on {date}.": "", + "Your subscription will renew on {date}.": "" +} diff --git a/ghost/i18n/locales/fr/newsletter.json b/ghost/i18n/locales/fr/newsletter.json new file mode 100644 index 00000000000..7f8fc23df23 --- /dev/null +++ b/ghost/i18n/locales/fr/newsletter.json @@ -0,0 +1,24 @@ +{ + "By {authors}": "Par {authors}", + "Comment": "Commentaire", + "complimentary": "", + "Email": "E-mail", + "free": "gratuit", + "Keep reading": "Continuer la lecture", + "Less like this": "Moins de ce genre", + "Manage subscription": "Gérer l'abonnement", + "Member since": "Membre depuis", + "More like this": "Plus de ce genre", + "Name": "Nom", + "paid": "payé", + "Subscription details": "Détails de l'abonnement", + "trialing": "en essai gratuit", + "Unsubscribe": "Se désabonner", + "View in browser": "Voir dans le navigateur", + "You are receiving this because you are a %%{status}%% subscriber to {site}.": "Vous recevez ceci parce que vous êtes un(e) abonné(e) %%{status}%% de {site}.", + "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "Votre essai gratuit se termine le {date}, après quoi le prix normal vous sera facturé. Vous pouvez toujours annuler avant cette date.", + "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "Votre abonnement a été annulé et expirera le {date}. Vous pouvez reprendre votre abonnement via les paramètres de votre compte.", + "Your subscription has expired.": "Votre abonnement a expiré.", + "Your subscription will expire on {date}.": "Votre abonnement expirera le {date}.", + "Your subscription will renew on {date}.": "Votre abonnement sera renouvelé le {date}." +} diff --git a/ghost/i18n/locales/fr/theme.json b/ghost/i18n/locales/fr/theme.json new file mode 100644 index 00000000000..362e6b07114 --- /dev/null +++ b/ghost/i18n/locales/fr/theme.json @@ -0,0 +1,3 @@ +{ + "Read more": "Lirer plus" +} \ No newline at end of file diff --git a/ghost/i18n/locales/gd/newsletter.json b/ghost/i18n/locales/gd/newsletter.json new file mode 100644 index 00000000000..bd24d1dc525 --- /dev/null +++ b/ghost/i18n/locales/gd/newsletter.json @@ -0,0 +1,24 @@ +{ + "By {authors}": "", + "Comment": "", + "complimentary": "", + "Email": "", + "free": "", + "Keep reading": "", + "Less like this": "", + "Manage subscription": "", + "Member since": "", + "More like this": "", + "Name": "", + "paid": "", + "Subscription details": "", + "trialing": "", + "Unsubscribe": "", + "View in browser": "", + "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", + "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", + "Your subscription has expired.": "", + "Your subscription will expire on {date}.": "", + "Your subscription will renew on {date}.": "" +} diff --git a/ghost/i18n/locales/hi/newsletter.json b/ghost/i18n/locales/hi/newsletter.json new file mode 100644 index 00000000000..bd24d1dc525 --- /dev/null +++ b/ghost/i18n/locales/hi/newsletter.json @@ -0,0 +1,24 @@ +{ + "By {authors}": "", + "Comment": "", + "complimentary": "", + "Email": "", + "free": "", + "Keep reading": "", + "Less like this": "", + "Manage subscription": "", + "Member since": "", + "More like this": "", + "Name": "", + "paid": "", + "Subscription details": "", + "trialing": "", + "Unsubscribe": "", + "View in browser": "", + "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", + "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", + "Your subscription has expired.": "", + "Your subscription will expire on {date}.": "", + "Your subscription will renew on {date}.": "" +} diff --git a/ghost/i18n/locales/hr/newsletter.json b/ghost/i18n/locales/hr/newsletter.json new file mode 100644 index 00000000000..bd24d1dc525 --- /dev/null +++ b/ghost/i18n/locales/hr/newsletter.json @@ -0,0 +1,24 @@ +{ + "By {authors}": "", + "Comment": "", + "complimentary": "", + "Email": "", + "free": "", + "Keep reading": "", + "Less like this": "", + "Manage subscription": "", + "Member since": "", + "More like this": "", + "Name": "", + "paid": "", + "Subscription details": "", + "trialing": "", + "Unsubscribe": "", + "View in browser": "", + "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", + "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", + "Your subscription has expired.": "", + "Your subscription will expire on {date}.": "", + "Your subscription will renew on {date}.": "" +} diff --git a/ghost/i18n/locales/hu/newsletter.json b/ghost/i18n/locales/hu/newsletter.json new file mode 100644 index 00000000000..bd24d1dc525 --- /dev/null +++ b/ghost/i18n/locales/hu/newsletter.json @@ -0,0 +1,24 @@ +{ + "By {authors}": "", + "Comment": "", + "complimentary": "", + "Email": "", + "free": "", + "Keep reading": "", + "Less like this": "", + "Manage subscription": "", + "Member since": "", + "More like this": "", + "Name": "", + "paid": "", + "Subscription details": "", + "trialing": "", + "Unsubscribe": "", + "View in browser": "", + "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", + "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", + "Your subscription has expired.": "", + "Your subscription will expire on {date}.": "", + "Your subscription will renew on {date}.": "" +} diff --git a/ghost/i18n/locales/hu/search.json b/ghost/i18n/locales/hu/search.json index bb3a1948ba7..f5e43a43a2e 100644 --- a/ghost/i18n/locales/hu/search.json +++ b/ghost/i18n/locales/hu/search.json @@ -6,4 +6,4 @@ "Search posts, tags and authors": "Keresés bejegyzések, címkék és szerzők között", "Show more results": "További találatok megjelenítése", "Tags": "Címkék" -} \ No newline at end of file +} diff --git a/ghost/i18n/locales/id/newsletter.json b/ghost/i18n/locales/id/newsletter.json new file mode 100644 index 00000000000..bd24d1dc525 --- /dev/null +++ b/ghost/i18n/locales/id/newsletter.json @@ -0,0 +1,24 @@ +{ + "By {authors}": "", + "Comment": "", + "complimentary": "", + "Email": "", + "free": "", + "Keep reading": "", + "Less like this": "", + "Manage subscription": "", + "Member since": "", + "More like this": "", + "Name": "", + "paid": "", + "Subscription details": "", + "trialing": "", + "Unsubscribe": "", + "View in browser": "", + "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", + "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", + "Your subscription has expired.": "", + "Your subscription will expire on {date}.": "", + "Your subscription will renew on {date}.": "" +} diff --git a/ghost/i18n/locales/is/newsletter.json b/ghost/i18n/locales/is/newsletter.json new file mode 100644 index 00000000000..bd24d1dc525 --- /dev/null +++ b/ghost/i18n/locales/is/newsletter.json @@ -0,0 +1,24 @@ +{ + "By {authors}": "", + "Comment": "", + "complimentary": "", + "Email": "", + "free": "", + "Keep reading": "", + "Less like this": "", + "Manage subscription": "", + "Member since": "", + "More like this": "", + "Name": "", + "paid": "", + "Subscription details": "", + "trialing": "", + "Unsubscribe": "", + "View in browser": "", + "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", + "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", + "Your subscription has expired.": "", + "Your subscription will expire on {date}.": "", + "Your subscription will renew on {date}.": "" +} diff --git a/ghost/i18n/locales/it/newsletter.json b/ghost/i18n/locales/it/newsletter.json new file mode 100644 index 00000000000..bd24d1dc525 --- /dev/null +++ b/ghost/i18n/locales/it/newsletter.json @@ -0,0 +1,24 @@ +{ + "By {authors}": "", + "Comment": "", + "complimentary": "", + "Email": "", + "free": "", + "Keep reading": "", + "Less like this": "", + "Manage subscription": "", + "Member since": "", + "More like this": "", + "Name": "", + "paid": "", + "Subscription details": "", + "trialing": "", + "Unsubscribe": "", + "View in browser": "", + "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", + "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", + "Your subscription has expired.": "", + "Your subscription will expire on {date}.": "", + "Your subscription will renew on {date}.": "" +} diff --git a/ghost/i18n/locales/ja/newsletter.json b/ghost/i18n/locales/ja/newsletter.json new file mode 100644 index 00000000000..bd24d1dc525 --- /dev/null +++ b/ghost/i18n/locales/ja/newsletter.json @@ -0,0 +1,24 @@ +{ + "By {authors}": "", + "Comment": "", + "complimentary": "", + "Email": "", + "free": "", + "Keep reading": "", + "Less like this": "", + "Manage subscription": "", + "Member since": "", + "More like this": "", + "Name": "", + "paid": "", + "Subscription details": "", + "trialing": "", + "Unsubscribe": "", + "View in browser": "", + "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", + "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", + "Your subscription has expired.": "", + "Your subscription will expire on {date}.": "", + "Your subscription will renew on {date}.": "" +} diff --git a/ghost/i18n/locales/ko/newsletter.json b/ghost/i18n/locales/ko/newsletter.json new file mode 100644 index 00000000000..bd24d1dc525 --- /dev/null +++ b/ghost/i18n/locales/ko/newsletter.json @@ -0,0 +1,24 @@ +{ + "By {authors}": "", + "Comment": "", + "complimentary": "", + "Email": "", + "free": "", + "Keep reading": "", + "Less like this": "", + "Manage subscription": "", + "Member since": "", + "More like this": "", + "Name": "", + "paid": "", + "Subscription details": "", + "trialing": "", + "Unsubscribe": "", + "View in browser": "", + "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", + "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", + "Your subscription has expired.": "", + "Your subscription will expire on {date}.": "", + "Your subscription will renew on {date}.": "" +} diff --git a/ghost/i18n/locales/kz/newsletter.json b/ghost/i18n/locales/kz/newsletter.json new file mode 100644 index 00000000000..bd24d1dc525 --- /dev/null +++ b/ghost/i18n/locales/kz/newsletter.json @@ -0,0 +1,24 @@ +{ + "By {authors}": "", + "Comment": "", + "complimentary": "", + "Email": "", + "free": "", + "Keep reading": "", + "Less like this": "", + "Manage subscription": "", + "Member since": "", + "More like this": "", + "Name": "", + "paid": "", + "Subscription details": "", + "trialing": "", + "Unsubscribe": "", + "View in browser": "", + "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", + "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", + "Your subscription has expired.": "", + "Your subscription will expire on {date}.": "", + "Your subscription will renew on {date}.": "" +} diff --git a/ghost/i18n/locales/lt/newsletter.json b/ghost/i18n/locales/lt/newsletter.json new file mode 100644 index 00000000000..bd24d1dc525 --- /dev/null +++ b/ghost/i18n/locales/lt/newsletter.json @@ -0,0 +1,24 @@ +{ + "By {authors}": "", + "Comment": "", + "complimentary": "", + "Email": "", + "free": "", + "Keep reading": "", + "Less like this": "", + "Manage subscription": "", + "Member since": "", + "More like this": "", + "Name": "", + "paid": "", + "Subscription details": "", + "trialing": "", + "Unsubscribe": "", + "View in browser": "", + "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", + "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", + "Your subscription has expired.": "", + "Your subscription will expire on {date}.": "", + "Your subscription will renew on {date}.": "" +} diff --git a/ghost/i18n/locales/mk/newsletter.json b/ghost/i18n/locales/mk/newsletter.json new file mode 100644 index 00000000000..bd24d1dc525 --- /dev/null +++ b/ghost/i18n/locales/mk/newsletter.json @@ -0,0 +1,24 @@ +{ + "By {authors}": "", + "Comment": "", + "complimentary": "", + "Email": "", + "free": "", + "Keep reading": "", + "Less like this": "", + "Manage subscription": "", + "Member since": "", + "More like this": "", + "Name": "", + "paid": "", + "Subscription details": "", + "trialing": "", + "Unsubscribe": "", + "View in browser": "", + "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", + "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", + "Your subscription has expired.": "", + "Your subscription will expire on {date}.": "", + "Your subscription will renew on {date}.": "" +} diff --git a/ghost/i18n/locales/mn/newsletter.json b/ghost/i18n/locales/mn/newsletter.json new file mode 100644 index 00000000000..bd24d1dc525 --- /dev/null +++ b/ghost/i18n/locales/mn/newsletter.json @@ -0,0 +1,24 @@ +{ + "By {authors}": "", + "Comment": "", + "complimentary": "", + "Email": "", + "free": "", + "Keep reading": "", + "Less like this": "", + "Manage subscription": "", + "Member since": "", + "More like this": "", + "Name": "", + "paid": "", + "Subscription details": "", + "trialing": "", + "Unsubscribe": "", + "View in browser": "", + "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", + "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", + "Your subscription has expired.": "", + "Your subscription will expire on {date}.": "", + "Your subscription will renew on {date}.": "" +} diff --git a/ghost/i18n/locales/ms/newsletter.json b/ghost/i18n/locales/ms/newsletter.json new file mode 100644 index 00000000000..bd24d1dc525 --- /dev/null +++ b/ghost/i18n/locales/ms/newsletter.json @@ -0,0 +1,24 @@ +{ + "By {authors}": "", + "Comment": "", + "complimentary": "", + "Email": "", + "free": "", + "Keep reading": "", + "Less like this": "", + "Manage subscription": "", + "Member since": "", + "More like this": "", + "Name": "", + "paid": "", + "Subscription details": "", + "trialing": "", + "Unsubscribe": "", + "View in browser": "", + "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", + "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", + "Your subscription has expired.": "", + "Your subscription will expire on {date}.": "", + "Your subscription will renew on {date}.": "" +} diff --git a/ghost/i18n/locales/nl/newsletter.json b/ghost/i18n/locales/nl/newsletter.json new file mode 100644 index 00000000000..bd24d1dc525 --- /dev/null +++ b/ghost/i18n/locales/nl/newsletter.json @@ -0,0 +1,24 @@ +{ + "By {authors}": "", + "Comment": "", + "complimentary": "", + "Email": "", + "free": "", + "Keep reading": "", + "Less like this": "", + "Manage subscription": "", + "Member since": "", + "More like this": "", + "Name": "", + "paid": "", + "Subscription details": "", + "trialing": "", + "Unsubscribe": "", + "View in browser": "", + "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", + "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", + "Your subscription has expired.": "", + "Your subscription will expire on {date}.": "", + "Your subscription will renew on {date}.": "" +} diff --git a/ghost/i18n/locales/nn/newsletter.json b/ghost/i18n/locales/nn/newsletter.json new file mode 100644 index 00000000000..bd24d1dc525 --- /dev/null +++ b/ghost/i18n/locales/nn/newsletter.json @@ -0,0 +1,24 @@ +{ + "By {authors}": "", + "Comment": "", + "complimentary": "", + "Email": "", + "free": "", + "Keep reading": "", + "Less like this": "", + "Manage subscription": "", + "Member since": "", + "More like this": "", + "Name": "", + "paid": "", + "Subscription details": "", + "trialing": "", + "Unsubscribe": "", + "View in browser": "", + "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", + "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", + "Your subscription has expired.": "", + "Your subscription will expire on {date}.": "", + "Your subscription will renew on {date}.": "" +} diff --git a/ghost/i18n/locales/no/comments.json b/ghost/i18n/locales/no/comments.json index b39c5095f9b..f488ff762af 100644 --- a/ghost/i18n/locales/no/comments.json +++ b/ghost/i18n/locales/no/comments.json @@ -1,72 +1,72 @@ { - "{{amount}} characters left": "", - "{{amount}} comments": "", - "{{amount}} days ago": "", - "{{amount}} hrs ago": "", - "{{amount}} mins ago": "", - "{{amount}} months ago": "", - "{{amount}} more": "", - "{{amount}} seconds ago": "", - "{{amount}} weeks ago": "", - "{{amount}} years ago": "", - "1 comment": "", - "Add comment": "", - "Add context to your comment, share your name and expertise to foster a healthy discussion.": "", - "Add reply": "", - "Already a member?": "", - "Anonymous": "", - "Become a member of {{publication}} to start commenting.": "", - "Become a paid member of {{publication}} to start commenting.": "", - "Cancel": "", - "Comment": "", - "Complete your profile": "", - "Delete": "", - "Deleted member": "", - "Discussion": "", - "Edit": "", - "Edit this comment": "", - "edited": "", - "Enter your name": "", - "Expertise": "", - "Founder @ Acme Inc": "", - "Full-time parent": "", - "Head of Marketing at Acme, Inc": "", - "Hide": "", - "Hide comment": "", - "Jamie Larson": "", - "Join the discussion": "", - "Just now": "", - "Local resident": "", - "Member discussion": "", - "Name": "", - "Neurosurgeon": "", - "One day ago": "", - "One hour ago": "", - "One min ago": "", - "One month ago": "", - "One week ago": "", - "One year ago": "", - "Reply": "", - "Reply to comment": "", - "Report": "", - "Report comment": "", - "Report this comment?": "", - "Save": "", - "Sending": "", - "Sent": "", - "Show": "", - "Show {{amount}} more replies": "", - "Show {{amount}} previous comments": "", - "Show 1 more reply": "", - "Show 1 previous comment": "", - "Show comment": "", - "Sign in": "", - "Sign up now": "", - "Sort by": "", - "Start the conversation": "", - "This comment has been hidden.": "", - "This comment has been removed.": "", - "Upgrade now": "", - "Yesterday": "", - "Your request will be sent to the owner of this site.": "" + "{{amount}} characters left": "{{amount}} tegn igjen", + "{{amount}} comments": "{{amount}} kommentarer", + "{{amount}} days ago": "{{amount}} dager siden", + "{{amount}} hrs ago": "{{amount}} timer siden", + "{{amount}} mins ago": "{{amount}} minutter siden", + "{{amount}} months ago": "{{amount}} måneder siden", + "{{amount}} more": "{{amount}} mer", + "{{amount}} seconds ago": "{{amount}} sekunder siden", + "{{amount}} weeks ago": "{{amount}} uker siden", + "{{amount}} years ago": "{{amount}} år siden", + "1 comment": "1 kommentar", + "Add comment": "Legg til kommentar", + "Add context to your comment, share your name and expertise to foster a healthy discussion.": "Legg til kontekst i kommentaren din, del navnet ditt og ekspertisen din for å fremme en god diskusjon.", + "Add reply": "Legg til svar", + "Already a member?": "Allerede medlem?", + "Anonymous": "Anonym", + "Become a member of {{publication}} to start commenting.": "Bli medlem av {{publication}} for å begynne å kommentere.", + "Become a paid member of {{publication}} to start commenting.": "Bli betalende medlem av {{publication}} for å begynne å kommentere.", + "Cancel": "Avbryt", + "Comment": "Kommentar", + "Complete your profile": "Fullfør profilen din", + "Delete": "Slett", + "Deleted member": "Slettet medlem", + "Discussion": "Diskusjon", + "Edit": "Rediger", + "Edit this comment": "Rediger denne kommentaren", + "edited": "redigert", + "Enter your name": "Skriv inn navnet ditt", + "Expertise": "Ekspertise", + "Founder @ Acme Inc": "Grunnlegger @ Acme Inc", + "Full-time parent": "Forelder på heltid", + "Head of Marketing at Acme, Inc": "Markedsføringsleder hos Acme Inc", + "Hide": "Skjul", + "Hide comment": "Skjul kommentar", + "Jamie Larson": "Jamie Larson", + "Join the discussion": "Delta i diskusjonen", + "Just now": "Akkurat nå", + "Local resident": "Lokal innbygger", + "Member discussion": "Medlemsdiskusjon", + "Name": "Navn", + "Neurosurgeon": "Neurokirurg", + "One day ago": "En dag siden", + "One hour ago": "En time siden", + "One min ago": "Ett minutt siden", + "One month ago": "En måned siden", + "One week ago": "En uke siden", + "One year ago": "Ett år siden", + "Reply": "Svar", + "Reply to comment": "Svar på kommentar", + "Report": "Rapporter", + "Report comment": "Rapporter kommentar", + "Report this comment?": "Rapportere denne kommentaren?", + "Save": "Lagre", + "Sending": "Sender", + "Sent": "Sendt", + "Show": "Vis", + "Show {{amount}} more replies": "Vis {{amount}} flere svar", + "Show {{amount}} previous comments": "Vis {{amount}} tidligere kommentarer", + "Show 1 more reply": "Vis ett svar til", + "Show 1 previous comment": "Vis én tidligere kommentar", + "Show comment": "Vis kommentar", + "Sign in": "Logg inn", + "Sign up now": "Registrer deg nå", + "Sort by": "Sorter etter", + "Start the conversation": "Start samtalen", + "This comment has been hidden.": "Denne kommentaren er skjult.", + "This comment has been removed.": "Denne kommentaren er fjernet.", + "Upgrade now": "Oppgrader nå", + "Yesterday": "I går", + "Your request will be sent to the owner of this site.": "Din forespørsel vil bli sendt til eieren av dette nettstedet." } diff --git a/ghost/i18n/locales/no/ghost.json b/ghost/i18n/locales/no/ghost.json index 647e6725b33..390b3168981 100644 --- a/ghost/i18n/locales/no/ghost.json +++ b/ghost/i18n/locales/no/ghost.json @@ -1,13 +1,13 @@ { "All the best!": "Alt det beste!", - "Complete signup for {{siteTitle}}!": "Fullfør påmelding for {{siteTitle}}!", + "Complete signup for {{siteTitle}}!": "Fullfør registreringen for {{siteTitle}}!", "Complete your sign up to {{siteTitle}}!": "Fullfør registreringen din på {{siteTitle}}!", - "Confirm email address": "Bekreft e-post", - "Confirm signup": "Bekreft påmelding", - "Confirm your email address": "Bekreft din e-post", + "Confirm email address": "Bekreft e-postadresse", + "Confirm signup": "Bekreft registrering", + "Confirm your email address": "Bekreft din e-postadresse", "Confirm your email update for {{siteTitle}}!": "Bekreft e-postoppdateringen din for {{siteTitle}}!", "Confirm your subscription to {{siteTitle}}": "Bekreft abonnementet ditt på {{siteTitle}}", - "For your security, the link will expire in 24 hours time.": "For din sikkerhet så vil denne linken utløpe om 24 timer", + "For your security, the link will expire in 24 hours time.": "For din sikkerhet vil denne lenken utløpe om 24 timer.", "Hey there,": "Hei,", "Hey there!": "Hei!", "If you did not make this request, you can safely ignore this email.": "Hvis du ikke har bedt om dette, kan du trygt ignorere denne e-posten.", @@ -15,20 +15,20 @@ "Please confirm your email address with this link:": "Vennligst bekreft e-postadressen din med denne lenken:", "Secure sign in link for {{siteTitle}}": "Sikker påloggingslenke for {{siteTitle}}", "See you soon!": "Ser deg snart!", - "Sent to {{email}}": "Send til {{email}}", + "Sent to {{email}}": "Sendt til {{email}}", "Sign in": "Logg inn", "Sign in to {{siteTitle}}": "Logg inn på {{siteTitle}}", - "Tap the link below to complete the signup process for {{siteTitle}}, and be automatically signed in:": "Trykk på lenken nedenfor for å fullføre registreringsprosessen for {{siteTitle}}. Du blir automatisk logget på:", + "Tap the link below to complete the signup process for {{siteTitle}}, and be automatically signed in:": "Trykk på lenken nedenfor for å fullføre registreringsprosessen for {{siteTitle}}. Du blir automatisk logget inn:", "Thank you for signing up to {{siteTitle}}!": "Takk for at du registrerte deg på {{siteTitle}}!", "Thank you for subscribing to {{siteTitle}}!": "Takk for at du abonnerer på {{siteTitle}}!", - "Thank you for subscribing to {{siteTitle}}.": "Takk for at du abonnerer på {{siteTitle}}", - "Thank you for subscribing to {{siteTitle}}. Tap the link below to be automatically signed in:": "Takk for at du abonnerer på {{siteTitle}}. Trykk på lenken nedenfor for å bli automatisk logget på:", + "Thank you for subscribing to {{siteTitle}}.": "Takk for at du abonnerer på {{siteTitle}}.", + "Thank you for subscribing to {{siteTitle}}. Tap the link below to be automatically signed in:": "Takk for at du abonnerer på {{siteTitle}}. Trykk på lenken nedenfor for å bli automatisk logget inn:", "This email address will not be used.": "Denne e-postadressen vil ikke bli brukt.", "Welcome back to {{siteTitle}}!": "Velkommen tilbake til {{siteTitle}}!", - "Welcome back! Use this link to securely sign in to your {{siteTitle}} account:": "Velkommen tilbake! Bruk denne lenken til å logge deg sikkert på {{siteTitle}}-kontoen din:", + "Welcome back! Use this link to securely sign in to your {{siteTitle}} account:": "Velkommen tilbake! Bruk denne lenken for å logge deg sikkert på {{siteTitle}}-kontoen din:", "You can also copy & paste this URL into your browser:": "Du kan også kopiere og lime inn denne URL-en i nettleseren din:", "You will not be signed up, and no account will be created for you.": "Du vil ikke bli registrert, og ingen konto vil bli opprettet for deg.", - "You will not be subscribed.": "Du vil ikke ble meldt på", - "You're one tap away from subscribing to {{siteTitle}} — please confirm your email address with this link:": "Du er ett klikk unna fra å abonnere på {{siteTitle}} — bekreft e-postadressen din med denne lenken:", - "You're one tap away from subscribing to {{siteTitle}}!": "Du er ett klikk unna fra å abonnere på {{siteTitle}}!" + "You will not be subscribed.": "Du vil ikke bli meldt på.", + "You're one tap away from subscribing to {{siteTitle}} — please confirm your email address with this link:": "Du er ett klikk unna å abonnere på {{siteTitle}} — bekreft e-postadressen din med denne lenken:", + "You're one tap away from subscribing to {{siteTitle}}!": "Du er ett klikk unna å abonnere på {{siteTitle}}!" } diff --git a/ghost/i18n/locales/no/newsletter.json b/ghost/i18n/locales/no/newsletter.json new file mode 100644 index 00000000000..bd24d1dc525 --- /dev/null +++ b/ghost/i18n/locales/no/newsletter.json @@ -0,0 +1,24 @@ +{ + "By {authors}": "", + "Comment": "", + "complimentary": "", + "Email": "", + "free": "", + "Keep reading": "", + "Less like this": "", + "Manage subscription": "", + "Member since": "", + "More like this": "", + "Name": "", + "paid": "", + "Subscription details": "", + "trialing": "", + "Unsubscribe": "", + "View in browser": "", + "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", + "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", + "Your subscription has expired.": "", + "Your subscription will expire on {date}.": "", + "Your subscription will renew on {date}.": "" +} diff --git a/ghost/i18n/locales/no/portal.json b/ghost/i18n/locales/no/portal.json index 9601fdc41bf..469d517b2b6 100644 --- a/ghost/i18n/locales/no/portal.json +++ b/ghost/i18n/locales/no/portal.json @@ -1,122 +1,122 @@ { - "(save {{highestYearlyDiscount}}%)": "", + "(save {{highestYearlyDiscount}}%)": "(spar {{highestYearlyDiscount}}%)", "{{amount}} days free": "{{amount}} dager gratis", "{{amount}} off": "{{amount}} rabatt", "{{amount}} off for first {{number}} months.": "{{amount}} rabatt første {{number}} måneder.", - "{{amount}} off for first {{period}}.": "{{amount}} rabatt den første {{period}}.", + "{{amount}} off for first {{period}}.": "{{amount}} rabatt for første {{period}}.", "{{amount}} off forever.": "{{amount}} rabatt for alltid.", "{{discount}}% discount": "{{discount}}% rabatt", - "{{memberEmail}} will no longer receive {{newsletterName}} newsletter.": "{{memberEmail}} vil ikke lengre motta nyhetsbrevet {{newsletterName}}.", - "{{memberEmail}} will no longer receive emails when someone replies to your comments.": "{{memberEmail}} vil ikke lengre motta e-poster når noen svarer på dine kommentarer.", - "{{memberEmail}} will no longer receive this newsletter.": "{{memberEmail}} vil ikke lengre motta nyhetsbrevet.", + "{{memberEmail}} will no longer receive {{newsletterName}} newsletter.": "{{memberEmail}} vil ikke lenger motta nyhetsbrevet {{newsletterName}}.", + "{{memberEmail}} will no longer receive emails when someone replies to your comments.": "{{memberEmail}} vil ikke lenger motta e-poster når noen svarer på dine kommentarer.", + "{{memberEmail}} will no longer receive this newsletter.": "{{memberEmail}} vil ikke lenger motta nyhetsbrevet.", "{{trialDays}} days free": "{{trialDays}} dager gratis", - "+1 (123) 456-7890": "", + "+1 (123) 456-7890": "+47 123 45 678", "A login link has been sent to your inbox. If it doesn't arrive in 3 minutes, be sure to check your spam folder.": "En påloggingslenke har blitt sendt til innboksen din. Hvis den ikke kommer innen 3 minutter, må du sjekke søppelposten din.", "Account": "Konto", - "Account details updated successfully": "", + "Account details updated successfully": "Kontodetaljer oppdatert", "Account settings": "Kontoinnstillinger", "After a free trial ends, you will be charged the regular price for the tier you've chosen. You can always cancel before then.": "Etter at prøveperioden er over, vil du bli belastet den vanlige prisen for nivået du har valgt. Du kan alltid avbryte før det.", "Already a member?": "Allerede medlem?", - "An error occurred": "", - "An unexpected error occured. Please try again or contact support if the error persists.": "En uforutsett feil oppstod. Vennligst prøv igjen eller ta kontakt om feilen vedvarer.", + "An error occurred": "En feil oppstod", + "An unexpected error occured. Please try again or contact support if the error persists.": "En uforutsett feil oppstod. Vennligst prøv igjen eller ta kontakt hvis feilen vedvarer.", "Back": "Tilbake", - "Back to Log in": "Tilbake til logg inn", + "Back to Log in": "Tilbake til innlogging", "Billing info": "Fakturainformasjon", - "Black Friday": "", - "Cancel anytime.": "Ingen bindingstid", + "Black Friday": "Black Friday", + "Cancel anytime.": "Ingen bindingstid.", "Cancel subscription": "Kanseller abonnement", "Cancellation reason": "Årsak til kansellering", "Change": "Endre", - "Change plan": "", - "Check spam & promotions folders": "Sjekk foldere for søppelpost og reklame", - "Check with your mail provider": "Ta kontakt med din epostleverandør", - "Check your inbox to verify email update": "", + "Change plan": "Endre avtale", + "Check spam & promotions folders": "Sjekk søppelpost og reklame", + "Check with your mail provider": "Sjekk med din e-postleverandør", + "Check your inbox to verify email update": "Sjekk innboksen for å bekrefte e-postoppdatering", "Choose": "Velg", - "Choose a different plan": "Velg et annet nivå", - "Choose a plan": "", - "Choose your newsletters": "Velg dine nyhetsbrev", + "Choose a different plan": "Velg en annen avtale", + "Choose a plan": "Velg en avtale", + "Choose your newsletters": "Velg nyhetsbrev", "Click here to retry": "Klikk her for å prøve igjen", "Close": "Lukk", "Comments": "Kommentarer", "Complimentary": "Gratis", "Confirm": "Bekreft", "Confirm cancellation": "Bekreft kansellering", - "Confirm subscription": "Bekreft påmelding", - "Contact support": "", + "Confirm subscription": "Bekreft abonnement", + "Contact support": "Kontakt support", "Continue": "Fortsett", "Continue subscription": "Fortsett abonnement", - "Could not create stripe checkout session": "", - "Could not sign in. Login link expired.": "Kunne ikke logge på. Tiden for lenken har utgått.", + "Could not create stripe checkout session": "Kunne ikke opprette utsjekkingssesjon hos Stripe", + "Could not sign in. Login link expired.": "Kunne ikke logge inn. Påloggingslenken har utløpt.", "Could not update email! Invalid link.": "Kunne ikke oppdatere e-post! Ugyldig lenke.", - "Create a new contact": "Registrer en ny kontakt", - "Current plan": "Gjeldende plan", + "Create a new contact": "Opprett en ny kontakt", + "Current plan": "Nåværende avtale", "Delete account": "Slett konto", - "Didn't mean to do this? Manage your preferences .": "Var dett en feil? Du kan endre dine preferanser ", + "Didn't mean to do this? Manage your preferences .": "Var dette en feil? Administrer innstillingene dine .", "Don't have an account?": "Har du ikke konto?", "Edit": "Rediger", "Email": "E-post", - "Email newsletter": "E-postbasert nyhetsbrev", - "Email newsletter settings updated": "", - "Email preferences": "Innstillinger for e-post", + "Email newsletter": "E-postnyhetsbrev", + "Email newsletter settings updated": "Innstillinger for e-postnyhetsbrev oppdatert", + "Email preferences": "E-postinnstillinger", "Emails": "E-poster", "Emails disabled": "E-poster deaktivert", "Ends {{offerEndDate}}": "Avsluttes {{offerEndDate}}", "Enter your email address": "Oppgi din e-postadresse", - "Enter your name": "Oppgi ditt navn", + "Enter your name": "Oppgi navnet ditt", "Error": "Feil", - "Expires {{expiryDate}}": "Avsluttes {{expiryDate}}", - "Failed to cancel subscription, please try again": "", - "Failed to log in, please try again": "", - "Failed to log out, please try again": "", - "Failed to process checkout, please try again": "", - "Failed to send magic link email": "", - "Failed to send verification email": "", - "Failed to sign up, please try again": "", - "Failed to update account data": "", - "Failed to update account details": "", - "Failed to update billing information, please try again": "", - "Failed to update newsletter settings": "", - "Failed to update subscription, please try again": "", + "Expires {{expiryDate}}": "Utløper {{expiryDate}}", + "Failed to cancel subscription, please try again": "Kansellering mislyktes, vennligst prøv igjen", + "Failed to log in, please try again": "Innlogging mislyktes, vennligst prøv igjen", + "Failed to log out, please try again": "Utlogging mislyktes, vennligst prøv igjen", + "Failed to process checkout, please try again": "Betaling mislyktes, vennligst prøv igjen", + "Failed to send magic link email": "Kunne ikke sende lenke på e-post", + "Failed to send verification email": "Kunne ikke sende bekreftelses-e-post", + "Failed to sign up, please try again": "Registrering mislyktes, vennligst prøv igjen", + "Failed to update account data": "Kunne ikke oppdatere kontodata", + "Failed to update account details": "Kunne ikke oppdatere kontodetaljer", + "Failed to update billing information, please try again": "Kunne ikke oppdatere fakturainformasjon, vennligst prøv igjen", + "Failed to update newsletter settings": "Kunne ikke oppdatere nyhetsbrevinnstillinger", + "Failed to update subscription, please try again": "Kunne ikke oppdatere abonnement, vennligst prøv igjen", "Forever": "For alltid", "Free Trial – Ends {{trialEnd}}": "Gratis prøveperiode – avsluttes {{trialEnd}}", "Get help": "Få hjelp", - "Get in touch for help": "Ta kontakt for hjelp", - "Get notified when someone replies to your comment": "Få varsel dersom noen svarer på kommentaren din", + "Get in touch for help": "Kontakt for hjelp", + "Get notified when someone replies to your comment": "Få varsel når noen svarer på kommentaren din", "Give feedback on this post": "Gi tilbakemelding på dette innlegget", - "Help! I'm not receiving emails": "Hjelp, jeg mottar ikke e-poster", - "Here are a few other sites you may enjoy.": "Her en noen andre nettsteder du kan like.", - "If a newsletter is flagged as spam, emails are automatically disabled for that address to make sure you no longer receive any unwanted messages.": "Hvis et nyhetsbrev er flagget som søppelpost, deaktiveres e-poster automatisk for den adressen for å sikre at du ikke lenger mottar uønskede meldinger.", - "If the spam complaint was accidental, or you would like to begin receiving emails again, you can resubscribe to emails by clicking the button on the previous screen.": "Hvis du markerte som søppelpost ved et uhell eller du ønsker å begynne å motta e-poster igjen, kan du abonnere på nytt på e-poster ved å klikke på knappen på forrige skjerm.", - "If you cancel your subscription now, you will continue to have access until {{periodEnd}}.": "Hvis du kansellerer abonnementet ditt nå, vil du fortsette å ha tilgang til {{periodEnd}}.", - "If you have a corporate or government email account, reach out to your IT department and ask them to allow emails to be received from {{senderEmail}}": "Hvis du har en bedrifts-e-postkonto, ta kontakt med IT-avdelingen din og be dem om å tillate at e-poster mottas fra {{senderEmail}}", - "If you would like to start receiving emails again, the best next steps are to check your email address on file for any issues and then click resubscribe on the previous screen.": "Hvis du ønsker å begynne å motta e-poster igjen, sjekk e-postadressen din for eventuelle problemer og deretter klikke på abonner på nytt på forrige skjermbilde.", - "If you're not receiving the email newsletter you've subscribed to, here are a few things to check.": "Hvis du ikke mottar nyhetsbrevet på e-post du har abonnert på, er det noen ting du bør sjekke.", - "If you've completed all these checks and you're still not receiving emails, you can reach out to get support by contacting {{supportAddress}}.": "", - "In the event a permanent failure is received when attempting to send a newsletter, emails will be disabled on the account.": "", - "In your email client add {{senderEmail}} to your contacts list. This signals to your mail provider that emails sent from this address should be trusted.": "", + "Help! I'm not receiving emails": "Hjelp! Jeg mottar ikke e-poster", + "Here are a few other sites you may enjoy.": "Her er noen andre nettsteder du kanskje liker.", + "If a newsletter is flagged as spam, emails are automatically disabled for that address to make sure you no longer receive any unwanted messages.": "Hvis et nyhetsbrev er markert som søppelpost, blir e-poster automatisk deaktivert for den adressen.", + "If the spam complaint was accidental, or you would like to begin receiving emails again, you can resubscribe to emails by clicking the button on the previous screen.": "Hvis du merket som søppelpost ved et uhell eller vil begynne å motta e-poster igjen, kan du abonnere på nytt ved å klikke knappen på forrige skjerm.", + "If you cancel your subscription now, you will continue to have access until {{periodEnd}}.": "Hvis du avslutter abonnementet ditt nå, vil du fortsatt ha tilgang til {{periodEnd}}.", + "If you have a corporate or government email account, reach out to your IT department and ask them to allow emails to be received from {{senderEmail}}": "Hvis du har en bedrifts- eller offentlig e-post, kontakt IT-avdelingen og be dem godkjenne e-post fra {{senderEmail}}", + "If you would like to start receiving emails again, the best next steps are to check your email address on file for any issues and then click resubscribe on the previous screen.": "Hvis du vil begynne å motta e-poster igjen, sjekk e-postadressen din og klikk abonner på nytt på forrige skjerm.", + "If you're not receiving the email newsletter you've subscribed to, here are a few things to check.": "Hvis du ikke mottar e-posten du har abonnert på, er her noen ting å sjekke.", + "If you've completed all these checks and you're still not receiving emails, you can reach out to get support by contacting {{supportAddress}}.": "Hvis du har sjekket alt og fortsatt ikke mottar e-poster, kontakt {{supportAddress}}.", + "In the event a permanent failure is received when attempting to send a newsletter, emails will be disabled on the account.": "Hvis en permanent feil oppstår ved sending av nyhetsbrev, vil e-post bli deaktivert på kontoen.", + "In your email client add {{senderEmail}} to your contacts list. This signals to your mail provider that emails sent from this address should be trusted.": "Legg til {{senderEmail}} i kontaktlisten din i e-postklienten. Dette signaliserer til e-postleverandøren din at e-poster sendt fra denne adressen skal være pålitelige.", "Invalid email address": "Ugyldig e-postadresse", "Jamie Larson": "Ola Nordmann", "jamie@example.com": "ola.nordmann@example.com", "Less like this": "Mindre som dette", - "Make sure emails aren't accidentally ending up in the Spam or Promotions folders of your inbox. If they are, click on \"Mark as not spam\" and/or \"Move to inbox\".": "", + "Make sure emails aren't accidentally ending up in the Spam or Promotions folders of your inbox. If they are, click on \"Mark as not spam\" and/or \"Move to inbox\".": "Sørg for at e-poster ikke ender opp i spam- eller reklame-mappene ved en feil. Hvis de gjør det, klikk \"Merk som ikke søppel\" eller \"Flytt til innboks\".", "Manage": "Administrer", "Maybe later": "Kanskje senere", "Memberships unavailable, contact the owner for access.": "Medlemskap ikke tilgjengelig, ta kontakt for tilgang.", - "month": "", + "month": "måned", "Monthly": "Månedlig", "More like this": "Mer som dette", "Name": "Navn", "Need more help? Contact support": "Trenger du mer hjelp? Ta kontakt", - "Newsletters can be disabled on your account for two reasons: A previous email was marked as spam, or attempting to send an email resulted in a permanent failure (bounce).": "", - "No member exists with this e-mail address.": "", - "No member exists with this e-mail address. Please sign up first.": "", + "Newsletters can be disabled on your account for two reasons: A previous email was marked as spam, or attempting to send an email resulted in a permanent failure (bounce).": "Nyhetsbrev kan bli deaktivert på kontoen din av to grunner: En tidligere e-post ble markert som søppelpost, eller forsøk på å sende e-post resulterte i en permanent feil (bounce).", + "No member exists with this e-mail address.": "Ingen medlem eksisterer med denne e-postadressen.", + "No member exists with this e-mail address. Please sign up first.": "Ingen medlem eksisterer med denne e-postadressen. Vennligst registrer deg først.", "Not receiving emails?": "Mottar du ikke e-poster?", - "Now check your email!": "Sjekk e-posten din!", + "Now check your email!": "Sjekk e-posten din nå!", "Once resubscribed, if you still don't see emails in your inbox, check your spam folder. Some inbox providers keep a record of previous spam complaints and will continue to flag emails. If this happens, mark the latest newsletter as 'Not spam' to move it back to your primary inbox.": "Blir e-poster markert som søppelpost? Sjekk e-postmappen for søppelpost og merk som 'ikke søppel'.", "Permanent failure (bounce)": "Permanent feil (bounce)", "Phone number": "Telefonnummer", - "Plan": "Plan", - "Plan checkout was cancelled.": "Påmelding til plan ble kansellert. ", + "Plan": "Avtale", + "Plan checkout was cancelled.": "Påmelding til avtale ble kansellert.", "Plan upgrade was cancelled.": "Oppgradering ble kansellert.", "Please contact {{supportAddress}} to adjust your complimentary subscription.": "Vennligst kontakt {{supportAddress}} for å justere ditt gratis abonnement.", "Please enter {{fieldName}}": "Vennligst oppgi {{fieldName}}", @@ -128,27 +128,27 @@ "Retry": "Prøv på nytt", "Save": "Lagre", "Send an email and say hi!": "Send en e-post og si hei!", - "Send an email to {{senderEmail}} and say hello. This can also help signal to your mail provider that emails to and from this address should be trusted.": "", - "Sending login link...": "Sender påloggingslenke", + "Send an email to {{senderEmail}} and say hello. This can also help signal to your mail provider that emails to and from this address should be trusted.": "Send en e-post til {{senderEmail}} og si hei. Dette kan også signalisere til e-postleverandøren din at e-poster til og fra denne adressen bør være pålitelige.", + "Sending login link...": "Sender påloggingslenke...", "Sending...": "Sender...", "Show all": "Vis alt", "Sign in": "Logg inn", "Sign out": "Logg ut", "Sign up": "Opprett bruker", - "Signup error: Invalid link": "En feil oppstod: Ugyldig lenke", + "Signup error: Invalid link": "Feil ved registrering: Ugyldig lenke", "Something went wrong, please try again later.": "Noe gikk galt. Prøv igjen senere.", - "Sorry, no recommendations are available right now.": "", + "Sorry, no recommendations are available right now.": "Beklager, ingen anbefalinger er tilgjengelige for øyeblikket.", "Sorry, that didn’t work.": "Beklager, det fungerte ikke", - "Spam complaints": "Klager om søppelpost", + "Spam complaints": "Søppelpostklager", "Start {{amount}}-day free trial": "Start gratis prøveperiode på {{amount}} dager", - "Starting {{startDate}}": "Oppstartsdato {{startDate}}", - "Starting today": "Starter idag", + "Starting {{startDate}}": "Oppstart {{startDate}}", + "Starting today": "Starter i dag", "Submit feedback": "Send tilbakemelding", - "Subscribe": "Påmelding", - "Subscribed": "Påmeldt", - "Subscription plan updated successfully": "", + "Subscribe": "Abonner", + "Subscribed": "Abonnert", + "Subscription plan updated successfully": "Abonnementsavtale oppdatert", "Success": "Suksess", - "Success! Check your email for magic link to sign-in.": "Suksess! Sjekk din e-post for magisk lenke på pålogging.", + "Success! Check your email for magic link to sign-in.": "Suksess! Sjekk din e-post for magisk lenke for pålogging.", "Success! Your account is fully activated, you now have access to all content.": "Suksess! Din konto er nå aktivert, og du har tilgang til alt innhold.", "Success! Your email is updated.": "Suksess, din e-post er oppdatert", "Successfully unsubscribed": "Avmelding vellykket", @@ -159,51 +159,51 @@ "That didn't go to plan": "Det gikk ikke som planlagt", "The email address we have for you is {{memberEmail}} — if that's not correct, you can update it in your .": "E-postadressen vi har til deg er {{memberEmail}} – hvis det ikke er riktig, kan du oppdatere den i .", "There was a problem submitting your feedback. Please try again a little later.": "Det oppstod en feil. Vennligst prøv igjen senere.", - "There was an error cancelling your subscription, please try again.": "", - "There was an error continuing your subscription, please try again.": "", + "There was an error cancelling your subscription, please try again.": "En feil oppstod ved kansellering av abonnementet, vennligst prøv igjen.", + "There was an error continuing your subscription, please try again.": "En feil oppstod ved fornyelse av abonnementet, vennligst prøv igjen.", "There was an error processing your payment. Please try again.": "Det oppsto en feil under behandling av betalingen din. Vennligst prøv igjen.", - "There was an error sending the email, please try again": "", - "This site is invite-only, contact the owner for access.": "Denne nettsiden er kun fo inviterte. Kontakt eieren for invitasjon.", + "There was an error sending the email, please try again": "En feil oppstod ved sending av e-posten, vennligst prøv igjen", + "This site is invite-only, contact the owner for access.": "Denne nettsiden er kun for inviterte. Kontakt eieren for invitasjon.", "This site is not accepting payments at the moment.": "Denne nettsiden godtar ikke betalinger for øyeblikket.", "To complete signup, click the confirmation link in your inbox. If it doesn't arrive within 3 minutes, check your spam folder!": "For å fullføre registreringen, klikk på bekreftelseslenken i innboksen din. Hvis den ikke kommer innen 3 minutter, må du sjekke søppelposten din!", "To continue to stay up to date, subscribe to {{publication}} below.": "For å fortsette å holde deg oppdatert, abonner på {{publication}} nedenfor.", - "Too many attempts try again in {{number}} days.": "", - "Too many attempts try again in {{number}} hours.": "", - "Too many attempts try again in {{number}} minutes.": "", - "Too many different sign-in attempts, try again in {{number}} days": "", - "Too many different sign-in attempts, try again in {{number}} hours": "", - "Too many different sign-in attempts, try again in {{number}} minutes": "", + "Too many attempts try again in {{number}} days.": "For mange forsøk, prøv igjen om {{number}} dager.", + "Too many attempts try again in {{number}} hours.": "For mange forsøk, prøv igjen om {{number}} timer.", + "Too many attempts try again in {{number}} minutes.": "For mange forsøk, prøv igjen om {{number}} minutter.", + "Too many different sign-in attempts, try again in {{number}} days": "For mange påloggingsforsøk, prøv igjen om {{number}} dager.", + "Too many different sign-in attempts, try again in {{number}} hours": "For mange påloggingsforsøk, prøv igjen om {{number}} timer.", + "Too many different sign-in attempts, try again in {{number}} minutes": "For mange påloggingsforsøk, prøv igjen om {{number}} minutter.", "Try free for {{amount}} days, then {{originalPrice}}.": "Prøv gratis i {{amount}} dager, deretter {{originalPrice}}.", - "Unable to initiate checkout session": "", - "Unlock access to all newsletters by becoming a paid subscriber.": "Få tilgang til alle nyhetsbrevene ved å oppgradere ditt abonnement.", - "Unsubscribe from all emails": "Meld deg alle e-poster", + "Unable to initiate checkout session": "Kunne ikke starte utsjekkingssesjon", + "Unlock access to all newsletters by becoming a paid subscriber.": "Få tilgang til alle nyhetsbrev ved å bli betalende abonnent.", + "Unsubscribe from all emails": "Meld deg av alle e-poster", "Unsubscribed": "Avmeldt", "Unsubscribed from all emails.": "Avmeldt fra alle e-poster", "Unsubscribing from emails will not cancel your paid subscription to {{title}}": "Å melde seg av e-poster vil ikke avbryte abonnementet ditt på {{title}}", "Update": "Oppdater", "Update your preferences": "Oppdater dine valg", - "Verification link sent, check your inbox": "Lenke for verifisering er sent. Sjekk innboksen din.", + "Verification link sent, check your inbox": "Lenke for verifisering er sendt. Sjekk innboksen din.", "Verify your email address is correct": "Verifiser at e-posten din er korrekt", "View plans": "Se planer", "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "Vi kunne ikke melde deg av siden e-postadressen ikke ble funnet. Vennligst kontakt nettstedseieren.", - "Welcome back, {{name}}!": "Velkommen tilbake {{name}}!", - "Welcome back!": "Velkommen tilbake", + "Welcome back, {{name}}!": "Velkommen tilbake, {{name}}!", + "Welcome back!": "Velkommen tilbake!", "Welcome to {{siteTitle}}": "Velkommen til {{siteTitle}}", - "When an inbox fails to accept an email it is commonly called a bounce. In many cases, this can be temporary. However, in some cases, a bounced email can be returned as a permanent failure when an email address is invalid or non-existent.": "", - "Why has my email been disabled?": "Hvorfor fungerer ikke min epost?", - "year": "", + "When an inbox fails to accept an email it is commonly called a bounce. In many cases, this can be temporary. However, in some cases, a bounced email can be returned as a permanent failure when an email address is invalid or non-existent.": "Når en e-postadresse ikke kan motta e-post, kalles det en bounce. I mange tilfeller kan dette være midlertidig, men noen ganger kan det være en permanent feil hvis e-postadressen er ugyldig eller ikke eksisterer.", + "Why has my email been disabled?": "Hvorfor fungerer ikke min e-post?", + "year": "år", "Yearly": "Årlig", - "You currently have a free membership, upgrade to a paid subscription for full access.": "Du har for tiden et gratis abonnement, oppgrader for full tilgang.", + "You currently have a free membership, upgrade to a paid subscription for full access.": "Du har for tiden et gratis medlemskap. Oppgrader til et betalt abonnement for full tilgang.", "You have been successfully resubscribed": "Du har blitt meldt på igjen", "You're currently not receiving emails": "Du mottar for tiden ikke e-poster", "You're not receiving emails": "Du mottar ikke e-poster", - "You're not receiving emails because you either marked a recent message as spam, or because messages could not be delivered to your provided email address.": "Du mottar ikke e-poster fordi du enten nylig har merket en melding som spam, eller fordi meldinger ikke kunne leveres til den oppgitte e-postadressen din.", - "You've successfully signed in.": "Du har logged på igjen.", + "You're not receiving emails because you either marked a recent message as spam, or because messages could not be delivered to your provided email address.": "Du mottar ikke e-poster fordi du enten nylig har merket en melding som søppelpost, eller fordi meldinger ikke kunne leveres til den oppgitte e-postadressen din.", + "You've successfully signed in.": "Du har logget på.", "You've successfully subscribed to": "Du har meldt deg på", "Your account": "Din konto", - "Your email has failed to resubscribe, please try again": "", - "Your input helps shape what gets published.": "Din tilbakemelding bidrar til å forme hva som blir publisert.", + "Your email has failed to resubscribe, please try again": "E-posten din kunne ikke bli meldt på igjen, vennligst prøv på nytt", + "Your input helps shape what gets published.": "Din tilbakemelding bidrar til å påvirke hva som blir publisert.", "Your subscription will expire on {{expiryDate}}": "Ditt abonnement vil avsluttes den {{expiryDate}}", - "Your subscription will renew on {{renewalDate}}": "Ditt abonnemnet vil fornyes den {{renewalDate}}", - "Your subscription will start on {{subscriptionStart}}": "Ditt abonnement vil begynne den {{subscriptionStart}}" + "Your subscription will renew on {{renewalDate}}": "Ditt abonnement vil fornyes den {{renewalDate}}", + "Your subscription will start on {{subscriptionStart}}": "Ditt abonnement vil starte den {{subscriptionStart}}" } diff --git a/ghost/i18n/locales/no/search.json b/ghost/i18n/locales/no/search.json index 8902015528f..1c9170a8217 100644 --- a/ghost/i18n/locales/no/search.json +++ b/ghost/i18n/locales/no/search.json @@ -1,9 +1,9 @@ { - "Authors": "", - "Cancel": "", - "No matches found": "", - "Posts": "", - "Search posts, tags and authors": "", - "Show more results": "", - "Tags": "" + "Authors": "Forfattere", + "Cancel": "Avbryt", + "No matches found": "Ingen treff funnet", + "Posts": "Innlegg", + "Search posts, tags and authors": "Søk i innlegg, etiketter og forfattere", + "Show more results": "Vis flere resultater", + "Tags": "Etiketter" } diff --git a/ghost/i18n/locales/no/signup-form.json b/ghost/i18n/locales/no/signup-form.json index cc957d9d0d1..78ecaba0779 100644 --- a/ghost/i18n/locales/no/signup-form.json +++ b/ghost/i18n/locales/no/signup-form.json @@ -1,9 +1,9 @@ { - "Email sent": "", - "Now check your email!": "", - "Please enter a valid email address": "", - "Something went wrong, please try again.": "", - "Subscribe": "", - "To complete signup, click the confirmation link in your inbox. If it doesn't arrive within 3 minutes, check your spam folder!": "", - "Your email address": "" + "Email sent": "E-post sendt", + "Now check your email!": "Sjekk e-posten din nå!", + "Please enter a valid email address": "Vennligst skriv inn en gyldig e-postadresse", + "Something went wrong, please try again.": "Noe gikk galt, prøv igjen.", + "Subscribe": "Abonner", + "To complete signup, click the confirmation link in your inbox. If it doesn't arrive within 3 minutes, check your spam folder!": "For å fullføre registreringen, klikk på bekreftelseslenken i innboksen din. Hvis den ikke kommer innen 3 minutter, sjekk spam-mappen!", + "Your email address": "Din e-postadresse" } diff --git a/ghost/i18n/locales/pl/newsletter.json b/ghost/i18n/locales/pl/newsletter.json new file mode 100644 index 00000000000..bd24d1dc525 --- /dev/null +++ b/ghost/i18n/locales/pl/newsletter.json @@ -0,0 +1,24 @@ +{ + "By {authors}": "", + "Comment": "", + "complimentary": "", + "Email": "", + "free": "", + "Keep reading": "", + "Less like this": "", + "Manage subscription": "", + "Member since": "", + "More like this": "", + "Name": "", + "paid": "", + "Subscription details": "", + "trialing": "", + "Unsubscribe": "", + "View in browser": "", + "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", + "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", + "Your subscription has expired.": "", + "Your subscription will expire on {date}.": "", + "Your subscription will renew on {date}.": "" +} diff --git a/ghost/i18n/locales/pt-BR/newsletter.json b/ghost/i18n/locales/pt-BR/newsletter.json new file mode 100644 index 00000000000..bd24d1dc525 --- /dev/null +++ b/ghost/i18n/locales/pt-BR/newsletter.json @@ -0,0 +1,24 @@ +{ + "By {authors}": "", + "Comment": "", + "complimentary": "", + "Email": "", + "free": "", + "Keep reading": "", + "Less like this": "", + "Manage subscription": "", + "Member since": "", + "More like this": "", + "Name": "", + "paid": "", + "Subscription details": "", + "trialing": "", + "Unsubscribe": "", + "View in browser": "", + "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", + "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", + "Your subscription has expired.": "", + "Your subscription will expire on {date}.": "", + "Your subscription will renew on {date}.": "" +} diff --git a/ghost/i18n/locales/pt/newsletter.json b/ghost/i18n/locales/pt/newsletter.json new file mode 100644 index 00000000000..bd24d1dc525 --- /dev/null +++ b/ghost/i18n/locales/pt/newsletter.json @@ -0,0 +1,24 @@ +{ + "By {authors}": "", + "Comment": "", + "complimentary": "", + "Email": "", + "free": "", + "Keep reading": "", + "Less like this": "", + "Manage subscription": "", + "Member since": "", + "More like this": "", + "Name": "", + "paid": "", + "Subscription details": "", + "trialing": "", + "Unsubscribe": "", + "View in browser": "", + "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", + "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", + "Your subscription has expired.": "", + "Your subscription will expire on {date}.": "", + "Your subscription will renew on {date}.": "" +} diff --git a/ghost/i18n/locales/ro/newsletter.json b/ghost/i18n/locales/ro/newsletter.json new file mode 100644 index 00000000000..bd24d1dc525 --- /dev/null +++ b/ghost/i18n/locales/ro/newsletter.json @@ -0,0 +1,24 @@ +{ + "By {authors}": "", + "Comment": "", + "complimentary": "", + "Email": "", + "free": "", + "Keep reading": "", + "Less like this": "", + "Manage subscription": "", + "Member since": "", + "More like this": "", + "Name": "", + "paid": "", + "Subscription details": "", + "trialing": "", + "Unsubscribe": "", + "View in browser": "", + "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", + "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", + "Your subscription has expired.": "", + "Your subscription will expire on {date}.": "", + "Your subscription will renew on {date}.": "" +} diff --git a/ghost/i18n/locales/ru/newsletter.json b/ghost/i18n/locales/ru/newsletter.json new file mode 100644 index 00000000000..bd24d1dc525 --- /dev/null +++ b/ghost/i18n/locales/ru/newsletter.json @@ -0,0 +1,24 @@ +{ + "By {authors}": "", + "Comment": "", + "complimentary": "", + "Email": "", + "free": "", + "Keep reading": "", + "Less like this": "", + "Manage subscription": "", + "Member since": "", + "More like this": "", + "Name": "", + "paid": "", + "Subscription details": "", + "trialing": "", + "Unsubscribe": "", + "View in browser": "", + "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", + "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", + "Your subscription has expired.": "", + "Your subscription will expire on {date}.": "", + "Your subscription will renew on {date}.": "" +} diff --git a/ghost/i18n/locales/si/newsletter.json b/ghost/i18n/locales/si/newsletter.json new file mode 100644 index 00000000000..bd24d1dc525 --- /dev/null +++ b/ghost/i18n/locales/si/newsletter.json @@ -0,0 +1,24 @@ +{ + "By {authors}": "", + "Comment": "", + "complimentary": "", + "Email": "", + "free": "", + "Keep reading": "", + "Less like this": "", + "Manage subscription": "", + "Member since": "", + "More like this": "", + "Name": "", + "paid": "", + "Subscription details": "", + "trialing": "", + "Unsubscribe": "", + "View in browser": "", + "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", + "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", + "Your subscription has expired.": "", + "Your subscription will expire on {date}.": "", + "Your subscription will renew on {date}.": "" +} diff --git a/ghost/i18n/locales/sk/newsletter.json b/ghost/i18n/locales/sk/newsletter.json new file mode 100644 index 00000000000..bd24d1dc525 --- /dev/null +++ b/ghost/i18n/locales/sk/newsletter.json @@ -0,0 +1,24 @@ +{ + "By {authors}": "", + "Comment": "", + "complimentary": "", + "Email": "", + "free": "", + "Keep reading": "", + "Less like this": "", + "Manage subscription": "", + "Member since": "", + "More like this": "", + "Name": "", + "paid": "", + "Subscription details": "", + "trialing": "", + "Unsubscribe": "", + "View in browser": "", + "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", + "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", + "Your subscription has expired.": "", + "Your subscription will expire on {date}.": "", + "Your subscription will renew on {date}.": "" +} diff --git a/ghost/i18n/locales/sl/newsletter.json b/ghost/i18n/locales/sl/newsletter.json new file mode 100644 index 00000000000..bd24d1dc525 --- /dev/null +++ b/ghost/i18n/locales/sl/newsletter.json @@ -0,0 +1,24 @@ +{ + "By {authors}": "", + "Comment": "", + "complimentary": "", + "Email": "", + "free": "", + "Keep reading": "", + "Less like this": "", + "Manage subscription": "", + "Member since": "", + "More like this": "", + "Name": "", + "paid": "", + "Subscription details": "", + "trialing": "", + "Unsubscribe": "", + "View in browser": "", + "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", + "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", + "Your subscription has expired.": "", + "Your subscription will expire on {date}.": "", + "Your subscription will renew on {date}.": "" +} diff --git a/ghost/i18n/locales/sq/newsletter.json b/ghost/i18n/locales/sq/newsletter.json new file mode 100644 index 00000000000..bd24d1dc525 --- /dev/null +++ b/ghost/i18n/locales/sq/newsletter.json @@ -0,0 +1,24 @@ +{ + "By {authors}": "", + "Comment": "", + "complimentary": "", + "Email": "", + "free": "", + "Keep reading": "", + "Less like this": "", + "Manage subscription": "", + "Member since": "", + "More like this": "", + "Name": "", + "paid": "", + "Subscription details": "", + "trialing": "", + "Unsubscribe": "", + "View in browser": "", + "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", + "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", + "Your subscription has expired.": "", + "Your subscription will expire on {date}.": "", + "Your subscription will renew on {date}.": "" +} diff --git a/ghost/i18n/locales/sr-Cyrl/newsletter.json b/ghost/i18n/locales/sr-Cyrl/newsletter.json new file mode 100644 index 00000000000..bd24d1dc525 --- /dev/null +++ b/ghost/i18n/locales/sr-Cyrl/newsletter.json @@ -0,0 +1,24 @@ +{ + "By {authors}": "", + "Comment": "", + "complimentary": "", + "Email": "", + "free": "", + "Keep reading": "", + "Less like this": "", + "Manage subscription": "", + "Member since": "", + "More like this": "", + "Name": "", + "paid": "", + "Subscription details": "", + "trialing": "", + "Unsubscribe": "", + "View in browser": "", + "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", + "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", + "Your subscription has expired.": "", + "Your subscription will expire on {date}.": "", + "Your subscription will renew on {date}.": "" +} diff --git a/ghost/i18n/locales/sr/newsletter.json b/ghost/i18n/locales/sr/newsletter.json new file mode 100644 index 00000000000..bd24d1dc525 --- /dev/null +++ b/ghost/i18n/locales/sr/newsletter.json @@ -0,0 +1,24 @@ +{ + "By {authors}": "", + "Comment": "", + "complimentary": "", + "Email": "", + "free": "", + "Keep reading": "", + "Less like this": "", + "Manage subscription": "", + "Member since": "", + "More like this": "", + "Name": "", + "paid": "", + "Subscription details": "", + "trialing": "", + "Unsubscribe": "", + "View in browser": "", + "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", + "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", + "Your subscription has expired.": "", + "Your subscription will expire on {date}.": "", + "Your subscription will renew on {date}.": "" +} diff --git a/ghost/i18n/locales/sv/newsletter.json b/ghost/i18n/locales/sv/newsletter.json new file mode 100644 index 00000000000..bd24d1dc525 --- /dev/null +++ b/ghost/i18n/locales/sv/newsletter.json @@ -0,0 +1,24 @@ +{ + "By {authors}": "", + "Comment": "", + "complimentary": "", + "Email": "", + "free": "", + "Keep reading": "", + "Less like this": "", + "Manage subscription": "", + "Member since": "", + "More like this": "", + "Name": "", + "paid": "", + "Subscription details": "", + "trialing": "", + "Unsubscribe": "", + "View in browser": "", + "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", + "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", + "Your subscription has expired.": "", + "Your subscription will expire on {date}.": "", + "Your subscription will renew on {date}.": "" +} diff --git a/ghost/i18n/locales/sw/newsletter.json b/ghost/i18n/locales/sw/newsletter.json new file mode 100644 index 00000000000..bd24d1dc525 --- /dev/null +++ b/ghost/i18n/locales/sw/newsletter.json @@ -0,0 +1,24 @@ +{ + "By {authors}": "", + "Comment": "", + "complimentary": "", + "Email": "", + "free": "", + "Keep reading": "", + "Less like this": "", + "Manage subscription": "", + "Member since": "", + "More like this": "", + "Name": "", + "paid": "", + "Subscription details": "", + "trialing": "", + "Unsubscribe": "", + "View in browser": "", + "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", + "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", + "Your subscription has expired.": "", + "Your subscription will expire on {date}.": "", + "Your subscription will renew on {date}.": "" +} diff --git a/ghost/i18n/locales/ta/newsletter.json b/ghost/i18n/locales/ta/newsletter.json new file mode 100644 index 00000000000..bd24d1dc525 --- /dev/null +++ b/ghost/i18n/locales/ta/newsletter.json @@ -0,0 +1,24 @@ +{ + "By {authors}": "", + "Comment": "", + "complimentary": "", + "Email": "", + "free": "", + "Keep reading": "", + "Less like this": "", + "Manage subscription": "", + "Member since": "", + "More like this": "", + "Name": "", + "paid": "", + "Subscription details": "", + "trialing": "", + "Unsubscribe": "", + "View in browser": "", + "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", + "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", + "Your subscription has expired.": "", + "Your subscription will expire on {date}.": "", + "Your subscription will renew on {date}.": "" +} diff --git a/ghost/i18n/locales/th/newsletter.json b/ghost/i18n/locales/th/newsletter.json new file mode 100644 index 00000000000..bd24d1dc525 --- /dev/null +++ b/ghost/i18n/locales/th/newsletter.json @@ -0,0 +1,24 @@ +{ + "By {authors}": "", + "Comment": "", + "complimentary": "", + "Email": "", + "free": "", + "Keep reading": "", + "Less like this": "", + "Manage subscription": "", + "Member since": "", + "More like this": "", + "Name": "", + "paid": "", + "Subscription details": "", + "trialing": "", + "Unsubscribe": "", + "View in browser": "", + "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", + "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", + "Your subscription has expired.": "", + "Your subscription will expire on {date}.": "", + "Your subscription will renew on {date}.": "" +} diff --git a/ghost/i18n/locales/tr/newsletter.json b/ghost/i18n/locales/tr/newsletter.json new file mode 100644 index 00000000000..bd24d1dc525 --- /dev/null +++ b/ghost/i18n/locales/tr/newsletter.json @@ -0,0 +1,24 @@ +{ + "By {authors}": "", + "Comment": "", + "complimentary": "", + "Email": "", + "free": "", + "Keep reading": "", + "Less like this": "", + "Manage subscription": "", + "Member since": "", + "More like this": "", + "Name": "", + "paid": "", + "Subscription details": "", + "trialing": "", + "Unsubscribe": "", + "View in browser": "", + "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", + "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", + "Your subscription has expired.": "", + "Your subscription will expire on {date}.": "", + "Your subscription will renew on {date}.": "" +} diff --git a/ghost/i18n/locales/uk/newsletter.json b/ghost/i18n/locales/uk/newsletter.json new file mode 100644 index 00000000000..bd24d1dc525 --- /dev/null +++ b/ghost/i18n/locales/uk/newsletter.json @@ -0,0 +1,24 @@ +{ + "By {authors}": "", + "Comment": "", + "complimentary": "", + "Email": "", + "free": "", + "Keep reading": "", + "Less like this": "", + "Manage subscription": "", + "Member since": "", + "More like this": "", + "Name": "", + "paid": "", + "Subscription details": "", + "trialing": "", + "Unsubscribe": "", + "View in browser": "", + "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", + "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", + "Your subscription has expired.": "", + "Your subscription will expire on {date}.": "", + "Your subscription will renew on {date}.": "" +} diff --git a/ghost/i18n/locales/ur/newsletter.json b/ghost/i18n/locales/ur/newsletter.json new file mode 100644 index 00000000000..bd24d1dc525 --- /dev/null +++ b/ghost/i18n/locales/ur/newsletter.json @@ -0,0 +1,24 @@ +{ + "By {authors}": "", + "Comment": "", + "complimentary": "", + "Email": "", + "free": "", + "Keep reading": "", + "Less like this": "", + "Manage subscription": "", + "Member since": "", + "More like this": "", + "Name": "", + "paid": "", + "Subscription details": "", + "trialing": "", + "Unsubscribe": "", + "View in browser": "", + "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", + "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", + "Your subscription has expired.": "", + "Your subscription will expire on {date}.": "", + "Your subscription will renew on {date}.": "" +} diff --git a/ghost/i18n/locales/uz/newsletter.json b/ghost/i18n/locales/uz/newsletter.json new file mode 100644 index 00000000000..bd24d1dc525 --- /dev/null +++ b/ghost/i18n/locales/uz/newsletter.json @@ -0,0 +1,24 @@ +{ + "By {authors}": "", + "Comment": "", + "complimentary": "", + "Email": "", + "free": "", + "Keep reading": "", + "Less like this": "", + "Manage subscription": "", + "Member since": "", + "More like this": "", + "Name": "", + "paid": "", + "Subscription details": "", + "trialing": "", + "Unsubscribe": "", + "View in browser": "", + "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", + "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", + "Your subscription has expired.": "", + "Your subscription will expire on {date}.": "", + "Your subscription will renew on {date}.": "" +} diff --git a/ghost/i18n/locales/vi/newsletter.json b/ghost/i18n/locales/vi/newsletter.json new file mode 100644 index 00000000000..bd24d1dc525 --- /dev/null +++ b/ghost/i18n/locales/vi/newsletter.json @@ -0,0 +1,24 @@ +{ + "By {authors}": "", + "Comment": "", + "complimentary": "", + "Email": "", + "free": "", + "Keep reading": "", + "Less like this": "", + "Manage subscription": "", + "Member since": "", + "More like this": "", + "Name": "", + "paid": "", + "Subscription details": "", + "trialing": "", + "Unsubscribe": "", + "View in browser": "", + "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", + "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", + "Your subscription has expired.": "", + "Your subscription will expire on {date}.": "", + "Your subscription will renew on {date}.": "" +} diff --git a/ghost/i18n/locales/zh-Hant/newsletter.json b/ghost/i18n/locales/zh-Hant/newsletter.json new file mode 100644 index 00000000000..bd24d1dc525 --- /dev/null +++ b/ghost/i18n/locales/zh-Hant/newsletter.json @@ -0,0 +1,24 @@ +{ + "By {authors}": "", + "Comment": "", + "complimentary": "", + "Email": "", + "free": "", + "Keep reading": "", + "Less like this": "", + "Manage subscription": "", + "Member since": "", + "More like this": "", + "Name": "", + "paid": "", + "Subscription details": "", + "trialing": "", + "Unsubscribe": "", + "View in browser": "", + "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", + "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", + "Your subscription has expired.": "", + "Your subscription will expire on {date}.": "", + "Your subscription will renew on {date}.": "" +} diff --git a/ghost/i18n/locales/zh/newsletter.json b/ghost/i18n/locales/zh/newsletter.json new file mode 100644 index 00000000000..bd24d1dc525 --- /dev/null +++ b/ghost/i18n/locales/zh/newsletter.json @@ -0,0 +1,24 @@ +{ + "By {authors}": "", + "Comment": "", + "complimentary": "", + "Email": "", + "free": "", + "Keep reading": "", + "Less like this": "", + "Manage subscription": "", + "Member since": "", + "More like this": "", + "Name": "", + "paid": "", + "Subscription details": "", + "trialing": "", + "Unsubscribe": "", + "View in browser": "", + "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", + "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", + "Your subscription has expired.": "", + "Your subscription will expire on {date}.": "", + "Your subscription will renew on {date}.": "" +} diff --git a/ghost/i18n/package.json b/ghost/i18n/package.json index 3a0da95e025..ad7c54b5e5c 100644 --- a/ghost/i18n/package.json +++ b/ghost/i18n/package.json @@ -13,12 +13,13 @@ "lint:code": "eslint *.js lib/ --ext .js --cache", "lint": "yarn lint:code && yarn lint:test", "lint:test": "eslint -c test/.eslintrc.js test/ --ext .js --cache", - "translate": "yarn translate:ghost && yarn translate:portal && yarn translate:signup-form && yarn translate:comments && yarn translate:search && node generate-context.js", + "translate": "yarn translate:ghost && yarn translate:portal && yarn translate:signup-form && yarn translate:comments && yarn translate:search && yarn translate:newsletter && node generate-context.js", "translate:ghost": "NAMESPACE=ghost i18next '../core/core/{frontend,server,shared}/**/*.{js,jsx}'", "translate:portal": "NAMESPACE=portal i18next '../../apps/portal/src/**/*.{js,jsx}'", "translate:signup-form": "NAMESPACE=signup-form i18next '../../apps/signup-form/src/**/*.{ts,tsx}'", "translate:comments": "NAMESPACE=comments i18next '../../apps/comments-ui/src/**/*.{ts,tsx}'", - "translate:search": "NAMESPACE=search i18next '../../apps/sodo-search/src/**/*.{js,jsx,ts,tsx}'" + "translate:search": "NAMESPACE=search i18next '../../apps/sodo-search/src/**/*.{js,jsx,ts,tsx}'", + "translate:newsletter": "NAMESPACE=newsletter i18next '../email-service/{lib/email-templates/*.hbs,lib/email-templates/partials/*.hbs,lib/*.js}'" }, "files": [ "index.js", diff --git a/ghost/i18n/test/i18n.test.js b/ghost/i18n/test/i18n.test.js index 1c755b0f640..fd4a744a7b2 100644 --- a/ghost/i18n/test/i18n.test.js +++ b/ghost/i18n/test/i18n.test.js @@ -90,4 +90,10 @@ describe('i18n', function () { } }); }); + describe('newsletter i18n', function () { + it('should be able to translate and interpolate a date', async function () { + const t = i18n('fr', 'newsletter').t; + assert.equal(t('Your subscription will renew on {date}.', {date: '8 Oct 2024'}), 'Votre abonnement sera renouvelé le 8 Oct 2024.'); + }); + }); }); diff --git a/yarn.lock b/yarn.lock index 11dce4acc32..c472e9df8ee 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7608,10 +7608,10 @@ dependencies: "@tryghost/kg-clean-basic-html" "4.1.1" -"@tryghost/kg-unsplash-selector@0.2.5": - version "0.2.5" - resolved "https://registry.yarnpkg.com/@tryghost/kg-unsplash-selector/-/kg-unsplash-selector-0.2.5.tgz#3c012858b050d4144532db0254d49bbd8c41ae9c" - integrity sha512-z/fERvZX+eWzve+4iqkFkztlBaZ/dtimQ//dwJUqCU3ghtJh3PLpWBGF0vGzDe8r+1wD84twGnkRLyZGoUy3Pw== +"@tryghost/kg-unsplash-selector@0.2.6": + version "0.2.6" + resolved "https://registry.yarnpkg.com/@tryghost/kg-unsplash-selector/-/kg-unsplash-selector-0.2.6.tgz#bf605bc2e43fc66d1e47f78ed7c94a1ae3cd02f2" + integrity sha512-dLCXR+tB18/2tI9tZW4EEebMS1+MAsxEhqTJpXgj4Sf3WKuMR9OholpJrk7LnWABvv0jyT9RXSzp/+tZH6MJTA== "@tryghost/kg-utils@1.0.28": version "1.0.28" @@ -7620,10 +7620,10 @@ dependencies: semver "^7.6.2" -"@tryghost/koenig-lexical@1.3.26": - version "1.3.26" - resolved "https://registry.yarnpkg.com/@tryghost/koenig-lexical/-/koenig-lexical-1.3.26.tgz#22a222d7439c711593bc3ce679ab73e8c7ec7d1c" - integrity sha512-Nj4OZJkkUCMv5vikHk1M+irz8zEoV+6ugpV8fvyQ/UWOe6BCej03As1jm9ZCWsFc080oyJYHxNOh5vz34mAKOg== +"@tryghost/koenig-lexical@1.3.27": + version "1.3.27" + resolved "https://registry.yarnpkg.com/@tryghost/koenig-lexical/-/koenig-lexical-1.3.27.tgz#d98a11b7c21b0d45715947dac5a99b4e21009e16" + integrity sha512-TTpiJSKDQ8BL2szN1J7gKUEUUz04i/POeI2x5eayUVy6AYeNnxBR1+t0C6fewAUY3um4+UooTDMsmi9WpSPFBw== "@tryghost/limit-service@1.2.14": version "1.2.14" @@ -9095,7 +9095,7 @@ abstract-leveldown@~0.12.0, abstract-leveldown@~0.12.1: dependencies: xtend "~3.0.0" -accepts@~1.3.4, accepts@~1.3.5, accepts@~1.3.8: +accepts@~1.3.4, accepts@~1.3.8: version "1.3.8" resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e" integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw== @@ -11936,11 +11936,6 @@ bytes@1: resolved "https://registry.yarnpkg.com/bytes/-/bytes-1.0.0.tgz#3569ede8ba34315fab99c3e92cb04c7220de1fa8" integrity sha512-/x68VkHLeTl3/Ll8IvxdwzhrT+IyKc52e/oyHhA2RwqPqswSnjVbSddfPRwAsJtbilMAPSRWwAlpxdYsSWOTKQ== -bytes@3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.0.0.tgz#d32815404d689699f85a4ea4fa8755dd13a96048" - integrity sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw== - bytes@3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" @@ -12998,24 +12993,24 @@ compress-commons@^4.1.0: normalize-path "^3.0.0" readable-stream "^3.6.0" -compressible@~2.0.16: +compressible@~2.0.18: version "2.0.18" resolved "https://registry.yarnpkg.com/compressible/-/compressible-2.0.18.tgz#af53cca6b070d4c3c0750fbd77286a6d7cc46fba" integrity sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg== dependencies: mime-db ">= 1.43.0 < 2" -compression@1.7.4, compression@^1.7.4: - version "1.7.4" - resolved "https://registry.yarnpkg.com/compression/-/compression-1.7.4.tgz#95523eff170ca57c29a0ca41e6fe131f41e5bb8f" - integrity sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ== +compression@1.7.5, compression@^1.7.4: + version "1.7.5" + resolved "https://registry.yarnpkg.com/compression/-/compression-1.7.5.tgz#fdd256c0a642e39e314c478f6c2cd654edd74c93" + integrity sha512-bQJ0YRck5ak3LgtnpKkiabX5pNF7tMUh1BSy2ZBOTh0Dim0BUu6aPPwByIns6/A5Prh8PufSPerMDUklpzes2Q== dependencies: - accepts "~1.3.5" - bytes "3.0.0" - compressible "~2.0.16" + bytes "3.1.2" + compressible "~2.0.18" debug "2.6.9" + negotiator "~0.6.4" on-headers "~1.0.2" - safe-buffer "5.1.2" + safe-buffer "5.2.1" vary "~1.1.2" concat-map@0.0.1: @@ -23770,11 +23765,16 @@ needle@^2.5.2: iconv-lite "^0.4.4" sax "^1.2.4" -negotiator@0.6.3, negotiator@^0.6.2, negotiator@^0.6.3: +negotiator@0.6.3: version "0.6.3" resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd" integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== +negotiator@^0.6.2, negotiator@^0.6.3, negotiator@~0.6.4: + version "0.6.4" + resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.4.tgz#777948e2452651c570b712dd01c23e262713fff7" + integrity sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w== + neo-async@^2.5.0, neo-async@^2.6.1, neo-async@^2.6.2: version "2.6.2" resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" From 90462c7f5c9bf7525af9eb1f90493cc8f4e6ddf9 Mon Sep 17 00:00:00 2001 From: Cathy Sarisky Date: Sun, 10 Nov 2024 11:01:07 -0500 Subject: [PATCH 02/12] tweaks --- ghost/core/test/unit/frontend/helpers/t.test.js | 2 +- .../unit/frontend/services/theme-engine/i18n.test.js | 2 +- .../frontend/services/theme-engine/theme-i18n.test.js | 2 +- ghost/email-service/lib/EmailRenderer.js | 10 ---------- 4 files changed, 3 insertions(+), 13 deletions(-) diff --git a/ghost/core/test/unit/frontend/helpers/t.test.js b/ghost/core/test/unit/frontend/helpers/t.test.js index e9dfe989782..6fcb736d1f2 100644 --- a/ghost/core/test/unit/frontend/helpers/t.test.js +++ b/ghost/core/test/unit/frontend/helpers/t.test.js @@ -1,7 +1,7 @@ const should = require('should'); const path = require('path'); const t = require('../../../../core/frontend/helpers/t'); -const themeI18n = require('../../../../core/frontend/services/theme-engine/i18n-off'); +const themeI18n = require('../../../../core/frontend/services/theme-engine/i18n'); describe('{{t}} helper', function () { let ogBasePath = themeI18n.basePath; diff --git a/ghost/core/test/unit/frontend/services/theme-engine/i18n.test.js b/ghost/core/test/unit/frontend/services/theme-engine/i18n.test.js index 45d211fd33d..c16c4e337f6 100644 --- a/ghost/core/test/unit/frontend/services/theme-engine/i18n.test.js +++ b/ghost/core/test/unit/frontend/services/theme-engine/i18n.test.js @@ -1,7 +1,7 @@ const should = require('should'); const sinon = require('sinon'); -const I18n = require('../../../../../core/frontend/services/theme-engine/i18n-off/I18n'); +const I18n = require('../../../../../core/frontend/services/theme-engine/i18n/I18n'); const logging = require('@tryghost/logging'); diff --git a/ghost/core/test/unit/frontend/services/theme-engine/theme-i18n.test.js b/ghost/core/test/unit/frontend/services/theme-engine/theme-i18n.test.js index 9cadf7f9db1..4c990408f3f 100644 --- a/ghost/core/test/unit/frontend/services/theme-engine/theme-i18n.test.js +++ b/ghost/core/test/unit/frontend/services/theme-engine/theme-i18n.test.js @@ -1,6 +1,6 @@ const should = require('should'); -const ThemeI18n = require('../../../../../core/frontend/services/theme-engine/i18n-off').ThemeI18n; +const ThemeI18n = require('../../../../../core/frontend/services/theme-engine/i18n').ThemeI18n; describe('ThemeI18n Class behavior', function () { it('defaults to en', function () { diff --git a/ghost/email-service/lib/EmailRenderer.js b/ghost/email-service/lib/EmailRenderer.js index 9a26c116ed5..8d929daf8a1 100644 --- a/ghost/email-service/lib/EmailRenderer.js +++ b/ghost/email-service/lib/EmailRenderer.js @@ -31,15 +31,6 @@ const messages = { } }; -// this is required to trigger the t parser -/* -t('Your subscription has expired.'); -t('Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.'); -t('Your subscription will renew on {date}.'); -t('Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.'); -t('Your subscription will expire on {date}.'); -*/ - function escapeHtml(unsafe) { return unsafe .replace(/&/g, '&') @@ -597,7 +588,6 @@ class EmailRenderer { * @param {MemberLike} member * @returns {string} */ - getMemberStatusText(member) { const t = this.#t; const locale = this.#getValidLocale(); From ea5f4704852c3ac221c830097280e4978d158905 Mon Sep 17 00:00:00 2001 From: Cathy Sarisky Date: Sun, 10 Nov 2024 11:14:06 -0500 Subject: [PATCH 03/12] tweaks. --- .../core/frontend/services/theme-engine/i18n/I18n.js | 6 +++--- ghost/i18n/lib/i18n.js | 11 +++++------ 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/ghost/core/core/frontend/services/theme-engine/i18n/I18n.js b/ghost/core/core/frontend/services/theme-engine/i18n/I18n.js index 800f5d58932..f6ff9e33b5d 100644 --- a/ghost/core/core/frontend/services/theme-engine/i18n/I18n.js +++ b/ghost/core/core/frontend/services/theme-engine/i18n/I18n.js @@ -10,8 +10,8 @@ const isEqual = require('lodash/isEqual'); const isNil = require('lodash/isNil'); const merge = require('lodash/merge'); const get = require('lodash/get'); -const i18nLib = require('@tryghost/i18n'); -const i18n = require('@tryghost/i18n/lib/i18n'); +//const i18nLib = require('@tryghost/i18n'); +//const i18n = require('@tryghost/i18n/lib/i18n'); class I18n { /** @@ -109,7 +109,7 @@ class I18n { lng: this.locale(), ns: 'theme' }); - return(i18n.t(key, hash)); + return i18n.t(key, hash); } /** * Attempt to load strings from a file diff --git a/ghost/i18n/lib/i18n.js b/ghost/i18n/lib/i18n.js index dcafeb3e571..8a80664cd2f 100644 --- a/ghost/i18n/lib/i18n.js +++ b/ghost/i18n/lib/i18n.js @@ -88,16 +88,15 @@ module.exports = (lng = 'en', ns = 'portal') => { }, {}); } else { resources = { - "en": { - "theme": require(`../locales/en/theme.json`) + en: { + theme: require(`../locales/en/theme.json`) }, - "fr": { - "theme": require(`../locales/fr/theme.json`) + fr: { + theme: require(`../locales/fr/theme.json`) } - } + }; } - i18nextInstance.init({ lng, From 38355b903f488d8d031c15e6e74eb17a290c51a1 Mon Sep 17 00:00:00 2001 From: Cathy Sarisky Date: Sun, 4 May 2025 10:07:58 -0400 Subject: [PATCH 04/12] update to latest, add test --- ghost/i18n/lib/i18n.js | 4 +++- ghost/i18n/test/i18n.test.js | 10 ++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/ghost/i18n/lib/i18n.js b/ghost/i18n/lib/i18n.js index b8445a00d15..70cbf537464 100644 --- a/ghost/i18n/lib/i18n.js +++ b/ghost/i18n/lib/i18n.js @@ -120,9 +120,11 @@ module.exports = (lng = 'en', ns = 'portal') => { keySeparator: false, // if the value is an empty string, return the key + // this allows empty strings for the en files, and causes all other languages to fallback to en. returnEmptyString: false, - // do not load a fallback + // load en as the fallback for any missing language. + // load nb as the fallback for no for backwards compatibility fallbackLng: { no: ['nb', 'en'], default: ['en'] diff --git a/ghost/i18n/test/i18n.test.js b/ghost/i18n/test/i18n.test.js index 83ce3dbcf8f..b8b5872269b 100644 --- a/ghost/i18n/test/i18n.test.js +++ b/ghost/i18n/test/i18n.test.js @@ -158,4 +158,14 @@ describe('i18n', function () { assert.deepEqual(resources.xx, englishResources.en); }); }); + describe('it can translate a theme resource', function () { + it('should fall back to en for a key that is missing in the locale', async function () { + const t = i18n('fr', 'theme').t; + assert.equal(t('Back'), 'Back'); + }); + it('should translate a key that is present in the locale file', async function () { + const t = i18n('fr', 'theme').t; + assert.equal(t('Read more'), 'Lirer plus'); + }); + }); }); From 02a147bac36af247e7e75035e815d09ef79400e0 Mon Sep 17 00:00:00 2001 From: Cathy Sarisky Date: Sun, 4 May 2025 11:19:44 -0400 Subject: [PATCH 05/12] rip out most of the theme i18n, fix tests and squash bugs/missing functionality --- ghost/core/core/frontend/helpers/t.js | 7 +- .../services/theme-engine/i18n/I18n.js | 326 ------------------ .../services/theme-engine/i18n/ThemeI18n.js | 96 +++--- .../services/theme-engine/i18n.test.js | 107 ++---- .../services/theme-engine/theme-i18n.test.js | 7 +- ghost/i18n/lib/i18n.js | 55 ++- ghost/i18n/locales/en/theme.json | 3 - ghost/i18n/locales/fr/theme.json | 3 - ghost/i18n/test/i18n.test.js | 211 +++++++++++- 9 files changed, 337 insertions(+), 478 deletions(-) delete mode 100644 ghost/core/core/frontend/services/theme-engine/i18n/I18n.js delete mode 100644 ghost/i18n/locales/en/theme.json delete mode 100644 ghost/i18n/locales/fr/theme.json diff --git a/ghost/core/core/frontend/helpers/t.js b/ghost/core/core/frontend/helpers/t.js index cd4cdcc40d3..ba9b8a8bba6 100644 --- a/ghost/core/core/frontend/helpers/t.js +++ b/ghost/core/core/frontend/helpers/t.js @@ -17,7 +17,7 @@ module.exports = function t(text, options = {}) { // no-op: translation key is missing, return an empty string return ''; } - console.log('t got', text, options); + console.log('t got', text); const bindings = {}; let prop; for (prop in options.hash) { @@ -25,6 +25,7 @@ module.exports = function t(text, options = {}) { bindings[prop] = options.hash[prop]; } } - - return themeI18n.t(text, bindings); + let result = themeI18n.t(text, bindings); + console.log('t result', result); + return result; }; diff --git a/ghost/core/core/frontend/services/theme-engine/i18n/I18n.js b/ghost/core/core/frontend/services/theme-engine/i18n/I18n.js deleted file mode 100644 index f6ff9e33b5d..00000000000 --- a/ghost/core/core/frontend/services/theme-engine/i18n/I18n.js +++ /dev/null @@ -1,326 +0,0 @@ -const errors = require('@tryghost/errors'); -const logging = require('@tryghost/logging'); -const fs = require('fs-extra'); -const path = require('path'); -const MessageFormat = require('intl-messageformat'); -const jp = require('jsonpath'); -const isString = require('lodash/isString'); -const isObject = require('lodash/isObject'); -const isEqual = require('lodash/isEqual'); -const isNil = require('lodash/isNil'); -const merge = require('lodash/merge'); -const get = require('lodash/get'); -//const i18nLib = require('@tryghost/i18n'); -//const i18n = require('@tryghost/i18n/lib/i18n'); - -class I18n { - /** - * @param {object} [options] - * @param {string} options.basePath - the base path to the translations directory - * @param {string} [options.locale] - a locale string - * @param {string} [options.stringMode] - which mode our translation keys use - */ - constructor(options = {}) { - this._basePath = options.basePath || __dirname; - this._locale = options.locale || this.defaultLocale(); - this._stringMode = options.stringMode || 'dot'; - - this._strings = null; - } - - /** - * BasePath getter & setter used for testing - */ - set basePath(basePath) { - this._basePath = basePath; - } - - /** - * Need to call init after this - */ - get basePath() { - return this._basePath; - } - - /** - * English is our default locale - */ - defaultLocale() { - return 'en'; - } - - supportedLocales() { - return [this.defaultLocale()]; - } - - /** - * Exporting the current locale (e.g. "en") to make it available for other files as well, - * such as core/frontend/helpers/date.js and core/frontend/helpers/lang.js - */ - locale() { - return this._locale; - } - - /** - * Helper method to find and compile the given data context with a proper string resource. - * - * @param {string} translationPath Path within the JSON language file to desired string (ie: "errors.init.jsNotBuilt") - * @param {object} [bindings] - * @returns {string} - */ - /*t(translationPath, bindings) { - let string; - let msg; - - //string = this._findString(translationPath); - - return i18n.t(translationPath, bindings); - - // If the path returns an array (as in the case with anything that has multiple paragraphs such as emails), then - // loop through them and return an array of translated/formatted strings. Otherwise, just return the normal - // translated/formatted string. - if (Array.isArray(string)) { - msg = []; - string.forEach(function (s) { - msg.push(this._formatMessage(s, bindings)); - }); - } else { - msg = this._formatMessage(string, bindings); - } - - return msg; - } - - /** - * Setup i18n support: - * - Load proper language file into memory - */ - init() { - - - //this._strings = this._loadStrings(); - //this._initializeIntl(); - } - t(key, hash) { - const i18nLib = require('@tryghost/i18n'); - const i18nLanguage = this.locale() || 'en'; - const i18n = i18nLib(i18nLanguage, 'theme'); - i18n.init({ - lng: this.locale(), - ns: 'theme' - }); - return i18n.t(key, hash); - } - /** - * Attempt to load strings from a file - * - * @param {string} [locale] - * @returns {object} strings - */ - _loadStrings(locale) { - locale = locale || this.locale(); - - try { - return this._readTranslationsFile(locale); - } catch (err) { - if (err.code === 'ENOENT') { - this._handleMissingFileError(locale); - - if (locale !== this.defaultLocale()) { - this._handleFallbackToDefault(); - return this._loadStrings(this.defaultLocale()); - } - } else if (err instanceof SyntaxError) { - this._handleInvalidFileError(locale, err); - } else { - throw err; - } - - // At this point we've done all we can and strings must be an object - return {}; - } - } - - /** - * Do the lookup within the JSON file using jsonpath - * - * @param {String} msgPath - */ - _getCandidateString(msgPath) { - // Our default string mode is "dot" for dot-notation, e.g. $.something.like.this used in the backend - // Both jsonpath's dot-notation and bracket-notation start with '$' E.g.: $.store.book.title or $['store']['book']['title'] - // While bracket-notation allows any Unicode characters in keys (i.e. for themes / fulltext mode) E.g. $['Read more'] - // dot-notation allows only word characters in keys for backend messages (that is \w or [A-Za-z0-9_] in RegExp) - let jsonPath = `$.${msgPath}`; - let fallback = null; - - if (this._stringMode === 'fulltext') { - jsonPath = jp.stringify(['$', msgPath]); - // In fulltext mode we can use the passed string as a fallback - fallback = msgPath; - } - - try { - return jp.value(this._strings, jsonPath) || fallback; - } catch (err) { - this._handleInvalidKeyError(msgPath, err); - } - } - - /** - * Parse JSON file for matching locale, returns string giving path. - * - * @param {string} msgPath Path with in the JSON language file to desired string (ie: "errors.init.jsNotBuilt") - * @returns {string} - */ - _findString(msgPath, opts) { - const options = merge({log: true}, opts || {}); - let candidateString; - let matchingString; - - // no path? no string - if (!msgPath || msgPath.length === 0 || !isString(msgPath)) { - this._handleEmptyKeyError(); - return ''; - } - - // If not in memory, load translations for core - if (isNil(this._strings)) { - this._handleUninitialisedError(msgPath); - } - - candidateString = this._getCandidateString(msgPath); - - matchingString = candidateString || {}; - - if (isObject(matchingString) || isEqual(matchingString, {})) { - if (options.log) { - this._handleMissingKeyError(msgPath); - } - - matchingString = this._fallbackError(); - } - - return matchingString; - } - - _translationFileDirs() { - return [this.basePath]; - } - - // If we are passed a locale, use that, else use this.locale - _translationFileName(locale) { - return `${locale || this.locale()}.json`; - } - - /** - * Read the translations file - * Error handling to be done by consumer - * - * @param {string} locale - */ - _readTranslationsFile(locale) { - const filePath = path.join(...this._translationFileDirs(), this._translationFileName(locale)); - const content = fs.readFileSync(filePath, 'utf8'); - return JSON.parse(content); - } - - /** - * Format the string using the correct locale and applying any bindings - * @param {String} string - * @param {Object} bindings - */ - _formatMessage(string, bindings) { - let currentLocale = this.locale(); - let msg = new MessageFormat(string, currentLocale); - - try { - msg = msg.format(bindings); - } catch (err) { - this._handleFormatError(err); - - // fallback - msg = new MessageFormat(this._fallbackError(), currentLocale); - msg = msg.format(); - } - - return msg; - } - - /** - * [Private] Setup i18n support: - * - Polyfill node.js if it does not have Intl support or support for a particular locale - */ - _initializeIntl() { - let hasBuiltInLocaleData; - let IntlPolyfill; - - if (global.Intl) { - // Determine if the built-in `Intl` has the locale data we need. - hasBuiltInLocaleData = this.supportedLocales().every(function (locale) { - return Intl.NumberFormat.supportedLocalesOf(locale)[0] === locale && - Intl.DateTimeFormat.supportedLocalesOf(locale)[0] === locale; - }); - if (!hasBuiltInLocaleData) { - // `Intl` exists, but it doesn't have the data we need, so load the - // polyfill and replace the constructors with need with the polyfill's. - IntlPolyfill = require('intl'); - Intl.NumberFormat = IntlPolyfill.NumberFormat; - Intl.DateTimeFormat = IntlPolyfill.DateTimeFormat; - } - } else { - // No `Intl`, so use and load the polyfill. - global.Intl = require('intl'); - } - } - - _handleUninitialisedError(key) { - logging.warn(`i18n was used before it was initialised with key ${key}`); - this.init(); - } - - _handleFormatError(err) { - logging.error(err.message); - } - - _handleFallbackToDefault() { - logging.warn(`i18n is falling back to ${this.defaultLocale()}.json.`); - } - - _handleMissingFileError(locale) { - logging.warn(`i18n was unable to find ${locale}.json.`); - } - - _handleInvalidFileError(locale, err) { - logging.error(new errors.IncorrectUsageError({ - err, - message: `i18n was unable to parse ${locale}.json. Please check that it is valid JSON.` - })); - } - - _handleEmptyKeyError() { - logging.warn('i18n.t() was called without a key'); - } - - _handleMissingKeyError(key) { - logging.error(new errors.IncorrectUsageError({ - message: `i18n.t() was called with a key that could not be found: ${key}` - })); - } - - _handleInvalidKeyError(key, err) { - throw new errors.IncorrectUsageError({ - err, - message: `i18n.t() called with an invalid key: ${key}` - }); - } - - /** - * A really basic error for if everything goes wrong - */ - _fallbackError() { - return get(this._strings, 'errors.errors.anErrorOccurred', 'An error occurred'); - } -} - -module.exports = I18n; diff --git a/ghost/core/core/frontend/services/theme-engine/i18n/ThemeI18n.js b/ghost/core/core/frontend/services/theme-engine/i18n/ThemeI18n.js index 4df8fa1a854..7cc3be96a67 100644 --- a/ghost/core/core/frontend/services/theme-engine/i18n/ThemeI18n.js +++ b/ghost/core/core/frontend/services/theme-engine/i18n/ThemeI18n.js @@ -1,74 +1,68 @@ const errors = require('@tryghost/errors'); const logging = require('@tryghost/logging'); -const I18n = require('./I18n'); +const i18nLib = require('@tryghost/i18n'); +const path = require('path'); -class ThemeI18n extends I18n { +class ThemeI18n { /** - * @param {object} [options] + * @param {object} options * @param {string} options.basePath - the base path for the translation directory (e.g. where themes live) * @param {string} [options.locale] - a locale string */ - constructor(options = {}) { - super(options); - // We don't care what gets passed in, themes use fulltext mode - this._stringMode = 'fulltext'; + constructor(options) { + if (!options || !options.basePath) { + throw new Error('basePath is required'); + } + this._basePath = options.basePath; + this._locale = options.locale || 'en'; + this._activeTheme = null; + this._i18n = null; } /** - * Setup i18n support for themes: - * - Load correct language file into memory - * - * @param {object} options - * @param {String} options.activeTheme - name of the currently loaded theme - * @param {String} options.locale - name of the currently loaded locale - * + * BasePath getter & setter used for testing */ - init({activeTheme, locale} = {}) { - // This function is called during theme initialization, and when switching language or theme. - this._locale = locale || this._locale; - this._activetheme = activeTheme || this._activetheme; - - super.init(); - } - - _translationFileDirs() { - return [this.basePath, this._activetheme, 'locales']; + set basePath(basePath) { + this._basePath = basePath; } - _handleUninitialisedError(key) { - throw new errors.IncorrectUsageError({message: `Theme translation was used before it was initialised with key ${key}`}); + get basePath() { + return this._basePath; } - _handleFallbackToDefault() { - logging.warn(`Theme translations falling back to locales/${this.defaultLocale()}.json.`); - } - - _handleMissingFileError(locale) { - if (locale !== this.defaultLocale()) { - logging.warn(`Theme translations file locales/${locale}.json not found.`); + /** + * Setup i18n support for themes: + * - Load correct language file into memory + * + * @param {object} options + * @param {string} options.activeTheme - name of the currently loaded theme + * @param {string} options.locale - name of the currently loaded locale + */ + init(options) { + if (!options || !options.activeTheme) { + throw new Error('activeTheme is required'); } - } - _handleInvalidFileError(locale, err) { - logging.error(new errors.IncorrectUsageError({ - err, - message: `Theme translations unable to parse locales/${locale}.json. Please check that it is valid JSON.` - })); - } + this._locale = options.locale || this._locale; + this._activeTheme = options.activeTheme; - _handleEmptyKeyError() { - logging.warn('Theme translations {{t}} helper called without a translation key.'); + const themeLocalesPath = path.join(this._basePath, this._activeTheme, 'locales'); + this._i18n = i18nLib(this._locale, 'theme', { themePath: themeLocalesPath }); } - _handleMissingKeyError() { - // This case cannot be reached in themes as we use the key as the fallback - } - - _handleInvalidKeyError(key, err) { - throw new errors.IncorrectUsageError({ - err, - message: `Theme translations {{t}} helper called with an invalid translation key: ${key}` - }); + /** + * Helper method to find and compile the given data context with a proper string resource. + * + * @param {string} key - The translation key + * @param {object} [bindings] - Optional bindings for the translation + * @returns {string} + */ + t(key, bindings) { + if (!this._i18n) { + throw new errors.IncorrectUsageError({message: `Theme translation was used before it was initialised with key ${key}`}); + } + const result = this._i18n.t(key, bindings); + return typeof result === 'string' ? result : String(result); } } diff --git a/ghost/core/test/unit/frontend/services/theme-engine/i18n.test.js b/ghost/core/test/unit/frontend/services/theme-engine/i18n.test.js index c16c4e337f6..366fe844147 100644 --- a/ghost/core/test/unit/frontend/services/theme-engine/i18n.test.js +++ b/ghost/core/test/unit/frontend/services/theme-engine/i18n.test.js @@ -1,86 +1,55 @@ const should = require('should'); const sinon = require('sinon'); - -const I18n = require('../../../../../core/frontend/services/theme-engine/i18n/I18n'); - const logging = require('@tryghost/logging'); +const ThemeI18n = require('../../../../../core/frontend/services/theme-engine/i18n/ThemeI18n'); +const fs = require('fs-extra'); +const path = require('path'); -describe('I18n Class behavior', function () { - it('defaults to en', function () { - const i18n = new I18n(); - i18n.locale().should.eql('en'); - }); +describe('ThemeI18n Class behavior', function () { + let i18n; + const testBasePath = path.join(__dirname, '../../../utils/fixtures/themes/'); - it('can have a different locale set', function () { - const i18n = new I18n({locale: 'fr'}); - i18n.locale().should.eql('fr'); + beforeEach(function () { + i18n = new ThemeI18n({basePath: testBasePath}); }); - describe('file loading behavior', function () { - it('will fallback to en file correctly without changing locale', function () { - const i18n = new I18n({locale: 'fr'}); - - let fileSpy = sinon.spy(i18n, '_readTranslationsFile'); - - i18n.locale().should.eql('fr'); - i18n.init(); - - i18n.locale().should.eql('fr'); - fileSpy.calledTwice.should.be.true(); - fileSpy.secondCall.args[0].should.eql('en'); - }); + afterEach(function () { + sinon.restore(); }); - describe('translation key dot notation (default behavior)', function () { - const fakeStrings = { - test: {string: {path: 'I am correct'}} - }; - let i18n; - - beforeEach(function initBasicI18n() { - i18n = new I18n(); - sinon.stub(i18n, '_loadStrings').returns(fakeStrings); - i18n.init(); - }); - - it('correctly loads strings', function () { - i18n._strings.should.eql(fakeStrings); - }); - - it('correctly uses dot notation', function () { - i18n.t('test.string.path').should.eql('I am correct'); - }); - - it('uses key fallback correctly', function () { - const loggingStub = sinon.stub(logging, 'error'); - i18n.t('unknown.string').should.eql('An error occurred'); - sinon.assert.calledOnce(loggingStub); - }); + it('defaults to en', function () { + i18n._locale.should.eql('en'); + }); - it('errors for invalid strings', function () { - should(function () { - i18n.t('unknown string'); - }).throw('i18n.t() called with an invalid key: unknown string'); - }); + it('initializes with theme path', function () { + i18n.init({activeTheme: 'locale-theme', locale: 'de'}); + const result = i18n.t('Top left Button'); + result.should.eql('Oben Links.'); }); - describe('translation key fulltext notation (theme behavior)', function () { - const fakeStrings = {'Full text': 'I am correct'}; - let i18n; + it('falls back to en when translation not found', function () { + i18n.init({activeTheme: 'locale-theme', locale: 'fr'}); + const result = i18n.t('Top left Button'); + result.should.eql('Left Button on Top'); + }); - beforeEach(function initFulltextI18n() { - i18n = new I18n({stringMode: 'fulltext'}); - sinon.stub(i18n, '_loadStrings').returns(fakeStrings); - i18n.init(); - }); + it('uses key as fallback when no translation files exist', function () { + i18n.init({activeTheme: 'locale-theme-1.4', locale: 'de'}); + const result = i18n.t('Top left Button'); + result.should.eql('Top left Button'); + }); - afterEach(function () { - sinon.restore(); - }); + it('returns empty string for empty key', function () { + i18n.init({activeTheme: 'locale-theme', locale: 'en'}); + const result = i18n.t(''); + result.should.eql(''); + }); - it('correctly loads strings', function () { - i18n._strings.should.eql(fakeStrings); - }); + it('throws error if used before initialization', function () { + should(function () { + i18n.t('some key'); + }).throw('Theme translation was used before it was initialised with key some key'); + }); it('correctly uses fulltext with bracket notation', function () { i18n.t('Full text').should.eql('I am correct'); @@ -90,4 +59,4 @@ describe('I18n Class behavior', function () { i18n.t('unknown string').should.eql('unknown string'); }); }); -}); + diff --git a/ghost/core/test/unit/frontend/services/theme-engine/theme-i18n.test.js b/ghost/core/test/unit/frontend/services/theme-engine/theme-i18n.test.js index 4c990408f3f..c1dd21fe128 100644 --- a/ghost/core/test/unit/frontend/services/theme-engine/theme-i18n.test.js +++ b/ghost/core/test/unit/frontend/services/theme-engine/theme-i18n.test.js @@ -1,10 +1,9 @@ const should = require('should'); - -const ThemeI18n = require('../../../../../core/frontend/services/theme-engine/i18n').ThemeI18n; +const ThemeI18n = require('../../../../../core/frontend/services/theme-engine/i18n/ThemeI18n'); describe('ThemeI18n Class behavior', function () { it('defaults to en', function () { - const i18n = new ThemeI18n(); - i18n.locale().should.eql('en'); + const i18n = new ThemeI18n({basePath: '/some/path'}); + i18n._locale.should.eql('en'); }); }); diff --git a/ghost/i18n/lib/i18n.js b/ghost/i18n/lib/i18n.js index 70cbf537464..37586bead59 100644 --- a/ghost/i18n/lib/i18n.js +++ b/ghost/i18n/lib/i18n.js @@ -1,4 +1,6 @@ const i18next = require('i18next'); +const fs = require('fs-extra'); +const path = require('path'); const SUPPORTED_LOCALES = [ 'af', // Afrikaans @@ -86,12 +88,18 @@ function generateResources(locales, ns) { /** * @param {string} [lng] * @param {'ghost'|'portal'|'test'|'signup-form'|'comments'|'search'|'newsletter'|'theme'} ns - + * @param {object} [options] + * @param {string} [options.themePath] - Path to theme's locales directory for theme namespace */ -module.exports = (lng = 'en', ns = 'portal') => { +module.exports = (lng = 'en', ns = 'portal', options = {}) => { const i18nextInstance = i18next.createInstance(); - let interpolation = {}; - if (ns === 'newsletter') { + let interpolation = { + prefix: '{{', + suffix: '}}' + }; + + // Set single curly braces for theme and newsletter namespaces + if (ns === 'theme' || ns === 'newsletter') { interpolation = { prefix: '{', suffix: '}' @@ -102,14 +110,39 @@ module.exports = (lng = 'en', ns = 'portal') => { if (ns !== 'theme') { resources = generateResources(SUPPORTED_LOCALES, ns); } else { - resources = { - en: { - theme: require(`../locales/en/theme.json`) - }, - fr: { - theme: require(`../locales/fr/theme.json`) + // For theme namespace, we need to load translations from the theme's locales directory + resources = {}; + const themeLocalesPath = options.themePath; + + if (themeLocalesPath) { + // Try to load the requested locale first + try { + const localePath = path.join(themeLocalesPath, `${lng}.json`); + const content = fs.readFileSync(localePath, 'utf8'); + resources[lng] = { + theme: JSON.parse(content) + }; + } catch (err) { + // If the requested locale fails, try English as fallback + try { + const enPath = path.join(themeLocalesPath, 'en.json'); + const content = fs.readFileSync(enPath, 'utf8'); + resources[lng] = { + theme: JSON.parse(content) + }; + } catch (enErr) { + // If both fail, use an empty object + resources[lng] = { + theme: {} + }; + } } - }; + } else { + // If no theme path provided, use empty translations + resources[lng] = { + theme: {} + }; + } } i18nextInstance.init({ diff --git a/ghost/i18n/locales/en/theme.json b/ghost/i18n/locales/en/theme.json deleted file mode 100644 index f17ed711e3b..00000000000 --- a/ghost/i18n/locales/en/theme.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "Read more": "Read more" -} \ No newline at end of file diff --git a/ghost/i18n/locales/fr/theme.json b/ghost/i18n/locales/fr/theme.json deleted file mode 100644 index 362e6b07114..00000000000 --- a/ghost/i18n/locales/fr/theme.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "Read more": "Lirer plus" -} \ No newline at end of file diff --git a/ghost/i18n/test/i18n.test.js b/ghost/i18n/test/i18n.test.js index b8b5872269b..01270fca9d7 100644 --- a/ghost/i18n/test/i18n.test.js +++ b/ghost/i18n/test/i18n.test.js @@ -1,6 +1,7 @@ const assert = require('assert/strict'); const fs = require('fs/promises'); const path = require('path'); +const fsExtra = require('fs-extra'); const i18n = require('../'); @@ -158,14 +159,208 @@ describe('i18n', function () { assert.deepEqual(resources.xx, englishResources.en); }); }); - describe('it can translate a theme resource', function () { - it('should fall back to en for a key that is missing in the locale', async function () { - const t = i18n('fr', 'theme').t; - assert.equal(t('Back'), 'Back'); - }); - it('should translate a key that is present in the locale file', async function () { - const t = i18n('fr', 'theme').t; - assert.equal(t('Read more'), 'Lirer plus'); + + describe('theme resources', function () { + let themeLocalesPath; + let cleanup; + + beforeEach(async function () { + // Create a temporary theme locales directory + themeLocalesPath = path.join(__dirname, 'temp-theme-locales'); + await fsExtra.ensureDir(themeLocalesPath); + cleanup = async () => { + await fsExtra.remove(themeLocalesPath); + }; + }); + + afterEach(async function () { + await cleanup(); + }); + + it('loads translations from theme locales directory', async function () { + // Create test translation files + const enContent = { + 'Read more': 'Read more', + 'Subscribe': 'Subscribe' + }; + const frContent = { + 'Read more': 'Lire plus', + 'Subscribe': 'S\'abonner' + }; + + await fsExtra.writeJson(path.join(themeLocalesPath, 'en.json'), enContent); + await fsExtra.writeJson(path.join(themeLocalesPath, 'fr.json'), frContent); + + const t = i18n('fr', 'theme', {themePath: themeLocalesPath}).t; + assert.equal(t('Read more'), 'Lire plus'); + assert.equal(t('Subscribe'), 'S\'abonner'); + }); + + it('falls back to en when translation is missing', async function () { + // Create only English translation file + const enContent = { + 'Read more': 'Read more', + 'Subscribe': 'Subscribe' + }; + await fsExtra.writeJson(path.join(themeLocalesPath, 'en.json'), enContent); + + const t = i18n('fr', 'theme', {themePath: themeLocalesPath}).t; + assert.equal(t('Read more'), 'Read more'); + assert.equal(t('Subscribe'), 'Subscribe'); + }); + + it('uses empty translations when no files exist', async function () { + const t = i18n('fr', 'theme', {themePath: themeLocalesPath}).t; + assert.equal(t('Read more'), 'Read more'); + assert.equal(t('Subscribe'), 'Subscribe'); + }); + + it('handles invalid JSON files gracefully', async function () { + // Create invalid JSON file + await fsExtra.writeFile(path.join(themeLocalesPath, 'fr.json'), 'invalid json'); + + const t = i18n('fr', 'theme', {themePath: themeLocalesPath}).t; + assert.equal(t('Read more'), 'Read more'); + assert.equal(t('Subscribe'), 'Subscribe'); + }); + + it('initializes i18next with correct configuration', async function () { + const enContent = { + 'Read more': 'Read more' + }; + await fsExtra.writeJson(path.join(themeLocalesPath, 'en.json'), enContent); + + const instance = i18n('fr', 'theme', {themePath: themeLocalesPath}); + + // Verify i18next configuration + assert.equal(instance.language, 'fr'); + assert.deepEqual(instance.options.ns, ['theme']); + assert.equal(instance.options.defaultNS, 'theme'); + assert.equal(instance.options.fallbackLng.default[0], 'en'); + assert.equal(instance.options.returnEmptyString, false); + + // Verify resources are loaded correctly + const resources = instance.store.data; + assert(resources.fr); + assert(resources.fr.theme); + assert.equal(resources.fr.theme['Read more'], 'Read more'); + }); + + it('handles interpolation correctly', async function () { + const enContent = { + 'Welcome {name}': 'Welcome {name}' + }; + await fsExtra.writeJson(path.join(themeLocalesPath, 'en.json'), enContent); + + const t = i18n('en', 'theme', {themePath: themeLocalesPath}).t; + assert.equal(t('Welcome {name}', {name: 'John'}), 'Welcome John'); + }); + + it('interpolates variables in theme translations', async function () { + const enContent = { + 'Welcome, {name}': 'Welcome, {name}', + 'Hello {firstName} {lastName}': 'Hello {firstName} {lastName}' + }; + await fsExtra.writeJson(path.join(themeLocalesPath, 'en.json'), enContent); + + const t = i18n('en', 'theme', {themePath: themeLocalesPath}).t; + + // Test simple interpolation + assert.equal(t('Welcome, {name}', {name: 'John'}), 'Welcome, John'); + + // Test multiple variables + assert.equal( + t('Hello {firstName} {lastName}', {firstName: 'John', lastName: 'Doe'}), + 'Hello John Doe' + ); + }); + + it('uses single curly braces for theme namespace interpolation', async function () { + const enContent = { + 'Welcome, {name}': 'Welcome, {name}' + }; + await fsExtra.writeJson(path.join(themeLocalesPath, 'en.json'), enContent); + + const t = i18n('en', 'theme', {themePath: themeLocalesPath}).t; + assert.equal(t('Welcome, {name}', {name: 'John'}), 'Welcome, John'); + }); + + it('uses double curly braces for portal namespace interpolation', async function () { + const t = i18n('en', 'portal').t; + assert.equal(t('Welcome, {{name}}', {name: 'John'}), 'Welcome, John'); + }); + + it('uses single curly braces for newsletter namespace interpolation', async function () { + const t = i18n('en', 'newsletter').t; + assert.equal(t('Welcome, {name}', {name: 'John'}), 'Welcome, John'); + }); + }); + + describe('i18next initialization', function () { + it('initializes with correct default configuration', function () { + const instance = i18n('en', 'portal'); + + // Verify basic configuration + assert.equal(instance.language, 'en'); + assert.deepEqual(instance.options.ns, ['portal']); + assert.equal(instance.options.defaultNS, 'portal'); + assert.equal(instance.options.fallbackLng.default[0], 'en'); + assert.equal(instance.options.returnEmptyString, false); + assert.equal(instance.options.nsSeparator, false); + assert.equal(instance.options.keySeparator, false); + + // Verify interpolation configuration for portal namespace + assert.equal(instance.options.interpolation.prefix, '{{'); + assert.equal(instance.options.interpolation.suffix, '}}'); + }); + + it('initializes with correct theme configuration', function () { + const instance = i18n('en', 'theme', {themePath: '/path/to/theme'}); + + // Verify basic configuration + assert.equal(instance.language, 'en'); + assert.deepEqual(instance.options.ns, ['theme']); + assert.equal(instance.options.defaultNS, 'theme'); + assert.equal(instance.options.fallbackLng.default[0], 'en'); + assert.equal(instance.options.returnEmptyString, false); + assert.equal(instance.options.nsSeparator, false); + assert.equal(instance.options.keySeparator, false); + + // Verify interpolation configuration for theme namespace + assert.equal(instance.options.interpolation.prefix, '{'); + assert.equal(instance.options.interpolation.suffix, '}'); + }); + + it('initializes with correct newsletter configuration', function () { + const instance = i18n('en', 'newsletter'); + + // Verify basic configuration + assert.equal(instance.language, 'en'); + assert.deepEqual(instance.options.ns, ['newsletter']); + assert.equal(instance.options.defaultNS, 'newsletter'); + assert.equal(instance.options.fallbackLng.default[0], 'en'); + assert.equal(instance.options.returnEmptyString, false); + assert.equal(instance.options.nsSeparator, false); + assert.equal(instance.options.keySeparator, false); + + // Verify interpolation configuration for newsletter namespace + assert.equal(instance.options.interpolation.prefix, '{'); + assert.equal(instance.options.interpolation.suffix, '}'); + }); + + it('initializes with correct fallback language configuration', function () { + const instance = i18n('no', 'portal'); + + // Verify Norwegian fallback chain + assert.deepEqual(instance.options.fallbackLng.no, ['nb', 'en']); + assert.deepEqual(instance.options.fallbackLng.default, ['en']); + }); + + it('initializes with empty theme resources when no theme path provided', function () { + const instance = i18n('en', 'theme'); + + // Verify empty theme resources + assert.deepEqual(instance.store.data.en.theme, {}); }); }); }); From 08d0e392ce1636b96086d39b5fb410dd1bbcc569 Mon Sep 17 00:00:00 2001 From: Cathy Sarisky Date: Sun, 4 May 2025 11:40:26 -0400 Subject: [PATCH 06/12] work on interpolation --- ghost/core/core/frontend/helpers/t.js | 7 +++++-- ghost/i18n/lib/i18n.js | 7 +++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/ghost/core/core/frontend/helpers/t.js b/ghost/core/core/frontend/helpers/t.js index ba9b8a8bba6..df4c0f673f5 100644 --- a/ghost/core/core/frontend/helpers/t.js +++ b/ghost/core/core/frontend/helpers/t.js @@ -17,7 +17,7 @@ module.exports = function t(text, options = {}) { // no-op: translation key is missing, return an empty string return ''; } - console.log('t got', text); + const bindings = {}; let prop; for (prop in options.hash) { @@ -25,7 +25,10 @@ module.exports = function t(text, options = {}) { bindings[prop] = options.hash[prop]; } } + let result = themeI18n.t(text, bindings); - console.log('t result', result); + + // The helper should always return a string, not a SafeString + // HTML escaping is handled by the template engine based on whether {{ or {{{ was used return result; }; diff --git a/ghost/i18n/lib/i18n.js b/ghost/i18n/lib/i18n.js index 37586bead59..b56664421fb 100644 --- a/ghost/i18n/lib/i18n.js +++ b/ghost/i18n/lib/i18n.js @@ -106,6 +106,13 @@ module.exports = (lng = 'en', ns = 'portal', options = {}) => { }; } + // Only disable HTML escaping for theme namespace + /* Disabled for now - need work through security implications + + if (ns === 'theme') { + interpolation.escapeValue = false; + } */ + let resources; if (ns !== 'theme') { resources = generateResources(SUPPORTED_LOCALES, ns); From 196b9bdf771d7a111a53f921ce3aba6f826babab Mon Sep 17 00:00:00 2001 From: Cathy Sarisky Date: Sun, 4 May 2025 12:57:29 -0400 Subject: [PATCH 07/12] put {{{ function back. --- ghost/i18n/lib/i18n.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/ghost/i18n/lib/i18n.js b/ghost/i18n/lib/i18n.js index b56664421fb..ecd7e5bbd36 100644 --- a/ghost/i18n/lib/i18n.js +++ b/ghost/i18n/lib/i18n.js @@ -107,11 +107,9 @@ module.exports = (lng = 'en', ns = 'portal', options = {}) => { } // Only disable HTML escaping for theme namespace - /* Disabled for now - need work through security implications - - if (ns === 'theme') { + if (ns === 'theme') { interpolation.escapeValue = false; - } */ + } let resources; if (ns !== 'theme') { From c8e19cb7312474f5dfa1cbe5aacda42479c336d1 Mon Sep 17 00:00:00 2001 From: Cathy Sarisky Date: Sun, 4 May 2025 13:43:52 -0400 Subject: [PATCH 08/12] fix test lint --- ghost/i18n/test/i18n.test.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ghost/i18n/test/i18n.test.js b/ghost/i18n/test/i18n.test.js index 01270fca9d7..7c4ab41b21b 100644 --- a/ghost/i18n/test/i18n.test.js +++ b/ghost/i18n/test/i18n.test.js @@ -181,11 +181,11 @@ describe('i18n', function () { // Create test translation files const enContent = { 'Read more': 'Read more', - 'Subscribe': 'Subscribe' + Subscribe: 'Subscribe' }; const frContent = { 'Read more': 'Lire plus', - 'Subscribe': 'S\'abonner' + Subscribe: 'S\'abonner' }; await fsExtra.writeJson(path.join(themeLocalesPath, 'en.json'), enContent); @@ -200,7 +200,7 @@ describe('i18n', function () { // Create only English translation file const enContent = { 'Read more': 'Read more', - 'Subscribe': 'Subscribe' + Subscribe: 'Subscribe' }; await fsExtra.writeJson(path.join(themeLocalesPath, 'en.json'), enContent); From eeeaccc43fdae0fe10265eed12cd908fb96279de Mon Sep 17 00:00:00 2001 From: Cathy Sarisky Date: Sun, 4 May 2025 13:58:14 -0400 Subject: [PATCH 09/12] lint --- .../services/theme-engine/i18n/ThemeI18n.js | 7 +++---- .../frontend/services/theme-engine/i18n.test.js | 14 ++++++-------- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/ghost/core/core/frontend/services/theme-engine/i18n/ThemeI18n.js b/ghost/core/core/frontend/services/theme-engine/i18n/ThemeI18n.js index 7cc3be96a67..7c3ccb81da4 100644 --- a/ghost/core/core/frontend/services/theme-engine/i18n/ThemeI18n.js +++ b/ghost/core/core/frontend/services/theme-engine/i18n/ThemeI18n.js @@ -1,5 +1,4 @@ const errors = require('@tryghost/errors'); -const logging = require('@tryghost/logging'); const i18nLib = require('@tryghost/i18n'); const path = require('path'); @@ -11,7 +10,7 @@ class ThemeI18n { */ constructor(options) { if (!options || !options.basePath) { - throw new Error('basePath is required'); + throw new errors.IncorrectUsageError({message: 'basePath is required'}); } this._basePath = options.basePath; this._locale = options.locale || 'en'; @@ -40,14 +39,14 @@ class ThemeI18n { */ init(options) { if (!options || !options.activeTheme) { - throw new Error('activeTheme is required'); + throw new errors.IncorrectUsageError({message: 'activeTheme is required'}); } this._locale = options.locale || this._locale; this._activeTheme = options.activeTheme; const themeLocalesPath = path.join(this._basePath, this._activeTheme, 'locales'); - this._i18n = i18nLib(this._locale, 'theme', { themePath: themeLocalesPath }); + this._i18n = i18nLib(this._locale, 'theme', {themePath: themeLocalesPath}); } /** diff --git a/ghost/core/test/unit/frontend/services/theme-engine/i18n.test.js b/ghost/core/test/unit/frontend/services/theme-engine/i18n.test.js index 366fe844147..9a9454a82c9 100644 --- a/ghost/core/test/unit/frontend/services/theme-engine/i18n.test.js +++ b/ghost/core/test/unit/frontend/services/theme-engine/i18n.test.js @@ -1,8 +1,6 @@ const should = require('should'); const sinon = require('sinon'); -const logging = require('@tryghost/logging'); const ThemeI18n = require('../../../../../core/frontend/services/theme-engine/i18n/ThemeI18n'); -const fs = require('fs-extra'); const path = require('path'); describe('ThemeI18n Class behavior', function () { @@ -51,12 +49,12 @@ describe('ThemeI18n Class behavior', function () { }).throw('Theme translation was used before it was initialised with key some key'); }); - it('correctly uses fulltext with bracket notation', function () { - i18n.t('Full text').should.eql('I am correct'); - }); + it('correctly uses fulltext with bracket notation', function () { + i18n.t('Full text').should.eql('I am correct'); + }); - it('uses key fallback correctly', function () { - i18n.t('unknown string').should.eql('unknown string'); - }); + it('uses key fallback correctly', function () { + i18n.t('unknown string').should.eql('unknown string'); }); +}); From f9e176aa7246397d4626937809935ee9c5d5b8f1 Mon Sep 17 00:00:00 2001 From: Cathy Sarisky Date: Sun, 4 May 2025 15:45:55 -0400 Subject: [PATCH 10/12] various changes - do tests pass on CI now? --- .../services/theme-engine/i18n/ThemeI18n.js | 39 ++++++++++++++++++- .../core/test/unit/frontend/helpers/t.test.js | 25 +++++++----- .../services/theme-engine/i18n.test.js | 38 ++++++++++-------- .../services/theme-engine/theme-i18n.test.js | 9 ----- 4 files changed, 74 insertions(+), 37 deletions(-) delete mode 100644 ghost/core/test/unit/frontend/services/theme-engine/theme-i18n.test.js diff --git a/ghost/core/core/frontend/services/theme-engine/i18n/ThemeI18n.js b/ghost/core/core/frontend/services/theme-engine/i18n/ThemeI18n.js index 7c3ccb81da4..3d59722307b 100644 --- a/ghost/core/core/frontend/services/theme-engine/i18n/ThemeI18n.js +++ b/ghost/core/core/frontend/services/theme-engine/i18n/ThemeI18n.js @@ -1,6 +1,7 @@ const errors = require('@tryghost/errors'); const i18nLib = require('@tryghost/i18n'); const path = require('path'); +const fs = require('fs-extra'); class ThemeI18n { /** @@ -37,7 +38,7 @@ class ThemeI18n { * @param {string} options.activeTheme - name of the currently loaded theme * @param {string} options.locale - name of the currently loaded locale */ - init(options) { + async init(options) { if (!options || !options.activeTheme) { throw new errors.IncorrectUsageError({message: 'activeTheme is required'}); } @@ -46,7 +47,41 @@ class ThemeI18n { this._activeTheme = options.activeTheme; const themeLocalesPath = path.join(this._basePath, this._activeTheme, 'locales'); - this._i18n = i18nLib(this._locale, 'theme', {themePath: themeLocalesPath}); + + // Check if the theme path exists + const themePathExists = await fs.pathExists(themeLocalesPath); + + if (!themePathExists) { + // If the theme path doesn't exist, use the key as the translation + this._i18n = { + t: key => key + }; + return; + } + + // Initialize i18n with the theme path + // Note: @tryghost/i18n uses synchronous file operations internally + // This is fine in production but in tests we need to ensure the files exist first + try { + // Verify the locale file exists + const localePath = path.join(themeLocalesPath, `${this._locale}.json`); + await fs.access(localePath); + + // Initialize i18n + this._i18n = i18nLib(this._locale, 'theme', {themePath: themeLocalesPath}); + } catch (err) { + // If the requested locale fails, try English as fallback + try { + const enPath = path.join(themeLocalesPath, 'en.json'); + await fs.access(enPath); + this._i18n = i18nLib(this._locale, 'theme', {themePath: themeLocalesPath}); + } catch (enErr) { + // If both fail, use the key as the translation + this._i18n = { + t: key => key + }; + } + } } /** diff --git a/ghost/core/test/unit/frontend/helpers/t.test.js b/ghost/core/test/unit/frontend/helpers/t.test.js index 6fcb736d1f2..dfefdc28289 100644 --- a/ghost/core/test/unit/frontend/helpers/t.test.js +++ b/ghost/core/test/unit/frontend/helpers/t.test.js @@ -14,8 +14,13 @@ describe('{{t}} helper', function () { themeI18n.basePath = ogBasePath; }); - it('theme translation is DE', function () { - themeI18n.init({activeTheme: 'locale-theme', locale: 'de'}); + beforeEach(async function () { + // Reset the i18n instance before each test + themeI18n._i18n = null; + }); + + it('theme translation is DE', async function () { + await themeI18n.init({activeTheme: 'locale-theme', locale: 'de'}); let rendered = t.call({}, 'Top left Button', { hash: {} @@ -24,8 +29,8 @@ describe('{{t}} helper', function () { rendered.should.eql('Oben Links.'); }); - it('theme translation is EN', function () { - themeI18n.init({activeTheme: 'locale-theme', locale: 'en'}); + it('theme translation is EN', async function () { + await themeI18n.init({activeTheme: 'locale-theme', locale: 'en'}); let rendered = t.call({}, 'Top left Button', { hash: {} @@ -34,8 +39,8 @@ describe('{{t}} helper', function () { rendered.should.eql('Left Button on Top'); }); - it('[fallback] no theme translation file found for FR', function () { - themeI18n.init({activeTheme: 'locale-theme', locale: 'fr'}); + it('[fallback] no theme translation file found for FR', async function () { + await themeI18n.init({activeTheme: 'locale-theme', locale: 'fr'}); let rendered = t.call({}, 'Top left Button', { hash: {} @@ -44,8 +49,8 @@ describe('{{t}} helper', function () { rendered.should.eql('Left Button on Top'); }); - it('[fallback] no theme files at all, use key as translation', function () { - themeI18n.init({activeTheme: 'locale-theme-1.4', locale: 'de'}); + it('[fallback] no theme files at all, use key as translation', async function () { + await themeI18n.init({activeTheme: 'locale-theme-1.4', locale: 'de'}); let rendered = t.call({}, 'Top left Button', { hash: {} @@ -70,8 +75,8 @@ describe('{{t}} helper', function () { rendered.should.eql(''); }); - it('returns a translated string even if no options are passed', function () { - themeI18n.init({activeTheme: 'locale-theme', locale: 'en'}); + it('returns a translated string even if no options are passed', async function () { + await themeI18n.init({activeTheme: 'locale-theme', locale: 'en'}); let rendered = t.call({}, 'Top left Button'); diff --git a/ghost/core/test/unit/frontend/services/theme-engine/i18n.test.js b/ghost/core/test/unit/frontend/services/theme-engine/i18n.test.js index 9a9454a82c9..bf7c5319a5a 100644 --- a/ghost/core/test/unit/frontend/services/theme-engine/i18n.test.js +++ b/ghost/core/test/unit/frontend/services/theme-engine/i18n.test.js @@ -2,13 +2,16 @@ const should = require('should'); const sinon = require('sinon'); const ThemeI18n = require('../../../../../core/frontend/services/theme-engine/i18n/ThemeI18n'); const path = require('path'); +const fs = require('fs-extra'); describe('ThemeI18n Class behavior', function () { let i18n; - const testBasePath = path.join(__dirname, '../../../utils/fixtures/themes/'); + const testBasePath = path.join(__dirname, '../../../../utils/fixtures/themes/'); - beforeEach(function () { + beforeEach(async function () { i18n = new ThemeI18n({basePath: testBasePath}); + // Log the test fixtures directory structure + const themePath = path.join(testBasePath, 'locale-theme', 'locales'); }); afterEach(function () { @@ -19,26 +22,31 @@ describe('ThemeI18n Class behavior', function () { i18n._locale.should.eql('en'); }); - it('initializes with theme path', function () { - i18n.init({activeTheme: 'locale-theme', locale: 'de'}); + it('can have a different locale set', async function () { + await i18n.init({activeTheme: 'locale-theme', locale: 'fr'}); + i18n._locale.should.eql('fr'); + }); + + it('initializes with theme path', async function () { + await i18n.init({activeTheme: 'locale-theme', locale: 'de'}); const result = i18n.t('Top left Button'); result.should.eql('Oben Links.'); }); - it('falls back to en when translation not found', function () { - i18n.init({activeTheme: 'locale-theme', locale: 'fr'}); + it('falls back to en when translation not found', async function () { + await i18n.init({activeTheme: 'locale-theme', locale: 'fr'}); const result = i18n.t('Top left Button'); result.should.eql('Left Button on Top'); }); - it('uses key as fallback when no translation files exist', function () { - i18n.init({activeTheme: 'locale-theme-1.4', locale: 'de'}); + it('uses key as fallback when no translation files exist', async function () { + await i18n.init({activeTheme: 'locale-theme-1.4', locale: 'de'}); const result = i18n.t('Top left Button'); result.should.eql('Top left Button'); }); - it('returns empty string for empty key', function () { - i18n.init({activeTheme: 'locale-theme', locale: 'en'}); + it('returns empty string for empty key', async function () { + await i18n.init({activeTheme: 'locale-theme', locale: 'en'}); const result = i18n.t(''); result.should.eql(''); }); @@ -49,12 +57,10 @@ describe('ThemeI18n Class behavior', function () { }).throw('Theme translation was used before it was initialised with key some key'); }); - it('correctly uses fulltext with bracket notation', function () { - i18n.t('Full text').should.eql('I am correct'); - }); - - it('uses key fallback correctly', function () { - i18n.t('unknown string').should.eql('unknown string'); + it('uses key fallback correctly', async function () { + await i18n.init({activeTheme: 'locale-theme', locale: 'en'}); + const result = i18n.t('unknown string'); + result.should.eql('unknown string'); }); }); diff --git a/ghost/core/test/unit/frontend/services/theme-engine/theme-i18n.test.js b/ghost/core/test/unit/frontend/services/theme-engine/theme-i18n.test.js deleted file mode 100644 index c1dd21fe128..00000000000 --- a/ghost/core/test/unit/frontend/services/theme-engine/theme-i18n.test.js +++ /dev/null @@ -1,9 +0,0 @@ -const should = require('should'); -const ThemeI18n = require('../../../../../core/frontend/services/theme-engine/i18n/ThemeI18n'); - -describe('ThemeI18n Class behavior', function () { - it('defaults to en', function () { - const i18n = new ThemeI18n({basePath: '/some/path'}); - i18n._locale.should.eql('en'); - }); -}); From e95074d08ed816780251e08037dab364923e66e3 Mon Sep 17 00:00:00 2001 From: Cathy Sarisky Date: Sun, 4 May 2025 15:58:41 -0400 Subject: [PATCH 11/12] fix the lint --- .../core/test/unit/frontend/services/theme-engine/i18n.test.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/ghost/core/test/unit/frontend/services/theme-engine/i18n.test.js b/ghost/core/test/unit/frontend/services/theme-engine/i18n.test.js index bf7c5319a5a..6b9594c7e70 100644 --- a/ghost/core/test/unit/frontend/services/theme-engine/i18n.test.js +++ b/ghost/core/test/unit/frontend/services/theme-engine/i18n.test.js @@ -2,7 +2,6 @@ const should = require('should'); const sinon = require('sinon'); const ThemeI18n = require('../../../../../core/frontend/services/theme-engine/i18n/ThemeI18n'); const path = require('path'); -const fs = require('fs-extra'); describe('ThemeI18n Class behavior', function () { let i18n; @@ -10,8 +9,6 @@ describe('ThemeI18n Class behavior', function () { beforeEach(async function () { i18n = new ThemeI18n({basePath: testBasePath}); - // Log the test fixtures directory structure - const themePath = path.join(testBasePath, 'locale-theme', 'locales'); }); afterEach(function () { From 45666b8f3ecb85af35148504c8a70e085057937c Mon Sep 17 00:00:00 2001 From: Cathy Sarisky <42299862+cathysarisky@users.noreply.github.com> Date: Sun, 4 May 2025 17:03:20 -0400 Subject: [PATCH 12/12] Update ghost/core/core/frontend/helpers/t.js revert unnecessary change in t. --- ghost/core/core/frontend/helpers/t.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/ghost/core/core/frontend/helpers/t.js b/ghost/core/core/frontend/helpers/t.js index df4c0f673f5..0d684fd9a09 100644 --- a/ghost/core/core/frontend/helpers/t.js +++ b/ghost/core/core/frontend/helpers/t.js @@ -26,9 +26,7 @@ module.exports = function t(text, options = {}) { } } - let result = themeI18n.t(text, bindings); - // The helper should always return a string, not a SafeString // HTML escaping is handled by the template engine based on whether {{ or {{{ was used - return result; + return themeI18n.t(text, bindings); };