diff --git a/client/config/jest/rawTransform.js b/client/config/jest/rawTransform.js new file mode 100644 index 000000000000..578c76037321 --- /dev/null +++ b/client/config/jest/rawTransform.js @@ -0,0 +1,14 @@ +// This is a custom Jest transformer turning raw imports into empty strings. +// http://facebook.github.io/jest/docs/en/webpack.html + +const transform = { + process() { + return { code: "module.exports = '';" }; + }, + getCacheKey() { + // The output is always the same. + return "rawTransform"; + }, +}; + +export default transform; diff --git a/client/package.json b/client/package.json index 9b4021e45a05..b7dd19a3e7c4 100644 --- a/client/package.json +++ b/client/package.json @@ -54,6 +54,7 @@ "node" ], "moduleNameMapper": { + "\\?raw$": "/config/jest/rawTransform.js", "\\.s?css(\\?css)?$": "/config/jest/cssTransform.js" }, "modulePaths": [], diff --git a/client/src/document/index.tsx b/client/src/document/index.tsx index b517d7fdbbe6..1a395b7abb2e 100644 --- a/client/src/document/index.tsx +++ b/client/src/document/index.tsx @@ -34,7 +34,7 @@ import "./index.scss"; // code could come with its own styling rather than it having to be part of the // main bundle all the time. import "./interactive-examples.scss"; -import "../lit/interactive-example.global.scss"; +import "../lit/interactive-example/global.scss"; import { DocumentSurvey } from "../ui/molecules/document-survey"; import { useIncrementFrequentlyViewed } from "../plus/collections/frequently-viewed"; import { useInteractiveExamplesActionHandler as useInteractiveExamplesTelemetry } from "../telemetry/interactive-examples"; @@ -63,7 +63,7 @@ export class HTTPError extends Error { export function Document(props /* TODO: define a TS interface for this */) { React.useEffect(() => { - import("../lit/interactive-example.js"); + import("../lit/interactive-example/index.js"); }, []); const gleanClick = useGleanClick(); diff --git a/client/src/lit/globals.d.ts b/client/src/lit/globals.d.ts index bd8d8892343c..7d27afa8196a 100644 --- a/client/src/lit/globals.d.ts +++ b/client/src/lit/globals.d.ts @@ -1,5 +1,6 @@ import { MDNImageHistory, TeamMember } from "./about"; import { InteractiveExample } from "./interactive-example"; +import { TabPanel, Tab, TabWrapper } from "./interactive-example/tabs"; import { ContributorList } from "./community/contributor-list"; import { ScrimInline } from "./curriculum/scrim-inline"; import { PlayConsole } from "./play/console"; @@ -12,6 +13,9 @@ declare global { "mdn-image-history": MDNImageHistory; "team-member": TeamMember; "interactive-example": InteractiveExample; + "ix-tab": Tab; + "ix-tab-panel": TabPanel; + "ix-tab-wrapper": TabWrapper; "contributor-list": ContributorList; "scrim-inline": ScrimInline; "play-console": PlayConsole; diff --git a/client/src/lit/interactive-example.global.scss b/client/src/lit/interactive-example.global.scss deleted file mode 100644 index 7390f94d818b..000000000000 --- a/client/src/lit/interactive-example.global.scss +++ /dev/null @@ -1,13 +0,0 @@ -interactive-example { - display: block; - height: 444px; - margin: 1rem 0; - - &[height="shorter"] { - height: 364px; - } - - &[height="taller"] { - height: 654px; - } -} diff --git a/client/src/lit/interactive-example.js b/client/src/lit/interactive-example.js deleted file mode 100644 index f02c0aae3e73..000000000000 --- a/client/src/lit/interactive-example.js +++ /dev/null @@ -1,113 +0,0 @@ -import { html, LitElement } from "lit"; -import { ref, createRef } from "lit/directives/ref.js"; -import "./play/editor.js"; -import "./play/controller.js"; -import "./play/console.js"; -import "./play/runner.js"; -import { GleanMixin } from "./glean-mixin.js"; - -import styles from "./interactive-example.scss?css" with { type: "css" }; - -/** - * @import { Ref } from 'lit/directives/ref.js'; - * @import { PlayController } from "./play/controller.js"; - */ - -export class InteractiveExample extends GleanMixin(LitElement) { - static properties = { - name: { type: String }, - }; - - static styles = styles; - - constructor() { - super(); - this.name = ""; - } - - /** @type {Ref} */ - _controller = createRef(); - - _run() { - this._controller.value?.run(); - } - - _reset() { - this._controller.value?.reset(); - } - - _initialCode() { - const examples = this.closest("section")?.querySelectorAll( - ".code-example pre[class*=interactive-example]" - ); - return Array.from(examples || []).reduce((acc, pre) => { - const language = pre.classList[1]; - return language && pre.textContent - ? { - ...acc, - [language]: acc[language] - ? `${acc[language]}\n${pre.textContent}` - : pre.textContent, - } - : acc; - }, /** @type {Object} */ ({})); - } - - /** @param {Event} ev */ - _telemetryHandler(ev) { - let action = ev.type; - if ( - ev.type === "click" && - ev.target instanceof HTMLElement && - ev.target.id - ) { - action = `click@${ev.target.id}`; - } - this._gleanClick(`interactive-examples-lit: ${action}`); - } - - connectedCallback() { - super.connectedCallback(); - this._telemetryHandler = this._telemetryHandler.bind(this); - this.renderRoot.addEventListener("focus", this._telemetryHandler); - this.renderRoot.addEventListener("copy", this._telemetryHandler); - this.renderRoot.addEventListener("cut", this._telemetryHandler); - this.renderRoot.addEventListener("paste", this._telemetryHandler); - this.renderRoot.addEventListener("click", this._telemetryHandler); - } - - render() { - return html` - -
-

