docs(themes): add Open in Studio button

Entire-Checkpoint: 10e4ab343956
This commit is contained in:
Jan De Dobbeleer
2026-07-30 19:44:41 +02:00
committed by Jan De Dobbeleer
parent 267f672b96
commit 4db87633d3
6 changed files with 241 additions and 23 deletions
+28 -1
View File
@@ -38,6 +38,12 @@ const CONFIG = {
THEME_EXTENSIONS: ['.omp.json', '.omp.toml', '.omp.yaml'],
SEGMENT_DATA_FILE: join(__dirname, 'segment_data.json'),
GITHUB_BASE_URL: 'https://github.com/JanDeDobbeleer/oh-my-posh/blob/main/themes',
// "Open in Studio" (ThemeGallery/index.js) fetches a theme's config from here at click time,
// rather than this exporter reading the file content into the manifest. That keeps the gallery
// reading the actual current file on every click - not a snapshot frozen at whenever the site
// was last built - and keeps generated/themes.json from growing by the size of every theme's
// source on top of its already-large inlined SVGs.
RAW_GITHUB_BASE_URL: 'https://raw.githubusercontent.com/JanDeDobbeleer/oh-my-posh/main/themes',
FONT_FAMILY: VICTOR_MONO.FONT_FAMILY,
CELL_WIDTH: VICTOR_MONO.CELL_WIDTH,
LINE_HEIGHT: VICTOR_MONO.LINE_HEIGHT,
@@ -72,6 +78,21 @@ function getThemeNameFromFile(fileName) {
return fileName.slice(0, secondLastDotIndex);
}
// The format string the studio's editor/parser (ConfigEditor/serialize.js) and CONFIG_FORMATS
// (Studio/config.js) key on - derived from the same extension isValidTheme already checked,
// so a theme file's own on-disk syntax is what "Open in Studio" loads it as.
function getThemeFormat(fileName) {
if (fileName.endsWith('.omp.json')) {
return 'json';
}
if (fileName.endsWith('.omp.toml')) {
return 'toml';
}
return 'yaml';
}
async function fetchJsonWithTimeout(url) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), TRENDING_FETCH_TIMEOUT_MS);
@@ -234,11 +255,16 @@ function buildPoshArgs(configPath, outputPath) {
// dangerouslySetInnerHTML, never parsed as MDX/JSX - MDX compiles a document's body as JSX, and
// the exporter's raw SVG attributes are not valid JSX, so the markup has to arrive as an opaque
// string rather than live in the .mdx source itself), the name to label it
// with, and the GitHub URL both the heading and the render link to.
// with, and the GitHub URL both the heading and the render link to. rawConfigUrl and format are
// what "Open in Studio" (ThemeGallery/index.js) needs to fetch and parse the theme's actual
// config at click time - see RAW_GITHUB_BASE_URL's own comment for why that's a URL, not the
// file content itself.
function buildManifestEntry(themeName, themeFile, svg) {
return {
name: themeName,
githubUrl: `${CONFIG.GITHUB_BASE_URL}/${themeFile}`,
rawConfigUrl: `${CONFIG.RAW_GITHUB_BASE_URL}/${themeFile}`,
format: getThemeFormat(themeFile),
svg,
};
}
@@ -388,6 +414,7 @@ export {
exportTheme,
isValidTheme,
getThemeNameFromFile,
getThemeFormat,
buildManifestEntry,
asyncPool,
main,
+5 -1
View File
@@ -37,10 +37,14 @@ const HERO_PATH = path.join(__dirname, '../../generated/hero.json');
const GENERATED_BY = 'This file is generated by `npm run themes` (which shells out to an oh-my-posh binary on ' +
'PATH), not written by hand. Run it, then re-run the website build.';
const THEME_FORMATS = ['json', 'toml', 'yaml'];
function isThemeEntry(entry) {
return entry
&& typeof entry.name === 'string'
&& typeof entry.githubUrl === 'string'
&& typeof entry.rawConfigUrl === 'string'
&& THEME_FORMATS.includes(entry.format)
&& typeof entry.svg === 'string';
}
@@ -64,7 +68,7 @@ function validateManifest() {
if (!isThemeEntry(entry)) {
throw new Error(
`oh-my-posh-themes plugin: malformed entry in ${MANIFEST_PATH}: ${JSON.stringify(entry)}. ` +
'Expected { name, githubUrl, svg }.',
'Expected { name, githubUrl, rawConfigUrl, format, svg }.',
);
}
@@ -1,11 +1,18 @@
// The sessionStorage handshake between a segment doc's editor and the studio (see Config.js's
// "Add to Studio" action and Studio/index.js's mount effect). Two keys:
// The sessionStorage handshake between a segment doc's/theme gallery's editor and the studio
// (see Config.js's "Add to Studio" action, ThemeGallery/index.js's "Open in Studio" action, and
// Studio/index.js's mount effect). Three keys:
//
// - APPEND_KEY is a one-shot mailbox: a segment doc writes { segment } to it right before
// navigating to /docs/studio, and the studio reads-then-deletes it on mount. sessionStorage
// (rather than a URL param) because a segment can be an arbitrarily large object with no
// length limit or encoding to worry about, and because it survives the navigation without
// showing up in the address bar.
// - LOAD_KEY is also a one-shot mailbox, but for a *whole config* rather than one segment:
// "Open in Studio" writes { format, text } to it after fetching a theme's actual config from
// GitHub, and the studio replaces its entire editor contents with it on mount - deliberately
// not folded into whatever the reader already had open (unlike APPEND_KEY), since picking a
// theme means "start from this", not "add this on top of what I was doing". Takes priority
// over both SESSION_KEY and APPEND_KEY on mount; see Studio/index.js.
// - SESSION_KEY is the studio's own "resume where I left off": it saves { format, text } on
// every change and restores it on mount. Without this, walking from one segment doc to
// another and back to the studio would find a fresh, pristine studio each time - APPEND_KEY's
@@ -17,6 +24,7 @@
// errors. A blocked hand-off should degrade to "just navigate" / "just show the pristine
// starter" rather than take the page down.
export const APPEND_KEY = 'omp-studio-pending-segment';
export const LOAD_KEY = 'omp-studio-pending-load';
export const SESSION_KEY = 'omp-studio-session';
export function trySessionStorageGet(key) {
+53 -13
View File
@@ -6,6 +6,7 @@ import { buildRenderOptions, RENDER_DATA_JSON } from '../ConfigEditor/renderDefa
import { convertConfig, parseConfig, stringifyConfig } from '../ConfigEditor/serialize';
import {
APPEND_KEY,
LOAD_KEY,
SESSION_KEY,
trySessionStorageGet,
trySessionStorageRemove,
@@ -54,33 +55,72 @@ function Studio() {
ensureLoaded();
}, [ensureLoaded]);
// Resumes the reader's own session (see studioHandoff.js's SESSION_KEY doc comment) and, if a
// segment doc queued one, appends its segment on top - so "Add to Studio" really does add to
// whatever is already here rather than to a pristine starter every time. Runs exactly once, on
// mount: format/configText are intentionally read as their *initial* values (the plain
// STARTER), not the current state, since this effect IS what decides what "current" becomes.
// Resumes the reader's own session (see studioHandoff.js's SESSION_KEY doc comment), applies a
// pending theme load if "Open in Studio" queued one (LOAD_KEY, replacing rather than resuming),
// and otherwise, if a segment doc queued one, appends its segment on top - so "Add to Studio"
// really does add to whatever is already here rather than to a pristine starter every time.
// Runs exactly once, on mount: format/configText are intentionally read as their *initial*
// values (the plain STARTER), not the current state, since this effect IS what decides what
// "current" becomes.
useEffect(() => {
let baseFormat = CONFIG_FORMAT;
let baseText = STARTERS[CONFIG_FORMAT];
const savedRaw = trySessionStorageGet(SESSION_KEY);
// LOAD_KEY ("Open in Studio" on a theme) takes priority over everything else: it means
// "start from this theme", not "add to whatever was already open". A hit here skips the
// SESSION_KEY resume below entirely (the whole point is to replace it) and the stale
// APPEND_KEY removal further down guards against an unrelated queued segment from an
// earlier, unfinished visit suddenly attaching itself to a freshly loaded theme.
const loadRaw = trySessionStorageGet(LOAD_KEY);
let loaded = false;
if (loadRaw) {
// One-shot regardless of outcome, same reasoning as APPEND_KEY below.
trySessionStorageRemove(LOAD_KEY);
if (savedRaw) {
try {
const saved = JSON.parse(savedRaw);
const payload = JSON.parse(loadRaw);
if (saved && typeof saved.text === 'string' && CONFIG_FORMATS.includes(saved.format)) {
baseFormat = saved.format;
baseText = saved.text;
if (
payload &&
typeof payload.text === 'string' &&
CONFIG_FORMATS.includes(payload.format)
) {
baseFormat = payload.format;
baseText = payload.text;
loaded = true;
}
} catch {
// Corrupt or foreign sessionStorage value - fall back to the pristine starter.
// Corrupt payload - fall through to the normal session-resume/starter path below.
}
}
if (!loaded) {
const savedRaw = trySessionStorageGet(SESSION_KEY);
if (savedRaw) {
try {
const saved = JSON.parse(savedRaw);
if (saved && typeof saved.text === 'string' && CONFIG_FORMATS.includes(saved.format)) {
baseFormat = saved.format;
baseText = saved.text;
}
} catch {
// Corrupt or foreign sessionStorage value - fall back to the pristine starter.
}
}
}
const pendingRaw = trySessionStorageGet(APPEND_KEY);
if (pendingRaw) {
if (loaded) {
// A theme load supersedes any queued segment append too - discard it rather than silently
// bolting an unrelated segment onto the theme that was just opened.
if (pendingRaw) {
trySessionStorageRemove(APPEND_KEY);
}
} else if (pendingRaw) {
// One-shot: consumed here regardless of what happens next, so a later, unrelated studio
// visit never re-applies it.
trySessionStorageRemove(APPEND_KEY);
+80 -6
View File
@@ -1,8 +1,72 @@
import React from 'react';
import React, { useCallback, useState } from 'react';
import Link from '@docusaurus/Link';
import { useHistory } from '@docusaurus/router';
import themes from '../../../generated/themes.json';
import { parseConfig } from '../ConfigEditor/serialize';
import { LOAD_KEY, trySessionStorageSet } from '../ConfigEditor/studioHandoff';
import styles from './styles.module.css';
// Fetches a theme's actual config from GitHub (rawConfigUrl - see export_themes.mjs's own
// comment on why the manifest carries a URL rather than the file content) and, on success, hands
// it to the studio via LOAD_KEY before navigating there - mirroring Config.js's "Add to Studio"
// but replacing the whole editor instead of appending one segment (see studioHandoff.js).
//
// The fetch happens *before* navigating, not after, so a network failure - GitHub down, offline,
// blocked by an extension/firewall - leaves the reader on this page with a clear error message
// and the existing GitHub link as a fallback, rather than dropping them onto a studio that either
// silently kept its previous contents or shows nothing.
function OpenInStudioButton({ name, rawConfigUrl, format }) {
const history = useHistory();
const [status, setStatus] = useState('idle'); // 'idle' | 'loading' | 'error'
const handleClick = useCallback(async () => {
setStatus('loading');
try {
const response = await fetch(rawConfigUrl);
if (!response.ok) {
throw new Error(`request failed with status ${response.status}`);
}
const text = await response.text();
// Guards against a "successful" fetch that isn't actually the config - GitHub serving an
// HTML error/rate-limit page with a 200, for instance. A config that doesn't parse must
// not be handed to the studio as though it were real.
parseConfig(format, text);
if (!trySessionStorageSet(LOAD_KEY, JSON.stringify({ format, text }))) {
throw new Error('sessionStorage is unavailable');
}
history.push('/docs/studio');
} catch {
setStatus('error');
}
}, [rawConfigUrl, format, history]);
return (
<div className={styles.studioAction}>
<button
type="button"
className={styles.studioButton}
onClick={handleClick}
disabled={status === 'loading'}
>
{status === 'loading' ? 'Loading…' : 'Open in Studio'}
</button>
{status === 'error' && (
<p className={styles.studioError}>
Couldn&apos;t fetch {name}&apos;s config from GitHub. Try again, or{' '}
<Link to={rawConfigUrl}>view it directly</Link>.
</p>
)}
</div>
);
}
// ThemeCard inlines one theme's SVG verbatim via dangerouslySetInnerHTML - the
// markup never passes through MDX/JSX, so it can't be mangled by the MDX
// compiler and it doesn't need to be escaped/quoted for JSX attribute rules.
@@ -10,12 +74,15 @@ import styles from './styles.module.css';
// SVG inherits the page's own @font-face (see custom.css's "Victor Mono"
// declaration), so the icons and powerline glyphs render without any font
// embedding, subsetting, or data URI of their own.
function ThemeCard({ name, githubUrl, svg }) {
function ThemeCard({ name, githubUrl, rawConfigUrl, format, svg }) {
return (
<div className={styles.card}>
<h3 className={styles.heading} id={name.toLowerCase()}>
<Link to={githubUrl}>{name}</Link>
</h3>
<div className={styles.headingRow}>
<h3 className={styles.heading} id={name.toLowerCase()}>
<Link to={githubUrl}>{name}</Link>
</h3>
<OpenInStudioButton name={name} rawConfigUrl={rawConfigUrl} format={format} />
</div>
<Link to={githubUrl} className={styles.render}>
<span className={styles.svgWrapper} dangerouslySetInnerHTML={{ __html: svg }} />
</Link>
@@ -39,7 +106,14 @@ function ThemeGallery() {
return (
<div className={styles.gallery}>
{themes.map((theme) => (
<ThemeCard key={theme.name} name={theme.name} githubUrl={theme.githubUrl} svg={theme.svg} />
<ThemeCard
key={theme.name}
name={theme.name}
githubUrl={theme.githubUrl}
rawConfigUrl={theme.rawConfigUrl}
format={theme.format}
svg={theme.svg}
/>
))}
</div>
);
@@ -1,6 +1,10 @@
.gallery {
display: flex;
flex-direction: column;
/* Cards otherwise stretch to the full container width (flex column's default cross-axis
stretch), which is wider than the rendered svg - that's what left the "Open in Studio"
button floating free of the svg's own right edge instead of lining up with it. */
align-items: flex-start;
gap: 2rem;
margin-top: 2rem;
}
@@ -9,10 +13,71 @@
display: flex;
flex-direction: column;
gap: 0.5rem;
/* Sizes the card - and so headingRow, which stretches to fill it - to the width of its
widest child, the rendered svg (via .render/.svgWrapper), so the button's right edge lines
up with the svg's regardless of viewport width. max-width keeps a wide theme's render from
pushing the card past the viewport - .render's own overflow-x:auto handles the svg itself
scrolling in that case, same as before. */
width: fit-content;
max-width: 100%;
}
.heading {
margin: 0;
/* Lets the name shrink/wrap instead of forcing headingRow (and so the card) wider than the
svg it's now sized to match. */
min-width: 0;
}
.headingRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
}
.studioAction {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.25rem;
/* Never squeezed by a long theme name - it should keep its own width and let .heading give. */
flex-shrink: 0;
}
/* Same micro-label recipe as Config.module.css's .action (segment docs' Copy/Add to Studio) -
kept as its own copy rather than a shared import since the two live in separate CSS modules
scoped to their own components. */
.studioButton {
padding: 0.1rem 0.4rem;
font-family: var(--ifm-font-family-monospace);
font-size: var(--omp-label-size);
letter-spacing: var(--omp-label-tracking);
text-transform: uppercase;
color: var(--omp-text-secondary);
background: none;
border: 1px solid var(--omp-card-border-color);
border-radius: var(--omp-radius-sm);
cursor: pointer;
}
.studioButton:hover:not(:disabled) {
color: var(--ifm-color-primary);
border-color: var(--ifm-color-primary);
}
.studioButton:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.studioError {
margin: 0;
max-width: 20rem;
font-size: var(--omp-text-body);
color: var(--ifm-color-danger);
text-align: right;
}
.render {