├── .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 | [![Static Badge](https://img.shields.io/badge/Random-MDN-black?logo=bluesky)](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 = /<meta name="description" content="([^"]+?)"[^>]*>/is; 62 | const TITLE_REGEX = /<h1>(.*?)<\/h1>/i; 63 | const IMAGE_REGEX = /<meta name="og:image" content="(.*?)"[^>]*>/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 (<!--lit-part-->...<!--/lit-part-->) 74 | title: doc.match(TITLE_REGEX)?.[1]?.replace(/<!--[\s\S]*?-->/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 | --------------------------------------------------------------------------------