├── .nvmrc
├── .npmrc
├── .gitattributes
├── .husky
└── pre-commit
├── config
├── i18n
│ ├── locales
│ │ ├── espanol
│ │ │ ├── redirects.json
│ │ │ ├── meta-tags.json
│ │ │ └── links.json
│ │ ├── italian
│ │ │ ├── redirects.json
│ │ │ ├── meta-tags.json
│ │ │ └── links.json
│ │ ├── japanese
│ │ │ ├── redirects.json
│ │ │ ├── meta-tags.json
│ │ │ └── links.json
│ │ ├── portuguese
│ │ │ ├── redirects.json
│ │ │ ├── meta-tags.json
│ │ │ └── links.json
│ │ ├── ukrainian
│ │ │ ├── redirects.json
│ │ │ ├── meta-tags.json
│ │ │ └── links.json
│ │ ├── chinese
│ │ │ ├── redirects.json
│ │ │ ├── meta-tags.json
│ │ │ ├── links.json
│ │ │ └── translations.json
│ │ ├── dothraki
│ │ │ ├── meta-tags.json
│ │ │ ├── redirects.json
│ │ │ └── links.json
│ │ ├── english
│ │ │ ├── meta-tags.json
│ │ │ └── links.json
│ │ └── korean
│ │ │ ├── meta-tags.json
│ │ │ ├── redirects.json
│ │ │ ├── links.json
│ │ │ └── translations.json
│ ├── config.js
│ ├── generate-serve-config.js
│ ├── locales.test.js
│ └── redirects.test.js
└── serve.json
├── docker
├── test
│ ├── prd
│ │ ├── .env.sample
│ │ ├── docker-compose.yml
│ │ └── nginx
│ │ │ └── nginx.conf
│ └── dev
│ │ ├── docker-compose.yml
│ │ ├── nginx
│ │ └── nginx.conf
│ │ └── html
│ │ ├── dothraki
│ │ └── index.html
│ │ └── index.html
├── ghost
│ ├── content
│ │ ├── chinese
│ │ │ ├── data
│ │ │ │ └── ghost.db
│ │ │ ├── images
│ │ │ │ ├── 2022
│ │ │ │ │ └── 02
│ │ │ │ │ │ ├── miya-liu-photo.jpg
│ │ │ │ │ │ ├── miya-liu-photo_o.jpg
│ │ │ │ │ │ ├── freecodecamp-org-gravatar.jpeg
│ │ │ │ │ │ └── freecodecamp-org-gravatar_o.jpeg
│ │ │ │ └── size
│ │ │ │ │ ├── w100
│ │ │ │ │ └── 2022
│ │ │ │ │ │ └── 02
│ │ │ │ │ │ ├── miya-liu-photo.jpg
│ │ │ │ │ │ └── freecodecamp-org-gravatar.jpeg
│ │ │ │ │ └── w30
│ │ │ │ │ └── 2022
│ │ │ │ │ └── 02
│ │ │ │ │ └── miya-liu-photo.jpg
│ │ │ └── settings
│ │ │ │ └── routes.yaml
│ │ └── espanol
│ │ │ ├── data
│ │ │ └── ghost.db
│ │ │ ├── images
│ │ │ ├── 2022
│ │ │ │ ├── 02
│ │ │ │ │ ├── rafael-photo.jpeg
│ │ │ │ │ ├── rafael-photo_o.jpeg
│ │ │ │ │ ├── freecodecamp-org-gravatar.jpeg
│ │ │ │ │ └── freecodecamp-org-gravatar_o.jpeg
│ │ │ │ └── 05
│ │ │ │ │ ├── 5f9c9afc740569d1a4ca2907.jpeg
│ │ │ │ │ └── 5f9c9afc740569d1a4ca2907_o.jpeg
│ │ │ ├── 2024
│ │ │ │ └── 09
│ │ │ │ │ ├── fccbg_25e868b401.png
│ │ │ │ │ ├── low-rez-publication-cover.png
│ │ │ │ │ └── photo-1528724977141-d90af338860c.jpeg
│ │ │ └── size
│ │ │ │ ├── w30
│ │ │ │ ├── 2022
│ │ │ │ │ └── 02
│ │ │ │ │ │ └── rafael-photo.jpeg
│ │ │ │ └── size
│ │ │ │ │ └── w30
│ │ │ │ │ └── 2022
│ │ │ │ │ └── 02
│ │ │ │ │ └── rafael-photo.jpeg
│ │ │ │ └── w100
│ │ │ │ └── 2022
│ │ │ │ └── 02
│ │ │ │ ├── rafael-photo.jpeg
│ │ │ │ └── freecodecamp-org-gravatar.jpeg
│ │ │ └── settings
│ │ │ └── routes.yaml
│ ├── config.production.json
│ └── docker-compose.yml
├── Dockerfile
└── README.md
├── renovate.json
├── jest.config.js
├── src
├── htaccess.njk
├── redirects.njk
├── robots.njk
├── _includes
│ ├── partials
│ │ ├── gtm-body.njk
│ │ ├── icons
│ │ │ ├── infinity.njk
│ │ │ ├── rss.njk
│ │ │ ├── point.njk
│ │ │ ├── location.njk
│ │ │ ├── facebook.njk
│ │ │ ├── avatar.njk
│ │ │ ├── stackoverflow.njk
│ │ │ ├── x.njk
│ │ │ ├── website.njk
│ │ │ ├── linkedin.njk
│ │ │ ├── magnifying-glass.njk
│ │ │ ├── instagram.njk
│ │ │ ├── youtube.njk
│ │ │ ├── twitter.njk
│ │ │ ├── email.njk
│ │ │ └── github.njk
│ │ ├── gam-ad-bottom.njk
│ │ ├── banner.njk
│ │ ├── gtm-head.njk
│ │ ├── pagination.njk
│ │ ├── learn-cta-row.njk
│ │ ├── ad.njk
│ │ ├── social-row.njk
│ │ ├── search-bar.njk
│ │ ├── prism.njk
│ │ ├── site-nav.njk
│ │ ├── role-list-item.njk
│ │ ├── card.njk
│ │ └── byline.njk
│ ├── assets
│ │ ├── js
│ │ │ ├── client-dayjs.js
│ │ │ ├── cookie-checker.js
│ │ │ ├── algolia-locale-setup.js
│ │ │ ├── time-ago.js
│ │ │ ├── toggle-menu-button.js
│ │ │ ├── published-date.js
│ │ │ ├── pagination.js
│ │ │ ├── banner.js
│ │ │ └── social-row.js
│ │ └── css
│ │ │ └── variables.css
│ └── layouts
│ │ ├── sitemap.njk
│ │ ├── feed.njk
│ │ ├── author.njk
│ │ └── tag.njk
├── feeds
│ └── feeds.njk
├── sitemaps
│ ├── sitemaps.njk
│ ├── schemas
│ │ ├── sitemap-image.xsd
│ │ └── siteindex.xsd
│ └── sitemaps.test.js
├── tags
│ └── tags.njk
├── posts
│ └── posts.njk
├── authors
│ └── authors.njk
├── docs
│ └── docs.njk
├── _data
│ ├── secrets.js
│ └── site.js
├── headers.njk
├── index.njk
├── 404.njk
└── search-results.njk
├── .prettierignore
├── utils
├── transforms
│ ├── css-min.js
│ └── js-min.js
├── wait.js
├── shorten-excerpt.js
├── translate.js
├── site-path.js
├── get-username.js
├── cache.js
├── load-json.js
├── full-escaper.js
├── error-logger.js
├── api.js
├── shortcodes
│ ├── dates.js
│ ├── cache-buster.js
│ └── images.js
├── dayjs.js
├── ghost
│ └── fetch-from-ghost.js
├── get-image-dimensions.js
├── search-bar-placeholder-number.js
├── ping-editorial-team.js
├── modify-html-content.js
├── fitvids.js
└── search-bar-placeholder-number.test.js
├── registry-test
├── Dockerfile
├── Makefile
└── index.js
├── .prettierrc
├── cypress
├── support
│ ├── utils
│ │ ├── rss.ts
│ │ └── post-cards.ts
│ ├── e2e.ts
│ └── commands.ts
├── tsconfig.json
├── e2e
│ ├── english
│ │ ├── 404
│ │ │ ├── i18n.cy.ts
│ │ │ └── 404.cy.ts
│ │ ├── author
│ │ │ ├── i18n.cy.ts
│ │ │ └── structured-data.cy.ts
│ │ ├── tag
│ │ │ ├── tag.cy.ts
│ │ │ ├── i18n.cy.ts
│ │ │ └── structured-data.cy.ts
│ │ ├── page
│ │ │ ├── page.cy.ts
│ │ │ └── structured-data.cy.ts
│ │ ├── landing
│ │ │ └── structured-data.cy.ts
│ │ ├── post
│ │ │ ├── ads.cy.ts
│ │ │ └── i18n.cy.ts
│ │ └── search-results
│ │ │ └── search-results.cy.ts
│ ├── chinese
│ │ ├── landing
│ │ │ └── landing.cy.ts
│ │ └── post
│ │ │ ├── post.cy.ts
│ │ │ └── ads.cy.ts
│ └── espanol
│ │ ├── author
│ │ ├── i18n.cy.ts
│ │ └── structured-data.cy.ts
│ │ ├── tag
│ │ ├── tag.cy.ts
│ │ ├── i18n.cy.ts
│ │ └── structured-data.cy.ts
│ │ ├── page
│ │ ├── page.cy.ts
│ │ └── structured-data.cy.ts
│ │ ├── landing
│ │ └── structured-data.cy.ts
│ │ └── post
│ │ ├── ads.cy.ts
│ │ ├── i18n.cy.ts
│ │ └── structured-data.cy.ts
└── fixtures
│ ├── mock-hashnode-pages.json
│ ├── common-expected-json-ld.json
│ └── common-expected-meta.json
├── cypress.config.js
├── .editorconfig
├── eslint.config.js
├── .github
├── workflows
│ ├── no-forks.yml
│ ├── crowdin-upload.yml
│ ├── docr-cleanup.yml
│ ├── cypress.yml
│ └── node.js-test.yml
└── CODEOWNERS
├── .gitignore
├── LICENSE.md
├── sample.env
└── tools
└── download-trending.js
/.nvmrc:
--------------------------------------------------------------------------------
1 | 22
2 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | engine-strict=true
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | * text=auto eol=lf
2 |
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | npx lint-staged
2 |
--------------------------------------------------------------------------------
/config/i18n/locales/espanol/redirects.json:
--------------------------------------------------------------------------------
1 | []
2 |
--------------------------------------------------------------------------------
/config/i18n/locales/italian/redirects.json:
--------------------------------------------------------------------------------
1 | []
2 |
--------------------------------------------------------------------------------
/config/i18n/locales/japanese/redirects.json:
--------------------------------------------------------------------------------
1 | []
2 |
--------------------------------------------------------------------------------
/config/i18n/locales/portuguese/redirects.json:
--------------------------------------------------------------------------------
1 | []
2 |
--------------------------------------------------------------------------------
/config/i18n/locales/ukrainian/redirects.json:
--------------------------------------------------------------------------------
1 | []
2 |
--------------------------------------------------------------------------------
/docker/test/prd/.env.sample:
--------------------------------------------------------------------------------
1 | REGISTRY_NAME=
2 | IMAGE_NAME=
3 |
--------------------------------------------------------------------------------
/renovate.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["github>freecodecamp/renovate-config"]
3 | }
4 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | testPathIgnorePatterns: ['/node_modules/', '/cypress/']
3 | };
4 |
--------------------------------------------------------------------------------
/src/htaccess.njk:
--------------------------------------------------------------------------------
1 | ---
2 | permalink: .htaccess
3 | ---
4 | ErrorDocument 404 /404.html
5 | Redirect 302 ^/rss/?$ /rss.xml
6 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | # Comment out the line below to run Prettier against the files in the final build
2 | dist/
3 | package-lock.json
4 |
--------------------------------------------------------------------------------
/docker/ghost/content/chinese/data/ghost.db:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/freeCodeCamp/news/HEAD/docker/ghost/content/chinese/data/ghost.db
--------------------------------------------------------------------------------
/docker/ghost/content/espanol/data/ghost.db:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/freeCodeCamp/news/HEAD/docker/ghost/content/espanol/data/ghost.db
--------------------------------------------------------------------------------
/utils/transforms/css-min.js:
--------------------------------------------------------------------------------
1 | import cleanCSS from 'clean-css';
2 |
3 | export default code => {
4 | return new cleanCSS({}).minify(code).styles;
5 | };
6 |
--------------------------------------------------------------------------------
/src/redirects.njk:
--------------------------------------------------------------------------------
1 | ---
2 | permalink: '_redirects'
3 | ---
4 |
5 | # Netlify redirects file, edit with your domain
6 | # https://www.netlify.com/docs/redirects/
7 |
--------------------------------------------------------------------------------
/utils/wait.js:
--------------------------------------------------------------------------------
1 | export const wait = ms => {
2 | return new Promise(resolve => {
3 | setTimeout(() => {
4 | resolve(ms);
5 | }, ms);
6 | });
7 | };
8 |
--------------------------------------------------------------------------------
/utils/shorten-excerpt.js:
--------------------------------------------------------------------------------
1 | export const shortenExcerpt = (str, maxLength = 50) => {
2 | return str.replace(/\n+/g, ' ').split(' ').slice(0, maxLength).join(' ');
3 | };
4 |
--------------------------------------------------------------------------------
/utils/translate.js:
--------------------------------------------------------------------------------
1 | import i18next from '../config/i18n/config.js';
2 |
3 | export const translate = (key, data) => {
4 | return i18next.t(key, { ...data });
5 | };
6 |
--------------------------------------------------------------------------------
/docker/ghost/content/chinese/images/2022/02/miya-liu-photo.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/freeCodeCamp/news/HEAD/docker/ghost/content/chinese/images/2022/02/miya-liu-photo.jpg
--------------------------------------------------------------------------------
/docker/ghost/content/espanol/images/2022/02/rafael-photo.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/freeCodeCamp/news/HEAD/docker/ghost/content/espanol/images/2022/02/rafael-photo.jpeg
--------------------------------------------------------------------------------
/src/robots.njk:
--------------------------------------------------------------------------------
1 | ---
2 | permalink: 'robots.txt'
3 | ---
4 | User-agent: *
5 | Sitemap: {{ "/sitemap.xml" | htmlBaseUrl(site.url) }}
6 | Disallow: /ghost/
7 | Disallow: /p/
8 |
--------------------------------------------------------------------------------
/docker/ghost/content/chinese/images/2022/02/miya-liu-photo_o.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/freeCodeCamp/news/HEAD/docker/ghost/content/chinese/images/2022/02/miya-liu-photo_o.jpg
--------------------------------------------------------------------------------
/docker/ghost/content/espanol/images/2022/02/rafael-photo_o.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/freeCodeCamp/news/HEAD/docker/ghost/content/espanol/images/2022/02/rafael-photo_o.jpeg
--------------------------------------------------------------------------------
/docker/ghost/content/espanol/images/2024/09/fccbg_25e868b401.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/freeCodeCamp/news/HEAD/docker/ghost/content/espanol/images/2024/09/fccbg_25e868b401.png
--------------------------------------------------------------------------------
/docker/ghost/content/espanol/images/size/w30/2022/02/rafael-photo.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/freeCodeCamp/news/HEAD/docker/ghost/content/espanol/images/size/w30/2022/02/rafael-photo.jpeg
--------------------------------------------------------------------------------
/docker/ghost/content/chinese/images/size/w100/2022/02/miya-liu-photo.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/freeCodeCamp/news/HEAD/docker/ghost/content/chinese/images/size/w100/2022/02/miya-liu-photo.jpg
--------------------------------------------------------------------------------
/docker/ghost/content/chinese/images/size/w30/2022/02/miya-liu-photo.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/freeCodeCamp/news/HEAD/docker/ghost/content/chinese/images/size/w30/2022/02/miya-liu-photo.jpg
--------------------------------------------------------------------------------
/docker/ghost/content/espanol/images/size/w100/2022/02/rafael-photo.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/freeCodeCamp/news/HEAD/docker/ghost/content/espanol/images/size/w100/2022/02/rafael-photo.jpeg
--------------------------------------------------------------------------------
/registry-test/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:22-alpine
2 |
3 | ARG BUILD_ID
4 | ENV BUILD_ID=${BUILD_ID}
5 |
6 | WORKDIR /app
7 | COPY . .
8 | EXPOSE 3000
9 |
10 | CMD ["node", "index.js"]
11 |
--------------------------------------------------------------------------------
/docker/ghost/content/chinese/images/2022/02/freecodecamp-org-gravatar.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/freeCodeCamp/news/HEAD/docker/ghost/content/chinese/images/2022/02/freecodecamp-org-gravatar.jpeg
--------------------------------------------------------------------------------
/docker/ghost/content/espanol/images/2022/02/freecodecamp-org-gravatar.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/freeCodeCamp/news/HEAD/docker/ghost/content/espanol/images/2022/02/freecodecamp-org-gravatar.jpeg
--------------------------------------------------------------------------------
/docker/ghost/content/espanol/images/2022/05/5f9c9afc740569d1a4ca2907.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/freeCodeCamp/news/HEAD/docker/ghost/content/espanol/images/2022/05/5f9c9afc740569d1a4ca2907.jpeg
--------------------------------------------------------------------------------
/docker/ghost/content/espanol/images/2022/05/5f9c9afc740569d1a4ca2907_o.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/freeCodeCamp/news/HEAD/docker/ghost/content/espanol/images/2022/05/5f9c9afc740569d1a4ca2907_o.jpeg
--------------------------------------------------------------------------------
/docker/ghost/content/espanol/images/2024/09/low-rez-publication-cover.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/freeCodeCamp/news/HEAD/docker/ghost/content/espanol/images/2024/09/low-rez-publication-cover.png
--------------------------------------------------------------------------------
/utils/site-path.js:
--------------------------------------------------------------------------------
1 | import { URL } from 'url';
2 |
3 | import { config } from '../config/index.js';
4 |
5 | const { siteURL } = config;
6 |
7 | export const sitePath = new URL(siteURL).pathname;
8 |
--------------------------------------------------------------------------------
/config/i18n/locales/chinese/redirects.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "source": "/deno-oak-lian-jie-mysql-shi-zhan-jiao-cheng",
4 | "destination": "/chinese/news/how-to-use-mysql-in-deno-oak"
5 | }
6 | ]
7 |
--------------------------------------------------------------------------------
/docker/ghost/content/chinese/images/2022/02/freecodecamp-org-gravatar_o.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/freeCodeCamp/news/HEAD/docker/ghost/content/chinese/images/2022/02/freecodecamp-org-gravatar_o.jpeg
--------------------------------------------------------------------------------
/docker/ghost/content/espanol/images/2022/02/freecodecamp-org-gravatar_o.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/freeCodeCamp/news/HEAD/docker/ghost/content/espanol/images/2022/02/freecodecamp-org-gravatar_o.jpeg
--------------------------------------------------------------------------------
/src/_includes/partials/gtm-body.njk:
--------------------------------------------------------------------------------
1 |
3 |
--------------------------------------------------------------------------------
/utils/get-username.js:
--------------------------------------------------------------------------------
1 | // Currently used to get X / Twitter and Facebook useranmes from URLs
2 | export const getUsername = url => {
3 | return new URL(url).pathname.split('/').filter(Boolean)[0];
4 | };
5 |
--------------------------------------------------------------------------------
/docker/ghost/content/espanol/images/size/w30/size/w30/2022/02/rafael-photo.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/freeCodeCamp/news/HEAD/docker/ghost/content/espanol/images/size/w30/size/w30/2022/02/rafael-photo.jpeg
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "endOfLine": "lf",
3 | "semi": true,
4 | "singleQuote": true,
5 | "jsxSingleQuote": true,
6 | "tabWidth": 2,
7 | "trailingComma": "none",
8 | "arrowParens": "avoid"
9 | }
10 |
--------------------------------------------------------------------------------
/docker/ghost/content/espanol/images/2024/09/photo-1528724977141-d90af338860c.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/freeCodeCamp/news/HEAD/docker/ghost/content/espanol/images/2024/09/photo-1528724977141-d90af338860c.jpeg
--------------------------------------------------------------------------------
/docker/ghost/content/chinese/images/size/w100/2022/02/freecodecamp-org-gravatar.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/freeCodeCamp/news/HEAD/docker/ghost/content/chinese/images/size/w100/2022/02/freecodecamp-org-gravatar.jpeg
--------------------------------------------------------------------------------
/docker/ghost/content/espanol/images/size/w100/2022/02/freecodecamp-org-gravatar.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/freeCodeCamp/news/HEAD/docker/ghost/content/espanol/images/size/w100/2022/02/freecodecamp-org-gravatar.jpeg
--------------------------------------------------------------------------------
/utils/cache.js:
--------------------------------------------------------------------------------
1 | import NodeCache from 'node-cache';
2 |
3 | const cache = new NodeCache();
4 |
5 | export const getCache = key => cache.get(key);
6 |
7 | export const setCache = (key, data) => cache.set(key, data);
8 |
--------------------------------------------------------------------------------
/docker/ghost/content/chinese/settings/routes.yaml:
--------------------------------------------------------------------------------
1 | routes:
2 |
3 | collections:
4 | /:
5 | permalink: /{slug}/
6 | template: index
7 |
8 | taxonomies:
9 | tag: /tag/{slug}/
10 | author: /author/{slug}/
11 |
--------------------------------------------------------------------------------
/docker/ghost/content/espanol/settings/routes.yaml:
--------------------------------------------------------------------------------
1 | routes:
2 |
3 | collections:
4 | /:
5 | permalink: /{slug}/
6 | template: index
7 |
8 | taxonomies:
9 | tag: /tag/{slug}/
10 | author: /author/{slug}/
11 |
--------------------------------------------------------------------------------
/src/feeds/feeds.njk:
--------------------------------------------------------------------------------
1 | ---
2 | pagination:
3 | data: datasource.feeds
4 | size: 1
5 | alias: feed
6 | addAllPagesToCollections: true
7 | layout: 'layouts/feed.njk'
8 | permalink: '{{ feed.path }}/rss.xml'
9 | ---
10 |
--------------------------------------------------------------------------------
/src/_includes/partials/icons/infinity.njk:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/src/sitemaps/sitemaps.njk:
--------------------------------------------------------------------------------
1 | ---
2 | pagination:
3 | data: datasource.sitemaps
4 | size: 1
5 | alias: sitemap
6 | addAllPagesToCollections: true
7 | layout: 'layouts/sitemap.njk'
8 | permalink: '{{ sitemap.path }}'
9 | ---
10 |
--------------------------------------------------------------------------------
/utils/load-json.js:
--------------------------------------------------------------------------------
1 | import gracefulFS from 'graceful-fs';
2 |
3 | const { readFileSync } = gracefulFS;
4 |
5 | export const loadJSON = filename => {
6 | const data = readFileSync(filename, 'utf-8');
7 | return JSON.parse(data);
8 | };
9 |
--------------------------------------------------------------------------------
/cypress/support/utils/rss.ts:
--------------------------------------------------------------------------------
1 | export const XMLToDOM = (xml: string) => {
2 | const parser = new DOMParser();
3 |
4 | return parser.parseFromString(xml, 'application/xml');
5 | };
6 |
7 | module.exports = {
8 | XMLToDOM
9 | };
10 |
--------------------------------------------------------------------------------
/src/tags/tags.njk:
--------------------------------------------------------------------------------
1 | ---
2 | pagination:
3 | data: datasource.tags
4 | size: 1
5 | alias: tag
6 | addAllPagesToCollections: true
7 | layout: 'layouts/tag.njk'
8 | permalink: '{{ tag.path }}/{% if tag.page %}{{ tag.page }}/{% endif %}'
9 | ---
10 |
--------------------------------------------------------------------------------
/src/_includes/partials/gam-ad-bottom.njk:
--------------------------------------------------------------------------------
1 |
2 | {% set localizedAdText %}{% t 'ad-text' %}{% endset %}
3 |
{{ localizedAdText | upper }}
4 |
5 |
6 |
--------------------------------------------------------------------------------
/src/posts/posts.njk:
--------------------------------------------------------------------------------
1 | ---
2 | pagination:
3 | data: datasource.posts
4 | size: 1
5 | alias: post
6 | addAllPagesToCollections: true
7 | layout: 'layouts/post.njk'
8 | permalink: '{{ post.path }}'
9 | ---
10 |
11 | {{ post.html | safe if post.html }}
12 |
--------------------------------------------------------------------------------
/src/authors/authors.njk:
--------------------------------------------------------------------------------
1 | ---
2 | pagination:
3 | data: datasource.authors
4 | size: 1
5 | alias: author
6 | addAllPagesToCollections: true
7 | layout: 'layouts/author.njk'
8 | permalink: '{{ author.path }}/{% if author.page %}{{ author.page }}/{% endif %}'
9 | ---
10 |
--------------------------------------------------------------------------------
/src/_includes/assets/js/client-dayjs.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-undef */
2 | document.addEventListener('DOMContentLoaded', () => {
3 | // Load dayjs plugins and set locale
4 | dayjs.extend(dayjs_plugin_localizedFormat);
5 | dayjs.extend(dayjs_plugin_relativeTime);
6 | dayjs.locale('{{ site.lang | lower }}');
7 | });
8 |
--------------------------------------------------------------------------------
/cypress.config.js:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'cypress';
2 |
3 | import { config } from './config/index.js';
4 | const { postsPerPage } = config;
5 |
6 | export default defineConfig({
7 | e2e: {
8 | baseUrl: 'http://localhost:8080/news/',
9 | retries: 4
10 | },
11 | env: {
12 | postsPerPage
13 | }
14 | });
15 |
--------------------------------------------------------------------------------
/src/_includes/partials/icons/rss.njk:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/src/docs/docs.njk:
--------------------------------------------------------------------------------
1 | ---
2 | pagination:
3 | data: datasource.pages
4 | size: 1
5 | alias: 'doc'
6 | addAllPagesToCollections: true
7 | layout: 'layouts/doc.njk'
8 | permalink: '{{ doc.path }}'
9 | ---
10 |
11 | {# pages are aliased as 'doc' to prevent conflict with the 11ty page object #}
12 |
13 | {{ doc.html | safe if doc.html }}
14 |
--------------------------------------------------------------------------------
/src/_includes/assets/js/cookie-checker.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-unused-vars */
2 | // Global
3 |
4 | const isAuthenticated = document.cookie
5 | .split(';')
6 | .some(item => item.trim().startsWith('jwt_access_token='));
7 |
8 | const isDonor = document.cookie
9 | .split(';')
10 | .some(item => item.trim().startsWith('isDonor=true'));
11 |
--------------------------------------------------------------------------------
/src/_includes/partials/icons/point.njk:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/src/_includes/partials/banner.njk:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | {% block headScripts %}
6 | {% set js %}
7 | {% include "assets/js/banner.js" %}
8 | {% endset %}
9 |
10 | {% endblock %}
--------------------------------------------------------------------------------
/utils/full-escaper.js:
--------------------------------------------------------------------------------
1 | import escape from 'lodash/escape.js';
2 |
3 | // Mimic Ghost/Handlebars escaping
4 | // raw: & < > " ' ` =
5 | // html-escaped: & < > " ' ` =
6 | export const fullEscaper = s =>
7 | escape(s)
8 | .replace(/&(amp;)?#39;/g, ''')
9 | .replace(/`/g, '`')
10 | .replace(/=/g, '=');
11 |
--------------------------------------------------------------------------------
/utils/transforms/js-min.js:
--------------------------------------------------------------------------------
1 | import { minify } from 'terser';
2 |
3 | export default async (code, callback) => {
4 | try {
5 | const minified = await minify(code);
6 | callback(null, minified.code);
7 | } catch (err) {
8 | console.error('Terser error: ', err, code);
9 | // Fail gracefully
10 | callback(null, code);
11 | }
12 | };
13 |
--------------------------------------------------------------------------------
/docker/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:22-alpine
2 | RUN npm install -g --progress=false serve
3 |
4 | ARG BUILD_LANGUAGE
5 |
6 | WORKDIR /var/www/html/
7 | COPY dist .
8 |
9 | WORKDIR /app
10 | COPY docker/languages/$BUILD_LANGUAGE/serve.json .
11 |
12 | EXPOSE 3000
13 | CMD serve --config /app/serve.json --cors --no-clipboard --no-port-switching -p 3000 /var/www/html
14 |
--------------------------------------------------------------------------------
/src/_includes/partials/icons/location.njk:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/config/i18n/locales/portuguese/meta-tags.json:
--------------------------------------------------------------------------------
1 | {
2 | "title": "freeCodeCamp.org",
3 | "keywords": "freeCodeCamp, programação, front-end, programador, artigo, expressões regulares, Python, JavaScript, AWS, JSON, HTML, CSS, Bootstrap, React, Vue, Webpack",
4 | "description": "Aprenda a codificar - de graça. Tutoriais de programação em Python, JavaScript, Linux e muito mais."
5 | }
6 |
--------------------------------------------------------------------------------
/utils/error-logger.js:
--------------------------------------------------------------------------------
1 | import gracefulFS from 'graceful-fs';
2 |
3 | const { writeFileSync } = gracefulFS;
4 | const reportedErrors = [];
5 |
6 | export const errorLogger = ({ type, name }) => {
7 | if (!reportedErrors.includes(name)) {
8 | reportedErrors.push(name);
9 |
10 | return writeFileSync(`${type}-errors.log`, `${name}\n`, { flag: 'a+' });
11 | }
12 | };
13 |
--------------------------------------------------------------------------------
/config/i18n/locales/chinese/meta-tags.json:
--------------------------------------------------------------------------------
1 | {
2 | "title": "freeCodeCamp 中文编程教程:Python、JavaScript、Java、Git 等",
3 | "keywords": "freeCodeCamp, freeCodeCamp中文, 编程, 前端, 程序员, Python, JavaScript, Git, AWS, JSON, HTML, CSS, Bootstrap, React, Vue",
4 | "description": "freeCodeCamp 是一个免费学习编程的开发者社区,涵盖 Python、HTML、CSS、React、Vue、BootStrap、JSON 教程等,还有活跃的技术论坛和丰富的社区活动,在你学习编程和找工作时为你提供建议和帮助。"
5 | }
6 |
--------------------------------------------------------------------------------
/config/i18n/locales/japanese/meta-tags.json:
--------------------------------------------------------------------------------
1 | {
2 | "title": "freeCodeCamp プログラミングチュートリアル: Python, JavaScript, Git 他",
3 | "keywords": "freeCodeCamp, フリーコードキャンプ, プログラミング, フロントエンド, プログラマー, 記事, 正規表現, Python, JavaScript, AWS, JSON, HTML, CSS, Bootstrap, React, Vue, Webpack",
4 | "description": "エキスパートの手によるプログラミングチュートリアル記事を幅広く掲載。ウェブ開発、データサイエンス、DevOps、セキュリティ、開発者としてのキャリアなどについて学びましょう。"
5 | }
6 |
--------------------------------------------------------------------------------
/docker/test/prd/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3'
2 | services:
3 | mock-web:
4 | image: nginx
5 | volumes:
6 | - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf
7 | ports:
8 | - '80:80'
9 | depends_on:
10 | - news
11 | news:
12 | image: registry.digitalocean.com/${REGISTRY_NAME}/${IMAGE_NAME}:latest
13 | ports:
14 | - '3000:3000'
15 |
--------------------------------------------------------------------------------
/config/i18n/locales/italian/meta-tags.json:
--------------------------------------------------------------------------------
1 | {
2 | "title": "freeCodeCamp.org",
3 | "keywords": "freeCodeCamp, programmazione, front-end, programmatore, articolo, espressioni regolari, Python, JavaScript, AWS, JSON, HTML, CSS, Bootstrap, React, Vue, Webpack",
4 | "description": "Impara a programmare gratuitamente! Tutorial di programmazione su Python, JavaScript, Linux e molto altro."
5 | }
6 |
--------------------------------------------------------------------------------
/src/_includes/partials/gtm-head.njk:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | indent_style = space
5 | indent_size = 2
6 | end_of_line = lf
7 | charset = utf-8
8 | trim_trailing_whitespace = true
9 | insert_final_newline = true
10 |
11 | [package.json]
12 | indent_style = space
13 | indent_size = 2
14 |
15 | [*.md]
16 | trim_trailing_whitespace = false
17 |
18 | [makefile]
19 | indent_style = tab
20 | [Makefile]
21 | indent_style = tab
22 |
--------------------------------------------------------------------------------
/cypress/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["es6", "dom"],
5 | "allowJs": true,
6 | "types": ["cypress", "node"],
7 | "moduleDetection": "force",
8 | "module": "commonjs",
9 | "moduleResolution": "node",
10 | "preserveValueImports": false,
11 | "resolveJsonModule": true,
12 | "esModuleInterop": true
13 | },
14 | "include": ["**/*.ts"]
15 | }
16 |
--------------------------------------------------------------------------------
/src/_includes/partials/icons/facebook.njk:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/src/_includes/partials/pagination.njk:
--------------------------------------------------------------------------------
1 | {% if pagination.nextPageHref | nextPageExists %}
2 |
3 | {% t 'buttons.load-more-articles' %}
4 |
5 |
6 | {% set js %}
7 | {% include "assets/js/pagination.js" %}
8 | {% endset %}
9 |
10 | {% endif %}
11 |
--------------------------------------------------------------------------------
/docker/test/dev/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3'
2 | services:
3 | mock-web:
4 | image: nginx
5 | volumes:
6 | - ./html:/usr/share/nginx/html
7 | - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf
8 | ports:
9 | - '80:80'
10 | depends_on:
11 | - news
12 | news:
13 | build:
14 | context: ../../../
15 | dockerfile: ./docker/Dockerfile
16 | ports:
17 | - '3000:3000'
18 |
--------------------------------------------------------------------------------
/src/_includes/partials/icons/avatar.njk:
--------------------------------------------------------------------------------
1 | {{ avatarTitle }}
2 |
--------------------------------------------------------------------------------
/src/_includes/partials/icons/stackoverflow.njk:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/docker/test/prd/nginx/nginx.conf:
--------------------------------------------------------------------------------
1 |
2 | upstream news {
3 | server news:3000;
4 | }
5 |
6 | server {
7 | listen 80;
8 | server_name learn;
9 |
10 | location /news/ {
11 | proxy_pass http://news/;
12 | proxy_set_header X-Real-IP $remote_addr;
13 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
14 | proxy_set_header Host $http_host;
15 | proxy_cache_bypass $http_upgrade;
16 | proxy_redirect off;
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/src/_includes/assets/js/algolia-locale-setup.js:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line no-unused-vars
2 | let client, index;
3 |
4 | document.addEventListener('DOMContentLoaded', () => {
5 | // load Algolia and set index globally
6 | // eslint-disable-next-line no-undef
7 | client = algoliasearch(
8 | '{{ secrets.algoliaAppId }}',
9 | '{{ secrets.algoliaAPIKey }}'
10 | );
11 |
12 | index = client.initIndex('{{ secrets.algoliaIndex }}');
13 | });
14 |
--------------------------------------------------------------------------------
/src/_includes/partials/icons/x.njk:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/config/i18n/locales/dothraki/meta-tags.json:
--------------------------------------------------------------------------------
1 | {
2 | "title": "freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More",
3 | "keywords": "freeCodeCamp, programming, front-end, programmer, article, regular expressions, Python, JavaScript, AWS, JSON, HTML, CSS, Bootstrap, React, Vue, Webpack",
4 | "description": "Browse thousands of programming tutorials written by experts. Learn Web Development, Data Science, DevOps, Security, and get developer career advice."
5 | }
6 |
--------------------------------------------------------------------------------
/config/i18n/locales/english/meta-tags.json:
--------------------------------------------------------------------------------
1 | {
2 | "title": "freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More",
3 | "keywords": "freeCodeCamp, programming, front-end, programmer, article, regular expressions, Python, JavaScript, AWS, JSON, HTML, CSS, Bootstrap, React, Vue, Webpack",
4 | "description": "Browse thousands of programming tutorials written by experts. Learn Web Development, Data Science, DevOps, Security, and get developer career advice."
5 | }
6 |
--------------------------------------------------------------------------------
/config/i18n/locales/korean/meta-tags.json:
--------------------------------------------------------------------------------
1 | {
2 | "title": "freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More",
3 | "keywords": "freeCodeCamp, programming, front-end, programmer, article, regular expressions, Python, JavaScript, AWS, JSON, HTML, CSS, Bootstrap, React, Vue, Webpack",
4 | "description": "Browse thousands of programming tutorials written by experts. Learn Web Development, Data Science, DevOps, Security, and get developer career advice."
5 | }
6 |
--------------------------------------------------------------------------------
/config/i18n/locales/ukrainian/meta-tags.json:
--------------------------------------------------------------------------------
1 | {
2 | "title": "freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More",
3 | "keywords": "freeCodeCamp, programming, front-end, programmer, article, regular expressions, Python, JavaScript, AWS, JSON, HTML, CSS, Bootstrap, React, Vue, Webpack",
4 | "description": "Browse thousands of programming tutorials written by experts. Learn Web Development, Data Science, DevOps, Security, and get developer career advice."
5 | }
6 |
--------------------------------------------------------------------------------
/src/_includes/assets/js/time-ago.js:
--------------------------------------------------------------------------------
1 | document.addEventListener('DOMContentLoaded', () => {
2 | const postDates = [
3 | ...document.querySelectorAll(
4 | '.post-card-meta .meta-content > time.meta-item'
5 | )
6 | ];
7 |
8 | postDates.forEach(date => {
9 | const dateStr = date.getAttribute('datetime');
10 |
11 | // Display time ago date
12 | // eslint-disable-next-line no-undef
13 | date.innerHTML = dayjs().to(dayjs(dateStr));
14 | });
15 | });
16 |
--------------------------------------------------------------------------------
/src/_includes/partials/icons/website.njk:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/cypress/e2e/english/author/i18n.cy.ts:
--------------------------------------------------------------------------------
1 | const selectors = {
2 | authorPostCount: "[data-test-label='author-post-count']"
3 | };
4 |
5 | describe('Author page i18n (Hashnode sourced)', () => {
6 | it('an author page with one post does not render its post count i18n key', () => {
7 | cy.visit('/author/abbeyrenn/');
8 |
9 | cy.get(selectors.authorPostCount)
10 | .invoke('text')
11 | .then(text => text.trim())
12 | .should('not.equal', 'author.one-post');
13 | });
14 | });
15 |
--------------------------------------------------------------------------------
/cypress/e2e/chinese/landing/landing.cy.ts:
--------------------------------------------------------------------------------
1 | describe('Landing', () => {
2 | before(() => {
3 | // Update baseUrl to include current language
4 | Cypress.config('baseUrl', 'http://localhost:8080/chinese/news/');
5 | });
6 |
7 | beforeEach(() => {
8 | cy.visit('/');
9 | });
10 |
11 | it('should render basic components', () => {
12 | cy.get('nav').should('be.visible');
13 | cy.get('.banner').should('be.visible');
14 | cy.get('footer').should('be.visible');
15 | });
16 | });
17 |
--------------------------------------------------------------------------------
/src/_data/secrets.js:
--------------------------------------------------------------------------------
1 | import { config } from '../../config/index.js';
2 |
3 | const {
4 | algoliaAppId,
5 | algoliaAPIKey,
6 | algoliaIndex,
7 | adsEnabled,
8 | eleventyEnv,
9 | googleAdsenseDataAdClient,
10 | googleAdsenseDataAdSlot,
11 | postsPerPage
12 | } = config;
13 |
14 | export default {
15 | algoliaAppId,
16 | algoliaAPIKey,
17 | algoliaIndex,
18 | adsEnabled,
19 | eleventyEnv,
20 | googleAdsenseDataAdClient,
21 | googleAdsenseDataAdSlot,
22 | postsPerPage
23 | };
24 |
--------------------------------------------------------------------------------
/config/i18n/locales/espanol/meta-tags.json:
--------------------------------------------------------------------------------
1 | {
2 | "title": "Cursos de programación freeCodeCamp en Español: Python, JavaScript, Git y más",
3 | "keywords": "freeCodeCamp, programación, front-end, programador, artículo, expresiones regulares, Python, JavaScript, AWS, JSON, HTML, CSS, Bootstrap, React, Vue, Webpack",
4 | "description": "Descubre miles de cursos de programación escritos por expertos. Aprende Desarrollo Web, Ciencia de Datos, DevOps, Seguridad y obtén asesoramiento profesional para desarrolladores."
5 | }
6 |
--------------------------------------------------------------------------------
/src/_includes/partials/learn-cta-row.njk:
--------------------------------------------------------------------------------
1 | {% set learnURL %}{% t 'links:learn' %}{% endset %}
2 |
3 |
4 |
5 | {% t 'learn-to-code-cta', {
6 | '<0>': '',
7 | '0>': ' ',
8 | interpolation: {
9 | escapeValue: false
10 | }
11 | } %}
12 |
13 |
14 |
--------------------------------------------------------------------------------
/src/_includes/assets/js/toggle-menu-button.js:
--------------------------------------------------------------------------------
1 | document.addEventListener('DOMContentLoaded', () => {
2 | document
3 | .getElementById('toggle-button-nav')
4 | .addEventListener('click', function () {
5 | const dropDownMenu = document.getElementById('menu-dropdown');
6 | const toggleButton = document.getElementById('toggle-button-nav');
7 | dropDownMenu.classList.toggle('display-menu');
8 | toggleButton.ariaExpanded =
9 | toggleButton.ariaExpanded == 'true' ? 'false' : 'true';
10 | });
11 | });
12 |
--------------------------------------------------------------------------------
/config/i18n/locales/korean/redirects.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "source": "/@:authorName",
4 | "destination": "/korean/news/author/:authorName"
5 | },
6 | {
7 | "source": "/tagged/:tagName",
8 | "destination": "/korean/news/tag/:tagName"
9 | },
10 | {
11 | "source": "/archive/:archiveSlug?",
12 | "destination": "/korean/news"
13 | },
14 | {
15 | "source": "/our-sponsors",
16 | "destination": "/korean/news/sponsors"
17 | },
18 | {
19 | "source": "/about-freecodecamp",
20 | "destination": "/korean/news/about"
21 | }
22 | ]
23 |
--------------------------------------------------------------------------------
/registry-test/Makefile:
--------------------------------------------------------------------------------
1 | ifeq ($(TAGNAME_CI),)
2 | LOCAL_TAGNAME := $(shell git rev-parse --short HEAD)-$(shell date +%Y%m%d)-$(shell date +%H%M)
3 | else
4 | LOCAL_TAGNAME := $(TAGNAME_CI)
5 | endif
6 |
7 | DOCKER_REPO := registry.digitalocean.com/${REGISTRY_NAME}/ops/test
8 |
9 | .PHONY: build
10 | build:
11 | docker build \
12 | --build-arg BUILD_ID=$(LOCAL_TAGNAME) \
13 | --platform linux/amd64 \
14 | --tag $(DOCKER_REPO):${LOCAL_TAGNAME} \
15 | --tag $(DOCKER_REPO):latest \
16 | .
17 |
18 | .PHONY: push
19 | push:
20 | docker push --all-tags $(DOCKER_REPO)
21 |
--------------------------------------------------------------------------------
/config/i18n/locales/dothraki/redirects.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "source": "/@:authorName",
4 | "destination": "/dothraki/news/author/:authorName"
5 | },
6 | {
7 | "source": "/tagged/:tagName",
8 | "destination": "/dothraki/news/tag/:tagName"
9 | },
10 | {
11 | "source": "/archive/:archiveSlug?",
12 | "destination": "/dothraki/news"
13 | },
14 | {
15 | "source": "/our-sponsors",
16 | "destination": "/dothraki/news/sponsors"
17 | },
18 | {
19 | "source": "/about-freecodecamp",
20 | "destination": "/dothraki/news/about"
21 | }
22 | ]
23 |
--------------------------------------------------------------------------------
/src/_includes/partials/icons/linkedin.njk:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/src/_includes/partials/icons/magnifying-glass.njk:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/docker/ghost/config.production.json:
--------------------------------------------------------------------------------
1 | {
2 | "url": "http://localhost:2368",
3 | "server": {
4 | "port": 2368,
5 | "host": "0.0.0.0"
6 | },
7 | "database": {
8 | "client": "sqlite3",
9 | "connection": {
10 | "filename": "/var/lib/ghost/content/data/ghost.db"
11 | }
12 | },
13 | "mail": {
14 | "transport": "Direct"
15 | },
16 | "logging": {
17 | "transports": ["file", "stdout"]
18 | },
19 | "process": "systemd",
20 | "paths": {
21 | "contentPath": "/var/lib/ghost/content"
22 | },
23 | "privacy": {
24 | "useGravatar": false
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/_includes/partials/icons/instagram.njk:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/cypress/e2e/chinese/post/post.cy.ts:
--------------------------------------------------------------------------------
1 | const selectors = {
2 | socialRow: "[data-test-label='social-row']"
3 | };
4 |
5 | describe('Post', () => {
6 | before(() => {
7 | // Update baseUrl to include current language
8 | Cypress.config('baseUrl', 'http://localhost:8080/chinese/news/');
9 | });
10 |
11 | beforeEach(() => {
12 | cy.visit('/javascript-array-length/');
13 | });
14 |
15 | it('should render', () => {
16 | cy.contains('JavaScript 数组的长度');
17 | });
18 |
19 | it('should not display a social row', () => {
20 | cy.get(selectors.socialRow).should('not.exist');
21 | });
22 | });
23 |
--------------------------------------------------------------------------------
/src/_includes/partials/icons/youtube.njk:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/src/headers.njk:
--------------------------------------------------------------------------------
1 | ---
2 | permalink: '_headers'
3 | ---
4 |
5 | /feed.xml
6 | content-type: application/rss+xml; charset=UTF-8
7 |
8 | # These are default recommended security headers, for Netlify
9 | # https://www.netlify.com/docs/headers-and-basic-auth/
10 |
11 | /*
12 | Referrer-Policy: no-referrer-when-downgrade
13 | Strict-Transport-Security: max-age=31536000
14 | X-Content-Type-Options: nosniff
15 | X-Frame-Options: SAMEORIGIN
16 | X-Xss-Protection: 1; mode=block
17 | Feature-Policy: accelerometer 'none'; camera 'none'; geolocation 'none'; gyroscope 'none'; magnetometer 'none'; microphone 'none'; payment 'none'; usb 'none'
18 |
--------------------------------------------------------------------------------
/cypress/e2e/espanol/author/i18n.cy.ts:
--------------------------------------------------------------------------------
1 | const selectors = {
2 | authorPostCount: "[data-test-label='author-post-count']"
3 | };
4 |
5 | describe('Author page i18n (Ghost sourced)', () => {
6 | before(() => {
7 | // Update baseUrl to include current language
8 | Cypress.config('baseUrl', 'http://localhost:8080/espanol/news/');
9 | });
10 |
11 | it('an author page with one post does not render its post count i18n key', () => {
12 | cy.visit('/author/rafael/');
13 |
14 | cy.get(selectors.authorPostCount)
15 | .invoke('text')
16 | .then(text => text.trim())
17 | .should('not.equal', 'author.one-post');
18 | });
19 | });
20 |
--------------------------------------------------------------------------------
/docker/test/dev/nginx/nginx.conf:
--------------------------------------------------------------------------------
1 |
2 | upstream news {
3 | server news:3000;
4 | }
5 |
6 | server {
7 | listen 80;
8 | server_name learn;
9 |
10 | root /usr/share/nginx/html;
11 |
12 | location / {
13 | try_files $uri /index.html;
14 | }
15 |
16 | location /dothraki/ {
17 | try_files $uri /dothraki/index.html;
18 | }
19 |
20 | location /italian/news/ {
21 | proxy_pass http://news/;
22 | proxy_set_header X-Real-IP $remote_addr;
23 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
24 | proxy_set_header Host $http_host;
25 | proxy_cache_bypass $http_upgrade;
26 | proxy_redirect off;
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/src/_includes/partials/ad.njk:
--------------------------------------------------------------------------------
1 |
2 | {% set localizedAdText %}{% t 'ad-text' %}{% endset %}
3 |
{{ localizedAdText | upper }}
4 |
12 |
15 |
16 |
--------------------------------------------------------------------------------
/cypress/e2e/english/tag/tag.cy.ts:
--------------------------------------------------------------------------------
1 | import { loadAndCountAllPostCards } from '../../../support/utils/post-cards';
2 |
3 | const selectors = {
4 | tagName: "[data-test-label='tag-name']",
5 | tagPostCount: "[data-test-label='tag-post-count']"
6 | };
7 |
8 | describe('Tag pages (Hashnode sourced)', () => {
9 | beforeEach(() => {
10 | cy.visit('/tag/c-programming/');
11 | });
12 |
13 | it('should render', () => {
14 | cy.contains(selectors.tagName, '#C PROGRAMMING');
15 | });
16 |
17 | it('the number of total posts should match the post count at the top of the page (1)', () => {
18 | loadAndCountAllPostCards(selectors.tagPostCount);
19 | });
20 | });
21 |
--------------------------------------------------------------------------------
/utils/api.js:
--------------------------------------------------------------------------------
1 | import GhostContentAPI from '@tryghost/content-api';
2 |
3 | import { config } from '../config/index.js';
4 |
5 | const { currentLocale_ghost } = config;
6 |
7 | const upperLocale = currentLocale_ghost.toUpperCase();
8 | const url = process.env[`${upperLocale}_GHOST_API_URL`];
9 | const key = process.env[`${upperLocale}_GHOST_CONTENT_API_KEY`];
10 | const version = process.env[`${upperLocale}_GHOST_API_VERSION`];
11 |
12 | export const ghostAPI = process.env.DO_NOT_FETCH_FROM_GHOST
13 | ? null
14 | : new GhostContentAPI({ url, key, version });
15 |
16 | export const ghostAPIURL = url;
17 |
18 | export const hashnodeHost = process.env[`${upperLocale}_HASHNODE_HOST`];
19 |
--------------------------------------------------------------------------------
/utils/shortcodes/dates.js:
--------------------------------------------------------------------------------
1 | import dayjs from '../dayjs.js';
2 |
3 | export const publishedDateShortcode = dateStr => dayjs(dateStr).format('LL');
4 |
5 | export const timeAgoShortcode = dateStr => dayjs().to(dayjs(dateStr));
6 |
7 | export const fullYearShortcode = () => dayjs(new Date()).format('YYYY');
8 |
9 | // Format dates for RSS feed
10 | export const buildDateFormatterShortcode = (timezone, dateStr) => {
11 | const dateObj = dateStr ? new Date(dateStr) : new Date();
12 | return dayjs(dateObj)
13 | .tz(timezone)
14 | .locale('en')
15 | .format('ddd, DD MMM YYYY HH:mm:ss ZZ');
16 | };
17 |
18 | export const toISOStringShortcode = dateStr => new Date(dateStr).toISOString();
19 |
--------------------------------------------------------------------------------
/cypress/e2e/english/tag/i18n.cy.ts:
--------------------------------------------------------------------------------
1 | const selectors = {
2 | tagPostCount: "[data-test-label='tag-post-count']"
3 | };
4 |
5 | describe('Tag page i18n (Hashnode sourced)', () => {
6 | it('a tag page with 1 post does not render its post count i18n key', () => {
7 | cy.visit('/tag/music/');
8 |
9 | cy.get(selectors.tagPostCount)
10 | .invoke('text')
11 | .then(text => text.trim())
12 | .should('not.equal', 'tag.one-post');
13 | });
14 |
15 | it('a tag page with multiple posts does not render its post count i18n key', () => {
16 | cy.visit('/tag/freecodecamp/');
17 |
18 | cy.get(selectors.tagPostCount)
19 | .invoke('text')
20 | .then(text => text.trim())
21 | .should('not.equal', 'tag.multiple-posts');
22 | });
23 | });
24 |
--------------------------------------------------------------------------------
/src/_includes/assets/css/variables.css:
--------------------------------------------------------------------------------
1 | /* Variables
2 | /* ---------------------------------------------------------- */
3 |
4 | :root {
5 | /* Colours */
6 | --dark-blue: #002ead;
7 | --theme-color: #0a0a23;
8 | --gray90: #0a0a23;
9 | --gray85: #1b1b32;
10 | --gray80: #2a2a40;
11 | --gray75: #3b3b4f;
12 | --gray45: #858591;
13 | --gray15: #d0d0d5;
14 | --gray10: #dfdfe2;
15 | --gray05: #eeeef0;
16 | --gray00: #fff;
17 | --header-height: 38px;
18 | /* Font */
19 | --font-family-sans-serif: 'Lato', sans-serif;
20 | --header-element-size: 28px;
21 | --header-sub-element-size: 45px;
22 | --z-index-site-header: 200;
23 | }
24 |
25 | /* Japanese Font */
26 | :root:lang(ja) {
27 | --font-family-sans-serif: 'Lato', 'Noto Sans JP', sans-serif;
28 | }
29 |
--------------------------------------------------------------------------------
/cypress/e2e/english/404/i18n.cy.ts:
--------------------------------------------------------------------------------
1 | const selectors = {
2 | errorMessage: "[data-test-label='error-message']"
3 | };
4 |
5 | // Tests here should apply to all 404 pages, regardless of the source
6 | describe('404 i18n', () => {
7 | beforeEach(() => {
8 | cy.visit('/testing-testing-1-2/', { failOnStatusCode: false });
9 | });
10 |
11 | it('the error message elements do not render their i18n keys', () => {
12 | cy.get(`${selectors.errorMessage} .error-description`)
13 | .invoke('text')
14 | .then(text => text.trim())
15 | .should('not.equal', '404.page-not-found');
16 | cy.get(`${selectors.errorMessage} .error-link`)
17 | .invoke('text')
18 | .then(text => text.trim())
19 | .should('not.contain', '404.go-to-front-page');
20 | });
21 | });
22 |
--------------------------------------------------------------------------------
/config/i18n/locales/dothraki/links.json:
--------------------------------------------------------------------------------
1 | {
2 | "forum": "https://forum.freecodecamp.org/",
3 | "learn": "https://www.freecodecamp.org/learn",
4 | "donate": "https://www.freecodecamp.org/donate/",
5 | "banner": {
6 | "default": "https://www.freecodecamp.org/",
7 | "authenticated": "https://www.freecodecamp.org/donate",
8 | "authenticated-donor": "https://www.freecodecamp.org/news/how-to-donate-to-free-code-camp/"
9 | },
10 | "twitter": "https://twitter.com/freecodecamp",
11 | "footer": {
12 | "about": "https://www.freecodecamp.org/news/about/",
13 | "support": "https://www.freecodecamp.org/news/support/",
14 | "honesty": "https://www.freecodecamp.org/news/academic-honesty-policy/",
15 | "coc": "https://www.freecodecamp.org/news/code-of-conduct/"
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/config/i18n/locales/english/links.json:
--------------------------------------------------------------------------------
1 | {
2 | "forum": "https://forum.freecodecamp.org/",
3 | "learn": "https://www.freecodecamp.org/learn",
4 | "donate": "https://www.freecodecamp.org/donate/",
5 | "banner": {
6 | "default": "https://www.freecodecamp.org/",
7 | "authenticated": "https://www.freecodecamp.org/donate",
8 | "authenticated-donor": "https://www.freecodecamp.org/news/how-to-donate-to-free-code-camp/"
9 | },
10 | "twitter": "https://twitter.com/freecodecamp",
11 | "footer": {
12 | "about": "https://www.freecodecamp.org/news/about/",
13 | "support": "https://www.freecodecamp.org/news/support/",
14 | "honesty": "https://www.freecodecamp.org/news/academic-honesty-policy/",
15 | "coc": "https://www.freecodecamp.org/news/code-of-conduct/"
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/config/i18n/locales/korean/links.json:
--------------------------------------------------------------------------------
1 | {
2 | "forum": "https://forum.freecodecamp.org/",
3 | "learn": "https://www.freecodecamp.org/learn",
4 | "donate": "https://www.freecodecamp.org/donate/",
5 | "banner": {
6 | "default": "https://www.freecodecamp.org/",
7 | "authenticated": "https://www.freecodecamp.org/donate",
8 | "authenticated-donor": "https://www.freecodecamp.org/news/how-to-donate-to-free-code-camp/"
9 | },
10 | "twitter": "https://twitter.com/freeCodeCampKO",
11 | "footer": {
12 | "about": "https://www.freecodecamp.org/news/about/",
13 | "support": "https://www.freecodecamp.org/news/support/",
14 | "honesty": "https://www.freecodecamp.org/news/academic-honesty-policy/",
15 | "coc": "https://www.freecodecamp.org/news/code-of-conduct/"
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/src/_includes/partials/social-row.njk:
--------------------------------------------------------------------------------
1 | {% if post.primary_author.twitter_handle or post.original_post.primary_author.twitter_handle %}
2 | {% set tweetCTAKey = 'social-row.cta.tweet-a-thanks' %}
3 | {% else %}
4 | {% set tweetCTAKey = 'social-row.cta.tweet-it' %}
5 | {% endif %}
6 |
7 |
8 | {% t tweetCTAKey, {
9 | '<0>': '',
11 | interpolation: {
12 | escapeValue: false
13 | }
14 | } %}
15 |
16 |
17 | {% block scripts %}
18 | {% set js %}
19 | {% include "assets/js/social-row.js" %}
20 | {% endset %}
21 |
22 | {% endblock %}
23 |
--------------------------------------------------------------------------------
/cypress/e2e/espanol/tag/tag.cy.ts:
--------------------------------------------------------------------------------
1 | import { loadAndCountAllPostCards } from '../../../support/utils/post-cards';
2 |
3 | const selectors = {
4 | tagName: "[data-test-label='tag-name']",
5 | tagPostCount: "[data-test-label='tag-post-count']"
6 | };
7 |
8 | describe('Tag pages (Ghost sourced)', () => {
9 | before(() => {
10 | // Update baseUrl to include current language
11 | Cypress.config('baseUrl', 'http://localhost:8080/espanol/news/');
12 | });
13 |
14 | beforeEach(() => {
15 | cy.visit('/tag/javascript/');
16 | });
17 |
18 | it('should render', () => {
19 | cy.contains(selectors.tagName, '#JAVASCRIPT');
20 | });
21 |
22 | it('the number of total posts should match the post count at the top of the page (4)', () => {
23 | loadAndCountAllPostCards(selectors.tagPostCount);
24 | });
25 | });
26 |
--------------------------------------------------------------------------------
/config/i18n/locales/ukrainian/links.json:
--------------------------------------------------------------------------------
1 | {
2 | "forum": "https://forum.freecodecamp.org/c/ukrainian/569",
3 | "learn": "https://www.freecodecamp.org/ukrainian/learn",
4 | "donate": "https://www.freecodecamp.org/ukrainian/donate",
5 | "banner": {
6 | "default": "https://www.freecodecamp.org/ukrainian",
7 | "authenticated": "https://www.freecodecamp.org/ukrainian/donate",
8 | "authenticated-donor": "https://www.freecodecamp.org/news/how-to-donate-to-free-code-camp/"
9 | },
10 | "twitter": "https://twitter.com/freeCodeCampUK",
11 | "footer": {
12 | "about": "https://www.freecodecamp.org/news/about/",
13 | "support": "https://www.freecodecamp.org/news/support/",
14 | "honesty": "https://www.freecodecamp.org/news/academic-honesty-policy/",
15 | "coc": "https://www.freecodecamp.org/news/code-of-conduct/"
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/cypress/e2e/english/404/404.cy.ts:
--------------------------------------------------------------------------------
1 | import commonExpectedMeta from '../../../fixtures/common-expected-meta.json';
2 | const selectors = {
3 | errorLink: "[data-test-label='error-link']"
4 | };
5 |
6 | // Tests here should apply to all 404 pages, regardless of the source
7 | describe('404', () => {
8 | beforeEach(() => {
9 | cy.visit('/testing-testing-1-2/', { failOnStatusCode: false });
10 | });
11 |
12 | it('should render basic components', () => {
13 | cy.get('nav').should('be.visible');
14 | cy.get('.banner').should('be.visible');
15 | cy.get('footer').should('be.visible');
16 | });
17 |
18 | it('the error link should point to to the full URL of the landing page', () => {
19 | cy.get(selectors.errorLink).should(
20 | 'have.attr',
21 | 'href',
22 | commonExpectedMeta.english.siteUrl
23 | );
24 | });
25 | });
26 |
--------------------------------------------------------------------------------
/config/i18n/locales/chinese/links.json:
--------------------------------------------------------------------------------
1 | {
2 | "forum": "https://forum.freecodecamp.org/c/chinese/533",
3 | "learn": "https://www.freecodecamp.org/chinese/learn",
4 | "donate": "https://www.freecodecamp.org/chinese/donate",
5 | "banner": {
6 | "default": "https://www.freecodecamp.org/chinese/",
7 | "authenticated": "https://www.freecodecamp.org/chinese/donate",
8 | "authenticated-donor": "https://www.freecodecamp.org/chinese/news/how-to-donate-to-free-code-camp/"
9 | },
10 | "twitter": "https://twitter.com/freeCodeCampZH",
11 | "footer": {
12 | "about": "https://www.freecodecamp.org/chinese/news/about/",
13 | "support": "https://www.freecodecamp.org/chinese/news/support/",
14 | "honesty": "https://www.freecodecamp.org/chinese/news/academic-honesty/",
15 | "coc": "https://www.freecodecamp.org/chinese/news/code-of-conduct/"
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/cypress/support/e2e.ts:
--------------------------------------------------------------------------------
1 | // ***********************************************************
2 | // This example support/index.js is processed and
3 | // loaded automatically before your test files.
4 | //
5 | // This is a great place to put global configuration and
6 | // behavior that modifies Cypress.
7 | //
8 | // You can change the location of this file or turn off
9 | // automatically serving support files with the
10 | // 'supportFile' configuration option.
11 | //
12 | // You can read more here:
13 | // https://on.cypress.io/configuration
14 | // ***********************************************************
15 |
16 | // Import commands.js
17 | require('./commands');
18 |
19 | // eslint-disable-next-line no-unused-vars
20 | Cypress.on('uncaught:exception', (err, runnable) => {
21 | // Returning false here prevents Cypress from failing the test
22 | return false;
23 | });
24 |
--------------------------------------------------------------------------------
/config/i18n/locales/japanese/links.json:
--------------------------------------------------------------------------------
1 | {
2 | "forum": "https://forum.freecodecamp.org/c/japanese/552",
3 | "learn": "https://www.freecodecamp.org/japanese/learn",
4 | "donate": "https://www.freecodecamp.org/japanese/donate",
5 | "banner": {
6 | "default": "https://www.freecodecamp.org/japanese/",
7 | "authenticated": "https://www.freecodecamp.org/japanese/donate",
8 | "authenticated-donor": "https://www.freecodecamp.org/japanese/news/how-to-donate-to-free-code-camp/"
9 | },
10 | "twitter": "https://twitter.com/freecodecampJA",
11 | "footer": {
12 | "about": "https://www.freecodecamp.org/japanese/news/about/",
13 | "support": "https://www.freecodecamp.org/japanese/news/support/",
14 | "honesty": "https://www.freecodecamp.org/japanese/news/academic-honesty-policy/",
15 | "coc": "https://www.freecodecamp.org/japanese/news/code-of-conduct/"
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/cypress/e2e/chinese/post/ads.cy.ts:
--------------------------------------------------------------------------------
1 | const selectors = {
2 | scripts: {
3 | adsense: 'script[src*="adsbygoogle.js"]'
4 | },
5 | adContainer: "[data-test-label='ad-wrapper']"
6 | };
7 |
8 | describe('Ads', () => {
9 | before(() => {
10 | // Update baseUrl to include current language
11 | Cypress.config('baseUrl', 'http://localhost:8080/chinese/news/');
12 | });
13 |
14 | beforeEach(() => {
15 | cy.visit('/javascript-array-length/');
16 | });
17 |
18 | it('the adsense script should not be within the `head` element', () => {
19 | cy.get(`head ${selectors.scripts.adsense}`).should('not.exist');
20 | });
21 |
22 | it('the post should not include any ad containers', () => {
23 | cy.get(selectors.adContainer).should('not.exist');
24 | });
25 |
26 | it('the post should not use the ad layout', () => {
27 | cy.get('.ad-layout').should('not.exist');
28 | });
29 | });
30 |
--------------------------------------------------------------------------------
/src/_includes/partials/icons/twitter.njk:
--------------------------------------------------------------------------------
1 |
2 |
4 |
--------------------------------------------------------------------------------
/cypress/e2e/espanol/tag/i18n.cy.ts:
--------------------------------------------------------------------------------
1 | const selectors = {
2 | tagPostCount: "[data-test-label='tag-post-count']"
3 | };
4 |
5 | describe('Tag page i18n (Ghost sourced)', () => {
6 | before(() => {
7 | // Update baseUrl to include current language
8 | Cypress.config('baseUrl', 'http://localhost:8080/espanol/news/');
9 | });
10 |
11 | it('a tag page with 1 post does not render its post count i18n key', () => {
12 | cy.visit('/tag/linux/');
13 |
14 | cy.get(selectors.tagPostCount)
15 | .invoke('text')
16 | .then(text => text.trim())
17 | .should('not.equal', 'tag.one-post');
18 | });
19 |
20 | it('a tag page with multiple posts does not render its post count i18n key', () => {
21 | cy.visit('/tag/javascript/');
22 |
23 | cy.get(selectors.tagPostCount)
24 | .invoke('text')
25 | .then(text => text.trim())
26 | .should('not.equal', 'tag.multiple-posts');
27 | });
28 | });
29 |
--------------------------------------------------------------------------------
/src/_includes/partials/icons/email.njk:
--------------------------------------------------------------------------------
1 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
12 |
13 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/docker/ghost/docker-compose.yml:
--------------------------------------------------------------------------------
1 | services:
2 | chinese:
3 | image: ghost:3
4 | restart: always
5 | ports:
6 | - 3020:2368
7 | environment:
8 | url: http://localhost:3020
9 | volumes:
10 | - ./content/chinese/data:/var/lib/ghost/content/data
11 | - ./content/chinese/images:/var/lib/ghost/content/images
12 | - ./content/chinese/settings:/var/lib/ghost/content/settings
13 | - ./config.production.json:/var/lib/ghost/config.production.json
14 |
15 | espanol:
16 | image: ghost:3
17 | restart: always
18 | ports:
19 | - 3030:2368
20 | environment:
21 | url: http://localhost:3030
22 | volumes:
23 | - ./content/espanol/data:/var/lib/ghost/content/data
24 | - ./content/espanol/images:/var/lib/ghost/content/images
25 | - ./content/espanol/settings:/var/lib/ghost/content/settings
26 | - ./config.production.json:/var/lib/ghost/config.production.json
27 |
--------------------------------------------------------------------------------
/config/i18n/locales/espanol/links.json:
--------------------------------------------------------------------------------
1 | {
2 | "forum": "https://forum.freecodecamp.org/c/espanol/522",
3 | "learn": "https://www.freecodecamp.org/espanol/learn",
4 | "donate": "https://www.freecodecamp.org/espanol/donate",
5 | "banner": {
6 | "default": "https://www.freecodecamp.org/espanol",
7 | "authenticated": "https://www.freecodecamp.org/espanol/donate",
8 | "authenticated-donor": "https://www.freecodecamp.org/espanol/news/how-to-donate-to-free-code-camp/"
9 | },
10 | "twitter": "https://twitter.com/freecodecampes",
11 | "footer": {
12 | "about": "https://www.freecodecamp.org/espanol/news/acerca-de-freecodecamp-preguntas-frecuentes/",
13 | "support": "https://www.freecodecamp.org/espanol/news/preguntas-comunes-de-soporte-tecnico/",
14 | "honesty": "https://www.freecodecamp.org/espanol/news/politica-de-honestidad-academica/",
15 | "coc": "https://www.freecodecamp.org/espanol/news/codigo-de-conducta/"
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/cypress/e2e/english/page/page.cy.ts:
--------------------------------------------------------------------------------
1 | const selectors = {
2 | fccSource: "[data-test-label='x-fcc-source']",
3 | featureImage: "[data-test-label='feature-image']",
4 | postFullTitle: "[data-test-label='post-full-title']",
5 | postContent: "[data-test-label='post-content']",
6 | articlePublishedTime: 'head meta[property="article:published_time"]'
7 | };
8 |
9 | describe('Page (Hashnode sourced)', () => {
10 | beforeEach(() => {
11 | cy.visit('/thank-you-for-being-a-supporter/');
12 | });
13 |
14 | it('should render', () => {
15 | cy.contains('Thank You for Being a Supporter');
16 | });
17 |
18 | it('should contain the fCC source meta tag with Hashnode as a source', () => {
19 | cy.get(selectors.fccSource).should('have.attr', 'content', 'Hashnode');
20 | });
21 |
22 | it('should not contain the article:published_time meta tag', () => {
23 | cy.get(selectors.articlePublishedTime).should('not.exist');
24 | });
25 | });
26 |
--------------------------------------------------------------------------------
/utils/dayjs.js:
--------------------------------------------------------------------------------
1 | import dayjs from 'dayjs';
2 | import localizedFormat from 'dayjs/plugin/localizedFormat.js';
3 | import relativeTime from 'dayjs/plugin/relativeTime.js';
4 | import utc from 'dayjs/plugin/utc.js';
5 | import timezone from 'dayjs/plugin/timezone.js';
6 |
7 | import { config } from '../config/index.js';
8 | const { currentLocale_i18nISOCode } = config;
9 |
10 | const localeCode = currentLocale_i18nISOCode.toLowerCase();
11 |
12 | // Dynamically include dayjs locale
13 | import(`dayjs/locale/${localeCode}.js`)
14 | .then(() => {
15 | console.log(`Day.js locale ${localeCode} loaded successfully.`);
16 | })
17 | .catch(err => {
18 | console.error(`Error loading Day.js locale ${localeCode}:`, err);
19 | });
20 |
21 | // Load dayjs plugins
22 | dayjs.extend(localizedFormat);
23 | dayjs.extend(relativeTime);
24 | dayjs.extend(utc);
25 | dayjs.extend(timezone);
26 |
27 | dayjs.locale(localeCode);
28 |
29 | export default dayjs;
30 |
--------------------------------------------------------------------------------
/cypress/support/commands.ts:
--------------------------------------------------------------------------------
1 | // ***********************************************
2 | // This example commands.js shows you how to
3 | // create various custom commands and overwrite
4 | // existing commands.
5 | //
6 | // For more comprehensive examples of custom
7 | // commands please read more here:
8 | // https://on.cypress.io/custom-commands
9 | // ***********************************************
10 | //
11 | //
12 | // -- This is a parent command --
13 | // Cypress.Commands.add('login', (email, password) => { ... })
14 | //
15 | //
16 | // -- This is a child command --
17 | // Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
18 | //
19 | //
20 | // -- This is a dual command --
21 | // Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
22 | //
23 | //
24 | // -- This will overwrite an existing command --
25 | // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
26 |
--------------------------------------------------------------------------------
/config/i18n/locales/italian/links.json:
--------------------------------------------------------------------------------
1 | {
2 | "forum": "https://forum.freecodecamp.org/c/italiano/535",
3 | "learn": "https://www.freecodecamp.org/italian/learn",
4 | "donate": "https://www.freecodecamp.org/italian/donate",
5 | "banner": {
6 | "default": "https://forum.freecodecamp.org/t/contribuire-a-tradurre-freecodecamp-in-italiano/479009/",
7 | "authenticated": "https://www.freecodecamp.org/italian/donate",
8 | "authenticated-donor": "https://www.freecodecamp.org/news/how-to-donate-to-free-code-camp/"
9 | },
10 | "twitter": "https://twitter.com/freeCodeCampIT",
11 | "footer": {
12 | "about": "https://www.freecodecamp.org/italian/news/faq/",
13 | "support": "https://www.freecodecamp.org/italian/news/domande-frequenti-supporto-tecnico-freecodecamp-faq/",
14 | "honesty": "https://www.freecodecamp.org/italian/news/freecodecamps-academic-honesty-policy/",
15 | "coc": "https://www.freecodecamp.org/italian/news/codice-di-condotta/"
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/src/_includes/partials/search-bar.njk:
--------------------------------------------------------------------------------
1 | {% set searchPlaceholder %}
2 | {% if site.roundedTotalRecords < 100 %}
3 | {%- t 'search.placeholder.default' -%}
4 | {% else %}
5 | {%- t 'search.placeholder.numbered', {
6 | roundedTotalRecords: site.roundedTotalRecordsLocalizedString
7 | } -%}
8 | {% endif %}
9 | {% endset %}
10 |
23 |
--------------------------------------------------------------------------------
/cypress/e2e/espanol/page/page.cy.ts:
--------------------------------------------------------------------------------
1 | const selectors = {
2 | fccSource: "[data-test-label='x-fcc-source']",
3 | featureImage: "[data-test-label='feature-image']",
4 | articlePublishedTime: 'head meta[property="article:published_time"]'
5 | };
6 |
7 | describe('Page (Ghost sourced)', () => {
8 | before(() => {
9 | // Update baseUrl to include current language
10 | Cypress.config('baseUrl', 'http://localhost:8080/espanol/news/');
11 | });
12 |
13 | beforeEach(() => {
14 | cy.visit('/gracias-por-ser-un-partidario/');
15 | });
16 |
17 | it('should render', () => {
18 | cy.contains('freeCodeCamp es una ONG educativa muy eficiente.');
19 | });
20 |
21 | it('should contain the fCC source meta tag with Ghost as a source', () => {
22 | cy.get(selectors.fccSource).should('have.attr', 'content', 'Ghost');
23 | });
24 |
25 | it('should contain the article:published_time meta tag', () => {
26 | cy.get(selectors.articlePublishedTime).should('exist');
27 | });
28 | });
29 |
--------------------------------------------------------------------------------
/src/index.njk:
--------------------------------------------------------------------------------
1 | ---
2 | pagination:
3 | data: datasource.posts
4 | size: 25
5 | alias: posts
6 | ---
7 |
8 | {#
9 | size must be set manually,
10 | and be the same number as env.POSTS_PER_PAGE
11 | #}
12 |
13 | {% extends 'layouts/default.njk' %}
14 | {% from "partials/card.njk" import card %}
15 |
16 | {% block content %}
17 |
18 |
19 |
20 | {% for post in posts %}
21 | {{ card(post, loop.index0) }}
22 | {% endfor %}
23 |
24 | {% include "partials/pagination.njk" %}
25 |
26 |
27 | {% endblock %}
28 |
29 | {% block headScripts %}
30 |
31 | {% endblock %}
32 |
33 | {% block jsonLd %}
34 |
35 | {% endblock %}
36 |
--------------------------------------------------------------------------------
/config/i18n/locales/portuguese/links.json:
--------------------------------------------------------------------------------
1 | {
2 | "forum": "https://forum.freecodecamp.org/c/portugues/534",
3 | "learn": "https://www.freecodecamp.org/portuguese/learn",
4 | "donate": "https://www.freecodecamp.org/portuguese/donate",
5 | "banner": {
6 | "default": "discord.gg/PRyKn3Vbay",
7 | "authenticated": "https://www.freecodecamp.org/portuguese/donate",
8 | "authenticated-donor": "https://www.freecodecamp.org/news/how-to-donate-to-free-code-camp/"
9 | },
10 | "twitter": "https://twitter.com/freecodecampPT",
11 | "footer": {
12 | "about": "https://www.freecodecamp.org/portuguese/news/sobre-o-freecodecamp-perguntas-frequentes/",
13 | "support": "https://www.freecodecamp.org/portuguese/news/perguntas-frequentes-sobre-suporte-tecnico-faq-do-freecodecamp/",
14 | "honesty": "https://www.freecodecamp.org/portuguese/news/politica-de-honestidade-academica-do-freecodecamp/",
15 | "coc": "https://www.freecodecamp.org/portuguese/news/codigo-de-conduta-do-freecodecamp/"
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/registry-test/index.js:
--------------------------------------------------------------------------------
1 | import { createServer } from 'http';
2 | const port = 3000;
3 |
4 | const server = createServer((req, res) => {
5 | const startTime = Date.now();
6 |
7 | res.on('finish', () => {
8 | const endTime = Date.now();
9 | const responseTime = endTime - startTime;
10 | const clientIP = req.headers['client-ip'] || req.connection.remoteAddress;
11 | console.log(
12 | `[${new Date(startTime).toISOString()}] - Method: ${req.method} - URL: ${
13 | req.url
14 | } - IP: ${clientIP} - Response Time: ${responseTime}ms`
15 | );
16 | });
17 |
18 | res.statusCode = 200;
19 | res.setHeader('Content-Type', 'text/plain');
20 | res.end(
21 | `This is container/application is running on: ${process.env.BUILD_ID} \n`
22 | );
23 | });
24 |
25 | server.listen(port, () => {
26 | console.log(`Server running at http://localhost:${port}`);
27 | });
28 |
29 | process.on('SIGINT', () => {
30 | console.log('Shutting down server');
31 | server.close();
32 | process.exit();
33 | });
34 |
--------------------------------------------------------------------------------
/config/i18n/config.js:
--------------------------------------------------------------------------------
1 | import i18next, { use } from 'i18next';
2 | import Backend from 'i18next-fs-backend';
3 | import gracefulFS from 'graceful-fs';
4 | import { join } from 'path';
5 |
6 | import { config } from '../index.js';
7 |
8 | const { readdirSync, lstatSync } = gracefulFS;
9 | const { currentLocale_i18n, currentLocale_i18nISOCode } = config;
10 |
11 | use(Backend).init({
12 | lng: currentLocale_i18nISOCode,
13 | fallbackLng: 'en',
14 | initImmediate: false,
15 | preload: readdirSync(join(import.meta.dirname, './locales')).filter(
16 | fileName => {
17 | const joinedPath = join(join(import.meta.dirname, './locales'), fileName);
18 | const isDirectory = lstatSync(joinedPath).isDirectory();
19 | return isDirectory;
20 | }
21 | ),
22 | ns: ['translations', 'meta-tags', 'links', 'trending'],
23 | defaultNS: 'translations',
24 | backend: {
25 | loadPath: join(
26 | import.meta.dirname,
27 | `./locales/${currentLocale_i18n}/{{ ns }}.json`
28 | )
29 | }
30 | });
31 |
32 | export default i18next;
33 |
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | import globals from 'globals';
2 | import js from '@eslint/js';
3 | import pluginCypress from 'eslint-plugin-cypress';
4 | import eslintConfigPrettier from 'eslint-config-prettier/flat';
5 |
6 | const { node, browser, commonjs } = globals;
7 |
8 | export default [
9 | js.configs.recommended,
10 | pluginCypress.configs.recommended,
11 | eslintConfigPrettier,
12 | {
13 | ignores: ['**/dist/', '**/package-lock.json'],
14 | languageOptions: {
15 | globals: {
16 | ...node,
17 | ...browser,
18 | ...commonjs,
19 | Atomics: 'readonly',
20 | SharedArrayBuffer: 'readonly',
21 | isDonor: 'writable',
22 | isAuthenticated: 'writable'
23 | },
24 | // ecmaVersion: 2020,
25 | parserOptions: {}
26 | },
27 | rules: {
28 | 'no-unused-vars': [
29 | 'warn',
30 | {
31 | argsIgnorePattern: '^_',
32 | varsIgnorePattern: '^_',
33 | caughtErrorsIgnorePattern: '^_'
34 | }
35 | ]
36 | }
37 | }
38 | ];
39 |
--------------------------------------------------------------------------------
/src/_includes/layouts/sitemap.njk:
--------------------------------------------------------------------------------
1 |
2 |
3 | {% set isSitemapIndex = sitemap.path === 'sitemap.xml' %}
4 | {% set outerTagName = 'sitemapindex' if isSitemapIndex else 'urlset' %}
5 | {% set innerTagName = 'sitemap' if isSitemapIndex else 'url' %}
6 | <{{ outerTagName }}
7 | xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
8 | {{ ('xmlns:image="http://www.google.com/schemas/sitemap-image/1.1"' | safe) if not isSitemapIndex }}>
9 | {% for entry in sitemap.entries %}
10 | <{{ innerTagName }}>
11 | {{ entry.loc }}
12 | {% if entry.lastmod %}
13 | {{ entry.lastmod }}
14 | {% endif %}
15 | {% if entry.image %}
16 |
17 | {{ entry.image.loc }}
18 | {{ entry.image.caption }}
19 |
20 | {% endif %}
21 | {{ innerTagName }}>
22 | {% endfor %}
23 | {{ outerTagName }}>
24 |
--------------------------------------------------------------------------------
/src/_includes/partials/prism.njk:
--------------------------------------------------------------------------------
1 |
7 |
8 |
12 |
13 |
19 |
20 |
24 |
25 |
26 |
30 |
34 |
--------------------------------------------------------------------------------
/cypress/fixtures/mock-hashnode-pages.json:
--------------------------------------------------------------------------------
1 | {
2 | "publication": {
3 | "staticPages": {
4 | "edges": [
5 | {
6 | "node": {
7 | "id": "6698b0d32204c0bcdb9636b4",
8 | "slug": "thank-you-for-being-a-supporter",
9 | "title": "Thank You for Being a Supporter",
10 | "content": {
11 | "html": "freeCodeCamp is a highly-efficient education NGO. This year alone, we've provided million hours of free education to people around the world.
\nAt our charity's current operating budget, every dollar you donate to freeCodeCamp translates into 50 hours worth of technology education.
\nWhen you donate to freeCodeCamp, you help people learn new skills and provide for their families.
\nYou also help us create new resources for you and your family to use to expand your own technology skills.
\nThank you again for supporting our charity.
\n"
12 | }
13 | }
14 | }
15 | ],
16 | "pageInfo": {
17 | "endCursor": "MQ==",
18 | "hasNextPage": false
19 | }
20 | }
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/config/serve.json:
--------------------------------------------------------------------------------
1 | {
2 | "directoryListing": false,
3 | "headers": [
4 | {
5 | "source": "{**/*.html}",
6 | "headers": [
7 | {
8 | "key": "Cache-Control",
9 | "value": "public, max-age=0, must-revalidate"
10 | }
11 | ]
12 | },
13 | {
14 | "source": "{**/*.ico,**/*.js,**/*.css}",
15 | "headers": [
16 | {
17 | "key": "Cache-Control",
18 | "value": "public, max-age=31536000, immutable"
19 | }
20 | ]
21 | },
22 | {
23 | "source": "{**/*.woff,**/*.woff2,**/*.ttf,**/*.eot}",
24 | "headers": [
25 | {
26 | "key": "Cache-Control",
27 | "value": "public, max-age=31536000, immutable"
28 | }
29 | ]
30 | },
31 | {
32 | "source": "{**/*.jpg,**/*.png,**/*.gif}",
33 | "headers": [
34 | {
35 | "key": "Cache-Control",
36 | "value": "public, max-age=31536000, immutable"
37 | }
38 | ]
39 | }
40 | ],
41 | "rewrites": [
42 | {
43 | "source": "/:authorOrTag?/:name?/rss",
44 | "destination": "/:authorOrTag?/:name?/rss.xml"
45 | }
46 | ]
47 | }
48 |
--------------------------------------------------------------------------------
/.github/workflows/no-forks.yml:
--------------------------------------------------------------------------------
1 | name: GitHub - No PRs from Forks
2 | on:
3 | pull_request_target:
4 | types: [opened, synchronize, reopened]
5 |
6 | jobs:
7 | no-forks:
8 | name: No-Forks
9 | runs-on: ubuntu-latest
10 | steps:
11 | - name: Comment and Close
12 | uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
13 | if: ${{ github.event.pull_request.head.repo.full_name != 'freeCodeCamp/news' }}
14 | with:
15 | script: |
16 | github.rest.issues.createComment({
17 | issue_number: context.issue.number,
18 | owner: context.repo.owner,
19 | repo: context.repo.repo,
20 | body: "This is a quick reminder that we can not accept pull requests from forks (including staff and prior contributors).\n\nThis repo is a \"special\" case because of how we build, test and deploy the codebase. If you are working on something, Please open a new pull request from a branch on this repository."
21 | })
22 | github.rest.pulls.update({
23 | pull_number: context.issue.number,
24 | owner: context.repo.owner,
25 | repo: context.repo.repo,
26 | state: 'closed'
27 | })
28 |
--------------------------------------------------------------------------------
/config/i18n/generate-serve-config.js:
--------------------------------------------------------------------------------
1 | import gracefulFS from 'graceful-fs';
2 | import { join } from 'path';
3 |
4 | import { config } from '../../config/index.js';
5 | import { loadJSON } from '../../utils/load-json.js';
6 | import source from '../serve.json' with { type: 'json' };
7 |
8 | const { locales } = config;
9 | const { writeFileSync, mkdirSync } = gracefulFS;
10 |
11 | locales.push('dothraki');
12 |
13 | for (let language of locales) {
14 | const sourceClone = { ...source };
15 | const filePath = join(
16 | import.meta.dirname,
17 | `/locales/${language}/redirects.json`
18 | );
19 | const redirectsArray = loadJSON(filePath);
20 |
21 | sourceClone.redirects = [
22 | {
23 | source: '/:slug/amp',
24 | destination:
25 | language === 'english' ? '/news/:slug' : `/${language}/news/:slug`,
26 | type: 302
27 | },
28 | ...(redirectsArray.length ? redirectsArray : [])
29 | ];
30 |
31 | mkdirSync(join(import.meta.dirname, `../../docker/languages/${language}`), {
32 | recursive: true
33 | });
34 |
35 | writeFileSync(
36 | join(import.meta.dirname, `../../docker/languages/${language}/serve.json`),
37 | JSON.stringify(sourceClone, null, 2)
38 | );
39 |
40 | console.log(`Wrote ${language}/serve.json`);
41 | }
42 |
--------------------------------------------------------------------------------
/utils/ghost/fetch-from-ghost.js:
--------------------------------------------------------------------------------
1 | import { ghostAPI } from '../api.js';
2 | import { wait } from '../wait.js';
3 |
4 | export const fetchFromGhost = async endpoint => {
5 | let currPage = 1;
6 | let lastPage = 5;
7 | let data = [];
8 | const options = {
9 | include: ['tags', 'authors'],
10 | filter: 'status:published',
11 | limit: 200
12 | };
13 |
14 | if (process.env.DO_NOT_FETCH_FROM_GHOST) {
15 | console.log(
16 | 'DO_NOT_FETCH_FROM_GHOST is active. This is likely because Ghost is not available for this environment.'
17 | );
18 | return [];
19 | }
20 |
21 | while (currPage && currPage <= lastPage) {
22 | const ghostRes = await ghostAPI[endpoint]
23 | .browse({
24 | ...options,
25 | page: currPage
26 | })
27 | .catch(err => {
28 | console.error(err);
29 | });
30 |
31 | lastPage = ghostRes.meta.pagination.pages;
32 | if (ghostRes.length > 0)
33 | console.log(
34 | `Fetched Ghost ${endpoint} page ${currPage} of ${lastPage}...and using ${process.memoryUsage.rss() / 1024 / 1024} MB of memory`
35 | );
36 | currPage = ghostRes.meta.pagination.next;
37 |
38 | ghostRes.forEach(obj => data.push(obj));
39 | await wait(200);
40 | }
41 |
42 | return data;
43 | };
44 |
--------------------------------------------------------------------------------
/src/404.njk:
--------------------------------------------------------------------------------
1 | ---
2 | permalink: 404.html
3 | ---
4 |
5 | {% extends 'layouts/default.njk' %}
6 | {% from "partials/card.njk" import card %}
7 |
8 | {% set codeinjection_head = doc.codeinjection_head %}
9 | {% set codeinjection_foot = doc.codeinjection_foot %}
10 |
11 | {% set title = "Developer News | 404 - Page not found" %}
12 |
13 | {% block content %}
14 |
34 | {% endblock %}
35 |
--------------------------------------------------------------------------------
/config/i18n/locales.test.js:
--------------------------------------------------------------------------------
1 | import gracefulFS from 'graceful-fs';
2 | import { setup } from 'jest-json-schema-extended';
3 |
4 | import { config } from '../index.js';
5 |
6 | const { existsSync } = gracefulFS;
7 | const { locales, localeCodes, algoliaIndices } = config;
8 |
9 | setup();
10 |
11 | const filesThatShouldExist = [
12 | {
13 | name: 'links.json'
14 | },
15 | {
16 | name: 'meta-tags.json'
17 | },
18 | {
19 | name: 'redirects.json'
20 | },
21 | {
22 | name: 'translations.json'
23 | }
24 | ];
25 |
26 | const path = `${import.meta.dirname}/locales`;
27 |
28 | describe('Locale tests:', () => {
29 | locales.forEach(lang => {
30 | describe(`-- ${lang} --`, () => {
31 | filesThatShouldExist.forEach(file => {
32 | // check that each json file exists
33 | test(`${file.name} file exists`, () => {
34 | const exists = existsSync(`${path}/${lang}/${file.name}`);
35 | expect(exists).toBeTruthy();
36 | });
37 | });
38 |
39 | test(`has an entry in the localeCodes array`, () => {
40 | expect(localeCodes[lang].length).toBeGreaterThan(0);
41 | });
42 |
43 | test(`has an entry in the algoliaIndices array`, () => {
44 | expect(algoliaIndices[lang].length).toBeGreaterThan(0);
45 | });
46 | });
47 | });
48 | });
49 |
--------------------------------------------------------------------------------
/.github/CODEOWNERS:
--------------------------------------------------------------------------------
1 | # -------------------------------------------------
2 | # CODEOWNERS - For automated review request for
3 | # high impact files.
4 | #
5 | # Important: The order in this file cascades.
6 | #
7 | # https://help.github.com/articles/about-codeowners
8 | # -------------------------------------------------
9 |
10 | # -------------------------------------------------
11 | # All files are owned by dev team
12 | # -------------------------------------------------
13 |
14 | * @freecodecamp/dev-team
15 |
16 | # --- Owned by none (negate rule above) ---
17 |
18 | package.json
19 | package-lock.json
20 |
21 | # -------------------------------------------------
22 | # All files in the root are owned by dev team
23 | # -------------------------------------------------
24 |
25 | /* @freecodecamp/dev-team
26 |
27 | # --- Owned by none (negate rule above) ---
28 |
29 | /package.json
30 | /package-lock.json
31 |
32 | # -------------------------------------------------
33 | # Exception for i18n PRs
34 | # -------------------------------------------------
35 |
36 | /config/i18n/locales/**/*.json @freecodecamp/i18n
37 |
38 | # -------------------------------------------------
39 | # Revert override for English because codeowners
40 | # doesn't support negation?
41 | # -------------------------------------------------
42 | /config/i18n/locales/english/*.json @freecodecamp/dev-team
43 |
--------------------------------------------------------------------------------
/src/_includes/assets/js/published-date.js:
--------------------------------------------------------------------------------
1 | document.addEventListener('DOMContentLoaded', () => {
2 | const postFeed = document.querySelector('.post-feed');
3 | const localizeDates = datesList => {
4 | datesList.forEach(date => {
5 | const dateStr = date.getAttribute('datetime');
6 | // eslint-disable-next-line no-undef
7 | const dateObj = dayjs(dateStr);
8 |
9 | // Display either time since published or month, day, and year
10 | date.innerHTML = dateObj.format('LL');
11 | });
12 | };
13 |
14 | // Localize dates when loading more articles to the page
15 | const config = {
16 | childList: true,
17 | attributes: true,
18 | subtree: true,
19 | characterData: true
20 | };
21 | const observer = new MutationObserver(mutations => {
22 | // Capture new article nodes that are appended to the page
23 | // and localize their dates
24 | const newNodes = mutations.map(mutation => [...mutation.addedNodes]).flat();
25 | const newPostDates = newNodes
26 | .map(node => [...node.querySelectorAll('time')])
27 | .flat(1);
28 |
29 | observer.disconnect();
30 | localizeDates(newPostDates);
31 | observer.observe(postFeed, config);
32 | });
33 |
34 | // Observe mutations as the search results page loads and
35 | // new hits are appended
36 | if (postFeed) observer.observe(postFeed, config);
37 | });
38 |
--------------------------------------------------------------------------------
/cypress/e2e/english/tag/structured-data.cy.ts:
--------------------------------------------------------------------------------
1 | import commonExpectedJsonLd from '../../../fixtures/common-expected-json-ld.json';
2 | const tagExpectedJsonLd = {
3 | '@type': 'Series',
4 | url: 'http://localhost:8080/news/tag/freecodecamp/',
5 | name: 'freeCodeCamp.org'
6 | };
7 | let jsonLdObj;
8 |
9 | describe('Tag page structured data (JSON-LD – Hashnode sourced)', () => {
10 | beforeEach(() => {
11 | cy.visit('/tag/freecodecamp/');
12 |
13 | jsonLdObj = cy
14 | .get('head script[type="application/ld+json"]')
15 | .then($script => {
16 | jsonLdObj = JSON.parse($script.text());
17 | });
18 | });
19 |
20 | it('matches the expected base values', () => {
21 | expect(jsonLdObj['@context']).to.equal(commonExpectedJsonLd['@context']);
22 | expect(jsonLdObj['@type']).to.equal(tagExpectedJsonLd['@type']);
23 | expect(jsonLdObj.url).to.equal(tagExpectedJsonLd.url);
24 | expect(jsonLdObj.name).to.equal(tagExpectedJsonLd.name);
25 | });
26 |
27 | it('matches the expected publisher values', () => {
28 | expect(jsonLdObj.publisher).to.deep.equal(
29 | commonExpectedJsonLd.english.publisher
30 | );
31 | });
32 |
33 | it('matches the expected mainEntityOfPage values', () => {
34 | expect(jsonLdObj.mainEntityOfPage).to.deep.equal(
35 | commonExpectedJsonLd.english.mainEntityOfPage
36 | );
37 | });
38 | });
39 |
--------------------------------------------------------------------------------
/utils/shortcodes/cache-buster.js:
--------------------------------------------------------------------------------
1 | import gracefulFS from 'graceful-fs';
2 | import md5 from 'md5';
3 | import { parse, normalize } from 'path';
4 |
5 | const { readFileSync, writeFileSync, mkdirSync } = gracefulFS;
6 | export let manifest = {};
7 |
8 | export const cacheBusterShortcode = filePath => {
9 | // Handle cases where filePath doesn't start with /
10 | filePath = filePath.startsWith('/') ? filePath : `/${filePath}`;
11 | const { dir, base, name, ext } = parse(filePath);
12 | const localFilePath = `./src/_includes${filePath}`;
13 |
14 | if (!manifest[base]) {
15 | // Create the final directory if it doesn't already exist
16 | const finalBasePath = `./dist${dir}`;
17 | mkdirSync(finalBasePath, { recursive: true });
18 |
19 | // Generate 10 char MD5 hash of file content
20 | // of original filenames --> hashed equivalents
21 | const content = readFileSync(localFilePath);
22 | const hash = md5(content).slice(0, 10);
23 | const hashedFilename = `${name}-${hash}${ext}`;
24 |
25 | // Write hashed version of file and save to manifest
26 | writeFileSync(`${finalBasePath}/${hashedFilename}`, content);
27 | manifest[base] = hashedFilename;
28 | }
29 |
30 | const hashedRelativeUrl = `${filePath.replace(base, manifest[base])}`;
31 |
32 | // Return final path with hashed filename to the template
33 | return normalize(hashedRelativeUrl);
34 | };
35 |
--------------------------------------------------------------------------------
/cypress/e2e/english/landing/structured-data.cy.ts:
--------------------------------------------------------------------------------
1 | import commonExpectedJsonLd from '../../../fixtures/common-expected-json-ld.json';
2 | let jsonLdObj;
3 |
4 | describe('Landing structured data (JSON-LD – Hashnode sourced)', () => {
5 | beforeEach(() => {
6 | cy.visit('/');
7 |
8 | jsonLdObj = cy
9 | .get('head script[type="application/ld+json"]')
10 | .then($script => {
11 | jsonLdObj = JSON.parse($script.text());
12 | });
13 | });
14 |
15 | it('matches the expected base values', () => {
16 | expect(jsonLdObj['@context']).to.equal(commonExpectedJsonLd['@context']);
17 | expect(jsonLdObj['@type']).to.equal(commonExpectedJsonLd['@type']);
18 | expect(jsonLdObj.url).to.equal(commonExpectedJsonLd.english.url);
19 | expect(jsonLdObj.description).to.equal(
20 | commonExpectedJsonLd.english.description
21 | );
22 | });
23 |
24 | it('matches the expected publisher values', () => {
25 | expect(jsonLdObj.publisher).to.deep.equal(
26 | commonExpectedJsonLd.english.publisher
27 | );
28 | });
29 |
30 | it('matches the expected image values', () => {
31 | expect(jsonLdObj.image).to.deep.equal(commonExpectedJsonLd.image);
32 | });
33 |
34 | it('matches the expected mainEntityOfPage values', () => {
35 | expect(jsonLdObj.mainEntityOfPage).to.deep.equal(
36 | commonExpectedJsonLd.english.mainEntityOfPage
37 | );
38 | });
39 | });
40 |
--------------------------------------------------------------------------------
/src/_includes/assets/js/pagination.js:
--------------------------------------------------------------------------------
1 | (async () => {
2 | const readMoreBtn = document.getElementById('readMoreBtn');
3 | const readMoreRow = document.querySelector('.read-more-row');
4 | const postFeed = document.querySelector('.post-feed');
5 | let nextPageNum = 1;
6 |
7 | const fetchNextPage = async () => {
8 | try {
9 | const nextTag = document.querySelector('link[rel="next"]');
10 | let href = window.location.href;
11 | if (!href.endsWith('/')) href = `${href}/`;
12 | const nextPageUrl = `${href}${nextPageNum}/`;
13 | const res = await fetch(nextPageUrl);
14 |
15 | if (nextTag) nextTag.href = nextPageUrl;
16 |
17 | if (res.ok) {
18 | const text = await res.text();
19 | const parser = new DOMParser();
20 |
21 | nextPageNum++;
22 | return parser.parseFromString(text, 'text/html');
23 | } else {
24 | readMoreRow.remove();
25 | }
26 | } catch (e) {
27 | console.error(`Connection error: ${e}`);
28 | }
29 | };
30 |
31 | let nextPageHtml = await fetchNextPage();
32 |
33 | const renderArticles = async () => {
34 | const nextPagePostCards = [...nextPageHtml.querySelectorAll('.post-card')];
35 | nextPagePostCards.forEach(postCard => postFeed.appendChild(postCard));
36 |
37 | nextPageHtml = await fetchNextPage();
38 | };
39 |
40 | readMoreBtn.addEventListener('click', renderArticles);
41 | })();
42 |
--------------------------------------------------------------------------------
/src/_includes/partials/icons/github.njk:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Keys
2 | .env
3 | .env.ci
4 |
5 | # Logs
6 | logs
7 | *.log
8 | npm-debug.log*
9 | yarn-debug.log*
10 | yarn-error.log*
11 |
12 | # Runtime data
13 | pids
14 | *.pid
15 | *.seed
16 | *.pid.lock
17 |
18 | # Directory for instrumented libs generated by jscoverage/JSCover
19 | lib-cov
20 |
21 | # Coverage directory used by tools like istanbul
22 | coverage
23 |
24 | # nyc test coverage
25 | .nyc_output
26 |
27 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
28 | .grunt
29 |
30 | # Bower dependency directory (https://bower.io/)
31 | bower_components
32 |
33 | # node-waf configuration
34 | .lock-wscript
35 |
36 | # Compiled binary addons (https://nodejs.org/api/addons.html)
37 | build/Release
38 |
39 | # Dependency directories
40 | node_modules/
41 | jspm_packages/
42 |
43 | # Typescript v1 declaration files
44 | typings/
45 |
46 | # Optional npm cache directory
47 | .npm
48 |
49 | # Optional eslint cache
50 | .eslintcache
51 |
52 | # Optional REPL history
53 | .node_repl_history
54 |
55 | # Output of 'npm pack'
56 | *.tgz
57 |
58 | # Yarn Integrity file
59 | .yarn-integrity
60 |
61 | # IDE
62 | .idea/*
63 | *.iml
64 | *.sublime-*
65 |
66 | # OSX
67 | .DS_Store
68 | .vscode
69 |
70 | # Eleventy Custom
71 | .cache/
72 | yarn-error.log
73 | .netlify/
74 | dist/
75 | *.tag.gz
76 | .secrets
77 |
78 | # Cypress.io
79 | cypress/videos
80 | cypress/screenshots
81 |
82 | # Generated config
83 | config/i18n/locales/**/trending.json
84 |
85 | docker/languages/
86 |
--------------------------------------------------------------------------------
/cypress/e2e/espanol/tag/structured-data.cy.ts:
--------------------------------------------------------------------------------
1 | import commonExpectedJsonLd from '../../../fixtures/common-expected-json-ld.json';
2 | const tagExpectedJsonLd = {
3 | '@type': 'Series',
4 | url: 'http://localhost:8080/espanol/news/tag/freecodecamp/',
5 | name: 'freeCodeCamp'
6 | };
7 | let jsonLdObj;
8 |
9 | describe('Tag page structured data (JSON-LD – Ghost sourced)', () => {
10 | before(() => {
11 | // Update baseUrl to include current language
12 | Cypress.config('baseUrl', 'http://localhost:8080/espanol/news/');
13 | });
14 |
15 | beforeEach(() => {
16 | cy.visit('/tag/freecodecamp/');
17 |
18 | jsonLdObj = cy
19 | .get('head script[type="application/ld+json"]')
20 | .then($script => {
21 | jsonLdObj = JSON.parse($script.text());
22 | });
23 | });
24 |
25 | it('matches the expected base values', () => {
26 | expect(jsonLdObj['@context']).to.equal(commonExpectedJsonLd['@context']);
27 | expect(jsonLdObj['@type']).to.equal(tagExpectedJsonLd['@type']);
28 | expect(jsonLdObj.url).to.equal(tagExpectedJsonLd.url);
29 | expect(jsonLdObj.name).to.equal(tagExpectedJsonLd.name);
30 | });
31 |
32 | it('matches the expected publisher values', () => {
33 | expect(jsonLdObj.publisher).to.deep.equal(
34 | commonExpectedJsonLd.espanol.publisher
35 | );
36 | });
37 |
38 | it('matches the expected mainEntityOfPage values', () => {
39 | expect(jsonLdObj.mainEntityOfPage).to.deep.equal(
40 | commonExpectedJsonLd.espanol.mainEntityOfPage
41 | );
42 | });
43 | });
44 |
--------------------------------------------------------------------------------
/src/_includes/assets/js/banner.js:
--------------------------------------------------------------------------------
1 | document.addEventListener('DOMContentLoaded', () => {
2 | const bannerAnchor = document.getElementById('banner');
3 | const bannerTextNode = document.getElementById('banner-text');
4 | const bannerDefaultText = `{% t 'banner.default', {
5 | '<0>': '',
6 | '0>': ' ',
7 | interpolation: {
8 | escapeValue: false
9 | }
10 | } %}`;
11 | const bannerDefaultLink = `{% t 'links:banner.default' %}`;
12 | const bannerAuthText = `{% t 'banner.authenticated', {
13 | '<0>': '',
14 | '0>': ' ',
15 | interpolation: {
16 | escapeValue: false
17 | }
18 | } %}`;
19 | const bannerAuthLink = `{% t 'links:banner.authenticated' %}`;
20 |
21 | const bannerDonorText = `{% t 'banner.authenticated-donor', {
22 | '<0>': '',
23 | '0>': ' ',
24 | interpolation: {
25 | escapeValue: false
26 | }
27 | } %}`;
28 | const bannerDonorLink = `{% t 'links:banner.authenticated-donor' %}`;
29 | if (isAuthenticated) {
30 | bannerTextNode.innerHTML = isDonor ? bannerDonorText : bannerAuthText;
31 | bannerAnchor.href = isDonor ? bannerDonorLink : bannerAuthLink;
32 | const textVariationType = isDonor ? 'donor' : 'authenticated';
33 | bannerAnchor.setAttribute('text-variation', textVariationType);
34 | } else {
35 | bannerTextNode.innerHTML = bannerDefaultText;
36 | bannerAnchor.href = bannerDefaultLink;
37 | bannerAnchor.setAttribute('text-variation', 'default');
38 | }
39 | });
40 |
--------------------------------------------------------------------------------
/cypress/e2e/english/author/structured-data.cy.ts:
--------------------------------------------------------------------------------
1 | import commonExpectedJsonLd from '../../../fixtures/common-expected-json-ld.json';
2 | const authorExpectedJsonLd = {
3 | '@type': 'Person',
4 | sameAs: ['https://x.com/abbeyrenn'], // X / Twitter
5 | name: 'Abigail Rennemeyer',
6 | url: 'http://localhost:8080/news/author/abbeyrenn/',
7 | description:
8 | 'I love editing articles and working with contributors. I also love the outdoors and good food.\n'
9 | };
10 | let jsonLdObj;
11 |
12 | describe('Author page structured data (JSON-LD – Hashnode sourced)', () => {
13 | beforeEach(() => {
14 | cy.visit('/author/abbeyrenn/');
15 |
16 | jsonLdObj = cy
17 | .get('head script[type="application/ld+json"]')
18 | .then($script => {
19 | jsonLdObj = JSON.parse($script.text());
20 | });
21 | });
22 |
23 | it('matches the expected base values', () => {
24 | expect(jsonLdObj['@context']).to.equal(commonExpectedJsonLd['@context']);
25 | expect(jsonLdObj['@type']).to.equal(authorExpectedJsonLd['@type']);
26 | expect(jsonLdObj.sameAs).to.deep.equal(authorExpectedJsonLd.sameAs);
27 | expect(jsonLdObj.name).to.equal(authorExpectedJsonLd.name);
28 | expect(jsonLdObj.url).to.equal(authorExpectedJsonLd.url);
29 | expect(jsonLdObj.description).to.equal(authorExpectedJsonLd.description);
30 | });
31 |
32 | it('matches the expected mainEntityOfPage values', () => {
33 | expect(jsonLdObj.mainEntityOfPage).to.deep.equal(
34 | commonExpectedJsonLd.english.mainEntityOfPage
35 | );
36 | });
37 | });
38 |
--------------------------------------------------------------------------------
/cypress/e2e/espanol/landing/structured-data.cy.ts:
--------------------------------------------------------------------------------
1 | import commonExpectedJsonLd from '../../../fixtures/common-expected-json-ld.json';
2 | let jsonLdObj;
3 |
4 | describe('Landing structured data (JSON-LD – Ghost sourced)', () => {
5 | before(() => {
6 | // Update baseUrl to include current language
7 | Cypress.config('baseUrl', 'http://localhost:8080/espanol/news/');
8 | });
9 |
10 | beforeEach(() => {
11 | cy.visit('/');
12 |
13 | jsonLdObj = cy
14 | .get('head script[type="application/ld+json"]')
15 | .then($script => {
16 | jsonLdObj = JSON.parse($script.text());
17 | });
18 | });
19 |
20 | it('matches the expected base values', () => {
21 | expect(jsonLdObj['@context']).to.equal(commonExpectedJsonLd['@context']);
22 | expect(jsonLdObj['@type']).to.equal(commonExpectedJsonLd['@type']);
23 | expect(jsonLdObj.url).to.equal(commonExpectedJsonLd.espanol.url);
24 | expect(jsonLdObj.description).to.equal(
25 | commonExpectedJsonLd.espanol.description
26 | );
27 | });
28 |
29 | it('matches the expected publisher values', () => {
30 | expect(jsonLdObj.publisher).to.deep.equal(
31 | commonExpectedJsonLd.espanol.publisher
32 | );
33 | });
34 |
35 | it('matches the expected image values', () => {
36 | expect(jsonLdObj.image).to.deep.equal(commonExpectedJsonLd.image);
37 | });
38 |
39 | it('matches the expected mainEntityOfPage values', () => {
40 | expect(jsonLdObj.mainEntityOfPage).to.deep.equal(
41 | commonExpectedJsonLd.espanol.mainEntityOfPage
42 | );
43 | });
44 | });
45 |
--------------------------------------------------------------------------------
/utils/get-image-dimensions.js:
--------------------------------------------------------------------------------
1 | import probe from 'probe-image-size';
2 |
3 | import { errorLogger } from './error-logger.js';
4 | import { getCache, setCache } from './cache.js';
5 |
6 | export const getImageDimensions = async (url, description) => {
7 | let imageDimensions = { width: 600, height: 400 };
8 |
9 | try {
10 | if (url.startsWith('data:')) {
11 | console.warn(`
12 | ---------------------------------------------------------------
13 | Warning: Skipping data URL for image dimensions in ${description.substring(0, 350)}...
14 | ---------------------------------------------------------------
15 | `);
16 |
17 | throw new Error('Data URL');
18 | }
19 | const cachedDimensions = getCache(url);
20 | if (cachedDimensions) imageDimensions = cachedDimensions;
21 |
22 | const fetchedImageDimensions = await probe(url, {
23 | open_timeout: 5000,
24 | response_timeout: 5000,
25 | read_timeout: 5000,
26 | // Don't follow redirects, which can
27 | // cause some localized builds to hang
28 | follow_max: 0
29 | });
30 | imageDimensions = {
31 | width: fetchedImageDimensions?.width
32 | ? fetchedImageDimensions?.width
33 | : imageDimensions.width,
34 | height: fetchedImageDimensions?.height
35 | ? fetchedImageDimensions?.height
36 | : imageDimensions.height
37 | };
38 |
39 | setCache(url, imageDimensions);
40 | } catch (_err) {
41 | errorLogger({ type: 'image', name: description });
42 | }
43 |
44 | return imageDimensions;
45 | };
46 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | BSD 3-Clause License
2 |
3 | Copyright (c) 2021, freeCodeCamp. All rights reserved.
4 |
5 | Redistribution and use in source and binary forms, with or without modification,
6 | are permitted provided that the following conditions are met:
7 |
8 | - Redistributions of source code must retain the above copyright notice, this
9 | list of conditions and the following disclaimer.
10 |
11 | - Redistributions in binary form must reproduce the above copyright notice, this
12 | list of conditions and the following disclaimer in the documentation and/or
13 | other materials provided with the distribution.
14 |
15 | - Neither the name of the copyright holder nor the names of its contributors may
16 | be used to endorse or promote products derived from this software without
17 | specific prior written permission.
18 |
19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
20 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
21 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
23 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
24 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
25 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
26 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
27 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
28 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29 |
--------------------------------------------------------------------------------
/cypress/support/utils/post-cards.ts:
--------------------------------------------------------------------------------
1 | const calculateClicks = total => {
2 | const postsPerPage = Cypress.env('postsPerPage');
3 |
4 | // If returning the num of clicks, subtract 1 because the first page is
5 | // fully populated
6 | return total <= postsPerPage ? 0 : Math.ceil(total / postsPerPage) - 1;
7 | };
8 |
9 | export const getPostCards = () => cy.get('.post-feed').find('.post-card');
10 |
11 | export const loadAndCountAllPostCards = (selector: string) => {
12 | cy.get(selector)
13 | .invoke('text')
14 | .then(text => {
15 | const loadMoreSelector = "[data-test-label='load-more-articles-button']";
16 | const totalPosts = Number(text.trim().match(/\d+/)[0]);
17 | const numOfClicks = calculateClicks(totalPosts);
18 |
19 | cy.intercept('GET', /\/news\/(author|tag)\/.+\/\d+/).as('fetchNextPage');
20 |
21 | Cypress._.times(numOfClicks, () => {
22 | cy.get(loadMoreSelector).click();
23 |
24 | cy.wait('@fetchNextPage');
25 | });
26 |
27 | getPostCards().should('have.length', totalPosts);
28 | });
29 | };
30 |
31 | export const loadAllPosts = () => {
32 | cy.get('body').then($el => {
33 | const loadMoreArticlesButtonIsVisible = $el
34 | .find("[data-test-label='load-more-articles-button']")
35 | .is(':visible');
36 |
37 | if (loadMoreArticlesButtonIsVisible) {
38 | cy.get("[data-test-label='load-more-articles-button']").click({
39 | force: true
40 | });
41 |
42 | loadAllPosts();
43 | }
44 | });
45 | };
46 |
47 | module.exports = {
48 | getPostCards,
49 | loadAndCountAllPostCards,
50 | loadAllPosts
51 | };
52 |
--------------------------------------------------------------------------------
/.github/workflows/crowdin-upload.yml:
--------------------------------------------------------------------------------
1 | name: i18n - Upload News
2 | on:
3 | workflow_dispatch:
4 | schedule:
5 | # runs every weekday at 1:00 PM UTC
6 | - cron: '0 13 * * 1-5'
7 |
8 | env:
9 | CAMPERBOT_GITHUB_TOKEN: ${{ secrets.CAMPERBOT_GITHUB_TOKEN }}
10 | CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_CAMPERBOT_SERVICE_TOKEN }}
11 | CROWDIN_API_URL: 'https://freecodecamp.crowdin.com/api/v2/'
12 | CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID_NEWS }}
13 |
14 | jobs:
15 | i18n-upload-news-files:
16 | name: News
17 | runs-on: ubuntu-24.04
18 |
19 | steps:
20 | - name: Checkout Source Files
21 | uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
22 |
23 | - name: Generate Crowdin Config
24 | uses: freecodecamp/crowdin-action@main
25 | env:
26 | PLUGIN: 'generate-config'
27 | PROJECT_NAME: 'news'
28 |
29 | - name: Crowdin Upload
30 | uses: crowdin/github-action@master
31 | # options: https://github.com/crowdin/github-action/blob/master/action.yml
32 | with:
33 | # uploads
34 | upload_sources: true
35 | upload_translations: false
36 | auto_approve_imported: false
37 | import_eq_suggestions: false
38 |
39 | # downloads
40 | download_translations: false
41 |
42 | # pull-request
43 | create_pull_request: false
44 |
45 | # global options
46 | config: './crowdin-config.yml'
47 | base_url: ${{ secrets.CROWDIN_BASE_URL_FCC }}
48 |
49 | # Uncomment below to debug
50 | # dryrun_action: true
51 |
--------------------------------------------------------------------------------
/docker/test/dev/html/dothraki/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | freeCodeCamp.org | Learn to Code
5 |
56 |
57 |
58 |
59 |
60 |
Idde tat freecodecamp's learning platform.
61 |
Jini dothraki learn root page.
62 |
Happy coding!
63 |
Click here to go back to the main page.
64 |
65 |
66 |
67 |
--------------------------------------------------------------------------------
/cypress/fixtures/common-expected-json-ld.json:
--------------------------------------------------------------------------------
1 | {
2 | "@context": "https://schema.org",
3 | "@type": "WebSite",
4 | "image": {
5 | "@type": "ImageObject",
6 | "url": "https://cdn.freecodecamp.org/platform/universal/fcc_meta_1920X1080-indigo.png",
7 | "width": 1920,
8 | "height": 1080
9 | },
10 | "english": {
11 | "publisher": {
12 | "@type": "Organization",
13 | "name": "freeCodeCamp.org",
14 | "url": "http://localhost:8080/news/",
15 | "logo": {
16 | "@type": "ImageObject",
17 | "url": "https://cdn.freecodecamp.org/platform/universal/fcc_primary.svg",
18 | "width": 2100,
19 | "height": 240
20 | }
21 | },
22 | "url": "http://localhost:8080/news/",
23 | "mainEntityOfPage": {
24 | "@type": "WebPage",
25 | "@id": "http://localhost:8080/news/"
26 | },
27 | "description": "Browse thousands of programming tutorials written by experts. Learn Web Development, Data Science, DevOps, Security, and get developer career advice."
28 | },
29 | "espanol": {
30 | "publisher": {
31 | "@type": "Organization",
32 | "name": "freeCodeCamp.org",
33 | "url": "http://localhost:8080/espanol/news/",
34 | "logo": {
35 | "@type": "ImageObject",
36 | "url": "https://cdn.freecodecamp.org/platform/universal/fcc_primary.svg",
37 | "width": 2100,
38 | "height": 240
39 | }
40 | },
41 | "url": "http://localhost:8080/espanol/news/",
42 | "mainEntityOfPage": {
43 | "@type": "WebPage",
44 | "@id": "http://localhost:8080/espanol/news/"
45 | },
46 | "description": "Descubre miles de cursos de programación escritos por expertos. Aprende Desarrollo Web, Ciencia de Datos, DevOps, Seguridad y obtén asesoramiento profesional para desarrolladores."
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/utils/search-bar-placeholder-number.js:
--------------------------------------------------------------------------------
1 | import { liteClient as algoliasearch } from 'algoliasearch/lite';
2 | import { join } from 'path';
3 |
4 | import { loadJSON } from './load-json.js';
5 | import { config } from '../config/index.js';
6 |
7 | const { algoliaAppId, algoliaAPIKey, algoliaIndex, eleventyEnv } = config;
8 | // Load mock search hits for testing in CI
9 | const mockHits = loadJSON(
10 | join(import.meta.dirname, '../cypress/fixtures/mock-search-hits.json')
11 | );
12 |
13 | export const roundDownToNearestHundred = num => Math.floor(num / 100) * 100;
14 |
15 | export const convertToLocalizedString = (num, ISOCode) =>
16 | num.toLocaleString(ISOCode); // Use commas or decimals depending on the locale
17 |
18 | export const getRoundedTotalRecords = async () => {
19 | let totalRecords = 0;
20 |
21 | try {
22 | if (eleventyEnv === 'ci') {
23 | totalRecords = mockHits.length;
24 | } else {
25 | const client = algoliasearch(algoliaAppId, algoliaAPIKey);
26 | const index = client.initIndex(algoliaIndex);
27 | const res = await index.search('');
28 |
29 | totalRecords = res?.nbHits;
30 | }
31 | } catch (_err) {
32 | process.env['FCC_DISABLE_WARNING'] === 'false' &&
33 | console.warn(`
34 | ----------------------------------------------------------
35 | Warning: Could not get the total number of Algolia records
36 | ----------------------------------------------------------
37 | Make sure that Algolia keys and index are set up correctly,
38 | or that the mock search hits are available if running in CI
39 | mode.
40 |
41 | Falling back to the default search placeholder text.
42 | ----------------------------------------------------------
43 | `);
44 | }
45 |
46 | return roundDownToNearestHundred(totalRecords);
47 | };
48 |
--------------------------------------------------------------------------------
/cypress/fixtures/common-expected-meta.json:
--------------------------------------------------------------------------------
1 | {
2 | "siteName": "freeCodeCamp.org",
3 | "english": {
4 | "siteUrl": "http://localhost:8080/news/",
5 | "title": "freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More",
6 | "keywords": "freeCodeCamp, programming, front-end, programmer, article, regular expressions, Python, JavaScript, AWS, JSON, HTML, CSS, Bootstrap, React, Vue, Webpack",
7 | "description": "Browse thousands of programming tutorials written by experts. Learn Web Development, Data Science, DevOps, Security, and get developer career advice.",
8 | "twitterHandle": "@freecodecamp"
9 | },
10 | "espanol": {
11 | "siteUrl": "http://localhost:8080/espanol/news/",
12 | "title": "Cursos de programación freeCodeCamp en Español: Python, JavaScript, Git y más",
13 | "keywords": "freeCodeCamp, programación, front-end, programador, artículo, expresiones regulares, Python, JavaScript, AWS, JSON, HTML, CSS, Bootstrap, React, Vue, Webpack",
14 | "description": "Descubre miles de cursos de programación escritos por expertos. Aprende Desarrollo Web, Ciencia de Datos, DevOps, Seguridad y obtén asesoramiento profesional para desarrolladores.",
15 | "twitterHandle": "@freecodecampes"
16 | },
17 | "publicationCover": {
18 | "url": "https://cdn.freecodecamp.org/platform/universal/fcc_meta_1920X1080-indigo.png",
19 | "width": 1920,
20 | "height": 1080
21 | },
22 | "favicon": {
23 | "ico": "https://cdn.freecodecamp.org/universal/favicons/favicon.ico",
24 | "png": "https://cdn.freecodecamp.org/universal/favicons/favicon.png"
25 | },
26 | "generator": "Eleventy",
27 | "facebook": {
28 | "url": "https://www.facebook.com/freecodecamp"
29 | },
30 | "twitter": {
31 | "cardType": "summary_large_image",
32 | "label1": "Written by",
33 | "label2": "Filed under"
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/utils/ping-editorial-team.js:
--------------------------------------------------------------------------------
1 | import fetch from 'node-fetch';
2 |
3 | import { errorLogger } from './error-logger.js';
4 | import { config } from '../config/index.js';
5 |
6 | const { chatWebhookKey, chatWebhookToken, eleventyEnv, currentLocale_i18n } =
7 | config;
8 |
9 | export const pingEditorialTeam = async duplicatesArr => {
10 | const msg = `Posts / pages with duplicate slugs have been found between Ghost and Hashnode. The following have been removed from the latest build:
11 |
12 | ${duplicatesArr.map(obj => `- The ${obj.contentType} titled "${obj.title}" with the slug "/${obj.slug}" on the ${currentLocale_i18n.charAt(0).toUpperCase() + currentLocale_i18n.slice(1)} ${obj.source} publication`).join('\n')}
13 |
14 | Please update the post / page slugs on either Ghost or Hashnode to include them in future builds.
15 | `;
16 | process.env['FCC_DISABLE_WARNING'] === 'false' &&
17 | console.warn(`
18 | -----------------------------------------------
19 | WARNING: Duplicate Post / Page Slugs Found
20 | -----------------------------------------------
21 | ${msg}
22 | `);
23 | errorLogger({ type: 'duplicate-slugs', name: msg });
24 |
25 | // Prevent sending messages while in dev or CI environments
26 | if (eleventyEnv === 'dev' || eleventyEnv === 'ci') return;
27 |
28 | try {
29 | const chatWebhookURL = `https://chat.googleapis.com/v1/spaces/AAAAHMCb1fg/messages?key=${chatWebhookKey}&token=${chatWebhookToken}`;
30 | const res = await fetch(chatWebhookURL, {
31 | method: 'POST',
32 | headers: {
33 | 'Content-Type': 'application/json'
34 | },
35 | body: JSON.stringify({
36 | text: msg
37 | })
38 | });
39 |
40 | return await res.json();
41 | } catch (err) {
42 | console.error(`
43 | -----------------------------------------------
44 | Error: Unable to ping the editorial team
45 | -----------------------------------------------
46 |
47 | ${err}
48 | `);
49 | }
50 | };
51 |
--------------------------------------------------------------------------------
/.github/workflows/docr-cleanup.yml:
--------------------------------------------------------------------------------
1 | name: DOCR - Cleanup Container Images
2 | on:
3 | workflow_dispatch:
4 | schedule:
5 | - cron: '5 0 * * 3,6' # 12:05 UTC on Wednesdays and Saturdays (6 hour maintenance window)
6 |
7 | jobs:
8 | remove:
9 | name: Delete Old Images
10 | runs-on: ubuntu-latest
11 | strategy:
12 | fail-fast: false
13 | matrix:
14 | repos:
15 | - english
16 | - chinese
17 | - espanol
18 | - italian
19 | - japanese
20 | - korean
21 | - portuguese
22 | - ukrainian
23 | variants:
24 | # - dev
25 | - org
26 | # Exclude the following combinations
27 | exclude:
28 | - repos: english
29 | variants: dev
30 |
31 | steps:
32 | - name: Install doctl
33 | uses: digitalocean/action-doctl@v2
34 | with:
35 | token: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }}
36 |
37 | - name: Log in to DigitalOcean Container Registry with short-lived credentials
38 | run: doctl registry login --expiry-seconds 1200
39 |
40 | - name: Delete Images
41 | uses: raisedadead/action-docr-cleanup@62b968c928fbb2dbce8b0caf11c0391f0921ea46 # v1
42 | with:
43 | repository_name: '${{ matrix.variants }}/news-${{ matrix.repos }}'
44 | days: '4'
45 |
46 | clean:
47 | name: Do Garbage Collection
48 | runs-on: ubuntu-latest
49 | needs: remove
50 | steps:
51 | - name: Install doctl
52 | uses: digitalocean/action-doctl@v2
53 | with:
54 | token: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }}
55 |
56 | - name: Log in to DigitalOcean Container Registry with short-lived credentials
57 | run: doctl registry login --expiry-seconds 1200
58 |
59 | - name: Trigger Garbage collection
60 | run: doctl registry garbage-collection start --include-untagged-manifests --force
61 |
--------------------------------------------------------------------------------
/docker/test/dev/html/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | freeCodeCamp.org | Learn to Code
5 |
56 |
57 |
58 |
59 |
60 |
Welcome to freeCodeCamp's learning platform.
61 |
This is a English learn root page.
62 |
Happy coding!
63 |
64 | Click here for /dothraki landing
65 | page.
66 |
67 |
68 | Click here for
69 | /italian/news landing page.
70 |
71 |
72 |
73 |
74 |
--------------------------------------------------------------------------------
/src/_includes/partials/site-nav.njk:
--------------------------------------------------------------------------------
1 |
2 |
3 | {% include "partials/search-bar.njk" %}
4 |
5 |
6 |
7 |
8 |
43 |
44 |
--------------------------------------------------------------------------------
/cypress/e2e/english/post/ads.cy.ts:
--------------------------------------------------------------------------------
1 | const selectors = {
2 | scripts: {
3 | adsense: 'script[src*="adsbygoogle.js"]'
4 | },
5 | ads: {
6 | wrapper: "[data-test-label='ad-wrapper']",
7 | text: "[data-test-label='ad-text']"
8 | }
9 | };
10 |
11 | describe('Ads (Hashnode sourced)', () => {
12 | beforeEach(() => {
13 | cy.visit('/how-do-numerical-conversions-work/');
14 | });
15 |
16 | it('the adsense script should be within the `head` element', () => {
17 | cy.get(`head ${selectors.scripts.adsense}`).should('have.length', 1);
18 | });
19 |
20 | it('the post should contain at least one ad', () => {
21 | cy.get(selectors.ads.wrapper).should('have.length.gte', 1);
22 | });
23 |
24 | it('all ad containers in the post should be visible', () => {
25 | cy.get(selectors.ads.wrapper).should('be.visible');
26 | });
27 |
28 | it('each ad container should contain an inner `ins` element', () => {
29 | cy.get(selectors.ads.wrapper).each($el => {
30 | cy.wrap($el).find('ins');
31 | });
32 | });
33 |
34 | it('each ad container should contain an inner `script` element', () => {
35 | cy.get(selectors.ads.wrapper).each($el => {
36 | cy.wrap($el).find('script').should('have.length', 1);
37 | });
38 | });
39 |
40 | it('ad elements should have the expected attributes and values', () => {
41 | cy.get('.ad-wrapper ins').each($el => {
42 | // Test for the bare essential attributes since data-ad-format can cause other attributes to change dynamically
43 | // To do: Refactor npm scripts and config to use Cypress env vars for data-ad-client and data-ad-slot. Might also
44 | // be able to get rid of the .env.ci file altogether
45 | expect($el.attr('data-ad-client')).to.equal('ca-pub-1234567890');
46 | expect($el.attr('data-ad-slot')).to.equal('1234567890');
47 | expect($el.attr('data-ad-format')).to.equal('auto');
48 | expect($el.attr('data-full-width-responsive')).to.equal('true');
49 | });
50 | });
51 | });
52 |
--------------------------------------------------------------------------------
/docker/README.md:
--------------------------------------------------------------------------------
1 | ## Notes
2 |
3 | This directory contains the Dockerfile to build out the image for the publications from the eleventy output. Additionally, there are other files that can be used for testing and experimentation.
4 |
5 | **What's in this directory:**
6 |
7 | - The [`ghost`](./ghost) directory contains the docker-compose file and seed content for standing up a set of local Ghost instances. This is useful for local development and testing against a cluster of Ghost instances.
8 | - The [`test/dev`](./test/dev) directory contains the docker-compose file to stand up a prod-like news instances that builds the eleventy output and serves it up. The build depends on the configurations set in the `.env` file in the root of the project.
9 | - The [`test/prd`](./test/prd) directory contains the docker-compose file to stand up a prod-like news instances that pulls down the image from the registry and serves it up. This should be the closest to the production environment.
10 |
11 | ## Instructions
12 |
13 | ### For the `ghost` directory
14 |
15 | - `docker compose up -d` to start the ghost instances.
16 |
17 | ### For the `dev` directory
18 |
19 | - Ensure the `.env` file is set up correctly. You have to build the `Italian` version of the site.
20 | - Run `docker-compose up -d` to stand up the prod-like development instance of `/italian/news`.
21 | - Visit `http://localhost/italian/news` to see the site.
22 |
23 | ### For the `prd` directory
24 |
25 | - Build and push the image to the registry, using the pipeline.
26 | - Authenticate to the docker registry (`doctl registry login`)
27 | - Pull the image from the registry (`docker pull registry.digitalocean.com/registry-name/image-name:latest`)
28 | - Update the image in the `.env` file in the `prd` directory - this file is unrelated to the root env config, and is used for providing keys to the docker setup. You can copy the sample .env file (`cp .env.sample .env`)
29 | - Run `docker-compose up -d` to stand up the prod-like instance of `/news`.
30 | - Visit `http://localhost/news` to see the site.
31 |
--------------------------------------------------------------------------------
/cypress/e2e/espanol/author/structured-data.cy.ts:
--------------------------------------------------------------------------------
1 | import commonExpectedJsonLd from '../../../fixtures/common-expected-json-ld.json';
2 | const authorExpectedJsonLd = {
3 | '@type': 'Person',
4 | sameAs: ['https://x.com/RafaelDavisH'], // X / Twitter
5 | name: 'Rafael D. Hernandez',
6 | url: 'http://localhost:8080/espanol/news/author/rafael/',
7 | // Custom banner image
8 | image: {
9 | '@type': 'ImageObject',
10 | url: 'http://localhost:3030/content/images/2024/09/fccbg_25e868b401.png',
11 | width: 1500,
12 | height: 500
13 | },
14 | description:
15 | 'Web Developer | Global Language Translations Lead at @freeCodeCamp'
16 | };
17 | let jsonLdObj;
18 |
19 | describe('Author page structured data (JSON-LD – Ghost sourced)', () => {
20 | before(() => {
21 | // Update baseUrl to include current language
22 | Cypress.config('baseUrl', 'http://localhost:8080/espanol/news/');
23 | });
24 |
25 | beforeEach(() => {
26 | cy.visit('/author/rafael/');
27 |
28 | jsonLdObj = cy
29 | .get('head script[type="application/ld+json"]')
30 | .then($script => {
31 | jsonLdObj = JSON.parse($script.text());
32 | });
33 | });
34 |
35 | it('matches the expected base values', () => {
36 | expect(jsonLdObj['@context']).to.equal(commonExpectedJsonLd['@context']);
37 | expect(jsonLdObj['@type']).to.equal(authorExpectedJsonLd['@type']);
38 | expect(jsonLdObj.sameAs).to.deep.equal(authorExpectedJsonLd.sameAs);
39 | expect(jsonLdObj.name).to.equal(authorExpectedJsonLd.name);
40 | expect(jsonLdObj.url).to.equal(authorExpectedJsonLd.url);
41 | expect(jsonLdObj.description).to.equal(authorExpectedJsonLd.description);
42 | });
43 |
44 | it('matches the expected image values', () => {
45 | expect(jsonLdObj.image).to.deep.equal(authorExpectedJsonLd.image);
46 | });
47 |
48 | it('matches the expected mainEntityOfPage values', () => {
49 | expect(jsonLdObj.mainEntityOfPage).to.deep.equal(
50 | commonExpectedJsonLd.espanol.mainEntityOfPage
51 | );
52 | });
53 | });
54 |
--------------------------------------------------------------------------------
/src/search-results.njk:
--------------------------------------------------------------------------------
1 | ---
2 | permalink: '/search/'
3 | ---
4 |
5 | {% extends 'layouts/default.njk' %}
6 |
7 | {% set codeinjection_head = doc.codeinjection_head %}
8 | {% set codeinjection_foot = doc.codeinjection_foot %}
9 |
10 | {% set searchLabel %}{% t 'search.label' %}{% endset %}
11 | {% set title = searchLabel + " - " + site.title %}
12 |
13 | {% block content %}
14 |
15 |
19 |
20 | {% endblock %}
21 |
22 | {% block headScripts %}
23 |
24 | {% endblock %}
25 |
26 | {% block scripts %}
27 | {% set js %}
28 | {% include "assets/js/search-results.js" %}
29 | {% endset %}
30 |
31 | {% endblock %}
32 |
33 | {% block seo %}
34 | {# Facebook OpenGraph #}
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 | {# X / Twitter Card #}
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 | {% endblock %}
54 |
--------------------------------------------------------------------------------
/cypress/e2e/espanol/post/ads.cy.ts:
--------------------------------------------------------------------------------
1 | const selectors = {
2 | scripts: {
3 | adsense: 'script[src*="adsbygoogle.js"]'
4 | },
5 | ads: {
6 | wrapper: "[data-test-label='ad-wrapper']",
7 | text: "[data-test-label='ad-text']"
8 | }
9 | };
10 |
11 | describe('Ads (Ghost sourced)', () => {
12 | before(() => {
13 | // Update baseUrl to include current language
14 | Cypress.config('baseUrl', 'http://localhost:8080/espanol/news/');
15 | });
16 |
17 | beforeEach(() => {
18 | cy.visit('/como-funciona-el-operado-de-signo-de-interrogacion-javascript/');
19 | });
20 |
21 | it('the adsense script should be within the `head` element', () => {
22 | cy.get(`head ${selectors.scripts.adsense}`).should('have.length', 1);
23 | });
24 |
25 | it('the post should contain at least one ad', () => {
26 | cy.get(selectors.ads.wrapper).should('have.length.gte', 1);
27 | });
28 |
29 | it('all ad containers in the post should be visible', () => {
30 | cy.get(selectors.ads.wrapper).should('be.visible');
31 | });
32 |
33 | it('each ad container should contain an inner `ins` element', () => {
34 | cy.get(selectors.ads.wrapper).each($el => {
35 | cy.wrap($el).find('ins');
36 | });
37 | });
38 |
39 | it('each ad container should contain an inner `script` element', () => {
40 | cy.get(selectors.ads.wrapper).each($el => {
41 | cy.wrap($el).find('script').should('have.length', 1);
42 | });
43 | });
44 |
45 | it('ad elements should have the expected attributes and values', () => {
46 | cy.get('.ad-wrapper ins').each($el => {
47 | // Test for the bare essential attributes since data-ad-format can cause other attributes to change dynamically
48 | // To do: Refactor npm scripts and config to use Cypress env vars for data-ad-client and data-ad-slot. Might also
49 | // be able to get rid of the .env.ci file altogether
50 | expect($el.attr('data-ad-client')).to.equal('ca-pub-1234567890');
51 | expect($el.attr('data-ad-slot')).to.equal('1234567890');
52 | expect($el.attr('data-ad-format')).to.equal('auto');
53 | expect($el.attr('data-full-width-responsive')).to.equal('true');
54 | });
55 | });
56 | });
57 |
--------------------------------------------------------------------------------
/config/i18n/redirects.test.js:
--------------------------------------------------------------------------------
1 | import { config } from '../../config/index.js';
2 | import { loadJSON } from '../../utils/load-json.js';
3 | const { locales } = config;
4 | const testLocales = [...locales, 'dothraki'];
5 |
6 | const path = `${import.meta.dirname}`;
7 |
8 | // To do: Simplify the npm scripts and test the generated serve.json files
9 | // in the docker directory. Add a test for the first /slug/amp to
10 | // /news/slug or /lang/news/slug redirect.
11 | describe('Redirect and rewrite tests:', () => {
12 | testLocales.forEach(lang => {
13 | describe(`-- ${lang} --`, () => {
14 | const redirects = loadJSON(`${path}/locales/${lang}/redirects.json`);
15 |
16 | test('redirects is an array', () => {
17 | expect(Array.isArray(redirects)).toBe(true);
18 | });
19 |
20 | test('redirect sources start with /', () => {
21 | redirects.map(redirect => expect(redirect.source).toMatch(/^\//));
22 | });
23 |
24 | test('redirect destinations start with / or https://', () => {
25 | redirects.map(redirect =>
26 | expect(redirect.destination).toMatch(/^\/|^https:\/\//)
27 | );
28 | });
29 |
30 | test('redirect sources do not end with /', () => {
31 | redirects.map(redirect => expect(redirect.source).not.toMatch(/\/$/));
32 | });
33 |
34 | test('redirect destinations do not end with /', () => {
35 | redirects.map(redirect =>
36 | expect(redirect.destination).not.toMatch(/\/$/)
37 | );
38 | });
39 |
40 | test('internal redirects point to the correct base path', () => {
41 | redirects
42 | .filter(redirect => redirect.destination.startsWith('/'))
43 | .map(redirect => {
44 | const expectedBasePath =
45 | lang === 'english'
46 | ? new RegExp(`^/news(/|$)`)
47 | : new RegExp(`^/${lang}/news(/|$)`);
48 |
49 | expect(redirect.destination).toMatch(expectedBasePath);
50 | });
51 | });
52 |
53 | test('there are no duplicate redirects', () => {
54 | const sources = redirects.map(obj => obj.source);
55 | const uniqueSources = [...new Set(sources)];
56 |
57 | expect(sources.length).toEqual(uniqueSources.length);
58 | });
59 | });
60 | });
61 | });
62 |
--------------------------------------------------------------------------------
/cypress/e2e/english/page/structured-data.cy.ts:
--------------------------------------------------------------------------------
1 | import commonExpectedJsonLd from '../../../fixtures/common-expected-json-ld.json';
2 | const pageExpectedJsonLd = {
3 | '@type': 'Article',
4 | headline: 'Thank You for Being a Supporter',
5 | url: 'http://localhost:8080/news/thank-you-for-being-a-supporter/',
6 | image: {
7 | '@type': 'ImageObject',
8 | url: 'https://cdn.freecodecamp.org/platform/universal/fcc_meta_1920X1080-indigo.png',
9 | width: 1920,
10 | height: 1080
11 | },
12 | description:
13 | 'freeCodeCamp is a highly-efficient education NGO. This year alone, we've provided million hours of free education to people around the world. At our charity's current operating budget, every dollar you donate to freeCodeCamp translates into 50 hours worth of technology education. When you donate to freeCodeCamp, you help people learn'
14 | };
15 | let jsonLdObj;
16 |
17 | describe('Page structured data (JSON-LD – Hashnode sourced)', () => {
18 | beforeEach(() => {
19 | cy.visit('/thank-you-for-being-a-supporter/');
20 |
21 | jsonLdObj = cy
22 | .get('head script[type="application/ld+json"]')
23 | .then($script => {
24 | jsonLdObj = JSON.parse($script.text());
25 | });
26 | });
27 |
28 | it('matches the expected base values', () => {
29 | expect(jsonLdObj['@context']).to.equal(commonExpectedJsonLd['@context']);
30 | expect(jsonLdObj['@type']).to.equal(pageExpectedJsonLd['@type']);
31 | expect(jsonLdObj.url).to.equal(pageExpectedJsonLd.url);
32 | expect(jsonLdObj.description).to.equal(pageExpectedJsonLd.description);
33 | expect(jsonLdObj.headline).to.equal(pageExpectedJsonLd.headline);
34 | });
35 |
36 | it('matches the expected publisher values', () => {
37 | expect(jsonLdObj.publisher).to.deep.equal(
38 | commonExpectedJsonLd.english.publisher
39 | );
40 | });
41 |
42 | it('matches the expected image values', () => {
43 | expect(jsonLdObj.image).to.deep.equal(pageExpectedJsonLd.image);
44 | });
45 |
46 | it('matches the expected mainEntityOfPage values', () => {
47 | expect(jsonLdObj.mainEntityOfPage).to.deep.equal(
48 | commonExpectedJsonLd.english.mainEntityOfPage
49 | );
50 | });
51 |
52 | // Note: Hashnode sourced pages don't include an author, or published/modified dates,
53 | // so those tests are omitted
54 | });
55 |
--------------------------------------------------------------------------------
/src/_includes/assets/js/social-row.js:
--------------------------------------------------------------------------------
1 | document.addEventListener('DOMContentLoaded', () => {
2 | const tweetButton = document.getElementById('tweet-btn');
3 | const url = window.location;
4 | // Use Nunjucks URL encoding here in case titles have any special characters like backticks
5 | const title = '{{ post.title | urlencode }}'.replace(/'/g, '%27');
6 | const twitterHandles = {
7 | originalPostAuthor:
8 | '{{ post.original_post.primary_author.twitter_handle }}',
9 | currentPostAuthor: '{{ post.primary_author.twitter_handle }}' // Author or translator depending on context
10 | };
11 | const isTranslation = Boolean('{{ post.original_post }}');
12 | let thanks;
13 |
14 | // Customize the tweet message only in cases where the (original post) author
15 | // or translator has a Twitter handle
16 | if (
17 | isTranslation &&
18 | (twitterHandles.originalPostAuthor || twitterHandles.currentPostAuthor)
19 | ) {
20 | const names = {
21 | originalPostAuthor: '{{ post.original_post.primary_author.name }}',
22 | currentPostAuthor: '{{ post.primary_author.name }}'
23 | };
24 |
25 | // Use either an X /Twitter handle or name in the post text
26 | thanks = encodeURIComponent(`{% t 'social-row.tweets.translation', {
27 | author: '${
28 | twitterHandles.originalPostAuthor
29 | ? twitterHandles.originalPostAuthor
30 | : names.originalPostAuthor
31 | }',
32 | translator: '${
33 | twitterHandles.currentPostAuthor
34 | ? twitterHandles.currentPostAuthor
35 | : names.currentPostAuthor
36 | }'
37 | } %}`);
38 | } else if (!isTranslation && twitterHandles.currentPostAuthor) {
39 | // An original post on a source Ghost instance
40 | // Only customize the post text if the author has an X / Twitter handle
41 | thanks = encodeURIComponent(`{% t 'social-row.tweets.default', {
42 | author: '${twitterHandles.currentPostAuthor}'
43 | } %}`);
44 | }
45 |
46 | const twitterIntentStr = thanks
47 | ? `https://x.com/intent/post?text=${thanks}%0A%0A${title}%0A%0A${url}`
48 | : `https://x.com/intent/post?text=${title}%0A%0A${url}`;
49 |
50 | const windowOpenStr = `window.open(
51 | '${twitterIntentStr}',
52 | 'share-twitter',
53 | 'width=550, height=235'
54 | ); return false;`;
55 |
56 | tweetButton.setAttribute('onclick', windowOpenStr);
57 | });
58 |
--------------------------------------------------------------------------------
/.github/workflows/cypress.yml:
--------------------------------------------------------------------------------
1 | name: CI - Cypress (e2e) tests
2 | on:
3 | push:
4 | branches-ignore:
5 | - renovate/**
6 | pull_request:
7 |
8 | jobs:
9 | cypress-run:
10 | name: E2E
11 | runs-on: ubuntu-24.04
12 | strategy:
13 | matrix:
14 | node-version: [22.x]
15 | browsers: [chrome, firefox]
16 | languages: [english, chinese, espanol]
17 |
18 | env:
19 | BUILD_LANG: ${{ matrix.languages }}
20 |
21 | CHINESE_GHOST_API_URL: ${{ secrets.CI_CHINESE_GHOST_API_URL }}
22 | CHINESE_GHOST_API_VERSION: ${{ secrets.CI_CHINESE_GHOST_API_VERSION }}
23 | CHINESE_GHOST_CONTENT_API_KEY: ${{ secrets.CI_CHINESE_GHOST_CONTENT_API_KEY }}
24 |
25 | ESPANOL_GHOST_API_URL: ${{ secrets.CI_ESPANOL_GHOST_API_URL }}
26 | ESPANOL_GHOST_API_VERSION: ${{ secrets.CI_ESPANOL_GHOST_API_VERSION }}
27 | ESPANOL_GHOST_CONTENT_API_KEY: ${{ secrets.CI_ESPANOL_GHOST_CONTENT_API_KEY }}
28 |
29 | HASHNODE_API_URL: api_url_from_hashnode_dashboard
30 | ENGLISH_HASHNODE_HOST: host_from_hashnode_dashboard
31 |
32 | ADS_ENABLED: true
33 | GOOGLE_ADSENSE_DATA_AD_CLIENT: ca-pub-1234567890
34 | GOOGLE_ADSENSE_DATA_AD_SLOT: 1234567890
35 |
36 | POSTS_PER_PAGE: ${{ secrets.POSTS_PER_PAGE }}
37 |
38 | SITE_DOMAIN: localhost:8080
39 |
40 | LOCALE_FOR_UI: ${{ matrix.languages }}
41 | LOCALE_FOR_GHOST: ${{ matrix.languages }}
42 |
43 | steps:
44 | - name: Checkout source files
45 | uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
46 |
47 | - name: Use Node.js ${{ matrix.node-version }}
48 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
49 | with:
50 | node-version: ${{ matrix.node-version }}
51 |
52 | - name: Install dependencies
53 | run: npm ci
54 |
55 | - name: Start Ghost containers
56 | run: npm run start:containers
57 |
58 | - name: Start CI server and run Cypress
59 | uses: cypress-io/github-action@v6
60 | with:
61 | start: npm run start:ci:${{ matrix.languages }}
62 | wait-on: http://localhost:8080
63 | wait-on-timeout: 1200
64 | browser: ${{ matrix.browsers }}
65 | headless: true
66 | spec: cypress/e2e/${{ matrix.languages }}/**/*
67 |
68 | - name: Stop Ghost containers
69 | run: npm run stop:containers
70 |
--------------------------------------------------------------------------------
/src/_includes/partials/role-list-item.njk:
--------------------------------------------------------------------------------
1 | {% macro roleListItem(authorOrTranslator, publishedAt, role, locale) %}
2 | {# Use the full URL for original authors, and a relative path for
3 | translators and regular authors #}
4 | {% set authorOrTranslatorURL = authorOrTranslator.url if role === "author" else authorOrTranslator.path %}
5 | {% set translatedLocale %}{% t "original-author-translator.locales." + locale %}{% endset %}
6 |
7 |
8 |
9 | {% if authorOrTranslator.profile_image %}
10 | {%
11 | image
12 | authorOrTranslator.profile_image,
13 | "author-profile-image",
14 | authorOrTranslator.name,
15 | "30px",
16 | [30],
17 | authorOrTranslator.image_dimensions.profile_image,
18 | "profile-image",
19 | lazyLoad
20 | %}
21 | {% else %}
22 |
23 | {% set avatarTitle = authorOrTranslator.name %}
24 | {% include "partials/icons/avatar.njk" %}
25 |
26 | {% endif %}
27 |
28 |
29 |
30 | {% if role === 'author' %}
31 | {% t 'original-author-translator.roles.author', {
32 | name: authorOrTranslator.name,
33 | locale: translatedLocale
34 | } %}
35 | {% elif role === 'translator' %}
36 | {% t 'original-author-translator.roles.translator', {
37 | name: authorOrTranslator.name
38 | } %}
39 | {% else %}
40 | {{ authorOrTranslator.name }}
41 | {% endif %}
42 |
43 |
44 | {% timeAgo publishedAt %}
45 |
46 |
47 |
48 | {% endmacro %}
49 |
--------------------------------------------------------------------------------
/utils/shortcodes/images.js:
--------------------------------------------------------------------------------
1 | // Note: Update this and image shortcodes once we
2 | // sync all Ghost images to an S3 bucket
3 | const ghostImageRe = /\/content\/images\/\d+\/\d+\//g;
4 |
5 | // Handle images from Ghost and from third-parties
6 | export const imageShortcode = (
7 | src,
8 | classes,
9 | alt,
10 | sizes,
11 | widths,
12 | dimensions,
13 | testLabel,
14 | lazyLoad
15 | ) => {
16 | const imageUrls = src.match(ghostImageRe)
17 | ? widths.map(width =>
18 | src.replace('/content/images/', `/content/images/size/w${width}/`)
19 | )
20 | : [src];
21 |
22 | // data-test-label is set dynamically to post-card-image or author-profile-image
23 | return `
24 |
40 | `;
41 | };
42 |
43 | // Copy images over from Ghost
44 | export const featureImageShortcode = (src, alt, sizes, widths, dimensions) => {
45 | const imageUrls = src.match(ghostImageRe)
46 | ? widths.map(width =>
47 | src.replace('/content/images/', `/content/images/size/w${width}/`)
48 | )
49 | : [src];
50 |
51 | return `
52 |
53 |
58 |
67 |
75 |
76 | `;
77 | };
78 |
--------------------------------------------------------------------------------
/src/_includes/layouts/feed.njk:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 | {% set metaTitle %}{% t 'meta-tags:title' %}{% endset %}
6 |
7 |
8 |
9 |
10 |
11 |
12 | {{ site.url }}
13 |
14 | https://cdn.freecodecamp.org/universal/favicons/favicon.png
15 |
16 |
17 |
18 | {{ site.url }}
19 |
20 | Eleventy
21 | {% buildDateFormatter site.timezone %}
22 |
23 | 60
24 | {% for post in feed.posts %}
25 | -
26 |
27 |
28 |
29 |
30 |
31 |
32 | {{ post.path | htmlBaseUrl(site.url) }}
33 | {{ post.id }}
34 | {% for tag in post.tags %}
35 |
36 |
37 |
38 | {% endfor %}
39 |
40 |
41 |
42 | {% buildDateFormatter site.timezone, post.published_at %}
43 |
44 |
45 |
46 |
47 |
48 | {% endfor %}
49 |
50 |
51 |
--------------------------------------------------------------------------------
/src/sitemaps/schemas/sitemap-image.xsd:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
9 | XML Schema for the Image Sitemap extension. This schema defines the
10 | Image-specific elements only; the core Sitemap elements are defined
11 | separately.
12 |
13 | Help Center documentation for the Image Sitemap extension:
14 |
15 | https://developers.google.com/search/docs/advanced/sitemaps/image-sitemaps
16 |
17 | Copyright 2010 Google Inc. All Rights Reserved.
18 |
19 |
20 |
21 |
22 |
23 | Encloses all information about a single image. Each URL (<loc> tag)
24 | can include up to 1,000 <image:image> tags.
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 | The URL of the image.
33 |
34 |
35 |
36 |
37 |
38 |
39 | The caption of the image.
40 |
41 |
42 |
43 |
44 |
45 |
46 | The geographic location of the image. For example,
47 | "Limerick, Ireland".
48 |
49 |
50 |
51 |
52 |
53 |
54 | The title of the image.
55 |
56 |
57 |
58 |
59 |
60 |
61 | A URL to the license of the image.
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
--------------------------------------------------------------------------------
/src/_data/site.js:
--------------------------------------------------------------------------------
1 | import { getImageDimensions } from '../../utils/get-image-dimensions.js';
2 | import {
3 | convertToLocalizedString,
4 | getRoundedTotalRecords
5 | } from '../../utils/search-bar-placeholder-number.js';
6 | import { getUsername } from '../../utils/get-username.js';
7 | import { translate } from '../../utils/translate.js';
8 | import { config } from '../../config/index.js';
9 |
10 | const { currentLocale_i18nISOCode, siteURL } = config;
11 |
12 | // Get X / Twitter profile based on links in config/i18n/locales/lang/links.json --
13 | // falls back to English Twitter profile if one for the current UI locale
14 | // isn't found
15 | const twitterURL = translate('links:twitter');
16 | const twitterHandle = twitterURL
17 | ? `@${getUsername(twitterURL)}`
18 | : '@freecodecamp';
19 | const logoURL =
20 | 'https://cdn.freecodecamp.org/platform/universal/fcc_primary.svg';
21 | const coverImageURL =
22 | 'https://cdn.freecodecamp.org/platform/universal/fcc_meta_1920X1080-indigo.png';
23 | const iconURL = 'https://cdn.freecodecamp.org/universal/favicons/favicon.ico';
24 |
25 | export default async () => {
26 | const site = {
27 | url: siteURL,
28 | lang: currentLocale_i18nISOCode.toLowerCase(),
29 | title: 'freeCodeCamp.org',
30 | facebook: 'https://www.facebook.com/freecodecamp',
31 | facebook_username: 'freecodecamp',
32 | twitter_handle: twitterHandle,
33 | twitter: `https://x.com/${twitterHandle}`,
34 | logo: logoURL,
35 | cover_image: coverImageURL,
36 | og_image: coverImageURL,
37 | twitter_image: coverImageURL,
38 | icon: iconURL
39 | };
40 |
41 | // Determine image dimensions before server runs for structured data
42 | const logoDimensions = await getImageDimensions(
43 | logoURL,
44 | `Site logo: ${logoURL}`
45 | );
46 | const coverImageDimensions = await getImageDimensions(
47 | coverImageURL,
48 | `Site cover image: ${coverImageURL}`
49 | );
50 |
51 | site.image_dimensions = {
52 | logo: {
53 | width: logoDimensions.width,
54 | height: logoDimensions.height
55 | },
56 | cover_image: {
57 | width: coverImageDimensions.width,
58 | height: coverImageDimensions.height
59 | }
60 | };
61 |
62 | // Dynamic search bar placeholder number
63 | const roundedTotalRecords = await getRoundedTotalRecords();
64 | site.roundedTotalRecords = roundedTotalRecords;
65 | site.roundedTotalRecordsLocalizedString = convertToLocalizedString(
66 | roundedTotalRecords,
67 | currentLocale_i18nISOCode
68 | );
69 |
70 | return site;
71 | };
72 |
--------------------------------------------------------------------------------
/cypress/e2e/english/post/i18n.cy.ts:
--------------------------------------------------------------------------------
1 | const selectors = {
2 | socialRowCTA: "[data-test-label='social-row-cta']",
3 | learnCTARow: "[data-test-label='learn-cta-row']",
4 | tweetButton: "[data-test-label='tweet-button']",
5 | defaultBio: "[data-test-label='default-bio']",
6 | adText: "[data-test-label='ad-text']"
7 | };
8 |
9 | describe('Post i18n (Hashnode sourced)', () => {
10 | context('General tests', () => {
11 | beforeEach(() => {
12 | cy.visit('/freecodecamp-press-books-handbooks/');
13 | });
14 |
15 | it('the learn CTA section should not render its i18n keys', () => {
16 | cy.get(`${selectors.learnCTARow} p`)
17 | .invoke('text')
18 | .then(text => text.trim())
19 | .should('not.contain', 'learn-to-code-cta');
20 | });
21 |
22 | it('the advertisement disclaimer text should not render its i18n key', () => {
23 | cy.get(selectors.adText)
24 | .invoke('text')
25 | .then(text => text.trim().toLowerCase())
26 | .should('not.equal', 'ad-text');
27 | });
28 | });
29 |
30 | context('Author with Twitter', () => {
31 | beforeEach(() => {
32 | cy.visit('/freecodecamp-press-books-handbooks/');
33 | });
34 |
35 | it('the social row CTA should not render its i18n keys', () => {
36 | cy.get(selectors.socialRowCTA)
37 | .invoke('text')
38 | .then(text => text.trim())
39 | .should('not.equal', 'social-row.cta.tweet-a-thanks');
40 | });
41 |
42 | it('the social row CTA tweet button should not render its i18n keys', () => {
43 | cy.get(selectors.tweetButton)
44 | .should('have.attr', 'onclick')
45 | .should('not.contain', 'social-row.tweets.default');
46 | });
47 | });
48 |
49 | context('Author with no Twitter or bio', () => {
50 | beforeEach(() => {
51 | cy.visit('/the-c-programming-handbook-for-beginners/');
52 | });
53 |
54 | it('the social row CTA should not render its i18n keys', () => {
55 | cy.get(selectors.socialRowCTA)
56 | .invoke('text')
57 | .then(text => text.trim())
58 | .should('not.equal', 'social-row.cta.tweet-it');
59 | });
60 |
61 | it('the social row CTA tweet button should not render its i18n keys', () => {
62 | cy.get(selectors.tweetButton)
63 | .should('have.attr', 'onclick')
64 | .should('not.contain', 'social-row.tweets.default');
65 | });
66 |
67 | it('the default author bio should not render its i18n key', () => {
68 | cy.get(selectors.defaultBio).should('not.contain', 'default-bio');
69 | });
70 | });
71 | });
72 |
--------------------------------------------------------------------------------
/src/sitemaps/schemas/siteindex.xsd:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
9 | XML Schema for Sitemap index files.
10 | Last Modifed 2009-04-08
11 |
12 |
13 |
14 |
15 |
16 | Container for a set of up to 50,000 sitemap URLs.
17 | This is the root element of the XML file.
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 | Container for the data needed to describe a sitemap.
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 | REQUIRED: The location URI of a sitemap.
43 | The URI must conform to RFC 2396 (http://www.ietf.org/rfc/rfc2396.txt).
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 | OPTIONAL: The date the document was last modified. The date must conform
55 | to the W3C DATETIME format (http://www.w3.org/TR/NOTE-datetime).
56 | Example: 2005-05-10
57 | Lastmod may also contain a timestamp.
58 | Example: 2005-05-10T17:33:30+08:00
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
--------------------------------------------------------------------------------
/sample.env:
--------------------------------------------------------------------------------
1 | # ---------------------
2 | # Ghost API
3 | # ---------------------
4 |
5 | LOCAL_GHOST_API_URL=api_url_from_ghost_dashboard
6 | LOCAL_GHOST_CONTENT_API_KEY=api_key_from_ghost_dashboard
7 | LOCAL_GHOST_API_VERSION=v{major}.{minor}
8 |
9 | CHINESE_GHOST_API_URL=api_url_from_ghost_dashboard
10 | CHINESE_GHOST_CONTENT_API_KEY=api_key_from_ghost_dashboard
11 | CHINESE_GHOST_API_VERSION=v{major}.{minor}
12 |
13 | ESPANOL_GHOST_API_URL=api_url_from_ghost_dashboard
14 | ESPANOL_GHOST_CONTENT_API_KEY=api_key_from_ghost_dashboard
15 | ESPANOL_GHOST_API_VERSION=v{major}.{minor}
16 |
17 | ITALIAN_GHOST_API_URL=api_url_from_ghost_dashboard
18 | ITALIAN_GHOST_CONTENT_API_KEY=api_key_from_ghost_dashboard
19 | ITALIAN_GHOST_API_VERSION=v{major}.{minor}
20 |
21 | JAPANESE_GHOST_API_URL=api_url_from_ghost_dashboard
22 | JAPANESE_GHOST_CONTENT_API_KEY=api_key_from_ghost_dashboard
23 | JAPANESE_GHOST_API_VERSION=v{major}.{minor}
24 |
25 | KOREAN_GHOST_API_URL=api_url_from_ghost_dashboard
26 | KOREAN_GHOST_CONTENT_API_KEY=api_key_from_ghost_dashboard
27 | KOREAN_GHOST_API_VERSION=v{major}.{minor}
28 |
29 | PORTUGUESE_GHOST_API_URL=api_url_from_ghost_dashboard
30 | PORTUGUESE_GHOST_CONTENT_API_KEY=api_key_from_ghost_dashboard
31 | PORTUGUESE_GHOST_API_VERSION=v{major}.{minor}
32 |
33 | UKRAINIAN_GHOST_API_URL=api_url_from_ghost_dashboard
34 | UKRAINIAN_GHOST_CONTENT_API_KEY=api_key_from_ghost_dashboard
35 | UKRAINIAN_GHOST_API_VERSION=v{major}.{minor}
36 |
37 | # ---------------------
38 | # Hashnode API
39 | # ---------------------
40 |
41 | HASHNODE_API_URL=api_url_from_hashnode_dashboard
42 | ENGLISH_HASHNODE_HOST=host_from_hashnode_dashboard
43 |
44 | # ---------------------
45 | # Search
46 | # ---------------------
47 |
48 | ALGOLIA_APP_ID=app_id_from_algolia_dashboard
49 | ALGOLIA_API_KEY=api_key_from_algolia_dashboard
50 |
51 | # ---------------------
52 | # Monetization
53 | # ---------------------
54 |
55 | ADS_ENABLED=true
56 | GOOGLE_ADSENSE_DATA_AD_CLIENT=ca-pub-1234567890
57 | GOOGLE_ADSENSE_DATA_AD_SLOT=1234567890
58 |
59 | # ---------------------------------
60 | # Google Chat credentials
61 | # ---------------------------------
62 |
63 | CHAT_WEBHOOK_KEY=chat_webhook_key_from_space_settings
64 | CHAT_WEBHOOK_TOKEN=chat_webhook_token_from_space_settings
65 |
66 | # ---------------------
67 | # Pagination
68 | # ---------------------
69 |
70 | # This number and the one for `size` in the
71 | # front matter of `src/index.njk` must match
72 | POSTS_PER_PAGE=25
73 |
74 | # ---------------------
75 | # Build variants
76 | # ---------------------
77 |
78 | # SITE_DOMAIN=freecodecamp.org
79 | SITE_DOMAIN=localhost:8080
80 | LOCALE_FOR_UI=italian
81 | LOCALE_FOR_GHOST=italian
82 |
83 | # HASHNODE_DEBUG_MODE_FIRST_PAGE_ONLY=true
84 | # DO_NOT_FETCH_FROM_GHOST=true
85 |
--------------------------------------------------------------------------------
/tools/download-trending.js:
--------------------------------------------------------------------------------
1 | import gracefulFS from 'graceful-fs';
2 | import { resolve } from 'path';
3 | import fetch from 'node-fetch';
4 | import { load } from 'js-yaml';
5 |
6 | import { trendingSchemaValidator } from './schemas/trending-schema.js';
7 | import { config } from '../config/index.js';
8 |
9 | const { readFileSync, writeFileSync } = gracefulFS;
10 | const { currentLocale_i18n } = config;
11 |
12 | const download = async clientLocale => {
13 | const trendingURL = `https://cdn.freecodecamp.org/universal/trending/${clientLocale}.yaml`;
14 | const trendingLocation = resolve(
15 | import.meta.dirname,
16 | `../config/i18n/locales/${clientLocale}/trending.json`
17 | );
18 |
19 | const loadLocalTrendingJSON = () => {
20 | const localTrendingJSON = readFileSync(trendingLocation, 'utf8');
21 |
22 | if (!localTrendingJSON) {
23 | throw new Error(
24 | `
25 | ----------------------------------------------------
26 | Error: ${trendingLocation} is missing.
27 | ----------------------------------------------------
28 | `
29 | );
30 | }
31 |
32 | return localTrendingJSON;
33 | };
34 |
35 | const loadTrendingJSON = async () => {
36 | try {
37 | const res = await fetch(trendingURL);
38 |
39 | if (!res.ok) {
40 | throw new Error(
41 | `
42 | ----------------------------------------------------
43 | Error: The CDN is missing the trending YAML file.
44 | ----------------------------------------------------
45 | Unable to fetch the ${clientLocale} footer: ${res.statusText}
46 | `
47 | );
48 | }
49 |
50 | const data = await res.text();
51 | const trendingJSON = JSON.stringify(load(data));
52 |
53 | return trendingJSON;
54 | } catch (err) {
55 | if (process.env.FREECODECAMP_NODE_ENV === 'production') {
56 | throw new Error(err.message);
57 | }
58 |
59 | return loadLocalTrendingJSON();
60 | }
61 | };
62 |
63 | const trendingJSON = await loadTrendingJSON();
64 |
65 | writeFileSync(trendingLocation, trendingJSON);
66 |
67 | const trendingObject = JSON.parse(trendingJSON);
68 | const validationError = trendingSchemaValidator(trendingObject).error || null;
69 |
70 | if (validationError) {
71 | throw new Error(
72 | `
73 | ----------------------------------------------------
74 | Error: The trending JSON is invalid.
75 | ----------------------------------------------------
76 | Unable to validate the ${clientLocale} trending JSON schema: ${validationError.message}
77 | `
78 | );
79 | }
80 | };
81 |
82 | if (!currentLocale_i18n)
83 | throw Error('currentLocale_i18n must be set to a valid locale');
84 |
85 | download(currentLocale_i18n);
86 |
--------------------------------------------------------------------------------
/src/_includes/partials/card.njk:
--------------------------------------------------------------------------------
1 | {% from "partials/role-list-item.njk" import roleListItem %}
2 |
3 | {% macro card(post, index) %}
4 | {% set fCCAuthorRegEx = r/^freeCodeCamp(\.org)?$/ %}
5 | {% set lazyLoad = true if (index >= 4) else false %}
6 | {% set primaryTag = post.tags[0] %}
7 |
8 |
9 |
10 | {%
11 | image
12 | post.feature_image,
13 | "post-card-image",
14 | (post.title | escape),
15 | "(max-width: 360px) 300px,
16 | (max-width: 655px) 600px,
17 | (max-width: 767px) 1000px,
18 | (min-width: 768px) 300px,
19 | 92vw",
20 | [300, 600, 1000, 2000],
21 | post.image_dimensions.feature_image,
22 | "feature-image",
23 | lazyLoad
24 | %}
25 |
26 |
27 |
28 |
42 |
43 |
44 | {% if fCCAuthorRegEx.test(post.primary_author.name) %}
45 | {% timeAgo post.published_at %}
46 | {% else %}
47 |
48 | {% if post.original_post %}
49 | {{ roleListItem(post.primary_author, post.published_at, 'translator') }}
50 | {{ roleListItem(post.original_post.primary_author, post.original_post.published_at, 'author', post.original_post.locale_i18n) }}
51 | {% else %}
52 | {{ roleListItem(post.primary_author, post.published_at) }}
53 | {% endif %}
54 |
55 | {% endif %}
56 |
57 |
58 |
59 | {% endmacro %}
60 |
--------------------------------------------------------------------------------
/src/sitemaps/sitemaps.test.js:
--------------------------------------------------------------------------------
1 | import gracefulFS from 'graceful-fs';
2 | import libxmljs from 'libxmljs';
3 | import { join } from 'path';
4 |
5 | const { readFileSync } = gracefulFS;
6 | const { parseXml } = libxmljs;
7 |
8 | const sitemapFilenames = [
9 | 'sitemap.xml',
10 | 'sitemap-pages.xml',
11 | 'sitemap-posts.xml',
12 | 'sitemap-tags.xml',
13 | 'sitemap-authors.xml'
14 | ];
15 | const distPath = join(import.meta.dirname, '../../dist');
16 |
17 | describe('Sitemap tests:', () => {
18 | describe('Validate sitemaps against schemas', () => {
19 | sitemapFilenames.forEach(sitemapFilename => {
20 | test(`${sitemapFilename} is valid`, () => {
21 | try {
22 | const sitemap = readFileSync(join(distPath, sitemapFilename), 'utf8');
23 | const schemaFilename =
24 | sitemapFilename === 'sitemap.xml' ? 'siteindex.xsd' : 'sitemap.xsd';
25 | const schema = readFileSync(
26 | join(import.meta.dirname, `./schemas/${schemaFilename}`),
27 | 'utf8'
28 | );
29 | const sitemapDoc = parseXml(sitemap);
30 | const schemaDoc = parseXml(schema);
31 | const isValid = sitemapDoc.validate(schemaDoc);
32 |
33 | if (!isValid) {
34 | throw new Error(
35 | `${sitemapFilename} is not valid: ${sitemapDoc.validationErrors}`
36 | );
37 | }
38 |
39 | expect(isValid).toBeTruthy();
40 | } catch (err) {
41 | // Throw error again to fail test if a sitemap cannot be parsed correctly
42 | throw new Error(err);
43 | }
44 | });
45 | });
46 | });
47 |
48 | describe('Posts sitemap', () => {
49 | describe('Last modified date', () => {
50 | test('All posts should have a valid last modified date', () => {
51 | const postsSitemap = readFileSync(
52 | join(distPath, 'sitemap-posts.xml'),
53 | 'utf8'
54 | );
55 | const postsSitemapDoc = parseXml(postsSitemap);
56 | const postURLNodes = postsSitemapDoc
57 | .root()
58 | .childNodes()
59 | .filter(node => {
60 | return node.name() === 'url';
61 | });
62 | const lastmodDates = postURLNodes
63 | .map(node => {
64 | return node
65 | .childNodes()
66 | .filter(node => node.name() === 'lastmod')
67 | .map(node => node.text());
68 | })
69 | .flat();
70 |
71 | // Ensure all posts have a last modified date
72 | expect(lastmodDates).toHaveLength(postURLNodes.length);
73 |
74 | // Ensure all last modified dates are valid dates
75 | lastmodDates.forEach(date => {
76 | expect(new Date(date)).toBeInstanceOf(Date);
77 | });
78 | });
79 | });
80 | });
81 | });
82 |
--------------------------------------------------------------------------------
/cypress/e2e/espanol/post/i18n.cy.ts:
--------------------------------------------------------------------------------
1 | const selectors = {
2 | socialRowCTA: "[data-test-label='social-row-cta']",
3 | learnCTARow: "[data-test-label='learn-cta-row']",
4 | tweetButton: "[data-test-label='tweet-button']",
5 | defaultBio: "[data-test-label='default-bio']",
6 | adText: "[data-test-label='ad-text']"
7 | };
8 |
9 | describe('Post i18n (Ghost sourced)', () => {
10 | before(() => {
11 | // Update baseUrl to include current language
12 | Cypress.config('baseUrl', 'http://localhost:8080/espanol/news/');
13 | });
14 |
15 | context('General tests', () => {
16 | beforeEach(() => {
17 | cy.visit(
18 | '/como-funciona-el-operado-de-signo-de-interrogacion-javascript/'
19 | );
20 | });
21 |
22 | it('the learn CTA section should not render its i18n keys', () => {
23 | cy.get(`${selectors.learnCTARow} p`)
24 | .invoke('text')
25 | .then(text => text.trim())
26 | .should('not.contain', 'learn-to-code-cta');
27 | });
28 |
29 | it('the advertisement disclaimer text should not render its i18n key', () => {
30 | cy.get(selectors.adText)
31 | .invoke('text')
32 | .then(text => text.trim().toLowerCase())
33 | .should('not.equal', 'ad-text');
34 | });
35 | });
36 |
37 | context('Author with Twitter', () => {
38 | beforeEach(() => {
39 | cy.visit(
40 | '/como-funciona-el-operado-de-signo-de-interrogacion-javascript/'
41 | );
42 | });
43 |
44 | it('the social row CTA should not render its i18n keys', () => {
45 | cy.get(selectors.socialRowCTA)
46 | .invoke('text')
47 | .then(text => text.trim())
48 | .should('not.equal', 'social-row.cta.tweet-a-thanks');
49 | });
50 |
51 | it('the social row CTA tweet button should not render its i18n keys', () => {
52 | cy.get(selectors.tweetButton)
53 | .should('have.attr', 'onclick')
54 | .should('not.contain', 'social-row.tweets.default');
55 | });
56 | });
57 |
58 | context('Author with no Twitter or bio', () => {
59 | beforeEach(() => {
60 | cy.visit('/ghost-no-author-profile-pic/');
61 | });
62 |
63 | it('the social row CTA should not render its i18n keys', () => {
64 | cy.get(selectors.socialRowCTA)
65 | .invoke('text')
66 | .then(text => text.trim())
67 | .should('not.equal', 'social-row.cta.tweet-it');
68 | });
69 |
70 | it('the social row CTA tweet button should not render its i18n keys', () => {
71 | cy.get(selectors.tweetButton)
72 | .should('have.attr', 'onclick')
73 | .should('not.contain', 'social-row.tweets.default');
74 | });
75 |
76 | it('the default author bio should not render its i18n key', () => {
77 | cy.get(selectors.defaultBio).should('not.contain', 'default-bio');
78 | });
79 | });
80 | });
81 |
--------------------------------------------------------------------------------
/src/_includes/layouts/author.njk:
--------------------------------------------------------------------------------
1 | {% extends 'layouts/default.njk' %}
2 | {% from "partials/card.njk" import card %}
3 |
4 | {% set title = author.name + " - " + site.title %}
5 | {% set canonicalUrl = author.path | htmlBaseUrl(site.url) %}
6 | {% set defaultDescription %}{% t 'meta-tags:description' %}{% endset %}
7 |
8 | {% block content %}
9 | {% include "partials/author-info.njk" %}
10 |
11 |
12 |
13 |
14 | {% if author.posts %}
15 | {% for post in author.posts %}
16 | {{ card(post, loop.index0) }}
17 | {% endfor %}
18 | {% endif %}
19 |
20 | {% include "partials/pagination.njk" %}
21 |
22 |
23 | {% endblock %}
24 |
25 | {% block seo %}
26 | {% if author.bio %}
27 |
28 | {% endif %}
29 |
30 | {# Facebook OpenGraph #}
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 | {% if author.facebook_username %}
39 |
40 | {% endif %}
41 |
42 | {# X / Twitter Card #}
43 |
44 |
45 |
46 |
47 |
48 |
49 | {% if author.twitter_handle %}
50 |
51 | {% endif %}
52 |
53 |
54 |
55 | {% endblock %}
56 |
57 | {% block jsonLd %}
58 |
59 | {% endblock %}
60 |
--------------------------------------------------------------------------------
/utils/modify-html-content.js:
--------------------------------------------------------------------------------
1 | import jsdom from 'jsdom';
2 |
3 | import { translate } from './translate.js';
4 | import {
5 | generateHashnodeEmbedMarkup,
6 | setDefaultAlt
7 | } from './modify-html-helpers.js';
8 | import { getImageDimensions } from './get-image-dimensions.js';
9 | import { fitVids } from './fitvids.js';
10 |
11 | const { JSDOM } = jsdom;
12 |
13 | export const modifyHTMLContent = async ({ postContent, postTitle, source }) => {
14 | const dom = new JSDOM(postContent);
15 | const window = dom.window;
16 | const document = window.document;
17 | const hashnodeEmbedAnchorEls = [
18 | ...document.querySelectorAll('div.embed-wrapper a.embed-card')
19 | ];
20 |
21 | await Promise.all(
22 | hashnodeEmbedAnchorEls.map(async anchorEl => {
23 | const embedWrapper = anchorEl.parentElement;
24 | const embedURL = anchorEl.href;
25 | const embedMarkup = await generateHashnodeEmbedMarkup(embedURL);
26 |
27 | if (embedMarkup) {
28 | embedWrapper.innerHTML = embedMarkup;
29 | }
30 | })
31 | );
32 |
33 | const embeds = [...document.getElementsByTagName('embed')];
34 | const images = [...document.getElementsByTagName('img')];
35 | const iframes = [...document.getElementsByTagName('iframe')];
36 |
37 | if (source === 'Ghost' && (embeds.length || iframes.length)) fitVids(window);
38 |
39 | await Promise.all(
40 | images.map(async image => {
41 | // To do: swap out the image URLs here once we have them auto synced
42 | // with an S3 bucket
43 | const { width, height } = await getImageDimensions(
44 | image.src,
45 | `Body image in ${postTitle}: ${image.src}`
46 | );
47 |
48 | image.setAttribute('width', width);
49 | image.setAttribute('height', height);
50 |
51 | if (!image.alt) setDefaultAlt(image);
52 |
53 | image.setAttribute('loading', 'lazy');
54 | }),
55 |
56 | iframes.map(async iframe => {
57 | if (!iframe.title) iframe.setAttribute('title', translate('embed-title'));
58 | // For iframes on Hashnode that were copy and pasted into an HTML block,
59 | // wrap them in a div similar to how Hashnode does for links in embed blocks
60 | if (
61 | source === 'Hashnode' &&
62 | !['embed-wrapper', 'giphy-wrapper'].some(className =>
63 | iframe?.parentElement?.classList.contains(className)
64 | )
65 | ) {
66 | const embedWrapper = document.createElement('div');
67 |
68 | embedWrapper.classList.add('embed-wrapper');
69 | iframe.parentElement.replaceChild(embedWrapper, iframe);
70 | embedWrapper.appendChild(iframe);
71 | }
72 |
73 | iframe.setAttribute('loading', 'lazy');
74 | })
75 | );
76 |
77 | // The jsdom parser wraps the incomplete HTML from the Ghost
78 | // API with HTML, head, and body elements, so return whatever
79 | // is within the new body element it added
80 | return document.body.innerHTML;
81 | };
82 |
--------------------------------------------------------------------------------
/.github/workflows/node.js-test.yml:
--------------------------------------------------------------------------------
1 | name: CI - Node.js Tests
2 |
3 | on:
4 | push:
5 | branches: [main]
6 | pull_request:
7 | branches: [main]
8 |
9 | jobs:
10 | lint:
11 | name: Lint
12 | runs-on: ubuntu-24.04
13 |
14 | strategy:
15 | matrix:
16 | node-version: [22.x]
17 |
18 | steps:
19 | - name: Checkout source code
20 | uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
21 |
22 | - name: Use Node.js ${{ matrix.node-version }}
23 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
24 | with:
25 | node-version: ${{ matrix.node-version }}
26 |
27 | - name: Set environment variables
28 | run: cp sample.env .env
29 |
30 | - name: Install dependencies
31 | run: npm ci
32 |
33 | - name: Lint source files
34 | run: npm run lint
35 |
36 | test:
37 | name: Test
38 | needs: lint
39 | runs-on: ubuntu-24.04
40 |
41 | strategy:
42 | matrix:
43 | node-version: [22.x]
44 | languages: [english, espanol]
45 |
46 | env:
47 | POSTS_PER_PAGE: ${{ secrets.POSTS_PER_PAGE }}
48 |
49 | HASHNODE_API_URL: api_url_from_hashnode_dashboard
50 | ENGLISH_HASHNODE_HOST: host_from_hashnode_dashboard
51 |
52 | ESPANOL_GHOST_API_URL: ${{ secrets.CI_ESPANOL_GHOST_API_URL }}
53 | ESPANOL_GHOST_API_VERSION: ${{ secrets.CI_ESPANOL_GHOST_API_VERSION }}
54 | ESPANOL_GHOST_CONTENT_API_KEY: ${{ secrets.CI_ESPANOL_GHOST_CONTENT_API_KEY }}
55 |
56 | SITE_DOMAIN: localhost:8080
57 |
58 | LOCALE_FOR_UI: ${{ matrix.languages }}
59 | LOCALE_FOR_GHOST: ${{ matrix.languages }}
60 |
61 | steps:
62 | - name: Checkout source code
63 | uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
64 |
65 | - name: Use Node.js ${{ matrix.node-version }}
66 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
67 | with:
68 | node-version: ${{ matrix.node-version }}
69 |
70 | - name: Install dependencies
71 | run: npm ci
72 |
73 | - name: Set fetch from Ghost environment variable
74 | if: ${{ matrix.languages == 'english' }}
75 | run: echo "DO_NOT_FETCH_FROM_GHOST=true" >> $GITHUB_ENV
76 |
77 | - name: Start Espanol Ghost container
78 | if: ${{ matrix.languages == 'espanol' }}
79 | run: npm run start:containers --service espanol
80 |
81 | - name: Sleep for 5 seconds
82 | if: ${{ matrix.languages == 'espanol' }}
83 | run: sleep 5s
84 | shell: bash
85 |
86 | - name: Build CI site
87 | run: npm run build:ci
88 |
89 | - name: Run tests
90 | run: npm run test
91 |
92 | - name: Perform Typescript Typecheck
93 | run: npm run type-check
94 |
95 | - name: Stop Ghost containers
96 | if: ${{ matrix.languages == 'espanol' }}
97 | run: npm run stop:containers
98 |
--------------------------------------------------------------------------------
/src/_includes/partials/byline.njk:
--------------------------------------------------------------------------------
1 | {% macro byline(authorOrTranslator, lazyLoad, showBio, role, locale) %}
2 | {# Use the full URL for original authors, and a relative path for
3 | translators and regular authors #}
4 | {% set authorOrTranslatorURL = authorOrTranslator.url if role === "author" else authorOrTranslator.path %}
5 | {% set translatedLocale %}{% t "original-author-translator.locales." + locale %}{% endset %}
6 |
7 |
8 | {% if authorOrTranslator.profile_image %}
9 | {%
10 | image
11 | authorOrTranslator.profile_image,
12 | "author-profile-image",
13 | authorOrTranslator.name,
14 | "60px",
15 | [60],
16 | authorOrTranslator.image_dimensions.profile_image,
17 | "profile-image",
18 | lazyLoad
19 | %}
20 | {% else %}
21 |
22 | {% set avatarTitle = authorOrTranslator.name %}
23 | {% include "partials/icons/avatar.njk" %}
24 |
25 | {% endif %}
26 |
27 |
60 |
61 | {% endmacro %}
62 |
--------------------------------------------------------------------------------
/cypress/e2e/english/search-results/search-results.cy.ts:
--------------------------------------------------------------------------------
1 | const selectors = {
2 | authorList: "[data-test-label='author-list']",
3 | authorProfileImage: "[data-test-label='author-profile-image']",
4 | avatar: "[data-test-label='avatar']",
5 | postCard: "[data-test-label='post-card']"
6 | };
7 |
8 | // Tests here should apply to all search results pages, which are built dynamically from calls to Algolia
9 | describe('Search results', () => {
10 | beforeEach(() => {
11 | // Note: 11ty's dev server expects a trailing slash
12 | // immediately after `/search`, before the `query` URL
13 | // parameter. This does not happen on production since we
14 | // don't redirect from the non-trailing slash version of the
15 | // page to the trailing slash version
16 | cy.visit('/search/?query=mock%20search%20results');
17 | });
18 |
19 | it('should render basic components', () => {
20 | cy.get('nav').should('be.visible');
21 | cy.get('.banner').should('be.visible');
22 | cy.get('footer').should('be.visible');
23 | });
24 |
25 | it("should show the author's profile image", () => {
26 | cy.get(selectors.postCard)
27 | .contains(
28 | 'freeCodeCamp Just Got a Million Dollar Donation from an Alum to Build a Carbon-Neutral Web3 Curriculum'
29 | )
30 | .parentsUntil('article')
31 | .find(selectors.authorProfileImage)
32 | .then($el => expect($el[0].tagName.toLowerCase()).to.equal('img'));
33 | });
34 |
35 | it("the author profile image should contain an `alt` attribute with the author's name", () => {
36 | cy.get(selectors.postCard)
37 | .contains(
38 | 'freeCodeCamp Just Got a Million Dollar Donation from an Alum to Build a Carbon-Neutral Web3 Curriculum'
39 | )
40 | .parentsUntil('article')
41 | .find(selectors.authorProfileImage)
42 | .then($el => expect($el[0].alt).to.equal('Quincy Larson'));
43 | });
44 |
45 | it('post cards written by an author with no profile image should show the author SVG', () => {
46 | cy.get(selectors.postCard)
47 | .contains('No Author Profile Pic')
48 | .parentsUntil('article')
49 | .find(selectors.avatar)
50 | .then($el => expect($el[0].tagName.toLowerCase()).to.equal('svg'));
51 | });
52 |
53 | it("the avatar SVG should contain a `title` element with the author's name", () => {
54 | cy.get(selectors.postCard)
55 | .contains('No Author Profile Pic')
56 | .parentsUntil('article')
57 | .find(selectors.avatar)
58 | .contains('title', 'Mrugesh Mohapatra');
59 | });
60 |
61 | it("posts written by 'freeCodeCamp.org' should not show the `author-list`, which contain's the author's name and profile image", () => {
62 | cy.get(selectors.postCard)
63 | .contains('Common Technical Support Questions – freeCodeCamp FAQ')
64 | .parentsUntil('article')
65 | .find(selectors.authorList)
66 | .should('not.exist');
67 | });
68 |
69 | // To do: Finalize search schema and add tests for the original post / translator feature
70 | });
71 |
--------------------------------------------------------------------------------
/utils/fitvids.js:
--------------------------------------------------------------------------------
1 | export const fitVids = window => {
2 | const document = window.document;
3 | let count = 0;
4 |
5 | [...document.children].forEach(node => {
6 | const selectors = [
7 | 'iframe[src*="player.vimeo.com"]',
8 | 'iframe[src*="youtube.com"]',
9 | 'iframe[src*="youtube-nocookie.com"]',
10 | 'iframe[src*="kickstarter.com"][src*="video.html"]',
11 | 'iframe[src*="player.bilibili.com"]',
12 | 'object',
13 | 'embed'
14 | ];
15 |
16 | let allVideos = [...node.querySelectorAll(selectors.join(','))];
17 | allVideos = allVideos.filter(node => node !== 'object object');
18 |
19 | allVideos.forEach(videoNode => {
20 | if (
21 | (videoNode.tagName.toLowerCase() === 'embed' &&
22 | videoNode.parentNode.getAttribute('object')?.length) ||
23 | videoNode.parentNode.classList.contains('fluid-width-video-wrapper')
24 | ) {
25 | return;
26 | }
27 | if (
28 | !window.getComputedStyle(videoNode)['width'] &&
29 | !window.getComputedStyle(videoNode)['height'] &&
30 | (isNaN(videoNode.getAttribute('width')) ||
31 | isNaN(videoNode.getAttribute('height')) ||
32 | !videoNode.getAttribute('width') ||
33 | !videoNode.getAttribute('height'))
34 | ) {
35 | // Set the width and height for a 16:9 aspect ratio if either attribute is not set
36 | videoNode.setAttribute('width', 256);
37 | videoNode.setAttribute('height', 144);
38 | }
39 |
40 | const width = !isNaN(parseInt(videoNode.getAttribute('width'), 10))
41 | ? parseInt(videoNode.getAttribute('width'), 10)
42 | : parseFloat(
43 | window.getComputedStyle(videoNode, null).width.replace('px', '')
44 | );
45 | const height =
46 | videoNode.tagName.toLowerCase() === 'object' ||
47 | (videoNode.getAttribute('height') &&
48 | !isNaN(parseInt(videoNode.getAttribute('height'), 10)))
49 | ? parseInt(videoNode.getAttribute('height'), 10)
50 | : parseFloat(
51 | window.getComputedStyle(videoNode, null).height.replace('px', '')
52 | );
53 | const aspectRatio = height / width;
54 |
55 | if (!videoNode.getAttribute('name')) {
56 | const videoName = 'fitvid' + count;
57 | videoNode.setAttribute('name', videoName);
58 | count++;
59 | }
60 |
61 | const videoNodeParent = videoNode.parentNode;
62 | const embeddedVideoHTML = `
63 |
66 |
70 | ${videoNode.outerHTML}
71 |
72 |
73 | `;
74 |
75 | if (
76 | videoNodeParent.tagName.toLowerCase() === 'figure' &&
77 | videoNodeParent.classList.contains('kg-card')
78 | ) {
79 | videoNodeParent.outerHTML = embeddedVideoHTML;
80 | } else {
81 | videoNode.outerHTML = embeddedVideoHTML;
82 | }
83 | });
84 | });
85 | };
86 |
--------------------------------------------------------------------------------
/src/_includes/layouts/tag.njk:
--------------------------------------------------------------------------------
1 | {% extends 'layouts/default.njk' %}
2 | {% from "partials/card.njk" import card %}
3 |
4 | {% set title = tag.name + " - " + site.title %}
5 | {% set canonicalUrl = tag.path | htmlBaseUrl(site.url) %}
6 | {% set postCount = tag.count.posts %}
7 | {% set popularTags = datasource.popularTags %}
8 |
9 | {% block content %}
10 |
30 |
31 |
32 |
33 | {% if tag.posts %}
34 | {% for post in tag.posts %}
35 | {{ card(post, loop.index0) }}
36 | {% endfor %}
37 | {% endif %}
38 |
39 | {% include "partials/pagination.njk" %}
40 |
41 |
42 | {% endblock %}
43 |
44 | {% block seo %}
45 | {# Facebook OpenGraph #}
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 | {# X / Twitter Card #}
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 | {% endblock %}
65 |
66 | {% block jsonLd %}
67 |
68 | {% endblock %}
69 |
--------------------------------------------------------------------------------
/config/i18n/locales/chinese/translations.json:
--------------------------------------------------------------------------------
1 | {
2 | "buttons": {
3 | "forum": "论坛",
4 | "donate": "捐款",
5 | "load-more-articles": "加载更多文章",
6 | "menu": "Menu",
7 | "learn": "Curriculum"
8 | },
9 | "search": {
10 | "label": "搜索",
11 | "accessible-name": "提交您的搜索查询",
12 | "placeholder": {
13 | "default": "Search our news articles, tutorials, and books",
14 | "numbered": "Search {{ roundedTotalRecords }}+ news articles, tutorials, and books"
15 | },
16 | "see-results": "查看 {{ searchQuery }} 的所有结果",
17 | "no-results": "No results found"
18 | },
19 | "banner": {
20 | "default": "学习编程 — {{ <0> }}3,000 小时免费课程{{ 0> }}",
21 | "authenticated": "支持我们的慈善组织和我们的使命。{{ <0> }}捐款给 freeCodeCamp.org{{ 0> }}。",
22 | "authenticated-donor": "谢谢你{{ <0> }}捐款{{ 0> }}支持 freeCodeCamp。"
23 | },
24 | "default-bio": "阅读 {{ <0> }}更多文章{{ 0> }}。",
25 | "author": {
26 | "no-posts": "没有文章",
27 | "one-post": "1 篇文章",
28 | "multiple-posts": "{{ postCount }} 篇文章"
29 | },
30 | "tag": {
31 | "no-posts": "没有文章",
32 | "one-post": "共 1 篇文章",
33 | "multiple-posts": "共 {{ postCount }} 篇文章"
34 | },
35 | "404": {
36 | "page-not-found": "页面不存在",
37 | "go-to-front-page": "返回上一页"
38 | },
39 | "social-row": {
40 | "cta": {
41 | "tweet-a-thanks": "你都读这么多了,为表谢意就感谢一下作者吧。{{ <0> }}说一声谢谢{{ 0> }}",
42 | "tweet-it": "如果这篇文章有帮助,那就{{ <0> }}分享{{ 0> }}一下吧。"
43 | },
44 | "tweets": {
45 | "default": "感谢作者 {{ author }} 写下这篇有帮助的文章。",
46 | "translation": "感谢作者 {{ author }} 写下这篇有帮助的文章,同时感谢 {{ translator }} 翻译了这篇文章。"
47 | }
48 | },
49 | "learn-to-code-cta": "在 freeCodeCamp 免费学习编程。 freeCodeCamp 的开源课程已帮助 40,000 多人获得开发者工作。{{ <0> }}开始学习{{ 0> }}",
50 | "original-author-translator": {
51 | "roles": {
52 | "author": "作者:{{ name }} ({{ locale }})",
53 | "translator": "译者:{{ name }}"
54 | },
55 | "details": {
56 | "original-article": "{{ <0> }}原文:{{ 0> }} {{ title }}"
57 | },
58 | "locales": {
59 | "chinese": "中文",
60 | "english": "英语",
61 | "espanol": "西班牙语",
62 | "italian": "意大利语",
63 | "japanese": "日语",
64 | "korean": "韩语/朝鲜语",
65 | "portuguese": "葡萄牙语",
66 | "ukrainian": "乌克兰语"
67 | }
68 | },
69 | "embed-title": "嵌入内容",
70 | "footer": {
71 | "tax-exempt-status": "freeCodeCamp 是捐助者支持的 501(c)(3) 条款下具有免税资格的慈善组织(税号:82-0779546)。",
72 | "mission-statement": "我们的使命:帮助人们免费学习编程。我们通过创建成千上万的视频、文章和交互式编程课程——所有内容向公众免费开放——来实现这一目标。",
73 | "donation-initiatives": "所有给 freeCodeCamp 的捐款都将用于我们的教育项目,购买服务器和其他服务,以及聘用员工。",
74 | "donate-text": "你可以{{ <0> }}点击此处免税捐款{{ 0> }}。",
75 | "trending-books-and-handbooks": "Trending Books and Handbooks",
76 | "mobile-app": "移动应用",
77 | "our-nonprofit": "我们的慈善机构",
78 | "links": {
79 | "powered-by": "Publication powered by Hashnode",
80 | "about": "简介",
81 | "alumni": "校友网络",
82 | "open-source": "开源",
83 | "shop": "商店",
84 | "support": "支持",
85 | "sponsors": "赞助商",
86 | "honesty": "学术诚信",
87 | "coc": "行为规范",
88 | "privacy": "隐私条例",
89 | "tos": "服务条款",
90 | "copyright": "版权条例"
91 | }
92 | },
93 | "fallback": {
94 | "message": "你的浏览器不支持 HTML5 {{ element }}。",
95 | "audio": "音频",
96 | "video": "视频"
97 | },
98 | "ad-text": "广告"
99 | }
100 |
--------------------------------------------------------------------------------
/cypress/e2e/espanol/page/structured-data.cy.ts:
--------------------------------------------------------------------------------
1 | import commonExpectedJsonLd from '../../../fixtures/common-expected-json-ld.json';
2 | const pageExpectedJsonLd = {
3 | '@type': 'Article',
4 | author: {
5 | '@type': 'Person',
6 | name: 'freeCodeCamp.org',
7 | image: {
8 | '@type': 'ImageObject',
9 | url: 'http://localhost:3030/content/images/2022/02/freecodecamp-org-gravatar.jpeg',
10 | width: 250,
11 | height: 250
12 | },
13 | url: 'http://localhost:8080/espanol/news/author/freecodecamp/',
14 | sameAs: [
15 | 'https://www.freecodecamp.org',
16 | 'https://www.facebook.com/freecodecamp',
17 | 'https://x.com/freecodecamp'
18 | ]
19 | },
20 | headline: 'Gracias por ser un partidario',
21 | url: 'http://localhost:8080/espanol/news/gracias-por-ser-un-partidario/',
22 | datePublished: '2024-09-13T10:11:05.000Z',
23 | dateModified: '2024-09-13T10:11:05.000Z',
24 | image: {
25 | '@type': 'ImageObject',
26 | url: 'https://cdn.freecodecamp.org/platform/universal/fcc_meta_1920X1080-indigo.png',
27 | width: 1920,
28 | height: 1080
29 | },
30 | description:
31 | 'freeCodeCamp es una ONG educativa muy eficiente. Solo este año, hemos brindado\nmillones de horas de educación gratuita a personas de todo el mundo.\n\nCon el presupuesto operativo actual de nuestra organización benéfica, cada dólar\nque donas a freeCodeCamp se traduce en 50 horas de educación tecnológica.\n\nCuando donas a freeCodeCamp, ayudas a las personas a aprender nuevas habilidades\ny a mantener a sus familias.\n\nTambién nos ayudas a crear nuevos recursos para que tú y tu familia los utilicen\npar'
32 | };
33 | let jsonLdObj;
34 |
35 | describe('Page structured data (JSON-LD – Ghost sourced)', () => {
36 | before(() => {
37 | // Update baseUrl to include current language
38 | Cypress.config('baseUrl', 'http://localhost:8080/espanol/news/');
39 | });
40 |
41 | beforeEach(() => {
42 | cy.visit('/gracias-por-ser-un-partidario/');
43 |
44 | jsonLdObj = cy
45 | .get('head script[type="application/ld+json"]')
46 | .then($script => {
47 | jsonLdObj = JSON.parse($script.text());
48 | });
49 | });
50 |
51 | it('matches the expected base values', () => {
52 | expect(jsonLdObj['@context']).to.equal(commonExpectedJsonLd['@context']);
53 | expect(jsonLdObj['@type']).to.equal(pageExpectedJsonLd['@type']);
54 | expect(jsonLdObj.url).to.equal(pageExpectedJsonLd.url);
55 | expect(jsonLdObj.datePublished).to.equal(pageExpectedJsonLd.datePublished);
56 | expect(jsonLdObj.dateModified).to.equal(pageExpectedJsonLd.dateModified);
57 | expect(jsonLdObj.description).to.equal(pageExpectedJsonLd.description);
58 | expect(jsonLdObj.headline).to.equal(pageExpectedJsonLd.headline);
59 | });
60 |
61 | it('matches the expected publisher values', () => {
62 | expect(jsonLdObj.publisher).to.deep.equal(
63 | commonExpectedJsonLd.espanol.publisher
64 | );
65 | });
66 |
67 | it('matches the expected image values', () => {
68 | expect(jsonLdObj.image).to.deep.equal(pageExpectedJsonLd.image);
69 | });
70 |
71 | it('matches the expected mainEntityOfPage values', () => {
72 | expect(jsonLdObj.mainEntityOfPage).to.deep.equal(
73 | commonExpectedJsonLd.espanol.mainEntityOfPage
74 | );
75 | });
76 |
77 | it('matches the expected author values', () => {
78 | expect(jsonLdObj.author).to.deep.equal(pageExpectedJsonLd.author);
79 | });
80 | });
81 |
--------------------------------------------------------------------------------
/utils/search-bar-placeholder-number.test.js:
--------------------------------------------------------------------------------
1 | import {
2 | roundDownToNearestHundred,
3 | convertToLocalizedString
4 | } from './search-bar-placeholder-number.js';
5 |
6 | describe('Search bar placeholder number tests:', () => {
7 | describe('Number rounding', () => {
8 | test('Numbers less than 100 return 0', () => {
9 | const testArr = [0, 1, 50, 99];
10 |
11 | testArr.forEach(num => {
12 | expect(roundDownToNearestHundred(num)).toEqual(0);
13 | });
14 | });
15 |
16 | test('Numbers greater than 100 return a number rounded down to the nearest 100', () => {
17 | const testArr = [
18 | {
19 | num: 100,
20 | expected: 100
21 | },
22 | {
23 | num: 101,
24 | expected: 100
25 | },
26 | {
27 | num: 199,
28 | expected: 100
29 | },
30 | {
31 | num: 999,
32 | expected: 900
33 | },
34 | {
35 | num: 1000,
36 | expected: 1000
37 | },
38 | {
39 | num: 1001,
40 | expected: 1000
41 | },
42 | {
43 | num: 1999,
44 | expected: 1900
45 | },
46 | {
47 | num: 10000,
48 | expected: 10000
49 | },
50 | {
51 | num: 10001,
52 | expected: 10000
53 | },
54 | {
55 | num: 19999,
56 | expected: 19900
57 | }
58 | ];
59 |
60 | testArr.forEach(obj => {
61 | expect(roundDownToNearestHundred(obj.num)).toEqual(obj.expected);
62 | });
63 | });
64 | });
65 |
66 | describe('Number localization', () => {
67 | test('Numbers are localized to the correct locale', () => {
68 | const testArr = [
69 | {
70 | num: 100,
71 | locale: 'en',
72 | expected: '100'
73 | },
74 | {
75 | num: 100,
76 | locale: 'zh',
77 | expected: '100'
78 | },
79 | {
80 | num: 100,
81 | locale: 'de',
82 | expected: '100'
83 | },
84 | {
85 | num: 1000,
86 | locale: 'en',
87 | expected: '1,000'
88 | },
89 | {
90 | num: 1000,
91 | locale: 'zh',
92 | expected: '1,000'
93 | },
94 | {
95 | num: 1000,
96 | locale: 'de',
97 | expected: '1.000'
98 | },
99 | {
100 | num: 10000,
101 | locale: 'en',
102 | expected: '10,000'
103 | },
104 | {
105 | num: 10000,
106 | locale: 'zh',
107 | expected: '10,000'
108 | },
109 | {
110 | num: 10000,
111 | locale: 'de',
112 | expected: '10.000'
113 | },
114 | {
115 | num: 100000,
116 | locale: 'en',
117 | expected: '100,000'
118 | },
119 | {
120 | num: 100000,
121 | locale: 'zh',
122 | expected: '100,000'
123 | },
124 | {
125 | num: 100000,
126 | locale: 'de',
127 | expected: '100.000'
128 | }
129 | ];
130 |
131 | testArr.forEach(obj => {
132 | expect(convertToLocalizedString(obj.num, obj.locale)).toEqual(
133 | obj.expected
134 | );
135 | });
136 | });
137 | });
138 | });
139 |
--------------------------------------------------------------------------------
/cypress/e2e/espanol/post/structured-data.cy.ts:
--------------------------------------------------------------------------------
1 | import commonExpectedJsonLd from '../../../fixtures/common-expected-json-ld.json';
2 | const postExpectedJsonLd = {
3 | '@type': 'Article',
4 | author: {
5 | '@type': 'Person',
6 | name: 'Rafael D. Hernandez',
7 | image: {
8 | '@type': 'ImageObject',
9 | url: 'http://localhost:3030/content/images/2022/02/rafael-photo.jpeg',
10 | width: 2000,
11 | height: 2150
12 | },
13 | url: 'http://localhost:8080/espanol/news/author/rafael/',
14 | sameAs: ['https://x.com/RafaelDavisH']
15 | },
16 | headline:
17 | 'Cómo funciona el operador de signo de interrogación (?) en JavaScript',
18 | url: 'http://localhost:8080/espanol/news/como-funciona-el-operado-de-signo-de-interrogacion-javascript/',
19 | datePublished: '2022-02-18T03:06:29.000Z',
20 | dateModified: '2022-02-19T12:39:23.000Z',
21 | image: {
22 | '@type': 'ImageObject',
23 | url: 'https://www.freecodecamp.org/espanol/news/content/images/2022/02/Pink-Cute-Chic-Vintage-90s-Virtual-Trivia-Quiz-Presentations--5--1.png',
24 | width: 1000,
25 | height: 563
26 | },
27 | keywords: 'JavaScript',
28 | description:
29 | 'Artículo original escrito por: Nishant Kumar\n[https://www.freecodecamp.org/news/author/nishant-kumar/]\nArtículo original: How the Question Mark (?) Operator Works in JavaScript\n[https://www.freecodecamp.org/news/how-the-question-mark-works-in-javascript/]\nTraducido y adaptado por: Rafael D. Hernandez [/espanol/news/author/rafael/]\n\nEl operador de signo de interrogación o condicional, representado por a ?, es\nuna de las características más potentes de JavaScript. El operador ? se usa en\nsentencia'
30 | };
31 | let jsonLdObj;
32 |
33 | describe('Post structured data (JSON-LD – Ghost sourced)', () => {
34 | before(() => {
35 | // Update baseUrl to include current language
36 | Cypress.config('baseUrl', 'http://localhost:8080/espanol/news/');
37 | });
38 |
39 | beforeEach(() => {
40 | cy.visit('/como-funciona-el-operado-de-signo-de-interrogacion-javascript/');
41 |
42 | jsonLdObj = cy
43 | .get('head script[type="application/ld+json"]')
44 | .then($script => {
45 | jsonLdObj = JSON.parse($script.text());
46 | });
47 | });
48 |
49 | it('matches the expected base values', () => {
50 | expect(jsonLdObj['@context']).to.equal(commonExpectedJsonLd['@context']);
51 | expect(jsonLdObj['@type']).to.equal(postExpectedJsonLd['@type']);
52 | expect(jsonLdObj.url).to.equal(postExpectedJsonLd.url);
53 | expect(jsonLdObj.datePublished).to.equal(postExpectedJsonLd.datePublished);
54 | expect(jsonLdObj.dateModified).to.equal(postExpectedJsonLd.dateModified);
55 | expect(jsonLdObj.description).to.equal(postExpectedJsonLd.description);
56 | expect(jsonLdObj.headline).to.equal(postExpectedJsonLd.headline);
57 | expect(jsonLdObj.keywords).to.equal(postExpectedJsonLd.keywords);
58 | });
59 |
60 | it('matches the expected publisher values', () => {
61 | expect(jsonLdObj.publisher).to.deep.equal(
62 | commonExpectedJsonLd.espanol.publisher
63 | );
64 | });
65 |
66 | it('matches the expected image values', () => {
67 | expect(jsonLdObj.image).to.deep.equal(postExpectedJsonLd.image);
68 | });
69 |
70 | it('matches the expected mainEntityOfPage values', () => {
71 | expect(jsonLdObj.mainEntityOfPage).to.deep.equal(
72 | commonExpectedJsonLd.espanol.mainEntityOfPage
73 | );
74 | });
75 |
76 | it('matches the expected author values', () => {
77 | expect(jsonLdObj.author).to.deep.equal(postExpectedJsonLd.author);
78 | });
79 | });
80 |
--------------------------------------------------------------------------------
/config/i18n/locales/korean/translations.json:
--------------------------------------------------------------------------------
1 | {
2 | "buttons": {
3 | "forum": "포럼",
4 | "donate": "기부하기",
5 | "load-more-articles": "더 많은 기사들 불러오기",
6 | "menu": "Menu",
7 | "learn": "Curriculum"
8 | },
9 | "search": {
10 | "label": "검색",
11 | "accessible-name": "검색 쿼리를 제출하세요",
12 | "placeholder": {
13 | "default": "뉴스 기사, 튜토리얼, 서적 검색",
14 | "numbered": "{{ roundedTotalRecords }} 건의 뉴스 기사, 튜토리얼, 서적을 검색"
15 | },
16 | "see-results": "{{ searchQuery }} 의 모든 결과 보기",
17 | "no-results": "해당 결과가 없습니다"
18 | },
19 | "banner": {
20 | "default": "코드 배우기 — {{ <0> }}3000시간 무료 커리큘럼{{ 0> }}",
21 | "authenticated": "저희의 자선 단체 및 사명을 지원하세요. {{ <0> }}freeCodeCamp.org에 기부하기{{ 0> }}.",
22 | "authenticated-donor": "freeCodeCamp에 {{ <0> }}당신의 기부{{ 0> }}를 통해 지원해주셔서 감사합니다."
23 | },
24 | "default-bio": "{{ <0> }}더 많은 게시글{{ 0> }} 읽기.",
25 | "author": {
26 | "no-posts": "게시글 없음",
27 | "one-post": "게시글 1개",
28 | "multiple-posts": "게시글 {{ postCount }}개"
29 | },
30 | "tag": {
31 | "no-posts": "게시글 모음",
32 | "one-post": "게시글 1개 모음",
33 | "multiple-posts": "게시글 {{ postCount }}개 모음"
34 | },
35 | "404": {
36 | "page-not-found": "페이지를 찾을 수 없습니다",
37 | "go-to-front-page": "앞 페이지로 이동"
38 | },
39 | "social-row": {
40 | "cta": {
41 | "tweet-a-thanks": "여기까지 읽으셨다면, 저자에게 감사의 인사를 보내 관심을 보여주세요. {{ <0> }}감사의 인사 보내기{{ 0> }}",
42 | "tweet-it": "이 기사가 도움이 되셨다면, {{ <0> }}공유하세요{{ 0> }}."
43 | },
44 | "tweets": {
45 | "default": "{{ author }}의 유용한 기사 작성에 감사드립니다.",
46 | "translation": "{{ author }}의 유용한 기사 작성과, {{ translator }}의 번역에 감사드립니다."
47 | }
48 | },
49 | "learn-to-code-cta": "무료로 코드를 배워보세요. freeCodeCamp의 오픈 소스 커리큘럼을 통해 40,000명 이상이 개발자로 취업했습니다. {{ <0> }}시작하기{{ 0> }}",
50 | "original-author-translator": {
51 | "roles": {
52 | "author": "저자: {{ name }} ({{ locale }})",
53 | "translator": "번역자: {{ name }}"
54 | },
55 | "details": {
56 | "original-article": "{{ <0> }}기사 원문:{{ 0> }} {{ title }}"
57 | },
58 | "locales": {
59 | "chinese": "중국어",
60 | "english": "영어",
61 | "espanol": "스페인어",
62 | "italian": "이탈리아어",
63 | "japanese": "일본어",
64 | "korean": "한국어",
65 | "portuguese": "포르투갈어",
66 | "ukrainian": "우크라이나어"
67 | }
68 | },
69 | "embed-title": "포함된 컨텐츠",
70 | "footer": {
71 | "tax-exempt-status": "freeCodeCamp는 기부자들의 후원으로 운영되는,\n세금이 면제되는 501(c)(3) 자선/비영리 단체입니다.\n(미국 연방 세금 식별번호: 82-0779546)",
72 | "mission-statement": "우리의 사명: 사람들이 무료로 코드를 배울 수 있도록 돕는 것입니다. 이를 위해 수천 개의 비디오, 아티클, 상호작용형 코딩 강의를 만들고 있습니다 - 모두 대중에게 무료로 제공됩니다.",
73 | "donation-initiatives": "freeCodeCamp 에 후원하여 교육을 개설하고, 서버, 서비스 비용과 스태프 임금 등을 지불할 수 있도록 도와주세요.",
74 | "donate-text": "이곳에서 {{ <0> }}세금 감면 기부{{ 0> }}가 가능합니다.",
75 | "trending-books-and-handbooks": "인기 도서 및 핸드북",
76 | "mobile-app": "모바일 앱",
77 | "our-nonprofit": "저희의 비영리",
78 | "links": {
79 | "powered-by": "Publication powered by Hashnode",
80 | "about": "더 보기",
81 | "alumni": "졸업생 네트워크",
82 | "open-source": "오픈 소스",
83 | "shop": "상점",
84 | "support": "지원",
85 | "sponsors": "스폰서들",
86 | "honesty": "학문적 정직성",
87 | "coc": "행동 강령",
88 | "privacy": "개인정보 보호정책",
89 | "tos": "이용 약관",
90 | "copyright": "저작권 정책"
91 | }
92 | },
93 | "fallback": {
94 | "message": "당신의 브라우저가 HTML5의 {{ element }} 을 지원하지 않습니다.",
95 | "audio": "음성",
96 | "video": "비디오"
97 | },
98 | "ad-text": "광고"
99 | }
100 |
--------------------------------------------------------------------------------