${this.name}

- -
- - -
- - -
-
- `; - } - - firstUpdated() { - const code = this._initialCode(); - if (this._controller.value) { - this._controller.value.code = code; - } - } - - disconnectedCallback() { - super.disconnectedCallback(); - this.renderRoot.removeEventListener("focus", this._telemetryHandler); - this.renderRoot.removeEventListener("copy", this._telemetryHandler); - this.renderRoot.removeEventListener("cut", this._telemetryHandler); - this.renderRoot.removeEventListener("paste", this._telemetryHandler); - this.renderRoot.removeEventListener("click", this._telemetryHandler); - } -} - -customElements.define("interactive-example", InteractiveExample); diff --git a/client/src/lit/interactive-example.scss b/client/src/lit/interactive-example.scss deleted file mode 100644 index 2aeedcfc1e44..000000000000 --- a/client/src/lit/interactive-example.scss +++ /dev/null @@ -1,73 +0,0 @@ -@use "../ui/vars" as *; -@use "../ui/atoms/button/mixins" as button; - -h4 { - border: 1px solid var(--border-secondary); - border-top-left-radius: var(--elem-radius); - border-top-right-radius: var(--elem-radius); - font-size: 1rem; - font-weight: normal; - grid-area: header; - line-height: 1.1876; - margin: 0; - padding: 0.5rem 1rem; -} - -play-editor { - border: 1px solid var(--border-secondary); - border-bottom-left-radius: var(--elem-radius); - border-bottom-right-radius: var(--elem-radius); - border-top: none; - grid-area: editor; - margin-top: -0.5rem; - overflow: auto; -} - -.buttons { - display: flex; - flex-direction: column; - gap: 0.5rem; - grid-area: buttons; - - button { - @include button.secondary; - } -} - -play-console { - border: 1px solid var(--border-secondary); - border-radius: var(--elem-radius); - grid-area: console; -} - -.template-javascript { - align-content: start; - display: grid; - gap: 0.5rem; - grid-template-areas: - "header header" - "editor editor" - "buttons console"; - grid-template-columns: max-content 1fr; - grid-template-rows: max-content 1fr 8rem; - height: 100%; - - play-runner { - display: none; - } - - @media (max-width: $screen-sm) { - grid-template-areas: - "header" - "editor" - "buttons" - "console"; - grid-template-columns: 1fr; - grid-template-rows: max-content 1fr max-content 8rem; - - .buttons { - flex-direction: row; - justify-content: space-between; - } - } -} diff --git a/client/src/lit/interactive-example/example.css b/client/src/lit/interactive-example/example.css new file mode 100644 index 000000000000..bea64f84593c --- /dev/null +++ b/client/src/lit/interactive-example/example.css @@ -0,0 +1,71 @@ +@font-face { + font-family: "Inter"; + src: + url("/shared-assets/fonts/Inter.var.woff2") + format("woff2 supports variations"), + url("/shared-assets/fonts/Inter.var.woff2") format("woff2-variations"); + font-weight: 1 999; + font-stretch: 75% 100%; + font-style: oblique 0deg 20deg; + font-display: swap; +} + +/* fonts used by the examples rendered inside the shadow dom. Because + @font-face does not work in shadow dom: + http://robdodson.me/at-font-face-doesnt-work-in-shadow-dom/ */ +@font-face { + font-family: "Fira Sans"; + src: + local("FiraSans-Regular"), + url("/shared-assets/fonts/FiraSans-Regular.woff2") format("woff2"); +} + +@font-face { + font-family: "Fira Sans"; + font-weight: normal; + font-style: oblique; + src: + local("FiraSans-SemiBoldItalic"), + url("/shared-assets/fonts/FiraSans-SemiBoldItalic.woff2") format("woff2"); +} + +@font-face { + font-family: "Dancing Script"; + src: url("/shared-assets/fonts/dancing-script/dancing-script-regular.woff2") + format("woff2"); +} + +@font-face { + font-family: molot; + src: url("/shared-assets/fonts/molot.woff2") format("woff2"); +} + +@font-face { + font-family: rapscallion; + src: url("/shared-assets/fonts/rapscall.woff2") format("woff2"); +} + +body { + background-color: #fff; + font: + 400 1rem/1.1876 Inter, + BlinkMacSystemFont, + "Segoe UI", + "Roboto", + "Oxygen", + "Ubuntu", + "Cantarell", + "Fira Sans", + "Droid Sans", + "Helvetica Neue", + sans-sans; + color: #15141aff; + font-size: 0.9rem; + line-height: 1.5; + padding: 2rem 1rem 1rem; + min-width: min-content; +} + +body math { + font-size: 1.5rem; +} diff --git a/client/src/lit/interactive-example/example.js b/client/src/lit/interactive-example/example.js new file mode 100644 index 000000000000..9cabc6b63ecc --- /dev/null +++ b/client/src/lit/interactive-example/example.js @@ -0,0 +1,11 @@ +window.addEventListener("click", (event) => { + // open links in parent frame if they have no `_target` set + const target = event.target; + if (target instanceof HTMLAnchorElement) { + const hrefAttr = target.getAttribute("href"); + const targetAttr = target.getAttribute("target"); + if (hrefAttr && !hrefAttr.startsWith("#") && !targetAttr) { + target.target = "_parent"; + } + } +}); diff --git a/client/src/lit/interactive-example/global.scss b/client/src/lit/interactive-example/global.scss new file mode 100644 index 000000000000..b033a4bb6cac --- /dev/null +++ b/client/src/lit/interactive-example/global.scss @@ -0,0 +1,52 @@ +@use "../../ui/vars" as *; + +interactive-example { + display: block; + height: 444px; + margin: 1rem 0; + + &[height="shorter"] { + height: 364px; + } + + &[height="taller"] { + height: 654px; + } + + &[height="tabbed-shorter"] { + height: 351px; + margin-top: 1.5rem; + } + + &[height="tabbed-standard"] { + height: 421px; + } + + &[height="tabbed-taller"] { + height: 631px; + } + + @media (max-width: $screen-lg) { + height: 513px; + + &[height="shorter"] { + height: 433px; + } + + &[height="taller"] { + height: 725px; + } + + &[height="tabbed-shorter"] { + height: 487px; + } + + &[height="tabbed-standard"] { + height: 548px; + } + + &[height="tabbed-taller"] { + height: 774px; + } + } +} diff --git a/client/src/lit/interactive-example/index.js b/client/src/lit/interactive-example/index.js new file mode 100644 index 000000000000..5e0f001be3c5 --- /dev/null +++ b/client/src/lit/interactive-example/index.js @@ -0,0 +1,189 @@ +import { html, LitElement } from "lit"; +import { ref, createRef } from "lit/directives/ref.js"; +import { decode } from "he"; + +import "../play/editor.js"; +import "../play/controller.js"; +import "../play/console.js"; +import "../play/runner.js"; +import { GleanMixin } from "../glean-mixin.js"; +import "./tabs.js"; + +import styles from "./index.scss?css" with { type: "css" }; + +import exampleJs from "./example.js?raw"; +import exampleStyle from "./example.css?raw"; + +/** + * @import { Ref } from 'lit/directives/ref.js'; + * @import { PlayController } from "../play/controller.js"; + * @import { PlayRunner } from "../play/runner.js"; + */ + +const LANGUAGE_CLASSES = ["html", "js", "css"]; +const GLEAN_EVENT_TYPES = ["focus", "copy", "cut", "paste", "click"]; + +export class InteractiveExample extends GleanMixin(LitElement) { + static properties = { + name: { type: String }, + }; + + static styles = styles; + + constructor() { + super(); + this.name = ""; + /** @type {string[]} */ + this._languages = []; + /** @type {Record} */ + this._code = {}; + } + + /** @type {Ref} */ + _controller = createRef(); + /** @type {Ref} */ + _runner = createRef(); + + _run() { + this._controller.value?.run(); + } + + _reset() { + this._controller.value?.reset(); + } + + _initialCode() { + const exampleNodes = this.closest("section")?.querySelectorAll( + ".code-example pre[class*=interactive-example]" + ); + const code = Array.from(exampleNodes || []).reduce((acc, pre) => { + const language = Array.from(pre.classList).find((c) => + LANGUAGE_CLASSES.includes(c) + ); + return language && pre.textContent + ? { + ...acc, + [language]: acc[language] + ? `${acc[language]}\n${pre.textContent}` + : pre.textContent, + } + : acc; + }, /** @type {Object} */ ({})); + this._languages = Object.keys(code); + this._template = + this._languages.length === 1 && this._languages[0] === "js" + ? "javascript" + : "tabbed"; + if (this._template === "tabbed") { + code["js-hidden"] = exampleJs; + code["css-hidden"] = exampleStyle; + } + return code; + } + + /** @param {string} lang */ + _langName(lang) { + switch (lang) { + case "html": + return "HTML"; + case "css": + return "CSS"; + case "js": + return "JavaScript"; + default: + return lang; + } + } + + /** @param {Event} ev */ + _telemetryHandler(ev) { + let action = ev.type; + if ( + ev.type === "click" && + ev.target instanceof HTMLElement && + ev.target.id + ) { + action = `click@${ev.target.id}`; + } + this._gleanClick(`interactive-examples-lit: ${action}`); + } + + connectedCallback() { + super.connectedCallback(); + this._telemetryHandler = this._telemetryHandler.bind(this); + GLEAN_EVENT_TYPES.forEach((type) => { + this.renderRoot.addEventListener(type, this._telemetryHandler); + }); + this._code = this._initialCode(); + } + + _renderJavascript() { + return html` + +
+
+

