Skip to content

Commit da68cb8

Browse files
BagToadCopilot
andauthored
Add terminal-mockup canvas extension for marketing screenshots (cli#13612)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 5c9ec1c commit da68cb8

17 files changed

Lines changed: 2246 additions & 0 deletions
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# Terminal mockup canvas
2+
3+
A [GitHub Copilot app](https://github.com/github/app) canvas extension
4+
that renders mock-up `gh` output as VSCode-styled terminal screenshots. Built
5+
for producing marketing imagery (blog posts, changelogs, social) where real
6+
terminal recordings are impractical.
7+
8+
## Using it
9+
10+
Open the canvas from a Copilot app session. Pick a starting mockup from the
11+
library dropdown, edit the content and toolbar options, and export a PNG via
12+
the download button. Files download through the browser/runtime, which
13+
typically lands them in the configured downloads directory.
14+
15+
The toolbar controls font, font size, width, window chrome (macOS or none),
16+
backdrop (subtle blue glow / grid / none), and an "auto-style" toggle that
17+
colorizes common `gh` patterns without requiring inline tags.
18+
19+
## Content markup
20+
21+
Content can be authored as raw ANSI escapes, or with a more readable bracket
22+
syntax that the renderer maps to the VSCode Dark+ palette:
23+
24+
- Named colors: `[red]`, `[green]`, `[yellow]`, `[blue]`, `[magenta]`,
25+
`[cyan]`, `[white]`, `[black]` (bright variants prefixed `br`, e.g.
26+
`[brblue]`), plus `[muted]` for grayed-out text and `[link]` for blue
27+
underlined link styling.
28+
- Modifiers: `[bold]` (or `[b]`), `[italic]` (or `[i]`), `[underline]`
29+
(or `[u]`), `[dim]`.
30+
- Each tag closes with its matching `[/name]`, e.g. `[red]error[/red]`.
31+
32+
When auto-style is on, the renderer also colorizes PR/issue states, labels,
33+
checkboxes, timestamps, and similar conventional output without explicit tags.
34+
35+
## Library
36+
37+
Mockups live in two locations:
38+
39+
- **Project library** at `./library/*.json`: committed to the repo, the
40+
shared starting set.
41+
- **User library** at `$COPILOT_HOME/extensions/terminal-mockup/artifacts/*.json`:
42+
local-only, for personal experiments.
43+
44+
Saving a new mockup writes to the user library by default; renaming an
45+
existing one preserves its scope. The dropdown shows both, prefixed by scope.
46+
47+
## Vendored dependencies
48+
49+
[`assets/html2canvas.min.js`](./assets/html2canvas.min.js) is the unmodified
50+
[html2canvas](https://github.com/niklasvh/html2canvas) 1.4.1 distribution
51+
(MIT). Used to rasterize the rendered DOM into a PNG in-browser.
Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
// ANSI SGR + bracket markup tokenizer.
2+
// Produces a flat array of styled segments: { text, classes }.
3+
//
4+
// Supports:
5+
// - ANSI CSI SGR sequences: \x1b[<params>m (0, 1, 3, 4, 22, 23, 24, 30-37, 39, 90-97, 38;5;N, 38;2;R;G;B)
6+
// - Bracket markup: [b]..[/b], [i]..[/i], [u]..[/u], [dim]..[/dim], [muted]..[/muted], [link]..[/link],
7+
// [red] [green] [yellow] [blue] [magenta] [cyan] [white] [black]
8+
// [brred] [brgreen] [bryellow] [brblue] [brmagenta] [brcyan] [brwhite] [brblack]
9+
// - Plain text passthrough
10+
//
11+
// Bracket tags can nest. ANSI state machine handles standard SGR codes only;
12+
// other CSI/OSC sequences are dropped silently.
13+
14+
const ANSI_FG = {
15+
30: "black", 31: "red", 32: "green", 33: "yellow",
16+
34: "blue", 35: "magenta", 36: "cyan", 37: "white",
17+
90: "br-black", 91: "br-red", 92: "br-green", 93: "br-yellow",
18+
94: "br-blue", 95: "br-magenta", 96: "br-cyan", 97: "br-white",
19+
};
20+
21+
const COLOR_NAMES = new Set([
22+
"red", "green", "yellow", "blue", "magenta", "cyan", "white", "black",
23+
"brred", "brgreen", "bryellow", "brblue", "brmagenta", "brcyan", "brwhite", "brblack",
24+
]);
25+
const TAG_TO_FG = {
26+
red: "red", green: "green", yellow: "yellow", blue: "blue",
27+
magenta: "magenta", cyan: "cyan", white: "white", black: "black",
28+
brred: "br-red", brgreen: "br-green", bryellow: "br-yellow", brblue: "br-blue",
29+
brmagenta: "br-magenta", brcyan: "br-cyan", brwhite: "br-white", brblack: "br-black",
30+
};
31+
32+
function classesFromState(state) {
33+
const cls = [];
34+
if (state.fg) cls.push(`fg-${state.fg}`);
35+
if (state.bold) cls.push("bold");
36+
if (state.italic) cls.push("italic");
37+
if (state.underline) cls.push("underline");
38+
if (state.dim) cls.push("dim");
39+
return cls;
40+
}
41+
42+
function emit(out, text, state) {
43+
if (!text) return;
44+
out.push({ text, classes: classesFromState(state) });
45+
}
46+
47+
// Step 1: parse ANSI escape codes into a flat segment list, ignoring brackets.
48+
function parseAnsi(input) {
49+
const segments = [];
50+
const state = { fg: null, bold: false, italic: false, underline: false, dim: false };
51+
let buf = "";
52+
let i = 0;
53+
while (i < input.length) {
54+
const ch = input.charCodeAt(i);
55+
if (ch === 0x1b && input[i + 1] === "[") {
56+
if (buf) { emit(segments, buf, state); buf = ""; }
57+
// Find terminator
58+
let j = i + 2;
59+
while (j < input.length) {
60+
const c = input.charCodeAt(j);
61+
// CSI parameter bytes: 0x30-0x3f; intermediates: 0x20-0x2f; final: 0x40-0x7e
62+
if (c >= 0x40 && c <= 0x7e) break;
63+
j++;
64+
}
65+
const final = input[j];
66+
const params = input.slice(i + 2, j);
67+
if (final === "m") applySgr(state, params);
68+
i = j + 1;
69+
continue;
70+
}
71+
buf += input[i];
72+
i++;
73+
}
74+
if (buf) emit(segments, buf, state);
75+
return segments;
76+
}
77+
78+
function applySgr(state, paramsStr) {
79+
const tokens = paramsStr.split(";").map((t) => (t === "" ? 0 : Number(t)));
80+
let i = 0;
81+
while (i < tokens.length) {
82+
const t = tokens[i];
83+
if (t === 0) {
84+
state.fg = null; state.bold = false; state.italic = false;
85+
state.underline = false; state.dim = false;
86+
} else if (t === 1) state.bold = true;
87+
else if (t === 2) state.dim = true;
88+
else if (t === 3) state.italic = true;
89+
else if (t === 4) state.underline = true;
90+
else if (t === 22) { state.bold = false; state.dim = false; }
91+
else if (t === 23) state.italic = false;
92+
else if (t === 24) state.underline = false;
93+
else if (t === 39) state.fg = null;
94+
else if (ANSI_FG[t]) state.fg = ANSI_FG[t];
95+
else if (t === 38) {
96+
const mode = tokens[i + 1];
97+
if (mode === 5) {
98+
state.fg = map256(tokens[i + 2]);
99+
i += 2;
100+
} else if (mode === 2) {
101+
// Truecolor not mapped to a named slot; skip params and leave fg unchanged.
102+
i += 4;
103+
}
104+
}
105+
// ignore 40-49, 48 etc (we don't render backgrounds for now)
106+
i++;
107+
}
108+
}
109+
110+
// Map 256-color cube to nearest named slot. Coarse but adequate.
111+
function map256(n) {
112+
if (n == null) return null;
113+
if (n < 8) return ANSI_FG[30 + n] || null;
114+
if (n < 16) return ANSI_FG[90 + (n - 8)] || null;
115+
// Grayscale ramp (232 = near-black, 255 = near-white). The middle range
116+
// is the "muted" gray that gh uses for footer URLs, bullet separators, etc.
117+
if (n >= 232 && n <= 243) return "muted";
118+
if (n >= 244 && n <= 250) return "br-black"; // softer gray
119+
// Color cube fallback: no good mapping, let the default fg apply.
120+
return null;
121+
}
122+
// Step 2: walk segments and split on bracket markup, updating per-segment classes.
123+
function parseBrackets(segments) {
124+
const out = [];
125+
const stack = []; // each entry: array of class strings added by this tag
126+
const tagRe = /\[(\/?)([a-zA-Z]+)\]/g;
127+
for (const seg of segments) {
128+
const text = seg.text;
129+
let last = 0;
130+
tagRe.lastIndex = 0;
131+
let m;
132+
const baseClasses = seg.classes.slice();
133+
while ((m = tagRe.exec(text)) !== null) {
134+
const before = text.slice(last, m.index);
135+
if (before) out.push({ text: before, classes: mergeClasses(baseClasses, stack) });
136+
const closing = m[1] === "/";
137+
const tag = m[2].toLowerCase();
138+
const added = tagToClasses(tag);
139+
if (added.length === 0) {
140+
// Not a recognized tag; treat as literal text.
141+
out.push({ text: m[0], classes: mergeClasses(baseClasses, stack) });
142+
} else if (closing) {
143+
// Pop most recent matching frame.
144+
for (let i = stack.length - 1; i >= 0; i--) {
145+
if (stack[i].tag === tag) { stack.splice(i, 1); break; }
146+
}
147+
} else {
148+
stack.push({ tag, classes: added });
149+
}
150+
last = m.index + m[0].length;
151+
}
152+
const tail = text.slice(last);
153+
if (tail) out.push({ text: tail, classes: mergeClasses(baseClasses, stack) });
154+
}
155+
return out;
156+
}
157+
158+
function tagToClasses(tag) {
159+
if (tag === "b" || tag === "bold") return ["bold"];
160+
if (tag === "i" || tag === "italic") return ["italic"];
161+
if (tag === "u" || tag === "underline") return ["underline"];
162+
if (tag === "dim") return ["dim"];
163+
if (tag === "muted") return ["fg-muted"];
164+
if (tag === "link") return ["fg-br-blue", "underline"];
165+
if (COLOR_NAMES.has(tag)) return [`fg-${TAG_TO_FG[tag]}`];
166+
return [];
167+
}
168+
169+
function mergeClasses(base, stack) {
170+
const set = new Set(base);
171+
for (const frame of stack) {
172+
for (const c of frame.classes) set.add(c);
173+
}
174+
return Array.from(set);
175+
}
176+
177+
// Step 3: optional auto-styling for plain-looking segments.
178+
// Operates only on segments that have no styling yet, to avoid clobbering
179+
// user-specified colors. Splits on detected patterns and inserts styled spans.
180+
function autoStyle(segments) {
181+
const out = [];
182+
for (const seg of segments) {
183+
if (seg.classes.length > 0) {
184+
out.push(seg);
185+
continue;
186+
}
187+
autoStyleSegment(seg.text, out);
188+
}
189+
return out;
190+
}
191+
192+
function autoStyleSegment(text, out) {
193+
// Process line by line so we can detect $ prompts.
194+
const lines = text.split(/(\n)/);
195+
for (const line of lines) {
196+
if (line === "\n") {
197+
out.push({ text: "\n", classes: [] });
198+
continue;
199+
}
200+
if (line === "") continue;
201+
// Prompt line: leading `$ `
202+
const promptMatch = line.match(/^(\s*)(\$)( )(.*)$/);
203+
if (promptMatch) {
204+
const [, leading, dollar, space, rest] = promptMatch;
205+
if (leading) out.push({ text: leading, classes: [] });
206+
out.push({ text: dollar, classes: ["fg-muted"] });
207+
out.push({ text: space, classes: [] });
208+
// Apply inline auto-stylers to the rest of the prompt line
209+
autoStyleInline(rest, out);
210+
continue;
211+
}
212+
autoStyleInline(line, out);
213+
}
214+
}
215+
216+
function autoStyleInline(text, out) {
217+
// Detect URLs and color/dim them; detect standalone +N/-N tokens for diff stats; detect #NNN refs.
218+
// Single regex with alternation; iterate over matches.
219+
const re = /(https?:\/\/[^\s)>\]]+)|(?<![\w/-])([+-]\d+)(?![\w-])|(?<![\w/-])(#\d+)(?![\w-])/g;
220+
let last = 0;
221+
let m;
222+
while ((m = re.exec(text)) !== null) {
223+
if (m.index > last) out.push({ text: text.slice(last, m.index), classes: [] });
224+
if (m[1]) {
225+
out.push({ text: m[1], classes: ["fg-muted"] });
226+
} else if (m[2]) {
227+
const cls = m[2].startsWith("+") ? "fg-br-green" : "fg-br-red";
228+
out.push({ text: m[2], classes: [cls] });
229+
} else if (m[3]) {
230+
out.push({ text: m[3], classes: ["fg-br-blue"] });
231+
}
232+
last = m.index + m[0].length;
233+
}
234+
if (last < text.length) out.push({ text: text.slice(last), classes: [] });
235+
}
236+
237+
export function parse(input, { autoStyle: enableAuto = true } = {}) {
238+
const ansiSegments = parseAnsi(input ?? "");
239+
const bracketSegments = parseBrackets(ansiSegments);
240+
return enableAuto ? autoStyle(bracketSegments) : bracketSegments;
241+
}
242+
243+
export function renderToDom(target, input, opts) {
244+
const segments = parse(input, opts);
245+
target.replaceChildren();
246+
const frag = document.createDocumentFragment();
247+
for (const seg of segments) {
248+
if (seg.classes.length === 0) {
249+
frag.appendChild(document.createTextNode(seg.text));
250+
} else {
251+
const span = document.createElement("span");
252+
span.className = seg.classes.join(" ");
253+
span.textContent = seg.text;
254+
frag.appendChild(span);
255+
}
256+
}
257+
target.appendChild(frag);
258+
}

0 commit comments

Comments
 (0)