├── .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 | 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 | 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 | '': '', 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 |
11 | 22 |
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 | 22 | {% endfor %} 23 | 24 | -------------------------------------------------------------------------------- /src/_includes/partials/prism.njk: -------------------------------------------------------------------------------- 1 | 7 | 13 | 19 | 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.

\n

At our charity's current operating budget, every dollar you donate to freeCodeCamp translates into 50 hours worth of technology education.

\n

When you donate to freeCodeCamp, you help people learn new skills and provide for their families.

\n

You also help us create new resources for you and your family to use to expand your own technology skills.

\n

Thank 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 |
15 |
16 |
17 |
18 |

404

19 |

{% t '404.page-not-found' %}

20 | {% t '404.go-to-front-page' %} → 21 |
22 |
23 |
24 | 33 |
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 | '': '', 7 | interpolation: { 8 | escapeValue: false 9 | } 10 | } %}`; 11 | const bannerDefaultLink = `{% t 'links:banner.default' %}`; 12 | const bannerAuthText = `{% t 'banner.authenticated', { 13 | '<0>': '', 14 | '': '', 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 | '': '', 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 | 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 |
16 |
17 |
18 |
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 | 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 | ${alt} 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 | ${alt} 75 | 76 | `; 77 | }; 78 | -------------------------------------------------------------------------------- /src/_includes/layouts/feed.njk: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | {% set metaTitle %}{% t 'meta-tags:title' %}{% endset %} 6 | 7 | <![CDATA[ {{ (metaTitle | safe) if feed.path === "/" else (feed.name | safe + " - freeCodeCamp.org") }} ]]> 8 | 9 | 10 | 11 | 12 | {{ site.url }} 13 | 14 | https://cdn.freecodecamp.org/universal/favicons/favicon.png 15 | 16 | <![CDATA[ {{ (metaTitle | safe) if feed.path === "/" else (feed.name | safe + " - freeCodeCamp.org") }} ]]> 17 | 18 | {{ site.url }} 19 | 20 | Eleventy 21 | {% buildDateFormatter site.timezone %} 22 | 23 | 60 24 | {% for post in feed.posts %} 25 | 26 | 27 | <![CDATA[ {{ post.title | safe }} ]]> 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 |
    29 | {% if primaryTag %} 30 | 31 | 32 | #{{ primaryTag.name }} 33 | 34 | 35 | {% endif %} 36 |

    37 | 38 | {{ post.title }} 39 | 40 |

    41 |
    42 |
    43 | 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 |
    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 | {% if showBio %} 45 | {% if authorOrTranslator.bio %} 46 |

    {{ authorOrTranslator.bio }}

    47 | {% else %} 48 |

    49 | {% t 'default-bio', { 50 | '<0>': '', 51 | '': '', 52 | interpolation: { 53 | escapeValue: false 54 | } 55 | } %} 56 |

    57 | {% endif %} 58 | {% endif %} 59 |
    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 |
    11 |

    #{{ tag.name | upper }}

    12 |

    13 | {% if postCount == 0 %} 14 | {% t 'tag.no-posts' %} 15 | {% elif postCount == 1 %} 16 | {% t 'tag.one-post' %} 17 | {% else %} 18 | {% t 'tag.multiple-posts', { postCount: postCount } %} 19 | {% endif %} 20 |

    21 | 22 |
    23 | {% for tag in popularTags %} 24 | 25 |

    #{{ tag.name }} | {{ tag.count.posts }}

    26 |
    27 | {% endfor %} 28 |
    29 |
    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 小时免费课程{{ }}", 21 | "authenticated": "支持我们的慈善组织和我们的使命。{{ <0> }}捐款给 freeCodeCamp.org{{ }}。", 22 | "authenticated-donor": "谢谢你{{ <0> }}捐款{{ }}支持 freeCodeCamp。" 23 | }, 24 | "default-bio": "阅读 {{ <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> }}说一声谢谢{{ }}", 42 | "tweet-it": "如果这篇文章有帮助,那就{{ <0> }}分享{{ }}一下吧。" 43 | }, 44 | "tweets": { 45 | "default": "感谢作者 {{ author }} 写下这篇有帮助的文章。", 46 | "translation": "感谢作者 {{ author }} 写下这篇有帮助的文章,同时感谢 {{ translator }} 翻译了这篇文章。" 47 | } 48 | }, 49 | "learn-to-code-cta": "在 freeCodeCamp 免费学习编程。 freeCodeCamp 的开源课程已帮助 40,000 多人获得开发者工作。{{ <0> }}开始学习{{ }}", 50 | "original-author-translator": { 51 | "roles": { 52 | "author": "作者:{{ name }} ({{ locale }})", 53 | "translator": "译者:{{ name }}" 54 | }, 55 | "details": { 56 | "original-article": "{{ <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> }}点击此处免税捐款{{ }}。", 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시간 무료 커리큘럼{{ }}", 21 | "authenticated": "저희의 자선 단체 및 사명을 지원하세요. {{ <0> }}freeCodeCamp.org에 기부하기{{ }}.", 22 | "authenticated-donor": "freeCodeCamp에 {{ <0> }}당신의 기부{{ }}를 통해 지원해주셔서 감사합니다." 23 | }, 24 | "default-bio": "{{ <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> }}감사의 인사 보내기{{ }}", 42 | "tweet-it": "이 기사가 도움이 되셨다면, {{ <0> }}공유하세요{{ }}." 43 | }, 44 | "tweets": { 45 | "default": "{{ author }}의 유용한 기사 작성에 감사드립니다.", 46 | "translation": "{{ author }}의 유용한 기사 작성과, {{ translator }}의 번역에 감사드립니다." 47 | } 48 | }, 49 | "learn-to-code-cta": "무료로 코드를 배워보세요. freeCodeCamp의 오픈 소스 커리큘럼을 통해 40,000명 이상이 개발자로 취업했습니다. {{ <0> }}시작하기{{ }}", 50 | "original-author-translator": { 51 | "roles": { 52 | "author": "저자: {{ name }} ({{ locale }})", 53 | "translator": "번역자: {{ name }}" 54 | }, 55 | "details": { 56 | "original-article": "{{ <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> }}세금 감면 기부{{ }}가 가능합니다.", 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 | --------------------------------------------------------------------------------