From 562d5e840c39e65d822f57426895a51c3fc00a87 Mon Sep 17 00:00:00 2001 From: Sesh Sadasivam Date: Tue, 5 Jul 2022 16:48:00 -0400 Subject: [PATCH 01/14] Modularize conditional plugin directory structure --- .../conditional_plugins.ts | 14 ++++++++++++- .../{ => plugins}/halloween.plugin.tsx | 20 ++++++++++--------- .../utils/print_enabling_url_to_console.ts | 8 ++++++++ 3 files changed, 32 insertions(+), 10 deletions(-) rename src_js/conditional_plugins/{ => plugins}/halloween.plugin.tsx (95%) create mode 100644 src_js/conditional_plugins/utils/print_enabling_url_to_console.ts diff --git a/src_js/conditional_plugins/conditional_plugins.ts b/src_js/conditional_plugins/conditional_plugins.ts index a53e118a..e13c8274 100644 --- a/src_js/conditional_plugins/conditional_plugins.ts +++ b/src_js/conditional_plugins/conditional_plugins.ts @@ -1,4 +1,16 @@ -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 each of their `shouldRun()` + * methods. They can also be force-enabled by inserting + * `?enable_=1` in the URL. + */ + +import { initialize as initializeHalloweenPlugin } from './plugins/halloween.plugin'; import type { ConditionalPluginInput } from './types.d'; diff --git a/src_js/conditional_plugins/halloween.plugin.tsx b/src_js/conditional_plugins/plugins/halloween.plugin.tsx similarity index 95% rename from src_js/conditional_plugins/halloween.plugin.tsx rename to src_js/conditional_plugins/plugins/halloween.plugin.tsx index ee388287..52a2ea88 100644 --- a/src_js/conditional_plugins/halloween.plugin.tsx +++ b/src_js/conditional_plugins/plugins/halloween.plugin.tsx @@ -1,31 +1,33 @@ /** @jsx JSXDom.h */ import * as JSXDom from 'jsx-dom'; -import type { ConditionalPluginInput, PluginDefinition } from './types'; +import type { ConditionalPluginInput, PluginDefinition } from '../types'; +import { printEnablingURLToConsole } from '../utils/print_enabling_url_to_console'; + +const PLUGIN_ID = 'halloween'; export function initialize(): PluginDefinition { return { - id: 'halloween', + id: PLUGIN_ID, plugin: HalloweenPlugin, shouldRun: () => { const today = new Date(); // Console message if we are *just* past the Halloween-mode end-date. + // After November 5 until November 15. 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(), + printEnablingURLToConsole( + PLUGIN_ID, + "🤫 Psst... It's well past halloween, but you can re-enable halloween mode by clicking this url:", ); } // Remember that months are 0-indexed in JS! return ( - (today.getMonth() === 9 && today.getDate() >= 25) || - (today.getMonth() === 10 && today.getDate() <= 5) + (today.getMonth() === 9 && today.getDate() >= 25) || // October 25 + (today.getMonth() === 10 && today.getDate() <= 5) // November 5 ); }, }; 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()); +} From a245d72a3b4cae7c568e226bccf1183205a83204 Mon Sep 17 00:00:00 2001 From: Sesh Sadasivam Date: Tue, 5 Jul 2022 17:54:11 -0400 Subject: [PATCH 02/14] Implement basic popover for April Fools languages joke --- _sass/jekyll-theme-primer-spec.scss | 1 + .../conditional_plugins.ts | 6 +- .../plugins/april_fools_languages.plugin.tsx | 95 +++++++++++++++++++ 3 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 src_js/conditional_plugins/plugins/april_fools_languages.plugin.tsx diff --git a/_sass/jekyll-theme-primer-spec.scss b/_sass/jekyll-theme-primer-spec.scss index 6464c39a..fb8a4719 100644 --- a/_sass/jekyll-theme-primer-spec.scss +++ b/_sass/jekyll-theme-primer-spec.scss @@ -5,6 +5,7 @@ @import '@primer/css/markdown/index.scss'; @import '@primer/css/box/index.scss'; @import '@primer/css/buttons/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/src_js/conditional_plugins/conditional_plugins.ts b/src_js/conditional_plugins/conditional_plugins.ts index e13c8274..b6bd4373 100644 --- a/src_js/conditional_plugins/conditional_plugins.ts +++ b/src_js/conditional_plugins/conditional_plugins.ts @@ -11,10 +11,14 @@ */ import { initialize as initializeHalloweenPlugin } from './plugins/halloween.plugin'; +import { initialize as initializeAprilFoolsLanguagesPlugin } from './plugins/april_fools_languages.plugin'; import type { ConditionalPluginInput } from './types.d'; -const PLUGINS = [initializeHalloweenPlugin()] +const PLUGINS = [ + initializeHalloweenPlugin(), + initializeAprilFoolsLanguagesPlugin(), +] .filter((pluginDefinition) => { const forceEnableOption = pluginForceEnableOption(pluginDefinition.id); if (forceEnableOption !== null) { 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..039d98fd --- /dev/null +++ b/src_js/conditional_plugins/plugins/april_fools_languages.plugin.tsx @@ -0,0 +1,95 @@ +/** @jsx JSXDom.h */ +import * as JSXDom from 'jsx-dom'; +import type { PluginDefinition } from '../types'; +import { printEnablingURLToConsole } from '../utils/print_enabling_url_to_console'; + +const PLUGIN_ID = 'april_fools_languages'; + +export function initialize(): PluginDefinition { + return { + id: PLUGIN_ID, + plugin: AprilFoolsLanguagesPlugin, + shouldRun: () => { + const today = new Date(); + // Console message if we are *just* past the April Fools end-date. + // After April 3 until April 13. + if ( + today.getMonth() === 3 && + today.getDate() > 3 && + today.getDate() <= 13 + ) { + printEnablingURLToConsole( + PLUGIN_ID, + "🤫 Psst... It's well past halloween, but you can re-enable halloween mode by clicking this url:", + ); + } + + // Remember that months are 0-indexed in JS! + return ( + (today.getMonth() === 2 && today.getDate() >= 29) || // March 29 + (today.getMonth() === 3 && today.getDate() <= 3) // April 3 + ); + }, + }; +} + +async function AprilFoolsLanguagesPlugin(): Promise { + insertLanguageToggleIfNeeded(); +} + +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()); +} + +function toggleLanguagePopover() { + const languagePopoverId = 'primer-spec-april-fools-language-popover'; + const existingPopover = document.querySelector(`#${languagePopoverId}`); + if (existingPopover) { + existingPopover.remove(); + } else { + const topbar = document.querySelector('header.primer-spec-topbar'); + topbar?.appendChild( +
+
+

Popover heading

+

Message about this particular piece of UI.

+ +
+
, + ); + } +} From b434616309ec77c6b0890233fca18328e223393d Mon Sep 17 00:00:00 2001 From: Sesh Sadasivam Date: Tue, 5 Jul 2022 18:48:30 -0400 Subject: [PATCH 03/14] Add April Fools language dropdown and dark mode styles --- _sass/jekyll-theme-primer-spec.scss | 1 + .../plugins/april_fools_languages.plugin.tsx | 67 +++++++++++++++++-- 2 files changed, 64 insertions(+), 4 deletions(-) diff --git a/_sass/jekyll-theme-primer-spec.scss b/_sass/jekyll-theme-primer-spec.scss index fb8a4719..ce35a993 100644 --- a/_sass/jekyll-theme-primer-spec.scss +++ b/_sass/jekyll-theme-primer-spec.scss @@ -5,6 +5,7 @@ @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'; diff --git a/src_js/conditional_plugins/plugins/april_fools_languages.plugin.tsx b/src_js/conditional_plugins/plugins/april_fools_languages.plugin.tsx index 039d98fd..a015502d 100644 --- a/src_js/conditional_plugins/plugins/april_fools_languages.plugin.tsx +++ b/src_js/conditional_plugins/plugins/april_fools_languages.plugin.tsx @@ -35,6 +35,7 @@ export function initialize(): PluginDefinition { async function AprilFoolsLanguagesPlugin(): Promise { insertLanguageToggleIfNeeded(); + insertDarkModeStylesIfNeeded(); } function insertLanguageToggleIfNeeded() { @@ -83,13 +84,71 @@ function toggleLanguagePopover() { style="right: 8em; pointer-events: auto;" >
-

