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".
+
+
+
+
+ Choose language
+
+
+
+
+
+
{getLanguageButton('english', 'English')}
+
{getLanguageButton('pig-latin', 'Pig Latin')}
+
{getLanguageButton('pirate', 'Pirate')}
+
{getLanguageButton('upside-down', 'Upside Down')}
+
+
+
+
+
,
+ );
+
+ 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