diff --git a/_layouts/spec.html b/_layouts/spec.html index 13f0b4aa..1cd94ba5 100644 --- a/_layouts/spec.html +++ b/_layouts/spec.html @@ -118,7 +118,8 @@ {%- if defaultCodeblockVariant == nil -%} {%- assign defaultCodeblockVariant = site.primerSpec.defaultCodeblockVariant -%} {%- endif -%} - defaultCodeblockVariant: "{{ defaultCodeblockVariant | default: 'enhanced' }}" + defaultCodeblockVariant: "{{ defaultCodeblockVariant | default: 'enhanced' }}", + disableJokes: {{ site.primerSpec.disableJokes | default: false }} }; diff --git a/_sass/jekyll-theme-primer-spec.scss b/_sass/jekyll-theme-primer-spec.scss index 6464c39a..ce35a993 100644 --- a/_sass/jekyll-theme-primer-spec.scss +++ b/_sass/jekyll-theme-primer-spec.scss @@ -5,6 +5,8 @@ @import '@primer/css/markdown/index.scss'; @import '@primer/css/box/index.scss'; @import '@primer/css/buttons/index.scss'; +@import '@primer/css/dropdown/index.scss'; +@import '@primer/css/popover/index.scss'; @import '@primer/css/tooltips/index.scss'; @import 'spec/base.scss'; @import 'spec/rouge.scss'; diff --git a/docs/USAGE_ADVANCED.md b/docs/USAGE_ADVANCED.md index e4cf416e..ee7a34ca 100644 --- a/docs/USAGE_ADVANCED.md +++ b/docs/USAGE_ADVANCED.md @@ -38,6 +38,7 @@ See the [Primer Spec README](../README.md) for the main usage instructions. This - [`defaultSubthemeMode`: String](#defaultsubthememode-string) - [`defaultCodeblockVariant`: CodeblockVariant (String)](#defaultcodeblockvariant-codeblockvariant-string-1) - [`sitemap`: Boolean \| {label: String; externalLinks: Array}](#sitemap-boolean--label-string-externallinks-array) + - [`disableJokes`: Boolean](#disablejokes-boolean) - [Pinning to a specific version](#pinning-to-a-specific-version) - [Using without Jekyll](#using-without-jekyll) @@ -416,6 +417,14 @@ To exclude a page from the sitemap, set [`excludeFromSitemap: true`](#excludefro **NOTE:** A sitemap will only be rendered if your site has multiple pages. +#### `disableJokes`: Boolean + +Primer Spec displays Easter Eggs to students around Halloween and April Fools. The jokes do not interefere with the spec's content without students' explicit consent. (See the [Halloween joke](https://github.com/eecs485staff/primer-spec/pull/157) as an example.) + +If you'd prefer for Primer Spec to not render jokes on your website, add the config `disableJokes: false` to the Primer Spec config in `_config.yml`. + +We hope you'll keep the jokes enabled as a fun way to engage students around these holidays! If you _do_ end up disabling these jokes, we'd appreciate your feedback on why you chose to do so. Please feel free to open an [issue](https://github.com/eecs485staff/primer-spec/issues/new/) or [discussion](https://github.com/eecs485staff/primer-spec/discussions/new) on the Primer Spec repo, and we can follow up there :) + ## Pinning to a specific version We take care to release new versions of Primer Spec on the `main` branch only between semesters at the University of Michigan. However, if your site needs an even stronger guarantee of stability, you can pin your site to a specific _minor_ version of Primer Spec. diff --git a/package-lock.json b/package-lock.json index 686addd6..1eb84d61 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "anchor-js": "^4.3.1", "clsx": "^1.1.1", "jsx-dom": "*", + "pig-latinizer": "^1.0.6", "preact": "^10.5.14" }, "devDependencies": { @@ -11445,6 +11446,11 @@ "node": ">=6" } }, + "node_modules/pig-latinizer": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/pig-latinizer/-/pig-latinizer-1.0.6.tgz", + "integrity": "sha512-+jSJEByMlAW/SVE/cb/94+9RYSLdM0putYBSm6dWTuh+7mEabjXa6oWHP0rmGUZGnRW/JHfEsJ2U7s0tVW+Org==" + }, "node_modules/pirates": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.5.tgz", @@ -23607,6 +23613,11 @@ "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", "dev": true }, + "pig-latinizer": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/pig-latinizer/-/pig-latinizer-1.0.6.tgz", + "integrity": "sha512-+jSJEByMlAW/SVE/cb/94+9RYSLdM0putYBSm6dWTuh+7mEabjXa6oWHP0rmGUZGnRW/JHfEsJ2U7s0tVW+Org==" + }, "pirates": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.5.tgz", diff --git a/package.json b/package.json index 5d935403..600d72f7 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "anchor-js": "^4.3.1", "clsx": "^1.1.1", "jsx-dom": "*", + "pig-latinizer": "^1.0.6", "preact": "^10.5.14" }, "devDependencies": { diff --git a/src_js/conditional_plugins/conditional_plugins.ts b/src_js/conditional_plugins/conditional_plugins.ts index a53e118a..0cd0e6c4 100644 --- a/src_js/conditional_plugins/conditional_plugins.ts +++ b/src_js/conditional_plugins/conditional_plugins.ts @@ -1,33 +1,39 @@ -import { initialize as initializeHalloweenPlugin } from './halloween.plugin'; +/** + * Conditional plugins are loaded asynchronously and are intentionally + * isolated from the rest of Primer Spec. This is because this conditional + * plugin framework was designed to build temporary pranks and jokes! (We don't + * want these jokes to affect the page load time and the spec-reading + * experience.) + * + * Plugins run based on conditions defined in the `shouldLoadPlugin()` method. + * They can also be force-enabled by inserting + * `?enable_=1` in the URL. + */ import type { ConditionalPluginInput } from './types.d'; +import { shouldLoadPlugin } from './should_load_plugin'; +import { loadPlugin } from './load_plugin'; -const PLUGINS = [initializeHalloweenPlugin()] - .filter((pluginDefinition) => { - const forceEnableOption = pluginForceEnableOption(pluginDefinition.id); - if (forceEnableOption !== null) { - return forceEnableOption; - } - return pluginDefinition.shouldRun(); - }) - .map((pluginDefinition) => pluginDefinition.plugin); +/** + * When adding a new Plugin: + * 1. Add the plugin definition to `./plugins/[your-plugin].plugin.ts` + * 2. Choose a plugin ID, then add it to this list + * 3. Add a condition to `shouldLoadPlugin()` for this plugin ID + * 4. Update `loadPlugin()` to load the plugin definition from (1) + */ +const PLUGIN_IDS = ['halloween', 'april_fools_languages']; + +const pluginsPromises = PLUGIN_IDS.filter((pluginId) => + shouldLoadPlugin(pluginId), +).map((pluginId) => loadPlugin(pluginId)); export async function executePlugins( input: ConditionalPluginInput, ): Promise { + const plugins = await Promise.all(pluginsPromises); await Promise.all( - PLUGINS.map(async (plugin) => { - await plugin(input); + plugins.map(async (plugin) => { + await plugin?.(input); }), ); } - -function pluginForceEnableOption(pluginId: string): boolean | null { - const match = window.location.search.match( - new RegExp(`enable_${pluginId}=([0|1])`), - ); - if (match) { - return match[1] === '1'; - } - return null; -} diff --git a/src_js/conditional_plugins/load_plugin.ts b/src_js/conditional_plugins/load_plugin.ts new file mode 100644 index 00000000..84e43134 --- /dev/null +++ b/src_js/conditional_plugins/load_plugin.ts @@ -0,0 +1,23 @@ +import { Plugin } from './types.d'; + +/** + * Given a plugin ID, lazy-load the appropriate JS module containing the plugin + * definition and return the plugin. + * + * Notice that we use the dynamic `import()` syntax. Webpack identifies this as + * an opportunity to split the JS bundle, hence decreasing the size of the main + * Primer Spec JS bundle. Additionally, we won't download the JS code for all + * plugins, only the ones that need to run. + */ +export async function loadPlugin(pluginId: string): Promise { + let plugin: Plugin | null = null; + switch (pluginId) { + case 'halloween': + plugin = (await import('./plugins/halloween.plugin')).default; + break; + case 'april_fools_languages': + plugin = (await import('./plugins/april_fools_languages.plugin')).default; + break; + } + return plugin; +} diff --git a/src_js/conditional_plugins/plugins/april_fools_languages.plugin.tsx b/src_js/conditional_plugins/plugins/april_fools_languages.plugin.tsx new file mode 100644 index 00000000..015e55e2 --- /dev/null +++ b/src_js/conditional_plugins/plugins/april_fools_languages.plugin.tsx @@ -0,0 +1,287 @@ +/** @jsx JSXDom.h */ +import * as JSXDom from 'jsx-dom'; +import PigLatinizer from 'pig-latinizer'; +import { translate as pirateSpeakTranslate } from './utils/pirate_speak'; +import { flipStringUpsideDown } from './utils/upside_down'; + +let currentLanguage = 'english'; + +export default async function AprilFoolsLanguagesPlugin(): Promise { + insertLanguageToggleIfNeeded(); + insertDarkModeStylesIfNeeded(); + storeOriginalPageContentsIfNeeded(); + registerWindowEventListenerOnce(); +} + +/////////// +// UI /// +/////////// + +function insertLanguageToggleIfNeeded() { + const languageToggleId = 'primer-spec-april-fools-language-toggle'; + const existingLanguageToggle = document.querySelector(`#${languageToggleId}`); + if (existingLanguageToggle) { + return; + } + + const settingsToggleContainer = document.querySelector( + '.primer-spec-settings-toggle', + ); + const settingsToggle = settingsToggleContainer?.querySelector( + '.primer-spec-hoverable', + ); + if (!settingsToggle || !settingsToggleContainer) { + console.warn( + 'Primer Spec: April Fools Languages joke: Could not find settings toggle', + ); + return; + } + + const languageToggle = settingsToggle.cloneNode(true) as HTMLElement; + languageToggle.id = languageToggleId; + languageToggle.style.paddingRight = '1em'; + const languageIcon = languageToggle.querySelector('i.fa-cog'); + languageIcon?.classList.remove('fa-cog'); + languageIcon?.classList.add('fa-language'); + settingsToggleContainer.prepend(languageToggle); + + const languageToggleBtn = languageToggle.querySelector('button'); + languageToggleBtn?.addEventListener('click', () => toggleLanguagePopover()); +} + +const languagePopoverId = 'primer-spec-april-fools-language-popover'; +function toggleLanguagePopover(opts?: { doNotOpen: boolean }) { + const existingPopover = document.querySelector(`#${languagePopoverId}`); + if (existingPopover) { + existingPopover.remove(); + } else if (opts == null || !opts.doNotOpen) { + const topbar = document.querySelector('header.primer-spec-topbar'); + topbar?.appendChild( +
+
+ +

Change this page's "language"

+

April Fools! Try reading this page in another "language".

+
+ +
+
+
, + ); + + setCurrentLanguage(currentLanguage); + } +} + +function getLanguageButton(id: string, label: string) { + return ( + + ); +} + +const DARK_MODE_STYLE_ID = 'primer-spec-april-fools-languages-dark-mode-styles'; +function insertDarkModeStylesIfNeeded() { + if (!document.querySelector(`#${DARK_MODE_STYLE_ID}`)) { + document.head.appendChild( + , + ); + } +} + +function setCurrentLanguage(languageId: string) { + if (currentLanguage !== languageId) { + currentLanguage = languageId; + changePageLanguage(languageId); + } + + const chosenLanguageLabel = document.querySelector( + `#${languagePopoverId}-chosen-language`, + ); + const chosenLanguageButton = document.querySelector( + `#${languagePopoverId}-${currentLanguage}`, + ); + if (chosenLanguageLabel && chosenLanguageButton) { + chosenLanguageLabel.innerHTML = chosenLanguageButton.innerHTML; + } + + // Close the dropdown + document + .querySelector('#primer-spec-april-fools-language-popover details.dropdown') + ?.removeAttribute('open'); +} + +let windowEventRegistered = false; +function registerWindowEventListenerOnce() { + if (!windowEventRegistered) { + // If the user clicks outside the language popover, close the popover if it's + // open. + window.addEventListener('click', (event: Event) => { + const target = event?.target as HTMLElement | null; + if ( + target && + target.closest( + '#primer-spec-april-fools-language-popover, #primer-spec-april-fools-language-toggle', + ) == null && + document.body.contains(target) + ) { + toggleLanguagePopover({ doNotOpen: true }); + } + }); + windowEventRegistered = true; + } +} + +///////////////////////////// +// LANGUAGE CHANGE INFRA // +///////////////////////////// + +let originalPageContents: string | null = null; + +function storeOriginalPageContentsIfNeeded() { + if (!originalPageContents) { + const mainContent = document.querySelector( + 'main#primer-spec-preact-main-content', + ); + originalPageContents = mainContent?.innerHTML ?? null; + } +} + +type Translator = (text: string | null) => string | null; + +async function changePageLanguage(languageId: string) { + const TRANSLATOR_GETTERS: { + [languageId: string]: () => Promise; + } = { + english: getEnglishTranslator, + 'pig-latin': getPigLatinTranslator, + pirate: getPirateTranslator, + 'upside-down': getUpsideDownTranslator, + }; + + if (originalPageContents && languageId in TRANSLATOR_GETTERS) { + const translatedHtml = translate( + originalPageContents, + await TRANSLATOR_GETTERS[languageId](), + ); + setMainContentHTML(translatedHtml); + } +} + +function setMainContentHTML(html: string) { + const mainContent = document.querySelector( + 'main#primer-spec-preact-main-content', + ); + if (!mainContent) { + return; + } + mainContent.innerHTML = html; +} + +function translate( + originalHtmlStr: string, + translator: (text: string | null) => string | null, +) { + const translateChildNodes = (parentEl: Element) => { + const newChildNodes: Node[] = [...parentEl.childNodes].map((node: Node) => { + if (node.nodeType == Node.TEXT_NODE) { + node.textContent = translator(node.textContent); + return node; + } else if (node.nodeType === Node.ELEMENT_NODE) { + if ( + (node as HTMLElement).classList.contains('primer-spec-code-block') || + (node as HTMLElement).classList.contains( + 'primer-spec-code-block-processed', + ) || + (node as HTMLElement).classList.contains('primer-spec-mermaid-output') + ) { + return node; + } + const newNode = node.cloneNode(true); + translateChildNodes(newNode as Element); + return newNode; + } + return node; + }); + parentEl.innerHTML = ''; + parentEl.append(...newChildNodes); + }; + + const originalHtml = new DOMParser().parseFromString( + originalHtmlStr, + 'text/html', + ).body; + translateChildNodes(originalHtml); + return originalHtml.innerHTML; +} + +//////////////////////////////// +// LANGUAGE IMPLEMENTATIONS // +//////////////////////////////// + +function getEnglishTranslator(): Promise { + return Promise.resolve((text: string | null) => text); +} + +async function getPigLatinTranslator(): Promise { + const translator = new PigLatinizer(); + return (text: string | null) => (text ? translator.translate(text) : text); +} + +async function getPirateTranslator(): Promise { + return (text: string | null) => (text ? pirateSpeakTranslate(text) : text); +} + +async function getUpsideDownTranslator(): Promise { + return (text: string | null) => (text ? flipStringUpsideDown(text) : text); +} diff --git a/src_js/conditional_plugins/halloween.plugin.tsx b/src_js/conditional_plugins/plugins/halloween.plugin.tsx similarity index 88% rename from src_js/conditional_plugins/halloween.plugin.tsx rename to src_js/conditional_plugins/plugins/halloween.plugin.tsx index ee388287..d822db8c 100644 --- a/src_js/conditional_plugins/halloween.plugin.tsx +++ b/src_js/conditional_plugins/plugins/halloween.plugin.tsx @@ -1,37 +1,10 @@ /** @jsx JSXDom.h */ import * as JSXDom from 'jsx-dom'; -import type { ConditionalPluginInput, PluginDefinition } from './types'; +import type { ConditionalPluginInput } from '../types'; -export function initialize(): PluginDefinition { - return { - id: 'halloween', - plugin: HalloweenPlugin, - shouldRun: () => { - const today = new Date(); - // Console message if we are *just* past the Halloween-mode end-date. - if ( - today.getMonth() === 10 && - today.getDate() > 5 && - today.getDate() <= 15 - ) { - const enabled_url = new URL(window.location.href); - enabled_url.searchParams.set('enable_halloween', '1'); - console.info( - "🤫 Psst... It's well past halloween, but you can re-enable halloween mode by clicking this url:\n", - enabled_url.toString(), - ); - } - - // Remember that months are 0-indexed in JS! - return ( - (today.getMonth() === 9 && today.getDate() >= 25) || - (today.getMonth() === 10 && today.getDate() <= 5) - ); - }, - }; -} - -async function HalloweenPlugin(input: ConditionalPluginInput): Promise { +export default async function HalloweenPlugin( + input: ConditionalPluginInput, +): Promise { registerHalloweenSubthemeIfNeeded(); if (!input.settings_shown) { replaceSettingsToggleWithHat(); diff --git a/src_js/conditional_plugins/plugins/utils/pirate_speak.ts b/src_js/conditional_plugins/plugins/utils/pirate_speak.ts new file mode 100644 index 00000000..c6990cc9 --- /dev/null +++ b/src_js/conditional_plugins/plugins/utils/pirate_speak.ts @@ -0,0 +1,279 @@ +/** + * The `pirate-speak` software was originally written by + * Michael Hadley . + * https://github.com/mikewesthad/pirate-speak + * + * The software was modified for use with Primer Spec. + * + * The original `pirate-speak` package was licensed under the MIT License, + * included below: + * + ******************* + * + * The MIT License (MIT) + * + * Copyright (c) 2015 Michael Hadley + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + ******************* + */ + +export const dictionary: { [englishWord: string]: string } = { + address: "port o' call", + admin: 'helm', + am: 'be', + an: 'a', + and: "n'", + are: 'be', + award: 'prize', + before: 'afore', + belief: 'creed', + between: 'betwixt', + big: 'vast', + bill: 'coin', + bills: 'coins', + boss: 'admiral', + box: 'barrel', + buddy: 'mate', + business: 'company', + businesses: 'companies', + calling: "callin'", + canada: 'Great North', + cash: 'coin', + cat: 'parrot', + cheat: 'hornswaggle', + comes: 'hails', + comments: 'yer words', + cool: 'shipshape', + country: 'land', + dashboard: 'shanty', + dead: "in Davy Jones's Locker", + disconnect: 'keelhaul', + // do: "d'", + dog: 'parrot', + dollar: 'doubloon', + dollars: 'doubloons', + dude: 'pirate', + employee: 'crew', + everyone: 'all hands', + eye: 'eye-patch', + family: 'kin', + fee: 'debt', + food: 'grub', + for: 'fer', + friend: 'matey', + friends: 'mateys', + go: 'sail', + good: 'jolly good', + grave: "Davy Jones's Locker", + group: 'maties', + haha: 'yo ho', + hahaha: 'yo ho ho', + hahahaha: 'yo ho ho ho', + hand: 'hook', + happy: 'grog-filled', + hello: 'ahoy', + hey: 'ahoy', + hi: 'ahoy', + hotel: 'fleebag inn', + i: 'me', + "i'm": 'i be', + 'i’m': 'i be', + internet: "series o' tubes", + invalid: 'sunk', + is: 'be', + island: 'isle', + "isn't": 'be not', + 'isn’t': 'be not', + "it's": "'tis", + 'it’s': "'tis", + jail: 'brig', + kill: 'keelhaul', + king: 'king', + ladies: 'lasses', + lady: 'lass', + lawyer: 'scurvy land lubber', + left: 'port', + leg: 'peg', + logout: 'walk the plank', + lol: 'blimey', + man: 'pirate', + manager: 'admiral', + money: 'doubloons', + month: 'moon', + my: 'me', + never: 'nary', + no: 'nay', + not: 'nay', + of: "o'", + old: 'barnacle-covered', + omg: 'shiver me timbers', + over: "o'er", + page: 'parchment', + people: 'scallywags', + person: 'scurvy dog', + posted: 'tacked to the yardarm', + president: 'king', + prison: 'brig', + quickly: 'smartly', + really: 'verily', + relative: 'kin', + relatives: 'kin', + religion: 'creed', + restaurant: 'galley', + right: 'starboard', + rotf: "rollin' on the decks", + say: 'cry', + seconds: "ticks o' tha clock", + shipping: 'cargo', + small: 'puny', + snack: 'grub', + soldier: 'sailor', + sorry: 'yarr', + spouse: "ball 'n' chain", + state: 'land', + supervisor: "Cap'n", + "that's": 'that be', + // the: 'thar', + thief: 'swoggler', + them: "'em", + // this: 'dis', + to: "t'", + together: "t'gether", + treasure: 'booty', + was: 'be', + water: 'grog', + we: 'our jolly crew', + // "we're": "we's", + // "we’re": "we's", + with: "wit'", + work: 'duty', + yah: 'aye', + yeah: 'aye', + yes: 'aye', + you: 'ye', + "you're": 'you be', + 'you’re': 'you be', + "you've": 'ye', + 'you’ve': 'ye', + your: 'yer', + yup: 'aye', + + // These translations were inspired by Facebook Pirate translations. + around: 'roundabouts', + cancel: 'retreat', + event: 'grog fest', + except: "exceptin'", + jarnuary: 'januarrry', + feburuary: 'februarrry', + march: 'marrrch', + april: "Month o' Showers", + // may: "Month o' May", + june: "Merry Month o' June", + july: 'jul-aye', + august: 'arrrgust', + september: 'Septembarrr', + october: 'Octobarrr', + november: 'Novembarrr', + december: 'Decembarrr', + sunday: "Day o' the Sun", + monday: 'munday', + tuesday: "toos'day", + wednesday: "Ondin's day", + thursday: 'tharrrsday', + saturday: 'satarrrday', + today: "t'day", + post: "scrawlin'", + tweet: "scrawlin'", + video: 'bewitched portrait', + location: "port o' call", + + // Other thoughts + file: 'parchment', + browser: "bewtch'd portal", + code: 'spells', + program: 'magic spell', + dependencies: 'dependen-seas', + starter: 'starrrter', +}; + +function translateWord(word: string) { + const pirateWord = dictionary[word.toLowerCase()]; + if (pirateWord === undefined) return word; + return applyCase(word, pirateWord); +} + +// Take the case from wordA and apply it to wordB +function applyCase(wordA: string, wordB: string) { + // Exception to avoid words like "I" being converted to "ME" + if (wordA.length === 1 && wordB.length !== 1) return wordB; + // Uppercase + if (wordA === wordA.toUpperCase()) return wordB.toUpperCase(); + // Lowercase + if (wordA === wordA.toLowerCase()) return wordB.toLowerCase(); + // Capitialized + const firstChar = wordA.slice(0, 1); + const otherChars = wordA.slice(1); + if ( + firstChar === firstChar.toUpperCase() && + otherChars === otherChars.toLowerCase() + ) { + return wordB.slice(0, 1).toUpperCase() + wordB.slice(1).toLowerCase(); + } + // Other cases + return wordB; +} + +function isLetter(character: string) { + if (character.search(/[a-zA-Z'’-]/) === -1) return false; + return true; +} + +export function translate(text: string) { + let translatedText = ''; + + // Loop through the text, one character at a time. + let word = ''; + for (let i = 0; i < text.length; i += 1) { + const character = text[i]; + // If the char is a letter, then we are in the middle of a word, so we + // should accumulate the letter into the word variable + if (isLetter(character)) { + word += character; + } + // If the char is not a letter, then we hit the end of a word, so we + // should translate the current word and add it to the translation + else { + if (word != '') { + // If we've just finished a word, translate it + const pirateWord = translateWord(word); + translatedText += pirateWord; + word = ''; + } + translatedText += character; // Add the non-letter character + } + } + + // If we ended the loop before translating a word, then translate the final + // word and add it to the translation. + if (word !== '') translatedText += translateWord(word); + + return translatedText; +} diff --git a/src_js/conditional_plugins/plugins/utils/upside_down.ts b/src_js/conditional_plugins/plugins/utils/upside_down.ts new file mode 100644 index 00000000..06dff2e9 --- /dev/null +++ b/src_js/conditional_plugins/plugins/utils/upside_down.ts @@ -0,0 +1,89 @@ +/** + * This module is adapted from the Upside Down Converter on fileformat.info: + * https://www.fileformat.info/convert/text/upside-down-map.htm + * + * Which is itself derived from revfad.com (by David Faden): + * https://www.revfad.com/flip.html + * + * This work, and the original work, is licensed under a Creative Commons + * Attribution-Share Alike 3.0 Unported License. + * http://creativecommons.org/licenses/by-sa/3.0/ + * + */ + +export function flipStringUpsideDown(aString: string): string { + const last = aString.length - 1; + //Thanks to Brook Monroe for the + //suggestion to use Array.join + const result = new Array(aString.length); + for (let i = last; i >= 0; --i) { + const c = aString.charAt(i); + const r = flipTable[c]; + result[last - i] = r != undefined ? r : c; + } + return result.join(''); +} + +const flipTable: { [char: string]: string } = { + '\u0021': '\u00A1', + '\u0022': '\u201E', + '\u0026': '\u214B', + '\u0027': '\u002C', + '\u0028': '\u0029', + '\u002E': '\u02D9', + '\u0033': '\u0190', + '\u0034': '\u152D', + '\u0036': '\u0039', + '\u0037': '\u2C62', + '\u003B': '\u061B', + '\u003C': '\u003E', + '\u003F': '\u00BF', + A: '\u2200', + B: '\u10412', + C: '\u2183', + D: '\u25D6', + E: '\u018E', + F: '\u2132', + G: '\u2141', + J: '\u017F', + K: '\u22CA', + L: '\u2142', + M: '\u0057', + N: '\u1D0E', + P: '\u0500', + Q: '\u038C', + R: '\u1D1A', + T: '\u22A5', + U: '\u2229', + V: '\u1D27', + Y: '\u2144', + '\u005B': '\u005D', + _: '\u203E', + a: '\u0250', + b: '\u0071', + c: '\u0254', + d: '\u0070', + e: '\u01DD', + f: '\u025F', + g: '\u0183', + h: '\u0265', + i: '\u0131', + j: '\u027E', + k: '\u029E', + l: '\u0283', + m: '\u026F', + n: '\u0075', + r: '\u0279', + t: '\u0287', + v: '\u028C', + w: '\u028D', + y: '\u028E', + '\u007B': '\u007D', + '\u203F': '\u2040', + '\u2045': '\u2046', + '\u2234': '\u2235', +}; + +for (const i in flipTable) { + flipTable[flipTable[i]] = i; +} diff --git a/src_js/conditional_plugins/should_load_plugin.ts b/src_js/conditional_plugins/should_load_plugin.ts new file mode 100644 index 00000000..afe76976 --- /dev/null +++ b/src_js/conditional_plugins/should_load_plugin.ts @@ -0,0 +1,66 @@ +import { isTodayInRange, Month } from './utils/is_today_in_range'; +import { printEnablingURLToConsole } from './utils/print_enabling_url_to_console'; + +export function shouldLoadPlugin(pluginId: string): boolean { + if (window.PrimerSpecConfig.disableJokes) { + return false; + } + + const forceEnableOption = pluginForceEnableOption(pluginId); + if (forceEnableOption !== null) { + return forceEnableOption; + } + + switch (pluginId) { + case 'halloween': + // Console message if we are *just* past the Halloween-mode end-date. + // From November 5 until November 15. + if ( + isTodayInRange( + { month: Month.NOVEMBER, date: 5 }, + { month: Month.NOVEMBER, date: 16 }, + ) + ) { + printEnablingURLToConsole( + pluginId, + "🤫 Psst... It's well past halloween, but you can re-enable halloween mode by clicking this url:", + ); + } + + return isTodayInRange( + { month: Month.OCTOBER, date: 25 }, + { month: Month.NOVEMBER, date: 5 }, + ); + + case 'april_fools_languages': + // Console message if we are *just* past the April Fools end-date. + // From April 4 until April 13. + if ( + isTodayInRange( + { month: Month.APRIL, date: 4 }, + { month: Month.APRIL, date: 13 }, + ) + ) { + printEnablingURLToConsole( + pluginId, + "🤫 Psst... It's well past April Fools, but you can re-enable the April Fools Language prank by clicking this url:", + ); + } + + return isTodayInRange( + { month: Month.MARCH, date: 29 }, + { month: Month.APRIL, date: 4 }, + ); + } + return false; +} + +function pluginForceEnableOption(pluginId: string): boolean | null { + const match = window.location.search.match( + new RegExp(`enable_${pluginId}=([0|1])`), + ); + if (match) { + return match[1] === '1'; + } + return null; +} diff --git a/src_js/conditional_plugins/types.d.ts b/src_js/conditional_plugins/types.d.ts index 49d83b40..ca5f79cb 100644 --- a/src_js/conditional_plugins/types.d.ts +++ b/src_js/conditional_plugins/types.d.ts @@ -7,9 +7,3 @@ export type ConditionalPluginInput = { }; export type Plugin = (input: ConditionalPluginInput) => Promise; - -export type PluginDefinition = { - id: string; - plugin: Plugin; - shouldRun: () => boolean; -}; diff --git a/src_js/conditional_plugins/utils/__tests__/is_today_in_range.test.ts b/src_js/conditional_plugins/utils/__tests__/is_today_in_range.test.ts new file mode 100644 index 00000000..9e8843e4 --- /dev/null +++ b/src_js/conditional_plugins/utils/__tests__/is_today_in_range.test.ts @@ -0,0 +1,83 @@ +import { Month, isTodayInRange } from '../is_today_in_range'; + +describe('isTodayInRange', () => { + beforeEach(() => { + jest.useFakeTimers('modern'); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('today is March 29, 2022', () => { + beforeEach(() => { + jest.setSystemTime(new Date('March 29, 2022')); + }); + + test('date before lower bound', () => { + const lower = { month: Month.APRIL, date: 1 }; + const upper = { month: Month.APRIL, date: 3 }; + expect(isTodayInRange(lower, upper)).toBe(false); + }); + + test('date equal to lower bound', () => { + const lower = { month: Month.MARCH, date: 29 }; + const upper = { month: Month.MARCH, date: 30 }; + expect(isTodayInRange(lower, upper)).toBe(true); + }); + + test('date after lower bound but before upper bound', () => { + const lower = { month: Month.MARCH, date: 28 }; + const upper = { month: Month.MARCH, date: 30 }; + expect(isTodayInRange(lower, upper)).toBe(true); + }); + + test('date after lower bound but before upper bound in a different month', () => { + const lower = { month: Month.MARCH, date: 28 }; + const upper = { month: Month.APRIL, date: 2 }; + expect(isTodayInRange(lower, upper)).toBe(true); + }); + + test('date just before upper bound', () => { + const lower = { month: Month.MARCH, date: 20 }; + const upper = { month: Month.MARCH, date: 30 }; + expect(isTodayInRange(lower, upper)).toBe(true); + }); + + test('date equal to upper bound', () => { + const lower = { month: Month.MARCH, date: 20 }; + const upper = { month: Month.MARCH, date: 29 }; + expect(isTodayInRange(lower, upper)).toBe(false); + }); + + test('date after upper bound', () => { + const lower = { month: Month.FEBRUARY, date: 2 }; + const upper = { month: Month.FEBRUARY, date: 20 }; + expect(isTodayInRange(lower, upper)).toBe(false); + }); + }); + + describe('today is April 2, 2022', () => { + beforeEach(() => { + jest.setSystemTime(new Date('April 2, 2022')); + }); + + test('date after lower bound', () => { + const lower = { month: Month.MARCH, date: 29 }; + const upper = { month: Month.APRIL, date: 3 }; + expect(isTodayInRange(lower, upper)).toBe(true); + }); + + test('date equal to upper bound', () => { + const lower = { month: Month.MARCH, date: 29 }; + const upper = { month: Month.APRIL, date: 2 }; + expect(isTodayInRange(lower, upper)).toBe(false); + }); + + test('date within multi-month bounds', () => { + const lower = { month: Month.MARCH, date: 29 }; + const upper = { month: Month.MAY, date: 2 }; + expect(isTodayInRange(lower, upper)).toBe(true); + }); + }); +}); diff --git a/src_js/conditional_plugins/utils/is_today_in_range.ts b/src_js/conditional_plugins/utils/is_today_in_range.ts new file mode 100644 index 00000000..98ad7614 --- /dev/null +++ b/src_js/conditional_plugins/utils/is_today_in_range.ts @@ -0,0 +1,47 @@ +export enum Month { + JANUARY = 0, + FEBRUARY, + MARCH, + APRIL, + MAY, + JUNE, + JULY, + AUGUST, + SEPTEMBER, + OCTOBER, + NOVEMBER, + DECEMBER, +} + +type DateWithoutYear = { + month: Month; // 0-indexed + date: number; +}; + +/** + * Return a boolean indicating whether today's date is between the dates + * `lowerBound` (inclusive) and `upperBound` (exclusive). + * + * KNOWN LIMITATION: Doesn't work across years (for instance, around New Year). + */ +export function isTodayInRange( + lowerBound: DateWithoutYear, + upperBound: DateWithoutYear, +): boolean { + const today = new Date(); + + if (today.getMonth() < lowerBound.month) { + return false; + } + let beyondLowerBound = true; + if (today.getMonth() === lowerBound.month) { + beyondLowerBound = today.getDate() >= lowerBound.date; + } + + if (today.getMonth() !== upperBound.month) { + return today.getMonth() < upperBound.month; + } + const withinUpperBound = today.getDate() < upperBound.date; + + return beyondLowerBound && withinUpperBound; +} diff --git a/src_js/conditional_plugins/utils/print_enabling_url_to_console.ts b/src_js/conditional_plugins/utils/print_enabling_url_to_console.ts new file mode 100644 index 00000000..faf0ca7c --- /dev/null +++ b/src_js/conditional_plugins/utils/print_enabling_url_to_console.ts @@ -0,0 +1,8 @@ +export function printEnablingURLToConsole( + pluginId: string, + message: string, +): void { + const enabled_url = new URL(window.location.href); + enabled_url.searchParams.set(`enable_${pluginId}`, '1'); + console.info(`${message}\n`, enabled_url.toString()); +} diff --git a/src_js/global.d.ts b/src_js/global.d.ts index 89249091..98edafb6 100644 --- a/src_js/global.d.ts +++ b/src_js/global.d.ts @@ -11,6 +11,7 @@ declare var PrimerSpecConfig: { sitemapSiteTitle?: string; useLegacyCodeBlocks?: boolean /* DEPRECATED in v1.7.0 */; defaultCodeblockVariant?: string; + disableJokes?: boolean; }; // Other global types