diff --git a/.browserslistrc b/.browserslistrc index ea5c29134f6fc..a26ad8491dfa0 100644 --- a/.browserslistrc +++ b/.browserslistrc @@ -1,4 +1,13 @@ # https://github.com/browserslist/browserslist#readme +defaults and supports es6-module +maintained node versions + +[production] + +cover 95% +not dead + +[development] + defaults -Explorer >= 10 diff --git a/.eslintignore b/.eslintignore index 23d7ce3c80d86..db1be353cbff8 100644 --- a/.eslintignore +++ b/.eslintignore @@ -7,11 +7,13 @@ public/en/user-survey-report public/static/documents public/static/legacy -# These should be fixed in the future as the -# tests and scripts will be updated in this PR -tests +# We don't want to lint/prettify the Coverage Results +coverage # MDX Plugin enforces Prettier formatting which should # be done in the future as we don't want to update the Markdown file # contents right now pages/**/*.md + +# We shouldn't lint statically generated Storybook files +storybook-static diff --git a/.eslintrc b/.eslintrc index 1314b286eeaff..f33dafbb16664 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,14 +1,42 @@ { "extends": ["eslint:recommended", "next"], + "root": true, "overrides": [ { "files": ["**/*.{mjs,js,jsx,ts,tsx}"], "extends": ["plugin:prettier/recommended"], - "env": { "node": true, "es6": true } + "plugins": ["import"], + "env": { "node": true, "es6": true }, + "rules": { + "import/order": [ + "warn", + { + "groups": [ + "builtin", + "external", + "internal", + "sibling", + "parent", + "index", + "type" + ] + } + ] + } + }, + { + "files": ["**/**.test.{js,jsx,ts,tsx}"], + "extends": ["plugin:testing-library/react"], + "env": { "jest": true, "node": true, "browser": true } }, { "files": ["**/*.{ts,tsx}"], - "globals": { "globalThis": false } + "parser": "@typescript-eslint/parser", + "plugins": ["@typescript-eslint"], + "globals": { "globalThis": false }, + "rules": { + "@typescript-eslint/consistent-type-imports": "error" + } }, { "files": ["**/*.tsx"], @@ -19,6 +47,7 @@ "react-hooks/rules-of-hooks": "error", "react-hooks/exhaustive-deps": "warn", "consistent-return": "off", + "react/destructuring-assignment": ["warn", "always"], "react/function-component-definition": [ "error", { @@ -29,7 +58,19 @@ "react/jsx-filename-extension": [ 2, { "extensions": [".js", ".jsx", ".ts", ".tsx"] } - ] + ], + "no-restricted-syntax": [ + "error", + { + "selector": "ImportDeclaration[source.value='react'][specifiers.0.type='ImportDefaultSpecifier']", + "message": "Default React import not allowed since we use the TypeScript jsx-transform. If you need a global type that collides with a React named export (such as `MouseEvent`), try using `globalThis.MouseHandler`" + }, + { + "selector": "ImportDeclaration[source.value='react'] :matches(ImportNamespaceSpecifier)", + "message": "Named * React import is not allowed. Please import what you need from React with Named Imports" + } + ], + "@typescript-eslint/consistent-type-definitions": ["error", "type"] } }, { @@ -68,6 +109,12 @@ "no-unused-vars": "off", "prefer-promise-reject-errors": "off" } + }, + { + "files": ["**/*.stories.{ts,tsx}"], + "rules": { + "import/no-anonymous-default-export": "off" + } } ] } diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000000000..bd4ff1cd9eeea --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,27 @@ + + +## Description + + + +## Related Issues + + + +### Check List + + + +- [ ] I have read the [Contributing Guidelines](https://github.com/nodejs/nodejs.org/blob/main/CONTRIBUTING.md) and made commit messages that follow the guideline. +- [ ] I have run `npx turbo lint` to ensure the code follows the style guide. And run `npx turbo lint:fix` to fix the style errors if necessary. +- [ ] I have run `npx turbo format` to ensure the code follows the style guide. +- [ ] I have run `npx turbo test` to check if all tests are passing, and/or `npx turbo test:snapshot` to update snapshots if I created and/or updated React Components. +- [ ] I've covered new added functionality with unit tests if necessary. diff --git a/.github/workflows/github-pages.yml b/.github/workflows/github-pages.yml index 76ba10718f5a6..00e2a9315be9c 100644 --- a/.github/workflows/github-pages.yml +++ b/.github/workflows/github-pages.yml @@ -23,6 +23,8 @@ jobs: steps: - name: Git Checkout uses: actions/checkout@v3 + with: + fetch-depth: 2 - name: Set up Node.js uses: actions/setup-node@v3 @@ -36,28 +38,36 @@ jobs: - name: Setup GitHub Pages uses: actions/configure-pages@v3 - - name: Restore Next.js cache + - name: Restore Cache uses: actions/cache/restore@v3 with: path: | .next/cache - key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}- + node_modules/.cache + key: build-${{ hashFiles('**/package-lock.json') }}- + restore-keys: | + build-${{ hashFiles('**/package-lock.json') }}- + enableCrossOsArchive: true - name: Build Next.js - run: npm run build + run: npx turbo build env: + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} + TURBO_TEAM: ${{ secrets.TURBO_TEAM }} NODE_OPTIONS: '--max_old_space_size=4096' NEXT_BASE_PATH: /nodejs.org - - name: Save Next.js cache + - name: Save Cache uses: actions/cache/save@v3 with: path: | .next/cache - key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('.next/cache/eslint') }} + node_modules/.cache + key: build-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('node_modules/.cache') }} + enableCrossOsArchive: true - name: Export Next.js static files - run: npm run export + run: npx turbo export - name: Upload Artifact uses: actions/upload-pages-artifact@v1 @@ -66,11 +76,13 @@ jobs: deploy: name: Deploy to GitHub Pages + runs-on: ubuntu-latest + needs: build + environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} - runs-on: ubuntu-latest - needs: build + steps: - name: Deploy to GitHub Pages id: deployment diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index bbf4a7b25cda7..7e573a6802105 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -4,9 +4,6 @@ on: pull_request: workflow_dispatch: -env: - FORCE_COLOR: 2 - jobs: lint: name: Lint @@ -15,6 +12,8 @@ jobs: steps: - name: Git Checkout uses: actions/checkout@v3 + with: + fetch-depth: 2 - name: Set up Node.js uses: actions/setup-node@v3 @@ -25,8 +24,77 @@ jobs: - name: Install NPM packages run: npm ci + - name: Restore Cache + uses: actions/cache/restore@v3 + with: + path: | + node_modules/.cache + key: tests-${{ hashFiles('**/package-lock.json') }}- + restore-keys: | + tests-${{ hashFiles('**/package-lock.json') }}- + enableCrossOsArchive: true + - name: Run Linting - run: npm run lint + run: npx turbo lint + env: + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} + TURBO_TEAM: ${{ secrets.TURBO_TEAM }} + + - name: Save Cache + uses: actions/cache/save@v3 + with: + path: | + node_modules/.cache + key: tests-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('node_modules/.cache') }} + enableCrossOsArchive: true + + unit-tests: + name: Tests on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + + steps: + - name: Git Checkout + uses: actions/checkout@v3 + with: + fetch-depth: 2 + + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + node-version-file: '.nvmrc' + cache: npm + + - name: Install NPM packages + run: npm ci + + - name: Restore Cache + uses: actions/cache/restore@v3 + with: + path: | + node_modules/.cache + key: tests-${{ hashFiles('**/package-lock.json') }}- + restore-keys: | + tests-${{ hashFiles('**/package-lock.json') }}- + enableCrossOsArchive: true + + - name: Run Unit Tests + run: npx turbo test:ci + env: + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} + TURBO_TEAM: ${{ secrets.TURBO_TEAM }} + + - name: Save Cache + uses: actions/cache/save@v3 + with: + path: | + node_modules/.cache + key: tests-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('node_modules/.cache') }} + enableCrossOsArchive: true build: name: Build on ${{ matrix.os }} @@ -40,6 +108,8 @@ jobs: steps: - name: Git Checkout uses: actions/checkout@v3 + with: + fetch-depth: 2 - name: Set up Node.js uses: actions/setup-node@v3 @@ -50,21 +120,29 @@ jobs: - name: Install NPM packages run: npm ci - - name: Restore Next.js cache + - name: Restore Cache uses: actions/cache/restore@v3 with: path: | .next/cache - key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}- + node_modules/.cache + key: build-${{ hashFiles('**/package-lock.json') }}- + restore-keys: | + build-${{ hashFiles('**/package-lock.json') }}- + enableCrossOsArchive: true - name: Build Next.js - run: npm run build + run: npx turbo build env: + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} + TURBO_TEAM: ${{ secrets.TURBO_TEAM }} NODE_OPTIONS: '--max_old_space_size=4096' - - name: Save Next.js cache + - name: Save Cache uses: actions/cache/save@v3 with: path: | .next/cache - key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('.next/cache/eslint') }} + node_modules/.cache + key: build-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('node_modules/.cache') }} + enableCrossOsArchive: true diff --git a/.gitignore b/.gitignore index 873453a7e4270..4a9bde3abf58c 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,16 @@ pages/en/blog/year-[0-9][0-9][0-9][0-9].md # ESLint Cache Files .eslintjscache .eslintmdcache + +# Jest +coverage +.swc + +# Storybook +storybook-static + +# Vercel Config +.vercel + +# TurboRepo +.turbo diff --git a/.prettierignore b/.prettierignore index 1f1524b90cdac..3fa9258b4d5ad 100644 --- a/.prettierignore +++ b/.prettierignore @@ -7,6 +7,9 @@ public/en/user-survey-report public/static/documents public/static/legacy +# We don't want to lint/prettify the Coverage Results +coverage + # MDX Plugin enforces Prettier formatting which should # be done in the future as we don't want to update the Markdown file # contents right now @@ -15,3 +18,6 @@ pages/**/*.md # Prettier's Handlebar parser is limited and chokes on some syntax features # https://github.com/prettier/prettier/issues/11834 scripts/release-post/template.hbs + +# We shouldn't lint statically generated Storybook files +storybook-static diff --git a/.storybook/constants.ts b/.storybook/constants.ts new file mode 100644 index 0000000000000..8d5ee42a41129 --- /dev/null +++ b/.storybook/constants.ts @@ -0,0 +1,34 @@ +import type { AppProps, NodeVersionData } from '../types'; +import englishMessages from '../i18n/locales/en.json'; + +const i18nData: AppProps['i18nData'] = { + currentLocale: { + code: 'en', + localName: 'English', + name: 'English', + langDir: 'ltr', + dateFormat: 'MM.DD.YYYY', + hrefLang: 'en-US', + enabled: true, + }, + localeMessages: englishMessages, +}; + +const nodeVersionData: NodeVersionData[] = [ + { + node: 'v19.8.1', + nodeNumeric: '19.8.1', + nodeMajor: 'v19.x', + npm: '9.5.1', + isLts: false, + }, + { + node: 'v18.15.0', + nodeNumeric: '18.15.0', + nodeMajor: 'v18.x', + npm: '9.5.0', + isLts: true, + }, +]; + +export const pageProps = { i18nData, nodeVersionData }; diff --git a/.storybook/main.ts b/.storybook/main.ts new file mode 100644 index 0000000000000..635301f873ac0 --- /dev/null +++ b/.storybook/main.ts @@ -0,0 +1,23 @@ +import type { StorybookConfig } from '@storybook/nextjs'; + +const config: StorybookConfig = { + stories: ['../components/**/*.stories.@(js|jsx|ts|tsx)'], + addons: [ + '@storybook/addon-links', + '@storybook/addon-essentials', + '@storybook/addon-interactions', + ], + framework: { + name: '@storybook/nextjs', + options: {}, + }, + features: { + storyStoreV7: true, + }, + docs: { + autodocs: 'tag', + }, + staticDirs: ['../public'], +}; + +export default config; diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx new file mode 100644 index 0000000000000..283ef3270bc53 --- /dev/null +++ b/.storybook/preview.tsx @@ -0,0 +1,55 @@ +import type { Preview } from '@storybook/react'; +import NextImage from 'next/image'; +import { ThemeProvider } from 'next-themes'; +import { NodeDataProvider } from '../providers/nodeDataProvider'; +import { LocaleProvider } from '../providers/localeProvider'; +import { SiteProvider } from '../providers/siteProvider'; +import openSans from '../util/openSans'; +import { pageProps } from './constants'; + +import '../styles/index.scss'; + +const preview: Preview = { + parameters: { + actions: { argTypesRegex: '^on[A-Z].*' }, + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/, + }, + }, + nextjs: { + router: { + basePath: '', + }, + }, + }, +}; + +export const decorators = [ + Story => ( + + + + + + + + + + + ), +]; + +Object.defineProperty(NextImage, 'default', { + configurable: true, + value: props => , +}); + +export default preview; diff --git a/.stylelintignore b/.stylelintignore index 988c07b6516f9..bc9418d5bb0fb 100644 --- a/.stylelintignore +++ b/.stylelintignore @@ -1,2 +1,11 @@ +# Next.js files build + +# Public Folder public + +# Jest +coverage + +# Storybook +storybook-static diff --git a/.stylelintrc b/.stylelintrc index a582d6bed2b5c..4d5d667a4b9fe 100644 --- a/.stylelintrc +++ b/.stylelintrc @@ -5,6 +5,7 @@ "order/properties-alphabetical-order": true, "no-descending-specificity": null, "scss/at-extend-no-missing-placeholder": null, + "scss/at-import-no-partial-leading-underscore": null, "selector-pseudo-class-no-unknown": [ true, { "ignorePseudoClasses": ["global"] } diff --git a/CODEOWNERS b/CODEOWNERS index 6dd3097940ca1..8d76c9111c38f 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1,5 +1,8 @@ # Default rules -* @nodejs/website + +- @nodejs/website # Node.js Release Blog Posts + /pages/en/blog/release @nodejs/releasers +/pages/en/blog/announcements @nodejs/releasers diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 08614e6c687bf..23c9cf520f327 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,7 +5,9 @@ Thank you for your interest in contributing to the Node.js Website. Before you p - [Code of Conduct](https://github.com/nodejs/node/blob/HEAD/CODE_OF_CONDUCT.md) - [Getting started](#getting-started) - [Vocabulary](#vocabulary) + - [Creating Components](#creating-components) - [Commit message guidelines](#commit-guidelines) + - [Unit Tests and Storybooks](#unit-tests-and-storybooks) - [Pull Request Policy](#pull-request-policy) - [Before merging](#before-merging) - [When merging](#when-merging) @@ -47,7 +49,7 @@ git checkout -b name-of-your-branch ```bash npm ci # installs this project's dependencies -npm run serve # starts a preview of your local changes +npx turbo serve # starts a preview of your local changes ``` 7. Perform a merge to sync your current branch with the upstream branch. @@ -57,10 +59,10 @@ git fetch upstream git merge upstream/main ``` -8. Run `npm run format` to confirm that linting, and formatting are passing. +8. Run `npx turbo format` to confirm that linting, and formatting are passing. ```bash -npm run format +npx turbo format ``` 9. Once you're happy with your changes, add and commit them to your branch, @@ -81,23 +83,83 @@ npm run format #### Serve/Build Options -- `npm run serve` runs Next.js's Local Development Server, listening by default on `http://localhost:3000/`. -- `npm run build` builds the Application on Production mode. The output is by default within `.next` folder. -- `npm run export` exports the website from the `.next` into a fully static website. The output is by default within `build` folder. +- `npx turbo serve` runs Next.js's Local Development Server, listening by default on `http://localhost:3000/`. +- `npx turbo build` builds the Application on Production mode. The output is by default within `.next` folder. +- `npx turbo export` exports the website from the `.next` into a fully static website. The output is by default within `build` folder. - This is what it's used to deploy the website on our current Node.js servers. -- `npm run start` starts a web server running serving the built content from `npm run build` +- `npx turbo start` starts a web server running serving the built content from `npx turbo build` #### Other CLI options We also offer other commands that offer you assistance during your local development -- `npm run lint` runs the linter for all the js files. - - `npm run lint:fix` attempts to fix any linting errors -- `npm run prettier` runs the prettier for all the js files. - - `npm run prettier:fix` attempts to fix any style errors -- `npm run format` formats and fixes the whole codebase -- `npm run scripts:release-post` generates a release post for the current release - - **Usage:** `npm run scripts:release-post -- --version=vXX.X.X --force` +- `npx turbo lint` runs the linter for all the js files. + - `npx turbo lint:fix` attempts to fix any linting errors +- `npx turbo prettier` runs the prettier for all the js files. + - `npx turbo prettier:fix` attempts to fix any style errors +- `npx turbo format` formats and fixes the whole codebase +- `npx turbo scripts:release-post` generates a release post for the current release + - **Usage:** `npx turbo scripts:release-post -- --version=vXX.X.X --force` +- `npx turbo storybook` starts Storybook's local server +- `npx turbo storybook:build` builds Storybook as a static web application for publishing +- `npx turbo test` runs jest (unit-tests) locally + +## Creating Components + +The Node.js Website uses **React.js** as a Frontend Library for the development of the Website. React allows us to create user interfaces with a modern take on Web Development. + +If you're unfamiliar with React or Web Development in general, we encourage a read before taking on complex issues and tasks as this repository is **not for educational purposes** and we expect you to have a basic understanding of the technologies used. + +We also recommend getting familiar with technologies such as [Next.js][], [MDX][], [SCSS][] and "concepts" such as "CSS Modules" and "CSS-in-JS". + +### Best Practices when creating a Component + +- All React Components should be placed within the `components` folder. +- Each Component should be placed whenever possible within a sub-folder, which we call the "Domain" of the Component + - The domain is the representation of where these Components belong to or where will be used. + - For example, Components used within Article Pages or that are part of the structure of an Article or the Article Layouts, should be placed within `components/Article` +- Each component should have its own folder with the name of the Component +- The structure of each component folder follows the following template: + ```text + - ComponentName + - index.tsx // the component itself + - index.module.scss // all styles of the component are placed there + - index.stories.tsx // component Storybook stories + - __tests__ // component tests (such as unit tests, etc) + - index.test.tsx + ``` +- React Hooks belonging to a single Component should be placed within the Component's folder + - If the Hook as a wider usability or can be used by other Components, then it should be placed at the root `hooks` folder. +- If the Component has "sub-components" they should follow the same philosophy as the Component itself. + - For example, if the Component `ComponentName` has a sub-component called `SubComponentName`, then it should be placed within `ComponentName/SubComponentName` + +#### How a new Component should look like when freshly created + +```tsx +import styles from './index.module.scss'; +import type { FC } from 'react'; + +type MyComponentProps = {}; // The types of the Props of your Component + +const MyComponent: FC = ({ prop1, prop2... }) => ( + // Actual code of my Component +); + +export default MyComponent; +``` + +### Best practices for Component development in general + +- Only spread props `{ ... }` on the definition of the Component (Avoid having a variable named `props`) +- Avoid importing `React`, only import the modules from React that you need +- When importing types use `import type { NameOfImport } from 'module'` +- When defining a Component use the `FC` type from React to define the type of the Component + - When using `children` as a prop, use the `FC>` type instead + - Alterenatively you can define your type as `type MyComponentProps = PropsWithChildren<{ my other props}>` +- Each Props type should be prefixed by the name of the Component +- Components should always be the `default` export of a React Component file +- Avoid using DOM/Web APIs/`document`/`window` API access within a React Component. Use utilities or Hooks when you need a Reactive state +- Avoid making your Component too big. Deconstruct it into smaller Components/Hooks whenever possible ## Commit Guidelines @@ -112,6 +174,57 @@ Commits should be signed. You can read more about [Commit Signing][] here. - Commit messages **must** start with a capital letter - Commit messages **must not** end with a period `.` +## Unit Tests and Storybooks + +Each new feature or bug fix should be accompanied by a unit test (when deemed valuable). We use [Jest][] as our test runner and [React Testing Library][] for our React unit tests. + +We also use [Storybook][] to document our components. Each component should have a storybook story that documents the component's usage. + +### General Guidelines for Unit Tests + +Unit Tests are fundamental to ensure that code changes do not disrupt the functionalities of the Node.js Website: + +- We recommend that unit tests are added for content covering `util`, `scripts`, `hooks` and `components` whenever possible. +- Unit Tests should cover that the functionality of a given change is working as expected. +- When creating unit tests for React components, we recommend that the tests cover all the possible states of the component. +- We also recommend mocking external dependencies, if unsure about how to mock a certain dependency, raise the question on your Pull Request. + - We recommend using [Jest's Mock Functions](https://jestjs.io/docs/en/mock-functions) for mocking dependencies. + - We recommend using [Jest's Mock Modules](https://jestjs.io/docs/en/manual-mocks) for mocking dependencies that are not available on the Node.js runtime. + - Common Providers and Contexts from the lifecycle of our App, such as [`react-intl`][] should not be mocked but given an empty or fake context whenever possible. +- We recommend reading previous unit tests from the codebase for inspiration and code guidelines. + +### General Guidelines for Storybooks + +Storybooks are an essential part of our development process. They help us to document our components and to ensure that the components are working as expected. + +They also allow Developers to preview Components and be able to test them manually/individually to the smallest unit of the Application. (The individual Component itself). + +**Storybooks should be fully typed and follow the following template:** + +```tsx +import type { Meta as MetaObj, StoryObj } from '@storybook/react'; +import NameOfComponent from './index'; + +type Story = StoryObj; +type Meta = MetaObj; + +// If the component has any props that are interactable, they should be passed here +// We recommend reading Storybook docs for args: https://storybook.js.org/docs/react/writing-stories/args +export const Default: Story = {}; + +// If the Component has more than one State/Layout/Variant, there should be one Story for each variant +export const AnotherStory: Story = { + args: {}, +}; + +export default { component: NameOfComponent } as Meta; +``` + +- Stories should have `args` whenever possible, we want to be able to test the different aspects of a Component +- Please follow the template above to keep the Storybooks as consistent as possible +- We recommend reading previous Storybooks from the codebase for inspiration and code guidelines. +- If you need to decorate/wrap your Component/Story with a Container/Provider, please use [Storybook Decorators](https://storybook.js.org/docs/react/writing-stories/decorators) + ## Pull Request Policy ### Before merging @@ -157,21 +270,23 @@ More details about Collaboration can be found in the [COLLABORATOR_GUIDE.md](./C ## Developer's Certificate of Origin 1.1 ``` + By contributing to this project, I certify that: -* (a) The contribution was created in whole or in part by me and I have the right to +- (a) The contribution was created in whole or in part by me and I have the right to submit it under the open source license indicated in the file; or -* (b) The contribution is based upon previous work that, to the best of my knowledge, +- (b) The contribution is based upon previous work that, to the best of my knowledge, is covered under an appropriate open source license and I have the right under that license to submit that work with modifications, whether created in whole or in part by me, under the same open source license (unless I am permitted to submit under a different license), as indicated in the file; or -* (c) The contribution was provided directly to me by some other person who certified +- (c) The contribution was provided directly to me by some other person who certified (a), (b) or (c) and I have not modified it. -* (d) I understand and agree that this project and the contribution are public and that +- (d) I understand and agree that this project and the contribution are public and that a record of the contribution (including all personal information I submit with it, including my sign-off) is maintained indefinitely and may be redistributed consistent with this project or the open source license(s) involved. + ``` ## Remarks @@ -181,3 +296,10 @@ If something is missing here, or you feel something is not well described, feel [`squash`]: https://help.github.com/en/articles/about-pull-request-merges#squash-and-merge-your-pull-request-commits [Conventional Commits]: https://www.conventionalcommits.org/ [Commit Signing]: https://docs.github.com/en/authentication/managing-commit-signature-verification/signing-commits +[Jest]: https://jestjs.io/ +[React Testing Library]: https://testing-library.com/docs/react-testing-library/intro/ +[Storybook]: https://storybook.js.org/ +[`react-intl`]: https://formatjs.io/docs/react-intl/ +[Next.js]: https://nextjs.org/ +[MDX]: https://mdxjs.com/ +[SCSS]: https://sass-lang.com/ diff --git a/README.md b/README.md index c9d96810882a2..5587599bdc9fa 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ ```bash npm ci -npm run serve +npx turbo serve # listening at localhost:3000 ``` diff --git a/components/AnchoredHeading.tsx b/components/AnchoredHeading.tsx index 89f2f4ece9271..c19bc9cd7f638 100644 --- a/components/AnchoredHeading.tsx +++ b/components/AnchoredHeading.tsx @@ -1,4 +1,4 @@ -import type { PropsWithChildren } from 'react'; +import type { FC, PropsWithChildren } from 'react'; type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6; @@ -25,7 +25,7 @@ type AnchorHeadingProps = PropsWithChildren<{ // so we can just use '-- --' to quote the anchor name inside it. const COMMENT_FOR_HEADANCHOR = /--\x20?([\w\x20-]+)\x20?--/; -const AnchoredHeading = ({ children, level, id }: AnchorHeadingProps) => { +const AnchoredHeading: FC = ({ level, id, children }) => { const HeadingLevelTag = `h${level}` as any; let sanitizedId = diff --git a/components/Api/DataTag/__tests__/__snapshots__/index.test.tsx.snap b/components/Api/DataTag/__tests__/__snapshots__/index.test.tsx.snap new file mode 100644 index 0000000000000..2bd80b1a138f5 --- /dev/null +++ b/components/Api/DataTag/__tests__/__snapshots__/index.test.tsx.snap @@ -0,0 +1,34 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Data Tag component renders with blue background color when tag is 'M' 1`] = ` +
+ + M + +
+`; + +exports[`Data Tag component renders with red background color when tag is 'E' 1`] = ` +
+ + E + +
+`; + +exports[`Data Tag component renders with yellow background color when tag is 'C' 1`] = ` +
+ + C + +
+`; diff --git a/components/Api/DataTag/__tests__/index.test.tsx b/components/Api/DataTag/__tests__/index.test.tsx new file mode 100644 index 0000000000000..8e4d5c7cda5f3 --- /dev/null +++ b/components/Api/DataTag/__tests__/index.test.tsx @@ -0,0 +1,25 @@ +import { render } from '@testing-library/react'; +import DataTag from '../index'; + +describe('Data Tag component', () => { + it(`renders with red background color when tag is 'E'`, () => { + const { container } = render(); + + expect(container).toHaveStyle('background-color: var(--danger6)'); + expect(container).toMatchSnapshot(); + }); + + it(`renders with yellow background color when tag is 'C'`, () => { + const { container } = render(); + + expect(container).toHaveStyle('background-color: var(--warning4)'); + expect(container).toMatchSnapshot(); + }); + + it(`renders with blue background color when tag is 'M'`, () => { + const { container } = render(); + + expect(container).toHaveStyle('background-color: var(--info6)'); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/components/Api/DataTag/index.module.scss b/components/Api/DataTag/index.module.scss new file mode 100644 index 0000000000000..ce7fcd95408f9 --- /dev/null +++ b/components/Api/DataTag/index.module.scss @@ -0,0 +1,26 @@ +.dataTag { + border-radius: 50%; + color: #fff; + display: inline-block; + font-family: var(--sans); + font-size: 1.4rem; + font-weight: var(--font-weight-light); + height: 2.4rem; + line-height: 2.4rem; + margin-right: 0.8rem; + min-width: 2.4rem; + text-align: center; + width: 2.4rem; + + &[data-tag='C'] { + background-color: var(--warning4); + } + + &[data-tag='E'] { + background-color: var(--danger6); + } + + &[data-tag='M'] { + background-color: var(--info6); + } +} diff --git a/components/Api/DataTag/index.stories.ts b/components/Api/DataTag/index.stories.ts new file mode 100644 index 0000000000000..fda26da0f4f1c --- /dev/null +++ b/components/Api/DataTag/index.stories.ts @@ -0,0 +1,25 @@ +import DataTag from './index'; +import type { Meta as MetaObj, StoryObj } from '@storybook/react'; + +type Story = StoryObj; +type Meta = MetaObj; + +export const Red: Story = { + args: { + tag: 'E', + }, +}; + +export const Yellow: Story = { + args: { + tag: 'C', + }, +}; + +export const Blue: Story = { + args: { + tag: 'M', + }, +}; + +export default { component: DataTag } as Meta; diff --git a/components/Api/DataTag/index.tsx b/components/Api/DataTag/index.tsx new file mode 100644 index 0000000000000..049d03f9ab2a5 --- /dev/null +++ b/components/Api/DataTag/index.tsx @@ -0,0 +1,12 @@ +import styles from './index.module.scss'; +import type { FC } from 'react'; + +type DataTagProps = { tag: 'E' | 'C' | 'M' }; + +const DataTag: FC = ({ tag }) => ( + + {tag} + +); + +export default DataTag; diff --git a/components/Article/Alert/__tests__/__snapshots__/index.test.tsx.snap b/components/Article/Alert/__tests__/__snapshots__/index.test.tsx.snap new file mode 100644 index 0000000000000..abc4e76fd5f6a --- /dev/null +++ b/components/Article/Alert/__tests__/__snapshots__/index.test.tsx.snap @@ -0,0 +1,19 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Alert component should render correctly 1`] = ` +
+
+
+`; + +exports[`Alert component should support passing children into the component 1`] = ` +
+
+ This is an alert +
+
+`; diff --git a/components/Article/Alert/__tests__/index.test.tsx b/components/Article/Alert/__tests__/index.test.tsx new file mode 100644 index 0000000000000..b5e7b898f47ad --- /dev/null +++ b/components/Article/Alert/__tests__/index.test.tsx @@ -0,0 +1,16 @@ +import { render } from '@testing-library/react'; +import Alert from '../index'; + +describe('Alert component', () => { + it('should render correctly', () => { + const { container } = render(); + + expect(container).toMatchSnapshot(); + }); + + it('should support passing children into the component', () => { + const { container } = render(This is an alert); + + expect(container).toMatchSnapshot(); + }); +}); diff --git a/components/Article/Alert/index.module.scss b/components/Article/Alert/index.module.scss new file mode 100644 index 0000000000000..33a3b989967e1 --- /dev/null +++ b/components/Article/Alert/index.module.scss @@ -0,0 +1,16 @@ +.alert { + background: var(--purple5); + border-radius: 5px; + color: var(--color-fill-top-nav); + font-size: var(--font-size-body1); + font-weight: var(--font-weight-bold); + margin: var(--space-16) auto; + max-width: 90vw; + padding: var(--space-12); + position: relative; + + a, + a:hover { + color: white; + } +} diff --git a/components/Article/Alert/index.stories.ts b/components/Article/Alert/index.stories.ts new file mode 100644 index 0000000000000..cc8b0652d01b0 --- /dev/null +++ b/components/Article/Alert/index.stories.ts @@ -0,0 +1,13 @@ +import Alert from './index'; +import type { Meta as MetaObj, StoryObj } from '@storybook/react'; + +type Story = StoryObj; +type Meta = MetaObj; + +export const Default: Story = { + args: { + children: 'This is an alert', + }, +}; + +export default { component: Alert } as Meta; diff --git a/components/Article/Alert/index.tsx b/components/Article/Alert/index.tsx new file mode 100644 index 0000000000000..fc00b6c16ffad --- /dev/null +++ b/components/Article/Alert/index.tsx @@ -0,0 +1,8 @@ +import styles from './index.module.scss'; +import type { FC, PropsWithChildren } from 'react'; + +const Alert: FC = ({ children }) => ( +
{children}
+); + +export default Alert; diff --git a/components/Article/AuthorList/Author/__tests__/__snapshots__/index.test.tsx.snap b/components/Article/AuthorList/Author/__tests__/__snapshots__/index.test.tsx.snap new file mode 100644 index 0000000000000..f7d9312442eb7 --- /dev/null +++ b/components/Article/AuthorList/Author/__tests__/__snapshots__/index.test.tsx.snap @@ -0,0 +1,53 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Author component does not render without a username 1`] = ` +
+
  • + + + +
  • +
    +`; + +exports[`Author component renders correctly 1`] = ` +
    +
  • + + test-author + +
  • +
    +`; diff --git a/components/Article/AuthorList/Author/__tests__/index.test.tsx b/components/Article/AuthorList/Author/__tests__/index.test.tsx new file mode 100644 index 0000000000000..b1db41e5d4742 --- /dev/null +++ b/components/Article/AuthorList/Author/__tests__/index.test.tsx @@ -0,0 +1,24 @@ +import { render } from '@testing-library/react'; +import { IntlProvider } from 'react-intl'; +import Author from '..'; + +describe('Author component', () => { + it('renders correctly', () => { + const username = 'test-author'; + const { container } = render( + {}}> + + + ); + expect(container).toMatchSnapshot(); + }); + + it('does not render without a username', () => { + const { container } = render( + {}}> + + + ); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/components/Article/AuthorList/Author/index.module.scss b/components/Article/AuthorList/Author/index.module.scss new file mode 100644 index 0000000000000..416b6e8a802ec --- /dev/null +++ b/components/Article/AuthorList/Author/index.module.scss @@ -0,0 +1,16 @@ +.link { + &:hover img { + transform: scale(1.1); + } + + img { + background-size: contain; + border: 2px solid var(--brand-light); + border-radius: 100%; + display: block; + height: 30px; + margin-top: 5px; + transition: all 0.2s ease-in-out; + width: 30px; + } +} diff --git a/components/Article/AuthorList/Author/index.tsx b/components/Article/AuthorList/Author/index.tsx new file mode 100644 index 0000000000000..4adbc11d348ca --- /dev/null +++ b/components/Article/AuthorList/Author/index.tsx @@ -0,0 +1,48 @@ +import { useState } from 'react'; +import { useIntl } from 'react-intl'; +import Image from 'next/image'; +import styles from './index.module.scss'; +import type { FC } from 'react'; + +type AuthorProps = { username: string; size?: number }; + +const Author: FC = ({ username, size }) => { + // Clean up username and build links. + const githubUserName = username.trim(); + const githubLink = `https://github.com/${githubUserName}`; + const githubImgLink = `https://github.com/${githubUserName}.png?size=${size}`; + + const intl = useIntl(); + + const [authorImg, setAuthorImg] = useState(githubImgLink); + + const translation = intl.formatMessage( + { id: 'components.article.author.githubLinkLabel' }, + { username } + ); + + return ( +
  • + + {githubUserName} setAuthorImg('/placeholder-img.png')} + /> + +
  • + ); +}; + +export default Author; diff --git a/components/Article/AuthorList/__tests__/__snapshots__/authors-list.test.tsx.snap b/components/Article/AuthorList/__tests__/__snapshots__/authors-list.test.tsx.snap new file mode 100644 index 0000000000000..a5b6115c59044 --- /dev/null +++ b/components/Article/AuthorList/__tests__/__snapshots__/authors-list.test.tsx.snap @@ -0,0 +1,55 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AuthorsList component renders correctly 1`] = ` +
    +
    + components.article.authorList.title + +
    +
    +`; diff --git a/components/Article/AuthorList/__tests__/authors-list.test.tsx b/components/Article/AuthorList/__tests__/authors-list.test.tsx new file mode 100644 index 0000000000000..c1cb4aaff940b --- /dev/null +++ b/components/Article/AuthorList/__tests__/authors-list.test.tsx @@ -0,0 +1,15 @@ +import { render } from '@testing-library/react'; +import { IntlProvider } from 'react-intl'; +import AuthorsList from '..'; + +describe('AuthorsList component', () => { + it('renders correctly', () => { + const authors = ['test-author', 'another-test-author']; + const { container } = render( + {}}> + + + ); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/components/Article/AuthorList/index.module.scss b/components/Article/AuthorList/index.module.scss new file mode 100644 index 0000000000000..2d3c3c4b47f0a --- /dev/null +++ b/components/Article/AuthorList/index.module.scss @@ -0,0 +1,26 @@ +.authorList { + color: var(--color-text-secondary); + display: flex; + flex-direction: column; + font-size: var(--font-size-body2); + font-weight: var(--font-weight-bold); + margin-bottom: var(--space-24); + max-width: 600px; + text-transform: uppercase; + + ul { + display: flex; + flex-wrap: wrap; + list-style: none; + margin: 0; + padding: 0; + + li { + margin: 0.5rem 0.5rem 0.5rem 0; + + &:first-of-type a { + margin-left: 0; + } + } + } +} diff --git a/components/Article/AuthorList/index.stories.tsx b/components/Article/AuthorList/index.stories.tsx new file mode 100644 index 0000000000000..2f82bebbdd837 --- /dev/null +++ b/components/Article/AuthorList/index.stories.tsx @@ -0,0 +1,13 @@ +import AuthorList from './index'; +import type { Meta as MetaObj, StoryObj } from '@storybook/react'; + +type Story = StoryObj; +type Meta = MetaObj; + +export const Default: Story = { + args: { + authors: ['flaviocopes', 'MarkPieszak', 'mcollina', 'unavailable-author'], + }, +}; + +export default { component: AuthorList } as Meta; diff --git a/components/Article/AuthorList/index.tsx b/components/Article/AuthorList/index.tsx new file mode 100644 index 0000000000000..0dff81c20be65 --- /dev/null +++ b/components/Article/AuthorList/index.tsx @@ -0,0 +1,25 @@ +import { FormattedMessage } from 'react-intl'; +import Author from './Author'; +import styles from './index.module.scss'; +import type { FC } from 'react'; + +type AuthorListProps = { authors: string[] }; + +const AuthorList: FC = ({ authors }) => { + if (authors.length) { + return ( +
    + +
      + {authors.map(author => ( + + ))} +
    +
    + ); + } + + return null; +}; + +export default AuthorList; diff --git a/components/Article/BlockQuote/__tests__/__snapshots__/index.test.tsx.snap b/components/Article/BlockQuote/__tests__/__snapshots__/index.test.tsx.snap new file mode 100644 index 0000000000000..d26d87cee0ea7 --- /dev/null +++ b/components/Article/BlockQuote/__tests__/__snapshots__/index.test.tsx.snap @@ -0,0 +1,34 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`BlockQuote component should render correctly 1`] = ` +
    +
    +
    +`; + +exports[`BlockQuote component should support passing children into the component 1`] = ` +
    +
    + This is a block quote +
    +
    +`; + +exports[`BlockQuote component should support passing multiple children into the component 1`] = ` +
    +
    +

    + This is a block quote +

    +

    + This is a block quote +

    +
    +
    +`; diff --git a/components/Article/BlockQuote/__tests__/index.test.tsx b/components/Article/BlockQuote/__tests__/index.test.tsx new file mode 100644 index 0000000000000..8f5909ef68c66 --- /dev/null +++ b/components/Article/BlockQuote/__tests__/index.test.tsx @@ -0,0 +1,29 @@ +import { render } from '@testing-library/react'; +import BlockQuote from '../index'; + +describe('BlockQuote component', () => { + it('should render correctly', () => { + const { container } = render(
    ); + + expect(container).toMatchSnapshot(); + }); + + it('should support passing children into the component', () => { + const { container } = render( +
    This is a block quote
    + ); + + expect(container).toMatchSnapshot(); + }); + + it('should support passing multiple children into the component', () => { + const { container } = render( +
    +

    This is a block quote

    +

    This is a block quote

    +
    + ); + + expect(container).toMatchSnapshot(); + }); +}); diff --git a/components/Article/BlockQuote/index.module.scss b/components/Article/BlockQuote/index.module.scss new file mode 100644 index 0000000000000..5874a1fc7db72 --- /dev/null +++ b/components/Article/BlockQuote/index.module.scss @@ -0,0 +1,22 @@ +.blockQuote { + background-color: var(--color-fill-banner); + border-radius: 5px; + color: var(--color-text-primary); + font-size: var(--font-size-body1); + margin: var(--space-16) auto; + max-width: 90vw; + padding: var(--space-12); + position: relative; + + @media (max-width: 900px) { + margin: var(--space-08) auto; + } + + p:first-child { + margin-top: 0; + } + + p:last-child { + margin: 0; + } +} diff --git a/components/Article/BlockQuote/index.stories.tsx b/components/Article/BlockQuote/index.stories.tsx new file mode 100644 index 0000000000000..128405600d05c --- /dev/null +++ b/components/Article/BlockQuote/index.stories.tsx @@ -0,0 +1,22 @@ +import BlockQuote from './index'; +import type { Meta as MetaObj, StoryObj } from '@storybook/react'; + +type Story = StoryObj; +type Meta = MetaObj; + +export const Default: Story = { + args: { + children: 'This is a block quote', + }, +}; + +export const MultipleParagraph: Story = { + args: { + children: [ +

    This is a block quote 1

    , +

    This is a block quote 2

    , + ], + }, +}; + +export default { component: BlockQuote } as Meta; diff --git a/components/Article/BlockQuote/index.tsx b/components/Article/BlockQuote/index.tsx new file mode 100644 index 0000000000000..47681f83c5901 --- /dev/null +++ b/components/Article/BlockQuote/index.tsx @@ -0,0 +1,8 @@ +import styles from './index.module.scss'; +import type { FC, PropsWithChildren } from 'react'; + +const BlockQuote: FC = ({ children }) => ( +
    {children}
    +); + +export default BlockQuote; diff --git a/components/Article/EditLink/__tests__/__snapshots__/index.test.tsx.snap b/components/Article/EditLink/__tests__/__snapshots__/index.test.tsx.snap new file mode 100644 index 0000000000000..5e39c384a0b3e --- /dev/null +++ b/components/Article/EditLink/__tests__/__snapshots__/index.test.tsx.snap @@ -0,0 +1,61 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EditLink component edit mode renders correctly 1`] = ` + +`; + +exports[`EditLink component renders without a relative path 1`] = `
    `; + +exports[`EditLink component translate mode renders correctly 1`] = ` + +`; diff --git a/components/Article/EditLink/__tests__/index.test.tsx b/components/Article/EditLink/__tests__/index.test.tsx new file mode 100644 index 0000000000000..cf0104a288114 --- /dev/null +++ b/components/Article/EditLink/__tests__/index.test.tsx @@ -0,0 +1,81 @@ +import { render, screen } from '@testing-library/react'; +import EditLink from './../index'; +import { LocaleProvider } from '../../../../providers/localeProvider'; +import type { AppProps } from '../../../../types'; + +jest.mock('next/router', () => ({ + useRouter: jest.fn().mockImplementation(() => ({ + asPath: '', + })), +})); + +const absolutePath = + 'https://github.com/nodejs/nodejs.org/edit/major/website-redesign/pages/en/get-involved/contribute.md'; +const relativePath = 'get-involved/contribute.md'; +const editPath = 'pages/en/get-involved/contribute.md'; + +const i18nDataEditMode = { + currentLocale: { + code: 'en', + }, + localeMessages: { + 'components.article.editLink.title.edit': 'Edit this page on GitHub', + }, +} as unknown as AppProps['i18nData']; + +const i18nDataTranslateMode = { + currentLocale: { + code: 'xx', + }, + localeMessages: { + 'components.article.editLink.title.translate': + 'Interested to help with translations?', + }, +} as unknown as AppProps['i18nData']; + +describe('EditLink component', () => { + it('edit mode renders correctly', () => { + const { container } = render( + + + + ); + expect(container).toMatchSnapshot(); + }); + + it('translate mode renders correctly', () => { + const { container } = render( + + + + ); + expect(container).toMatchSnapshot(); + }); + + it('renders without a relative path', () => { + const { container } = render( + + + + ); + expect(container).toMatchSnapshot(); + }); + + it('produces correct relative path', () => { + render( + + + + ); + expect(screen.getByRole('link')).toHaveAttribute('href', absolutePath); + }); + + it('produces correct edit path', () => { + render( + + + + ); + expect(screen.getByRole('link')).toHaveAttribute('href', absolutePath); + }); +}); diff --git a/components/Article/EditLink/index.module.scss b/components/Article/EditLink/index.module.scss new file mode 100644 index 0000000000000..ae12a140d9d09 --- /dev/null +++ b/components/Article/EditLink/index.module.scss @@ -0,0 +1,30 @@ +.edit { + display: flex; + flex-wrap: wrap; + margin-top: var(--space-48); + + a { + color: var(--color-text-secondary); + font-family: var(--sans-serif); + font-size: var(--font-size-body2); + font-weight: var(--font-weight-regular); + margin-left: 0; + text-decoration: none !important; + text-transform: uppercase; + vertical-align: middle; + + span { + font-weight: var(--font-weight-regular); + vertical-align: middle; + } + + &:hover { + color: var(--brand-light); + } + + svg { + margin-left: 0.5rem; + vertical-align: middle; + } + } +} diff --git a/components/Article/EditLink/index.stories.tsx b/components/Article/EditLink/index.stories.tsx new file mode 100644 index 0000000000000..f64368e4817a7 --- /dev/null +++ b/components/Article/EditLink/index.stories.tsx @@ -0,0 +1,15 @@ +import EditLink from './index'; +import type { Meta as MetaObj, StoryObj } from '@storybook/react'; + +type Story = StoryObj; +type Meta = MetaObj; + +export const Edit: Story = { + args: { + relativePath: 'get-involved/contribute.md', + }, +}; + +export const Empty: Story = {}; + +export default { component: EditLink } as Meta; diff --git a/components/Article/EditLink/index.tsx b/components/Article/EditLink/index.tsx new file mode 100644 index 0000000000000..0cae2eccd77ce --- /dev/null +++ b/components/Article/EditLink/index.tsx @@ -0,0 +1,76 @@ +import { FaPencilAlt } from 'react-icons/fa'; +import { FormattedMessage } from 'react-intl'; +import styles from './index.module.scss'; +import { useLocale } from './../../../hooks/useLocale'; +import type { FC } from 'react'; + +type EditLinkProps = { + absolutePath?: string; + relativePath?: string; + editPath?: string; +}; + +// TODO(HinataKah0): Change branch from major/website-redesign to main + +const baseEditURL = + 'https://github.com/nodejs/nodejs.org/edit/major/website-redesign'; + +const translationReadmeURL = + 'https://github.com/nodejs/nodejs.org/blob/major/website-redesign/TRANSLATION.md'; + +const translationKeyPrefix = 'components.article.editLink.title'; + +type EditLinkParams = { + translationKey: string; + href: string; +}; + +const getEditLinkParams = ( + { absolutePath, relativePath, editPath }: EditLinkProps, + lang: string +): EditLinkParams => { + if (lang === 'en') { + // Initial content development is done on GitHub in English + return { + translationKey: `${translationKeyPrefix}.edit`, + href: + absolutePath || + (relativePath + ? `${baseEditURL}/pages/en/${relativePath}` + : `${baseEditURL}/${editPath}`), + }; + } + + return { + translationKey: `${translationKeyPrefix}.translate`, + href: translationReadmeURL, + }; +}; + +const EditLink: FC = ({ + absolutePath, + relativePath, + editPath, +}) => { + const { currentLocale } = useLocale(); + + if (!relativePath && !editPath && !absolutePath) { + return null; + } + + const editLinkParams = getEditLinkParams( + { absolutePath, relativePath, editPath }, + currentLocale.code + ); + + return ( + + ); +}; + +export default EditLink; diff --git a/components/Blog/BlogCard/__tests__/__snapshots__/index.test.tsx.snap b/components/Blog/BlogCard/__tests__/__snapshots__/index.test.tsx.snap new file mode 100644 index 0000000000000..430876477be63 --- /dev/null +++ b/components/Blog/BlogCard/__tests__/__snapshots__/index.test.tsx.snap @@ -0,0 +1,46 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`BlogCard component renders correctly 1`] = ` +
    +
    + +
    +

    + April 21, 2023 +

    +

    + components.blog.blogCard.author.by + + + Bat Man + +

    +
    +
    +
    +`; diff --git a/components/Blog/BlogCard/__tests__/index.test.tsx b/components/Blog/BlogCard/__tests__/index.test.tsx new file mode 100644 index 0000000000000..3a2b31ef998eb --- /dev/null +++ b/components/Blog/BlogCard/__tests__/index.test.tsx @@ -0,0 +1,32 @@ +import { render } from '@testing-library/react'; +import { IntlProvider } from 'react-intl'; + +import BlogCard from '..'; + +jest.mock('next/router', () => ({ + useRouter: jest.fn().mockReturnValue({}), +})); + +jest.mock('../../../../hooks/useLocale', () => ({ + useLocale: jest.fn().mockReturnValue({ + currentLocale: {}, + }), +})); + +describe('BlogCard component', () => { + it('renders correctly', () => { + const { container } = render( + {}}> + + + ); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/components/Blog/BlogCard/index.module.scss b/components/Blog/BlogCard/index.module.scss new file mode 100644 index 0000000000000..c16ec8aeabb9f --- /dev/null +++ b/components/Blog/BlogCard/index.module.scss @@ -0,0 +1,72 @@ +.blogCard { + display: flex; + flex-direction: column; + padding: var(--space-32) var(--space-24) var(--space-32) 0; + + @media (max-width: 900px) { + padding: var(--space-12) var(--space-24); + } + + .title { + background-color: var(--color-blog-card-background); + border-radius: 5px; + display: flex; + flex: 1 1 0px; + flex-direction: column; + padding: 1rem 1.5rem; + + a { + color: var(--color-text-accent); + font-size: 2em; + margin-bottom: 10px; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + } + + .metadata { + align-self: flex-start; + background-color: var(--color-dropdown-hover); + border-radius: 1rem; + display: flex; + margin-top: auto; + padding: 0.25rem; + + span, + a { + color: var(--color-text-high-contrast); + font-size: var(--font-size-body3); + padding: 0.125rem 0.5rem; + + &.category { + background-color: var(--color-dropdown-background); + border-radius: 1rem; + margin: 0; + } + } + } + + .content { + justify-self: flex-end; + + h4 { + margin: 0; + margin-top: 7px; + opacity: 0.7; + } + + p { + margin: 7px 0; + opacity: 0.8; + + li { + display: inline; + list-style: none; + margin: 0 3px; + } + } + } +} diff --git a/components/Blog/BlogCard/index.stories.tsx b/components/Blog/BlogCard/index.stories.tsx new file mode 100644 index 0000000000000..5a7f848ab6eee --- /dev/null +++ b/components/Blog/BlogCard/index.stories.tsx @@ -0,0 +1,18 @@ +import BlockQuote from './index'; +import type { Meta as MetaObj, StoryObj } from '@storybook/react'; + +type Story = StoryObj; +type Meta = MetaObj; + +export const Default: Story = { + args: { + author: 'Bat Man', + category: 'category-mock', + date: '2023-04-21 23:40:56.77', + slug: '/blog/category-mock/sample-blog', + title: 'Sample Test Blog', + readingTime: '1 min read', + }, +}; + +export default { component: BlockQuote } as Meta; diff --git a/components/Blog/BlogCard/index.tsx b/components/Blog/BlogCard/index.tsx new file mode 100644 index 0000000000000..147ac59b1340e --- /dev/null +++ b/components/Blog/BlogCard/index.tsx @@ -0,0 +1,50 @@ +import { FormattedDate, FormattedMessage } from 'react-intl'; +import styles from './index.module.scss'; +import LocalizedLink from '../../LocalizedLink'; +import navigation from '../../../navigation.json'; +import type { BlogPost } from '../../../types'; +import type { FC } from 'react'; + +const getBlogCategoryUrl = (category: string): string => + `${navigation.blog.link}/${category}/`; + +type BlogCardProps = Omit; + +const BlogCard: FC = ({ + title, + author, + date, + category, + readingTime, + slug, +}) => ( +
    +
    + {title} +
    + {category && ( + + {category} + + )} + {readingTime} +
    +
    +
    +

    + +

    + {author && ( +

    + {' '} + {author} +

    + )} +
    +
    +); + +export default BlogCard; diff --git a/components/Common/AnimatedPlaceholder/__tests__/__snapshots__/index.test.tsx.snap b/components/Common/AnimatedPlaceholder/__tests__/__snapshots__/index.test.tsx.snap new file mode 100644 index 0000000000000..cdb93e8192bcb --- /dev/null +++ b/components/Common/AnimatedPlaceholder/__tests__/__snapshots__/index.test.tsx.snap @@ -0,0 +1,35 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AnimatedPlaceholder component should render correctly with default skeleton 1`] = ` +
    +
    +
    +
    +
    +
    +
    +
    +
    +`; + +exports[`AnimatedPlaceholder component should support passing loader skeleton from outside 1`] = ` +
    +
    +
    +
    +
    +`; diff --git a/components/Common/AnimatedPlaceholder/__tests__/index.test.tsx b/components/Common/AnimatedPlaceholder/__tests__/index.test.tsx new file mode 100644 index 0000000000000..a6ed849a159e2 --- /dev/null +++ b/components/Common/AnimatedPlaceholder/__tests__/index.test.tsx @@ -0,0 +1,20 @@ +import { render } from '@testing-library/react'; +import AnimatedPlaceholder from './../index'; + +describe('AnimatedPlaceholder component', () => { + it('should render correctly with default skeleton', () => { + const { container } = render(); + + expect(container).toMatchSnapshot(); + }); + + it('should support passing loader skeleton from outside', () => { + const { container } = render( + +
    + + ); + + expect(container).toMatchSnapshot(); + }); +}); diff --git a/components/Common/AnimatedPlaceholder/index.module.scss b/components/Common/AnimatedPlaceholder/index.module.scss new file mode 100644 index 0000000000000..43182e38ab6b1 --- /dev/null +++ b/components/Common/AnimatedPlaceholder/index.module.scss @@ -0,0 +1,47 @@ +@keyframes placeHolderShimmer { + 0% { + background-position: -468px 0; + } + 100% { + background-position: 468px 0; + } +} + +.animatedBackground, +%animated-background { + animation-duration: 1.25s; + animation-fill-mode: forwards; + animation-iteration-count: infinite; + animation-name: placeHolderShimmer; + animation-timing-function: linear; + background: #f6f6f6; + background: linear-gradient(to right, #f6f6f6 8%, #f0f0f0 18%, #f6f6f6 33%); + background-size: 800px 104px; + height: 96px; + position: relative; +} + +.placeholder { + display: flex; + width: 100%; + + &Image { + @extend %animated-background; + + height: 40px; + margin-right: 5px; + min-width: 40px; + } + + &Text { + width: 100%; + } + + &TextLine { + @extend %animated-background; + + height: 10px; + margin: 4px 0; + width: 100%; + } +} diff --git a/components/Common/AnimatedPlaceholder/index.stories.tsx b/components/Common/AnimatedPlaceholder/index.stories.tsx new file mode 100644 index 0000000000000..5c5dda62aab11 --- /dev/null +++ b/components/Common/AnimatedPlaceholder/index.stories.tsx @@ -0,0 +1,9 @@ +import AnimatedPlaceholder from './index'; +import type { Meta as MetaObj, StoryObj } from '@storybook/react'; + +type Story = StoryObj; +type Meta = MetaObj; + +export const Default: Story = {}; + +export default { component: AnimatedPlaceholder } as Meta; diff --git a/components/Common/AnimatedPlaceholder/index.tsx b/components/Common/AnimatedPlaceholder/index.tsx new file mode 100644 index 0000000000000..b7f785270e20b --- /dev/null +++ b/components/Common/AnimatedPlaceholder/index.tsx @@ -0,0 +1,31 @@ +import styles from './index.module.scss'; +import type { FC, ReactNode } from 'react'; + +type AnimatedPlaceholderProps = { + children?: ReactNode; + width?: number; + height?: number; +}; + +const AnimatedPlaceholder: FC = ({ + children, + width, + height, +}) => ( +
    + {children || ( + <> +
    +
    +
    +
    +
    + + )} +
    +); + +export default AnimatedPlaceholder; diff --git a/components/Common/Banner/__tests__/index.test.tsx b/components/Common/Banner/__tests__/index.test.tsx new file mode 100644 index 0000000000000..f384b272f4cd2 --- /dev/null +++ b/components/Common/Banner/__tests__/index.test.tsx @@ -0,0 +1,145 @@ +import { render, screen } from '@testing-library/react'; +import { LocaleProvider } from '../../../../providers/localeProvider'; +import Banner from '../index'; +import type { AppProps, WebsiteBanner } from '../../../../types'; + +jest.mock('isomorphic-dompurify', () => ({ + sanitize: jest.fn((html: string) => html), +})); + +const bannersIndex: WebsiteBanner = { + endDate: '', + link: 'test/banner/link', + text: 'Test banner text', + startDate: '', +}; + +const i18nData = { currentLocale: { code: 'en' } } as AppProps['i18nData']; + +describe('Tests for Header component', () => { + it('renders when today between startDate and endDate', () => { + const beforeToday = new Date(); + beforeToday.setDate(beforeToday.getDate() - 1); + const afterToday = new Date(); + afterToday.setDate(afterToday.getDate() + 1); + + bannersIndex.startDate = beforeToday.toISOString(); + bannersIndex.endDate = afterToday.toISOString(); + + render( + + + + ); + + const bannerText = screen.getByText(bannersIndex.text || ''); + expect(bannerText).toBeInTheDocument(); + }); + + it('does not render when today before startDate', () => { + const beforeToday = new Date(); + beforeToday.setDate(beforeToday.getDate() + 1); + const afterToday = new Date(); + afterToday.setDate(afterToday.getDate() + 2); + + bannersIndex.startDate = beforeToday.toISOString(); + bannersIndex.endDate = afterToday.toISOString(); + + render( + + + + ); + + const bannerText = screen.queryByText(bannersIndex.text || ''); + expect(bannerText).not.toBeInTheDocument(); + }); + + it('does not render when today after endDate', () => { + const beforeToday = new Date(); + beforeToday.setDate(beforeToday.getDate() - 2); + const afterToday = new Date(); + afterToday.setDate(afterToday.getDate() - 1); + + bannersIndex.startDate = beforeToday.toISOString(); + bannersIndex.endDate = afterToday.toISOString(); + + render( + + + + ); + + const bannerText = screen.queryByText(bannersIndex.text || ''); + expect(bannerText).not.toBeInTheDocument(); + }); + + it('should use the supplied relative link', () => { + const beforeToday = new Date(); + beforeToday.setDate(beforeToday.getDate() - 1); + const afterToday = new Date(); + afterToday.setDate(afterToday.getDate() + 1); + + bannersIndex.startDate = beforeToday.toISOString(); + bannersIndex.endDate = afterToday.toISOString(); + bannersIndex.link = 'foo/bar'; + + render( + + + + ); + + const bannerText = screen.getByText(bannersIndex.text || ''); + expect(bannerText).toBeInTheDocument(); + + const bannerLink = bannerText.innerHTML; + expect(bannerLink).toMatch('http://nodejs.org/foo/bar'); + }); + + it('should use the supplied absolute link', () => { + const beforeToday = new Date(); + beforeToday.setDate(beforeToday.getDate() - 1); + const afterToday = new Date(); + afterToday.setDate(afterToday.getDate() + 1); + + bannersIndex.startDate = beforeToday.toISOString(); + bannersIndex.endDate = afterToday.toISOString(); + bannersIndex.link = 'https://nodejs.org/en/an-absolute-content'; + + render( + + + + ); + + const bannerText = screen.getByText(bannersIndex.text || ''); + expect(bannerText).toBeInTheDocument(); + + const bannerLink = bannerText.innerHTML; + expect(bannerLink).toMatch('https://nodejs.org/en/an-absolute-content'); + }); + + it('should display html content correctly', () => { + const beforeToday = new Date(); + beforeToday.setDate(beforeToday.getDate() - 1); + const afterToday = new Date(); + afterToday.setDate(afterToday.getDate() + 1); + + bannersIndex.startDate = beforeToday.toISOString(); + bannersIndex.endDate = afterToday.toISOString(); + bannersIndex.link = 'https://nodejs.org/en/an-absolute-content'; + bannersIndex.text = undefined; + bannersIndex.html = + 'Node.js'; + + render( + + + + ); + + const bannerImage = screen.getByTestId('test-image'); + expect(bannerImage).toBeInTheDocument(); + }); +}); diff --git a/components/Common/Banner/index.module.scss b/components/Common/Banner/index.module.scss new file mode 100644 index 0000000000000..98be8f6b45dd7 --- /dev/null +++ b/components/Common/Banner/index.module.scss @@ -0,0 +1,70 @@ +.banner { + color: var(--color-text-primary); + margin: 0 auto; + max-width: 90vw; + padding-top: var(--space-08); + position: relative; + + &, + p { + font-size: var(--font-size-body1); + font-weight: var(--font-weight-bold); + } + + a { + align-items: center; + color: var(--color-text-primary); + display: flex; + flex-direction: column; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + + img { + border-radius: 5px; + max-width: 100%; + object-fit: cover; + } + + &.bannerBtn { + background: var(--purple5); + border: 1px solid transparent; + border-radius: 5.6rem; + color: var(--color-fill-top-nav); + font-family: var(--sans); + font-size: 1rem; + font-style: normal; + font-weight: var(--font-weight-semibold); + line-height: var(--line-height-subheading); + margin-right: var(--space-32); + padding: 0 var(--space-16); + position: relative; + text-decoration: none; + white-space: nowrap; + + &:hover { + background-color: var(--color-text-primary); + cursor: pointer; + } + } + } + + p { + align-items: center; + background-color: var(--color-fill-banner); + border-radius: 5px; + display: flex; + flex-direction: row; + margin: 0; + padding: var(--space-12); + text-align: center; + + a { + &:hover { + text-decoration: underline; + } + } + } +} diff --git a/components/Common/Banner/index.stories.tsx b/components/Common/Banner/index.stories.tsx new file mode 100644 index 0000000000000..1688438477a92 --- /dev/null +++ b/components/Common/Banner/index.stories.tsx @@ -0,0 +1,51 @@ +import Banner from './index'; +import type { Meta as MetaObj, StoryObj } from '@storybook/react'; + +type Story = StoryObj; +type Meta = MetaObj; + +const addDaysToDate = (numDays: number, date: Date) => { + const newDate = new Date(date); + newDate.setDate(date.getDate() + numDays); + return newDate; +}; + +// Create mock start and end dates as Banner Component renders +// only if end date is on or after today's date +const startDate = new Date(); +const endDate = addDaysToDate(3, startDate); + +export const WithText: Story = { + args: { + bannersIndex: { + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + text: 'Banner Text', + link: 'https://nodejs.org/en/', + }, + }, +}; + +export const WithHTML: Story = { + args: { + bannersIndex: { + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + html: '

    Banner HTML

    ', + link: 'https://nodejs.org/en/', + }, + }, +}; + +export const WithHTMLImage: Story = { + args: { + bannersIndex: { + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + html: 'Banner Image', + link: 'https://nodejs.org/en/', + }, + }, +}; + +export default { component: Banner } as Meta; diff --git a/components/Common/Banner/index.tsx b/components/Common/Banner/index.tsx new file mode 100644 index 0000000000000..054c6a880133f --- /dev/null +++ b/components/Common/Banner/index.tsx @@ -0,0 +1,79 @@ +import { useMemo } from 'react'; +import { useIntl } from 'react-intl'; +import { sanitize } from 'isomorphic-dompurify'; +import styles from './index.module.scss'; +import { dateIsBetween } from '../../../util/dateIsBetween'; +import { isAbsoluteUrl } from '../../../util/isAbsoluteUrl'; +import type { FC } from 'react'; +import type { WebsiteBanner } from '../../../types'; + +const useTextContent = ({ text, link }: WebsiteBanner, bannerBtnText: string) => + useMemo(() => { + if (text) { + return ( +

    + + {bannerBtnText || 'Read More'} + + {text} +

    + ); + } + + return null; + }, [text, link, bannerBtnText]); + +const useHtmlContent = ({ html, link }: WebsiteBanner) => + useMemo(() => { + if (html) { + return ( + + ); + } + + return null; + }, [html, link]); + +type BannerProps = { bannersIndex: WebsiteBanner }; + +const Banner: FC = ({ bannersIndex }) => { + const { formatMessage } = useIntl(); + + const showBanner = dateIsBetween( + bannersIndex.startDate, + bannersIndex.endDate + ); + + const link = !isAbsoluteUrl(bannersIndex.link) + ? `http://nodejs.org/${bannersIndex.link}` + : bannersIndex.link; + + const textContent = useTextContent( + { ...bannersIndex, link }, + formatMessage({ id: 'components.common.banner.button.text' }) + ); + + const htmlContent = useHtmlContent({ ...bannersIndex, link }); + + if (showBanner) { + return ( +
    + {bannersIndex.text ? textContent : htmlContent} +
    + ); + } + + return null; +}; + +export default Banner; diff --git a/components/Common/DarkModeToggle/__tests__/__snapshots__/index.test.tsx.snap b/components/Common/DarkModeToggle/__tests__/__snapshots__/index.test.tsx.snap new file mode 100644 index 0000000000000..6f3759d5005b0 --- /dev/null +++ b/components/Common/DarkModeToggle/__tests__/__snapshots__/index.test.tsx.snap @@ -0,0 +1,49 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DarkModeToggle Component render dark mode toggle 1`] = ` +
    + +
    +`; diff --git a/components/Common/DarkModeToggle/__tests__/index.test.tsx b/components/Common/DarkModeToggle/__tests__/index.test.tsx new file mode 100644 index 0000000000000..4122acad5f843 --- /dev/null +++ b/components/Common/DarkModeToggle/__tests__/index.test.tsx @@ -0,0 +1,54 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { IntlProvider } from 'react-intl'; +import DarkModeToggle from '../index'; + +let mockCurrentTheme = ''; + +const mockToggleTheme = jest.fn().mockImplementation(() => { + mockCurrentTheme = mockCurrentTheme === 'dark' ? 'light' : 'dark'; +}); + +// Mock dark mode module for controlling dark mode HOC behaviour +jest.mock('next-themes', () => ({ + useTheme: () => { + return { theme: mockCurrentTheme, setTheme: mockToggleTheme }; + }, +})); + +describe('DarkModeToggle Component', () => { + it('render dark mode toggle', () => { + const { container } = render( + {}}> + + + ); + expect(container).toMatchSnapshot(); + }); + + it('switches dark theme to light theme', async () => { + const user = userEvent.setup(); + mockCurrentTheme = 'dark'; + render( + {}}> + + + ); + const toggle = screen.getByRole('button'); + await user.click(toggle); + expect(mockCurrentTheme).toBe('light'); + }); + + it('switches light theme to dark theme', async () => { + const user = userEvent.setup(); + mockCurrentTheme = 'light'; + render( + {}}> + + + ); + const toggle = screen.getByRole('button'); + await user.click(toggle); + expect(mockCurrentTheme).toBe('dark'); + }); +}); diff --git a/components/Common/DarkModeToggle/index.module.scss b/components/Common/DarkModeToggle/index.module.scss new file mode 100644 index 0000000000000..62ca5ac9f6aff --- /dev/null +++ b/components/Common/DarkModeToggle/index.module.scss @@ -0,0 +1,12 @@ +.darkModeToggle { + background: none; + border: none; + color: var(--color-text-accent); + cursor: pointer; + line-height: 0; + padding: 0; + + svg { + font-size: 2rem; + } +} diff --git a/components/Common/DarkModeToggle/index.stories.tsx b/components/Common/DarkModeToggle/index.stories.tsx new file mode 100644 index 0000000000000..1cf630c82b7df --- /dev/null +++ b/components/Common/DarkModeToggle/index.stories.tsx @@ -0,0 +1,9 @@ +import DarkModeToggle from './index'; +import type { Meta as MetaObj, StoryObj } from '@storybook/react'; + +type Story = StoryObj; +type Meta = MetaObj; + +export const Default: Story = {}; + +export default { component: DarkModeToggle } as Meta; diff --git a/components/Common/DarkModeToggle/index.tsx b/components/Common/DarkModeToggle/index.tsx new file mode 100644 index 0000000000000..71d62f9e46829 --- /dev/null +++ b/components/Common/DarkModeToggle/index.tsx @@ -0,0 +1,40 @@ +import { useTheme } from 'next-themes'; +import { useIntl } from 'react-intl'; +import { MdLightMode, MdNightlight } from 'react-icons/md'; +import styles from './index.module.scss'; + +const DarkModeToggle = () => { + const { theme, setTheme } = useTheme(); + + const intl = useIntl(); + + const isDark = theme === 'dark'; + + const toggleTheme = (isKeyPress?: boolean) => { + if (isKeyPress) { + return; + } + + setTheme(isDark ? 'light' : 'dark'); + }; + + const ariaLabelText = intl.formatMessage({ + id: 'components.header.buttons.toggleDarkMode', + }); + + return ( + + ); +}; + +export default DarkModeToggle; diff --git a/components/Common/Dropdown/__tests__/index.test.tsx b/components/Common/Dropdown/__tests__/index.test.tsx new file mode 100644 index 0000000000000..ebf0fe5069cd4 --- /dev/null +++ b/components/Common/Dropdown/__tests__/index.test.tsx @@ -0,0 +1,64 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import Dropdown from '..'; +import type { DropdownItem } from '../../../../types'; + +describe('Dropdown component', () => { + const items: DropdownItem[] = [ + { label: 'item1', title: 'Item 1', active: false, onClick: jest.fn() }, + { label: 'item2', title: 'Item 2', active: true, onClick: jest.fn() }, + { label: 'item3', title: 'Item 3', active: false, onClick: jest.fn() }, + ]; + + it('should render the items and apply active styles', () => { + render(); + + items.forEach(item => { + const button = screen.getByText(item.title); + expect(button).toBeInTheDocument(); + + if (item.active) { + expect(button).toHaveStyle('font-weight: bold'); + } else { + expect(button).not.toHaveStyle('font-weight: bold'); + } + }); + }); + + it('should call the onClick function when an item is clicked', () => { + render(); + const button = screen.getByText(items[2].title); + fireEvent.click(button); + expect(items[2].onClick).toHaveBeenCalledTimes(1); + }); + + it('should call the onClick function when Enter or Space is pressed', () => { + render(); + const button = screen.getByText(items[1].title); + fireEvent.keyDown(button, { key: 'Enter', code: 'Enter' }); + fireEvent.keyDown(button, { key: ' ', code: 'Space' }); + expect(items[1].onClick).toHaveBeenCalledTimes(2); + }); + + it('should not render the items when shouldShow prop is false', () => { + render(); + items.forEach(item => { + const button = screen.queryByText(item.title); + expect(button).not.toBeVisible(); + }); + }); + + it('should apply styles passed in the styles prop', () => { + const customStyles = { + backgroundColor: 'green', + padding: '10px', + borderRadius: '5px', + }; + + render(); + + const dropdownList = screen.getByRole('list'); + expect(dropdownList).toHaveStyle('background-color: green'); + expect(dropdownList).toHaveStyle('padding: 10px'); + expect(dropdownList).toHaveStyle('border-radius: 5px'); + }); +}); diff --git a/components/Common/Dropdown/index.module.scss b/components/Common/Dropdown/index.module.scss new file mode 100644 index 0000000000000..376988645b40b --- /dev/null +++ b/components/Common/Dropdown/index.module.scss @@ -0,0 +1,30 @@ +.dropdownList { + background-color: var(--color-dropdown-background); + border-radius: 5px; + height: fit-content; + list-style-type: none; + max-height: 200px; + min-width: 150px; + overflow-y: auto; + padding: 0; + position: absolute; + width: fit-content; + + > li { + > button { + background: none; + border: none; + + color: var(--color-text-primary); + cursor: pointer; + font-size: var(--font-size-body1); + padding: var(--space-12); + text-align: center; + width: 100%; + + &:hover { + background-color: var(--color-dropdown-hover); + } + } + } +} diff --git a/components/Common/Dropdown/index.stories.tsx b/components/Common/Dropdown/index.stories.tsx new file mode 100644 index 0000000000000..17f79e0bed7aa --- /dev/null +++ b/components/Common/Dropdown/index.stories.tsx @@ -0,0 +1,23 @@ +import Dropdown from './index'; +import type { Meta as MetaObj, StoryObj } from '@storybook/react'; + +type Story = StoryObj; +type Meta = MetaObj; + +const items = [...Array(10).keys()].map(item => ({ + title: `Item ${item + 1}`, + label: `item-${item + 1}`, + active: false, + onClick: () => {}, +})); + +items[2].active = true; + +export const withItems: Story = { + args: { + items: items, + shouldShow: true, + }, +}; + +export default { component: Dropdown } as Meta; diff --git a/components/Common/Dropdown/index.tsx b/components/Common/Dropdown/index.tsx new file mode 100644 index 0000000000000..acc44d1da5a49 --- /dev/null +++ b/components/Common/Dropdown/index.tsx @@ -0,0 +1,51 @@ +import styles from './index.module.scss'; +import type { DropdownItem } from '../../../types'; +import type { CSSProperties, FC, KeyboardEvent } from 'react'; + +type DropdownProps = { + items: Array; + shouldShow: boolean; + styles: CSSProperties; +}; + +const Dropdown: FC = ({ + items, + shouldShow, + styles: extraStyles, +}) => { + const mappedElements = items.map(item => { + const extraStyles = { fontWeight: item.active ? 'bold' : 'normal' }; + + const handleKeyPress = (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + item.onClick(); + } + }; + + return ( +
  • + +
  • + ); + }); + + const dropdownStyles = { + display: shouldShow ? 'block' : 'none', + ...extraStyles, + }; + + return ( +
      + {mappedElements} +
    + ); +}; + +export default Dropdown; diff --git a/components/Common/LanguageSelector/__tests__/index.test.tsx b/components/Common/LanguageSelector/__tests__/index.test.tsx new file mode 100644 index 0000000000000..8fbdb2c963a42 --- /dev/null +++ b/components/Common/LanguageSelector/__tests__/index.test.tsx @@ -0,0 +1,43 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { IntlProvider } from 'react-intl'; +import LanguageSelector from '..'; + +jest.mock('../../../../hooks/useLocale', () => ({ + useLocale: () => ({ + availableLocales: [ + { code: 'en', name: 'English', localName: 'English' }, + { code: 'es', name: 'Spanish', localName: 'Español' }, + ], + currentLocale: { code: 'en', name: 'English', localName: 'English' }, + }), +})); + +describe('LanguageSelector', () => { + test('clicking the language switch button toggles the dropdown display', () => { + render( + {}}> + + + ); + const button = screen.getByRole('button'); + expect(screen.queryByText('English')).not.toBeVisible(); + fireEvent.click(button); + expect(screen.queryByText('English')).toBeVisible(); + fireEvent.click(button); + expect(screen.queryByText('English')).not.toBeVisible(); + }); + + test('renders the Dropdown component with correct style', () => { + render( + {}}> + + + ); + const button = screen.getByRole('button'); + fireEvent.click(button); + const dropdown = screen.getByRole('list'); + expect(dropdown).toHaveStyle( + 'position: absolute; top: 60%; right: 0; margin: 0;' + ); + }); +}); diff --git a/components/Common/LanguageSelector/index.module.scss b/components/Common/LanguageSelector/index.module.scss new file mode 100644 index 0000000000000..9f9c7583acabc --- /dev/null +++ b/components/Common/LanguageSelector/index.module.scss @@ -0,0 +1,17 @@ +.languageSwitch { + background: none; + border: none; + color: var(--color-text-accent); + cursor: pointer; + line-height: 0; + padding: 0; + + svg { + font-size: 2rem; + } +} + +.container { + display: inline; + position: relative; +} diff --git a/components/Common/LanguageSelector/index.stories.tsx b/components/Common/LanguageSelector/index.stories.tsx new file mode 100644 index 0000000000000..7bfc8e4d5c620 --- /dev/null +++ b/components/Common/LanguageSelector/index.stories.tsx @@ -0,0 +1,20 @@ +import LanguageSelector from './index'; +import type { Meta as MetaObj, StoryObj } from '@storybook/react'; + +type Story = StoryObj; +type Meta = MetaObj; + +export const Default: Story = {}; + +const containerStyles = { textAlign: 'right' } as const; + +export default { + component: LanguageSelector, + decorators: [ + Story => ( +
    + +
    + ), + ], +} as Meta; diff --git a/components/Common/LanguageSelector/index.tsx b/components/Common/LanguageSelector/index.tsx new file mode 100644 index 0000000000000..e966f0f1cace6 --- /dev/null +++ b/components/Common/LanguageSelector/index.tsx @@ -0,0 +1,60 @@ +import { useMemo, useState } from 'react'; +import { MdOutlineTranslate } from 'react-icons/md'; +import { useIntl } from 'react-intl'; +import styles from './index.module.scss'; +import Dropdown from '../Dropdown'; +import { useLocale } from '../../../hooks/useLocale'; + +const dropdownStyle = { + position: 'absolute', + top: '60%', + right: '0', + margin: 0, +} as const; + +const LanguageSelector = () => { + const [showDropdown, setShowDropdown] = useState(false); + + const { availableLocales, currentLocale } = useLocale(); + + const intl = useIntl(); + + const dropdownItems = useMemo( + () => + availableLocales.map(locale => ({ + title: locale.localName, + label: locale.name, + onClick: () => { + // TODO: "locale changing logic yet to be implemented" + }, + active: currentLocale.code === locale.code, + })), + [availableLocales, currentLocale] + ); + + const ariaLabelText = intl.formatMessage({ + id: 'components.common.languageSelector.button.title', + }); + + return ( +
    + + + +
    + ); +}; + +export default LanguageSelector; diff --git a/components/Common/SectionTitle/__tests__/SectionTitle.test.tsx b/components/Common/SectionTitle/__tests__/SectionTitle.test.tsx new file mode 100644 index 0000000000000..3ea1f85e93947 --- /dev/null +++ b/components/Common/SectionTitle/__tests__/SectionTitle.test.tsx @@ -0,0 +1,17 @@ +import { render, screen } from '@testing-library/react'; +import SectionTitle from '..'; + +describe('SectionTitle component', () => { + const mockData = ['home', 'previous', 'current']; + + it('renders correctly with data', () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it('last item should be active', () => { + render(); + const active = screen.getByText(mockData[mockData.length - 1]); + expect(active).toHaveClass('active'); + }); +}); diff --git a/components/Common/SectionTitle/__tests__/__snapshots__/SectionTitle.test.tsx.snap b/components/Common/SectionTitle/__tests__/__snapshots__/SectionTitle.test.tsx.snap new file mode 100644 index 0000000000000..b628f4982d33b --- /dev/null +++ b/components/Common/SectionTitle/__tests__/__snapshots__/SectionTitle.test.tsx.snap @@ -0,0 +1,17 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SectionTitle component renders correctly with data 1`] = ` +
    +
    + home / + previous / + + current + +
    +
    +`; diff --git a/components/Common/SectionTitle/index.module.scss b/components/Common/SectionTitle/index.module.scss new file mode 100644 index 0000000000000..018959b7515b0 --- /dev/null +++ b/components/Common/SectionTitle/index.module.scss @@ -0,0 +1,13 @@ +.sectionTitle, +%section-title { + color: var(--color-text-primary); + font-size: var(--font-size-overline); + font-weight: var(--font-weight-semibold); + letter-spacing: var(--space-02); + line-height: var(--line-height-overline); + text-transform: uppercase; + + .active { + color: var(--brand4); + } +} diff --git a/components/Common/SectionTitle/index.stories.ts b/components/Common/SectionTitle/index.stories.ts new file mode 100644 index 0000000000000..26f9599a3c006 --- /dev/null +++ b/components/Common/SectionTitle/index.stories.ts @@ -0,0 +1,13 @@ +import SectionTitle from './index'; +import type { Meta as MetaObj, StoryObj } from '@storybook/react'; + +type Story = StoryObj; +type Meta = MetaObj; + +export const Default: Story = { + args: { + path: ['home', 'previous', 'current'], + }, +}; + +export default { component: SectionTitle } as Meta; diff --git a/components/Common/SectionTitle/index.tsx b/components/Common/SectionTitle/index.tsx new file mode 100644 index 0000000000000..cdc9362f2ee79 --- /dev/null +++ b/components/Common/SectionTitle/index.tsx @@ -0,0 +1,24 @@ +import styles from './index.module.scss'; +import type { FC } from 'react'; + +type SectionTitleProps = { path: string[] }; + +const SectionTitle: FC = ({ path }) => ( +
    + {path.map((item, index) => { + const isLast = index === path.length - 1; + + if (isLast) { + return ( + + {item} + + ); + } + + return `${item} / `; + })} +
    +); + +export default SectionTitle; diff --git a/components/Common/ShellBox/__tests__/__snapshots__/index.test.tsx.snap b/components/Common/ShellBox/__tests__/__snapshots__/index.test.tsx.snap new file mode 100644 index 0000000000000..0a18a22852632 --- /dev/null +++ b/components/Common/ShellBox/__tests__/__snapshots__/index.test.tsx.snap @@ -0,0 +1,25 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ShellBox should render 1`] = ` +
    +
    +    
    + + SHELL + + +
    + + test + +
    +
    +`; diff --git a/components/Common/ShellBox/__tests__/index.test.tsx b/components/Common/ShellBox/__tests__/index.test.tsx new file mode 100644 index 0000000000000..9d764926a5d86 --- /dev/null +++ b/components/Common/ShellBox/__tests__/index.test.tsx @@ -0,0 +1,58 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { IntlProvider } from 'react-intl'; +import ShellBox from '../index'; + +const mockWriteText = jest.fn(); +const originalNavigator = { ...window.navigator }; + +describe('ShellBox', () => { + beforeEach(() => { + Object.defineProperty(window, 'navigator', { + value: { + clipboard: { + writeText: mockWriteText, + }, + }, + }); + }); + + afterEach(() => { + Object.defineProperty(window, 'navigator', { + value: originalNavigator, + }); + }); + + it('should render', () => { + const { container } = render( + {}}> + test + + ); + expect(container).toMatchSnapshot(); + }); + + it('should call clipboard API with `test` once', async () => { + const user = userEvent.setup(); + const navigatorClipboardWriteTextSpy = jest + .fn() + .mockImplementation(() => Promise.resolve()); + + Object.defineProperty(window.navigator, 'clipboard', { + writable: true, + value: { + writeText: navigatorClipboardWriteTextSpy, + }, + }); + + render( + {}}> + test + + ); + const button = screen.getByRole('button'); + await user.click(button); + expect(navigatorClipboardWriteTextSpy).toHaveBeenCalledTimes(1); + expect(navigatorClipboardWriteTextSpy).toHaveBeenCalledWith('test'); + }); +}); diff --git a/components/Common/ShellBox/index.module.scss b/components/Common/ShellBox/index.module.scss new file mode 100644 index 0000000000000..f69a169efabc8 --- /dev/null +++ b/components/Common/ShellBox/index.module.scss @@ -0,0 +1,75 @@ +.shellBox { + background-color: var(--black10); + border-radius: 0.4rem; + box-sizing: border-box; + display: flex; + flex-direction: column; + font-family: var(--mono); + padding: 0 0 var(--space-48) var(--space-16); + position: relative; + + code { + color: var(--pink5); + font-family: inherit; + line-height: 30px; + overflow-x: hidden; + position: absolute; + top: 30px; + width: calc(100% - 20px); + + &:hover { + overflow-x: auto; + } + + &::-webkit-scrollbar { + height: 0.5em; + } + + &::-webkit-scrollbar, + &::-webkit-scrollbar-thumb { + border-radius: 4px; + overflow: visible; + } + + &::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.2); + } + + > span.function { + color: var(--warning5); + } + } + + .top { + display: inherit; + flex-direction: row; + justify-content: space-between; + margin-bottom: var(--space-08); + + span, + button { + align-items: center; + display: inherit; + font-size: var(--font-size-code); + height: 23px; + justify-content: center; + width: 86px; + } + + span { + background-color: var(--black3); + border-radius: 0 0 0.3rem 0.3rem; + color: var(--black9); + margin-left: 1.6rem; + } + + button { + background-color: var(--brand); + border-radius: 0 0.3rem 0.3rem 0.3rem; + border-width: 0; + i { + padding: 0; + } + } + } +} diff --git a/components/Common/ShellBox/index.stories.tsx b/components/Common/ShellBox/index.stories.tsx new file mode 100644 index 0000000000000..4e5fa06699427 --- /dev/null +++ b/components/Common/ShellBox/index.stories.tsx @@ -0,0 +1,31 @@ +import ShellBox from './index'; +import type { Meta as MetaObj, StoryObj } from '@storybook/react'; + +type Story = StoryObj; +type Meta = MetaObj; + +export const Default: Story = { + args: { + children: 'echo hello world', + textToCopy: 'echo hello world', + }, +}; + +export const WithoutTextToCopy: Story = { + args: { + children: 'echo hello world', + }, +}; + +export const WithTextToCopyJsx: Story = { + args: { + children: ( + + $echo hello world + + ), + textToCopy: 'echo hello world', + }, +}; + +export default { component: ShellBox } as Meta; diff --git a/components/Common/ShellBox/index.tsx b/components/Common/ShellBox/index.tsx new file mode 100644 index 0000000000000..273828e738ba1 --- /dev/null +++ b/components/Common/ShellBox/index.tsx @@ -0,0 +1,45 @@ +import { useRef } from 'react'; +import { FormattedMessage } from 'react-intl'; +import styles from './index.module.scss'; +import { useCopyToClipboard } from '../../../hooks/useCopyToClipboard'; +import type { FC, PropsWithChildren, MouseEvent, ReactNode } from 'react'; + +type ShellBoxProps = { + children: string | ReactNode; + textToCopy?: string; +}; + +const ShellBox: FC> = ({ + children, + textToCopy, +}: PropsWithChildren) => { + const [copied, copyText] = useCopyToClipboard(); + + const shellBoxRef = useRef(null); + + const handleCopyCode = async (event: MouseEvent) => { + event.preventDefault(); + // By default we want to use the textToCopy props but if it's absent + // we allow the user to copy by getting the inner HTML content of the Element + const _textToCopy = textToCopy || shellBoxRef.current?.innerHTML || ''; + + await copyText(_textToCopy.replace('$', '')); + }; + + return ( +
    +      
    + SHELL + +
    + {children} +
    + ); +}; + +export default ShellBox; diff --git a/components/Common/index.ts b/components/Common/index.ts new file mode 100644 index 0000000000000..d49eb3b30e135 --- /dev/null +++ b/components/Common/index.ts @@ -0,0 +1 @@ +export { default as ShellBox } from './ShellBox'; diff --git a/components/Downloads/DownloadList.tsx b/components/Downloads/DownloadList.tsx index b4293f8d17b86..ce144d256eec9 100644 --- a/components/Downloads/DownloadList.tsx +++ b/components/Downloads/DownloadList.tsx @@ -1,18 +1,17 @@ import { FormattedMessage } from 'react-intl'; - import LocalizedLink from '../LocalizedLink'; import { useNavigation } from '../../hooks/useNavigation'; - import type { NodeVersionData } from '../../types'; +import type { FC } from 'react'; type DownloadListProps = Pick; -const DownloadList = (props: DownloadListProps) => { +const DownloadList: FC = ({ node }) => { const { getSideNavigation } = useNavigation(); const [, ...downloadNavigation] = getSideNavigation('download', { - shaSums: { nodeVersion: props.node }, - allDownloads: { nodeVersion: props.node }, + shaSums: { nodeVersion: node }, + allDownloads: { nodeVersion: node }, }); return ( diff --git a/components/Downloads/DownloadReleasesTable.tsx b/components/Downloads/DownloadReleasesTable.tsx index c627a9a2ffb17..a258c27b00bdf 100644 --- a/components/Downloads/DownloadReleasesTable.tsx +++ b/components/Downloads/DownloadReleasesTable.tsx @@ -1,14 +1,15 @@ import { FormattedMessage } from 'react-intl'; import Link from 'next/link'; - import { getNodejsChangelog } from '../../util/getNodeJsChangelog'; import { getNodeApiLink } from '../../util/getNodeApiLink'; - import type { ExtendedNodeVersionData } from '../../types'; +import type { FC } from 'react'; type DownloadReleasesTableProps = { releases: ExtendedNodeVersionData[] }; -const DownloadReleasesTable = ({ releases }: DownloadReleasesTableProps) => ( +const DownloadReleasesTable: FC = ({ + releases, +}) => ( diff --git a/components/Downloads/PrimaryDownloadMatrix.tsx b/components/Downloads/PrimaryDownloadMatrix.tsx index 9298e0cc74834..39541801c8423 100644 --- a/components/Downloads/PrimaryDownloadMatrix.tsx +++ b/components/Downloads/PrimaryDownloadMatrix.tsx @@ -1,10 +1,9 @@ import classNames from 'classnames'; import semVer from 'semver'; - import LocalizedLink from '../LocalizedLink'; import { useNextraContext } from '../../hooks/useNextraContext'; - import type { NodeVersionData, LegacyDownloadsFrontMatter } from '../../types'; +import type { FC } from 'react'; type PrimaryDownloadMatrixProps = Pick< NodeVersionData, @@ -13,11 +12,16 @@ type PrimaryDownloadMatrixProps = Pick< // @TODO: Instead of using a static list it should be created dynamically. This is done on `nodejs.dev` // since this is a temporary solution and going to be fixed in the future. -const PrimaryDownloadMatrix = (props: PrimaryDownloadMatrixProps) => { +const PrimaryDownloadMatrix: FC = ({ + node, + nodeNumeric, + npm, + isLts, +}) => { const nextraContext = useNextraContext(); const { downloads } = nextraContext.frontMatter as LegacyDownloadsFrontMatter; - const hasWindowsArm64 = semVer.satisfies(props.node, '>= 19.9.0'); + const hasWindowsArm64 = semVer.satisfies(node, '>= 19.9.0'); const getIsVersionClassName = (isCurrent: boolean) => classNames({ 'is-version': isCurrent }); @@ -25,8 +29,8 @@ const PrimaryDownloadMatrix = (props: PrimaryDownloadMatrixProps) => { return (

    - {downloads.currentVersion}: {props.nodeNumeric} ( - {downloads.includes || 'includes'} npm {props.npm}) + {downloads.currentVersion}: {nodeNumeric} ( + {downloads.includes || 'includes'} npm {npm})

    {downloads.intro}

    @@ -34,7 +38,7 @@ const PrimaryDownloadMatrix = (props: PrimaryDownloadMatrixProps) => {
    {hasWindowsArm64 && ( @@ -180,14 +174,14 @@ const PrimaryDownloadMatrix = (props: PrimaryDownloadMatrixProps) => { diff --git a/components/Downloads/SecondaryDownloadMatrix.tsx b/components/Downloads/SecondaryDownloadMatrix.tsx index 2ad51fbf513d6..e6bffb520f17e 100644 --- a/components/Downloads/SecondaryDownloadMatrix.tsx +++ b/components/Downloads/SecondaryDownloadMatrix.tsx @@ -1,13 +1,15 @@ import DownloadList from './DownloadList'; import { useNextraContext } from '../../hooks/useNextraContext'; - import type { NodeVersionData, LegacyDownloadsFrontMatter } from '../../types'; +import type { FC } from 'react'; type SecondaryDownloadMatrixProps = Pick; // @TODO: Instead of using a static list it should be created dynamically. This is done on `nodejs.dev` // since this is a temporary solution and going to be fixed in the future. -const SecondaryDownloadMatrix = (props: SecondaryDownloadMatrixProps) => { +const SecondaryDownloadMatrix: FC = ({ + node, +}) => { const nextraContext = useNextraContext(); const { additional } = @@ -31,7 +33,7 @@ const SecondaryDownloadMatrix = (props: SecondaryDownloadMatrixProps) => {
    {downloads.WindowsInstaller} (.msi) - + 32-bit - + 64-bit ARM64 @@ -143,14 +139,14 @@ const PrimaryDownloadMatrix = (props: PrimaryDownloadMatrixProps) => { {downloads.WindowsBinary} (.zip) 32-bit 64-bit @@ -158,7 +154,7 @@ const PrimaryDownloadMatrix = (props: PrimaryDownloadMatrixProps) => { {hasWindowsArm64 && ( ARM64 @@ -169,9 +165,7 @@ const PrimaryDownloadMatrix = (props: PrimaryDownloadMatrixProps) => {
    {downloads.MacOSInstaller} (.pkg) - + 64-bit / ARM64 {downloads.MacOSBinary} (.tar.gz) 64-bit ARM64 @@ -198,7 +192,7 @@ const PrimaryDownloadMatrix = (props: PrimaryDownloadMatrixProps) => { {downloads.LinuxBinaries} (x64) 64-bit @@ -208,14 +202,14 @@ const PrimaryDownloadMatrix = (props: PrimaryDownloadMatrixProps) => { {downloads.LinuxBinaries} (ARM) ARMv7 ARMv8 @@ -225,10 +219,8 @@ const PrimaryDownloadMatrix = (props: PrimaryDownloadMatrixProps) => {
    {downloads.SourceCode} - - node-{props.node}.tar.gz + + node-{node}.tar.gz
    {additional.LinuxPowerSystems} 64-bit @@ -42,7 +44,7 @@ const SecondaryDownloadMatrix = (props: SecondaryDownloadMatrixProps) => { {additional.LinuxSystemZ} 64-bit @@ -52,7 +54,7 @@ const SecondaryDownloadMatrix = (props: SecondaryDownloadMatrixProps) => { {additional.AIXPowerSystems} 64-bit @@ -61,7 +63,7 @@ const SecondaryDownloadMatrix = (props: SecondaryDownloadMatrixProps) => {
    - + ); }; diff --git a/components/Footer.tsx b/components/Footer.tsx index b772f5c7619c6..eed12f807da8e 100644 --- a/components/Footer.tsx +++ b/components/Footer.tsx @@ -1,9 +1,10 @@ import { FormattedMessage } from 'react-intl'; +import type { FC } from 'react'; type FooterProps = { className?: string }; // Note.: We don't expect to translate these items as we're going to replace with `nodejs/nodejs.dev` footer -const Footer = ({ className }: FooterProps) => ( +const Footer: FC = ({ className }) => ( <> diff --git a/components/Header.tsx b/components/Header.tsx index 854a22b502dd4..65d1b68090e2a 100644 --- a/components/Header.tsx +++ b/components/Header.tsx @@ -2,10 +2,10 @@ import { useIntl } from 'react-intl'; import Image from 'next/image'; import classNames from 'classnames'; +import { useRouter } from 'next/router'; import LocalizedLink from './LocalizedLink'; -import { useLocale } from '../hooks/useLocale'; import { useNavigation } from '../hooks/useNavigation'; -import { useRouter } from 'next/router'; +import { useLocale } from '../hooks/useLocale'; const Header = () => { const { availableLocales, isCurrentLocaleRoute } = useLocale(); diff --git a/components/Home/Banner.tsx b/components/Home/Banner.tsx index 12cfeeef68352..748e804e6b7d9 100644 --- a/components/Home/Banner.tsx +++ b/components/Home/Banner.tsx @@ -1,5 +1,4 @@ import Link from 'next/link'; - import { useSiteConfig } from '../../hooks/useSiteConfig'; import { dateIsBetween } from '../../util/dateIsBetween'; diff --git a/components/Home/HomeDownloadButton.tsx b/components/Home/HomeDownloadButton.tsx index 54f58ea304ee2..467907620f8a9 100644 --- a/components/Home/HomeDownloadButton.tsx +++ b/components/Home/HomeDownloadButton.tsx @@ -1,25 +1,30 @@ import LocalizedLink from '../LocalizedLink'; import { useNextraContext } from '../../hooks/useNextraContext'; import { getNodejsChangelog } from '../../util/getNodeJsChangelog'; - import type { NodeVersionData } from '../../types'; +import type { FC } from 'react'; type HomeDownloadButtonProps = Pick< NodeVersionData, 'isLts' | 'node' | 'nodeMajor' | 'nodeNumeric' >; -const HomeDownloadButton = (props: HomeDownloadButtonProps) => { +const HomeDownloadButton: FC = ({ + node, + nodeMajor, + nodeNumeric, + isLts, +}) => { const { frontMatter: { labels }, } = useNextraContext(); - const nodeDownloadLink = `https://nodejs.org/dist/${props.node}/`; - const nodeApiLink = `https://nodejs.org/dist/latest-${props.nodeMajor}/docs/api/`; - const nodeAllDownloadsLink = `/download${props.isLts ? '/' : '/current'}`; + const nodeDownloadLink = `https://nodejs.org/dist/${node}/`; + const nodeApiLink = `https://nodejs.org/dist/latest-${nodeMajor}/docs/api/`; + const nodeAllDownloadsLink = `/download${isLts ? '/' : '/current'}`; const nodeDownloadTitle = - `${labels.download} ${props.nodeNumeric}` + - ` ${labels[props.isLts ? 'lts' : 'current']}`; + `${labels.download} ${nodeNumeric}` + + ` ${labels[isLts ? 'lts' : 'current']}`; return (
    @@ -27,10 +32,10 @@ const HomeDownloadButton = (props: HomeDownloadButtonProps) => { href={nodeDownloadLink} className="home-downloadbutton" title={nodeDownloadTitle} - data-version={props.node} + data-version={node} > - {props.nodeNumeric} {labels[props.isLts ? 'lts' : 'current']} - {labels[`tagline-${props.isLts ? 'lts' : 'current'}`]} + {nodeNumeric} {labels[isLts ? 'lts' : 'current']} + {labels[`tagline-${isLts ? 'lts' : 'current'}`]}
      @@ -40,7 +45,7 @@ const HomeDownloadButton = (props: HomeDownloadButtonProps) => {
    • - + {labels.changelog}
    • diff --git a/components/Home/NodeFeatures/NodeFeature/index.module.scss b/components/Home/NodeFeatures/NodeFeature/index.module.scss new file mode 100644 index 0000000000000..278076d33f94f --- /dev/null +++ b/components/Home/NodeFeatures/NodeFeature/index.module.scss @@ -0,0 +1,5 @@ +.container { + margin: 0 2em; + max-width: 248px; + text-align: center; +} diff --git a/components/Home/NodeFeatures/NodeFeature/index.tsx b/components/Home/NodeFeatures/NodeFeature/index.tsx new file mode 100644 index 0000000000000..fb958b5cb88b5 --- /dev/null +++ b/components/Home/NodeFeatures/NodeFeature/index.tsx @@ -0,0 +1,18 @@ +import styles from './index.module.scss'; +import type { ReactElement, FC } from 'react'; + +type NodeFeatureProps = { + icon: ReactElement; + heading: ReactElement; + description: ReactElement; +}; + +const NodeFeature: FC = ({ icon, heading, description }) => ( +
      + {icon} +

      {heading}

      +

      {description}

      +
      +); + +export default NodeFeature; diff --git a/components/Home/NodeFeatures/__test__/__snapshots__/index.test.tsx.snap b/components/Home/NodeFeatures/__test__/__snapshots__/index.test.tsx.snap new file mode 100644 index 0000000000000..4cf67440b4440 --- /dev/null +++ b/components/Home/NodeFeatures/__test__/__snapshots__/index.test.tsx.snap @@ -0,0 +1,97 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`NodeFeatures should render 1`] = ` +
      +
      +
      + + + + +

      + components.home.nodeFeatures.javascript.title +

      +

      + components.home.nodeFeatures.javascript.description +

      +
      +
      + + + +

      + components.home.nodeFeatures.openSource.title +

      +

      + components.home.nodeFeatures.openSource.description +

      +
      +
      + + + + +

      + components.home.nodeFeatures.everywhere.title +

      +

      + components.home.nodeFeatures.everywhere.description +

      +
      +
      +
      +`; diff --git a/components/Home/NodeFeatures/__test__/index.test.tsx b/components/Home/NodeFeatures/__test__/index.test.tsx new file mode 100644 index 0000000000000..11fd0c5177601 --- /dev/null +++ b/components/Home/NodeFeatures/__test__/index.test.tsx @@ -0,0 +1,14 @@ +import { render } from '@testing-library/react'; +import { IntlProvider } from 'react-intl'; +import NodeFeatures from '../index'; + +describe('NodeFeatures', () => { + it('should render', () => { + const { container } = render( + {}}> + + + ); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/components/Home/NodeFeatures/index.module.scss b/components/Home/NodeFeatures/index.module.scss new file mode 100644 index 0000000000000..43acfb4083d51 --- /dev/null +++ b/components/Home/NodeFeatures/index.module.scss @@ -0,0 +1,21 @@ +.nodeFeatures { + display: grid; + grid-gap: 1.5rem; + grid-template-columns: repeat(3, 1fr); + justify-content: center; + margin: 0 auto 5em auto; + + @media (max-width: 900px) { + grid-template-columns: 1fr; + } + + .featureIcon { + height: auto; + min-width: 120px; + opacity: 0.75; + + @media (max-width: 900px) { + min-width: 60px; + } + } +} diff --git a/components/Home/NodeFeatures/index.stories.tsx b/components/Home/NodeFeatures/index.stories.tsx new file mode 100644 index 0000000000000..0a36f50493b31 --- /dev/null +++ b/components/Home/NodeFeatures/index.stories.tsx @@ -0,0 +1,9 @@ +import NodeFeatures from '.'; +import type { Meta as MetaObj, StoryObj } from '@storybook/react'; + +type Story = StoryObj; +type Meta = MetaObj; + +export const Default: Story = {}; + +export default { component: NodeFeatures } as Meta; diff --git a/components/Home/NodeFeatures/index.tsx b/components/Home/NodeFeatures/index.tsx new file mode 100644 index 0000000000000..43942adfaf54c --- /dev/null +++ b/components/Home/NodeFeatures/index.tsx @@ -0,0 +1,45 @@ +import { cloneElement } from 'react'; +import { IoLogoNodejs, IoMdGitPullRequest, IoMdRocket } from 'react-icons/io'; +import { FormattedMessage } from 'react-intl'; +import NodeFeature from './NodeFeature'; +import styles from './index.module.scss'; +import type { ReactElement, FC } from 'react'; + +const styled = (icon: ReactElement): ReactElement => + cloneElement(icon, { + alt: 'Node Feature', + className: styles.featureIcon, + }); + +const features = [ + { + icon: styled(), + heading: 'components.home.nodeFeatures.javascript.title', + description: 'components.home.nodeFeatures.javascript.description', + }, + { + icon: styled(), + heading: 'components.home.nodeFeatures.openSource.title', + description: 'components.home.nodeFeatures.openSource.description', + }, + { + icon: styled(), + heading: 'components.home.nodeFeatures.everywhere.title', + description: 'components.home.nodeFeatures.everywhere.description', + }, +]; + +const NodeFeatures: FC = () => ( +
      + {features.map(feature => ( + } + description={} + /> + ))} +
      +); + +export default NodeFeatures; diff --git a/components/HtmlHead.tsx b/components/HtmlHead.tsx index 416574e87025a..869a9581d6542 100644 --- a/components/HtmlHead.tsx +++ b/components/HtmlHead.tsx @@ -1,13 +1,12 @@ import { useRouter } from 'next/router'; import Head from 'next/head'; - import { useSiteConfig } from '../hooks/useSiteConfig'; - import type { LegacyFrontMatter } from '../types'; +import type { FC } from 'react'; type HeaderProps = { frontMatter: LegacyFrontMatter }; -const HtmlHead = ({ frontMatter }: HeaderProps) => { +const HtmlHead: FC = ({ frontMatter }) => { const siteConfig = useSiteConfig(); const { route, basePath } = useRouter(); @@ -30,6 +29,7 @@ const HtmlHead = ({ frontMatter }: HeaderProps) => { /> + diff --git a/components/Learn/PreviousNextLink/__tests__/index.test.tsx b/components/Learn/PreviousNextLink/__tests__/index.test.tsx new file mode 100644 index 0000000000000..a303628be2323 --- /dev/null +++ b/components/Learn/PreviousNextLink/__tests__/index.test.tsx @@ -0,0 +1,46 @@ +import { render, screen } from '@testing-library/react'; +import { IntlProvider } from 'react-intl'; +import PrevNextLink from '..'; + +describe('PrevNextLink component', () => { + test('renders nothing if neither previous nor next are provided', () => { + render(); + const component = screen.queryByRole('list'); + expect(component).not.toBeInTheDocument; + }); + + test('renders previous link if previous is provided', () => { + const previous = { slug: '/previous-page' }; + render( + {}}> + + + ); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', previous.slug); + }); + + test('renders next link if next is provided', () => { + const next = { slug: '/next-page' }; + render( + {}}> + + + ); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', next.slug); + }); + + test('renders both previous and next links if both are provided', () => { + const previous = { slug: '/previous-page' }; + const next = { slug: '/next-page' }; + render( + {}}> + + + ); + const links = screen.getAllByRole('link'); + expect(links[0]).toHaveAttribute('href', previous.slug); + expect(links[1]).toHaveAttribute('href', next.slug); + }); +}); diff --git a/components/Learn/PreviousNextLink/index.module.scss b/components/Learn/PreviousNextLink/index.module.scss new file mode 100644 index 0000000000000..98d29f8a1b7e6 --- /dev/null +++ b/components/Learn/PreviousNextLink/index.module.scss @@ -0,0 +1,24 @@ +.prevNextLink { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + list-style: none; + margin: 0; + padding: 5rem 0 0 0; + + a { + align-items: center; + color: var(--black7) !important; + display: flex; + font-family: var(--sans-serif); + font-size: 1.4rem; + font-weight: var(--font-weight-regular); + text-decoration: none !important; + text-transform: uppercase; + vertical-align: middle; + + &:hover { + color: var(--brand-light) !important; + } + } +} diff --git a/components/Learn/PreviousNextLink/index.stories.tsx b/components/Learn/PreviousNextLink/index.stories.tsx new file mode 100644 index 0000000000000..9865c7b2f7f69 --- /dev/null +++ b/components/Learn/PreviousNextLink/index.stories.tsx @@ -0,0 +1,26 @@ +import PrevNextLink from '.'; +import type { Meta as MetaObj, StoryObj } from '@storybook/react'; + +type Story = StoryObj; +type Meta = MetaObj; + +export const Default: Story = { + args: { + previous: { slug: '/previous' }, + next: { slug: '/next' }, + }, +}; + +export const WithoutNext: Story = { + args: { + previous: { slug: '/previous' }, + }, +}; + +export const WithoutPrevious: Story = { + args: { + next: { slug: '/next' }, + }, +}; + +export default { component: PrevNextLink } as Meta; diff --git a/components/Learn/PreviousNextLink/index.tsx b/components/Learn/PreviousNextLink/index.tsx new file mode 100644 index 0000000000000..5dc6a85c5ba29 --- /dev/null +++ b/components/Learn/PreviousNextLink/index.tsx @@ -0,0 +1,40 @@ +import Link from 'next/link'; +import { FaAngleDoubleLeft, FaAngleDoubleRight } from 'react-icons/fa'; +import { FormattedMessage } from 'react-intl'; +import styles from './index.module.scss'; +import type { LinkInfo } from '../../../types'; +import type { FC } from 'react'; + +type PreviousNextLinkProps = { + previous?: LinkInfo; + next?: LinkInfo; +}; + +const PreviousNextLink: FC = ({ previous, next }) => { + if (!previous && !next) { + return null; + } + + return ( +
        +
      • + {previous && ( + + + + + )} +
      • +
      • + {next && ( + + + + + )} +
      • +
      + ); +}; + +export default PreviousNextLink; diff --git a/components/LocalizedLink.tsx b/components/LocalizedLink.tsx index 03cb82721c78d..d7600ab47cb1e 100644 --- a/components/LocalizedLink.tsx +++ b/components/LocalizedLink.tsx @@ -1,13 +1,14 @@ import { useMemo } from 'react'; import Link from 'next/link'; -import type { ComponentProps } from 'react'; - import { useLocale } from '../hooks/useLocale'; import { linkWithLocale } from '../util/linkWithLocale'; +import type { FC, ComponentProps } from 'react'; -const LocalizedLink = (props: ComponentProps) => { - const { href, children, ...extra } = props; - +const LocalizedLink: FC> = ({ + href, + children, + ...extra +}) => { const { currentLocale } = useLocale(); const localizedUrl = linkWithLocale(currentLocale.code); diff --git a/components/Pagination.tsx b/components/Pagination.tsx index dc99ab6a0b82a..d824e3ba91f1d 100644 --- a/components/Pagination.tsx +++ b/components/Pagination.tsx @@ -1,19 +1,19 @@ import { FormattedMessage } from 'react-intl'; - import LocalizedLink from './LocalizedLink'; +import type { FC } from 'react'; type PaginationProps = { prevSlug?: string; nextSlug?: string }; -const Pagination = (props: PaginationProps) => ( +const Pagination: FC = ({ nextSlug, prevSlug }) => (