Popover heading

-

Message about this particular piece of UI.

- +

Change this page's "language"

+

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

+
+ +
, ); } } + +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( + , + ); + } +} From e3e194ea0418b4b30cb9803c195537f9919fd865 Mon Sep 17 00:00:00 2001 From: Sesh Sadasivam Date: Tue, 5 Jul 2022 20:28:19 -0400 Subject: [PATCH 04/14] Make April fools language dropdown interactive Also store original page contents. --- .../plugins/april_fools_languages.plugin.tsx | 80 +++++++++++++++---- 1 file changed, 63 insertions(+), 17 deletions(-) diff --git a/src_js/conditional_plugins/plugins/april_fools_languages.plugin.tsx b/src_js/conditional_plugins/plugins/april_fools_languages.plugin.tsx index a015502d..a685ef6f 100644 --- a/src_js/conditional_plugins/plugins/april_fools_languages.plugin.tsx +++ b/src_js/conditional_plugins/plugins/april_fools_languages.plugin.tsx @@ -5,6 +5,8 @@ import { printEnablingURLToConsole } from '../utils/print_enabling_url_to_consol const PLUGIN_ID = 'april_fools_languages'; +let currentLanguage = 'english'; + export function initialize(): PluginDefinition { return { id: PLUGIN_ID, @@ -36,8 +38,13 @@ export function initialize(): PluginDefinition { async function AprilFoolsLanguagesPlugin(): Promise { insertLanguageToggleIfNeeded(); insertDarkModeStylesIfNeeded(); + storeOriginalPageContentsIfNeeded(); } +/////////// +// UI /// +/////////// + function insertLanguageToggleIfNeeded() { const languageToggleId = 'primer-spec-april-fools-language-toggle'; const existingLanguageToggle = document.querySelector(`#${languageToggleId}`); @@ -70,8 +77,8 @@ function insertLanguageToggleIfNeeded() { languageToggleBtn?.addEventListener('click', () => toggleLanguagePopover()); } +const languagePopoverId = 'primer-spec-april-fools-language-popover'; function toggleLanguagePopover() { - const languagePopoverId = 'primer-spec-april-fools-language-popover'; const existingPopover = document.querySelector(`#${languagePopoverId}`); if (existingPopover) { existingPopover.remove(); @@ -96,35 +103,41 @@ function toggleLanguagePopover() {
, ); + + setCurrentLanguage('english'); } } +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}`)) { @@ -152,3 +165,36 @@ function insertDarkModeStylesIfNeeded() { ); } } + +function setCurrentLanguage(languageId: string) { + currentLanguage = 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'); +} + +///////////////////////////// +// 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; + } +} From f3e3990c49157d65495d3be914d0592b834cf0b5 Mon Sep 17 00:00:00 2001 From: Sesh Sadasivam Date: Tue, 5 Jul 2022 20:41:33 -0400 Subject: [PATCH 05/14] Implement page language change infra --- .../plugins/april_fools_languages.plugin.tsx | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src_js/conditional_plugins/plugins/april_fools_languages.plugin.tsx b/src_js/conditional_plugins/plugins/april_fools_languages.plugin.tsx index a685ef6f..be8d2718 100644 --- a/src_js/conditional_plugins/plugins/april_fools_languages.plugin.tsx +++ b/src_js/conditional_plugins/plugins/april_fools_languages.plugin.tsx @@ -168,6 +168,8 @@ function insertDarkModeStylesIfNeeded() { function setCurrentLanguage(languageId: string) { currentLanguage = languageId; + changePageLanguage(languageId); + const chosenLanguageLabel = document.querySelector( `#${languagePopoverId}-chosen-language`, ); @@ -198,3 +200,36 @@ function storeOriginalPageContentsIfNeeded() { originalPageContents = mainContent?.innerHTML ?? null; } } + +function changePageLanguage(languageId: string) { + if (originalPageContents) { + switch (languageId) { + case 'english': + setMainContentHTML(originalPageContents); + break; + case 'pig-latin': + translateToPigLatin(originalPageContents); + break; + case 'pirate': + break; + } + } +} + +function setMainContentHTML(html: string) { + const mainContent = document.querySelector( + 'main#primer-spec-preact-main-content', + ); + if (!mainContent) { + return; + } + mainContent.innerHTML = html; +} + +//////////////////////////////// +// LANGUAGE IMPLEMENTATIONS // +//////////////////////////////// + +function translateToPigLatin(originalHtml: string) { + return originalHtml; +} From 13412f9e00c2560200538c489f7d156967cd349e Mon Sep 17 00:00:00 2001 From: Sesh Sadasivam Date: Tue, 5 Jul 2022 22:18:15 -0400 Subject: [PATCH 06/14] Implement Pig Latin translation --- .../plugins/april_fools_languages.plugin.tsx | 69 +++++++++++++++++-- 1 file changed, 63 insertions(+), 6 deletions(-) diff --git a/src_js/conditional_plugins/plugins/april_fools_languages.plugin.tsx b/src_js/conditional_plugins/plugins/april_fools_languages.plugin.tsx index be8d2718..984fa512 100644 --- a/src_js/conditional_plugins/plugins/april_fools_languages.plugin.tsx +++ b/src_js/conditional_plugins/plugins/april_fools_languages.plugin.tsx @@ -120,7 +120,7 @@ function toggleLanguagePopover() { , ); - setCurrentLanguage('english'); + setCurrentLanguage(currentLanguage); } } @@ -167,8 +167,10 @@ function insertDarkModeStylesIfNeeded() { } function setCurrentLanguage(languageId: string) { - currentLanguage = languageId; - changePageLanguage(languageId); + if (currentLanguage !== languageId) { + currentLanguage = languageId; + changePageLanguage(languageId); + } const chosenLanguageLabel = document.querySelector( `#${languagePopoverId}-chosen-language`, @@ -208,7 +210,7 @@ function changePageLanguage(languageId: string) { setMainContentHTML(originalPageContents); break; case 'pig-latin': - translateToPigLatin(originalPageContents); + setMainContentHTML(translateToPigLatin(originalPageContents)); break; case 'pirate': break; @@ -230,6 +232,61 @@ function setMainContentHTML(html: string) { // LANGUAGE IMPLEMENTATIONS // //////////////////////////////// -function translateToPigLatin(originalHtml: string) { - return originalHtml; +function translateToPigLatin(originalHtmlStr: string) { + const translateChildNodesToPigLatin = (parentEl: Element) => { + const newChildNodes: Node[] = [...parentEl.childNodes].map((node: Node) => { + if (node.nodeType == Node.TEXT_NODE) { + node.textContent = translateTextToPigLatin(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-mermaid-output') + ) { + return node; + } + const newNode = node.cloneNode(true); + translateChildNodesToPigLatin(newNode as Element); + return newNode; + } + return node; + }); + parentEl.innerHTML = ''; + parentEl.append(...newChildNodes); + }; + + const originalHtml = new DOMParser().parseFromString( + originalHtmlStr, + 'text/html', + ).body; + translateChildNodesToPigLatin(originalHtml); + return originalHtml.innerHTML; +} + +function translateTextToPigLatin(text: string | null): string | null { + if (!text || !text.trim()) { + return text; + } + + return text + .split(/([A-Za-z0-9]+)/) + .map((word, i) => { + if (i % 2 === 0) { + // This is the captured-whitespace, ignore it. + return word; + } + if (!word || !word.trim()) { + return word; + } + const fragments = word.split(/([aeiou]+)(.*)/); + const firstConsonantSound = fragments.shift(); + if (!firstConsonantSound) { + fragments.push('way'); + } else { + fragments.push(firstConsonantSound); + fragments.push('ay'); + } + return fragments.join(''); + }) + .join(''); } From d50fba60d2ac81196f2e453939d2eb070e7e0474 Mon Sep 17 00:00:00 2001 From: Sesh Sadasivam Date: Wed, 6 Jul 2022 09:22:23 -0400 Subject: [PATCH 07/14] Use npm package for Pig Latin --- package-lock.json | 11 +++ package.json | 1 + .../plugins/april_fools_languages.plugin.tsx | 86 +++++++++---------- 3 files changed, 52 insertions(+), 46 deletions(-) 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/plugins/april_fools_languages.plugin.tsx b/src_js/conditional_plugins/plugins/april_fools_languages.plugin.tsx index 984fa512..fbd8c4e8 100644 --- a/src_js/conditional_plugins/plugins/april_fools_languages.plugin.tsx +++ b/src_js/conditional_plugins/plugins/april_fools_languages.plugin.tsx @@ -203,18 +203,22 @@ function storeOriginalPageContentsIfNeeded() { } } -function changePageLanguage(languageId: string) { - if (originalPageContents) { - switch (languageId) { - case 'english': - setMainContentHTML(originalPageContents); - break; - case 'pig-latin': - setMainContentHTML(translateToPigLatin(originalPageContents)); - break; - case 'pirate': - break; - } +type Translator = (text: string | null) => string | null; + +async function changePageLanguage(languageId: string) { + const TRANSLATOR_GETTERS: { + [languageId: string]: () => Promise; + } = { + english: getEnglishTranslator, + 'pig-latin': getPigLatinTranslator, + }; + + if (originalPageContents && languageId in TRANSLATOR_GETTERS) { + const translatedHtml = translate( + originalPageContents, + await TRANSLATOR_GETTERS[languageId](), + ); + setMainContentHTML(translatedHtml); } } @@ -228,15 +232,14 @@ function setMainContentHTML(html: string) { mainContent.innerHTML = html; } -//////////////////////////////// -// LANGUAGE IMPLEMENTATIONS // -//////////////////////////////// - -function translateToPigLatin(originalHtmlStr: string) { - const translateChildNodesToPigLatin = (parentEl: Element) => { +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 = translateTextToPigLatin(node.textContent); + node.textContent = translator(node.textContent); return node; } else if (node.nodeType === Node.ELEMENT_NODE) { if ( @@ -246,7 +249,7 @@ function translateToPigLatin(originalHtmlStr: string) { return node; } const newNode = node.cloneNode(true); - translateChildNodesToPigLatin(newNode as Element); + translateChildNodes(newNode as Element); return newNode; } return node; @@ -259,34 +262,25 @@ function translateToPigLatin(originalHtmlStr: string) { originalHtmlStr, 'text/html', ).body; - translateChildNodesToPigLatin(originalHtml); + translateChildNodes(originalHtml); return originalHtml.innerHTML; } -function translateTextToPigLatin(text: string | null): string | null { - if (!text || !text.trim()) { - return text; - } +//////////////////////////////// +// LANGUAGE IMPLEMENTATIONS // +//////////////////////////////// - return text - .split(/([A-Za-z0-9]+)/) - .map((word, i) => { - if (i % 2 === 0) { - // This is the captured-whitespace, ignore it. - return word; - } - if (!word || !word.trim()) { - return word; - } - const fragments = word.split(/([aeiou]+)(.*)/); - const firstConsonantSound = fragments.shift(); - if (!firstConsonantSound) { - fragments.push('way'); - } else { - fragments.push(firstConsonantSound); - fragments.push('ay'); - } - return fragments.join(''); - }) - .join(''); +function getEnglishTranslator(): Promise { + return Promise.resolve((text: string | null) => text); +} + +async function getPigLatinTranslator(): Promise { + const PigLatinizer = await import('pig-latinizer'); + const translator = new PigLatinizer.default(); + return (text: string | null) => (text ? translator.translate(text) : text); } + +// TODO: DONOTCOMMIT! +// eslint-disable-next-line +// @ts-ignore +window.changePageLanguage = changePageLanguage; From 300be3ec361c4420394799ce222f09acf10f88af Mon Sep 17 00:00:00 2001 From: Sesh Sadasivam Date: Wed, 6 Jul 2022 10:12:30 -0400 Subject: [PATCH 08/14] Add pirate-speak --- .../plugins/april_fools_languages.plugin.tsx | 9 +- .../conditional_plugins/utils/pirate_speak.ts | 279 ++++++++++++++++++ 2 files changed, 284 insertions(+), 4 deletions(-) create mode 100644 src_js/conditional_plugins/utils/pirate_speak.ts diff --git a/src_js/conditional_plugins/plugins/april_fools_languages.plugin.tsx b/src_js/conditional_plugins/plugins/april_fools_languages.plugin.tsx index fbd8c4e8..0343bd6a 100644 --- a/src_js/conditional_plugins/plugins/april_fools_languages.plugin.tsx +++ b/src_js/conditional_plugins/plugins/april_fools_languages.plugin.tsx @@ -211,6 +211,7 @@ async function changePageLanguage(languageId: string) { } = { english: getEnglishTranslator, 'pig-latin': getPigLatinTranslator, + pirate: getPirateTranslator, }; if (originalPageContents && languageId in TRANSLATOR_GETTERS) { @@ -280,7 +281,7 @@ async function getPigLatinTranslator(): Promise { return (text: string | null) => (text ? translator.translate(text) : text); } -// TODO: DONOTCOMMIT! -// eslint-disable-next-line -// @ts-ignore -window.changePageLanguage = changePageLanguage; +async function getPirateTranslator(): Promise { + const PirateSpeak = await import('../utils/pirate_speak'); + return (text: string | null) => (text ? PirateSpeak.translate(text) : text); +} diff --git a/src_js/conditional_plugins/utils/pirate_speak.ts b/src_js/conditional_plugins/utils/pirate_speak.ts new file mode 100644 index 00000000..c6990cc9 --- /dev/null +++ b/src_js/conditional_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; +} From 7c789501e882410a8ed5382106d8ed8a19f1c2f2 Mon Sep 17 00:00:00 2001 From: Sesh Sadasivam Date: Wed, 6 Jul 2022 11:13:57 -0400 Subject: [PATCH 09/14] Add upside-down 'language' --- .../plugins/april_fools_languages.plugin.tsx | 8 ++ .../conditional_plugins/utils/upside_down.ts | 89 +++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 src_js/conditional_plugins/utils/upside_down.ts diff --git a/src_js/conditional_plugins/plugins/april_fools_languages.plugin.tsx b/src_js/conditional_plugins/plugins/april_fools_languages.plugin.tsx index 0343bd6a..24e088f3 100644 --- a/src_js/conditional_plugins/plugins/april_fools_languages.plugin.tsx +++ b/src_js/conditional_plugins/plugins/april_fools_languages.plugin.tsx @@ -113,6 +113,7 @@ function toggleLanguagePopover() {
  • {getLanguageButton('english', 'English')}
  • {getLanguageButton('pig-latin', 'Pig Latin')}
  • {getLanguageButton('pirate', 'Pirate')}
  • +
  • {getLanguageButton('upside-down', 'Upside Down')}
  • @@ -212,6 +213,7 @@ async function changePageLanguage(languageId: string) { english: getEnglishTranslator, 'pig-latin': getPigLatinTranslator, pirate: getPirateTranslator, + 'upside-down': getUpsideDownTranslator, }; if (originalPageContents && languageId in TRANSLATOR_GETTERS) { @@ -285,3 +287,9 @@ async function getPirateTranslator(): Promise { const PirateSpeak = await import('../utils/pirate_speak'); return (text: string | null) => (text ? PirateSpeak.translate(text) : text); } + +async function getUpsideDownTranslator(): Promise { + const UpsideDown = await import('../utils/upside_down'); + return (text: string | null) => + text ? UpsideDown.flipStringUpsideDown(text) : text; +} diff --git a/src_js/conditional_plugins/utils/upside_down.ts b/src_js/conditional_plugins/utils/upside_down.ts new file mode 100644 index 00000000..06dff2e9 --- /dev/null +++ b/src_js/conditional_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; +} From 962ba170a7543fad0dcb85994bf001f5b449d8c6 Mon Sep 17 00:00:00 2001 From: Sesh Sadasivam Date: Wed, 6 Jul 2022 15:49:21 -0400 Subject: [PATCH 10/14] Do not 'translate' Mermaid input blocks --- .../plugins/april_fools_languages.plugin.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src_js/conditional_plugins/plugins/april_fools_languages.plugin.tsx b/src_js/conditional_plugins/plugins/april_fools_languages.plugin.tsx index 24e088f3..10e93aa8 100644 --- a/src_js/conditional_plugins/plugins/april_fools_languages.plugin.tsx +++ b/src_js/conditional_plugins/plugins/april_fools_languages.plugin.tsx @@ -247,6 +247,9 @@ function translate( } 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; From 8ee453659202e9673f30eb22cb8a9a85a2eaa7d7 Mon Sep 17 00:00:00 2001 From: Sesh Sadasivam Date: Sat, 9 Jul 2022 21:32:36 -0400 Subject: [PATCH 11/14] Refactor plugins to lazy-load plugin definitions --- .../conditional_plugins.ts | 48 +++++------ src_js/conditional_plugins/load_plugin.ts | 14 ++++ .../plugins/april_fools_languages.plugin.tsx | 47 ++--------- .../plugins/halloween.plugin.tsx | 37 +-------- .../{ => plugins}/utils/pirate_speak.ts | 0 .../{ => plugins}/utils/upside_down.ts | 0 .../conditional_plugins/should_load_plugin.ts | 62 ++++++++++++++ src_js/conditional_plugins/types.d.ts | 6 -- .../utils/__tests__/is_today_in_range.test.ts | 83 +++++++++++++++++++ .../utils/is_today_in_range.ts | 47 +++++++++++ 10 files changed, 236 insertions(+), 108 deletions(-) create mode 100644 src_js/conditional_plugins/load_plugin.ts rename src_js/conditional_plugins/{ => plugins}/utils/pirate_speak.ts (100%) rename src_js/conditional_plugins/{ => plugins}/utils/upside_down.ts (100%) create mode 100644 src_js/conditional_plugins/should_load_plugin.ts create mode 100644 src_js/conditional_plugins/utils/__tests__/is_today_in_range.test.ts create mode 100644 src_js/conditional_plugins/utils/is_today_in_range.ts diff --git a/src_js/conditional_plugins/conditional_plugins.ts b/src_js/conditional_plugins/conditional_plugins.ts index b6bd4373..0cd0e6c4 100644 --- a/src_js/conditional_plugins/conditional_plugins.ts +++ b/src_js/conditional_plugins/conditional_plugins.ts @@ -5,45 +5,35 @@ * want these jokes to affect the page load time and the spec-reading * experience.) * - * Plugins run based on conditions defined in each of their `shouldRun()` - * methods. They can also be force-enabled by inserting + * Plugins run based on conditions defined in the `shouldLoadPlugin()` method. + * They can also be force-enabled by inserting * `?enable_=1` in the URL. */ -import { initialize as initializeHalloweenPlugin } from './plugins/halloween.plugin'; -import { initialize as initializeAprilFoolsLanguagesPlugin } from './plugins/april_fools_languages.plugin'; - import type { ConditionalPluginInput } from './types.d'; +import { shouldLoadPlugin } from './should_load_plugin'; +import { loadPlugin } from './load_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 PLUGINS = [ - initializeHalloweenPlugin(), - initializeAprilFoolsLanguagesPlugin(), -] - .filter((pluginDefinition) => { - const forceEnableOption = pluginForceEnableOption(pluginDefinition.id); - if (forceEnableOption !== null) { - return forceEnableOption; - } - return pluginDefinition.shouldRun(); - }) - .map((pluginDefinition) => pluginDefinition.plugin); +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..6f2db15f --- /dev/null +++ b/src_js/conditional_plugins/load_plugin.ts @@ -0,0 +1,14 @@ +import { Plugin } from './types.d'; + +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 index 10e93aa8..60ee25fc 100644 --- a/src_js/conditional_plugins/plugins/april_fools_languages.plugin.tsx +++ b/src_js/conditional_plugins/plugins/april_fools_languages.plugin.tsx @@ -1,41 +1,12 @@ /** @jsx JSXDom.h */ import * as JSXDom from 'jsx-dom'; -import type { PluginDefinition } from '../types'; -import { printEnablingURLToConsole } from '../utils/print_enabling_url_to_console'; - -const PLUGIN_ID = 'april_fools_languages'; +import PigLatinizer from 'pig-latinizer'; +import { translate as pirateSpeakTranslate } from './utils/pirate_speak'; +import { flipStringUpsideDown } from './utils/upside_down'; let currentLanguage = 'english'; -export function initialize(): PluginDefinition { - return { - id: PLUGIN_ID, - plugin: AprilFoolsLanguagesPlugin, - shouldRun: () => { - const today = new Date(); - // Console message if we are *just* past the April Fools end-date. - // After April 3 until April 13. - if ( - today.getMonth() === 3 && - today.getDate() > 3 && - today.getDate() <= 13 - ) { - printEnablingURLToConsole( - PLUGIN_ID, - "🤫 Psst... It's well past halloween, but you can re-enable halloween mode by clicking this url:", - ); - } - - // Remember that months are 0-indexed in JS! - return ( - (today.getMonth() === 2 && today.getDate() >= 29) || // March 29 - (today.getMonth() === 3 && today.getDate() <= 3) // April 3 - ); - }, - }; -} - -async function AprilFoolsLanguagesPlugin(): Promise { +export default async function AprilFoolsLanguagesPlugin(): Promise { insertLanguageToggleIfNeeded(); insertDarkModeStylesIfNeeded(); storeOriginalPageContentsIfNeeded(); @@ -281,18 +252,14 @@ function getEnglishTranslator(): Promise { } async function getPigLatinTranslator(): Promise { - const PigLatinizer = await import('pig-latinizer'); - const translator = new PigLatinizer.default(); + const translator = new PigLatinizer(); return (text: string | null) => (text ? translator.translate(text) : text); } async function getPirateTranslator(): Promise { - const PirateSpeak = await import('../utils/pirate_speak'); - return (text: string | null) => (text ? PirateSpeak.translate(text) : text); + return (text: string | null) => (text ? pirateSpeakTranslate(text) : text); } async function getUpsideDownTranslator(): Promise { - const UpsideDown = await import('../utils/upside_down'); - return (text: string | null) => - text ? UpsideDown.flipStringUpsideDown(text) : text; + return (text: string | null) => (text ? flipStringUpsideDown(text) : text); } diff --git a/src_js/conditional_plugins/plugins/halloween.plugin.tsx b/src_js/conditional_plugins/plugins/halloween.plugin.tsx index 52a2ea88..d822db8c 100644 --- a/src_js/conditional_plugins/plugins/halloween.plugin.tsx +++ b/src_js/conditional_plugins/plugins/halloween.plugin.tsx @@ -1,39 +1,10 @@ /** @jsx JSXDom.h */ import * as JSXDom from 'jsx-dom'; -import type { ConditionalPluginInput, PluginDefinition } from '../types'; -import { printEnablingURLToConsole } from '../utils/print_enabling_url_to_console'; +import type { ConditionalPluginInput } from '../types'; -const PLUGIN_ID = 'halloween'; - -export function initialize(): PluginDefinition { - return { - id: PLUGIN_ID, - plugin: HalloweenPlugin, - shouldRun: () => { - const today = new Date(); - // Console message if we are *just* past the Halloween-mode end-date. - // After November 5 until November 15. - if ( - today.getMonth() === 10 && - today.getDate() > 5 && - today.getDate() <= 15 - ) { - printEnablingURLToConsole( - PLUGIN_ID, - "🤫 Psst... It's well past halloween, but you can re-enable halloween mode by clicking this url:", - ); - } - - // Remember that months are 0-indexed in JS! - return ( - (today.getMonth() === 9 && today.getDate() >= 25) || // October 25 - (today.getMonth() === 10 && today.getDate() <= 5) // November 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/utils/pirate_speak.ts b/src_js/conditional_plugins/plugins/utils/pirate_speak.ts similarity index 100% rename from src_js/conditional_plugins/utils/pirate_speak.ts rename to src_js/conditional_plugins/plugins/utils/pirate_speak.ts diff --git a/src_js/conditional_plugins/utils/upside_down.ts b/src_js/conditional_plugins/plugins/utils/upside_down.ts similarity index 100% rename from src_js/conditional_plugins/utils/upside_down.ts rename to src_js/conditional_plugins/plugins/utils/upside_down.ts 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..8af14e13 --- /dev/null +++ b/src_js/conditional_plugins/should_load_plugin.ts @@ -0,0 +1,62 @@ +import { isTodayInRange, Month } from './utils/is_today_in_range'; +import { printEnablingURLToConsole } from './utils/print_enabling_url_to_console'; + +export function shouldLoadPlugin(pluginId: string): boolean { + 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; +} From d6d6a7fc20c2763e0669c44130e911b93d1fd573 Mon Sep 17 00:00:00 2001 From: Sesh Sadasivam Date: Sat, 9 Jul 2022 23:15:50 -0400 Subject: [PATCH 12/14] Add comment explaining bundle-splitting --- src_js/conditional_plugins/load_plugin.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src_js/conditional_plugins/load_plugin.ts b/src_js/conditional_plugins/load_plugin.ts index 6f2db15f..84e43134 100644 --- a/src_js/conditional_plugins/load_plugin.ts +++ b/src_js/conditional_plugins/load_plugin.ts @@ -1,5 +1,14 @@ 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) { From 6cb3f4d4e81e8c30786252b1109aaa600e518a37 Mon Sep 17 00:00:00 2001 From: Sesh Sadasivam Date: Sun, 10 Jul 2022 12:42:48 -0400 Subject: [PATCH 13/14] Add site-wide config option to disable all jokes --- _layouts/spec.html | 3 ++- docs/USAGE_ADVANCED.md | 9 +++++++++ src_js/conditional_plugins/should_load_plugin.ts | 4 ++++ src_js/global.d.ts | 1 + 4 files changed, 16 insertions(+), 1 deletion(-) diff --git a/_layouts/spec.html b/_layouts/spec.html index 4f2c074d..b826290d 100644 --- a/_layouts/spec.html +++ b/_layouts/spec.html @@ -108,7 +108,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/docs/USAGE_ADVANCED.md b/docs/USAGE_ADVANCED.md index 85a7a87d..686946e7 100644 --- a/docs/USAGE_ADVANCED.md +++ b/docs/USAGE_ADVANCED.md @@ -36,6 +36,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}](#sitemap-boolean--label-string) + - [`disableJokes`: Boolean](#disablejokes-boolean) - [Pinning to a specific version](#pinning-to-a-specific-version) - [Using without Jekyll](#using-without-jekyll) @@ -399,6 +400,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/src_js/conditional_plugins/should_load_plugin.ts b/src_js/conditional_plugins/should_load_plugin.ts index 8af14e13..afe76976 100644 --- a/src_js/conditional_plugins/should_load_plugin.ts +++ b/src_js/conditional_plugins/should_load_plugin.ts @@ -2,6 +2,10 @@ 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; diff --git a/src_js/global.d.ts b/src_js/global.d.ts index 4cd489d6..93d03e88 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 From 25245abea917f0c8627bd99496eb8b1ea39308e9 Mon Sep 17 00:00:00 2001 From: Sesh Sadasivam Date: Sun, 10 Jul 2022 13:02:33 -0400 Subject: [PATCH 14/14] Close April Fools language toggle on click outside --- .../plugins/april_fools_languages.plugin.tsx | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/src_js/conditional_plugins/plugins/april_fools_languages.plugin.tsx b/src_js/conditional_plugins/plugins/april_fools_languages.plugin.tsx index 60ee25fc..015e55e2 100644 --- a/src_js/conditional_plugins/plugins/april_fools_languages.plugin.tsx +++ b/src_js/conditional_plugins/plugins/april_fools_languages.plugin.tsx @@ -10,6 +10,7 @@ export default async function AprilFoolsLanguagesPlugin(): Promise { insertLanguageToggleIfNeeded(); insertDarkModeStylesIfNeeded(); storeOriginalPageContentsIfNeeded(); + registerWindowEventListenerOnce(); } /////////// @@ -49,11 +50,11 @@ function insertLanguageToggleIfNeeded() { } const languagePopoverId = 'primer-spec-april-fools-language-popover'; -function toggleLanguagePopover() { +function toggleLanguagePopover(opts?: { doNotOpen: boolean }) { const existingPopover = document.querySelector(`#${languagePopoverId}`); if (existingPopover) { existingPopover.remove(); - } else { + } else if (opts == null || !opts.doNotOpen) { const topbar = document.querySelector('header.primer-spec-topbar'); topbar?.appendChild(
    { + 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 // /////////////////////////////