mirror of
https://github.com/JanDeDobbeleer/oh-my-posh.git
synced 2026-08-24 10:14:12 -05:00
docs(themes): add Open in Studio button
Entire-Checkpoint: 10e4ab343956
This commit is contained in:
committed by
Jan De Dobbeleer
parent
267f672b96
commit
4db87633d3
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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't fetch {name}'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 {
|
||||
|
||||
Reference in New Issue
Block a user