From 2f830bbabdd0158b65c98a960ae5f3851e2dfbd7 Mon Sep 17 00:00:00 2001 From: Antonio Masoni <62446466+antoniomasoni@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:01:03 +0200 Subject: [PATCH 1/4] =?UTF-8?q?=F0=9F=8C=90=20Added=20Italian=20translatio?= =?UTF-8?q?ns=20to=20Ghost=20and=20Portal=20(#30529)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit no ref Added remaining Italian translations to Ghost and Portal sections. --- packages/i18n/locales/it/ghost.json | 70 +++++++------- packages/i18n/locales/it/portal.json | 132 +++++++++++++-------------- 2 files changed, 101 insertions(+), 101 deletions(-) diff --git a/packages/i18n/locales/it/ghost.json b/packages/i18n/locales/it/ghost.json index 20303660990..27b5cfb45fc 100644 --- a/packages/i18n/locales/it/ghost.json +++ b/packages/i18n/locales/it/ghost.json @@ -1,9 +1,9 @@ { - "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "", - "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", - "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "", - "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", - "{buyerName} sent you a gift": "", + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "{buyerName} ti ha regalato un abbonamento {tierName} di {duration} mesi a {siteTitle}", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "{buyerName} ti ha regalato un abbonamento {tierName} di {duration} anni a {siteTitle}", + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "{buyerName} ti ha regalato un abbonamento {tierName} di {duration} mesi a {siteTitle}", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "{buyerName} ti ha regalato un abbonamento {tierName} di {duration} anni a {siteTitle}", + "{buyerName} sent you a gift": "{buyerName} ti ha inviato un regalo", "{count} month_one": "{count} mese", "{count} month_many": "{count} mesi", "{count} month_other": "{count} mesi", @@ -12,7 +12,7 @@ "{count} year_other": "{count} anni", "{date}": "{date}", "{tierName} membership": "Abbonamento {tierName}", - "A gift, just for you": "", + "A gift, just for you": "Un regalo, solo per te", "All the best!": "A presto!", "Become a paid member of {site} to get access to all premium content.": "Abbonati a {site} per accedere a tutti i contenuti premium.", "By {authors}": "Di {authors}", @@ -37,7 +37,7 @@ "Hey there,": "Ciao,", "Hey there!": "Ciao!", "Hi {firstName},": "Ciao {firstName},", - "Hi {recipientName},": "", + "Hi {recipientName},": "Ciao {recipientName},", "If you did not make this request, you can safely ignore this email.": "Se non hai fatto tu questa richiesta, puoi tranquillamente ignorare questa email.", "If you did not make this request, you can simply delete this message.": "Se non hai fatto tu questa richiesta, puoi semplicemente cancellare questo messaggio.", "If you didn't try to sign in recently, you can safely ignore this email to deny access.": "Se non hai tentato di accedere di recente, puoi tranquillamente ignorare questa email per negare l'accesso.", @@ -55,12 +55,12 @@ "Or, skip the code and sign in directly": "Oppure, accedi direttamente senza il codice", "paid": "a pagamento", "Please confirm your email address with this link:": "Conferma il tuo indirizzo email a questo link:", - "Redeem your gift": "", + "Redeem your gift": "Riscatta il tuo regalo", "Redeem your gift subscription": "Riscatta il tuo abbonamento regalo", - "Redeem your gift:": "", + "Redeem your gift:": "Riscatta il tuo regalo:", "Secure sign in link for {siteTitle}": "Link per l'accesso sicuro a {siteTitle}", "See you soon!": "A presto!", - "Send the gift link manually so it can still be redeemed.": "", + "Send the gift link manually so it can still be redeemed.": "Invia manualmente il link del regalo affinché possa comunque essere riscattato.", "Sent to {email}": "Inviata a {email}", "Share": "Condividi", "Sign in": "Accedi", @@ -71,30 +71,30 @@ "Someone just replied to your comment": "Qualcuno ha appena risposto al tuo commento", "Someone just replied to your comment on {postTitle}.": "Qualcuno ha appena risposto al tuo commento su {postTitle}.", "Subscription details": "Dettagli", - "Support {siteTitle}": "", + "Support {siteTitle}": "Sostieni {siteTitle}", "Tap the link below to complete the signup process for {siteTitle}, and be automatically signed in:": "Clicca il link qui sotto per confermare l'iscrizione a {siteTitle}, ed effettuare automaticamente l'accesso:", "Thank you for signing up to {siteTitle}!": "Grazie per esserti iscritto a {siteTitle}!", "Thank you for subscribing to {siteTitle}!": "Grazie per esserti abbonato a {siteTitle}!", "Thank you for subscribing to {siteTitle}.": "Grazie per esserti abbonato a {siteTitle}.", "Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "Grazie per esserti abbonato a {siteTitle}. Clicca il link sottostante per effettuare automaticamente l'accesso:", "Thank you for supporting {siteTitle}.": "Grazie per il tuo supporto a {siteTitle}.", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "", - "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", - "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. You can also share the link below yourself.": "", - "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", - "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. You can also share the link below yourself.": "", - "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", - "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. You can also share the link below yourself.": "", - "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", - "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. You can also share the link below yourself.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "Grazie per il tuo supporto. Condividi il link qui sotto con la persona a cui desideri regalare un abbonamento {tierName} di {duration} mesi a {siteTitle}.", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "Grazie per il tuo supporto. Condividi il link qui sotto con la persona a cui desideri regalare un abbonamento {tierName} di {duration} anni a {siteTitle}.", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "Grazie per il tuo supporto. Condividi il link qui sotto con la persona a cui desideri regalare un abbonamento {tierName} di {duration} mesi a {siteTitle}.", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "Grazie per il tuo supporto. Condividi il link qui sotto con la persona a cui desideri regalare un abbonamento {tierName} di {duration} anni a {siteTitle}.", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "Grazie per il tuo supporto. Il tuo regalo — un abbonamento {tierName} di {duration} mesi a {siteTitle} — è in arrivo a {recipientEmail}. Puoi anche condividere personalmente il link qui sotto.", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. You can also share the link below yourself.": "Grazie per il tuo supporto. Il tuo regalo — un abbonamento {tierName} di {duration} mesi a {siteTitle} — verrà inviato a {recipientEmail} il {deliveryDate}. Puoi anche condividere personalmente il link qui sotto.", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "Grazie per il tuo supporto. Il tuo regalo — un abbonamento {tierName} di {duration} anni a {siteTitle} — è in arrivo a {recipientEmail}. Puoi anche condividere personalmente il link qui sotto.", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. You can also share the link below yourself.": "Grazie per il tuo supporto. Il tuo regalo — un abbonamento {tierName} di {duration} anni a {siteTitle} — verrà inviato a {recipientEmail} il {deliveryDate}. Puoi anche condividere personalmente il link qui sotto.", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "Grazie per il tuo supporto. Il tuo regalo — un abbonamento {tierName} di {duration} mesi a {siteTitle} — è in arrivo a {recipientEmail}. Puoi anche condividere personalmente il link qui sotto.", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. You can also share the link below yourself.": "Grazie per il tuo supporto. Il tuo regalo — un abbonamento {tierName} di {duration} mesi a {siteTitle} — verrà inviato a {recipientEmail} il {deliveryDate}. Puoi anche condividere personalmente il link qui sotto.", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "Grazie per il tuo supporto. Il tuo regalo — un abbonamento {tierName} di {duration} anni a {siteTitle} — è in arrivo a {recipientEmail}. Puoi anche condividere personalmente il link qui sotto.", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. You can also share the link below yourself.": "Grazie per il tuo supporto. Il tuo regalo — un abbonamento {tierName} di {duration} anni a {siteTitle} — verrà inviato a {recipientEmail} il {deliveryDate}. Puoi anche condividere personalmente il link qui sotto.", "Thanks for reading {siteTitle}.": "Grazie per leggere {siteTitle}.", "The link expires on {expiresAt} and can only be redeemed once.": "Il link scade il {expiresAt} e può essere riscattato una sola volta.", "This email address will not be used.": "Questo indirizzo email non verrà utilizzato.", - "This gift can only be redeemed once and expires on {expiresAt}.": "", - "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "", + "This gift can only be redeemed once and expires on {expiresAt}.": "Questo regalo può essere riscattato una sola volta e scade il {expiresAt}.", + "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "Questo messaggio è stato inviato da {siteDomain} a {email} per conto di {buyerName} ({buyerEmail}).", "This message was sent from {siteDomain} to {email}.": "Questo messaggio è stato inviato da {siteDomain} a {email}.", "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "Per mantenere il tuo abbonamento {tierName}, continua oggi con un abbonamento a pagamento e aggiungeremo automaticamente il restante periodo regalo come prova gratuita.", "trialing": "di prova", @@ -104,14 +104,14 @@ "Upgrade to continue reading.": "Abbonati per continuare a leggere.", "View comments": "Vedi commenti", "View in browser": "Vedi nel browser", - "We couldn't deliver your gift": "", - "We couldn't deliver your gift to {recipientEmail}. Send them the gift link below so they can redeem it.": "", + "We couldn't deliver your gift": "Non siamo riusciti a consegnare il tuo regalo", + "We couldn't deliver your gift to {recipientEmail}. Send them the gift link below so they can redeem it.": "Non siamo riusciti a consegnare il tuo regalo a {recipientEmail}. Invia il link del regalo qui sotto affinché possa essere riscattato.", "Welcome back to {siteTitle}!": "Bentornato su {siteTitle}!", "Welcome back to {siteTitle}! Your verification code is {otc}.": "Bentornato su {siteTitle}! Il tuo codice di verifica è {otc}.", "Welcome back! Here's your code to sign in to {siteTitle}": "Bentornato! Questo è il tuo codice per accedere a {siteTitle}", "Welcome back! Use this link to securely sign in to your {siteTitle} account:": "Bentornato! Usa questo link per accedere in sicurezza al tuo account su {siteTitle}:", - "What's included": "", - "What's included:": "", + "What's included": "Cosa è incluso", + "What's included:": "Cosa è incluso:", "When:": "Quando:", "Where:": "Dove:", "You are receiving this because you are a %%{status}%% subscriber to {site}.": "Ricevi questa mail perché hai un abbonamento %%{status}%% a {site}.", @@ -124,17 +124,17 @@ "You're one tap away from subscribing to {siteTitle}!": "Sei a un passo dall'abbonamento a {siteTitle}!", "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "Hai ricevuto in regalo un abbonamento {tierName} a {siteTitle} della durata di {duration} mese/i", "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "Hai ricevuto in regalo un abbonamento {tierName} a {siteTitle} della durata di {duration} anno/i", - "You've been gifted a membership to {siteTitle}": "", + "You've been gifted a membership to {siteTitle}": "Hai ricevuto in regalo un abbonamento a {siteTitle}", "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "La tua prova gratuita finisce il {date}, pagherai solo allora. Puoi cancellare l’abbonamento prima.", - "Your gift has been sent": "", - "Your gift is on its way": "", + "Your gift has been sent": "Il tuo regalo è stato inviato", + "Your gift is on its way": "Il tuo regalo è in arrivo", "Your gift is ready": "Il tuo regalo è pronto", - "Your gift is scheduled": "", + "Your gift is scheduled": "Il tuo regalo è programmato", "Your gift subscription confirmation": "Conferma del tuo abbonamento regalo", "Your gift subscription is ending soon": "Il tuo abbonamento regalo sta per terminare", "Your gift subscription to {siteTitle} ends on {consumesAt}.": "Il tuo abbonamento regalo a {siteTitle} terminerà il {consumesAt}.", - "Your gift was sent to {recipientEmail}. You can also share the link below yourself.": "", - "Your gift will be sent on {deliveryDate}": "", + "Your gift was sent to {recipientEmail}. You can also share the link below yourself.": "Il tuo regalo è stato inviato a {recipientEmail}. Puoi anche condividere personalmente il link qui sotto.", + "Your gift will be sent on {deliveryDate}": "Il tuo regalo verrà inviato il {deliveryDate}", "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "Il tuo abbonamento è stato annullato e scadrà il {date}. Puoi riattivare l'abbonamento dalle impostazioni del tuo account.", "Your subscription has expired.": "Il tuo abbonamento è scaduto.", "Your subscription will expire on {date}.": "Il tuo abbonamento scadrà il {date}.", diff --git a/packages/i18n/locales/it/portal.json b/packages/i18n/locales/it/portal.json index 2a99e29ad08..1023a722948 100644 --- a/packages/i18n/locales/it/portal.json +++ b/packages/i18n/locales/it/portal.json @@ -1,23 +1,23 @@ { - "(None)": "", + "(None)": "(Nessuno)", "(save {highestYearlyDiscount}%)": "(risparmia il {highestYearlyDiscount}%)", "{amount} days free": "{amount} giorni gratis", "{amount} off": "{amount} in meno", "{amount} off for first {number} months.": "{amount} in meno per i primi {number} mesi.", "{amount} off for first {period}.": "{amount} in meno per il primo {period}.", "{amount} off forever.": "{amount} in meno per sempre.", - "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}_one": "", - "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}_many": "", - "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}_other": "", - "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}_one": "{buyerName} ti ha regalato un abbonamento {tierName} di {duration} mese a {siteTitle}", + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}_many": "{buyerName} ti ha regalato un abbonamento {tierName} di {duration} mesi a {siteTitle}", + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}_other": "{buyerName} ti ha regalato un abbonamento {tierName} di {duration} mesi a {siteTitle}", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "{buyerName} ti ha regalato un abbonamento {tierName} di {duration} anni a {siteTitle}", "{discount}% discount": "{discount}% di sconto", - "{duration} membership": "", - "{field}: {message}": "", + "{duration} membership": "Abbonamento di {duration}", + "{field}: {message}": "{field}: {message}", "{memberEmail} will no longer receive {newsletterName} newsletter.": "La newsletter {newsletterName} non verrà più inviata a {memberEmail}.", "{memberEmail} will no longer receive emails when someone replies to your comments.": "Non riceverà più email a {memberEmail} quando qualcuno risponde ai tuoi commenti.", "{memberEmail} will no longer receive this newsletter.": "La newsletter non verrà più inviata a {memberEmail}.", - "{memberEmail} will no longer receive updates & announcements.": "", - "{months} month": "", + "{memberEmail} will no longer receive updates & announcements.": "{memberEmail} non riceverà più aggiornamenti e annunci.", + "{months} month": "{months} mese", "{months} months": "{months} mesi", "{months} months free": "{months} mesi gratis", "{tierName} membership": "Abbonamento {tierName}", @@ -33,9 +33,9 @@ "Account details updated successfully": "Dettagli dell'account aggiornati con successo", "Account settings": "Impostazioni account", "Add a personal note": "Aggiungi una nota personale", - "Add a short note to go with your gift": "", - "Address line 1": "", - "Address line 2": "", + "Add a short note to go with your gift": "Aggiungi un breve messaggio da accompagnare al tuo regalo", + "Address line 1": "Indirizzo (riga 1)", + "Address line 2": "Indirizzo (riga 2)", "After a free trial ends, you will be charged the regular price for the tier you've chosen. You can always cancel before then.": "Al termine della prova gratuita, ti verrà addebitato il prezzo regolare del piano scelto. Puoi annullare in qualsiasi momento prima della scadenza.", "Already a member?": "Sei già iscritto?", "An error occurred": "Si è verificato un errore", @@ -46,8 +46,8 @@ "Billing info & receipts": "Informazioni di fatturazione & ricevute", "Black Friday": "Black friday", "Bluesky": "Bluesky", - "Buy": "", - "Buying for someone else?": "", + "Buy": "Acquista", + "Buying for someone else?": "Stai acquistando per qualcun altro?", "Cancel anytime.": "Cancella in qualsiasi momento.", "Cancel subscription": "Cancella l'abbonamento", "Canceled": "Annullato", @@ -58,16 +58,16 @@ "Check with your mail provider": "Contatta il tuo provider di posta elettronica", "Check your inbox to verify email update": "Controlla la tua casella di posta per verificare l’aggiornamento dell’email", "Choose": "Scegli", - "Choose a date from today onwards": "", - "Choose a date within the next year": "", - "Choose a delivery date": "", + "Choose a date from today onwards": "Scegli una data a partire da oggi", + "Choose a date within the next year": "Scegli una data entro il prossimo anno", + "Choose a delivery date": "Scegli una data di consegna", "Choose a different plan": "Scegli un piano differente", "Choose a plan": "Scegli un piano", - "Choose a tier": "", + "Choose a tier": "Scegli un livello", "Choose your newsletters": "Scegli la tua newsletter", - "City": "", + "City": "Città", "Click here to retry": "Clicca qui per riprovare", - "Click the confirmation link in your inbox to finish redeeming your membership. If it doesn't arrive within 3 minutes, check your spam folder.": "", + "Click the confirmation link in your inbox to finish redeeming your membership. If it doesn't arrive within 3 minutes, check your spam folder.": "Fai clic sul link di conferma nella tua casella di posta per completare il riscatto del tuo abbonamento. Se non arriva entro 3 minuti, controlla la cartella spam.", "Close": "Chiudi", "Code": "Codice", "Comment preferences updated.": "Impostazioni dei commenti aggiornate.", @@ -79,8 +79,8 @@ "Contact support": "Contatta l'assistenza", "Continue": "Continua", "Continue subscription": "Riabbonati", - "Continue to delivery details": "", - "Continue to payment": "", + "Continue to delivery details": "Continua con i dettagli di consegna", + "Continue to payment": "Continua con il pagamento", "Continue with a paid subscription anytime. Your remaining gift period will be added as a free trial.": "Continua in qualsiasi momento con un abbonamento a pagamento. Il periodo regalo rimanente verrà aggiunto come prova gratuita.", "Copied": "Copiato", "Copy": "Copia", @@ -89,18 +89,18 @@ "Could not create Stripe checkout session": "Non è stato possibile creare la sessione di checkout di Stripe", "Could not sign in. Login link expired.": "Accesso non effettuato. Il link è scaduto.", "Could not update email! Invalid link.": "Email non aggiornata! Link non valido.", - "Country": "", + "Country": "Paese", "Create a new contact": "Crea un nuovo contatto", "Current plan": "Piano attuale", "Delete account": "Elimina account", - "Delivery date": "", - "Delivery method": "", + "Delivery date": "Data di consegna", + "Delivery method": "Metodo di consegna", "Didn't mean to do this? Manage your preferences .": "C’è qualcosa che non va? Gestisci le tue preferenze.", "Don't have an account?": "Non hai un account?", "Edit": "Modifica", "Email": "Email", "Email confirmation link expired.": "Link di conferma email scaduto.", - "Email it to them": "", + "Email it to them": "Invialo via email", "Email newsletter": "Newsletter", "Email newsletter settings updated": "Impostazioni delle newsletter aggiornate", "Email preferences": "Preferenze email", @@ -114,8 +114,8 @@ "Enjoy a free month on us.": "Goditi un mese gratuito offerto da noi.", "Enjoy a free month on us. You won't be charged until {newBillingDate}.": "Goditi un mese gratuito offerto da noi. Non ti verrà addebitato nulla fino al {newBillingDate}.", "Enter code above": "Inserisci il codice qui sopra", - "Enter the recipient's email address": "", - "Enter your email address": "Insirisci il tuo indirizzo email", + "Enter the recipient's email address": "Inserisci l'indirizzo email del destinatario", + "Enter your email address": "Inserisci il tuo indirizzo email", "Enter your name": "Inserisci il tuo nome", "Error": "Errore", "Expires {expiryDate}": "Scade il {expiryDate}", @@ -134,19 +134,19 @@ "Failed to update newsletter settings": "L’aggiornamento delle impostazioni della newsletter non è riuscito", "Failed to update subscription, please try again": "L’aggiornamento dell’abbonamento non è riuscito, riprova per favore", "Failed to verify code, please try again": "Verifica del codice non riuscita, per favore riprova", - "For a friend or colleague": "", + "For a friend or colleague": "Per un amico o un collega", "Forever": "Per sempre", "Free Trial – Ends {trialEnd}": "Prova gratuita – finisce il {trialEnd}", - "From": "", + "From": "Da", "Get help": "Chiedi aiuto", "Get in touch for help": "Richiedi assistenza", "Get notified when someone replies to your comment": "Ricevi una notifica quando qualcuno risponde a un tuo commento", "Gift a membership": "Regala un abbonamento", "Gift could not be redeemed": "Il regalo non può essere riscattato", "Gift details": "Dettagli del regalo", - "Gift duration": "", - "Gift link copied to clipboard": "", - "Gift membership": "", + "Gift duration": "Durata del regalo", + "Gift link copied to clipboard": "Link del regalo copiato negli appunti", + "Gift membership": "Regala un abbonamento", "Gift redeemed! You're all set.": "Regalo riscattato! È tutto pronto.", "Gift subscription": "Regala abbonamento", "Gift subscriptions are not available right now.": "Gli abbonamenti regalo non sono disponibili al momento.", @@ -154,10 +154,10 @@ "Give feedback on this post": "Manda un feedback per questo post", "Help! I'm not receiving emails": "Aiuto! Non ricevo le email", "Here are a few other sites you may enjoy.": "Ecco degli altri siti che potrebbero piacerti.", - "Hi {recipientName},": "", + "Hi {recipientName},": "Ciao {recipientName},", "Hide details": "Nascondi dettagli", - "How would you like to share this gift?": "", - "I'll share it myself": "", + "How would you like to share this gift?": "Come desideri condividere questo regalo?", + "I'll share it myself": "Lo condividerò personalmente", "If a newsletter is flagged as spam, emails are automatically disabled for that address to make sure you no longer receive any unwanted messages.": "Se una newsletter viene segnalata come spam, le email per quell'indirizzo vengono disabilitate automaticamente, in modo che tu non riceva nessun messaggio indesiderato.", "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.": "Se il reclamo per spam è stato commesso per errore, o nel caso tu voglia nuovamente ricevere le email, puoi iscriverti nuovamente cliccando il bottone nella schermata precedente.", "If you cancel your subscription now, you will continue to have access until {periodEnd}.": "Se annulli ora il tuo abbonamento, continuerai ad avere accesso fino al {periodEnd}.", @@ -192,9 +192,9 @@ "No thanks, I want to cancel": "No grazie, voglio annullare", "Not ready to share? We've also emailed a copy to your inbox.": "Non sei ancora pronto a condividerlo? Ti abbiamo anche inviato una copia nella tua casella email.", "Not receiving emails?": "Non ricevi le email?", - "Now": "", + "Now": "Ora", "Now check your email!": "Ora controlla la tua email!", - "Occasional updates from {siteTitle}": "", + "Occasional updates from {siteTitle}": "Aggiornamenti occasionali da {siteTitle}", "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.": "Se ancora non vedi le email una volta reiscritto, controlla nello spam. Alcuni provider tengono nota dei reclami e continuano a segnalare le email. Se questo dovesse succedere, segnala l'ultima email ricevuta come \"non spam\" e spostala nella tua posta in arrivo.", "Open AOL Mail": "Apri AOL Mail", "Open email": "Apri email", @@ -206,7 +206,7 @@ "Open Outlook": "Apri Outlook", "Open Proton Mail": "Apri Proton Mail", "Open Yahoo Mail": "Apri Yahoo Mail", - "Optional message": "", + "Optional message": "Messaggio facoltativo", "Permanent failure (bounce)": "Fallimento permanente (rimbalzo)", "Phone number": "Numero di telefono", "Plan": "Piano", @@ -216,12 +216,12 @@ "Please enter {fieldName}": "Per favore inserisci {fieldName}", "Please fill in required fields": "Si prega di compilare i campi obbligatori", "Podcasts": "Podcast", - "Postal code": "", + "Postal code": "Codice postale", "Re-enable emails": "Riattiva le email", - "Recipient's email": "", - "Recipient's name": "", + "Recipient's email": "Email del destinatario", + "Recipient's name": "Nome del destinatario", "Recommendations": "Consigliati", - "Redeem your gift": "", + "Redeem your gift": "Riscatta il tuo regalo", "Redeeming...": "Riscatto in corso...", "Renews at {price}.": "Rinnova a {price}.", "Resume subscription": "Riprendi abbonamento", @@ -234,7 +234,7 @@ "Sending...": "Invio...", "Share": "Condividi", "Share a full membership to {siteTitle} with a friend or colleague": "Condividi un abbonamento completo a {siteTitle} con un amico o collega", - "Share it yourself": "", + "Share it yourself": "Condividilo personalmente", "Show all": "Mostra tutti", "Sign in": "Accedi", "Sign out": "Esci", @@ -242,14 +242,14 @@ "Signup error: Invalid link": "Errore di accesso: link invalido", "Signups from this email domain are currently restricted.": "Le iscrizioni con questo dominio email sono attualmente limitate.", "Something went wrong, please try again later.": "Qualcosa è andato storto, riprova più tardi.", - "Sorry, no paid plans are available.": "", + "Sorry, no paid plans are available.": "Spiacenti, non sono disponibili piani a pagamento.", "Sorry, no recommendations are available right now.": "Ci dispiace, al momento non ci sono consigli disponibili.", "Sorry, that didn’t work.": "Ci dispiace, non ha funzionato.", "Spam complaints": "Reclami per spam", "Start {amount}-day free trial": "Inizia i {amount} giorni di prova gratuita", "Starting {startDate}": "Inizia il {startDate}", "Starting today": "Inizia oggi", - "State": "", + "State": "Stato o provincia", "Submit feedback": "Invia feedback", "Subscribe": "Abbonati", "Subscribed": "Abbonato", @@ -259,8 +259,8 @@ "Success! Your account is fully activated, you now have access to all content.": "Fatto! Il tuo account è stato attivato, ora hai accesso a tutti i contenuti.", "Success! Your email is updated.": "Fatto! La tua email è stata aggiornata.", "Successfully unsubscribed": "Disiscrizione effettuata con successo", - "Taylor Reid": "", - "taylor@example.com": "", + "Taylor Reid": "Taylor Reid", + "taylor@example.com": "taylor@example.com", "Thank you for subscribing. Before you start reading, below are a few other sites you may enjoy.": "Grazie per esserti iscritto. Prima della lettura, ecco alcuni siti che potrebbero interessarti.", "Thank you for your support": "Grazie per il tuo sostegno", "Thank you for your support!": "Grazie per il tuo sostegno!", @@ -272,20 +272,20 @@ "There was an error continuing your subscription, please try again.": "C'è stato un errore nella continuazione del tuo abbonamento, riprova per favore.", "There was an error processing your payment. Please try again.": "C'è stato un errore durante l’elaborazione del tuo pagamento. Riprova per favore.", "There was an error sending the email, please try again": "C'è stato un errore nell'invio dell'e-mail, per favore riprova", - "This gift can only be redeemed once and expires on {expiryDate}.": "", + "This gift can only be redeemed once and expires on {expiryDate}.": "Questo regalo può essere riscattato una sola volta e scade il {expiryDate}.", "This gift has already been consumed.": "Questo regalo è già stato utilizzato.", "This gift has already been redeemed.": "Questo regalo è già stato riscattato.", "This gift has been refunded.": "Questo regalo è stato rimborsato.", "This gift has expired.": "Questo regalo è scaduto.", - "This gift link is invalid.": "", + "This gift link is invalid.": "Questo link regalo non è valido.", "This site is invite-only, contact the owner for access.": "Questo sito è accessibile solo su invito, contatta il proprietario per poter accedere.", "This site is not accepting donations at the moment.": "Questo sito non accetta donazioni al momento.", "This site is not accepting payments at the moment.": "Questo sito non accetta pagamenti al momento.", "This site only accepts paid members.": "Questo sito è accessibile solo con abbonamento.", - "This unsubscribe link belongs to a different email address than the one you are signed in with.": "", + "This unsubscribe link belongs to a different email address than the one you are signed in with.": "Questo link per annullare l'iscrizione appartiene a un indirizzo email diverso da quello con cui hai effettuato l'accesso.", "Threads": "Threads", "Tier": "Livello", - "To": "", + "To": "A", "To complete signup, click the confirmation link in your inbox. If it doesn't arrive within 3 minutes, check your spam folder!": "Per completare l'iscrizione, clicca il link di conferma inviato alla tua email. Se non lo ricevi entro 3 minuti, controlla nello spam!", "To continue to stay up to date, subscribe to {publication} below.": "Per continuare a rimanere aggiornato, iscriviti a {publication} qui sotto.", "Too many attempts try again in {number} days.": "Troppi tentativi, riprova fra {number} giorni.", @@ -304,19 +304,19 @@ "Unsubscribing from emails will not cancel your paid subscription to {title}": "La disiscrizione dalle email non annullerà l'abbonamento a {title}", "Update": "Aggiorna", "Update your preferences": "Aggiorna le tue preferenze", - "Updates & announcements": "", + "Updates & announcements": "Aggiornamenti e annunci", "Verification link sent, check your inbox": "Link di verifica inviato, controlla la tua casella di posta", "Verify your email address is correct": "Controlla che il tuo indirizzo email sia corretto", "Verifying...": "Verificando...", "View plans": "Vedi piani", "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "Non è stato possibile disiscriverti poiché l'indirizzo email non è stato trovato. Si prega di contattare il proprietario del sito.", "We'd hate to see you leave. How about a special offer to stay?": "Ci dispiacerebbe vederti andare via. Che ne dici di un’offerta speciale per restare?", - "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.": "", - "We'll email it to the recipient. A copy will be in your inbox too.": "", + "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.": "Lo invieremo via email al destinatario il {deliveryDate}. Una copia è anche nella tua casella di posta.", + "We'll email it to the recipient. A copy will be in your inbox too.": "Lo invieremo via email al destinatario. Una copia sarà anche nella tua casella di posta.", "Welcome back, {name}!": "È bello rivederti, {name}!", "Welcome back!": "Bentornato!", "Welcome to {siteTitle}": "Benvenuto a {siteTitle}", - "What's included": "", + "What's included": "Cosa è incluso", "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.": "Quando una casella fallisce l'accettazione di un'email, questo viene normalmente chiamato \"rimbalzo\". In molti casi questo è temporaneo. Tuttavia, in alcuni casi un'email potrebbe tornare come un fallimento permanente se l'indirizzo è sbagliato o inesistente.", "Why has my email been disabled?": "Perché la mia email è stata disattivata?", "X (Twitter)": "X (Twitter)", @@ -329,22 +329,22 @@ "You're currently not receiving emails": "Al momento non ricevi nessuna email", "You're not receiving emails": "Non ricevi nessuna email", "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.": "Non ricevi email perché hai contrassegnato un messaggio recente come spam, o perché non è stato possibile recapitare i messaggi all'indirizzo email fornito.", - "You've been gifted a {duration}-month {tierName} membership_one": "", - "You've been gifted a {duration}-month {tierName} membership_many": "", - "You've been gifted a {duration}-month {tierName} membership_other": "", - "You've been gifted a {duration}-month {tierName} membership to {siteTitle}_one": "", - "You've been gifted a {duration}-month {tierName} membership to {siteTitle}_many": "", - "You've been gifted a {duration}-month {tierName} membership to {siteTitle}_other": "", - "You've been gifted a {duration}-year {tierName} membership": "", - "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "", + "You've been gifted a {duration}-month {tierName} membership_one": "Hai ricevuto in regalo un abbonamento {tierName} di {duration} mese", + "You've been gifted a {duration}-month {tierName} membership_many": "Hai ricevuto in regalo un abbonamento {tierName} di {duration} mesi", + "You've been gifted a {duration}-month {tierName} membership_other": "Hai ricevuto in regalo un abbonamento {tierName} di {duration} mesi", + "You've been gifted a {duration}-month {tierName} membership to {siteTitle}_one": "Hai ricevuto in regalo un abbonamento {tierName} di {duration} mese a {siteTitle}", + "You've been gifted a {duration}-month {tierName} membership to {siteTitle}_many": "Hai ricevuto in regalo un abbonamento {tierName} di {duration} mesi a {siteTitle}", + "You've been gifted a {duration}-month {tierName} membership to {siteTitle}_other": "Hai ricevuto in regalo un abbonamento {tierName} di {duration} mesi a {siteTitle}", + "You've been gifted a {duration}-year {tierName} membership": "Hai ricevuto in regalo un abbonamento {tierName} di {duration} anni", + "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "Hai ricevuto in regalo un abbonamento {tierName} di {duration} anni a {siteTitle}", "You've successfully signed in.": "Accesso effettuato.", "You've successfully subscribed to {siteTitle}": "Iscrizione effettuata a {siteTitle}", "Your account": "Il tuo account", "Your email": "La tua email", "Your email has failed to resubscribe, please try again": "La re-iscrizione della tua e-mail è fallita, per favore riprova", - "Your gift is on its way": "", + "Your gift is on its way": "Il tuo regalo è in arrivo", "Your gift is ready": "Il tuo regalo è pronto", - "Your gift is scheduled": "", + "Your gift is scheduled": "Il tuo regalo è programmato", "Your gift subscription will expire on {expiryDate}": "Il tuo abbonamento regalo scadrà il {expiryDate}", "your inbox": "la tua casella di posta", "Your input helps shape what gets published.": "Il tuo contributo aiuta a dare forma a ciò che viene pubblicato.", From 791d50c165b6efe9e992904d01f6df4d16b1b715 Mon Sep 17 00:00:00 2001 From: Steve Larson <9larsons@gmail.com> Date: Wed, 23 Sep 2026 14:41:51 +0200 Subject: [PATCH 2/4] Removed the tagDetailsReact labs flag (#30914) ref https://linear.app/ghost/issue/PLA-284 The React tag detail screen is generally available, so `tagDetailsReact` only kept an unreachable Ember path alive. This removes the flag and makes the released behavior unconditional. --- .../dunning/dunning-modal.acceptance.test.tsx | 2 +- .../src/ember-bridge/ember-bridge.test.tsx | 6 +- apps/admin/src/routes.tsx | 12 +- apps/admin/src/tag-detail-gate.test.tsx | 118 ----- apps/admin/src/tag-detail-gate.tsx | 17 - apps/admin/src/tags/api.ts | 3 +- .../detail/tag-detail.acceptance.test.tsx | 85 ++-- .../app/components/gh-search-input.js | 2 +- .../app/components/tags/delete-tag-modal.hbs | 19 - .../app/components/tags/delete-tag-modal.js | 29 -- .../app/components/tags/tag-form.hbs | 419 ------------------ .../app/components/tags/tag-form.js | 242 ---------- apps/ember-admin/app/controllers/tag.js | 64 --- apps/ember-admin/app/router.js | 3 - apps/ember-admin/app/routes/tag.js | 176 -------- apps/ember-admin/app/services/feature.js | 1 - apps/ember-admin/app/templates/tag.hbs | 38 -- .../tests/acceptance/error-handling-test.js | 15 - .../tests/acceptance/tag-react-flag-test.js | 119 ----- .../components/tags/tag-form-test.js | 254 ----------- .../ember-admin/tests/unit/routes/tag-test.js | 133 ------ .../tests/unit/services/state-bridge-test.js | 6 +- .../pages/admin/tags/tag-editor-page.ts | 15 +- e2e/tests/admin/tags/tag-detail.test.ts | 6 +- ghost/core/core/shared/labs.js | 2 +- 25 files changed, 49 insertions(+), 1737 deletions(-) delete mode 100644 apps/admin/src/tag-detail-gate.test.tsx delete mode 100644 apps/admin/src/tag-detail-gate.tsx delete mode 100644 apps/ember-admin/app/components/tags/delete-tag-modal.hbs delete mode 100644 apps/ember-admin/app/components/tags/delete-tag-modal.js delete mode 100644 apps/ember-admin/app/components/tags/tag-form.hbs delete mode 100644 apps/ember-admin/app/components/tags/tag-form.js delete mode 100644 apps/ember-admin/app/controllers/tag.js delete mode 100644 apps/ember-admin/app/routes/tag.js delete mode 100644 apps/ember-admin/app/templates/tag.hbs delete mode 100644 apps/ember-admin/tests/acceptance/tag-react-flag-test.js delete mode 100644 apps/ember-admin/tests/integration/components/tags/tag-form-test.js delete mode 100644 apps/ember-admin/tests/unit/routes/tag-test.js diff --git a/apps/admin/src/dunning/dunning-modal.acceptance.test.tsx b/apps/admin/src/dunning/dunning-modal.acceptance.test.tsx index f7a88c7fa47..d4475b48c97 100644 --- a/apps/admin/src/dunning/dunning-modal.acceptance.test.tsx +++ b/apps/admin/src/dunning/dunning-modal.acceptance.test.tsx @@ -30,7 +30,7 @@ it.each(['Dismiss', 'Pay now'])( fakeAdminEndpoint('GET', new RegExp(`^/tags/slug/${news.slug}/`), () => ({ tags: [news] })); const response = configResponse(); await renderAdminApp('/tags/news', { - labs: { dunningWarnings: true, tagDetailsReact: true }, + labs: { dunningWarnings: true }, boot: { browseConfig: { response: { diff --git a/apps/admin/src/ember-bridge/ember-bridge.test.tsx b/apps/admin/src/ember-bridge/ember-bridge.test.tsx index a01d079a2a9..7078c743b04 100644 --- a/apps/admin/src/ember-bridge/ember-bridge.test.tsx +++ b/apps/admin/src/ember-bridge/ember-bridge.test.tsx @@ -98,7 +98,7 @@ describe('useEmberFeatureFlag', () => { mock.stateBridge.isFeatureEnabled = vi.fn(() => enabled); window.EmberBridge = { state: mock.stateBridge }; - const { result } = renderHook(() => useEmberFeatureFlag('tagDetailsReact')); + const { result } = renderHook(() => useEmberFeatureFlag('postsListReact')); expect(result.current).toBe(false); enabled = true; @@ -112,7 +112,7 @@ describe('useEmberFeatureFlag', () => { const mock = createMockStateBridge(); mock.stateBridge.isFeatureEnabled = vi.fn(() => true); - const { result } = renderHook(() => useEmberFeatureFlag('tagDetailsReact')); + const { result } = renderHook(() => useEmberFeatureFlag('postsListReact')); expect(result.current).toBeUndefined(); window.EmberBridge = { state: mock.stateBridge }; @@ -129,7 +129,7 @@ describe('useEmberFeatureFlag', () => { mock.stateBridge.isFeatureEnabled = vi.fn(() => undefined); window.EmberBridge = { state: mock.stateBridge }; - const { result } = renderHook(() => useEmberFeatureFlag('tagDetailsReact')); + const { result } = renderHook(() => useEmberFeatureFlag('postsListReact')); expect(result.current).toBeNull(); }); diff --git a/apps/admin/src/routes.tsx b/apps/admin/src/routes.tsx index e68ef08385b..4b8b6872598 100644 --- a/apps/admin/src/routes.tsx +++ b/apps/admin/src/routes.tsx @@ -20,7 +20,6 @@ import HomeRedirect from './home-redirect'; import { EmberListWithGiftLinks } from './gift-link-modal-host'; import { EditorGate } from './editor-gate'; import { PagesListGate, PostsListGate } from './posts-list-gate'; -import { TagDetailGate } from './tag-detail-gate'; import { MemberActivityGate } from './member-activity-gate'; import { useFlagGatedRouteOwner } from './use-flag-gated-route-owner'; import { type AccessRouteHandle } from './route-access'; @@ -31,7 +30,7 @@ import { membersRouteChildren } from './members/api'; import { OnboardingRedirect, lazyOnboardingScreen } from './onboarding/api'; import { lazyPostAnalyticsRoot, postAnalyticsRouteChildren } from './posts/api'; import { canAccessSettingsRoute, lazySettingsScreen, settingsRouteChildren } from './settings/api'; -import { lazyTagsScreen } from './tags/api'; +import { lazyTagDetailScreen, lazyTagsScreen } from './tags/api'; import { canManageAutomations, canManageMembers, @@ -105,12 +104,9 @@ const appRoutes: RouteObject[] = [ // Covers both edit (`:tagSlug`) and create (the sentinel `new`) — // Ember's router declared `/tags/new` before `/tags/:tag_slug`, so a // tag with the literal slug "new" was already unreachable. - // - // TagDetailGate serves Ember or React depending on the - // `tagDetailsReact` Labs flag. path: '/tags/:tagSlug', - Component: TagDetailGate, handle: { requiresAccess: canManageTags } satisfies AccessRouteHandle, + lazy: lazyComponent(lazyTagDetailScreen), }, { path: '/members', @@ -232,7 +228,6 @@ export const routes: RouteObject[] = [ const EMBER_ROUTE_COMPONENTS = new Set([EmberFallback, EmberListWithGiftLinks]); export function useIsEmberOwnedRoute(pathname: string): boolean { - const tagDetailOwner = useFlagGatedRouteOwner('tagDetailsReact'); const postsListOwner = useFlagGatedRouteOwner('postsListReact'); const editorOwner = useFlagGatedRouteOwner('editorReact'); const memberActivityOwner = useFlagGatedRouteOwner('membersActivityReact'); @@ -240,9 +235,6 @@ export function useIsEmberOwnedRoute(pathname: string): boolean { if (!leaf) { return true; } - if (leaf.Component === TagDetailGate) { - return tagDetailOwner !== 'react'; - } if (leaf.Component === PostsListGate || leaf.Component === PagesListGate) { return postsListOwner !== 'react'; } diff --git a/apps/admin/src/tag-detail-gate.test.tsx b/apps/admin/src/tag-detail-gate.test.tsx deleted file mode 100644 index 7320db42248..00000000000 --- a/apps/admin/src/tag-detail-gate.test.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import React from 'react'; -import { TagDetailGate } from './tag-detail-gate'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { render, screen, waitFor } from '@testing-library/react'; - -const { mockUseBrowseConfig } = vi.hoisted(() => ({ - mockUseBrowseConfig: vi.fn(), -})); - -vi.mock('@tryghost/admin-x-framework/api/config', () => ({ - useBrowseConfig: mockUseBrowseConfig, -})); - -vi.mock('./ember-bridge', () => ({ - EmberFallback: () => React.createElement('div', { 'data-testid': 'ember-fallback' }), - useEmberFeatureFlag: (flag: string) => { - const stateBridge = window.EmberBridge?.state; - if (!stateBridge?.isFeatureEnabled) { - return undefined; - } - return stateBridge.isFeatureEnabled(flag) ?? null; - }, -})); - -vi.mock('./tags/detail/tag-detail', () => ({ - default: () => React.createElement('div', { 'data-testid': 'react-tag-detail' }), -})); - -const configResult = (overrides: Record) => ({ - data: undefined, - isError: false, - isLoading: false, - ...overrides, -}); - -const withLabs = (labs: Record) => configResult({ data: { config: { labs } } }); - -describe('TagDetailGate', () => { - beforeEach(() => { - mockUseBrowseConfig.mockReset(); - delete window.EmberBridge; - }); - - it('renders Ember while the flag is off', () => { - mockUseBrowseConfig.mockReturnValue(withLabs({ tagDetailsReact: false })); - - render(); - - expect(screen.getByTestId('ember-fallback')).toBeInTheDocument(); - }); - - it('renders React while the flag is on', async () => { - mockUseBrowseConfig.mockReturnValue(withLabs({ tagDetailsReact: true })); - - render(); - - // The React screen is lazily imported, so it arrives a tick later. - await waitFor(() => { - expect(screen.getByTestId('react-tag-detail')).toBeInTheDocument(); - }); - }); - - it('renders Ember when the flag is absent from config', () => { - mockUseBrowseConfig.mockReturnValue(withLabs({})); - - render(); - - expect(screen.getByTestId('ember-fallback')).toBeInTheDocument(); - }); - - it('renders Ember when the config query fails', () => { - // A failed config read must not blank the screen — Ember owns this URL - // by default and still serves it, so degrading to Ember keeps the tag - // detail working. - mockUseBrowseConfig.mockReturnValue(configResult({ isError: true, data: undefined })); - - render(); - - expect(screen.getByTestId('ember-fallback')).toBeInTheDocument(); - }); - - it('keeps React ownership when its config query fails but Ember has the flag', async () => { - mockUseBrowseConfig.mockReturnValue(configResult({ isError: true, data: undefined })); - window.EmberBridge = { - state: { - isFeatureEnabled: (flag: string) => flag === 'tagDetailsReact', - }, - } as unknown as typeof window.EmberBridge; - - render(); - - await waitFor(() => { - expect(screen.getByTestId('react-tag-detail')).toBeInTheDocument(); - }); - expect(screen.queryByTestId('ember-fallback')).not.toBeInTheDocument(); - }); - - it('renders Ember when the config query resolves with no data', () => { - mockUseBrowseConfig.mockReturnValue(configResult({ data: undefined })); - - render(); - - expect(screen.getByTestId('ember-fallback')).toBeInTheDocument(); - }); - - it('renders nothing while config is loading', () => { - // Deliberately not falling back to Ember here: doing so would un-hide - // the Ember shell and flash the old screen on every cold load for - // admins who have the flag on. - mockUseBrowseConfig.mockReturnValue(configResult({ isLoading: true })); - - const { container } = render(); - - expect(screen.queryByTestId('ember-fallback')).not.toBeInTheDocument(); - expect(screen.queryByTestId('react-tag-detail')).not.toBeInTheDocument(); - expect(container).toBeEmptyDOMElement(); - }); -}); diff --git a/apps/admin/src/tag-detail-gate.tsx b/apps/admin/src/tag-detail-gate.tsx deleted file mode 100644 index 08ae806362c..00000000000 --- a/apps/admin/src/tag-detail-gate.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { FlagGatedRoute } from './flag-gated-route'; -import { lazy } from 'react'; -import { lazyTagDetailScreen } from './tags/api'; - -/** - * Serves `/tags/:tagSlug` — covering both edit (`:tagSlug`) and create (the - * `new` sentinel) — from the React tag detail screen when the - * `tagDetailsReact` Labs flag is on, and from Ember otherwise. The gating - * semantics (loading, error, and flag branching) live in FlagGatedRoute. - */ -const TagDetailReact = lazy(lazyTagDetailScreen); - -export function TagDetailGate() { - return ; -} - -export default TagDetailGate; diff --git a/apps/admin/src/tags/api.ts b/apps/admin/src/tags/api.ts index fd349cf0707..16835f96d55 100644 --- a/apps/admin/src/tags/api.ts +++ b/apps/admin/src/tags/api.ts @@ -1,7 +1,6 @@ /** * Public surface of the tags domain, consumed by the admin shell - * (apps/admin/src/routes.tsx and the tag detail gate). Everything else in - * this domain is internal. + * (apps/admin/src/routes.tsx). Everything else in this domain is internal. */ // Lazy entries, not component re-exports: the shell mounts these behind diff --git a/apps/admin/src/tags/detail/tag-detail.acceptance.test.tsx b/apps/admin/src/tags/detail/tag-detail.acceptance.test.tsx index 81c655046cd..d417eee2e4e 100644 --- a/apps/admin/src/tags/detail/tag-detail.acceptance.test.tsx +++ b/apps/admin/src/tags/detail/tag-detail.acceptance.test.tsx @@ -17,8 +17,6 @@ import { sidebarScreen } from '@/layout/sidebar.screen'; import { tagsScreen } from '@/tags/tags.screen'; import { tagDetailScreen } from './tag-detail.screen'; -const FLAGS = { labs: { tagDetailsReact: true } }; - /** * A working single-tag fake: the slug read serves the latest state and writes * update it, so the post-save refetch reflects what was saved — the shape a @@ -34,11 +32,11 @@ function fakeTagWorld(t: Tag) { return saveApi; } -describe('Tag detail (tagDetailsReact on)', () => { +describe('Tag detail', () => { it('renders the seeded tag', async () => { const t = tag({ name: 'News', slug: 'news', description: 'All the news', count: { posts: 3 } }); fakeTagWorld(t); - await renderAdminApp(`/tags/${t.slug}`, FLAGS); + await renderAdminApp(`/tags/${t.slug}`); await expect.element(tagDetailScreen.title()).toHaveTextContent('News'); await expect.element(tagDetailScreen.internalBadge()).not.toBeInTheDocument(); @@ -64,7 +62,7 @@ describe('Tag detail (tagDetailsReact on)', () => { it('shows an internal badge after the name for internal tags', async () => { const t = tag({ name: '#News', slug: 'hash-news', visibility: 'internal' }); fakeTagWorld(t); - await renderAdminApp(`/tags/${t.slug}`, FLAGS); + await renderAdminApp(`/tags/${t.slug}`); await expect.element(tagDetailScreen.title()).toHaveTextContent('#News'); await expect.element(tagDetailScreen.internalBadge()).toHaveTextContent('INTERNAL'); @@ -73,7 +71,7 @@ describe('Tag detail (tagDetailsReact on)', () => { it('shows metadata in Search, X card, and Facebook card tabs', async () => { const t = tag({ name: 'News', slug: 'news' }); fakeTagWorld(t); - await renderAdminApp(`/tags/${t.slug}`, FLAGS); + await renderAdminApp(`/tags/${t.slug}`); const metadataCard = tagDetailScreen.metadataCard(); const searchTab = metadataCard.getByRole('tab', { name: 'Search' }); @@ -150,7 +148,7 @@ describe('Tag detail (tagDetailsReact on)', () => { codeinjection_foot: foot, }); const saveApi = fakeTagWorld(t); - await renderAdminApp(`/tags/${t.slug}`, FLAGS); + await renderAdminApp(`/tags/${t.slug}`); await expect .element(tagDetailScreen.codeInjectionTrigger()) @@ -192,7 +190,7 @@ describe('Tag detail (tagDetailsReact on)', () => { it('opens code injection when only the footer contains code', async () => { const t = tag({ name: 'News', slug: 'news', codeinjection_foot: '' }); fakeTagWorld(t); - await renderAdminApp(`/tags/${t.slug}`, FLAGS); + await renderAdminApp(`/tags/${t.slug}`); await expect .element(tagDetailScreen.codeInjectionTrigger()) @@ -203,7 +201,7 @@ describe('Tag detail (tagDetailsReact on)', () => { it('keeps CodeMirror autocomplete visible in the code injection accordion', async () => { const t = tag({ name: 'News', slug: 'news' }); fakeTagWorld(t); - await renderAdminApp(`/tags/${t.slug}`, FLAGS); + await renderAdminApp(`/tags/${t.slug}`); await tagDetailScreen.codeInjectionTrigger().click(); const headerEditor = tagDetailScreen.headerEditor(); @@ -225,11 +223,10 @@ describe('Tag detail (tagDetailsReact on)', () => { }); it('redirects to billing during a force upgrade', async () => { - const config = configResponse(FLAGS); + const config = configResponse(); config.config.hostSettings = { forceUpgrade: true }; await renderAdminApp('/tags/news', { - ...FLAGS, boot: { browseConfig: { response: config } }, }); @@ -241,7 +238,7 @@ describe('Tag detail (tagDetailsReact on)', () => { const t = tag({ name: 'News', slug: 'news', feature_image: null }); fakeTagWorld(t); fakeEndpoint('GET', 'https://api.unsplash.com/photos', []); - await renderAdminApp(`/tags/${t.slug}`, FLAGS); + await renderAdminApp(`/tags/${t.slug}`); await tagDetailScreen.unsplashButton().click(); @@ -253,7 +250,7 @@ describe('Tag detail (tagDetailsReact on)', () => { fakeTags([t]); fakeTagWorld(t); fakeEndpoint('GET', 'https://api.unsplash.com/photos', []); - await renderAdminApp(`/tags/${t.slug}`, FLAGS); + await renderAdminApp(`/tags/${t.slug}`); await tagDetailScreen.unsplashButton().click(); await expect.element(tagDetailScreen.unsplashHeading()).toBeVisible(); @@ -269,7 +266,7 @@ describe('Tag detail (tagDetailsReact on)', () => { it('saves edits and reports the saved state', async () => { const t = tag({ name: 'News', slug: 'news', visibility: 'public' }); const saveApi = fakeTagWorld(t); - await renderAdminApp(`/tags/${t.slug}`, FLAGS); + await renderAdminApp(`/tags/${t.slug}`); await tagDetailScreen.nameInput().fill('Renamed'); await tagDetailScreen.saveButton().click(); @@ -291,7 +288,7 @@ describe('Tag detail (tagDetailsReact on)', () => { meta_title: ' Meta title ', }); const saveApi = fakeTagWorld(t); - await renderAdminApp(`/tags/${t.slug}`, FLAGS); + await renderAdminApp(`/tags/${t.slug}`); await tagDetailScreen.saveButton().click(); @@ -305,7 +302,7 @@ describe('Tag detail (tagDetailsReact on)', () => { const t = tag({ name: 'News', slug: 'news', visibility: 'public' }); fakeTags([t]); fakeTagWorld(t); - await renderAdminApp(`/tags/${t.slug}`, FLAGS); + await renderAdminApp(`/tags/${t.slug}`); await tagDetailScreen.nameInput().fill('Renamed'); await tagDetailScreen.saveButton().click(); @@ -322,7 +319,7 @@ describe('Tag detail (tagDetailsReact on)', () => { const created = tag({ name: 'Weekly News', slug: 'weekly-news' }); const createApi = fakeAdminEndpoint('POST', new RegExp('^/tags/'), { tags: [created] }); fakeTagWorld(created); - await renderAdminApp('/tags/new', FLAGS); + await renderAdminApp('/tags/new'); await expect.element(tagDetailScreen.title()).toHaveTextContent('New tag'); const nameInput = tagDetailScreen.nameInput(); @@ -343,7 +340,7 @@ describe('Tag detail (tagDetailsReact on)', () => { it('shows validation errors instead of saving an invalid tag', async () => { const t = tag({ name: 'News', slug: 'news' }); const saveApi = fakeTagWorld(t); - await renderAdminApp(`/tags/${t.slug}`, FLAGS); + await renderAdminApp(`/tags/${t.slug}`); await tagDetailScreen.nameInput().fill(''); await tagDetailScreen.saveButton().click(); @@ -360,7 +357,7 @@ describe('Tag detail (tagDetailsReact on)', () => { const saveApi = fakeTagWorld(t); const pendingUpload = deferred<{ images: { url: string; ref: null }[] }>(); const uploadApi = fakeAdminEndpoint('POST', '/images/upload/', () => pendingUpload.promise); - await renderAdminApp(`/tags/${t.slug}`, FLAGS); + await renderAdminApp(`/tags/${t.slug}`); const uploadInput = tagDetailScreen.uploadImageInput(); await expect.element(uploadInput).toBeVisible(); @@ -387,7 +384,7 @@ describe('Tag detail (tagDetailsReact on)', () => { it('rejects image formats that the legacy uploader does not support', async () => { const t = tag({ name: 'News', slug: 'news', feature_image: null }); fakeTagWorld(t); - await renderAdminApp(`/tags/${t.slug}`, FLAGS); + await renderAdminApp(`/tags/${t.slug}`); await tagDetailScreen .uploadImageInput() @@ -402,7 +399,7 @@ describe('Tag detail (tagDetailsReact on)', () => { const t = tag({ name: 'News', slug: 'news', feature_image: null }); fakeTagWorld(t); fakeAdminEndpoint('POST', '/images/upload/', null, { status: 413 }); - await renderAdminApp(`/tags/${t.slug}`, FLAGS); + await renderAdminApp(`/tags/${t.slug}`); await tagDetailScreen .uploadImageInput() @@ -423,7 +420,7 @@ describe('Tag detail (tagDetailsReact on)', () => { fakeTags([first, second]); fakeTagWorld(first); fakeTagWorld(second); - await renderAdminApp(`/tags/${first.slug}`, FLAGS); + await renderAdminApp(`/tags/${first.slug}`); await tagDetailScreen.saveButton().click(); await expect.element(tagDetailScreen.savedButton()).toBeVisible(); @@ -444,7 +441,7 @@ describe('Tag detail (tagDetailsReact on)', () => { new RegExp(`^/tags/${t.id}/`), () => pendingSave.promise, ); - await renderAdminApp(`/tags/${t.slug}`, FLAGS); + await renderAdminApp(`/tags/${t.slug}`); await tagDetailScreen.nameInput().fill('Renamed'); await tagDetailScreen.saveButton().click(); @@ -469,7 +466,7 @@ describe('Tag detail (tagDetailsReact on)', () => { current = response.tags[0]; return response; }); - await renderAdminApp(`/tags/${current.slug}`, FLAGS); + await renderAdminApp(`/tags/${current.slug}`); await tagDetailScreen.accentColorHexInput().fill('AABBCC'); await userEvent.keyboard('{Meta>}s{/Meta}'); @@ -493,7 +490,7 @@ describe('Tag detail (tagDetailsReact on)', () => { current = response.tags[0]; return response; }); - await renderAdminApp(`/tags/${current.slug}`, FLAGS); + await renderAdminApp(`/tags/${current.slug}`); await tagDetailScreen.colorPickerButton().click(); await tagDetailScreen.pickerHexInput().fill('#AABBCC'); @@ -521,7 +518,7 @@ describe('Tag detail (tagDetailsReact on)', () => { current = response.tags[0]; return response; }); - await renderAdminApp(`/tags/${current.slug}`, FLAGS); + await renderAdminApp(`/tags/${current.slug}`); await tagDetailScreen.nameInput().fill('Renamed'); await tagDetailScreen.saveButton().click(); @@ -540,7 +537,7 @@ describe('Tag detail (tagDetailsReact on)', () => { fakeTags([]); const pendingCreate = deferred<{ tags: Tag[] }>(); const createApi = fakeAdminEndpoint('POST', new RegExp('^/tags/'), () => pendingCreate.promise); - await renderAdminApp('/tags/new', FLAGS); + await renderAdminApp('/tags/new'); await tagDetailScreen.nameInput().fill('Weekly News'); await tagDetailScreen.saveButton().click(); @@ -575,7 +572,7 @@ describe('Tag detail (tagDetailsReact on)', () => { }, { status: 422 }, ); - await renderAdminApp(`/tags/${t.slug}`, FLAGS); + await renderAdminApp(`/tags/${t.slug}`); await tagDetailScreen.nameInput().fill('Renamed'); await tagDetailScreen.saveButton().click(); @@ -593,7 +590,7 @@ describe('Tag detail (tagDetailsReact on)', () => { const deleteApi = fakeAdminEndpoint('DELETE', new RegExp(`^/tags/${t.id}/`), null, { status: 204, }); - await renderAdminApp(`/tags/${t.slug}`, FLAGS); + await renderAdminApp(`/tags/${t.slug}`); await tagDetailScreen.actionsButton().click(); await tagDetailScreen.deleteTagMenuItem().click(); @@ -610,7 +607,7 @@ describe('Tag detail (tagDetailsReact on)', () => { it('uses the current draft name in the delete confirmation', async () => { const t = tag({ name: 'News', slug: 'news' }); fakeTagWorld(t); - await renderAdminApp(`/tags/${t.slug}`, FLAGS); + await renderAdminApp(`/tags/${t.slug}`); await tagDetailScreen.nameInput().fill('Draft name'); await tagDetailScreen.actionsButton().click(); @@ -628,14 +625,14 @@ describe('Tag detail (tagDetailsReact on)', () => { { errors: [{ type: 'NotFoundError', message: 'Tag not found.' }] }, { status: 404 }, ); - await renderAdminApp('/tags/unknown', FLAGS); + await renderAdminApp('/tags/unknown'); await expect.element(tagDetailScreen.notFound()).toBeVisible(); }); it('keeps the Tags nav item active on the new tag route', async () => { fakeTags([]); - await renderAdminApp('/tags/new', FLAGS); + await renderAdminApp('/tags/new'); await expect.element(tagDetailScreen.title()).toHaveTextContent('New tag'); await expect.element(sidebarScreen.navLink('Tags')).toHaveAttribute('aria-current', 'page'); @@ -645,7 +642,7 @@ describe('Tag detail (tagDetailsReact on)', () => { const t = tag({ name: 'News', slug: 'news' }); fakeTags([t]); fakeTagWorld(t); - await renderAdminApp('/tags', FLAGS); + await renderAdminApp('/tags'); await tagsScreen.tagRows().getByRole('link', { name: 'News' }).click(); @@ -657,7 +654,7 @@ describe('Tag detail (tagDetailsReact on)', () => { const t = tag({ name: 'News', slug: 'news' }); fakeTags([t]); fakeTagWorld(t); - await renderAdminApp(`/tags/${t.slug}`, FLAGS); + await renderAdminApp(`/tags/${t.slug}`); await tagDetailScreen.nameInput().fill('Renamed'); await expect.poll(unsavedChangesGuarded).toBe(true); @@ -682,7 +679,7 @@ describe('Tag detail history guard', () => { const t = tag({ name: 'News', slug: 'news' }); fakeTags([t]); fakeTagWorld(t); - await renderAdminApp('/site', FLAGS); + await renderAdminApp('/site'); await sidebarScreen.navLink('Tags').click(); await expect.poll(currentRoute).toBe('/tags'); @@ -704,21 +701,3 @@ describe('Tag detail history guard', () => { await expect.poll(currentRoute).toBe('/tags'); }); }); - -describe('Tag detail (tagDetailsReact off)', () => { - // No tag fakes are declared in these tests on purpose: if the React screen - // mounted it would fetch the tag and fail the spec as an unhandled request. - it('defers /tags/:slug to Ember', async () => { - await renderAdminApp('/tags/news'); - - await expect.poll(currentRoute).toBe('/tags/news'); - await expect.element(tagDetailScreen.detail()).not.toBeInTheDocument(); - }); - - it('defers /tags/new to Ember', async () => { - await renderAdminApp('/tags/new'); - - await expect.poll(currentRoute).toBe('/tags/new'); - await expect.element(tagDetailScreen.detail()).not.toBeInTheDocument(); - }); -}); diff --git a/apps/ember-admin/app/components/gh-search-input.js b/apps/ember-admin/app/components/gh-search-input.js index eb641b6f5f2..f99fbc50afe 100644 --- a/apps/ember-admin/app/components/gh-search-input.js +++ b/apps/ember-admin/app/components/gh-search-input.js @@ -54,7 +54,7 @@ export default class GhSearchInputComponent extends Component { if (selected.groupName === 'Tags') { const id = selected.id.replace('tag.', ''); - this.router.transitionTo('tag', id); + this.router.transitionTo(`/tags/${id}`); } } diff --git a/apps/ember-admin/app/components/tags/delete-tag-modal.hbs b/apps/ember-admin/app/components/tags/delete-tag-modal.hbs deleted file mode 100644 index 4d7402f2303..00000000000 --- a/apps/ember-admin/app/components/tags/delete-tag-modal.hbs +++ /dev/null @@ -1,19 +0,0 @@ - \ No newline at end of file diff --git a/apps/ember-admin/app/components/tags/delete-tag-modal.js b/apps/ember-admin/app/components/tags/delete-tag-modal.js deleted file mode 100644 index 569439b4334..00000000000 --- a/apps/ember-admin/app/components/tags/delete-tag-modal.js +++ /dev/null @@ -1,29 +0,0 @@ -import Component from '@glimmer/component'; -import {inject as service} from '@ember/service'; -import {task} from 'ember-concurrency'; - -export default class DeleteTagModal extends Component { - @service notifications; - @service router; - - @task({drop: true}) - *deleteTagTask() { - try { - const {tag} = this.args.data; - - if (tag.isDeleted) { - return true; - } - - yield tag.destroyRecord(); - - this.notifications.closeAlerts('tag.delete'); - this.router.transitionTo('/tags'); - return true; - } catch (error) { - this.notifications.showAPIError(error, {key: 'tag.delete.failed'}); - } finally { - this.args.close(); - } - } -} diff --git a/apps/ember-admin/app/components/tags/tag-form.hbs b/apps/ember-admin/app/components/tags/tag-form.hbs deleted file mode 100644 index 18f5339d214..00000000000 --- a/apps/ember-admin/app/components/tags/tag-form.hbs +++ /dev/null @@ -1,419 +0,0 @@ -
-
-
-
- - - - - - - -

- Start with # to create internal tags. - Learn more -

-
- - - -
- -
- -
-
-
-
- - - - - - - - - - - - - -

Maximum: 500 characters. You’ve used {{gh-count-down-characters @tag.description 500}}

-
-
- - - - -
-
- -
-
-
-
-

Meta data

-

Extra content for search engines.

-
- -
- -
- {{#liquid-if this.metadataOpen}} -
-
-
- - - - -

Recommended: 70 characters. You’ve used {{gh-count-down-characters @tag.metaTitle 70}}

-
- - - - - -

Recommended: 156 characters. You’ve used {{gh-count-down-characters @tag.metaDescription 156}}

-
- - - - - - -
- -
- -
-
-
- {{svg-jar "google"}} - -
- -
{{this.seoTitle}}
-
{{#if this.seoDescription}}{{truncate this.seoDescription 149}}{{else}}Search engines will automatically show a custom preview of content related to the search term here if no custom meta description is set.{{/if}}
-
-
-
-
-
- {{/liquid-if}} -
-
- -
-
-
-

X card

-

Customized structured data for X.

-
- -
- -
- {{#liquid-if this.twitterMetadataOpen}} -
-
-
- - - - - - - - - -

Recommended: 70 characters. You’ve used {{gh-count-down-characters @tag.twitterTitle 70}}

-
- - - - - -

Recommended: 125 characters. You’ve used {{gh-count-down-characters @tag.twitterDescription 125}}

-
-
- -
- -
-
- {{svg-jar "social-x" class="social-icon"}} -
- {{or this.settings.metaTitle this.settings.title}} - 12 hrs -
- - -
- - -
-
-
-
-
-
- {{/liquid-if}} -
-
- -
-
-
-

Facebook card

-

Customize Open Graph data.

-
- -
- -
- {{#liquid-if this.facebookMetadataOpen}} -
-
-
- - - - - - - - - -

Recommended: 100 characters. You’ve used {{gh-count-down-characters @tag.ogTitle 100}}

-
- - - - - -

Recommended: 65 characters. You’ve used {{gh-count-down-characters @tag.ogDescription 65}}

-
-
- -
- -
-
- {{svg-jar "social-facebook" class="social-icon"}} -
-
{{or this.settings.metaTitle this.settings.title}}
-
12 hrs
-
-
-
- - -
-
- {{#if this.facebookImage}} -
- {{/if}} -
- {{!-- Ensures description is hidden if title exceeds one line --}} -
-
- {{this.config.blogDomain}} -
-
{{truncate this.facebookTitle}}
-
{{truncate this.facebookDescription}}
-
-
-
-
- {{svg-jar "facebook-like" class="z-999"}}{{svg-jar "facebook-heart" class="nl1"}}182 - 7 comments - 2 shares -
-
-
-
-
- {{/liquid-if}} -
-
- -
-
-
-

Code injection

-

Add styles/scripts to the header and footer.

-
- -
- -
- {{#liquid-if this.codeInjectionOpen}} -
- - - - - - - - - - - -
- {{/liquid-if}} -
-
-
\ No newline at end of file diff --git a/apps/ember-admin/app/components/tags/tag-form.js b/apps/ember-admin/app/components/tags/tag-form.js deleted file mode 100644 index 982ed575db5..00000000000 --- a/apps/ember-admin/app/components/tags/tag-form.js +++ /dev/null @@ -1,242 +0,0 @@ -import Component from '@glimmer/component'; -import Ember from 'ember'; -import {action} from '@ember/object'; -import {htmlSafe} from '@ember/template'; -import {inject} from 'ghost-admin/decorators/inject'; -import {run} from '@ember/runloop'; -import {inject as service} from '@ember/service'; -import {slugify} from '@tryghost/string'; -import {task, timeout} from 'ember-concurrency'; -import {tracked} from '@glimmer/tracking'; - -const {Handlebars} = Ember; - -export default class TagForm extends Component { - @service feature; - @service settings; - - @inject config; - - @tracked metadataOpen = false; - @tracked twitterMetadataOpen = false; - @tracked facebookMetadataOpen = false; - @tracked codeInjectionOpen = false; - - get seoTitle() { - const settingsTitle = this.settings.title || ''; - const tagName = settingsTitle ? `${this.args.tag.name} - ${settingsTitle}` : this.args.tag.name; - let metaTitle = this.args.tag.metaTitle || tagName; - - if (metaTitle && metaTitle.length > 70) { - metaTitle = metaTitle.substring(0, 70).trim(); - metaTitle = Handlebars.Utils.escapeExpression(metaTitle); - metaTitle = htmlSafe(`${metaTitle}…`); - } - - return metaTitle; - } - - get seoDescription() { - let metaDescription = this.args.tag.metaDescription || ''; - - metaDescription = metaDescription.length > 0 ? metaDescription : this.args.tag.description; - - if (metaDescription && metaDescription.length > 156) { - metaDescription = metaDescription.substring(0, 156).trim(); - metaDescription = Handlebars.Utils.escapeExpression(metaDescription); - metaDescription = htmlSafe(`${metaDescription}…`); - } - - return metaDescription; - } - - get seoURL() { - const blogUrl = this.config.blogUrl; - const seoSlug = this.args.tag.slug || ''; - - let seoURL = this.args.tag.canonicalUrl || `${blogUrl}/tag/${seoSlug}`; - - // only append a slash to the URL if the slug exists - - if (!seoURL.endsWith('/')) { - seoURL += '/'; - } - - if (seoURL.length > 70) { - seoURL = seoURL.substring(0, 70).trim(); - seoURL = Handlebars.Utils.escapeExpression(seoURL); - seoURL = htmlSafe(`${seoURL}…`); - } - - return seoURL; - } - - get tagURL() { - const blogUrl = this.config.blogUrl; - const tagSlug = this.args.tag.slug || ''; - - let tagURL = this.args.tag.canonicalUrl || `${blogUrl}/tag/${tagSlug}`; - - // only append a slash to the URL if the slug exists - - if (!tagURL.endsWith('/')) { - tagURL += '/'; - } - - return tagURL; - } - - get twitterTitle() { - return this.args.tag.twitterTitle || this.seoTitle; - } - - get twitterDescription() { - return this.args.tag.twitterDescription || this.seoDescription || this.settings.metaDescription || ''; - } - - get twitterImage() { - return this.args.tag.twitterImage || this.args.tag.featureImage; - } - - get facebookTitle() { - return this.args.tag.ogTitle || this.seoTitle; - } - - get facebookDescription() { - return this.args.tag.facebookDescription || this.seoDescription || this.settings.metaDescription || ''; - } - - get facebookImage() { - return this.args.tag.ogImage || this.args.tag.featureImage; - } - - get accentColor() { - const color = this.args.tag.accentColor; - if (color && color[0] === '#') { - return color.slice(1); - } - return color; - } - - get accentColorPickerValue() { - return this.args.tag.accentColor || '#ffffff'; - } - - get accentColorBgStyle() { - return htmlSafe(`background-color: ${this.accentColorPickerValue}`); - } - - @action - setTagProperty(property, newValue) { - const {tag} = this.args; - - if (newValue) { - newValue = newValue.trim(); - } - - // Generate slug based on name for new tag when empty - if (property === 'name' && tag.isNew && !this.hasChangedSlug) { - let slugValue = slugify(newValue); - if (/^#/.test(newValue)) { - slugValue = 'hash-' + slugValue; - } - tag.slug = slugValue; - } - - // ensure manual changes of slug don't get reset when changing name - if (property === 'slug') { - this.hasChangedSlug = !!newValue; - } - - tag[property] = newValue; - - // clear validation message when typing - tag.hasValidated.addObject(property); - } - - @action - validateTagProperty(property) { - return this.args.tag.validate({property}); - } - - @action - validateCanonicalUrl() { - const {tag} = this.args; - - let newUrl = tag.canonicalUrl; - const [oldUrl] = tag.changedAttributes().canonicalUrl; - let errMessage = ''; - - tag.errors.remove('canonicalUrl'); - tag.hasValidated.removeObject('canonicalUrl'); - - if (newUrl === '') { - tag.canonicalUrl = ''; - return; - } - - if (!newUrl) { - newUrl = oldUrl; - } - - try { - new URL(newUrl); - tag.canonicalUrl = ''; - run.schedule('afterRender', this, function () { - tag.canonicalUrl = newUrl; - }); - } catch (err) { - errMessage = 'The url should be a valid url'; - tag.errors.add('canonicalUrl', errMessage); - tag.hasValidated.pushObject('canonicalUrl'); - } - } - - @action - async updateAccentColor(event) { - const {tag} = this.args; - - let newColor = event.target.value; - const oldColor = tag.accentColor; - - // reset errors and validation - tag.errors.remove('accentColor'); - tag.hasValidated.removeObject('accentColor'); - - if (newColor === '') { - if (newColor === oldColor) { - return; - } - - // clear out the accent color - tag.accentColor = ''; - return; - } - - // accentColor will be null unless the user has input something - if (!newColor) { - newColor = oldColor; - } - - if (newColor[0] !== '#') { - newColor = `#${newColor}`; - } - - if (newColor.match(/#[0-9A-Fa-f]{6}$/)) { - if (newColor === oldColor) { - return; - } - - tag.accentColor = newColor; - } else { - tag.errors.add('accentColor', 'The colour should be in valid hex format'); - tag.hasValidated.pushObject('accentColor'); - } - } - - @task - *debounceUpdateAccentColorTask(event) { - yield timeout(10); - this.updateAccentColor(event); - } -} diff --git a/apps/ember-admin/app/controllers/tag.js b/apps/ember-admin/app/controllers/tag.js deleted file mode 100644 index ab95d11608e..00000000000 --- a/apps/ember-admin/app/controllers/tag.js +++ /dev/null @@ -1,64 +0,0 @@ -import Controller from '@ember/controller'; -import DeleteTagModal from '../components/tags/delete-tag-modal'; -import {action} from '@ember/object'; -import {inject} from 'ghost-admin/decorators/inject'; -import {inject as service} from '@ember/service'; -import {task} from 'ember-concurrency'; - -export default class TagController extends Controller { - @service modals; - @service notifications; - @service router; - @service tagsManager; - - @inject config; - - get tag() { - return this.model; - } - - get tagURL() { - const blogUrl = this.config.blogUrl; - const tagSlug = this.tag?.slug || ''; - - let tagURL = this.tag?.canonicalUrl || `${blogUrl}/tag/${tagSlug}`; - - if (!tagURL.endsWith('/')) { - tagURL += '/'; - } - - return tagURL; - } - - @action - confirmDeleteTag() { - return this.modals.open(DeleteTagModal, { - tag: this.model - }); - } - - @task({drop: true}) - *saveTask() { - const {tag} = this; - - try { - if (tag.get('errors').length !== 0) { - return; - } - const wasNew = tag.isNew; - yield tag.save(); - - if (wasNew) { - this.tagsManager.tagsScreenInfinityModel?.pushObjects([tag]); - } - // replace 'new' route with 'tag' route - this.replaceRoute('tag', tag); - - return tag; - } catch (error) { - if (error) { - this.notifications.showAPIError(error, {key: 'tag.save'}); - } - } - } -} diff --git a/apps/ember-admin/app/router.js b/apps/ember-admin/app/router.js index 5059b89d940..d37441e3db9 100644 --- a/apps/ember-admin/app/router.js +++ b/apps/ember-admin/app/router.js @@ -34,9 +34,6 @@ Router.map(function () { this.route('edit', {path: ':type/:post_id'}); }); - this.route('tag.new', {path: '/tags/new'}); - this.route('tag', {path: '/tags/:tag_slug'}); - this.route('migrate', function () { this.route('migrate', {path: '/*platform'}); }); diff --git a/apps/ember-admin/app/routes/tag.js b/apps/ember-admin/app/routes/tag.js deleted file mode 100644 index 7b412f670f2..00000000000 --- a/apps/ember-admin/app/routes/tag.js +++ /dev/null @@ -1,176 +0,0 @@ -import * as Sentry from '@sentry/ember'; -import AuthenticatedRoute from 'ghost-admin/routes/authenticated'; -import ConfirmUnsavedChangesModal from '../components/modals/confirm-unsaved-changes'; -import {action} from '@ember/object'; -import {inject as service} from '@ember/service'; - -export default class TagRoute extends AuthenticatedRoute { - @service feature; - @service modals; - @service router; - @service session; - @service('unsaved-changes') unsavedChanges; - - // ensures if a tag model is passed in directly we show it immediately - // and refresh in the background - _requiresBackgroundRefresh = true; - _unregisterUnsavedChanges = null; - - beforeModel(transition) { - super.beforeModel(...arguments); - - if (this.session.user.isAuthorOrContributor) { - return this.transitionTo('index'); - } - - // React owns this URL when the flag is on. Keep the Ember route from - // loading and rendering a second tag editor behind the React screen. - if (this.feature.tagDetailsReact !== true) { - return; - } - - transition.abort(); - - const reactRouteUrl = this._reactRouteUrl(transition); - - // Ember and React share window.location.hash, and an aborted - // transition never reaches updateURL. A URL intent (cold load, hash - // change, React-driven navigation) already has the browser URL - // pointing here, so React renders and there is nothing to do. A named - // intent (the Cmd-K search modal's tag results) has no URL yet - - // without writing one the click is a silent no-op. - if (!transition.intent?.url) { - this._navigateToReactRoute(reactRouteUrl); - } - - this._parkOnReactFallback(reactRouteUrl); - } - - // Built by hand rather than with `router.urlFor`, whose output depends on - // the configured location. `transition.to.params` is only populated for a - // transition passing string params - one passing a tag model serializes - // too late to read here, so fall back to the list rather than a bad URL. - _reactRouteUrl(transition) { - const {name, params} = transition.to ?? {}; - - if (name === 'tag.new') { - return '/tags/new'; - } - if (name === 'tag' && typeof params?.tag_slug === 'string') { - return `/tags/${encodeURIComponent(params.tag_slug)}`; - } - - return '/tags'; - } - - // Aborting stops Ember rendering this screen, but it also leaves the - // router believing it is still on the route we came from. That desync is - // only invisible until you navigate back to the very same URL: Ember - // compares it against the route it thinks it is on, finds no difference, - // and runs no transition at all. Park on `react-fallback` - the empty - // catch-all Ember already uses for URLs React owns - to keep the router's - // state honest. The fallback must use the real tag path: parking on - // `tag` briefly sends React to an unknown URL, and restoring the hash - // with replaceState does not notify React Router. That leaves the browser - // showing React's 404 until the next reload. - // See PostsRoute#_parkOnReactFallback for the full rationale (replace - // semantics, the parked-path guard, and URL restoration). - _parkOnReactFallback(reactRouteUrl) { - const fallbackPath = reactRouteUrl.replace(/^\//, ''); - const parkedPath = this.router.currentRouteName === 'react-fallback' - ? this.router.currentRoute?.params?.path - : null; - - if (parkedPath === fallbackPath) { - return; - } - - const url = window.location.hash; - const state = window.history.state; - - this.router.replaceWith('react-fallback', fallbackPath) - .finally(() => this._restoreUrl(url, state)); - } - - // Parking writes the fallback route's own path, so the captured URL goes - // back afterwards. `replaceState`: no history entry, and no `hashchange` - // to re-enter routing. The captured history state goes back with it - - // react-router keeps `{usr, key, idx}` there, and parking would otherwise - // drop it on the URL-intent path where React created the entry. - _restoreUrl(url, state) { - window.history.replaceState(state, '', url); - } - - // Seam so tests can assert the navigation without a real hash location - - // Ember acceptance tests run with `location: 'none'`. - _navigateToReactRoute(url) { - window.location.hash = url; - } - - model(params) { - this._requiresBackgroundRefresh = false; - - if (params.tag_slug) { - return this.store.queryRecord('tag', {slug: params.tag_slug, include: 'count.posts'}); - } else { - return this.store.createRecord('tag'); - } - } - - serialize(tag) { - return {tag_slug: tag.get('slug')}; - } - - setupController(controller, tag) { - super.setupController(...arguments); - - if (this._requiresBackgroundRefresh) { - tag.reload(); - } - - this._registerUnsavedChanges(controller); - } - - deactivate() { - this._requiresBackgroundRefresh = true; - this._unregisterUnsavedChanges?.(); - this._unregisterUnsavedChanges = null; - } - - @action - async willTransition(transition) { - return this.unsavedChanges.guardTransition(transition); - } - - _registerUnsavedChanges(controller) { - this._unregisterUnsavedChanges?.(); - this._unregisterUnsavedChanges = this.unsavedChanges.register({ - isDirty: () => controller.model?.hasDirtyAttributes, - confirmLeave: () => this._confirmUnsavedChanges(controller) - }); - } - - async _confirmUnsavedChanges(controller) { - if (controller.saveTask?.isRunning) { - try { - await controller.saveTask.last; - } catch (e) { - // ignore save errors — we'll check dirty state below - } - } - - if (!controller.model?.hasDirtyAttributes) { - return true; - } - - Sentry.captureMessage('showing unsaved changes modal for tags route'); - const shouldLeave = await this.modals.open(ConfirmUnsavedChangesModal); - - if (shouldLeave) { - controller.model.rollbackAttributes(); - return true; - } - - return false; - } -} diff --git a/apps/ember-admin/app/services/feature.js b/apps/ember-admin/app/services/feature.js index 393b1df792f..e88ca0c3610 100644 --- a/apps/ember-admin/app/services/feature.js +++ b/apps/ember-admin/app/services/feature.js @@ -96,7 +96,6 @@ export default class FeatureService extends Service { @feature('importMemberTier') importMemberTier; @feature('adminUIRefresh') adminUIRefresh; @feature('editorExcerpt') editorExcerpt; - @feature('tagDetailsReact') tagDetailsReact; @feature('paywallImprovements') paywallImprovements; @feature('automations') automations; @feature('csvContentImporter') csvContentImporter; diff --git a/apps/ember-admin/app/templates/tag.hbs b/apps/ember-admin/app/templates/tag.hbs deleted file mode 100644 index 08194f12311..00000000000 --- a/apps/ember-admin/app/templates/tag.hbs +++ /dev/null @@ -1,38 +0,0 @@ -
-
- -
-
- - Tags - - {{svg-jar "arrow-right-small"}} - {{if this.tag.isNew "New tag" this.tag.name}} -
-
- -
- -
-
- - - - - {{#unless this.tag.isNew}} -
- -
- {{/unless}} -
diff --git a/apps/ember-admin/tests/acceptance/error-handling-test.js b/apps/ember-admin/tests/acceptance/error-handling-test.js index 96ababef7e6..ebb8f6d58fe 100644 --- a/apps/ember-admin/tests/acceptance/error-handling-test.js +++ b/apps/ember-admin/tests/acceptance/error-handling-test.js @@ -89,20 +89,5 @@ describe('Acceptance: Error Handling', function () { expect(find('.gh-alert').textContent).to.not.match(/html>/); expect(find('.gh-alert').textContent).to.match(/An unexpected error occurred, please try again./); }); - - it('handles ember-ajax HTML response', async function () { - const tag = this.server.create('tag', {slug: 'test'}); - - this.server.del(`/tags/${tag.id}/`, htmlErrorResponse); - - await visit('/tags/test'); - - await click('[data-test-button="delete-tag"]'); - await click('[data-test-modal="confirm-delete-tag"] [data-test-button="confirm"]'); - - expect(findAll('.gh-alert').length).to.equal(1); - expect(find('.gh-alert').textContent).to.not.match(/html>/); - expect(find('.gh-alert').textContent).to.match(/An unexpected error occurred, please try again./); - }); }); }); diff --git a/apps/ember-admin/tests/acceptance/tag-react-flag-test.js b/apps/ember-admin/tests/acceptance/tag-react-flag-test.js deleted file mode 100644 index 3085306b28b..00000000000 --- a/apps/ember-admin/tests/acceptance/tag-react-flag-test.js +++ /dev/null @@ -1,119 +0,0 @@ -import sinon from 'sinon'; -import {afterEach, beforeEach, describe, it} from 'mocha'; -import {authenticateSession} from 'ember-simple-auth/test-support'; -import {enableLabsFlag} from '../helpers/labs-flag'; -import {expect} from 'chai'; -import {find, settled, visit} from '@ember/test-helpers'; -import {setupApplicationTest} from 'ember-mocha'; -import {setupMirage} from 'ember-cli-mirage/test-support'; - -// The `tagDetailsReact` flag hands /tags/:slug to the React app. Ember's side -// of that handshake is the tag route's beforeModel: it aborts so the Ember tag -// editor stays unrendered, and drives window.location.hash so navigations -// Ember itself starts (the Cmd-K search modal) still land somewhere — an -// aborted transition never reaches updateURL, and the two apps share the hash. - -// `visit()` rejects with TransitionAborted whenever the route aborts, which is -// the whole point of the flag being on. Swallow only that rejection so the -// assertions below can run; anything else still fails the test. -async function visitExpectingAbort(url) { - try { - await visit(url); - } catch (error) { - if (error?.message !== 'TransitionAborted' && error?.name !== 'TransitionAborted') { - throw error; - } - } - await settled(); -} - -describe('Acceptance: tag React flag', function () { - const hooks = setupApplicationTest(); - setupMirage(hooks); - - beforeEach(async function () { - this.server.loadFixtures('configs'); - this.server.loadFixtures('settings'); - - const role = this.server.create('role', {name: 'Administrator'}); - this.server.create('user', {roles: [role]}); - this.server.create('tag', {name: 'My tag', slug: 'my-tag'}); - - return await authenticateSession(); - }); - - afterEach(function () { - sinon.restore(); - }); - - describe('when the flag is off', function () { - it('renders the Ember tag editor', async function () { - await visit('/tags/my-tag'); - - expect(find('[data-test-screen-title]'), 'Ember tag editor title').to.exist; - }); - }); - - describe('when the flag is on', function () { - beforeEach(function () { - enableLabsFlag(this.server, 'tagDetailsReact'); - }); - - it('does not render the Ember tag editor', async function () { - await visitExpectingAbort('/tags/my-tag'); - - expect(find('[data-test-screen-title]'), 'Ember tag editor title').to.not.exist; - }); - - // The regression this guards: the Cmd-K search modal transitions by - // route name. Without supplying a URL the click is a silent no-op and - // the user is stranded on the previous screen. - it('navigates React when Ember initiates a tag transition', async function () { - const route = this.owner.lookup('route:tag'); - const navigate = sinon.stub(route, '_navigateToReactRoute'); - - await visitExpectingAbort('/posts'); - this.owner.lookup('service:router').transitionTo('tag', 'my-tag'); - await settled(); - - expect(navigate.calledOnce, '_navigateToReactRoute called once').to.be.true; - expect(navigate.firstCall.args[0], 'target url').to.equal('/tags/my-tag'); - }); - - it('navigates React when Ember initiates a new-tag transition', async function () { - const route = this.owner.lookup('route:tag.new'); - const navigate = sinon.stub(route, '_navigateToReactRoute'); - - await visitExpectingAbort('/posts'); - this.owner.lookup('service:router').transitionTo('tag.new'); - await settled(); - - expect(navigate.calledOnce, '_navigateToReactRoute called once').to.be.true; - expect(navigate.firstCall.args[0], 'target url').to.equal('/tags/new'); - }); - - // A URL that already points at the tag must be left exactly as it is — - // React is already rendering it. - it('does not rewrite a URL-initiated navigation', async function () { - const route = this.owner.lookup('route:tag'); - const navigate = sinon.stub(route, '_navigateToReactRoute'); - - await visitExpectingAbort('/tags/my-tag'); - - expect(navigate.called, '_navigateToReactRoute called').to.be.false; - }); - - // Aborting alone leaves the router still reporting the route it came - // from, so returning to that same URL later would be a no-op - // transition that renders nothing. Parking on the catch-all at the - // actual tag path keeps both routers truthful. - it('parks the router on the React fallback at the tag path', async function () { - const router = this.owner.lookup('service:router'); - - await visitExpectingAbort('/tags/my-tag'); - - expect(router.currentRouteName, 'currentRouteName after aborting').to.equal('react-fallback'); - expect(router.currentRoute?.params?.path, 'fallback path after aborting').to.equal('tags/my-tag'); - }); - }); -}); diff --git a/apps/ember-admin/tests/integration/components/tags/tag-form-test.js b/apps/ember-admin/tests/integration/components/tags/tag-form-test.js deleted file mode 100644 index 64590fb6f34..00000000000 --- a/apps/ember-admin/tests/integration/components/tags/tag-form-test.js +++ /dev/null @@ -1,254 +0,0 @@ -// TODO: remove usage of Ember Data's private `Errors` class when refactoring validations -// eslint-disable-next-line -import DS from 'ember-data'; -import EmberObject from '@ember/object'; -import Service from '@ember/service'; -import hbs from 'htmlbars-inline-precompile'; -import {blur, click, fillIn, find, findAll, render} from '@ember/test-helpers'; -import {describe, it} from 'mocha'; -import {expect} from 'chai'; -import {setupRenderingTest} from 'ember-mocha'; - -const {Errors} = DS; - -const configStub = Service.extend({ - blogUrl: 'http://localhost:2368' -}); - -describe.skip('Integration: Component: tags/tag-form', function () { - setupRenderingTest(); - - beforeEach(function () { - const tag = EmberObject.create({ - id: 1, - name: 'Test', - slug: 'test', - description: 'Description.', - metaTitle: 'Meta Title', - metaDescription: 'Meta description', - errors: Errors.create(), - hasValidated: [] - }); - - this.set('tag', tag); - this.set('setProperty', function (property, value) { - // this should be overridden if a call is expected - // eslint-disable-next-line no-console - console.error(`setProperty called '${property}: ${value}'`); - }); - - this.owner.register('service:config', configStub); - }); - - it('has the correct title', async function () { - await render(hbs` - - `); - expect(find('.tag-settings-pane h4').textContent, 'existing tag title').to.equal('Tag settings'); - - this.set('tag.isNew', true); - expect(find('.tag-settings-pane h4').textContent, 'new tag title').to.equal('New tag'); - }); - - it('renders main settings', async function () { - await render(hbs` - - `); - - expect(findAll('.gh-image-uploader').length, 'displays image uploader').to.equal(1); - expect(find('input[name="name"]').value, 'name field value').to.equal('Test'); - expect(find('input[name="slug"]').value, 'slug field value').to.equal('test'); - expect(find('textarea[name="description"]').value, 'description field value').to.equal('Description.'); - expect(find('input[name="metaTitle"]').value, 'metaTitle field value').to.equal('Meta Title'); - expect(find('textarea[name="metaDescription"]').value, 'metaDescription field value').to.equal('Meta description'); - }); - - it('can switch between main/meta settings', async function () { - await render(hbs` - - `); - - expect(find('.tag-settings-pane').classList.contains('settings-menu-pane-in'), 'main settings are displayed by default').to.be.true; - expect(find('.tag-meta-settings-pane').classList.contains('settings-menu-pane-out-right'), 'meta settings are hidden by default').to.be.true; - - await click('.meta-data-button'); - - expect(find('.tag-settings-pane').classList.contains('settings-menu-pane-out-left'), 'main settings are hidden after clicking Meta Data button').to.be.true; - expect(find('.tag-meta-settings-pane').classList.contains('settings-menu-pane-in'), 'meta settings are displayed after clicking Meta Data button').to.be.true; - - await click('.back'); - - expect(find('.tag-settings-pane').classList.contains('settings-menu-pane-in'), 'main settings are displayed after clicking "back"').to.be.true; - expect(find('.tag-meta-settings-pane').classList.contains('settings-menu-pane-out-right'), 'meta settings are hidden after clicking "back"').to.be.true; - }); - - it('has one-way binding for properties', async function () { - this.set('setProperty', function () { - // noop - }); - - await render(hbs` - - `); - - await fillIn('input[name="name"]', 'New name'); - await fillIn('input[name="slug"]', 'new-slug'); - await fillIn('textarea[name="description"]', 'New description'); - await fillIn('input[name="metaTitle"]', 'New metaTitle'); - await fillIn('textarea[name="metaDescription"]', 'New metaDescription'); - - expect(this.get('tag.name'), 'tag name').to.equal('Test'); - expect(this.get('tag.slug'), 'tag slug').to.equal('test'); - expect(this.get('tag.description'), 'tag description').to.equal('Description.'); - expect(this.get('tag.metaTitle'), 'tag metaTitle').to.equal('Meta Title'); - expect(this.get('tag.metaDescription'), 'tag metaDescription').to.equal('Meta description'); - }); - - it('triggers setProperty action on blur of all fields', async function () { - let lastSeenProperty = ''; - let lastSeenValue = ''; - - this.set('setProperty', function (property, value) { - lastSeenProperty = property; - lastSeenValue = value; - }); - - const testSetProperty = async (selector, expectedProperty, expectedValue) => { - await click(selector); - await fillIn(selector, expectedValue); - await blur(selector); - expect(lastSeenProperty, 'property').to.equal(expectedProperty); - expect(lastSeenValue, 'value').to.equal(expectedValue); - }; - - await render(hbs` - - `); - - await testSetProperty('input[name="name"]', 'name', 'New name'); - await testSetProperty('input[name="slug"]', 'slug', 'new-slug'); - await testSetProperty('textarea[name="description"]', 'description', 'New description'); - await testSetProperty('input[name="metaTitle"]', 'metaTitle', 'New metaTitle'); - await testSetProperty('textarea[name="metaDescription"]', 'metaDescription', 'New metaDescription'); - }); - - it('displays error messages for validated fields', async function () { - const errors = this.get('tag.errors'); - const hasValidated = this.get('tag.hasValidated'); - - errors.add('name', 'must be present'); - hasValidated.push('name'); - - errors.add('slug', 'must be present'); - hasValidated.push('slug'); - - errors.add('description', 'is too long'); - hasValidated.push('description'); - - errors.add('metaTitle', 'is too long'); - hasValidated.push('metaTitle'); - - errors.add('metaDescription', 'is too long'); - hasValidated.push('metaDescription'); - - await render(hbs` - - `); - - const nameFormGroup = find('input[name="name"]').closest('.form-group'); - expect(nameFormGroup, 'name form group has error state').to.have.class('error'); - expect(nameFormGroup.querySelector('.response'), 'name form group has error message').to.exist; - - const slugFormGroup = find('input[name="slug"]').closest('.form-group'); - expect(slugFormGroup, 'slug form group has error state').to.have.class('error'); - expect(slugFormGroup.querySelector('.response'), 'slug form group has error message').to.exist; - - const descriptionFormGroup = find('textarea[name="description"]').closest('.form-group'); - expect(descriptionFormGroup, 'description form group has error state').to.have.class('error'); - - const metaTitleFormGroup = find('input[name="metaTitle"]').closest('.form-group'); - expect(metaTitleFormGroup, 'metaTitle form group has error state').to.have.class('error'); - expect(metaTitleFormGroup.querySelector('.response'), 'metaTitle form group has error message').to.exist; - - const metaDescriptionFormGroup = find('textarea[name="metaDescription"]').closest('.form-group'); - expect(metaDescriptionFormGroup, 'metaDescription form group has error state').to.have.class('error'); - expect(metaDescriptionFormGroup.querySelector('.response'), 'metaDescription form group has error message').to.exist; - }); - - it('displays char count for text fields', async function () { - await render(hbs` - - `); - - const descriptionFormGroup = find('textarea[name="description"]').closest('.form-group'); - expect(descriptionFormGroup.querySelector('.word-count'), 'description char count').to.have.trimmed.text('12'); - - const metaDescriptionFormGroup = find('textarea[name="metaDescription"]').closest('.form-group'); - expect(metaDescriptionFormGroup.querySelector('.word-count'), 'description char count').to.have.trimmed.text('16'); - }); - - it('renders SEO title preview', async function () { - await render(hbs` - - `); - expect(find('.seo-preview-title').textContent, 'displays meta title if present').to.equal('Meta Title'); - - this.set('tag.metaTitle', ''); - expect(find('.seo-preview-title').textContent, 'falls back to tag name without metaTitle').to.equal('Test'); - - this.set('tag.name', (new Array(151).join('x'))); - const expectedLength = 70 + '…'.length; - expect(find('.seo-preview-title').textContent.length, 'cuts title to max 70 chars').to.equal(expectedLength); - }); - - it('renders SEO URL preview', async function () { - await render(hbs` - - `); - expect(find('.seo-preview-link').textContent, 'adds url and tag prefix').to.equal('http://localhost:2368/tag/test/'); - - this.set('tag.slug', (new Array(151).join('x'))); - const expectedLength = 70 + '…'.length; - expect(find('.seo-preview-link').textContent.length, 'cuts slug to max 70 chars').to.equal(expectedLength); - }); - - it('renders SEO description preview', async function () { - await render(hbs` - - `); - expect(find('.seo-preview-description').textContent, 'displays meta description if present').to.equal('Meta description'); - - this.set('tag.metaDescription', ''); - expect(find('.seo-preview-description').textContent, 'falls back to tag description without metaDescription').to.equal('Description.'); - - this.set('tag.description', (new Array(500).join('x'))); - const expectedLength = 156 + '…'.length; - expect(find('.seo-preview-description').textContent.length, 'cuts description to max 156 chars').to.equal(expectedLength); - }); - - it('resets if a new tag is received', async function () { - await render(hbs` - - `); - await click('.meta-data-button'); - expect(find('.tag-meta-settings-pane').classList.contains('settings-menu-pane-in'), 'meta data pane is shown').to.be.true; - - this.set('tag', EmberObject.create({id: '2'})); - expect(find('.tag-settings-pane').classList.contains('settings-menu-pane-in'), 'resets to main settings').to.be.true; - }); - - it('triggers delete tag modal on delete click', async function () { - let openModalFired = false; - - this.set('openModal', () => { - openModalFired = true; - }); - - await render(hbs` - - `); - await click('.settings-menu-delete-button'); - - expect(openModalFired).to.be.true; - }); -}); diff --git a/apps/ember-admin/tests/unit/routes/tag-test.js b/apps/ember-admin/tests/unit/routes/tag-test.js deleted file mode 100644 index d5a13b8e066..00000000000 --- a/apps/ember-admin/tests/unit/routes/tag-test.js +++ /dev/null @@ -1,133 +0,0 @@ -import Service from '@ember/service'; -import sinon from 'sinon'; -import {describe, it} from 'mocha'; -import {expect} from 'chai'; -import {setupTest} from 'ember-mocha'; - -describe('Unit: Route: tag', function () { - setupTest(); - - afterEach(function () { - sinon.restore(); - }); - - // The router is stubbed per-method on the real instance: wholesale service - // stubs miss the surface other injected services touch during the route's - // own instantiation. - function setupRoute(owner, {flagValue, routeName = 'route:tag'}) { - const requireAuthentication = sinon.spy(); - class SessionStub extends Service { - isAuthenticated = true; - user = {isAuthorOrContributor: false}; - requireAuthentication = requireAuthentication; - } - class FeatureStub extends Service { - tagDetailsReact = flagValue; - } - owner.register('service:session', SessionStub); - owner.register('service:feature', FeatureStub); - - const router = owner.lookup('service:router'); - // `finally` runs its callback synchronously so the URL restoration the - // real transition promise triggers is exercised, not just stubbed away. - const settled = {finally(callback) { - callback(); - return settled; - }}; - sinon.stub(router, 'replaceWith').returns(settled); - - const route = owner.lookup(routeName); - const navigate = sinon.stub(route, '_navigateToReactRoute'); - const replaceState = sinon.stub(window.history, 'replaceState'); - - return {route, router, navigate, replaceState, requireAuthentication}; - } - - it('aborts the Ember transition when React owns the tag detail route', function () { - const {route, router, navigate, requireAuthentication} = setupRoute(this.owner, {flagValue: true}); - // A URL intent, so beforeModel does not rewrite the hash itself. - const transition = { - abort: sinon.spy(), - intent: {url: '/tags/my-tag'}, - to: {name: 'tag', params: {tag_slug: 'my-tag'}} - }; - - route.beforeModel(transition); - - expect(requireAuthentication.calledOnce).to.be.true; - expect(transition.abort.calledOnce, 'transition aborted').to.be.true; - expect(navigate.called, 'no hash rewrite for a URL intent').to.be.false; - expect(router.replaceWith.calledWith('react-fallback', 'tags/my-tag'), 'parked on react-fallback').to.be.true; - }); - - // The Cmd-K search modal navigates by route name, so the abort leaves no - // URL behind for React to render - the click was a silent no-op. - it('writes the React URL for a named transition', function () { - const {route, router, navigate} = setupRoute(this.owner, {flagValue: true}); - const transition = { - abort: sinon.spy(), - intent: {}, - to: {name: 'tag', params: {tag_slug: 'my-tag'}} - }; - - route.beforeModel(transition); - - expect(navigate.calledOnceWith('/tags/my-tag'), 'hash written').to.be.true; - expect(router.replaceWith.calledWith('react-fallback', 'tags/my-tag'), 'parked on react-fallback').to.be.true; - }); - - it('writes the create URL for a named transition into tag.new', function () { - const {route, router, navigate} = setupRoute(this.owner, {flagValue: true, routeName: 'route:tag.new'}); - const transition = {abort: sinon.spy(), intent: {}, to: {name: 'tag.new', params: {}}}; - - route.beforeModel(transition); - - expect(navigate.calledOnceWith('/tags/new'), 'hash written').to.be.true; - expect(router.replaceWith.calledWith('react-fallback', 'tags/new'), 'parked on react-fallback').to.be.true; - }); - - it('restores the URL together with the history state react-router keeps', function () { - const {route, replaceState} = setupRoute(this.owner, {flagValue: true}); - const state = {usr: null, key: 'abc123', idx: 4}; - - route._restoreUrl('#/tags/my-tag', state); - - expect(replaceState.calledOnceWith(state, '', '#/tags/my-tag'), 'state restored with URL').to.be.true; - }); - - it('restores the captured URL after parking', function () { - const {route, replaceState} = setupRoute(this.owner, {flagValue: true}); - const state = {usr: null, key: 'abc123', idx: 4}; - sinon.stub(window.history, 'state').value(state); - const transition = { - abort: sinon.spy(), - intent: {url: '/tags/my-tag'}, - to: {name: 'tag', params: {tag_slug: 'my-tag'}} - }; - - route.beforeModel(transition); - - expect(replaceState.calledOnceWith(state, '', window.location.hash), 'URL and state restored').to.be.true; - }); - - it('does not park twice on the same fallback path', function () { - const {route, router} = setupRoute(this.owner, {flagValue: true}); - sinon.stub(router, 'currentRouteName').value('react-fallback'); - sinon.stub(router, 'currentRoute').value({params: {path: 'tags/my-tag'}}); - - route._parkOnReactFallback('/tags/my-tag'); - - expect(router.replaceWith.called, 'no re-parking').to.be.false; - }); - - it('keeps Ember ownership when the feature flag is not a boolean', function () { - const {route, router, navigate} = setupRoute(this.owner, {flagValue: 'true'}); - const transition = {abort: sinon.spy(), intent: {}, to: {name: 'tag', params: {tag_slug: 'my-tag'}}}; - - route.beforeModel(transition); - - expect(transition.abort.called, 'transition not aborted').to.be.false; - expect(navigate.called, 'no hash rewrite').to.be.false; - expect(router.replaceWith.called, 'no parking').to.be.false; - }); -}); diff --git a/apps/ember-admin/tests/unit/services/state-bridge-test.js b/apps/ember-admin/tests/unit/services/state-bridge-test.js index 1fdd5e20fd8..7bd3b944fcf 100644 --- a/apps/ember-admin/tests/unit/services/state-bridge-test.js +++ b/apps/ember-admin/tests/unit/services/state-bridge-test.js @@ -59,15 +59,15 @@ describe('Unit: Service: state-bridge', function () { it('does not claim route ownership before Labs settings load', function () { settings.settingsModel = null; - expect(service.isFeatureEnabled('tagDetailsReact')).to.be.undefined; + expect(service.isFeatureEnabled('postsListReact')).to.be.undefined; }); it('exposes the same strict Labs state used by Ember routes', function () { settings.settingsModel = {}; - sinon.stub(feature, 'tagDetailsReact').get(() => true); + sinon.stub(feature, 'postsListReact').get(() => true); sinon.stub(feature, 'adminUIRefresh').get(() => 'true'); - expect(service.isFeatureEnabled('tagDetailsReact')).to.be.true; + expect(service.isFeatureEnabled('postsListReact')).to.be.true; expect(service.isFeatureEnabled('adminUIRefresh')).to.be.false; expect(service.isFeatureEnabled('missingFlag')).to.be.false; }); diff --git a/e2e/helpers/pages/admin/tags/tag-editor-page.ts b/e2e/helpers/pages/admin/tags/tag-editor-page.ts index 68dddcd5a7d..6964dbd0937 100644 --- a/e2e/helpers/pages/admin/tags/tag-editor-page.ts +++ b/e2e/helpers/pages/admin/tags/tag-editor-page.ts @@ -18,18 +18,9 @@ export class TagEditorPage extends TagDetailsPage { this.pageUrl = '/ghost/#/tags'; - // Ember renders data-test-* attributes; React renders data-testid. - // Match either so the same flows drive both implementations. - this.deleteModal = page - .locator('[data-test-modal="confirm-delete-tag"]') - .or(page.getByTestId(deleteTagModal)) - .filter({ visible: true }); - this.deleteModalPostsCount = this.deleteModal - .locator('[data-test-text="posts-count"]') - .or(this.deleteModal.getByTestId(deleteTagPostsCount)); - this.deleteModalConfirmButton = this.deleteModal - .locator('[data-test-button="confirm"]') - .or(this.deleteModal.getByTestId(confirmDeleteTag)); + this.deleteModal = page.getByTestId(deleteTagModal).filter({ visible: true }); + this.deleteModalPostsCount = this.deleteModal.getByTestId(deleteTagPostsCount); + this.deleteModalConfirmButton = this.deleteModal.getByTestId(confirmDeleteTag); this.tagActionsButton = page.getByRole('button', { name: 'Tag actions' }); this.deleteMenuItem = page.getByRole('menuitem', { name: 'Delete tag' }); } diff --git a/e2e/tests/admin/tags/tag-detail.test.ts b/e2e/tests/admin/tags/tag-detail.test.ts index d79865dc231..1577916e796 100644 --- a/e2e/tests/admin/tags/tag-detail.test.ts +++ b/e2e/tests/admin/tags/tag-detail.test.ts @@ -4,10 +4,8 @@ import { expect, test } from '@/helpers/playwright'; import { usePerTestIsolation } from '@/helpers/playwright/isolation'; /** - * Behaviour contract for `/tags/:slug` and `/tags/new`. The React screen is - * generally available (`tagDetailsReact` sits in GA_FEATURES), so every site - * serves it; the assertions describe what the screen does rather than how it - * is built. + * Behaviour contract for `/tags/:slug` and `/tags/new`. The assertions + * describe what the screen does rather than how it is built. */ usePerTestIsolation(); diff --git a/ghost/core/core/shared/labs.js b/ghost/core/core/shared/labs.js index 69991aa4fe2..621f9d1f5d8 100644 --- a/ghost/core/core/shared/labs.js +++ b/ghost/core/core/shared/labs.js @@ -27,7 +27,7 @@ const messages = { }; // flags in this list always return `true`, allows quick global enable prior to full flag removal -const GA_FEATURES = ['automationAnalytics', 'tagDetailsReact']; +const GA_FEATURES = ['automationAnalytics']; // These features are considered publicly available and can be enabled/disabled by users const PUBLIC_BETA_FEATURES = [ From 9f7acff97aa48b0d2aab466467bf4049a3300660 Mon Sep 17 00:00:00 2001 From: Duy <104808921+honhatduy@users.noreply.github.com> Date: Wed, 23 Sep 2026 20:28:01 +0700 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=8C=90=20Updated=20Vietnamese=20trans?= =?UTF-8?q?lation=20(#30371)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit no ref Added gift subscription strings. --- packages/i18n/locales/vi/ghost.json | 74 ++++++++++---------- packages/i18n/locales/vi/portal.json | 100 +++++++++++++-------------- 2 files changed, 87 insertions(+), 87 deletions(-) diff --git a/packages/i18n/locales/vi/ghost.json b/packages/i18n/locales/vi/ghost.json index 4294e9f8d52..a3bd076e024 100644 --- a/packages/i18n/locales/vi/ghost.json +++ b/packages/i18n/locales/vi/ghost.json @@ -1,14 +1,14 @@ { - "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "", - "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", - "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "", - "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", - "{buyerName} sent you a gift": "", + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "{buyerName} vừa tặng bạn {duration}-tháng hạng thành viên {tierName} của {siteTitle}", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "{buyerName} vừa tặng bạn {duration}-năm hạng thành viên {tierName} của {siteTitle}", + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "{buyerName} vừa tặng bạn {duration}-tháng hạng thành viên {tierName} của {siteTitle}", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "{buyerName} vừa tặng bạn {duration}-năm hạng thành viên {tierName} của {siteTitle}", + "{buyerName} sent you a gift": "{buyerName} tặng bạn một món quà", "{count} month_other": "{count} tháng", "{count} year_other": "{count} năm", "{date}": "{date}", "{tierName} membership": "Thành viên {tierName}", - "A gift, just for you": "", + "A gift, just for you": "Một món quà dành cho bạn", "All the best!": "Chúc bạn mọi điều tốt đẹp nhất!", "Become a paid member of {site} to get access to all premium content.": "Trở thành thành viên trả phí của {site} để truy cập toàn bộ nội dung cao cấp.", "By {authors}": "Viết bởi {authors}", @@ -33,7 +33,7 @@ "Hey there,": "Chào bạn,", "Hey there!": "Chào bạn!", "Hi {firstName},": "Xin chào {firstName},", - "Hi {recipientName},": "", + "Hi {recipientName},": "Xin chào {recipientName},", "If you did not make this request, you can safely ignore this email.": "Nếu bạn không thực hiện yêu cầu này, vui lòng bỏ qua email này.", "If you did not make this request, you can simply delete this message.": "Nếu bạn không thực hiện yêu cầu này, hãy xóa email này.", "If you didn't try to sign in recently, you can safely ignore this email to deny access.": "Nếu không phải là bạn đăng nhập gần đây, bỏ qua email này để từ chối truy cập.", @@ -51,12 +51,12 @@ "Or, skip the code and sign in directly": "Hoặc, bỏ qua mã và đăng nhập trực tiếp", "paid": "trả phí", "Please confirm your email address with this link:": "Vui lòng xác nhận địa chỉ email của bạn bằng liên kết này:", - "Redeem your gift": "", + "Redeem your gift": "Mở quà của bạn", "Redeem your gift subscription": "Đổi quà tặng gói thành viên", - "Redeem your gift:": "", + "Redeem your gift:": "Mở quà của bạn", "Secure sign in link for {siteTitle}": "Liên kết đăng nhập an toàn {siteTitle}", "See you soon!": "Hẹn gặp lại bạn!", - "Send the gift link manually so it can still be redeemed.": "", + "Send the gift link manually so it can still be redeemed.": "Gửi liên kết quà thủ công để họ có thể nhận.", "Sent to {email}": "Đã gửi đến {email}", "Share": "Chia sẻ", "Sign in": "Đăng nhập", @@ -74,24 +74,24 @@ "Thank you for subscribing to {siteTitle}.": "Cám ơn bạn đã đăng ký thành viên {siteTitle}.", "Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "Cám ơn bạn đã đăng ký gói thành viên {siteTitle}. Nhấn vào liên kết bên dưới để đăng nhập tự động:", "Thank you for supporting {siteTitle}.": "Cảm ơn bạn đã ủng hộ {siteTitle}.", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "", - "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", - "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. You can also share the link below yourself.": "", - "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", - "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. You can also share the link below yourself.": "", - "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", - "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. You can also share the link below yourself.": "", - "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", - "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. You can also share the link below yourself.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "Cảm ơn sự ủng hộ của bạn. Hãy chia sẻ liên kết bên dưới với người mà bạn muốn tặng {duration}-tháng hạng thành viên {tierName} của {siteTitle}.", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "Cảm ơn sự ủng hộ của bạn. Hãy chia sẻ liên kết bên dưới với người mà bạn muốn tặng {duration}-năm hạng thành viên {tierName} của {siteTitle}.", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "Cảm ơn sự ủng hộ của bạn. Hãy chia sẻ liên kết bên dưới với người mà bạn muốn tặng {duration}-tháng hạng thành viên {tierName} của {siteTitle}.", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "Cảm ơn sự ủng hộ của bạn. Hãy chia sẻ liên kết bên dưới với người mà bạn muốn tặng {duration}-năm hạng thành viên {tierName} của {siteTitle}.", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "Cảm ơn sự ủng hộ của bạn. Món quà — {duration}-tháng hạng thành viên {tierName} của {siteTitle} — đã được gửi đến {recipientEmail}. Bạn cũng có thể tự chia sẻ liên kết bên dưới.", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. You can also share the link below yourself.": "Cảm ơn sự ủng hộ của bạn. Món quà — {duration}-tháng hạng thành viên {tierName} của {siteTitle} — sẽ được gửi đến {recipientEmail} vào {deliveryDate}. Bạn cũng có thể tự chia sẻ liên kết bên dưới.", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "Cảm ơn sự ủng hộ của bạn. Món quà — {duration}-năm hạng thành viên {tierName} của {siteTitle} — đã được gửi đến {recipientEmail}. Bạn cũng có thể tự chia sẻ liên kết bên dưới.", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. You can also share the link below yourself.": "Cảm ơn sự ủng hộ của bạn. Món quà — {duration}-năm hạng thành viên {tierName} của {siteTitle} — sẽ được gửi đến {recipientEmail} vào {deliveryDate}. Bạn cũng có thể tự chia sẻ liên kết bên dưới.", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "Cảm ơn sự ủng hộ của bạn. Món quà — {duration}-tháng hạng thành viên {tierName} của {siteTitle} — đã được gửi đến {recipientEmail}. Bạn cũng có thể tự chia sẻ liên kết bên dưới.", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. You can also share the link below yourself.": "Cảm ơn sự ủng hộ của bạn. Món quà — {duration}-tháng hạng thành viên {tierName} của {siteTitle} — sẽ được gửi đến {recipientEmail} vào {deliveryDate}. Bạn cũng có thể tự chia sẻ liên kết bên dưới.", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "Cảm ơn sự ủng hộ của bạn. Món quà — {duration}-năm hạng thành viên {tierName} của {siteTitle} — đã được gửi đến {recipientEmail}. Bạn cũng có thể tự chia sẻ liên kết bên dưới.", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. You can also share the link below yourself.": "Cảm ơn sự ủng hộ của bạn. Món quà — {duration}-năm hạng thành viên {tierName} của {siteTitle} — sẽ được gửi đến {recipientEmail} vào {deliveryDate}. Bạn cũng có thể tự chia sẻ liên kết bên dưới.", "Thanks for reading {siteTitle}.": "Cảm ơn bạn đã đọc {siteTitle}.", "The link expires on {expiresAt} and can only be redeemed once.": "Liên kết hết hạn vào {expiresAt} và chỉ có thể đổi một lần.", "This email address will not be used.": "Địa chỉ email này sẽ không còn sử dụng.", - "This gift can only be redeemed once and expires on {expiresAt}.": "", - "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "", - "This message was sent from {siteDomain} to {email}.": "Tin nhắn này được {siteDomain} gửi tới {email}.", + "This gift can only be redeemed once and expires on {expiresAt}.": "Món quà này chỉ có thể nhận một lần và hết hạn vào {expiresAt}.", + "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "Tin nhắn này được gửi từ {siteDomain} đến {email} thay mặt cho {buyerName} ({buyerEmail}).", + "This message was sent from {siteDomain} to {email}.": "Tin nhắn này được gửi từ {siteDomain} đến {email}.", "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "Để tiếp tục là thành viên {tierName}, hãy tiếp tục sử dụng gói trả phí ngay hôm nay và chúng tôi sẽ tự động gia hạn thời gian đọc thử còn lại của quà tặng dưới dạng bản đọc thử miễn phí.", "trialing": "đọc thử", "Unsubscribe": "Hủy đăng ký", @@ -100,14 +100,14 @@ "Upgrade to continue reading.": "Nâng cấp để đọc tiếp", "View comments": "Xem bình luận", "View in browser": "Xem bằng trình duyệt", - "We couldn't deliver your gift": "", - "We couldn't deliver your gift to {recipientEmail}. Send them the gift link below so they can redeem it.": "", + "We couldn't deliver your gift": "Chúng tôi không thể gửi món quà của bạn", + "We couldn't deliver your gift to {recipientEmail}. Send them the gift link below so they can redeem it.": "Chúng tôi không thể gửi món quà của bạn đến {recipientEmail}. Hãy gửi họ liên kết quà bên dưới.", "Welcome back to {siteTitle}!": "Chào mừng quay lại {siteTitle}!", "Welcome back to {siteTitle}! Your verification code is {otc}.": "Chào mừng quay lại {siteTitle}! Mã xác minh của bạn là {otc}.", "Welcome back! Here's your code to sign in to {siteTitle}": "Chào mừng quay lại! Đây là mã để bạn đăng nhập {siteTitle}", "Welcome back! Use this link to securely sign in to your {siteTitle} account:": "Chào mừng quay lại! Sử dụng liên kết này để đăng nhập an toàn vào tài khoản {siteTitle} của bạn:", - "What's included": "", - "What's included:": "", + "What's included": "Có những gì", + "What's included:": "Gồm có:", "When:": "Thời gian:", "Where:": "Địa điểm:", "You are receiving this because you are a %%{status}%% subscriber to {site}.": "Bạn nhận được email này vì bạn là thành viên %%{status}%% của {site}.", @@ -118,19 +118,19 @@ "You will not be subscribed.": "Bạn sẽ không đăng ký gói.", "You're one tap away from subscribing to {siteTitle} — please confirm your email address with this link:": "Bạn còn một bước nữa để đăng ký {siteTitle} — vui lòng xác nhận địa chỉ email của bạn bằng liên kết này:", "You're one tap away from subscribing to {siteTitle}!": "Bạn còn một bước nữa để đăng ký {siteTitle}!", - "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "Bạn vừa được tặng {duration}-tháng thành viên {tierName} trên {siteTitle}", - "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "Bạn vừa được tặng {duration}-năm {tierName} thành viên trên {siteTitle}", - "You've been gifted a membership to {siteTitle}": "", + "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "Bạn vừa được tặng {duration}-tháng hạng thành viên {tierName} của {siteTitle}", + "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "Bạn vừa được tặng {duration}-năm hạng thành viên {tierName} của {siteTitle}", + "You've been gifted a membership to {siteTitle}": "Bạn vừa được tặng hạng thành viên của {siteTitle}", "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "Gói đọc thử của bạn sẽ hết hạn vào {date} và sau đó bắt đầu tính phí. Bạn luôn có thể hủy gói trước thời điểm này.", - "Your gift has been sent": "", - "Your gift is on its way": "", + "Your gift has been sent": "Món quà của bạn đã được gửi đi", + "Your gift is on its way": "Món quà của bạn đang trên đường đến nơi", "Your gift is ready": "Món quà của bạn đã chuẩn bị xong", - "Your gift is scheduled": "", + "Your gift is scheduled": "Món quà của bạn đã được lên lịch", "Your gift subscription confirmation": "Xác nhận nhận quà gói thành viên", "Your gift subscription is ending soon": "Gói thành viên mà bạn được tặng sắp hết hạn", "Your gift subscription to {siteTitle} ends on {consumesAt}.": "Gói thành viên mà bạn được tặng trên {siteTitle} hết hạn vào {consumesAt}.", - "Your gift was sent to {recipientEmail}. You can also share the link below yourself.": "", - "Your gift will be sent on {deliveryDate}": "", + "Your gift was sent to {recipientEmail}. You can also share the link below yourself.": "Món quà của bạn đã được gửi đến {recipientEmail}. Bạn cũng có thể tự chia sẻ liên kết bên dưới.", + "Your gift will be sent on {deliveryDate}": "Món quà của bạn sẽ được gửi vào {deliveryDate}", "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "Gói thành viên của bạn đã bị hủy và sẽ hết hạn vào {date}. Bạn có thể tiếp tục lại trong cài đặt tài khoản.", "Your subscription has expired.": "Gói thành viên của bạn đã hết hạn.", "Your subscription will expire on {date}.": "Gói thành viên của bạn sẽ hết hạn vào {date}.", diff --git a/packages/i18n/locales/vi/portal.json b/packages/i18n/locales/vi/portal.json index e86e9b7250e..7514af07984 100644 --- a/packages/i18n/locales/vi/portal.json +++ b/packages/i18n/locales/vi/portal.json @@ -6,19 +6,19 @@ "{amount} off for first {number} months.": "Giảm {amount} cho {number} tháng đầu tiên.", "{amount} off for first {period}.": "Giảm {amount} cho {period} đầu tiên.", "{amount} off forever.": "Giảm {amount} vĩnh viễn.", - "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}_other": "", - "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}_other": "{buyerName} vừa tặng bạn {duration}-tháng hạng thành viên {tierName} của {siteTitle}", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "{buyerName} vừa tặng bạn {duration}-năm hạng thành viên {tierName} của {siteTitle}", "{discount}% discount": "Giảm {discount}%", - "{duration} membership": "", + "{duration} membership": "{duration} gói thành viên", "{field}: {message}": "", "{memberEmail} will no longer receive {newsletterName} newsletter.": "{memberEmail} sẽ không còn được nhận bản tin {newsletterName}.", "{memberEmail} will no longer receive emails when someone replies to your comments.": "{memberEmail} sẽ không còn được nhận email khi ai đó trả lời bình luận.", "{memberEmail} will no longer receive this newsletter.": "{memberEmail} sẽ không còn được nhận bản tin này.", "{memberEmail} will no longer receive updates & announcements.": "{memberEmail} sẽ không còn được nhận cập nhật & thông báo.", - "{months} month": "", + "{months} month": "{months} tháng", "{months} months": "{months} tháng", "{months} months free": "Miễn phí {months} tháng", - "{tierName} membership": "Thành viên {tierName}", + "{tierName} membership": "Hạng thành viên {tierName}", "{trialDays} days free": "{trialDays} ngày đọc thử miễn phí", "{years} years": "{years} năm", "+1 (123) 456-7890": "+84 (987) 654-321", @@ -31,10 +31,10 @@ "Account details updated successfully": "Đã cập nhật chi tiết tài khoản thành công", "Account settings": "Cài đặt", "Add a personal note": "Thêm lưu ý về người này", - "Add a short note to go with your gift": "", + "Add a short note to go with your gift": "Thêm ghi chú đi kèm với quà của bạn", "Address line 1": "", "Address line 2": "", - "After a free trial ends, you will be charged the regular price for the tier you've chosen. You can always cancel before then.": "Sau khi hết hạn đọc thử miễn phí, sẽ tính phí với gói thành viên mà bạn đã chọn. Bạn có thể hủy gói trước khi hết hạn đọc thử.", + "After a free trial ends, you will be charged the regular price for the tier you've chosen. You can always cancel before then.": "Sau khi hết hạn đọc thử miễn phí, sẽ tính phí với hạng thành viên mà bạn đã chọn. Bạn có thể hủy gói trước khi hết hạn đọc thử.", "Already a member?": "Bạn đã là thành viên?", "An error occurred": "Xảy ra lỗi", "An unexpected error occured. Please try again or contact support if the error persists.": "Xảy ra lỗi chưa biết. Hãy thử lại hoặc liên hệ hỗ trợ nếu vẫn tiếp tục lỗi.", @@ -44,8 +44,8 @@ "Billing info & receipts": "Thông tin thanh toán & hóa đơn", "Black Friday": "Ưu Đãi", "Bluesky": "Bluesky", - "Buy": "", - "Buying for someone else?": "", + "Buy": "Mua", + "Buying for someone else?": "Mua tặng ai đó?", "Cancel anytime.": "Hủy bất cứ lúc nào.", "Cancel subscription": "Hủy gói", "Canceled": "Đã hủy", @@ -56,12 +56,12 @@ "Check with your mail provider": "Yêu cầu nhà cung cấp dịch vụ email hỗ trợ", "Check your inbox to verify email update": "Kiểm tra email của bạn để xác minh cập nhật email", "Choose": "Chọn", - "Choose a date from today onwards": "", - "Choose a date within the next year": "", - "Choose a delivery date": "", + "Choose a date from today onwards": "Chọn một ngày từ hôm nay trở đi", + "Choose a date within the next year": "Chọn một ngày trong vòng một năm tới", + "Choose a delivery date": "Chọn ngày gửi", "Choose a different plan": "Chọn gói khác", "Choose a plan": "Chọn một gói", - "Choose a tier": "", + "Choose a tier": "Chọn một hạng thành viên", "Choose your newsletters": "Chọn bản tin bạn muốn nhận", "City": "", "Click here to retry": "Nhấn vào đây thử lại", @@ -77,8 +77,8 @@ "Contact support": "Liên hệ hỗ trợ", "Continue": "Tiếp tục", "Continue subscription": "Tiếp tục đăng ký", - "Continue to delivery details": "", - "Continue to payment": "", + "Continue to delivery details": "Tiếp tục tới chi tiết gửi", + "Continue to payment": "Tiếp tục thanh toán", "Continue with a paid subscription anytime. Your remaining gift period will be added as a free trial.": "Bạn có thể tiếp tục sử dụng gói trả phí bất cứ lúc nào. Thời gian sử dụng còn lại sẽ được tính là thời gian đọc thử miễn phí.", "Copied": "Đã sao chép", "Copy": "Sao chép", @@ -91,14 +91,14 @@ "Create a new contact": "Tạo liên hệ mới", "Current plan": "Gói hiện tại", "Delete account": "Xóa tài khoản", - "Delivery date": "", - "Delivery method": "", + "Delivery date": "Thời gian gửi", + "Delivery method": "Phương thức gửi", "Didn't mean to do this? Manage your preferences .": "Không muốn như vậy? Quản lý thiết lập .", "Don't have an account?": "Chưa có tài khoản?", "Edit": "Chỉnh sửa", "Email": "Email", - "Email confirmation link expired.": "", - "Email it to them": "", + "Email confirmation link expired.": "Liên kết xác nhận qua email đã hết hạn.", + "Email it to them": "Gửi email cho họ", "Email newsletter": "Bản tin email", "Email newsletter settings updated": "Đã cập nhật thiết lập bản tin email", "Email preferences": "Thiết lập email", @@ -112,7 +112,7 @@ "Enjoy a free month on us.": "Tận hưởng một tháng đọc miễn phí.", "Enjoy a free month on us. You won't be charged until {newBillingDate}.": "Tận hưởng một tháng đọc miễn phí. Bạn sẽ không bị tính phí cho tới {newBillingDate}.", "Enter code above": "Nhập mã ở trên", - "Enter the recipient's email address": "", + "Enter the recipient's email address": "Nhập địa chỉ email người nhận", "Enter your email address": "Nhập địa chỉ email của bạn", "Enter your name": "Nhập tên của bạn", "Error": "Lỗi", @@ -132,19 +132,19 @@ "Failed to update newsletter settings": "Không thể cập nhật thiết lập bản tin email", "Failed to update subscription, please try again": "Không thể cập nhật gói, vui lòng thử lại", "Failed to verify code, please try again": "Không thể xác minh mã, vui lòng thử lại", - "For a friend or colleague": "", + "For a friend or colleague": "Cho bạn bè hoặc đồng nghiệp", "Forever": "Vĩnh viễn", "Free Trial – Ends {trialEnd}": "Đọc Thử – Hết hạn vào {trialEnd}", - "From": "", + "From": "Từ", "Get help": "Trợ giúp", "Get in touch for help": "Yêu cầu trợ giúp", "Get notified when someone replies to your comment": "Thông báo khi có ai đó trả lời bình luận", "Gift a membership": "Tặng gói thành viên", "Gift could not be redeemed": "Không thể đổi quà", "Gift details": "Chi tiết quà tặng", - "Gift duration": "", - "Gift link copied to clipboard": "", - "Gift membership": "", + "Gift duration": "Thời hạn quà", + "Gift link copied to clipboard": "Đã sao chép liên kết quà tặng vào clipboard", + "Gift membership": "Tặng gói thành viên", "Gift redeemed! You're all set.": "Đã đổi quà! Mọi thứ sẵn sàng rồi.", "Gift subscription": "Tặng gói thành viên", "Gift subscriptions are not available right now.": "Tặng gói thành viên hiện không khả dụng.", @@ -152,10 +152,10 @@ "Give feedback on this post": "Phản hồi bài viết này", "Help! I'm not receiving emails": "Trợ giúp! Tôi không nhận được email", "Here are a few other sites you may enjoy.": "Đây là vài trang khác mà bạn có thể thích.", - "Hi {recipientName},": "", + "Hi {recipientName},": "Chào {recipientName},", "Hide details": "Ẩn chi tiết", - "How would you like to share this gift?": "", - "I'll share it myself": "", + "How would you like to share this gift?": "Bạn muốn chia sẻ món quà này như thế nào?", + "I'll share it myself": "Tôi sẽ tự chia sẻ nó", "If a newsletter is flagged as spam, emails are automatically disabled for that address to make sure you no longer receive any unwanted messages.": "Nếu một bản tin email bị gắn nhãn thư rác, email sẽ tự động bị vô hiệu hóa đối với địa chỉ đó để đảm bảo bạn không còn nhận được bất kỳ email không mong muốn nào nữa.", "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.": "Nếu khiếu nại về thư rác là vô tình hoặc bạn muốn bắt đầu nhận lại bản tin email, bạn có thể hoàn tác bằng cách nhấn vào nút trên màn hình trước đó.", "If you cancel your subscription now, you will continue to have access until {periodEnd}.": "Nếu bạn hủy gói lúc này, bạn sẽ có quyền truy cập tiếp tục cho đến {periodEnd}.", @@ -190,7 +190,7 @@ "No thanks, I want to cancel": "Cảm ơn, tôi vẫn muốn hủy", "Not ready to share? We've also emailed a copy to your inbox.": "Chưa sẵn sàng chia sẻ? Chúng tôi cũng đã gửi email một bản sao cho bạn.", "Not receiving emails?": "Bạn không nhận được email?", - "Now": "", + "Now": "Lập tức", "Now check your email!": "Kiểm tra hộp thư ngay!", "Occasional updates from {siteTitle}": "Thông tin cập nhật định kỳ từ {siteTitle}", "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.": "Sau khi chọn nhận lại, nếu bạn vẫn không thấy email trong hộp thư đến của mình, hãy kiểm tra thư mục Spam. Một số nhà cung cấp email lưu giữ hồ sơ về các khiếu nại thư rác trước đây và sẽ tiếp tục phân loại email. Nếu điều này xảy ra, hãy đánh dấu bản tin email mới nhất là 'Không phải Spam' để chuyển nó trở lại hộp thư đến của bạn.", @@ -204,7 +204,7 @@ "Open Outlook": "Mở Outlook", "Open Proton Mail": "Mở Proton Mail", "Open Yahoo Mail": "Mở Yahoo Mail", - "Optional message": "", + "Optional message": "Thông điệp tùy chọn", "Permanent failure (bounce)": "Thất bại vĩnh viễn (thư bị trả lại)", "Phone number": "Số điện thoại", "Plan": "Gói thành viên", @@ -216,11 +216,11 @@ "Podcasts": "Podcast", "Postal code": "", "Re-enable emails": "Kích hoạt lại email", - "Recipient's email": "", - "Recipient's name": "", + "Recipient's email": "Email người nhận", + "Recipient's name": "Tên người nhận", "Recommendations": "Đề xuất", - "Redeem your gift": "", - "Redeeming...": "Đang đổi...", + "Redeem your gift": "Mở quà của bạn", + "Redeeming...": "Đang nhận...", "Renews at {price}.": "Phí gia hạn {price}.", "Resume subscription": "Tiếp tục gói", "Retry": "Thử lại", @@ -232,7 +232,7 @@ "Sending...": "Đang gửi...", "Share": "Chia sẻ", "Share a full membership to {siteTitle} with a friend or colleague": "Chia sẻ gói thành viên {siteTitle} với một người bạn hoặc đồng nghiệp", - "Share it yourself": "", + "Share it yourself": "Tự chia sẻ nó", "Show all": "Hiện toàn bộ", "Sign in": "Đăng nhập", "Sign out": "Đăng xuất", @@ -257,8 +257,8 @@ "Success! Your account is fully activated, you now have access to all content.": "Xong! Đã kích hoạt tài khoản, giờ bạn có thể truy cập toàn bộ nội dung.", "Success! Your email is updated.": "Xong! Đã cập nhật email của bạn.", "Successfully unsubscribed": "Đã hủy gói thành công", - "Taylor Reid": "", - "taylor@example.com": "", + "Taylor Reid": "Taylor Reid", + "taylor@example.com": "taylor@example.com", "Thank you for subscribing. Before you start reading, below are a few other sites you may enjoy.": "Cảm ơn đã đăng ký. Trước khi bắt đầu đọc, hãy xem thử vài trang khác mà bạn có thể thích.", "Thank you for your support": "Cảm ơn bạn đã ủng hộ", "Thank you for your support!": "Cảm ơn bạn đã ủng hộ!", @@ -270,20 +270,20 @@ "There was an error continuing your subscription, please try again.": "Xảy ra lỗi khi tiếp tục gói thành viên, vui lòng thử lại", "There was an error processing your payment. Please try again.": "Xảy ra lỗi khi tiến hành thanh toán. Hãy thử lại sau.", "There was an error sending the email, please try again": "Xảy ra lỗi khi gửi email, vui lòng thử lại", - "This gift can only be redeemed once and expires on {expiryDate}.": "", + "This gift can only be redeemed once and expires on {expiryDate}.": "Món quà này chỉ có thể nhận một lần và sẽ hết hạn vào {expiryDate}.", "This gift has already been consumed.": "Món quà này đã được sử dụng hết.", "This gift has already been redeemed.": "Món quà này đã được đổi.", "This gift has been refunded.": "Món quà này đã được hoàn tiền.", "This gift has expired.": "Món quà này đã hết hạn.", - "This gift link is invalid.": "", + "This gift link is invalid.": "Liên kết quà không hợp lệ.", "This site is invite-only, contact the owner for access.": "Trang web này chỉ dành cho những người được mời, hãy liên hệ với chủ sở hữu để cấp quyền truy cập.", "This site is not accepting donations at the moment.": "Trang web này hiện chưa nhận quyên góp.", "This site is not accepting payments at the moment.": "Trang web này hiện chưa chấp nhận thanh toán.", "This site only accepts paid members.": "Trang web này chỉ dành cho thành viên trả phí.", "This unsubscribe link belongs to a different email address than the one you are signed in with.": "", "Threads": "Threads", - "Tier": "Gói", - "To": "", + "Tier": "Hạng thành viên", + "To": "Đến", "To complete signup, click the confirmation link in your inbox. If it doesn't arrive within 3 minutes, check your spam folder!": "Để hoàn tất đăng ký, nhấn vào liên kết xác nhận được gửi tới email của bạn. Sau 3 phút mà không thấy, hãy kiểm tra thư mục Spam!", "To continue to stay up to date, subscribe to {publication} below.": "Để tiếp tục được cập nhật, hãy đăng ký {publication} bên dưới.", "Too many attempts try again in {number} days.": "Thử quá nhiều, hãy thử lại sau {number} ngày.", @@ -309,12 +309,12 @@ "View plans": "Xem các gói", "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "Chúng tôi không thể hủy gói vì không tìm thấy địa chỉ email. Vui lòng liên hệ với chủ sở hữu trang web.", "We'd hate to see you leave. How about a special offer to stay?": "Chúng tôi không muốn bạn rời đi. Bạn có muốn nhận ưu đãi đặc biệt để ở lại không?", - "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.": "", - "We'll email it to the recipient. A copy will be in your inbox too.": "", + "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.": "Chúng tôi sẽ gửi email cho người nhận vào {deliveryDate}. Bạn cũng sẽ nhận được một bản sao trong hộp thư đến của mình.", + "We'll email it to the recipient. A copy will be in your inbox too.": "Chúng tôi sẽ gửi email cho người nhận. Bạn cũng sẽ nhận được một bản sao trong hộp thư đến của mình.", "Welcome back, {name}!": "Chào mừng trở lại, {name}!", "Welcome back!": "Chào mừng trở lại!", "Welcome to {siteTitle}": "Chào mừng đến với {siteTitle}", - "What's included": "", + "What's included": "Gồm có", "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.": "Khi hộp thư đến không nhận được email, nó thường được gọi là email bị trả lại. Điều này có thể là tạm thời. Tuy nhiên, trong một số trường hợp, email bị trả lại có thể là lỗi vĩnh viễn nếu địa chỉ email không hợp lệ hoặc không tồn tại.", "Why has my email been disabled?": "Tại sao email của tôi bị vô hiệu hóa?", "X (Twitter)": "X (Twitter)", @@ -327,18 +327,18 @@ "You're currently not receiving emails": "Hiện tại bạn không nhận email", "You're not receiving emails": "Bạn không nhận được bản tin email", "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.": "Bạn không nhận được bản tin email vì bạn đã đánh dấu một email gần đây là thư rác, hoặc vì địa chỉ email bạn cung cấp không thể gửi được.", - "You've been gifted a {duration}-month {tierName} membership_other": "", - "You've been gifted a {duration}-month {tierName} membership to {siteTitle}_other": "", - "You've been gifted a {duration}-year {tierName} membership": "", - "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "", + "You've been gifted a {duration}-month {tierName} membership_other": "Bạn vừa được tặng {duration}-tháng hạng thành viên {tierName}", + "You've been gifted a {duration}-month {tierName} membership to {siteTitle}_other": "Bạn vừa được tặng {duration}-tháng hạng thành viên {tierName} của {siteTitle}", + "You've been gifted a {duration}-year {tierName} membership": "Bạn vừa được tặng {duration}-năm hạng thành viên {tierName}", + "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "Bạn vừa được tặng {duration}-năm hạng thành viên {tierName} của {siteTitle}", "You've successfully signed in.": "Bạn đã đăng nhập thành công.", "You've successfully subscribed to {siteTitle}": "Bạn đã đăng ký thành công {siteTitle}", "Your account": "Tài khoản của bạn", "Your email": "Email của bạn", "Your email has failed to resubscribe, please try again": "Không thể gia hạn với email của bạn, vui lòng thử lại", - "Your gift is on its way": "", + "Your gift is on its way": "Món quà của bạn đang trên đường đến nơi", "Your gift is ready": "Món quà của bạn đã sẵn sàng", - "Your gift is scheduled": "", + "Your gift is scheduled": "Món quà của bạn đã được lên lịch", "Your gift subscription will expire on {expiryDate}": "Quà tặng gói thành viên sẽ hết hạn vào {expiryDate}", "your inbox": "hộp thư đến của bạn", "Your input helps shape what gets published.": "Thông tin bạn chọn giúp định hình nội dung được xuất bản.", From 33fbb7f603cf55c27baeeb9d3d59b4939e0a68c6 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Wed, 23 Sep 2026 08:30:51 -0500 Subject: [PATCH 4/4] Switched to shorter `Object.hasOwn` usage (#30944) no ref This change should have no user impact. All I did was run this from in `ghost/core/`: ```sh ast-grep --lang JavaScript -p 'Object.prototype.hasOwnProperty.call($PROP, $VAL)' --rewrite 'Object.hasOwn($PROP, $VAL)' -i ast-grep --lang TypeScript -p 'Object.prototype.hasOwnProperty.call($PROP, $VAL)' --rewrite 'Object.hasOwn($PROP, $VAL)' -i ``` I did *not* make this change in other parts of the code as they may run on the frontend which may not have this newer function. --- ghost/core/core/frontend/helpers/content.js | 2 +- ghost/core/core/frontend/helpers/t.js | 2 +- .../core/frontend/services/data/checks.js | 38 ++++++++-------- .../core/frontend/services/data/fetch-data.js | 4 +- .../services/theme-engine/config/index.js | 2 +- .../frontend/web/middleware/admin-toolbar.js | 2 +- ghost/core/core/frontend/web/site.js | 4 +- .../serializers/output/mappers/comments.js | 2 +- .../serializers/output/utils/extra-attrs.js | 2 +- .../serializers/output/utils/post-gating.js | 10 +---- .../server/data/db/better-sqlite3-patches.js | 2 +- ghost/core/core/server/data/db/connection.js | 2 +- .../server/data/importer/import-manager.js | 4 +- .../data/importer/importers/data/base.js | 2 +- .../importer/importers/data/data-importer.js | 2 +- .../importer/importers/data/users-importer.js | 2 +- .../core/core/server/data/schema/commands.js | 45 +++++++------------ .../data/schema/fixtures/fixture-manager.js | 2 +- .../core/core/server/data/schema/validator.ts | 14 +++--- .../core/core/server/models/base/listeners.js | 2 +- .../core/server/models/base/plugins/events.js | 11 ++--- .../server/models/base/plugins/sanitize.js | 6 +-- ghost/core/core/server/models/settings.js | 8 +--- .../services/comments/comments-controller.js | 4 +- .../services/comments/comments-service.js | 4 +- .../services/member-welcome-emails/service.js | 2 +- .../repositories/member-repository.js | 3 +- .../server/services/public-config/config.js | 2 +- .../recommendations/service/recommendation.ts | 2 +- .../recommendations/service/unsafe-data.ts | 4 +- .../route-settings/validation-errors.ts | 10 ++--- .../services/slack-ping/slack-ping-service.ts | 6 +-- .../webhook/checkout-session-event-service.js | 2 +- .../update-check/update-check-service.js | 4 +- .../e2e-api/members-comments/comments.test.js | 20 ++------- .../output/utils/extra-attrs.test.js | 14 +++--- .../route-settings-parser.test.ts | 2 +- ghost/core/test/utils/config-utils.js | 2 +- .../test/utils/e2e-framework-mock-manager.js | 4 +- 39 files changed, 101 insertions(+), 154 deletions(-) diff --git a/ghost/core/core/frontend/helpers/content.js b/ghost/core/core/frontend/helpers/content.js index a0d82726b6b..b630a4b749d 100644 --- a/ghost/core/core/frontend/helpers/content.js +++ b/ghost/core/core/frontend/helpers/content.js @@ -37,7 +37,7 @@ module.exports = function content(options = {}) { let runTruncate = false; for (const key of ['words', 'characters']) { - if (Object.prototype.hasOwnProperty.call(hash, key)) { + if (Object.hasOwn(hash, key)) { runTruncate = true; truncateOptions[key] = parseInt(hash[key], 10); } diff --git a/ghost/core/core/frontend/helpers/t.js b/ghost/core/core/frontend/helpers/t.js index e498b98c58e..060f330420e 100644 --- a/ghost/core/core/frontend/helpers/t.js +++ b/ghost/core/core/frontend/helpers/t.js @@ -25,7 +25,7 @@ module.exports = function t(text, options = {}) { const bindings = {}; let prop; for (prop in options.hash) { - if (Object.prototype.hasOwnProperty.call(options.hash, prop)) { + if (Object.hasOwn(options.hash, prop)) { bindings[prop] = options.hash[prop]; } } diff --git a/ghost/core/core/frontend/services/data/checks.js b/ghost/core/core/frontend/services/data/checks.js index f37a1680ebd..96074b75981 100644 --- a/ghost/core/core/frontend/services/data/checks.js +++ b/ghost/core/core/frontend/services/data/checks.js @@ -1,47 +1,47 @@ function isPost(jsonData) { return ( - Object.prototype.hasOwnProperty.call(jsonData, 'html') && - Object.prototype.hasOwnProperty.call(jsonData, 'title') && - Object.prototype.hasOwnProperty.call(jsonData, 'slug') + Object.hasOwn(jsonData, 'html') && + Object.hasOwn(jsonData, 'title') && + Object.hasOwn(jsonData, 'slug') ); } function isNewsletter(jsonData) { return ( - Object.prototype.hasOwnProperty.call(jsonData, 'name') && - Object.prototype.hasOwnProperty.call(jsonData, 'subscribe_on_signup') && - Object.prototype.hasOwnProperty.call(jsonData, 'visibility') + Object.hasOwn(jsonData, 'name') && + Object.hasOwn(jsonData, 'subscribe_on_signup') && + Object.hasOwn(jsonData, 'visibility') ); } function isPage(jsonData = {}) { - return Object.prototype.hasOwnProperty.call(jsonData, 'show_title_and_feature_image'); + return Object.hasOwn(jsonData, 'show_title_and_feature_image'); } function isTag(jsonData) { return ( - Object.prototype.hasOwnProperty.call(jsonData, 'name') && - Object.prototype.hasOwnProperty.call(jsonData, 'slug') && - Object.prototype.hasOwnProperty.call(jsonData, 'description') && - Object.prototype.hasOwnProperty.call(jsonData, 'feature_image') + Object.hasOwn(jsonData, 'name') && + Object.hasOwn(jsonData, 'slug') && + Object.hasOwn(jsonData, 'description') && + Object.hasOwn(jsonData, 'feature_image') ); } function isUser(jsonData) { return ( - Object.prototype.hasOwnProperty.call(jsonData, 'bio') && - Object.prototype.hasOwnProperty.call(jsonData, 'website') && - Object.prototype.hasOwnProperty.call(jsonData, 'profile_image') && - Object.prototype.hasOwnProperty.call(jsonData, 'location') + Object.hasOwn(jsonData, 'bio') && + Object.hasOwn(jsonData, 'website') && + Object.hasOwn(jsonData, 'profile_image') && + Object.hasOwn(jsonData, 'location') ); } function isNav(jsonData) { return ( - Object.prototype.hasOwnProperty.call(jsonData, 'label') && - Object.prototype.hasOwnProperty.call(jsonData, 'url') && - Object.prototype.hasOwnProperty.call(jsonData, 'slug') && - Object.prototype.hasOwnProperty.call(jsonData, 'current') + Object.hasOwn(jsonData, 'label') && + Object.hasOwn(jsonData, 'url') && + Object.hasOwn(jsonData, 'slug') && + Object.hasOwn(jsonData, 'current') ); } diff --git a/ghost/core/core/frontend/services/data/fetch-data.js b/ghost/core/core/frontend/services/data/fetch-data.js index e81c143537c..38f241a81e7 100644 --- a/ghost/core/core/frontend/services/data/fetch-data.js +++ b/ghost/core/core/frontend/services/data/fetch-data.js @@ -75,11 +75,11 @@ async function fetchData(pathOptions, routerOptions, locals) { postQuery.options.order = routerOptions.order; } - if (Object.prototype.hasOwnProperty.call(pathOptions, 'page')) { + if (Object.hasOwn(pathOptions, 'page')) { postQuery.options.page = pathOptions.page; } - if (Object.prototype.hasOwnProperty.call(pathOptions, 'limit')) { + if (Object.hasOwn(pathOptions, 'limit')) { postQuery.options.limit = pathOptions.limit; } diff --git a/ghost/core/core/frontend/services/theme-engine/config/index.js b/ghost/core/core/frontend/services/theme-engine/config/index.js index e1ede55cdea..ae81d340ac1 100644 --- a/ghost/core/core/frontend/services/theme-engine/config/index.js +++ b/ghost/core/core/frontend/services/theme-engine/config/index.js @@ -5,7 +5,7 @@ const allowedKeys = ['posts_per_page', 'image_sizes', 'card_assets']; module.exports.create = function configLoader(packageJson) { let config = _.cloneDeep(defaultConfig); - if (packageJson && Object.prototype.hasOwnProperty.call(packageJson, 'config')) { + if (packageJson && Object.hasOwn(packageJson, 'config')) { config = _.assign(config, _.pick(packageJson.config, allowedKeys)); } diff --git a/ghost/core/core/frontend/web/middleware/admin-toolbar.js b/ghost/core/core/frontend/web/middleware/admin-toolbar.js index fabedd214f8..b16d8bb8030 100644 --- a/ghost/core/core/frontend/web/middleware/admin-toolbar.js +++ b/ghost/core/core/frontend/web/middleware/admin-toolbar.js @@ -151,7 +151,7 @@ function isToolbarSuppressed(req) { function adminToolbarMiddleware(req, res, next) { const toolbarSuppressed = isToolbarSuppressed(req); - if (Object.prototype.hasOwnProperty.call(req.query || {}, QUERY_PARAM)) { + if (Object.hasOwn(req.query || {}, QUERY_PARAM)) { const value = getQueryValue(req.query[QUERY_PARAM]); if (value === '0') { diff --git a/ghost/core/core/frontend/web/site.js b/ghost/core/core/frontend/web/site.js index b9fe15e8cd4..4bcab87c8ea 100644 --- a/ghost/core/core/frontend/web/site.js +++ b/ghost/core/core/frontend/web/site.js @@ -120,7 +120,7 @@ module.exports = function setupSiteApp(routerConfig) { config.get('apps:internal').forEach((appName) => { const app = require(path.join(config.get('paths').internalAppPath, appName)); - if (Object.prototype.hasOwnProperty.call(app, 'setupMiddleware')) { + if (Object.hasOwn(app, 'setupMiddleware')) { app.setupMiddleware(siteApp); } }); @@ -204,7 +204,7 @@ module.exports = function setupSiteApp(routerConfig) { config.get('apps:internal').forEach((appName) => { const app = require(path.join(config.get('paths').internalAppPath, appName)); - if (Object.prototype.hasOwnProperty.call(app, 'setupErrorHandling')) { + if (Object.hasOwn(app, 'setupErrorHandling')) { app.setupErrorHandling(siteApp); } }); diff --git a/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/comments.js b/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/comments.js index 5e0cc7cf022..da31560286d 100644 --- a/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/comments.js +++ b/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/comments.js @@ -86,7 +86,7 @@ const commentMapper = (model, frame) => { ); if (!fields || fields.has('pinned')) { - if (Object.prototype.hasOwnProperty.call(jsonModel, 'pinned')) { + if (Object.hasOwn(jsonModel, 'pinned')) { response.pinned = Boolean(jsonModel.pinned); } else { const canShowPinned = !jsonModel.parent_id && Boolean(jsonModel.pinned_at); diff --git a/ghost/core/core/server/api/endpoints/utils/serializers/output/utils/extra-attrs.js b/ghost/core/core/server/api/endpoints/utils/serializers/output/utils/extra-attrs.js index 790c0a5e7ac..596ceaa8093 100644 --- a/ghost/core/core/server/api/endpoints/utils/serializers/output/utils/extra-attrs.js +++ b/ghost/core/core/server/api/endpoints/utils/serializers/output/utils/extra-attrs.js @@ -18,7 +18,7 @@ module.exports.forPost = (options, model, attrs) => { const formatsIncludesPlaintext = options.formats?.includes('plaintext'); // no columns requested - const noColumnsRequested = !Object.prototype.hasOwnProperty.call(options, 'columns'); + const noColumnsRequested = !Object.hasOwn(options, 'columns'); // 1. Gets excerpt from post's plaintext. If custom_excerpt exists, it overrides the excerpt but the key remains excerpt. if (columnsIncludesExcerpt) { diff --git a/ghost/core/core/server/api/endpoints/utils/serializers/output/utils/post-gating.js b/ghost/core/core/server/api/endpoints/utils/serializers/output/utils/post-gating.js index 8c5955d65be..4797e5b8cad 100644 --- a/ghost/core/core/server/api/endpoints/utils/serializers/output/utils/post-gating.js +++ b/ghost/core/core/server/api/endpoints/utils/serializers/output/utils/post-gating.js @@ -93,10 +93,7 @@ function _updateTextAttrs(attrs) { // @TODO: reconsider the location of this - it's part of members and adds a property to the API const forPost = (attrs, frame) => { // CASE: Access always defaults to true, unless members is enabled and the member does not have access - if ( - !Object.prototype.hasOwnProperty.call(frame.options, 'columns') || - frame.options.columns.includes('access') - ) { + if (!Object.hasOwn(frame.options, 'columns') || frame.options.columns.includes('access')) { attrs.access = true; } @@ -132,10 +129,7 @@ const forPost = (attrs, frame) => { attrs.html = attrs.html.replace(/%7Buuid%7D/gi, member.uuid); } - if ( - !Object.prototype.hasOwnProperty.call(frame.options, 'columns') || - frame.options.columns.includes('access') - ) { + if (!Object.hasOwn(frame.options, 'columns') || frame.options.columns.includes('access')) { attrs.access = memberHasAccess; } diff --git a/ghost/core/core/server/data/db/better-sqlite3-patches.js b/ghost/core/core/server/data/db/better-sqlite3-patches.js index dcf3f2cbd91..5e884bd00bd 100644 --- a/ghost/core/core/server/data/db/better-sqlite3-patches.js +++ b/ghost/core/core/server/data/db/better-sqlite3-patches.js @@ -63,7 +63,7 @@ function getPrimarySqliteCode(code) { return undefined; } - if (Object.prototype.hasOwnProperty.call(SQLITE_PRIMARY_RESULT_CODES, code)) { + if (Object.hasOwn(SQLITE_PRIMARY_RESULT_CODES, code)) { return code; } diff --git a/ghost/core/core/server/data/db/connection.js b/ghost/core/core/server/data/db/connection.js index 83946ea9fec..2aa7f3d6d85 100644 --- a/ghost/core/core/server/data/db/connection.js +++ b/ghost/core/core/server/data/db/connection.js @@ -28,7 +28,7 @@ function configure(dbConfig) { if (client === 'better-sqlite3') { // Backwards compatibility with old knex behaviour - dbConfig.useNullAsDefault = Object.prototype.hasOwnProperty.call(dbConfig, 'useNullAsDefault') + dbConfig.useNullAsDefault = Object.hasOwn(dbConfig, 'useNullAsDefault') ? dbConfig.useNullAsDefault : true; diff --git a/ghost/core/core/server/data/importer/import-manager.js b/ghost/core/core/server/data/importer/import-manager.js index 221b6afdd03..ebb0a836156 100644 --- a/ghost/core/core/server/data/importer/import-manager.js +++ b/ghost/core/core/server/data/importer/import-manager.js @@ -191,7 +191,7 @@ class ImportManager { debug('handler', handler.type, files); if (files.length > 0) { - if (Object.prototype.hasOwnProperty.call(importData, handler.type)) { + if (Object.hasOwn(importData, handler.type)) { // This limitation is here to reduce the complexity of the importer for now throw new errors.UnsupportedMediaTypeError({ message: tpl(messages.zipContainsMultipleDataFormats), @@ -290,7 +290,7 @@ class ImportManager { for (const importer of this.importers) { debug('importer looking for', importer.type, 'in', Object.keys(importData)); - if (Object.prototype.hasOwnProperty.call(importData, importer.type)) { + if (Object.hasOwn(importData, importer.type)) { importResults[importer.type] = await importer.doImport( importData[importer.type], importOptions, diff --git a/ghost/core/core/server/data/importer/importers/data/base.js b/ghost/core/core/server/data/importer/importers/data/base.js index 9686af830e9..241b8575b9f 100644 --- a/ghost/core/core/server/data/importer/importers/data/base.js +++ b/ghost/core/core/server/data/importer/importers/data/base.js @@ -193,7 +193,7 @@ class Base { const userReferenceProblems = {}; const handleObject = (obj, key) => { - if (!Object.prototype.hasOwnProperty.call(obj, key)) { + if (!Object.hasOwn(obj, key)) { return; } diff --git a/ghost/core/core/server/data/importer/importers/data/data-importer.js b/ghost/core/core/server/data/importer/importers/data/data-importer.js index 8fb32724cc6..8b71aaba1d3 100644 --- a/ghost/core/core/server/data/importer/importers/data/data-importer.js +++ b/ghost/core/core/server/data/importer/importers/data/data-importer.js @@ -86,7 +86,7 @@ const DataImporter = { }, }; - if (!Object.prototype.hasOwnProperty.call(importOptions, 'returnImportedData')) { + if (!Object.hasOwn(importOptions, 'returnImportedData')) { importOptions.returnImportedData = false; } diff --git a/ghost/core/core/server/data/importer/importers/data/users-importer.js b/ghost/core/core/server/data/importer/importers/data/users-importer.js index 4b02783d0cc..1745c6d6693 100644 --- a/ghost/core/core/server/data/importer/importers/data/users-importer.js +++ b/ghost/core/core/server/data/importer/importers/data/users-importer.js @@ -37,7 +37,7 @@ class UsersImporter extends BaseImporter { // NOTE: sort out duplicated roles based on incremental id _.each(this.requiredFromFile.roles_users, (attachedRole) => { - if (Object.prototype.hasOwnProperty.call(lookup, attachedRole.user_id)) { + if (Object.hasOwn(lookup, attachedRole.user_id)) { if (lookup[attachedRole.user_id].id < attachedRole.id) { lookup[attachedRole.user_id] = attachedRole; } diff --git a/ghost/core/core/server/data/schema/commands.js b/ghost/core/core/server/data/schema/commands.js index 2e7f1c62de5..d3083508037 100644 --- a/ghost/core/core/server/data/schema/commands.js +++ b/ghost/core/core/server/data/schema/commands.js @@ -28,18 +28,15 @@ function addTableColumn( let column; // creation distinguishes between text with fieldtype, string with maxlength and all others - if (columnSpec.type === 'text' && Object.prototype.hasOwnProperty.call(columnSpec, 'fieldtype')) { + if (columnSpec.type === 'text' && Object.hasOwn(columnSpec, 'fieldtype')) { column = tableBuilder[columnSpec.type](columnName, columnSpec.fieldtype); - } else if ( - columnSpec.type === 'binary' && - Object.prototype.hasOwnProperty.call(columnSpec, 'maxlength') - ) { + } else if (columnSpec.type === 'binary' && Object.hasOwn(columnSpec, 'maxlength')) { // knex emits an unbounded `blob` for a length-less binary column, which // MySQL can only index with a prefix. Passing the length gives us // `varbinary(N)`, which is indexable in full. column = tableBuilder[columnSpec.type](columnName, columnSpec.maxlength); } else if (columnSpec.type === 'string') { - if (Object.prototype.hasOwnProperty.call(columnSpec, 'maxlength')) { + if (Object.hasOwn(columnSpec, 'maxlength')) { column = tableBuilder[columnSpec.type](columnName, columnSpec.maxlength); } else { column = tableBuilder[columnSpec.type](columnName, 191); @@ -48,51 +45,39 @@ function addTableColumn( column = tableBuilder[columnSpec.type](columnName); } - if ( - Object.prototype.hasOwnProperty.call(columnSpec, 'nullable') && - columnSpec.nullable === true - ) { + if (Object.hasOwn(columnSpec, 'nullable') && columnSpec.nullable === true) { column.nullable(); } else { column.nullable(false); } - if (Object.prototype.hasOwnProperty.call(columnSpec, 'primary') && columnSpec.primary === true) { + if (Object.hasOwn(columnSpec, 'primary') && columnSpec.primary === true) { column.primary(); } - if (Object.prototype.hasOwnProperty.call(columnSpec, 'unique') && columnSpec.unique) { + if (Object.hasOwn(columnSpec, 'unique') && columnSpec.unique) { column.unique(); } - if (Object.prototype.hasOwnProperty.call(columnSpec, 'unsigned') && columnSpec.unsigned) { + if (Object.hasOwn(columnSpec, 'unsigned') && columnSpec.unsigned) { column.unsigned(); } - if (Object.prototype.hasOwnProperty.call(columnSpec, 'references')) { + if (Object.hasOwn(columnSpec, 'references')) { // check if table exists? column.references(columnSpec.references); } - if (Object.prototype.hasOwnProperty.call(columnSpec, 'constraintName')) { + if (Object.hasOwn(columnSpec, 'constraintName')) { column.withKeyName(columnSpec.constraintName); } - if ( - Object.prototype.hasOwnProperty.call(columnSpec, 'cascadeDelete') && - columnSpec.cascadeDelete === true - ) { + if (Object.hasOwn(columnSpec, 'cascadeDelete') && columnSpec.cascadeDelete === true) { column.onDelete('CASCADE'); - } else if ( - Object.prototype.hasOwnProperty.call(columnSpec, 'restrictDelete') && - columnSpec.restrictDelete === true - ) { + } else if (Object.hasOwn(columnSpec, 'restrictDelete') && columnSpec.restrictDelete === true) { column.onDelete('RESTRICT'); - } else if ( - Object.prototype.hasOwnProperty.call(columnSpec, 'setNullDelete') && - columnSpec.setNullDelete === true - ) { + } else if (Object.hasOwn(columnSpec, 'setNullDelete') && columnSpec.setNullDelete === true) { column.onDelete('SET NULL'); } - if (Object.prototype.hasOwnProperty.call(columnSpec, 'defaultTo')) { + if (Object.hasOwn(columnSpec, 'defaultTo')) { column.defaultTo(columnSpec.defaultTo); } - if (Object.prototype.hasOwnProperty.call(columnSpec, 'index') && columnSpec.index === true) { + if (Object.hasOwn(columnSpec, 'index') && columnSpec.index === true) { column.index(); } } @@ -195,7 +180,7 @@ async function addColumn(tableName, column, transaction = db.knex, columnSpec, o * @param {'instant'|'inplace'|'copy'|'auto'} [options.algorithm] - MySQL only */ async function dropColumn(tableName, column, transaction = db.knex, columnSpec = {}, options = {}) { - if (Object.prototype.hasOwnProperty.call(columnSpec, 'references')) { + if (Object.hasOwn(columnSpec, 'references')) { const [toTable, toColumn] = columnSpec.references.split('.'); await dropForeign({ fromTable: tableName, diff --git a/ghost/core/core/server/data/schema/fixtures/fixture-manager.js b/ghost/core/core/server/data/schema/fixtures/fixture-manager.js index 7de6fa622a2..f4470c42c31 100644 --- a/ghost/core/core/server/data/schema/fixtures/fixture-manager.js +++ b/ghost/core/core/server/data/schema/fixtures/fixture-manager.js @@ -281,7 +281,7 @@ class FixtureManager { const result = {}; for (const key in obj) { - if (Object.prototype.hasOwnProperty.call(obj, key)) { + if (Object.hasOwn(obj, key)) { // Process the key itself for placeholders const newKey = processString(key, replacements); diff --git a/ghost/core/core/server/data/schema/validator.ts b/ghost/core/core/server/data/schema/validator.ts index 02cd7ad04c1..b4f9fab8797 100644 --- a/ghost/core/core/server/data/schema/validator.ts +++ b/ghost/core/core/server/data/schema/validator.ts @@ -56,11 +56,11 @@ export function validateSchema(tableName: string, model: Model, options?: Option // check nullable if ( - Object.prototype.hasOwnProperty.call(schema[tableName][columnKey], 'nullable') && + Object.hasOwn(schema[tableName][columnKey], 'nullable') && schema[tableName][columnKey].nullable !== true && - Object.prototype.hasOwnProperty.call(schema[tableName][columnKey], 'type') && + Object.hasOwn(schema[tableName][columnKey], 'type') && schema[tableName][columnKey].type !== 'text' && - !Object.prototype.hasOwnProperty.call(schema[tableName][columnKey], 'defaultTo') + !Object.hasOwn(schema[tableName][columnKey], 'defaultTo') ) { if (validator.isEmpty(strVal)) { message = tpl(messages.valueCannotBeBlank, { @@ -78,7 +78,7 @@ export function validateSchema(tableName: string, model: Model, options?: Option // validate boolean columns if ( - Object.prototype.hasOwnProperty.call(schema[tableName][columnKey], 'type') && + Object.hasOwn(schema[tableName][columnKey], 'type') && schema[tableName][columnKey].type === 'boolean' ) { if (!(validator.isBoolean(strVal) || validator.isEmpty(strVal))) { @@ -103,7 +103,7 @@ export function validateSchema(tableName: string, model: Model, options?: Option // TODO: check if mandatory values should be enforced if (model.get(columnKey) !== null && model.get(columnKey) !== undefined) { // check length - if (Object.prototype.hasOwnProperty.call(schema[tableName][columnKey], 'maxlength')) { + if (Object.hasOwn(schema[tableName][columnKey], 'maxlength')) { if (!validator.isLength(strVal, 0, schema[tableName][columnKey].maxlength)) { message = tpl(messages.valueExceedsMaxLength, { tableName: tableName, @@ -120,7 +120,7 @@ export function validateSchema(tableName: string, model: Model, options?: Option } // check validations objects - if (Object.prototype.hasOwnProperty.call(schema[tableName][columnKey], 'validations')) { + if (Object.hasOwn(schema[tableName][columnKey], 'validations')) { validationErrors = validationErrors.concat( validator.validate( strVal, @@ -132,7 +132,7 @@ export function validateSchema(tableName: string, model: Model, options?: Option } // check type - if (Object.prototype.hasOwnProperty.call(schema[tableName][columnKey], 'type')) { + if (Object.hasOwn(schema[tableName][columnKey], 'type')) { if (schema[tableName][columnKey].type === 'integer' && !validator.isInt(strVal)) { message = tpl(messages.valueIsNotInteger, { tableName: tableName, diff --git a/ghost/core/core/server/models/base/listeners.js b/ghost/core/core/server/models/base/listeners.js index a195e55c01b..b24c8cbe6e1 100644 --- a/ghost/core/core/server/models/base/listeners.js +++ b/ghost/core/core/server/models/base/listeners.js @@ -92,7 +92,7 @@ const onNotificationsEdited = function (settingModel) { allNotifications = allNotifications.filter(function (notification) { // Do not delete the release notification - if (Object.prototype.hasOwnProperty.call(notification, 'custom') && !notification.custom) { + if (Object.hasOwn(notification, 'custom') && !notification.custom) { return true; } diff --git a/ghost/core/core/server/models/base/plugins/events.js b/ghost/core/core/server/models/base/plugins/events.js index ed5e9bb7f8b..95f0013e961 100644 --- a/ghost/core/core/server/models/base/plugins/events.js +++ b/ghost/core/core/server/models/base/plugins/events.js @@ -145,13 +145,13 @@ module.exports = function (Bookshelf) { * Exceptions: internal context or importing */ onCreating: function onCreating(model, attr, options) { - if (Object.prototype.hasOwnProperty.call(schema.tables[this.tableName], 'created_at')) { + if (Object.hasOwn(schema.tables[this.tableName], 'created_at')) { if (!model.get('created_at')) { model.set('created_at', new Date()); } } - if (Object.prototype.hasOwnProperty.call(schema.tables[this.tableName], 'updated_at')) { + if (Object.hasOwn(schema.tables[this.tableName], 'updated_at')) { if (!model.get('updated_at')) { model.set('updated_at', new Date()); } @@ -204,7 +204,7 @@ module.exports = function (Bookshelf) { } if (options && options.context && !options.context.internal && !options.importing) { - if (Object.prototype.hasOwnProperty.call(schema.tables[this.tableName], 'created_at')) { + if (Object.hasOwn(schema.tables[this.tableName], 'created_at')) { if (model.hasDateChanged('created_at', { beforeWrite: true })) { model.set('created_at', this.previous('created_at')); } @@ -212,10 +212,7 @@ module.exports = function (Bookshelf) { } // CASE: do not allow setting only the `updated_at` field, exception: importing - if ( - Object.prototype.hasOwnProperty.call(schema.tables[this.tableName], 'updated_at') && - !options.importing - ) { + if (Object.hasOwn(schema.tables[this.tableName], 'updated_at') && !options.importing) { if (options.migrating) { model.set('updated_at', model.previous('updated_at')); } else if (Object.keys(model.changed).length === 1 && model.changed.updated_at) { diff --git a/ghost/core/core/server/models/base/plugins/sanitize.js b/ghost/core/core/server/models/base/plugins/sanitize.js index 11df26f9dcc..8c2c3cdbaaa 100644 --- a/ghost/core/core/server/models/base/plugins/sanitize.js +++ b/ghost/core/core/server/models/base/plugins/sanitize.js @@ -110,7 +110,7 @@ module.exports = function (Bookshelf) { _.each(data, (value, property) => { if ( value !== null && - Object.prototype.hasOwnProperty.call(schema.tables[tableName], property) && + Object.hasOwn(schema.tables[tableName], property) && schema.tables[tableName][property].type === 'dateTime' && typeof value === 'string' ) { @@ -141,7 +141,7 @@ module.exports = function (Bookshelf) { _.each(relation, (relationValue, relationProperty) => { if ( relationValue !== null && - Object.prototype.hasOwnProperty.call( + Object.hasOwn( schema.tables[this.prototype.relationshipBelongsTo[property]], relationProperty, ) && @@ -179,7 +179,7 @@ module.exports = function (Bookshelf) { unfilteredOptions = unfilteredOptions || {}; filterConfig = filterConfig || {}; - if (Object.prototype.hasOwnProperty.call(unfilteredOptions, 'include')) { + if (Object.hasOwn(unfilteredOptions, 'include')) { throw new errors.IncorrectUsageError({ message: 'The model layer expects using `withRelated`.', }); diff --git a/ghost/core/core/server/models/settings.js b/ghost/core/core/server/models/settings.js index af3aaca9f43..413edae1b14 100644 --- a/ghost/core/core/server/models/settings.js +++ b/ghost/core/core/server/models/settings.js @@ -312,15 +312,11 @@ const Settings = ghostBookshelf.Model.extend( return setting.save(item, options); } else { // If we have a value, set it. - if (Object.prototype.hasOwnProperty.call(item, 'value')) { + if (Object.hasOwn(item, 'value')) { setting.set('value', item.value); } // Internal context can overwrite type (for fixture migrations) - if ( - options.context && - options.context.internal && - Object.prototype.hasOwnProperty.call(item, 'type') - ) { + if (options.context && options.context.internal && Object.hasOwn(item, 'type')) { setting.set('type', item.type); } diff --git a/ghost/core/core/server/services/comments/comments-controller.js b/ghost/core/core/server/services/comments/comments-controller.js index b1c22ee22c5..8d9acfbf7ca 100644 --- a/ghost/core/core/server/services/comments/comments-controller.js +++ b/ghost/core/core/server/services/comments/comments-controller.js @@ -190,10 +190,10 @@ module.exports = class CommentsController { async adminEdit(frame) { const data = frame.data.comments[0]; const updates = {}; - if (Object.prototype.hasOwnProperty.call(data, 'status')) { + if (Object.hasOwn(data, 'status')) { updates.status = data.status; } - if (Object.prototype.hasOwnProperty.call(data, 'pinned')) { + if (Object.hasOwn(data, 'pinned')) { updates.pinned = data.pinned; } diff --git a/ghost/core/core/server/services/comments/comments-service.js b/ghost/core/core/server/services/comments/comments-service.js index e28e47814c0..bff8b00e554 100644 --- a/ghost/core/core/server/services/comments/comments-service.js +++ b/ghost/core/core/server/services/comments/comments-service.js @@ -627,7 +627,7 @@ class CommentsService { async moderateComment(id, data, options) { const editData = {}; - if (Object.prototype.hasOwnProperty.call(data, 'status')) { + if (Object.hasOwn(data, 'status')) { editData.status = data.status; if (data.status === COMMENT_STATUS_DELETED) { @@ -635,7 +635,7 @@ class CommentsService { } } - if (Object.prototype.hasOwnProperty.call(data, 'pinned')) { + if (Object.hasOwn(data, 'pinned')) { if (typeof data.pinned !== 'boolean') { throw new errors.BadRequestError({ message: tpl(messages.invalidPinnedValue), diff --git a/ghost/core/core/server/services/member-welcome-emails/service.js b/ghost/core/core/server/services/member-welcome-emails/service.js index 38665416ef4..95bb4241cc7 100644 --- a/ghost/core/core/server/services/member-welcome-emails/service.js +++ b/ghost/core/core/server/services/member-welcome-emails/service.js @@ -261,7 +261,7 @@ class MemberWelcomeEmailService { const normalized = {}; for (const field of SHARED_SENDER_FIELDS) { - if (!Object.prototype.hasOwnProperty.call(attrs, field)) { + if (!Object.hasOwn(attrs, field)) { continue; } diff --git a/ghost/core/core/server/services/members/members-api/repositories/member-repository.js b/ghost/core/core/server/services/members/members-api/repositories/member-repository.js index f203ca616c5..3538654b608 100644 --- a/ghost/core/core/server/services/members/members-api/repositories/member-repository.js +++ b/ghost/core/core/server/services/members/members-api/repositories/member-repository.js @@ -1111,8 +1111,7 @@ module.exports = class MemberRepository { const memberIds = memberRows.map((row) => row.id); if (data.action === 'unsubscribe') { - const hasNewsletterSelected = - Object.prototype.hasOwnProperty.call(data, 'newsletter') && data.newsletter !== null; + const hasNewsletterSelected = Object.hasOwn(data, 'newsletter') && data.newsletter !== null; if (hasNewsletterSelected) { const membersArr = memberIds.map((i) => `'${i}'`).join(','); const unsubscribeRows = await this._MemberNewsletter.getFilteredCollectionQuery({ diff --git a/ghost/core/core/server/services/public-config/config.js b/ghost/core/core/server/services/public-config/config.js index 1fc01c2730c..144ce727909 100644 --- a/ghost/core/core/server/services/public-config/config.js +++ b/ghost/core/core/server/services/public-config/config.js @@ -19,7 +19,7 @@ const sanitizeHostSettings = (hostSettings) => { const copyPayloadProperties = (target, source, properties) => { for (const property of properties) { - if (Object.prototype.hasOwnProperty.call(source, property)) { + if (Object.hasOwn(source, property)) { target[property] = source[property]; } } diff --git a/ghost/core/core/server/services/recommendations/service/recommendation.ts b/ghost/core/core/server/services/recommendations/service/recommendation.ts index 96f08104751..3d8420ed0d4 100644 --- a/ghost/core/core/server/services/recommendations/service/recommendation.ts +++ b/ghost/core/core/server/services/recommendations/service/recommendation.ts @@ -179,7 +179,7 @@ export class Recommendation { for (const key of Object.keys(properties) as (keyof EditRecommendation)[]) { if ( - Object.prototype.hasOwnProperty.call(properties, key) && + Object.hasOwn(properties, key) && properties[key] !== undefined && properties[key] !== newProperties[key] ) { diff --git a/ghost/core/core/server/services/recommendations/service/unsafe-data.ts b/ghost/core/core/server/services/recommendations/service/unsafe-data.ts index ed1deb35a21..d75fb713566 100644 --- a/ghost/core/core/server/services/recommendations/service/unsafe-data.ts +++ b/ghost/core/core/server/services/recommendations/service/unsafe-data.ts @@ -56,7 +56,7 @@ export class UnsafeData { throw new errors.ValidationError({ message: `${this.field} must be an object` }); } - if (!Object.prototype.hasOwnProperty.call(this.data, key)) { + if (!Object.hasOwn(this.data, key)) { return undefined; } @@ -70,7 +70,7 @@ export class UnsafeData { throw new errors.ValidationError({ message: `${this.field} must be an object` }); } - if (!Object.prototype.hasOwnProperty.call(this.data, key)) { + if (!Object.hasOwn(this.data, key)) { throw new errors.ValidationError({ message: `${this.fieldWithKey(key)} is required` }); } diff --git a/ghost/core/core/server/services/route-settings/validation-errors.ts b/ghost/core/core/server/services/route-settings/validation-errors.ts index d09e5717e2d..d4f2b407f1e 100644 --- a/ghost/core/core/server/services/route-settings/validation-errors.ts +++ b/ghost/core/core/server/services/route-settings/validation-errors.ts @@ -148,9 +148,7 @@ const LIST_ENTRY_PROBLEMS: Record = { }; function lookup(table: Record, key: PathSegment | undefined): string | undefined { - return typeof key === 'string' && Object.prototype.hasOwnProperty.call(table, key) - ? table[key] - : undefined; + return typeof key === 'string' && Object.hasOwn(table, key) ? table[key] : undefined; } /** @@ -187,7 +185,7 @@ function describeExpectation( if ( path.length === 2 && typeof section === 'string' && - Object.prototype.hasOwnProperty.call(CONTAINER_EXPECTATIONS, section) + Object.hasOwn(CONTAINER_EXPECTATIONS, section) ) { return CONTAINER_EXPECTATIONS[section]; } @@ -233,9 +231,7 @@ function codeForPath(path: readonly PathSegment[]): RouteSettingsErrorCode | und const key = fieldKey(path); - return typeof key === 'string' && Object.prototype.hasOwnProperty.call(FIELD_CODES, key) - ? FIELD_CODES[key] - : undefined; + return typeof key === 'string' && Object.hasOwn(FIELD_CODES, key) ? FIELD_CODES[key] : undefined; } function valueAtPath(value: unknown, path: readonly PathSegment[]): unknown { diff --git a/ghost/core/core/server/services/slack-ping/slack-ping-service.ts b/ghost/core/core/server/services/slack-ping/slack-ping-service.ts index 8ab92b646f5..f3f72eb0f5d 100644 --- a/ghost/core/core/server/services/slack-ping/slack-ping-service.ts +++ b/ghost/core/core/server/services/slack-ping/slack-ping-service.ts @@ -78,11 +78,7 @@ export type SlackPingServiceDeps = { * @TODO: change this function to check for the properties we depend on */ function hasPostProperties(data: Record): boolean { - return ( - Object.prototype.hasOwnProperty.call(data, 'html') && - Object.prototype.hasOwnProperty.call(data, 'title') && - Object.prototype.hasOwnProperty.call(data, 'slug') - ); + return Object.hasOwn(data, 'html') && Object.hasOwn(data, 'title') && Object.hasOwn(data, 'slug'); } export class SlackPingService { diff --git a/ghost/core/core/server/services/stripe/services/webhook/checkout-session-event-service.js b/ghost/core/core/server/services/stripe/services/webhook/checkout-session-event-service.js index b00c0efc86a..89de23e4008 100644 --- a/ghost/core/core/server/services/stripe/services/webhook/checkout-session-event-service.js +++ b/ghost/core/core/server/services/stripe/services/webhook/checkout-session-event-service.js @@ -13,7 +13,7 @@ function isStripeMetadataTrue(value) { } function hasStripeMetadataKey(metadata, key) { - return Object.prototype.hasOwnProperty.call(metadata || {}, key); + return Object.hasOwn(metadata || {}, key); } function isGiftCheckoutSession(session) { diff --git a/ghost/core/core/server/services/update-check/update-check-service.js b/ghost/core/core/server/services/update-check/update-check-service.js index a42c71e8e4e..c380c038051 100644 --- a/ghost/core/core/server/services/update-check/update-check-service.js +++ b/ghost/core/core/server/services/update-check/update-check-service.js @@ -305,9 +305,7 @@ class UpdateCheckService { status: message.status || 'alert', type: message.type || 'info', id: message.id, - dismissible: Object.prototype.hasOwnProperty.call(message, 'dismissible') - ? message.dismissible - : true, + dismissible: Object.hasOwn(message, 'dismissible') ? message.dismissible : true, top: !!message.top, message: message.content, }; diff --git a/ghost/core/test/e2e-api/members-comments/comments.test.js b/ghost/core/test/e2e-api/members-comments/comments.test.js index 2fe7903ac24..808bf2287ad 100644 --- a/ghost/core/test/e2e-api/members-comments/comments.test.js +++ b/ghost/core/test/e2e-api/members-comments/comments.test.js @@ -1703,10 +1703,7 @@ describe('Comments API', function () { await testGetComments(`/api/comments/${comment.get('id')}/`, [commentMatcher]).expect( ({ body }) => { assert.equal(body.comments[0].disliked, true); - assert.equal( - Object.prototype.hasOwnProperty.call(body.comments[0].count, 'dislikes'), - false, - ); + assert.equal(Object.hasOwn(body.comments[0].count, 'dislikes'), false); }, ); }); @@ -1731,10 +1728,7 @@ describe('Comments API', function () { await testGetComments(`/api/comments/${comment.get('id')}/`, [commentMatcher]).expect( ({ body }) => { assert.equal(body.comments[0].disliked, false); - assert.equal( - Object.prototype.hasOwnProperty.call(body.comments[0].count, 'dislikes'), - false, - ); + assert.equal(Object.hasOwn(body.comments[0].count, 'dislikes'), false); }, ); }); @@ -1780,14 +1774,8 @@ describe('Comments API', function () { body.comments.map((comment) => comment.id), [bestComment.get('id'), likedComment.get('id'), dislikedComment.get('id')], ); - assert.equal( - Object.prototype.hasOwnProperty.call(body.comments[0].count, 'dislikes'), - false, - ); - assert.equal( - Object.prototype.hasOwnProperty.call(body.comments[0].count, 'net_score'), - false, - ); + assert.equal(Object.hasOwn(body.comments[0].count, 'dislikes'), false); + assert.equal(Object.hasOwn(body.comments[0].count, 'net_score'), false); }); it('Cannot like a comment multiple times', async function () { diff --git a/ghost/core/test/unit/api/canary/utils/serializers/output/utils/extra-attrs.test.js b/ghost/core/test/unit/api/canary/utils/serializers/output/utils/extra-attrs.test.js index 09c3cfc8728..3fdcafbfcd1 100644 --- a/ghost/core/test/unit/api/canary/utils/serializers/output/utils/extra-attrs.test.js +++ b/ghost/core/test/unit/api/canary/utils/serializers/output/utils/extra-attrs.test.js @@ -36,7 +36,7 @@ describe('Unit: endpoints/utils/serializers/output/utils/extra-attrs', function const attrs = {}; extraAttrsUtil.forPost(options, model, attrs); sinon.assert.called(modelGetStub); - assert.equal(Object.prototype.hasOwnProperty.call(attrs, 'excerpt'), true); + assert.equal(Object.hasOwn(attrs, 'excerpt'), true); assert.equal(attrs.excerpt, null); }); @@ -50,7 +50,7 @@ describe('Unit: endpoints/utils/serializers/output/utils/extra-attrs', function attrs, ); sinon.assert.called(modelGetStub); - assert.equal(Object.prototype.hasOwnProperty.call(attrs, 'plaintext'), true); + assert.equal(Object.hasOwn(attrs, 'plaintext'), true); }); it('has plaintext when formats includes plaintext', function () { @@ -63,14 +63,14 @@ describe('Unit: endpoints/utils/serializers/output/utils/extra-attrs', function attrs, ); sinon.assert.called(modelGetStub); - assert.equal(Object.prototype.hasOwnProperty.call(attrs, 'plaintext'), true); + assert.equal(Object.hasOwn(attrs, 'plaintext'), true); }); it('has excerpt when no columns are passed', function () { const attrs = {}; extraAttrsUtil.forPost({}, model, attrs); sinon.assert.called(modelGetStub); - assert.equal(Object.prototype.hasOwnProperty.call(attrs, 'excerpt'), true); + assert.equal(Object.hasOwn(attrs, 'excerpt'), true); }); it('has reading_time when no columns are passed', function () { @@ -78,7 +78,7 @@ describe('Unit: endpoints/utils/serializers/output/utils/extra-attrs', function html: 'html', }; extraAttrsUtil.forPost({}, model, attrs); - assert.equal(Object.prototype.hasOwnProperty.call(attrs, 'reading_time'), true); + assert.equal(Object.hasOwn(attrs, 'reading_time'), true); }); it('has reading_time when columns includes reading_time', function () { @@ -92,7 +92,7 @@ describe('Unit: endpoints/utils/serializers/output/utils/extra-attrs', function model, attrs, ); - assert.equal(Object.prototype.hasOwnProperty.call(attrs, 'reading_time'), true); + assert.equal(Object.hasOwn(attrs, 'reading_time'), true); }); it('does not leak null reading_time from the database when html is absent', function () { @@ -100,7 +100,7 @@ describe('Unit: endpoints/utils/serializers/output/utils/extra-attrs', function reading_time: null, }; extraAttrsUtil.forPost({}, model, attrs); - assert.equal(Object.prototype.hasOwnProperty.call(attrs, 'reading_time'), false); + assert.equal(Object.hasOwn(attrs, 'reading_time'), false); }); }); }); diff --git a/ghost/core/test/unit/server/services/route-settings/route-settings-parser.test.ts b/ghost/core/test/unit/server/services/route-settings/route-settings-parser.test.ts index c98d3007240..cf44781c140 100644 --- a/ghost/core/test/unit/server/services/route-settings/route-settings-parser.test.ts +++ b/ghost/core/test/unit/server/services/route-settings/route-settings-parser.test.ts @@ -104,7 +104,7 @@ describe('services/route-settings/route-settings-parser', function () { routes: { '/featured/': { controller: 'channel', filter: 'featured:true' } }, }); - assert.equal(Object.prototype.hasOwnProperty.call(result.routes[0], 'rss'), false); + assert.equal(Object.hasOwn(result.routes[0], 'rss'), false); }); it('respects explicit rss: true on channel route', function () { diff --git a/ghost/core/test/utils/config-utils.js b/ghost/core/test/utils/config-utils.js index 5f5654d56e8..c7f14d24c54 100644 --- a/ghost/core/test/utils/config-utils.js +++ b/ghost/core/test/utils/config-utils.js @@ -22,7 +22,7 @@ configUtils.set = function () { _.each(key, function (settingValue, settingKey) { config.set(settingKey, settingValue); }); - if (Object.prototype.hasOwnProperty.call(key, 'paths:contentPath')) { + if (Object.hasOwn(key, 'paths:contentPath')) { clearDerivedContentPaths(); } } else { diff --git a/ghost/core/test/utils/e2e-framework-mock-manager.js b/ghost/core/test/utils/e2e-framework-mock-manager.js index e3c59533b15..8a3e91f9a6c 100644 --- a/ghost/core/test/utils/e2e-framework-mock-manager.js +++ b/ghost/core/test/utils/e2e-framework-mock-manager.js @@ -492,9 +492,7 @@ const mockLimitService = (limit, options) => { if (!mocks.limitService.originalEntries.has(limit)) { mocks.limitService.originalEntries.set( limit, - Object.prototype.hasOwnProperty.call(limitService.limits, limit) - ? limitService.limits[limit] - : undefined, + Object.hasOwn(limitService.limits, limit) ? limitService.limits[limit] : undefined, ); } limitService.limits[limit] = {