├── .npmrc
├── .env.example
├── lib
├── config.js
├── mdn-article.test.js
├── bsky-bot.js
└── mdn-article.js
├── .github
├── dependabot.yml
└── workflows
│ ├── test.yml
│ └── bot.yml
├── package.json
├── LICENSE
├── .gitignore
├── biome.json
├── README.md
├── index.js
└── CODE-OF-CONDUCT.md
/.npmrc:
--------------------------------------------------------------------------------
1 | save-exact=true
2 | engine-strict=true
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | BSKY_HANDLE=random-mdn.bsky.social
2 | BSKY_PASSWORD=
--------------------------------------------------------------------------------
/lib/config.js:
--------------------------------------------------------------------------------
1 | import { env } from 'node:process';
2 | import { z } from 'zod';
3 |
4 | const envSchema = z.object({
5 | BSKY_HANDLE: z.string().min(1),
6 | BSKY_PASSWORD: z.string().min(1),
7 | BSKY_SERVICE: z.string().min(1).default('https://bsky.social'),
8 | });
9 |
10 | const parsedEnv = envSchema.parse(env);
11 |
12 | export const bskyAccount = {
13 | identifier: parsedEnv.BSKY_HANDLE,
14 | password: parsedEnv.BSKY_PASSWORD,
15 | };
16 |
17 | export const bskyService = parsedEnv.BSKY_SERVICE;
18 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | # Maintain dependencies for GitHub Actions
4 | - package-ecosystem: 'github-actions'
5 | # Workflow files stored in the default location of `.github/workflows`
6 | directory: '/'
7 | # Check for updates to GitHub Actions every weekday
8 | schedule:
9 | interval: 'weekly'
10 |
11 | # Maintain dependencies for npm
12 | - package-ecosystem: 'npm'
13 | # Look for `package.json` and `lock` files in the `root` directory
14 | directory: '/'
15 | # Check the npm registry for updates every weekday
16 | schedule:
17 | interval: 'weekly'
--------------------------------------------------------------------------------
/lib/mdn-article.test.js:
--------------------------------------------------------------------------------
1 | import { expect, test } from 'vitest';
2 | import { getArticleMetadata } from './mdn-article';
3 |
4 | test('getArticleMetadata', async () => {
5 | await expect(
6 | getArticleMetadata('https://developer.mozilla.org/en-US/docs/Web/HTML/Element/head'),
7 | ).resolves.toStrictEqual({
8 | title: '
: The Document Metadata (Hea…',
9 | description:
10 | 'The HTML element contains machine-readable information (metadata) about the document, like its title, scripts, and style sheets. There can be only one element in an HTML document.',
11 | hashtags: ['#webdev', '#HTML'],
12 | image: 'https://developer.mozilla.org/mdn-social-share.d893525a4fb5fb1f67a2.png',
13 | });
14 | });
15 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Test
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | paths-ignore:
8 | - '**.md'
9 | - '**.yml'
10 | pull_request:
11 | branches:
12 | - main
13 |
14 | # Cancel in progress workflows on pull_requests.
15 | # https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value
16 | concurrency:
17 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
18 | cancel-in-progress: true
19 |
20 | jobs:
21 | test:
22 | name: Test
23 | runs-on: ubuntu-latest
24 | steps:
25 | - name: Checkout repository
26 | uses: actions/checkout@v6
27 |
28 | - name: Setup node
29 | uses: actions/setup-node@v6.0.0
30 | with:
31 | cache: 'npm'
32 |
33 | - name: Install dependencies
34 | run: npm ci
35 |
36 | - name: Code quality
37 | run: npm run lint
38 |
39 | - name: Test
40 | run: npm run test
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "random-mdn-bot",
3 | "version": "1.0.0",
4 | "description": "A bot that posts random MDN articles",
5 | "scripts": {
6 | "dev": "node --env-file=.env ./index.js",
7 | "start": "node ./index.js",
8 | "test": "vitest",
9 | "test:ui": "vitest --ui",
10 | "test:run": "vitest run",
11 | "lint": "biome ci",
12 | "format": "biome check --write"
13 | },
14 | "repository": {
15 | "type": "git",
16 | "url": "git+https://github.com/random-mdn/random-mdn-bot.git"
17 | },
18 | "keywords": [],
19 | "author": "stefan judis ",
20 | "license": "MIT",
21 | "type": "module",
22 | "bugs": {
23 | "url": "https://github.com/random-mdn/random-mdn-bot/issues"
24 | },
25 | "homepage": "https://github.com/random-mdn/random-mdn-bot#readme",
26 | "dependencies": {
27 | "@atproto/api": "0.16.9",
28 | "@biomejs/biome": "2.2.4",
29 | "html-entities": "2.6.0"
30 | },
31 | "devDependencies": {
32 | "@vitest/ui": "3.2.4",
33 | "vite": "7.2.4",
34 | "vitest": "3.2.4"
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/.github/workflows/bot.yml:
--------------------------------------------------------------------------------
1 | name: Random MDN Bot
2 |
3 | on:
4 | schedule:
5 | # Run every 6 hours.
6 | - cron: '0 */6 * * *'
7 | workflow_dispatch: # This allows manually running the workflow from the GitHub actions page
8 | inputs:
9 | dryRun:
10 | description: "Dry run"
11 | type: boolean
12 |
13 | concurrency:
14 | group: random-mdn-bot
15 |
16 | jobs:
17 | post-mdn-article:
18 | runs-on: ubuntu-latest
19 | steps:
20 | - name: Checkout repository
21 | uses: actions/checkout@v6
22 |
23 | - name: Setup node
24 | uses: actions/setup-node@v6.0.0
25 | with:
26 | cache: 'npm'
27 |
28 | - name: Install dependencies
29 | run: npm ci
30 |
31 | - name: Post article
32 | run: |
33 | if [ "${{ inputs.dryRun }}" = "true" ]; then
34 | npm start -- --dry-run
35 | else
36 | npm start
37 | fi
38 | env:
39 | BSKY_SERVICE: https://bsky.social
40 | BSKY_HANDLE: ${{ vars.BSKY_HANDLE }}
41 | BSKY_PASSWORD: ${{ secrets.BSKY_PASSWORD }}
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018 random-mdn
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 |
8 | # Runtime data
9 | pids
10 | *.pid
11 | *.seed
12 | *.pid.lock
13 |
14 | # Directory for instrumented libs generated by jscoverage/JSCover
15 | lib-cov
16 |
17 | # Coverage directory used by tools like istanbul
18 | coverage
19 |
20 | # nyc test coverage
21 | .nyc_output
22 |
23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
24 | .grunt
25 |
26 | # Bower dependency directory (https://bower.io/)
27 | bower_components
28 |
29 | # node-waf configuration
30 | .lock-wscript
31 |
32 | # Compiled binary addons (https://nodejs.org/api/addons.html)
33 | build/Release
34 |
35 | # Dependency directories
36 | node_modules/
37 | jspm_packages/
38 |
39 | # TypeScript v1 declaration files
40 | typings/
41 |
42 | # Optional npm cache directory
43 | .npm
44 |
45 | # Optional eslint cache
46 | .eslintcache
47 |
48 | # Optional REPL history
49 | .node_repl_history
50 |
51 | # Output of 'npm pack'
52 | *.tgz
53 |
54 | # Yarn Integrity file
55 | .yarn-integrity
56 |
57 | # dotenv environment variables file
58 | .env
59 |
60 | # next.js build output
61 | .next
62 |
63 | # serverless
64 | .serverless
--------------------------------------------------------------------------------
/biome.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://biomejs.dev/schemas/2.2.4/schema.json",
3 | "vcs": {
4 | "enabled": false,
5 | "clientKind": "git",
6 | "useIgnoreFile": false
7 | },
8 | "files": {
9 | "ignoreUnknown": false
10 | },
11 | "formatter": {
12 | "enabled": true,
13 | "indentStyle": "space",
14 | "indentWidth": 2,
15 | "lineWidth": 100
16 | },
17 | "linter": {
18 | "enabled": true,
19 | "rules": {
20 | "recommended": true,
21 | "style": {
22 | "noParameterAssign": "error",
23 | "useAsConstAssertion": "error",
24 | "useDefaultParameterLast": "error",
25 | "useEnumInitializers": "error",
26 | "useSelfClosingElements": "error",
27 | "useSingleVarDeclarator": "error",
28 | "noUnusedTemplateLiteral": "error",
29 | "useNumberNamespace": "error",
30 | "noInferrableTypes": "error",
31 | "noUselessElse": "error"
32 | }
33 | }
34 | },
35 | "javascript": {
36 | "formatter": {
37 | "quoteStyle": "single"
38 | }
39 | },
40 | "assist": {
41 | "enabled": true,
42 | "actions": {
43 | "source": {
44 | "organizeImports": "on"
45 | }
46 | }
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # random-mdn-bot
2 |
3 | > A bot that posts random MDN articles
4 |
5 | [](https://bsky.app/profile/random-mdn.bsky.social)
6 |
7 |
8 | The project uses [GitHub Actions](https://docs.github.com/actions) to run a [Node.js](https://nodejs.org/) script every six hours.
9 |
10 | The script reads out the [MDN](https://mdn.dev) sitemap, parses it and posts the found article to [Bluesky](https://bsky.app/profile/random-mdn.bsky.social).
11 |
12 | ## Setup
13 |
14 | ```shell
15 | # clone the repo and get the source code
16 | git clone git@github.com:random-mdn/random-mdn-bot.git
17 |
18 | # install dependencies
19 | npm install
20 |
21 | # setup env
22 | cp .env.example .env
23 | ```
24 |
25 | ## Local development
26 |
27 | To execute the script locally run `npm run dev` in the project's directory.
28 |
29 | It should look as follows:
30 |
31 | ```shell
32 | npm run dev
33 | ```
34 |
35 | ## CLI options
36 |
37 | #### Dry run
38 |
39 | Run the function without posting the article.
40 |
41 | ```shell
42 | npm run dev -- --dry-run
43 | ```
44 |
45 | ## Roadmap
46 |
47 | If you would like to help :heart: that would be awesome! You can find ideas and the current planning in [the planning issue](https://github.com/random-mdn/random-mdn-bot/issues/1).
48 |
49 | ## Code of conduct
50 |
51 | This project follows and enforece a [Code of Conduct](./CODE-OF-CONDUCT.md) to make everybody feel welcomed and safe.
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | import { parseArgs } from 'node:util';
2 | import BskyBot from './lib/bsky-bot.js';
3 | import { getRandomArticle } from './lib/mdn-article.js';
4 |
5 | const { values: parsedArgs } = parseArgs({
6 | args: process.argv.slice(2),
7 | options: {
8 | 'dry-run': {
9 | type: 'boolean',
10 | default: false,
11 | },
12 | },
13 | });
14 |
15 | const bskyBotOptions = {
16 | dryRun: parsedArgs['dry-run'],
17 | postTemplateFn: (data) =>
18 | `🦖 Random MDN: ${data.title} 🦖\n\n${data.url}\n\n${data.description}\n\n${data.hashtags?.join(' ')}`,
19 | };
20 |
21 | await run();
22 |
23 | async function run() {
24 | try {
25 | const article = await getRandomArticle();
26 |
27 | // Bluesky posts can be 300 characters.
28 | let maxDescriptionLength = 300;
29 |
30 | // Let's subtract the evaluated `postTemplateFn` with an empty
31 | // description to see how many characters we have left.
32 | maxDescriptionLength -= bskyBotOptions.postTemplateFn({ ...article, description: '' }).length;
33 |
34 | if (article.description.length > maxDescriptionLength) {
35 | article.description = `${article.description.slice(0, maxDescriptionLength)}…`;
36 | }
37 |
38 | // Post the article to Bluesky
39 | const bskyBotRes = await BskyBot.run(article, bskyBotOptions);
40 | console.debug(`[${new Date().toISOString()}]:`, bskyBotRes);
41 | } catch (error) {
42 | process.exitCode = 1; // Signify error exit code
43 | console.error(error);
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/lib/bsky-bot.js:
--------------------------------------------------------------------------------
1 | import { AtpAgent, RichText } from '@atproto/api';
2 | import { bskyAccount, bskyService } from './config.js';
3 |
4 | export default class BskyBot {
5 | #agent;
6 |
7 | static defaultOptions = {
8 | service: bskyService,
9 | dryRun: false,
10 | };
11 |
12 | constructor(service) {
13 | this.#agent = new AtpAgent({ service });
14 | }
15 |
16 | login(loginOpts) {
17 | return this.#agent.login(loginOpts);
18 | }
19 |
20 | /**
21 | *
22 | * @param {Object} data
23 | * @param {Function} textTemplate
24 | * @returns
25 | */
26 | async post(data, templateFn = (_) => '') {
27 | let richText = new RichText({
28 | text: templateFn(data),
29 | });
30 |
31 | if (richText.graphemeLength > 300) {
32 | richText = new RichText({
33 | text: `${richText.unicodeText.slice(0, 300)}…`,
34 | });
35 | }
36 |
37 | await richText.detectFacets(this.#agent);
38 |
39 | const embedCard = await getBskyEmbedCard(data, this.#agent);
40 | const record = {
41 | $type: 'app.bsky.feed.post',
42 | text: richText.text,
43 | facets: richText.facets,
44 | embed: embedCard,
45 | createdAt: new Date().toISOString(),
46 | langs: ['en'],
47 | };
48 |
49 | console.debug('Posting record:', record);
50 |
51 | return this.#agent.post(record);
52 | }
53 |
54 | static async run(data, botOptions) {
55 | const { service, postTemplateFn, dryRun } = botOptions
56 | ? Object.assign({}, BskyBot.defaultOptions, botOptions)
57 | : BskyBot.defaultOptions;
58 |
59 | const bot = new BskyBot(service);
60 | await bot.login(bskyAccount);
61 |
62 | if (!dryRun) {
63 | // post the new article
64 | const res = await bot.post(data, postTemplateFn);
65 | return res;
66 | }
67 |
68 | return { dryRun: true, data, post: postTemplateFn(data) };
69 | }
70 | }
71 |
72 | /**
73 | *
74 | * @param {Object} data
75 | * @param {String} data.url
76 | * @param {String} data.title
77 | * @param {String} data.description
78 | * @param {String} data.image
79 | * @param {AtpAgent} agent
80 | * @returns
81 | */
82 | async function getBskyEmbedCard(data, agent) {
83 | if (!data || !agent) return;
84 |
85 | try {
86 | const blob = await fetch(data?.image).then((r) => r.blob());
87 | const { data: thumb } = await agent.uploadBlob(blob);
88 |
89 | return {
90 | $type: 'app.bsky.embed.external',
91 | external: {
92 | uri: data.url,
93 | title: data.title,
94 | description: data.description,
95 | thumb: thumb.blob,
96 | },
97 | };
98 | } catch (error) {
99 | console.error('Error fetching embed card:', error);
100 | return;
101 | }
102 | }
103 |
--------------------------------------------------------------------------------
/CODE-OF-CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | In the interest of fostering an open and welcoming environment, we as
6 | contributors and maintainers pledge to making participation in our project and
7 | our community a harassment-free experience for everyone, regardless of age, body
8 | size, disability, ethnicity, gender identity and expression, level of experience,
9 | nationality, personal appearance, race, religion, or sexual identity and
10 | orientation.
11 |
12 | ## Our Standards
13 |
14 | Examples of behavior that contributes to creating a positive environment
15 | include:
16 |
17 | * Using welcoming and inclusive language
18 | * Being respectful of differing viewpoints and experiences
19 | * Gracefully accepting constructive criticism
20 | * Focusing on what is best for the community
21 | * Showing empathy towards other community members
22 |
23 | Examples of unacceptable behavior by participants include:
24 |
25 | * The use of sexualized language or imagery and unwelcome sexual attention or
26 | advances
27 | * Trolling, insulting/derogatory comments, and personal or political attacks
28 | * Public or private harassment
29 | * Publishing others' private information, such as a physical or electronic
30 | address, without explicit permission
31 | * Other conduct which could reasonably be considered inappropriate in a
32 | professional setting
33 |
34 | ## Our Responsibilities
35 |
36 | Project maintainers are responsible for clarifying the standards of acceptable
37 | behavior and are expected to take appropriate and fair corrective action in
38 | response to any instances of unacceptable behavior.
39 |
40 | Project maintainers have the right and responsibility to remove, edit, or
41 | reject comments, commits, code, wiki edits, issues, and other contributions
42 | that are not aligned to this Code of Conduct, or to ban temporarily or
43 | permanently any contributor for other behaviors that they deem inappropriate,
44 | threatening, offensive, or harmful.
45 |
46 | ## Scope
47 |
48 | This Code of Conduct applies both within project spaces and in public spaces
49 | when an individual is representing the project or its community. Examples of
50 | representing a project or community include using an official project e-mail
51 | address, posting via an official social media account, or acting as an appointed
52 | representative at an online or offline event. Representation of a project may be
53 | further defined and clarified by project maintainers.
54 |
55 | ## Enforcement
56 |
57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
58 | reported by contacting the project team at stefanjudis@gmail.com. All
59 | complaints will be reviewed and investigated and will result in a response that
60 | is deemed necessary and appropriate to the circumstances. The project team is
61 | obligated to maintain confidentiality with regard to the reporter of an incident.
62 | Further details of specific enforcement policies may be posted separately.
63 |
64 | Project maintainers who do not follow or enforce the Code of Conduct in good
65 | faith may face temporary or permanent repercussions as determined by other
66 | members of the project's leadership.
67 |
68 | ## Attribution
69 |
70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
71 | available at [https://contributor-covenant.org/version/1/4][version]
72 |
73 | [homepage]: https://contributor-covenant.org
74 | [version]: https://contributor-covenant.org/version/1/4/
75 |
--------------------------------------------------------------------------------
/lib/mdn-article.js:
--------------------------------------------------------------------------------
1 | import { decode } from 'html-entities';
2 |
3 | /**
4 | * Sitemap Handling
5 | */
6 | const SITEMAP_URL = 'https://developer.mozilla.org/sitemaps/en-us/sitemap.xml.gz';
7 | const WEB_PATH = 'https://developer.mozilla.org/en-US/docs/Web';
8 |
9 | /**
10 | * Utilities
11 | */
12 | const onlyAllowWebUrls = (url) => url.startsWith(WEB_PATH);
13 |
14 | /**
15 | * Get a random MDN web documentation article (url with metadata)
16 | * @returns {Promise} A random article from the MDN sitemap
17 | */
18 | export const getRandomArticle = async () => {
19 | try {
20 | let url;
21 | let metadata;
22 |
23 | const webDocUrls = await getWebDocUrls();
24 |
25 | // loop over it because many pages don't include a description
26 | while (!metadata?.title || !metadata?.description) {
27 | // grab a random URL
28 | url = webDocUrls[Math.floor(webDocUrls.length * Math.random())];
29 | metadata = await getArticleMetadata(url);
30 | }
31 | return { url, ...metadata };
32 | } catch (error) {
33 | console.error(error);
34 | }
35 | };
36 |
37 | /**
38 | * Get all MDN Web Documentation URLs
39 | * - fetch MDN sitemap
40 | * - filter out non-web-documentation URLs
41 | *
42 | * @returns {Promise} Array of all web documentation URLs from the MDN sitemap
43 | */
44 | export const getWebDocUrls = async () => {
45 | const SITEMAP_URL_REGEX = /(.*?)<\/loc>/g;
46 | const response = await fetch(SITEMAP_URL, { headers: { 'accept-encoding': 'gzip' } });
47 | const sitemap = await response.text();
48 | return Array.from(sitemap.matchAll(SITEMAP_URL_REGEX))
49 | .map(([_, url]) => url)
50 | .filter(onlyAllowWebUrls);
51 | };
52 |
53 | /**
54 | * Read out and meta description for URL and check if the url holds a deprecated entry
55 | * We use the rather than the as the title is a little more verbose
56 | *
57 | * @param {String} url
58 | * @returns {Promise} Object of metadata for the documented URL
59 | */
60 | export const getArticleMetadata = async (url) => {
61 | const DESCRIPTION_REGEX = /]*>/is;
62 | const TITLE_REGEX = /(.*?)<\/h1>/i;
63 | const IMAGE_REGEX = /]*>/i;
64 | // to not rely on exact words this matches the deprecation container
65 | const DEPRECATION_REGEX = /class="notecard deprecated"/;
66 |
67 | const response = await fetch(url);
68 | const doc = await response.text();
69 |
70 | if (DEPRECATION_REGEX.test(doc)) return;
71 |
72 | const metadata = {
73 | // Strip html comments (...)
74 | title: doc.match(TITLE_REGEX)?.[1]?.replace(//g, ''),
75 | // Normalize text by removing all extra whitespace (including spaces, tabs, and line breaks)
76 | description: doc.match(DESCRIPTION_REGEX)?.[1].replace(/\s+/g, ' ').trim(),
77 | hashtags: getHashtags(url),
78 | image: doc.match(IMAGE_REGEX)?.[1],
79 | };
80 |
81 | // Limit `title` to 40 characters
82 | if (metadata.title.length > 40) {
83 | metadata.title = `${metadata.title.slice(0, 40)}…`;
84 | }
85 |
86 | metadata.title = decode(metadata.title);
87 | metadata.description = decode(metadata.description);
88 |
89 | return metadata;
90 | };
91 |
92 | /**
93 | * Get appropriate hashtags for the URL
94 | * (probably can be way smarter and better)
95 | *
96 | * @param {String} url
97 | * @returns {Array} fitting hashtags for the URL
98 | */
99 | export const getHashtags = (url) => {
100 | const hashtags = ['#webdev'];
101 | const SECTION_REGEX = /Web\/(.*?)\//;
102 | const [, section] = url.match(SECTION_REGEX);
103 | const hashtagWorthySections = ['CSS', 'Accessibility', 'JavaScript', 'HTTP', 'HTML', 'SVG'];
104 |
105 | if (hashtagWorthySections.includes(section)) {
106 | hashtags.push(`#${section}`);
107 | }
108 |
109 | return hashtags;
110 | };
111 |
--------------------------------------------------------------------------------