diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bc4f50990..f715eb33e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -46,6 +46,9 @@ jobs: - name: Run tsc run: npx tsc --noEmit + - name: Lint l10n strings + run: npm run l10n:lint + test: runs-on: ubuntu-latest defaults: diff --git a/README.md b/README.md index 9c9756f12..1555cb43b 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,10 @@ MDN's next fr(ont)e(n)d. - `npm run preview` - runs the preview server: using the production bundles with the rari server: useful for testing our prod rspack config +## L10n + +See [the l10n README](./l10n/README.md). + ### Accessing from non-localhost If you want to access fred from a different machine, you'll need to run with certain options: diff --git a/components/a11y-menu/server.js b/components/a11y-menu/server.js index 18104c998..3a1b77c6e 100644 --- a/components/a11y-menu/server.js +++ b/components/a11y-menu/server.js @@ -8,8 +8,18 @@ export class A11yMenu extends ServerComponent { */ render(context) { return html``; } } diff --git a/components/article-footer/server.js b/components/article-footer/server.js index 827e009ea..7bbf21f49 100644 --- a/components/article-footer/server.js +++ b/components/article-footer/server.js @@ -38,7 +38,9 @@ function Contribute(context) { return html`${context.l10n`Learn how to contribute`}${context.l10n( + "article-footer-learn-how-to-contribute", + )`Learn how to contribute`}`; } @@ -112,7 +114,9 @@ function GitHubSourceLink(context) { rel="noopener" >${locale === "de" ? "Übersetzung auf GitHub anzeigen" - : context.l10n`View this page on GitHub`}`; } @@ -146,12 +150,16 @@ function GitHubIssueLink(context) { return html`${locale === "de" ? "Fehler mit dieser Übersetzung melden" - : context.l10n`Report a problem with this content`}`; } diff --git a/components/baseline-indicator/server.js b/components/baseline-indicator/server.js index 9d9c8897d..428d97c31 100644 --- a/components/baseline-indicator/server.js +++ b/components/baseline-indicator/server.js @@ -137,26 +137,34 @@ export class BaselineIndicator extends ServerComponent { class="indicator" role="img" aria-label=${level === "not" - ? context.l10n`Baseline Cross` - : context.l10n`Baseline Check`} + ? context.l10n("baseline-indicator-baseline-cross")`Baseline Cross` + : context.l10n("baseline-indicator-baseline-check")`Baseline Check`} >
${level === "not" ? html`${context.l10n`Limited availability`}${context.l10n( + "baseline-indicator-limited-availability", + )`Limited availability`}` : html` - ${context.l10n`Baseline`} + ${context.l10n("baseline-indicator-baseline")`Baseline`} ${level === "high" - ? context.l10n`Widely available` + ? context.l10n( + "baseline-indicator-widely-available", + )`Widely available` : low_date?.getFullYear()} ${status.asterisk && " *"} `}
${level === "low" - ? html`
${context.l10n`Newly available`}
` + ? html`
+ ${context.l10n( + "baseline-indicator-newly-available", + )`Newly available`} +
` : nothing}
${ENGINES.map( @@ -174,7 +182,7 @@ export class BaselineIndicator extends ServerComponent { isBrowserSupported(browser) ? "supported" : "" }`} role="img" - aria-label=${`${browser.name} ${isBrowserSupported(browser) ? context.l10n`check` : context.l10n`cross`}`} + aria-label=${`${browser.name} ${isBrowserSupported(browser) ? context.l10n("baseline-indicator-check")`check` : context.l10n("baseline-indicator-cross")`cross`}`} >`, )} `, @@ -219,12 +227,14 @@ export class BaselineIndicator extends ServerComponent { target="_blank" class="learn-more" > - ${context.l10n`Learn more`} + ${context.l10n("baseline-indicator-learn-more")`Learn more`}
  • - ${context.l10n`See full compatibility`} + ${context.l10n( + "baseline-indicator-see-full-compatibility", + )`See full compatibility`}
  • @@ -235,7 +245,9 @@ export class BaselineIndicator extends ServerComponent { target="_blank" rel="noreferrer" > - ${context.l10n`Report feedback`} + ${context.l10n( + "baseline-indicator-report-feedback", + )`Report feedback`}
  • diff --git a/components/blog-index/server.js b/components/blog-index/server.js index fb1cecb99..c1c9a83ab 100644 --- a/components/blog-index/server.js +++ b/components/blog-index/server.js @@ -84,7 +84,9 @@ export class BlogIndex extends ServerComponent { html`
    -

    ${context.l10n`Blog it better`}

    +

    + ${context.l10n("blog-index-blog-it-better")`Blog it better`} +

    diff --git a/components/collection-save-button/element.js b/components/collection-save-button/element.js index c8a3cc91f..62ca56d43 100644 --- a/components/collection-save-button/element.js +++ b/components/collection-save-button/element.js @@ -199,16 +199,22 @@ export class MDNCollectionSaveButton extends L10nMixin(LitElement) { - + ${this._bookmarks.render({ initial: () => html``, pending: () => html``, @@ -218,7 +224,9 @@ export class MDNCollectionSaveButton extends L10nMixin(LitElement) { pending: () => html``, complete: (collections) => html` ${this._pending && this._lastAction === "save" - ? this.l10n`Saving…` - : this.l10n`Save`} + ? this.l10n( + "collection-save-button-saving", + )`Saving…` + : this.l10n("collection-save-button-save")`Save`} - ${this.l10n`Cancel`} + ${this.l10n("collection-save-button-cancel")`Cancel`} ${bookmarks?.length ? html` ${this._pending && this._lastAction === "delete" - ? this.l10n`Deleting…` - : this.l10n`Delete`} + ? this.l10n( + "collection-save-button-deleting", + )`Deleting…` + : this.l10n( + "collection-save-button-delete", + )`Delete`} ` : nothing} `, diff --git a/components/color-theme/element.js b/components/color-theme/element.js index 639017a99..40c517675 100644 --- a/components/color-theme/element.js +++ b/components/color-theme/element.js @@ -19,8 +19,8 @@ export class MDNColorTheme extends L10nMixin(LitElement) { this._mode = "light dark"; this._options = Object.entries({ "light dark": this.l10n("theme-default")`OS default`, - light: this.l10n`Light`, - dark: this.l10n`Dark`, + light: this.l10n("color-theme-light")`Light`, + dark: this.l10n("color-theme-dark")`Dark`, }); } @@ -68,9 +68,11 @@ export class MDNColorTheme extends L10nMixin(LitElement) { class="color-theme__button" data-mode=${this._mode} type="button" - aria-label=${this.l10n`Switch color theme`} + aria-label=${this.l10n( + "color-theme-switch-color-theme", + )`Switch color theme`} > - ${this.l10n`Theme`} + ${this.l10n("color-theme-theme")`Theme`}
    - ${this.l10n`Yes`} + ${this.l10n("content-feedback-yes")`Yes`} - ${this.l10n`No`} + ${this.l10n("content-feedback-no")`No`}
    `; } @@ -166,7 +172,7 @@ export class MDNContentFeedback extends L10nMixin(LitElement) { )}
    - ${this.l10n`Submit`} + ${this.l10n("content-feedback-submit")`Submit`}
    `; } diff --git a/components/contributor-spotlight/server.js b/components/contributor-spotlight/server.js index d4a15a9c6..f6cc0be63 100644 --- a/components/contributor-spotlight/server.js +++ b/components/contributor-spotlight/server.js @@ -34,12 +34,20 @@ export class ContributorSpotlight extends ServerComponent { const renderGetInvolved = () => html`
    -

    ${context.l10n`Want to be part of the journey?`}

    +

    + ${context.l10n( + "contributor-spotlight-want-to-be-part-of-the-journey", + )`Want to be part of the journey?`} +

    - ${context.l10n`Our constant quest for innovation starts here, with you. Every part of MDN (docs, demos and the site itself) springs from our incredible open community of developers. Please join us!`} + ${context.l10n( + "contributor-spotlight-our-constant-quest-for-innovatio", + )`Our constant quest for innovation starts here, with you. Every part of MDN (docs, demos and the site itself) springs from our incredible open community of developers. Please join us!`}

    ${Button.render(context, { - label: context.l10n`Get involved`, + label: context.l10n( + "contributor-spotlight-get-involved", + )`Get involved`, href: `/${context.locale}/community/`, icon: arrowRightIcon, iconPosition: "after", @@ -51,7 +59,11 @@ export class ContributorSpotlight extends ServerComponent { `; const header = html` -

    ${context.l10n`Contributor profile`}

    +

    + ${context.l10n( + "contributor-spotlight-contributor-profile", + )`Contributor profile`} +

    `; const baseUrl = context.url; diff --git a/components/copy-button/element.js b/components/copy-button/element.js index d70b36d66..9eee5a9cc 100644 --- a/components/copy-button/element.js +++ b/components/copy-button/element.js @@ -39,8 +39,8 @@ export class MDNCopyButton extends L10nMixin(LitElement) { } this._message = this._copiedSuccessfully - ? this.l10n`Copied` - : this.l10n`Copy failed!`; + ? this.l10n("copy-button-copied")`Copied` + : this.l10n("copy-button-copy-failed")`Copy failed!`; setTimeout( () => { @@ -57,7 +57,7 @@ export class MDNCopyButton extends L10nMixin(LitElement) { @click=${this._copy} .icon=${this._copiedSuccessfully ? check : undefined} variant=${this.variant} - >${this._message ?? this.l10n`Copy`}${this._message ?? this.l10n("copy-button-copy")`Copy`}`; } } diff --git a/components/footer/server.js b/components/footer/server.js index b88fb866a..99abdc279 100644 --- a/components/footer/server.js +++ b/components/footer/server.js @@ -13,27 +13,27 @@ const socials = (context) => [ { icon: "github", href: "https://github.com/mdn/", - ariaLabel: context.l10n`MDN on GitHub`, + ariaLabel: context.l10n("footer-mdn-on-github")`MDN on GitHub`, }, { icon: "bluesky", href: "https://bsky.app/profile/developer.mozilla.org", - ariaLabel: context.l10n`MDN on Bluesky`, + ariaLabel: context.l10n("footer-mdn-on-bluesky")`MDN on Bluesky`, }, { icon: "x", href: "https://x.com/mozdevnet", - ariaLabel: context.l10n`MDN on X`, + ariaLabel: context.l10n("footer-mdn-on-x")`MDN on X`, }, { icon: "mastodon", href: "https://mastodon.social/@mdn", - ariaLabel: context.l10n`MDN on Mastodon`, + ariaLabel: context.l10n("footer-mdn-on-mastodon")`MDN on Mastodon`, }, { icon: "rss", href: "/en-US/blog/rss.xml", - ariaLabel: context.l10n`MDN blog RSS feed`, + ariaLabel: context.l10n("footer-mdn-blog-rss-feed")`MDN blog RSS feed`, }, ]; @@ -43,75 +43,81 @@ const socials = (context) => [ */ const links = (context) => [ { - title: context.l10n`MDN`, + title: context.l10n("footer-mdn")`MDN`, links: [ - { text: context.l10n`About`, href: "/en-US/about" }, - { text: context.l10n`Blog`, href: "/en-US/blog/" }, + { text: context.l10n("footer-about")`About`, href: "/en-US/about" }, + { text: context.l10n("footer-blog")`Blog`, href: "/en-US/blog/" }, { - text: context.l10n`Mozilla careers`, + text: context.l10n("footer-mozilla-careers")`Mozilla careers`, href: "https://www.mozilla.org/en-US/careers/listings/", external: true, }, { - text: context.l10n`Advertise with us`, + text: context.l10n("footer-advertise-with-us")`Advertise with us`, href: "/en-US/advertising", }, - { text: context.l10n`MDN Plus`, href: "/en-US/plus" }, + { text: context.l10n("footer-mdn-plus")`MDN Plus`, href: "/en-US/plus" }, { - text: context.l10n`Product help`, + text: context.l10n("footer-product-help")`Product help`, href: "https://support.mozilla.org/products/mdn-plus", external: true, }, ], }, { - title: context.l10n`Contribute`, + title: context.l10n("footer-contribute")`Contribute`, links: [ { - text: context.l10n`MDN Community`, + text: context.l10n("footer-mdn-community")`MDN Community`, href: "/en-US/community", }, { - text: context.l10n`Community resources`, + text: context.l10n("footer-community-resources")`Community resources`, href: "/en-US/docs/MDN/Community", }, { - text: context.l10n`Writing guidelines`, + text: context.l10n("footer-writing-guidelines")`Writing guidelines`, href: "/en-US/docs/MDN/Writing_guidelines", }, - { text: context.l10n`MDN Discord`, href: "/discord", external: true }, { - text: context.l10n`MDN on GitHub`, + text: context.l10n("footer-mdn-discord")`MDN Discord`, + href: "/discord", + external: true, + }, + { + text: context.l10n("footer-mdn-on-github")`MDN on GitHub`, href: "https://github.com/mdn", external: true, }, ], }, { - title: context.l10n`Developers`, + title: context.l10n("footer-developers")`Developers`, links: [ { - text: context.l10n`Web technologies`, + text: context.l10n("footer-web-technologies")`Web technologies`, href: "/en-US/docs/Web", }, { - text: context.l10n`Learn web development`, + text: context.l10n( + "footer-learn-web-development", + )`Learn web development`, href: "/en-US/docs/Learn_web_development", }, { - text: context.l10n`Guides`, + text: context.l10n("footer-guides")`Guides`, href: "/en-US/docs/MDN/Guides", }, { - text: context.l10n`Tutorials`, + text: context.l10n("footer-tutorials")`Tutorials`, href: "/en-US/docs/MDN/Tutorials", }, { - text: context.l10n`Glossary`, + text: context.l10n("footer-glossary")`Glossary`, href: "/en-US/docs/Glossary", }, { - text: context.l10n`Hacks blog`, + text: context.l10n("footer-hacks-blog")`Hacks blog`, href: "https://hacks.mozilla.org/", external: true, }, @@ -124,22 +130,24 @@ const links = (context) => [ */ const mozillaLinks = (context) => [ { - text: context.l10n`Website Privacy Notice`, + text: context.l10n("footer-website-privacy-notice")`Website Privacy Notice`, href: "https://www.mozilla.org/privacy/websites/", external: true, }, { - text: context.l10n`Telemetry Settings`, + text: context.l10n("footer-telemetry-settings")`Telemetry Settings`, href: "https://www.mozilla.org/en-US/privacy/websites/data-preferences/", external: true, }, { - text: context.l10n`Legal`, + text: context.l10n("footer-legal")`Legal`, href: "https://www.mozilla.org/about/legal/terms/mozilla", external: true, }, { - text: context.l10n`Community Participation Guidelines`, + text: context.l10n( + "footer-community-participation-guidelin", + )`Community Participation Guidelines`, href: "https://www.mozilla.org/about/governance/policies/participation/", external: true, }, @@ -158,7 +166,7 @@ export class Footer extends ServerComponent {

    @@ -215,7 +223,7 @@ export class Footer extends ServerComponent {

      diff --git a/components/homepage-body/server.js b/components/homepage-body/server.js index dd6b2899b..07c52951c 100644 --- a/components/homepage-body/server.js +++ b/components/homepage-body/server.js @@ -18,15 +18,21 @@ export class HomepageBody extends ServerComponent { ? html`` : nothing}
      -

      ${context.l10n`Featured articles`}

      +

      + ${context.l10n("homepage-body-featured-articles")`Featured articles`} +

      ${FeaturedArticles.render(context.hyData.featuredArticles)}
      -

      ${context.l10n`Latest news`}

      +

      ${context.l10n("homepage-body-latest-news")`Latest news`}

      ${LatestNews.render(context.hyData.latestNews.items, context.locale)}
      -

      ${context.l10n`Recent contributions`}

      +

      + ${context.l10n( + "homepage-body-recent-contributions", + )`Recent contributions`} +

      ${RecentContributions.render( context.hyData.recentContributions.items, context.locale, diff --git a/components/homepage-contributor-spotlight/server.js b/components/homepage-contributor-spotlight/server.js index 05f9c117c..36f5aac3d 100644 --- a/components/homepage-contributor-spotlight/server.js +++ b/components/homepage-contributor-spotlight/server.js @@ -20,7 +20,11 @@ export class HomepageContributorSpotlight extends ServerComponent { return html`
      -

      ${context.l10n`Contributor Spotlight`}

      +

      + ${context.l10n( + "homepage-contributor-spotlight-contributor-spotlight", + )`Contributor Spotlight`} +

      ${contributor.contributorName} @@ -29,7 +33,9 @@ export class HomepageContributorSpotlight extends ServerComponent { ${Button({ - label: context.l10n`Get involved`, + label: context.l10n( + "homepage-contributor-spotlight-get-involved", + )`Get involved`, href: `/${context.locale}/community`, icon: arrowRightIcon, iconPosition: "after", diff --git a/components/homepage-search/element.js b/components/homepage-search/element.js index e64e16df8..0c313725a 100644 --- a/components/homepage-search/element.js +++ b/components/homepage-search/element.js @@ -20,11 +20,11 @@ export class MDNHomepageSearch extends L10nMixin(LitElement) { render() { return html``; } } diff --git a/components/interactive-example/with-choices.js b/components/interactive-example/with-choices.js index 50bc6d472..51ff10b56 100644 --- a/components/interactive-example/with-choices.js +++ b/components/interactive-example/with-choices.js @@ -122,7 +122,7 @@ export const InteractiveExampleWithChoices = (Base) => @click=${this._reset} variant="secondary" .disabled=${!this.__choiceUpdated} - >${this.l10n`Reset`}${this.l10n("interactive-example-reset")`Reset`}
        @click=${this.#choiceFocus} @focus=${this.#choiceSelect} @update=${this.#choiceUpdate} - aria-label=${this.l10n`Value select`} + aria-label=${this.l10n( + "interactive-example-value-select", + )`Value select`} > ${this._choices?.map( (code, index) => html` @@ -149,8 +151,9 @@ export const InteractiveExampleWithChoices = (Base) => .value=${code?.trim()} aria-label=${ifDefined( this.__choiceUnsupported[index] - ? this - .l10n`The current value is not supported by your browser.` + ? this.l10n( + "interactive-example-the-current-value-is-not-support", + )`The current value is not supported by your browser.` : undefined, )} > diff --git a/components/interactive-example/with-console.js b/components/interactive-example/with-console.js index c22a7d032..9e658fa4e 100644 --- a/components/interactive-example/with-console.js +++ b/components/interactive-example/with-console.js @@ -54,20 +54,26 @@ export const InteractiveExampleWithConsole = (Base) => id="execute" @click=${this._run} variant="secondary" - title=${this.l10n`Run example, and show console output`} - >${this.l10n`Run`}${this.l10n("interactive-example-run")`Run`} ${this.l10n`Reset`}${this.l10n("interactive-example-reset")`Reset`}

      ${decode(this.name)}

      ${this.l10n`Reset`}${this.l10n("interactive-example-reset")`Reset`}
      @@ -48,7 +48,7 @@ export const InteractiveExampleWithTabs = (Base) => )}
      -

      ${this.l10n`Output`}

      +

      ${this.l10n("interactive-example-output")`Output`}

      - ${this.l10n`Title`} - ${this.l10n`Repository`} + ${this.l10n("issues-table-title")`Title`} + ${this.l10n("issues-table-repository")`Repository`} diff --git a/components/language-switcher/element.js b/components/language-switcher/element.js index 646c2ef83..6058a8872 100644 --- a/components/language-switcher/element.js +++ b/components/language-switcher/element.js @@ -99,7 +99,9 @@ export class MDNLanguageSwitcher extends L10nMixin(LitElement) { ${this.l10n`Remember language`}${this.l10n( + "language-switcher-remember-language", + )`Remember language`} ${this.l10n`Learn more`}${this.l10n( + "language-switcher-learn-more", + )`Learn more`}
        diff --git a/components/login-button/element.js b/components/login-button/element.js index c292d5fcf..7641c9c49 100644 --- a/components/login-button/element.js +++ b/components/login-button/element.js @@ -17,7 +17,7 @@ export class MDNLoginButton extends L10nMixin(LitElement) { render() { return html`${this.l10n`Login`}${this.l10n("login-button-login")`Login`}`; } } diff --git a/components/modal/element.js b/components/modal/element.js index bfc44ce60..d63cb83ba 100644 --- a/components/modal/element.js +++ b/components/modal/element.js @@ -36,7 +36,7 @@ export class MDNModal extends L10nMixin(LitElement) { icon-only .icon=${exitIcon} @click=${this.close} - >${this.l10n`Exit modal`}${this.l10n("modal-exit-modal")`Exit modal`} diff --git a/components/navigation/server.js b/components/navigation/server.js index 80938dc77..8191c5b8b 100644 --- a/components/navigation/server.js +++ b/components/navigation/server.js @@ -29,7 +29,9 @@ export class Navigation extends ServerComponent { type="button" aria-expanded="false" aria-controls="navigation__popup" - aria-label=${context.l10n`Toggle navigation`} + aria-label=${context.l10n( + "navigation-toggle-navigation", + )`Toggle navigation`} >
      diff --git a/components/observatory-tests-and-scores/element.js b/components/observatory-tests-and-scores/element.js index d87bf7baa..697a16c95 100644 --- a/components/observatory-tests-and-scores/element.js +++ b/components/observatory-tests-and-scores/element.js @@ -42,7 +42,9 @@ export class MDNObservatoryTestsAndScores extends L10nMixin(LitElement) { return this._fetchMatrixTask.render({ pending: () => html`
      - ${this.l10n`Loading tests and scoring data...`} + ${this.l10n( + "observatory-tests-and-scores-loading-tests-and-scoring-data", + )`Loading tests and scoring data...`}
      `, complete: (data) => data.map( @@ -50,17 +52,31 @@ export class MDNObservatoryTestsAndScores extends L10nMixin(LitElement) {

      ${entry.title}

      - ${this.l10n`See`} + ${this.l10n("observatory-tests-and-scores-see")`See`} ${entry.title} - ${this.l10n`for guidance.`} + ${this.l10n( + "observatory-tests-and-scores-for-guidance", + )`for guidance.`}

      - - - + + + @@ -81,8 +97,9 @@ export class MDNObservatoryTestsAndScores extends L10nMixin(LitElement) { ), error: (error) => html`
      - ${this - .l10n`Failed to load tests and scoring data. Please try again later.`} + ${this.l10n( + "observatory-tests-and-scores-failed-to-load-tests-and-scoring", + )`Failed to load tests and scoring data. Please try again later.`} ${console.error("Observatory matrix fetch error:", error)}
      `, diff --git a/components/pagination/server.js b/components/pagination/server.js index 7d959ca59..0d0fd274d 100644 --- a/components/pagination/server.js +++ b/components/pagination/server.js @@ -28,7 +28,10 @@ export class Pagination extends ServerComponent { ); return html` -
      ${this.l10n`Test result`}${this.l10n`Description`}${this.l10n`Modifier`} + ${this.l10n( + "observatory-tests-and-scores-test-result", + )`Test result`} + + ${this.l10n( + "observatory-tests-and-scores-description", + )`Description`} + + ${this.l10n( + "observatory-tests-and-scores-modifier", + )`Modifier`} +
      - + diff --git a/components/survey/element.js b/components/survey/element.js index 2d2a31761..85b7469d1 100644 --- a/components/survey/element.js +++ b/components/survey/element.js @@ -194,7 +194,7 @@ export class MDNSurvey extends L10nMixin(LitElement) { return nothing; } - const title = this.l10n`Hide this survey`; + const title = this.l10n("survey-hide-this-survey")`Hide this survey`; return html`
      @@ -214,7 +214,9 @@ export class MDNSurvey extends L10nMixin(LitElement) { class="external" href=${this._source} target="_blank" - title=${this.l10n`Take survey (Opens in a new tab)`} + title=${this.l10n( + "survey-take-survey-opens-in-a-new-tab", + )`Take survey (Opens in a new tab)`} @click=${this.#onLinkClick} >${this._survey.question}` diff --git a/components/toggle-sidebar/element.js b/components/toggle-sidebar/element.js index 70af6cfb2..021d9f2d6 100644 --- a/components/toggle-sidebar/element.js +++ b/components/toggle-sidebar/element.js @@ -69,7 +69,7 @@ export class MDNToggleSidebar extends L10nMixin(LitElement) { icon-only variant="plain" > - ${this.l10n`Toggle sidebar`} + ${this.l10n("toggle-sidebar-toggle-sidebar")`Toggle sidebar`} `; } diff --git a/components/user-menu/element.js b/components/user-menu/element.js index dbb466003..8ed76b2b9 100644 --- a/components/user-menu/element.js +++ b/components/user-menu/element.js @@ -83,7 +83,7 @@ export class MDNUserMenu extends L10nMixin(LitElement) { height="32" alt="" />` - : this.l10n`User`} + : this.l10n("user-menu-user")`User`}

      ${user.email}

      diff --git a/components/writer-open-editor/element.js b/components/writer-open-editor/element.js index 721d76f97..1382468d9 100644 --- a/components/writer-open-editor/element.js +++ b/components/writer-open-editor/element.js @@ -23,7 +23,7 @@ export class MDNWriterOpenEditor extends L10nMixin(LitElement) { render() { return html` - ${this.l10n`Open in editor`} + ${this.l10n("writer-open-editor-open-in-editor")`Open in editor`} `; } } diff --git a/components/writer-toolbar/server.js b/components/writer-toolbar/server.js index 02f23b0a4..8a37090d1 100644 --- a/components/writer-toolbar/server.js +++ b/components/writer-toolbar/server.js @@ -14,7 +14,7 @@ export class WriterToolbar extends ServerComponent { return html`
      ${Button.render(context, { - label: context.l10n`View on MDN`, + label: context.l10n("writer-toolbar-view-on-mdn")`View on MDN`, href: prodUrl.toString(), variant: "plain", })} diff --git a/entry.ssr.js b/entry.ssr.js index b05ba138d..d05f9603c 100644 --- a/entry.ssr.js +++ b/entry.ssr.js @@ -47,9 +47,6 @@ for (const [name, def] of customElements.__definitions) { */ export async function render(path, partialContext, compilationStats) { const locale = path.split("/")[1] || "en-US"; - if (locale === "qa") { - path = path.replace("/qa/", "/en-US/"); - } const context = { path, diff --git a/l10n/README.md b/l10n/README.md new file mode 100644 index 000000000..871f64a1a --- /dev/null +++ b/l10n/README.md @@ -0,0 +1,61 @@ +# L10n + +We use [Fluent](https://projectfluent.org/) for l10n. + +## For developers + +In order to make adding l10n strings easy, we have a couple of convenience methods exposed via `context.l10n` (in a server component) and `this.l10n` (in a custom element, with the `L10nMixin` applied). + +These can be used in two ways: + +```js +// As an inline string, with an ID: +context.l10n("hello")`Hello`; + +// For more complex scenarios, involving arguments or +// HTML within the string: +context.l10n.raw({ + id: "hello-person", + args: { + name: "world", + }, + elements: { + link: { tag: "a", href: "https://example.com/" }, + }, +}); +``` + +The last of those examples requires manually adding a string to `./locales/en-US.ftl`, like this: + +```ftl +hello-person = Hello { $name }! +``` + +And it'll render into HTML like this: + +```html +Hello world! +``` + +The other examples are automatically extracted, and combined with the manually specified strings, into `./template.ftl`. + +### Pseudolocalization + +We have a few pseudo locales for testing if strings have been added, or if components will adapt to localized strings: + +- `qaa`: "accented" locale: adds accents to all characters, duplicates some vowels to create longer strings, wraps string in square brackets to help detect truncation +- `qai`: "id" locale: replaces strings with their identifiers, wrapped in square brackets + +The `qai` locale works all the time, the `qaa` locale must be manually generated with `node ./parser/transform.js` + +## For localizers + +The l10n experience isn't fantastic at the moment, and we have improvements to make, but it should be functional. + +Strings to be localized can be sourced from `./template.ftl`: this will include both manually added strings, as well as strings scraped from the code. Localized strings should be placed in `./locales/{locale}.ftl`. + +Adding Fluent comments to explain what context strings appear in is an open task, but for now they can be found in code by searching for the ID: it'll appear as `{this|context}.l10n({the id})` or `{this|context}.l10n({ id: {the id} })` + +If one English string is used in multiple places and requires multiple different strings in a locale, file an issue to distinguish the IDs. + +Please also file an issue for any other problems you encounter with localizing - either with specific strings which need fixing, or general issues with the l10n process. diff --git a/l10n/context.js b/l10n/context.js index 105da7e85..20f514f51 100644 --- a/l10n/context.js +++ b/l10n/context.js @@ -1,10 +1,11 @@ -import getFluentContext from "./fluent.js"; +import getFluentContext, { loadFluentFile } from "./fluent.js"; /** * @param {string} locale * @returns {Promise} */ export async function addFluent(locale) { + await loadFluentFile(locale); return { locale: locale, l10n: getFluentContext(locale), diff --git a/l10n/fluent.js b/l10n/fluent.js index a51d2fd1e..515c4850f 100644 --- a/l10n/fluent.js +++ b/l10n/fluent.js @@ -2,33 +2,15 @@ import { FluentBundle, FluentResource } from "@fluent/bundle"; import insane from "insane"; import { unsafeHTML } from "lit/directives/unsafe-html.js"; -import de_ftl from "../l10n/de.ftl"; -import enUS_ftl from "../l10n/en-US.ftl"; -import es_ftl from "../l10n/es.ftl"; -import fr_ftl from "../l10n/fr.ftl"; -import ja_ftl from "../l10n/ja.ftl"; -import ko_ftl from "../l10n/ko.ftl"; -import ptBR_ftl from "../l10n/pt-BR.ftl"; -import ru_ftl from "../l10n/ru.ftl"; -import zhCN_ftl from "../l10n/zh-CN.ftl"; -import zhTW_ftl from "../l10n/zh-TW.ftl"; +import enUS_ftl from "./locales/en-US.ftl"; /** * @import { AllowedTags } from "insane"; */ /** @type {Record} */ -const ftlMap = { +let ftlMap = { "en-US": enUS_ftl, - de: de_ftl, - es: es_ftl, - fr: fr_ftl, - ja: ja_ftl, - ko: ko_ftl, - "pt-BR": ptBR_ftl, - ru: ru_ftl, - "zh-CN": zhCN_ftl, - "zh-TW": zhTW_ftl, }; const ALLOWED_TAGS = ["i", "strong", "br", "em"]; @@ -150,7 +132,7 @@ export class Fluent { const parentMessage = bundle ? bundle.getMessage(id) : undefined; let message; - if (this.locale === "qa") { + if (this.locale === "qai") { return `[${id}${attr ? `.${attr}` : ""}]`; } @@ -210,57 +192,52 @@ function getLocale(locale) { } /** - * @param {string} [locale] + * @param {string} locale + */ +export async function loadFluentFile(locale) { + if (locale !== "qai" && !ftlMap[locale]) { + try { + const { default: localeStrings } = await import( + `./locales/${locale}.ftl` + ); + ftlMap[locale] = localeStrings; + } catch (error) { + console.error(error); + } + } +} + +/** + * @param {string} locale */ export default function getFluentContext(locale) { /** - * @overload * @param {string} id * @param {string} [_comment] * @returns {import("../types/fluent.js").L10nTag} */ - - /** - * @overload - * @param {TemplateStringsArray} strings - * @returns {string} - */ - - /** - * @param {string | TemplateStringsArray} idOrStrings - * @param {string} [_comment] - * @returns {import("../types/fluent.js").L10nTag | string} - */ - function l10n(idOrStrings, _comment) { - if (typeof idOrStrings === "string") { - // called as a function, returning a template tag: - // l10n("foobar")`Foobar` - const id = idOrStrings; - const localizedString = getLocale(locale)?.get(id); - const fallbackString = `[${id}]`; - /** @type {import("../types/fluent.js").L10nTag} */ - const tag = (strings) => { - // we don't currently support any expressions in the template string - // we might in the future, if we use l10n.raw a lot - const templateString = strings[0]; - return localizedString || templateString || fallbackString; - }; - tag.toString = () => { - // called as a function, used as a function: - // ${l10n("foobar")} - return ( - (typeof localizedString === "string" && localizedString) || - fallbackString - ); - }; - return tag; - } - // called directly as a template tag: - // l10n`Foobar` - // TODO: create consistent logic for id generation at runtime and scrapetime - const strings = idOrStrings; - const templateString = strings[0]; - return templateString || ""; + function l10n(id, _comment) { + // called as a function, returning a template tag: + // l10n("foobar")`Foobar` + const localizedStringOrHtml = getLocale(locale)?.get(id); + const localizedString = + typeof localizedStringOrHtml === "string" + ? localizedStringOrHtml + : undefined; + const fallbackString = `[${id}]`; + /** @type {import("../types/fluent.js").L10nTag} */ + const tag = (strings) => { + // we don't currently support any expressions in the template string + // we might in the future, if we use l10n.raw a lot + const templateString = strings[0]; + return localizedString || templateString || fallbackString; + }; + tag.toString = () => { + // called as a function, used as a function: + // ${l10n("foobar")} + return localizedString || fallbackString; + }; + return tag; } /** diff --git a/l10n/locales/.gitignore b/l10n/locales/.gitignore new file mode 100644 index 000000000..bee5eadef --- /dev/null +++ b/l10n/locales/.gitignore @@ -0,0 +1 @@ +qa*.ftl \ No newline at end of file diff --git a/l10n/de.ftl b/l10n/locales/de.ftl similarity index 100% rename from l10n/de.ftl rename to l10n/locales/de.ftl diff --git a/l10n/en-US.ftl b/l10n/locales/en-US.ftl similarity index 97% rename from l10n/en-US.ftl rename to l10n/locales/en-US.ftl index dad8a2089..67e4a5922 100644 --- a/l10n/en-US.ftl +++ b/l10n/locales/en-US.ftl @@ -1,3 +1,6 @@ +# WARNING: don't use this file as a source for strings requiring l10n, use ../template.ftl instead: +# this file only contains manually added strings, not ones inlined in code. See ../README.md for more details. + # TODO Use comments, see: https://firefox-source-docs.mozilla.org/l10n/fluent/review.html#comments # TODO Consider using terms, see: https://firefox-source-docs.mozilla.org/l10n/fluent/review.html#terms and https://projectfluent.org/fluent/guide/references.html#message-references diff --git a/l10n/es.ftl b/l10n/locales/es.ftl similarity index 100% rename from l10n/es.ftl rename to l10n/locales/es.ftl diff --git a/l10n/fr.ftl b/l10n/locales/fr.ftl similarity index 100% rename from l10n/fr.ftl rename to l10n/locales/fr.ftl diff --git a/l10n/ja.ftl b/l10n/locales/ja.ftl similarity index 100% rename from l10n/ja.ftl rename to l10n/locales/ja.ftl diff --git a/l10n/ko.ftl b/l10n/locales/ko.ftl similarity index 100% rename from l10n/ko.ftl rename to l10n/locales/ko.ftl diff --git a/l10n/pt-BR.ftl b/l10n/locales/pt-BR.ftl similarity index 100% rename from l10n/pt-BR.ftl rename to l10n/locales/pt-BR.ftl diff --git a/l10n/ru.ftl b/l10n/locales/ru.ftl similarity index 100% rename from l10n/ru.ftl rename to l10n/locales/ru.ftl diff --git a/l10n/zh-CN.ftl b/l10n/locales/zh-CN.ftl similarity index 100% rename from l10n/zh-CN.ftl rename to l10n/locales/zh-CN.ftl diff --git a/l10n/zh-TW.ftl b/l10n/locales/zh-TW.ftl similarity index 100% rename from l10n/zh-TW.ftl rename to l10n/locales/zh-TW.ftl diff --git a/l10n/mixin.js b/l10n/mixin.js index 9e6b03b74..0f813cb52 100644 --- a/l10n/mixin.js +++ b/l10n/mixin.js @@ -1,11 +1,14 @@ import { getSymmetricContext } from "../symmetric-context/both.js"; -import getFluentContext from "./fluent.js"; +import getFluentContext, { loadFluentFile } from "./fluent.js"; /** * @import { LitElement } from "lit"; */ +const locale = getSymmetricContext()?.locale; +if (locale) await loadFluentFile(locale); + /** * @template {new (...args: any[]) => LitElement} TBase * @param {TBase} Base @@ -17,7 +20,13 @@ export const L10nMixin = (Base) => */ constructor(...args) { super(...args); - const context = getSymmetricContext(); + let context = getSymmetricContext(); + if (!context) { + console.error("SymmetricContext is undefined, reverting to defaults"); + context = { + locale: "en-US", + }; + } this.locale = context.locale; this.l10n = getFluentContext(this.locale); } diff --git a/l10n/parser/extract.js b/l10n/parser/extract.js new file mode 100644 index 000000000..3c9b99642 --- /dev/null +++ b/l10n/parser/extract.js @@ -0,0 +1,4 @@ +import { extract } from "./extractor.js"; + +const lint = process.argv.includes("--lint"); +await extract({ lint }); diff --git a/l10n/parser/extractor.js b/l10n/parser/extractor.js new file mode 100644 index 000000000..c9d9f7d3c --- /dev/null +++ b/l10n/parser/extractor.js @@ -0,0 +1,118 @@ +import { readFile, writeFile } from "node:fs/promises"; + +import path from "node:path"; + +import { fileURLToPath } from "node:url"; + +import { + Comment, + Identifier, + Message, + Pattern, + TextElement, + parse, + serialize, +} from "@fluent/syntax"; +import { Node, Project, SyntaxKind } from "ts-morph"; + +/** + * @import { PropertyAccessExpression, TaggedTemplateExpression } from "ts-morph"; + */ + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +/** + * @param {{ lint?: boolean }} [options] + */ +export async function extract(options = {}) { + const manualStrings = await readFile( + fileURLToPath(import.meta.resolve("../locales/en-US.ftl")), + "utf8", + ); + const fluentResource = parse(manualStrings, {}); + + const project = new Project({}); + project.addSourceFilesAtPaths( + path.join(__dirname, "..", "..", "components", "**", "*.js"), + ); + + /** @type {Map} */ + const map = new Map(); + + for (const file of project.getSourceFiles()) { + for (const taggedTemplate of file.getDescendantsOfKind( + SyntaxKind.TaggedTemplateExpression, + )) { + const tagNode = taggedTemplate.getTag(); + if (Node.isCallExpression(tagNode)) { + // e.g. this.l10n("foobar")`barfoo` + const expr = tagNode.getExpression(); + if (Node.isPropertyAccessExpression(expr) && isL10nTag(expr)) { + const [arg] = tagNode.getArguments(); + if (Node.isStringLiteral(arg)) { + const key = arg.getLiteralValue(); + const value = getLiteralValue(taggedTemplate); + map.set(key, value); + } + } + } + } + } + + fluentResource.body = [ + new Comment( + `WARNING: do not edit this file, it's automatically generated by ExtractL10nPlugin. +If you need to manually add strings, do so in ./locales/en-US.ftl. See ./README.md for more details.`, + ), + ...fluentResource.body.filter( + (entry) => + !( + entry instanceof Comment && + (entry.content.startsWith("WARNING") || + entry.content.startsWith("TODO")) + ), + ), + ...[...map].map( + ([key, value]) => + new Message(new Identifier(key), new Pattern([new TextElement(value)])), + ), + ]; + + const output = serialize(fluentResource, {}); + const outputPath = fileURLToPath(import.meta.resolve("../template.ftl")); + + if (options.lint) { + const existing = await readFile(outputPath, "utf8"); + if (existing !== output) { + throw new Error( + "l10n template.ftl is out of date. Run `npm run l10n:extract` to update.", + ); + } + } else { + await writeFile(outputPath, output, "utf8"); + } +} + +/** + * @param {PropertyAccessExpression} tagNode + */ +function isL10nTag(tagNode) { + return ( + ["context", "this"].includes(tagNode.getExpression().getText()) && + "l10n" === tagNode.getName() + ); +} + +/** + * @param {TaggedTemplateExpression} taggedTemplate + */ +function getLiteralValue(taggedTemplate) { + const template = taggedTemplate.getTemplate(); + if (Node.isNoSubstitutionTemplateLiteral(template)) { + return template.getLiteralValue(); + } else { + throw new Error( + `L10n extractor: \`${taggedTemplate.getText()}\` has substitutions, which we don't support`, + ); + } +} diff --git a/l10n/parser/transform.js b/l10n/parser/transform.js new file mode 100644 index 000000000..4b24f14fc --- /dev/null +++ b/l10n/parser/transform.js @@ -0,0 +1,78 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; + +import { Transformer, parse, serialize } from "@fluent/syntax"; + +/** + * @import { TextElement } from "@fluent/syntax"; + */ + +class AccentTransformer extends Transformer { + // eslint-disable-next-line unicorn/consistent-function-scoping + MARKS = Array.from({ length: 0x3_6f - 0x3_00 + 1 }, (_, i) => + String.fromCodePoint(0x3_00 + i), + ); + + /** + * @param {number} min + * @param {number} max + */ + _randInt(min, max) { + return Math.floor(Math.random() * (max - min + 1)) + min; + } + + /** + * @param {number} min + * @param {number} max + */ + _randMarks(min, max) { + const n = this._randInt(min, max); + let out = ""; + for (let i = 0; i < n; i++) { + out += this.MARKS[this._randInt(0, this.MARKS.length - 1)]; + } + return out; + } + + /** @param {string} str */ + _accent(str) { + return [...str] + .flatMap((char) => { + return /\S/i.test(char) + ? ("aeiou".includes(char.toLowerCase()) + ? Array.from({ length: this._randInt(1, 3) }, () => char) + : [char] + ).map((char) => char + this._randMarks(1, 1)) + : char; + }) + .join(""); + } + + /** + * @param {TextElement} node + */ + visitTextElement(node) { + node.value = + "[" + + node.value + .split(/(<[^>]*>)/g) + .map((token) => (token.startsWith("<") ? token : this._accent(token))) + .join("") + + "]"; + return node; + } +} + +const strings = await readFile( + fileURLToPath(import.meta.resolve("../template.ftl")), + "utf8", +); +const resource = parse(strings, {}); + +const accentedResource = resource.clone(); +new AccentTransformer().genericVisit(accentedResource); +await writeFile( + fileURLToPath(import.meta.resolve("../locales/qaa.ftl")), + serialize(accentedResource, {}), + "utf8", +); diff --git a/l10n/template.ftl b/l10n/template.ftl new file mode 100644 index 000000000..22bcc18c8 --- /dev/null +++ b/l10n/template.ftl @@ -0,0 +1,333 @@ +# WARNING: do not edit this file, it's automatically generated by ExtractL10nPlugin. +# If you need to manually add strings, do so in ./locales/en-US.ftl. See ./README.md for more details. + +article-footer-last-modified = This page was last modified on by MDN contributors. +article-footer-source-title = Folder: { $folder } (Opens in a new tab) +baseline-asterisk = Some parts of this feature may have varying levels of support. +baseline-high-extra = This feature is well established and works across many devices and browser versions. It’s been available across browsers since { $date }. +baseline-low-extra = Since { $date }, this feature works across the latest devices and browser versions. This feature might not work in older devices or browsers. +baseline-not-extra = This feature is not Baseline because it does not work in some of the most widely-used browsers. +baseline-supported-in = Supported in { $browsers } +baseline-unsupported-in = Not widely supported in { $browsers } +baseline-supported-and-unsupported-in = Supported in { $supported }, but not widely supported in { $unsupported } +homepage-hero-title = Resources for Developers,
      by Developers +homepage-hero-description = Documenting CSS, HTML, and JavaScript, since 2005. +not-found-title = Page not found +not-found-description = Sorry, the page { $url } could not be found. +not-found-fallback-english = Good news: The page you requested exists in English. +not-found-fallback-search = The page you requested doesn't exist, but you could try a site search for: +not-found-back = Go back to the home page +reference-toc-header = In this article +footer-mofo = Visit Mozilla Corporation’s not-for-profit parent, the Mozilla Foundation. +footer-copyright = Portions of this content are ©1998–{ $year } by individual mozilla.org contributors. Content available under a Creative Commons license. +search-modal-site-search = Site search for { $query } +site-search-search-stats = Found { $results } documents. +site-search-suggestion-matches = + { $relation -> + [gt] + more than { $matches -> + [one] { $matches } match + *[other] { $matches } matches + } + *[eq] + { $matches -> + [one] { $matches } match + *[other] { $matches } matches + } + } +site-search-suggestions-text = Did you mean: +blog-time-to-read = + { $minutes -> + [one] { $minutes } minute read + *[other] { $minutes } minutes read + } +blog-post-not-found = Blog post not found. +blog-previous = Previous post +blog-next = Next post +-brand-name-obs = HTTP Observatory +obs-report = Report +obs-title = { -brand-name-obs } +obs-landing-intro = Launched in 2016, the { -brand-name-obs } enhances web security by analyzing compliance with best security practices. It has provided insights to over 6.9 million websites through 47 million scans. +obs-assessment = Developed by Mozilla, the { -brand-name-obs } performs an in-depth assessment of a site’s HTTP headers and other key security configurations. +obs-scanning = Its automated scanning process provides developers and website administrators with detailed, actionable feedback, focusing on identifying and addressing potential security vulnerabilities. +obs-security = The tool is instrumental in helping developers and website administrators strengthen their sites against common security threats in a constantly advancing digital environment. +obs-mdn = The { -brand-name-obs } provides effective security insights, guided by Mozilla's expertise and commitment to a safer and more secure internet and based on well-established trends and guidelines. +compat-loading = Loading… +compat-js-required = Enable JavaScript to view this browser compatibility table. +compat-browser-version-date = { $browser } { $version } – Release date: { $date } +compat-browser-version-released = Release date: { $date } +compat-link-report-issue = Report problems with this compatibility data +compat-link-report-issue-title = Report an issue with this compatibility data +compat-link-report-missing-title = Report missing compatibility data +compat-link-report-missing = Report this issue +compat-link-source = View data on GitHub +compat-link-source-title = File: { $filename } +compat-deprecated = Deprecated +compat-experimental = Experimental +compat-nonstandard = Non-standard +compat-no = No +compat-support-full = Full support +compat-support-partial = Partial support +compat-support-no = No support +compat-support-unknown = Support unknown +compat-support-preview = Preview browser support +compat-support-prefix = Implemented with the vendor prefix: { $prefix } +compat-support-altname = Alternate name: { $altname } +compat-support-removed = Removed in { $version } and later +compat-support-see-impl-url = See { $label } +compat-support-flags = + { NUMBER($has_added) -> + [one] From version { $version_added } + *[other] { "" } + }{ $has_last -> + [one] + { NUMBER($has_added) -> + *[zero] Until { $versionLast } users + [one] { " " }until { $versionLast } users + } + *[zero] + { NUMBER($has_added) -> + *[zero] Users + [one] { " " }users + } + } + { " " }must explicitly set the { $flag_name }{ " " } + { $flag_type -> + *[preference] preference + [runtime_flag] runtime flag + }{ NUMBER($has_value) -> + [one] { " " }to { $flag_value } + *[other] { "" } + }{ "." } + { NUMBER($has_pref_url) -> + [one] + { $flag_type -> + [preference] To change preferences in { $browser_name }, visit { $browser_pref_url }. + *[other] { "" } + } + *[other] { "" } + } +compat-legend = Legend +compat-legend-tip = Tip: you can click/tap on a cell for more information. +compat-legend-yes = { compat-support-full } +compat-legend-partial = { compat-support-partial } +compat-legend-preview = In development. Supported in a pre-release version. +compat-legend-no = { compat-support-no } +compat-legend-unknown = Compatibility unknown +compat-legend-experimental = { compat-experimental }. Expect behavior to change in the future. +compat-legend-nonstandard = { compat-nonstandard }. Check cross-browser support before using. +compat-legend-deprecated = { compat-deprecated }. Not for use in new websites. +compat-legend-footnote = See implementation notes. +compat-legend-disabled = User must explicitly enable this feature. +compat-legend-altname = Uses a non-standard name. +compat-legend-prefix = Requires a vendor prefix or different name for use. +compat-legend-more = Has more compatibility info. +placement-note = Ad +placement-no = Don't want to see ads? +pagination-next = Next page +pagination-prev = Previous page +pagination-current = Current page +pagination-goto = Go to page { $page } +logout = Sign out +login = Log in +settings = My settings +example-play-button-label = Play +example-play-button-title = Run example in MDN Playground (opens in new tab) +content-feedback-question = Was this page helpful to you? +content-feedback-reason = Why was this page not helpful to you? +content-feedback-thanks = Thank you for your feedback! +writer-reload-polling = Polling every { $seconds }s +a11y-menu-skip-to-main-content = Skip to main content +a11y-menu-skip-to-search = Skip to search +article-footer-learn-how-to-contribute = Learn how to contribute +article-footer-view-this-page-on-github = View this page on GitHub +article-footer-this-will-take-you-to-github-to = This will take you to GitHub to file a new issue. +article-footer-report-a-problem-with-this-conte = Report a problem with this content +baseline-indicator-baseline-cross = Baseline Cross +baseline-indicator-baseline-check = Baseline Check +baseline-indicator-limited-availability = Limited availability +baseline-indicator-baseline = Baseline +baseline-indicator-widely-available = Widely available +baseline-indicator-newly-available = Newly available +baseline-indicator-check = check +baseline-indicator-cross = cross +baseline-indicator-learn-more = Learn more +baseline-indicator-see-full-compatibility = See full compatibility +baseline-indicator-report-feedback = Report feedback +blog-previous = Previous Post +blog-next = Next post +blog-index-blog-it-better = Blog it better +reference-toc-header = In this article +blog-post-not-found = Blog post not found +collection-save-button-save-in-collection = Save in collection +collection-save-button-remove = Remove +collection-save-button-save = Save +collection-save-button-add-to-collection = Add to collection +collection-save-button-collection = Collection: +collection-save-button-saved-articles = Saved articles +collection-save-button-new-collection = New collection +collection-save-button-name = Name: +collection-save-button-note = Note: +collection-save-button-saving = Saving… +collection-save-button-cancel = Cancel +collection-save-button-deleting = Deleting… +collection-save-button-delete = Delete +theme-default = OS default +color-theme-light = Light +color-theme-dark = Dark +color-theme-switch-color-theme = Switch color theme +color-theme-theme = Theme +compat-link-report-issue-title = Report an issue with this compatibility data +compat-link-report-issue = Report problems with this compatibility data +compat-link-source = View data on GitHub +compat-experimental = Experimental +compat-deprecated = Experimental +compat-nonstandard = Non-standard +compat-support-partial = Partial support +compat-support-preview = Preview support +compat-support-full = Full support +compat-support-no = No support +compat-support-unknown = Support unknown +compat-yes = Yes +compat-partial = Partial +compat-no = No +compat-legend = Legend +compat-legend-tip = Tip: you can click/tap on a cell for more information. +compat-link-report-missing-title = Report missing compatibility data +compat-link-report-missing = Report this issue +compat-js-required = Enable JavaScript to view this browser compatibility table. +compat-loading = Loading… +content-feedback-content-is-out-of-date = Content is out of date +content-feedback-missing-information = Missing information +content-feedback-code-examples-not-working-as-exp = Code examples not working as expected +content-feedback-other = Other +content-feedback-question = Was this page helpful to you? +content-feedback-yes = Yes +content-feedback-no = No +content-feedback-reason = Why was this page not helpful to you? +content-feedback-submit = Submit +content-feedback-thanks = Thank you for your feedback! +contributor-spotlight-want-to-be-part-of-the-journey = Want to be part of the journey? +contributor-spotlight-our-constant-quest-for-innovatio = Our constant quest for innovation starts here, with you. Every part of MDN (docs, demos and the site itself) springs from our incredible open community of developers. Please join us! +contributor-spotlight-get-involved = Get involved +contributor-spotlight-contributor-profile = Contributor profile +copy-button-copied = Copied +copy-button-copy-failed = Copy failed! +copy-button-copy = Copy +footer-mdn-on-github = MDN on GitHub +footer-mdn-on-bluesky = MDN on Bluesky +footer-mdn-on-x = MDN on X +footer-mdn-on-mastodon = MDN on Mastodon +footer-mdn-blog-rss-feed = MDN blog RSS feed +footer-mdn = MDN +footer-about = About +footer-blog = Blog +footer-mozilla-careers = Mozilla careers +footer-advertise-with-us = Advertise with us +footer-mdn-plus = MDN Plus +footer-product-help = Product help +footer-contribute = Contribute +footer-mdn-community = MDN Community +footer-community-resources = Community resources +footer-writing-guidelines = Writing guidelines +footer-mdn-discord = MDN Discord +footer-developers = Developers +footer-web-technologies = Web technologies +footer-learn-web-development = Learn web development +footer-guides = Guides +footer-tutorials = Tutorials +footer-glossary = Glossary +footer-hacks-blog = Hacks blog +footer-website-privacy-notice = Website Privacy Notice +footer-telemetry-settings = Telemetry Settings +footer-legal = Legal +footer-community-participation-guidelin = Community Participation Guidelines +footer-mdn-logo = MDN logo +footer-tagline = Your blueprint for a better internet. +footer-mozilla-logo = Mozilla logo +generic-toc__header = In this article +homepage-body-featured-articles = Featured articles +homepage-body-latest-news = Latest news +homepage-body-recent-contributions = Recent contributions +homepage-contributor-spotlight-contributor-spotlight = Contributor Spotlight +homepage-contributor-spotlight-get-involved = Get involved +homepage-search-search-the-site = Search the site +homepage-search-search = Search +interactive-example-reset = Reset +interactive-example-value-select = Value select +interactive-example-the-current-value-is-not-support = The current value is not supported by your browser. +interactive-example-run-example-and-show-console-ou = Run example, and show console output +interactive-example-run = Run +interactive-example-reset-example-and-clear-console = Reset example, and clear console output +interactive-example-console-output = Console output +interactive-example-output = Output +issues-table-loading-issues = loading issues… +issues-table-title = Title +issues-table-repository = Repository +language-switcher-remember-language = Remember language +language-switcher-enable-this-setting-to-always-sw = Enable this setting to always switch to the current language when available. (Click to learn more.) +language-switcher-learn-more = Learn more +login-button-login = Login +modal-exit-modal = Exit modal +navigation-toggle-navigation = Toggle navigation +obs-about-title = About the HTTP Observatory +observatory-landing-read-our-faq = Read our FAQ +observatory-landing-report-feedback = Report Feedback +observatory-rescan-button-rescan = Rescan +observatory-rescan-button-wait-a-minute-to-rescan = Wait a minute to rescan +observatory-results-report-feedback = Report Feedback +observatory-results-faq = FAQ +observatory-tests-and-scores-loading-tests-and-scoring-data = Loading tests and scoring data... +observatory-tests-and-scores-see = See +observatory-tests-and-scores-for-guidance = for guidance. +observatory-tests-and-scores-test-result = Test result +observatory-tests-and-scores-description = Description +observatory-tests-and-scores-modifier = Modifier +observatory-tests-and-scores-failed-to-load-tests-and-scoring = Failed to load tests and scoring data. Please try again later. +pagination-pagination = Pagination +playground-do-you-really-want-to-clear-ever = Do you really want to clear everything? +playground-do-you-really-want-to-revert-you = Do you really want to revert your changes? +playground-playground = Playground +playground-format = Format +playground-run = Run +playground-share = Share +playground-clear = Clear +playground-reset = Reset +playground-seeing-something-inappropriate = Seeing something inappropriate? +playground-console = Console +playground-share-markdown = Share Markdown +playground-copy-markdown-to-clipboard = Copy markdown to clipboard +playground-share-your-code-via-permalink = Share your code via Permalink +playground-copy-to-clipboard = Copy to clipboard +playground-create-link = Create link +playground-report-this-malicious-or-inappro = Report this malicious or inappropriate shared playground. +playground-can-you-please-share-some-detail = Can you please share some details on what's wrong with this content: +playground-cancel = Cancel +playground-report = Report +recently-visited-recently-visited = Recently visited +scrim-inline-clicking-will-load-content-from = Clicking will load content from scrimba.com +scrim-inline-toggle-fullscreen = Toggle fullscreen +scrim-inline-open-on-scrimba = Open on Scrimba +scrim-inline-load-scrim-and-open-dialog = Load scrim and open dialog. +search-button-search-the-site = Search the site +search-modal-loading-search-index = Loading search index… +search-modal-search = Search +search-modal-exit-search = Exit search +sidebar-filter-filter-sidebar = Filter sidebar +sidebar-filter-filter = Filter +sidebar-filter-clear-filter-input = Clear filter input +site-search-search = Search +site-search-previous = Previous +site-search-next = Next +site-search-suggestions-text = Did you mean… +site-search-searching = Searching… +site-search-language = Language +site-search-both = Both +specifications-list-this-feature-does-not-appear-to = This feature does not appear to be defined in any specification. +specifications-list-specification = Specification +survey-hide-this-survey = Hide this survey +survey-take-survey-opens-in-a-new-tab = Take survey (Opens in a new tab) +toggle-sidebar-toggle-sidebar = Toggle sidebar +user-menu-user = User +writer-open-editor-open-in-editor = Open in editor +writer-toolbar-view-on-mdn = View on MDN diff --git a/package-lock.json b/package-lock.json index dab663e94..f162d7cd5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,6 +51,7 @@ "@csstools/postcss-global-data": "^4.0.0", "@eslint/compat": "^2.0.2", "@eslint/js": "^9.39.2", + "@fluent/syntax": "^0.19.0", "@jackolope/lit-analyzer": "^3.2.0", "@jackolope/ts-lit-plugin": "^3.1.6", "@mdn/browser-compat-data": "^7.3.0", @@ -103,6 +104,7 @@ "stylelint-config-recess-order": "^7.6.0", "stylelint-config-standard": "^40.0.0", "svgo-loader": "^4.0.0", + "ts-morph": "^26.0.0", "typescript": "^5.9.3", "typescript-eslint": "^8.54.0", "webpack-dev-middleware": "^7.4.5", @@ -5164,6 +5166,17 @@ "npm": ">=7.0.0" } }, + "node_modules/@fluent/syntax": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@fluent/syntax/-/syntax-0.19.0.tgz", + "integrity": "sha512-5D2qVpZrgpjtqU4eNOcWGp1gnUCgjfM+vKGE2y03kKN6z5EBhtx0qdRFbg8QuNNj8wXNoX93KJoYb+NqoxswmQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=14.0.0", + "npm": ">=7.0.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -9369,6 +9382,34 @@ "node": ">=10.13.0" } }, + "node_modules/@ts-morph/common": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.27.0.tgz", + "integrity": "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-glob": "^3.3.3", + "minimatch": "^10.0.1", + "path-browserify": "^1.0.1" + } + }, + "node_modules/@ts-morph/common/node_modules/minimatch": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz", + "integrity": "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@isaacs/brace-expansion": "^5.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -13232,6 +13273,13 @@ "node": ">=4" } }, + "node_modules/code-block-writer": { + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", + "integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==", + "dev": true, + "license": "MIT" + }, "node_modules/codemirror": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", @@ -30995,6 +31043,17 @@ "typescript": ">=4.0.0" } }, + "node_modules/ts-morph": { + "version": "26.0.0", + "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-26.0.0.tgz", + "integrity": "sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ts-morph/common": "~0.27.0", + "code-block-writer": "^13.0.3" + } + }, "node_modules/ts-simple-type": { "version": "2.0.0-next.0", "resolved": "https://registry.npmjs.org/ts-simple-type/-/ts-simple-type-2.0.0-next.0.tgz", diff --git a/package.json b/package.json index 36938d45c..abc0fd0c3 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,9 @@ "tsc": "tsc", "rari": "rari", "prepack": "npm run build", - "wdio": "wdio run ./wdio.conf.js" + "wdio": "wdio run ./wdio.conf.js", + "l10n:extract": "node l10n/parser/extract.js", + "l10n:lint": "node l10n/parser/extract.js --lint" }, "browserslist": [ "Chrome > 0 and last 2.5 years", @@ -72,6 +74,7 @@ "@csstools/postcss-global-data": "^4.0.0", "@eslint/compat": "^2.0.2", "@eslint/js": "^9.39.2", + "@fluent/syntax": "^0.19.0", "@jackolope/lit-analyzer": "^3.2.0", "@jackolope/ts-lit-plugin": "^3.1.6", "@mdn/browser-compat-data": "^7.3.0", @@ -124,6 +127,7 @@ "stylelint-config-recess-order": "^7.6.0", "stylelint-config-standard": "^40.0.0", "svgo-loader": "^4.0.0", + "ts-morph": "^26.0.0", "typescript": "^5.9.3", "typescript-eslint": "^8.54.0", "webpack-dev-middleware": "^7.4.5", diff --git a/server.js b/server.js index 329719e24..775f4e19a 100644 --- a/server.js +++ b/server.js @@ -224,6 +224,12 @@ export async function startServer() { }, selfHandleResponse: true, on: { + proxyReq: async (req) => { + const locale = req.path.split("/")[1]; + if (locale && /^q[a-t][a-z]$/.test(locale)) { + req.path = req.path.replace(locale, "en-US"); + } + }, proxyRes: async (proxyRes, req, res) => { const contentType = proxyRes.headers["content-type"] || ""; const statusCode = proxyRes.statusCode || 500; diff --git a/symmetric-context/both.js b/symmetric-context/both.js index 14f6aa0c6..b122b84a2 100644 --- a/symmetric-context/both.js +++ b/symmetric-context/both.js @@ -1,7 +1,7 @@ /** * Runs on either client or server, * and returns the client or server context respectively - * @returns {import("./types.js").SymmetricContext} + * @returns {import("./types.js").SymmetricContext | undefined} */ export function getSymmetricContext() { const serverStore = globalThis.__MDNServerContext?.getStore(); diff --git a/types/fluent.ts b/types/fluent.ts index 4cfd8f19e..890c2c140 100644 --- a/types/fluent.ts +++ b/types/fluent.ts @@ -1,10 +1,6 @@ -import { unsafeHTML } from "lit/directives/unsafe-html.js"; - export interface Element { tag: string; [k: string]: string; } -export type L10nTag = ( - strings: TemplateStringsArray, -) => string | ReturnType; +export type L10nTag = (strings: TemplateStringsArray) => string;
      ${context.l10n`Specification`} + ${context.l10n("specifications-list-specification")`Specification`} +