${decode(this.name)}

+
+ +
+ + +
+ + +
+
+ `; + } + + _renderTabbed() { + return html` + +
+
+

${decode(this.name)}

+ +
+ + ${this._languages.map( + (lang) => html` + ${this._langName(lang)} + + + + ` + )} + +
+

Output

+ +
+
+
+ `; + } + + render() { + return this._template === "javascript" + ? this._renderJavascript() + : this._renderTabbed(); + } + + firstUpdated() { + if (this._controller.value) { + this._controller.value.code = this._code; + } + } + + disconnectedCallback() { + super.disconnectedCallback(); + GLEAN_EVENT_TYPES.forEach((type) => { + this.renderRoot.removeEventListener(type, this._telemetryHandler); + }); + } +} + +customElements.define("interactive-example", InteractiveExample); diff --git a/client/src/lit/interactive-example/index.scss b/client/src/lit/interactive-example/index.scss new file mode 100644 index 000000000000..2c011467ecb7 --- /dev/null +++ b/client/src/lit/interactive-example/index.scss @@ -0,0 +1,168 @@ +@use "../../ui/vars" as *; +@use "../../ui/atoms/button/mixins" as button; + +header { + align-items: center; + border-bottom: 1px solid var(--border-secondary); + border-top-left-radius: var(--elem-radius); + border-top-right-radius: var(--elem-radius); + display: flex; + grid-area: header; + justify-content: space-between; + padding: 0.5rem 1rem; +} + +h4 { + font-size: 1rem; + font-weight: normal; + line-height: 1.1876; + margin: 0; +} + +play-editor { + grid-area: editor; + height: 100%; + overflow: auto; +} + +.buttons { + display: flex; + flex-direction: column; + gap: 0.5rem; + grid-area: buttons; + + button { + @include button.secondary; + } +} + +play-console { + border: 1px solid var(--border-secondary); + border-radius: var(--elem-radius); + grid-area: console; +} + +tab-wrapper { + grid-area: tabs; +} + +// ------------------- +// JavaScript examples +// ------------------- + +.template-javascript { + align-content: start; + display: grid; + gap: 0.5rem; + grid-template-areas: + "header header" + "editor editor" + "buttons console"; + grid-template-columns: max-content 1fr; + grid-template-rows: max-content 1fr 8rem; + height: 100%; + + header { + border: 1px solid var(--border-secondary); + } + + play-runner { + display: none; + grid-area: runner; + } + + play-editor { + border: 1px solid var(--border-secondary); + border-bottom-left-radius: var(--elem-radius); + border-bottom-right-radius: var(--elem-radius); + border-top: 0; + margin-top: -0.5rem; + } + + @media (max-width: $screen-sm) { + grid-template-areas: + "header" + "editor" + "buttons" + "console"; + grid-template-columns: 1fr; + grid-template-rows: max-content 1fr max-content 8rem; + + .buttons { + flex-direction: row; + justify-content: space-between; + } + } +} + +// --------------- +// Tabbed examples +// --------------- + +.template-tabbed { + --tabbed-font-heading: 600 0.625rem/1.2 var(--font-heading); + border: 1px solid var(--border-secondary); + border-radius: var(--elem-radius); + display: grid; + grid-template-areas: + "header header" + "tabs runner"; + grid-template-columns: 6fr 4fr; + grid-template-rows: max-content 1fr; + height: 100%; + overflow: hidden; + + #reset { + background-color: transparent; + border: 0; + border-radius: var(--elem-radius); + color: var(--text-primary); + cursor: pointer; + font: var(--tabbed-font-heading); + height: 2rem; + letter-spacing: 1.5px; + margin: 0; + max-width: 100px; + padding: 0.7em 0.9em; + text-transform: uppercase; + + &:hover { + background-color: var(--button-secondary-hover); + } + } + + .output-wrapper { + border-left: 1px solid var(--border-secondary); + grid-area: runner; + overflow: hidden; + position: relative; + + h4 { + background-color: var(--background-secondary); + border-bottom-left-radius: var(--elem-radius); + color: var(--text-secondary); + font: var(--tabbed-font-heading); + margin: 0; + padding: 0.5rem 1.6rem; + position: absolute; + right: 0; + text-transform: uppercase; + top: 0; + z-index: 2; + } + } + + @media (max-width: $screen-lg) { + grid-template-areas: + "header" + "tabs" + "runner"; + grid-template-columns: 1fr; + grid-template-rows: max-content 1fr 1fr; + + .output-wrapper { + border-left: 0; + border-top: 1px solid var(--border-secondary); + } + } +} diff --git a/client/src/lit/interactive-example/tabs.js b/client/src/lit/interactive-example/tabs.js new file mode 100644 index 000000000000..2253a3c4f61a --- /dev/null +++ b/client/src/lit/interactive-example/tabs.js @@ -0,0 +1,167 @@ +import { html, LitElement } from "lit"; + +import wrapperStyles from "./tabs.wrapper.scss?css" with { type: "css" }; +import tabStyles from "./tabs.tab.scss?css" with { type: "css" }; +import panelStyles from "./tabs.panel.scss?css" with { type: "css" }; + +/** + * @typedef {"first" | "prev" | "active" | "next" | "last"} Position + */ + +export class TabWrapper extends LitElement { + static styles = wrapperStyles; + + /** @param {Position} position */ + _getTab(position) { + const tabs = Array.from(this.querySelectorAll("ix-tab")); + if (position === "first") { + return tabs[0]; + } + if (position === "last") { + return tabs.at(-1); + } + const active = tabs.findIndex((tab) => tab.isActive); + if (position === "active") { + return tabs[active]; + } + if (position === "prev") { + return tabs.at((active - 1) % tabs.length); + } + if (position === "next") { + return tabs.at((active + 1) % tabs.length); + } + return undefined; + } + + /**@param {Tab | undefined} tab */ + _setTabActive(tab, focus = false) { + if (!tab) { + return; + } + this._getTab("active")?.unsetActive(); + tab.setActive(); + if (focus) { + tab.focus(); + } + } + + /** @param {MouseEvent} event */ + _tablistClick({ target }) { + if (target instanceof HTMLElement) { + const tab = target.closest("ix-tab") || undefined; + this._setTabActive(tab); + } + } + + /** @param {KeyboardEvent} event */ + _tablistKeyDown(event) { + /** @type {Position | undefined} */ + let position; + switch (event.key) { + case "ArrowRight": + case "ArrowDown": + position = "next"; + break; + case "ArrowLeft": + case "ArrowUp": + position = "prev"; + break; + case "Home": + position = "first"; + break; + case "End": + position = "last"; + break; + default: + return; + } + event.preventDefault(); + this._setTabActive(this._getTab(position), true); + } + + render() { + return html` +
+ +
+ + `; + } + + firstUpdated() { + this.querySelector("ix-tab")?.setActive(); + } +} + +customElements.define("ix-tab-wrapper", TabWrapper); + +export class Tab extends LitElement { + static styles = tabStyles; + + connectedCallback() { + super.connectedCallback(); + this.setAttribute("slot", "tablist"); + this.setAttribute("role", "tab"); + this.unsetActive(); + const panel = this.nextElementSibling; + if (panel instanceof TabPanel) { + this.panel = panel; + if (panel.id) { + this.setAttribute("aria-controls", panel.id); + } + if (this.id) { + panel.setAttribute("aria-labelledby", this.id); + } + } + } + + setActive() { + this.setAttribute("tabindex", "0"); + this.setAttribute("aria-selected", "true"); + this.panel?.setActive(); + } + + unsetActive() { + this.setAttribute("tabindex", "-1"); + this.setAttribute("aria-selected", "false"); + this.panel?.unsetActive(); + } + + get isActive() { + return this.getAttribute("aria-selected") === "true"; + } + + render() { + return html``; + } +} + +customElements.define("ix-tab", Tab); + +export class TabPanel extends LitElement { + static styles = panelStyles; + + connectedCallback() { + super.connectedCallback(); + this.setAttribute("tabindex", "0"); + this.setAttribute("role", "tabpanel"); + } + + setActive() { + this.setAttribute("slot", "active-panel"); + } + + unsetActive() { + this.removeAttribute("slot"); + } + + render() { + return html``; + } +} + +customElements.define("ix-tab-panel", TabPanel); diff --git a/client/src/lit/interactive-example/tabs.panel.scss b/client/src/lit/interactive-example/tabs.panel.scss new file mode 100644 index 000000000000..b873a3917b3b --- /dev/null +++ b/client/src/lit/interactive-example/tabs.panel.scss @@ -0,0 +1,4 @@ +:host { + flex: 1; + min-height: 0; +} diff --git a/client/src/lit/interactive-example/tabs.tab.scss b/client/src/lit/interactive-example/tabs.tab.scss new file mode 100644 index 000000000000..ebdfe1ce6825 --- /dev/null +++ b/client/src/lit/interactive-example/tabs.tab.scss @@ -0,0 +1,25 @@ +:host { + background-color: transparent; + border: 0 none; + border-bottom: 3px solid transparent; + border-top: 3px solid transparent; + color: var(--text-secondary); + cursor: pointer; + font: var(--type-emphasis-m); + padding: 0.5em 30px; + transition: + color 0.2s, + background-color 0.2s; +} + +:host(:hover), +:host(:focus) { + background-color: var(--ix-tab-background-active); + color: var(--text-primary); +} + +:host([aria-selected="true"]) { + background-color: var(--ix-tab-background-active); + border-bottom-color: var(--accent-primary); + color: var(--accent-primary); +} diff --git a/client/src/lit/interactive-example/tabs.wrapper.scss b/client/src/lit/interactive-example/tabs.wrapper.scss new file mode 100644 index 000000000000..9d6a72cc8df9 --- /dev/null +++ b/client/src/lit/interactive-example/tabs.wrapper.scss @@ -0,0 +1,14 @@ +:host { + display: flex; + flex-direction: column; + overflow: hidden; +} + +#tablist { + background: var(--background-secondary); + border-bottom: 1px solid var(--border-secondary); + display: flex; + flex-shrink: 0; + gap: 0.5rem; + overflow-x: auto; +} diff --git a/client/src/lit/play/controller.js b/client/src/lit/play/controller.js index 44413e7f9688..3ace9ac5a0d1 100644 --- a/client/src/lit/play/controller.js +++ b/client/src/lit/play/controller.js @@ -22,16 +22,28 @@ export class PlayController extends LitElement { this.runOnStart = false; this.runOnChange = false; this.srcPrefix = ""; + /** @type {Record} */ + this._code = {}; + /** @type {Record} */ + this._hiddenCode = {}; } /** @param {Record} code */ set code(code) { + this._code = Object.fromEntries( + Object.entries(code).filter(([language]) => !language.endsWith("-hidden")) + ); + this._hiddenCode = Object.fromEntries( + Object.entries(code) + .filter(([language]) => language.endsWith("-hidden")) + .map(([language, value]) => [language.replace(/-hidden$/, ""), value]) + ); if (!this.initialCode) { this.initialCode = code; } const editors = this.querySelectorAll("play-editor"); editors.forEach((editor) => { - const language = this._langAlias(editor.language); + const language = editor.language; if (language) { const value = code[language]; if (value !== undefined) { @@ -45,15 +57,17 @@ export class PlayController extends LitElement { } get code() { - /** @type {Record} */ - const code = { ...this.initialCode }; + const code = { ...this._code }; const editors = this.querySelectorAll("play-editor"); editors.forEach((editor) => { - const language = this._langAlias(editor.language); + const language = editor.language; if (language) { code[language] = editor.value; } }); + for (const [language, value] of Object.entries(this._hiddenCode)) { + code[language] = code[language] ? `${value}\n${code[language]}` : value; + } return code; } @@ -91,18 +105,6 @@ export class PlayController extends LitElement { } } - /** - * @param {string} lang - */ - _langAlias(lang) { - switch (lang) { - case "javascript": - return "js"; - default: - return lang; - } - } - _onEditorUpdate() { if (this.runOnChange) { this.run(); diff --git a/client/src/lit/play/editor.js b/client/src/lit/play/editor.js index c7192d76d17f..fc1b3c5f64e4 100644 --- a/client/src/lit/play/editor.js +++ b/client/src/lit/play/editor.js @@ -68,7 +68,7 @@ export class PlayEditor extends LitElement { _extensions() { const language = (() => { switch (this.language) { - case "javascript": + case "js": return [langJS()]; case "html": return [langHTML()]; @@ -114,7 +114,7 @@ export class PlayEditor extends LitElement { const prettier = await import("prettier/standalone"); const config = (() => { switch (this.language) { - case "javascript": + case "js": return { parser: "babel", plugins: [ diff --git a/client/src/lit/play/runner.js b/client/src/lit/play/runner.js index b869466874b2..db77235ae2e2 100644 --- a/client/src/lit/play/runner.js +++ b/client/src/lit/play/runner.js @@ -14,6 +14,7 @@ export class PlayRunner extends LitElement { static properties = { code: { type: Object }, srcPrefix: { type: String, attribute: "src-prefix" }, + sandbox: { type: String }, }; static styles = styles; @@ -24,6 +25,7 @@ export class PlayRunner extends LitElement { this.code = undefined; /** @type {string | undefined} */ this.srcPrefix = undefined; + this.sandbox = ""; this._subdomain = crypto.randomUUID(); } @@ -81,7 +83,7 @@ export class PlayRunner extends LitElement { src="${window.location .protocol}//${PLAYGROUND_BASE_HOST}/runner.html?blank" title="runner" - sandbox="allow-scripts allow-same-origin allow-forms" + sandbox="allow-scripts allow-same-origin allow-forms ${this.sandbox}" > `; } diff --git a/client/src/playground/index.tsx b/client/src/playground/index.tsx index 869faa86dce1..017e067ca55e 100644 --- a/client/src/playground/index.tsx +++ b/client/src/playground/index.tsx @@ -315,7 +315,7 @@ export default function Playground() {
JAVASCRIPT
diff --git a/client/src/setupProxy.js b/client/src/setupProxy.js index 6320de21ac2b..acc8617b2b06 100644 --- a/client/src/setupProxy.js +++ b/client/src/setupProxy.js @@ -30,6 +30,8 @@ function config(app) { }); // Proxy play runner app.use("**/runner.html", runnerProxy); + // Proxy shared assets + app.use("/shared-assets", proxy); } export default config; diff --git a/client/src/ui/base/_themes.scss b/client/src/ui/base/_themes.scss index a5245cd710c6..f1f82cdb1cf7 100644 --- a/client/src/ui/base/_themes.scss +++ b/client/src/ui/base/_themes.scss @@ -96,6 +96,8 @@ --code-background-inline: #{$mdn-theme-light-code-background-inline}; --code-background-block: #{$mdn-theme-light-code-background-block}; + --ix-tab-background-active: #fff; + --notecard-link-color: #{$mdn-color-neutral-80}; --scrollbar-bg: transparent; @@ -412,6 +414,8 @@ --code-background-inline: #{$mdn-theme-dark-code-background-inline}; --code-background-block: #{$mdn-theme-dark-code-background-block}; + --ix-tab-background-active: #4e4e4e; + --notecard-link-color: #{$mdn-color-neutral-10}; --scrollbar-bg: transparent; diff --git a/cloud-function/src/app.ts b/cloud-function/src/app.ts index c9cd727af7c5..18966a70b6a1 100644 --- a/cloud-function/src/app.ts +++ b/cloud-function/src/app.ts @@ -25,6 +25,7 @@ import { stripForwardedHostHeaders } from "./middlewares/stripForwardedHostHeade import { proxyPong } from "./handlers/proxy-pong.js"; import { handleRunner } from "./internal/play/index.js"; import { proxyContentAssets } from "./handlers/proxy-content-assets.js"; +import { proxySharedAssets } from "./handlers/proxy-shared-assets.js"; const router = Router(); router.use(cookieParser()); @@ -52,6 +53,8 @@ router.get( requireOrigin(Origin.play), handleRunner ); +// Interactive example assets +router.get("/shared-assets/*", requireOrigin(Origin.play), proxySharedAssets); // Assets. router.get( ["/assets/*", "/sitemaps/*", "/static/*", "/[^/]+.[^/]+"], diff --git a/cloud-function/src/env.ts b/cloud-function/src/env.ts index 4f88750106be..680d88a692ce 100644 --- a/cloud-function/src/env.ts +++ b/cloud-function/src/env.ts @@ -22,6 +22,7 @@ export enum Source { content = "content", liveSamples = "liveSamples", api = "rumba", + sharedAssets = "sharedAssets", } export const ORIGIN_MAIN: string = process.env["ORIGIN_MAIN"] || "localhost"; @@ -33,6 +34,8 @@ export const SOURCE_CONTENT: string = process.env["SOURCE_CONTENT"] || LOCAL_CONTENT; export const SOURCE_API: string = process.env["SOURCE_API"] || "https://developer.allizom.org/"; +export const SOURCE_SHARED_ASSETS: string = + process.env["SOURCE_SHARED_ASSETS"] || "https://mdn.github.io/shared-assets/"; export function getOriginFromRequest(req: Request): Origin { if ( @@ -59,6 +62,8 @@ export function sourceUri(source: Source): string { return SOURCE_CONTENT; case Source.api: return SOURCE_API; + case Source.sharedAssets: + return SOURCE_SHARED_ASSETS; default: return ""; } diff --git a/cloud-function/src/handlers/proxy-shared-assets.ts b/cloud-function/src/handlers/proxy-shared-assets.ts new file mode 100644 index 000000000000..0cb03e3f87a1 --- /dev/null +++ b/cloud-function/src/handlers/proxy-shared-assets.ts @@ -0,0 +1,37 @@ +import { + createProxyMiddleware, + fixRequestBody, + responseInterceptor, +} from "http-proxy-middleware"; + +import { Source, sourceUri } from "../env.js"; +import { PROXY_TIMEOUT } from "../constants.js"; + +const target = sourceUri(Source.sharedAssets); + +export const proxySharedAssets = createProxyMiddleware({ + target, + pathRewrite: { + "^/shared-assets/": "/", + }, + changeOrigin: true, + autoRewrite: true, + proxyTimeout: PROXY_TIMEOUT, + xfwd: true, + selfHandleResponse: true, + on: { + proxyReq: fixRequestBody, + proxyRes: responseInterceptor( + async (responseBuffer, _proxyRes, _req, res) => { + if (!res.headersSent) { + let cacheControl = "no-store, must-revalidate"; + if (200 <= res.statusCode && res.statusCode < 300) { + cacheControl = `public, max-age=${60 * 60 * 24 * 30}`; + } + res.setHeader("Cache-Control", cacheControl); + } + return responseBuffer; + } + ), + }, +}); diff --git a/package.json b/package.json index 3627809c297b..ea6d0c627f7f 100644 --- a/package.json +++ b/package.json @@ -116,6 +116,7 @@ "front-matter": "^4.0.2", "fs-extra": "^11.3.0", "got": "^13.0.0", + "he": "^1.2.0", "http-proxy-middleware": "^2.0.7", "image-size": "^1.2.0", "image-type": "^4.1.0", @@ -178,6 +179,7 @@ "@testing-library/react": "^15.0.7", "@types/async": "^3.2.24", "@types/cli-progress": "^3.11.6", + "@types/he": "^1.2.3", "@types/imagemin": "^9.0.1", "@types/jest": "^29.5.14", "@types/js-yaml": "^4.0.9", diff --git a/server/index.ts b/server/index.ts index 6782a0b550bc..aa37dd1d220d 100644 --- a/server/index.ts +++ b/server/index.ts @@ -372,6 +372,19 @@ app.get(["/*/runner.html", "/runner.html"], (req, res) => { handleRunner(req, res); }); +app.get( + "/shared-assets/*", + createProxyMiddleware({ + target: "https://mdn.github.io/shared-assets/", + pathRewrite: { + "^/shared-assets/": "/", + }, + changeOrigin: true, + autoRewrite: true, + xfwd: true, + }) +); + if (CURRICULUM_ROOT) { app.get( [ diff --git a/yarn.lock b/yarn.lock index a1c736184ba6..fdae3b5daaaf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3282,6 +3282,11 @@ dependencies: "@types/unist" "*" +"@types/he@^1.2.3": + version "1.2.3" + resolved "https://registry.yarnpkg.com/@types/he/-/he-1.2.3.tgz#c33ca3096f30cbd5d68d78211572de3f9adff75a" + integrity sha512-q67/qwlxblDzEDvzHhVkwc1gzVWxaNxeyHUBF4xElrvjL11O+Ytze+1fGpBHlr/H9myiBUaUXNnNPmBHxxfAcA== + "@types/html-minifier-terser@^6.0.0": version "6.1.0" resolved "https://registry.yarnpkg.com/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz#4fc33a00c1d0c16987b1a20cf92d20614c55ac35"