Files
Keith Solomon fa3df3e3f4 feat: add Fluid Typography Generator extension for VSCode
- Implemented core functionality to generate fluid typography CSS variables based on user-defined breakpoints and font sizes.
- Created configuration options for output format (Tailwind or vanilla CSS) and rounding settings.
- Added input parsing for settings from text files or VSCode interface.
- Developed CSS generation logic with support for `clamp()` and optional rounding.
- Included tests for parsing settings, generating CSS, and inserting text at selection in the editor.
- Documented project details and usage in project.md.
- Added example CSS output in typography.css.
2026-06-06 22:21:22 -05:00

391 lines
11 KiB
JavaScript

const vscode = require('vscode');
const { generateCss } = require('./src/fluidTypography');
const { insertTextAtSelectionValue } = require('./src/webviewText');
const DEFAULT_SIZE_LINES = `text-14px: 12px-14px
text-16px: 14px-16px
text-18px: 16px-18px
text-20px: 18px-20px
text-22px: 20px-22px
text-25px: 22px-25px
text-30px: 25px-30px
text-35px: 30px-35px
text-38px: 35px-38px
text-40px: 38px-40px
text-45px: 40px-45px
text-50px: 45px-50px
text-70px: 50px-70px
text-75px: 70px-75px`;
function getNonce() {
const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let text = '';
for (let index = 0; index < 32; index += 1) {
text += possible.charAt(Math.floor(Math.random() * possible.length));
}
return text;
}
async function openGeneratedCss(css) {
const document = await vscode.workspace.openTextDocument({
content: css,
language: 'css',
});
await vscode.window.showTextDocument(document, { preview: false });
}
function getExtensionSettings() {
const config = vscode.workspace.getConfiguration('fluidTypography');
return {
css: config.get('cssMode', 'tailwind'),
round: config.get('round.enabled', true),
roundValue: config.get('round.value', '2px'),
low: config.get('breakpoints.low', 360),
high: config.get('breakpoints.high', 1920),
};
}
async function generateFromText(input) {
try {
await openGeneratedCss(generateCss(input, getExtensionSettings()));
vscode.window.showInformationMessage('Fluid typography CSS generated.');
} catch (error) {
vscode.window.showErrorMessage(`Fluid typography input error: ${error.message}`);
}
}
async function generateFromFile() {
const selection = await vscode.window.showOpenDialog({
canSelectFiles: true,
canSelectFolders: false,
canSelectMany: false,
filters: {
Text: ['txt', 'md', 'css'],
All: ['*'],
},
title: 'Select fluid typography settings file',
});
if (!selection || selection.length === 0) {
return;
}
const bytes = await vscode.workspace.fs.readFile(selection[0]);
await generateFromText(Buffer.from(bytes).toString('utf8'));
}
async function generateFromSelection() {
const editor = vscode.window.activeTextEditor;
if (!editor) {
vscode.window.showWarningMessage('Open a settings file or select settings text first.');
return;
}
const selectedText = editor.document.getText(editor.selection);
const input = selectedText || editor.document.getText();
await generateFromText(input);
}
function getWebviewHtml(webview, settings) {
const nonce = getNonce();
const { cspSource } = webview;
const initialState = {
...settings,
sizes: DEFAULT_SIZE_LINES,
};
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src ${cspSource} 'unsafe-inline'; script-src 'nonce-${nonce}';">
<title>Fluid Typography Generator</title>
<style>
body {
color: var(--vscode-foreground);
font-family: var(--vscode-font-family);
margin: 0;
padding: 24px;
}
main {
display: grid;
gap: 20px;
max-width: 920px;
}
.grid {
display: grid;
gap: 12px;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
label {
display: grid;
gap: 6px;
font-weight: 600;
}
.description {
color: var(--vscode-descriptionForeground);
font-size: 12px;
font-weight: 400;
line-height: 1.4;
}
input,
select,
textarea {
background: var(--vscode-input-background);
border: 1px solid var(--vscode-input-border, transparent);
color: var(--vscode-input-foreground);
padding: 8px;
}
textarea,
pre {
font-family: var(--vscode-editor-font-family);
font-size: var(--vscode-editor-font-size);
line-height: 1.5;
padding: 12px;
}
textarea {
min-height: 300px;
resize: vertical;
width: 100%;
}
.toggle {
align-items: center;
display: flex;
gap: 8px;
min-height: 30px;
}
.toggle input {
margin: 0;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
button {
background: var(--vscode-button-background);
border: 0;
color: var(--vscode-button-foreground);
cursor: pointer;
padding: 8px 12px;
}
button:hover {
background: var(--vscode-button-hoverBackground);
}
pre {
max-height: 260px;
overflow: auto;
white-space: pre-wrap;
}
.field-header {
align-items: center;
display: flex;
gap: 8px;
justify-content: space-between;
}
</style>
</head>
<body>
<main>
<div class="grid">
<label for="cssMode">
Output
<select id="cssMode" title="Tailwind writes variables inside @theme static. Vanilla writes them inside :root.">
<option value="tailwind">Tailwind</option>
<option value="vanilla">Vanilla CSS</option>
</select>
<span class="description">Tailwind emits <code>@theme static</code>; vanilla emits <code>:root</code>.</span>
</label>
<label for="low">
Lower breakpoint
<input id="low" min="1" step="1" type="number" title="Viewport width where each fluid size reaches its minimum value.">
<span class="description">The viewport width where minimum sizes begin.</span>
</label>
<label for="high">
Upper breakpoint
<input id="high" min="1" step="1" type="number" title="Viewport width where each fluid size reaches its maximum value.">
<span class="description">The viewport width where maximum sizes stop scaling.</span>
</label>
<label>
Rounding
<span class="toggle">
<input id="round" type="checkbox" title="When enabled, generated values use round(down, clamp(...), value).">
<span>Enable CSS round()</span>
</span>
<span class="description">Rounding can reduce sub-pixel values, but depends on browser support for CSS <code>round()</code>.</span>
</label>
<label id="roundValueGroup" for="roundValue">
Rounding value
<input id="roundValue" pattern="\\d+(\\.\\d+)?(px|rem|em)" type="text" title="The interval passed to round(), such as 2px, 0.125rem, or 0.1em.">
<span class="description">Used as the final argument in <code>round(down, clamp(...), value)</code>.</span>
</label>
</div>
<label for="sizes">
<span class="field-header">
<span>Font-size variables</span>
<button id="pasteVariables" title="Paste from the VS Code clipboard into this field." type="button">Paste</button>
</span>
<textarea id="sizes" spellcheck="false" title="One variable per line, in the form name: minpx-maxpx."></textarea>
<span class="description">Use one range per line, for example <code>text-18px: 16px-18px</code>.</span>
</label>
<div class="actions">
<button id="preview" type="button">Preview</button>
<button id="generate" type="button">Generate CSS Document</button>
</div>
<pre id="output" aria-live="polite"></pre>
</main>
<script nonce="${nonce}">
const vscode = acquireVsCodeApi();
const initialState = ${JSON.stringify(initialState)};
const cssMode = document.getElementById('cssMode');
const low = document.getElementById('low');
const high = document.getElementById('high');
const round = document.getElementById('round');
const roundValue = document.getElementById('roundValue');
const roundValueGroup = document.getElementById('roundValueGroup');
const sizes = document.getElementById('sizes');
const output = document.getElementById('output');
const insertTextAtSelectionValue = ${insertTextAtSelectionValue.toString()};
function updateRoundVisibility() {
roundValueGroup.style.display = round.checked ? 'grid' : 'none';
}
function buildSettingsText() {
const lines = [
'CSS: ' + cssMode.value,
round.checked ? 'Round: yes, ' + roundValue.value : 'Round: no',
'Low: ' + low.value + 'px',
'High: ' + high.value + 'px',
'',
sizes.value.trim(),
];
return lines.join('\\n');
}
cssMode.value = initialState.css;
low.value = initialState.low;
high.value = initialState.high;
round.checked = initialState.round;
roundValue.value = initialState.roundValue;
sizes.value = initialState.sizes;
updateRoundVisibility();
round.addEventListener('change', updateRoundVisibility);
document.getElementById('pasteVariables').addEventListener('click', () => {
vscode.postMessage({ command: 'pasteVariables' });
});
document.getElementById('preview').addEventListener('click', () => {
vscode.postMessage({ command: 'preview', text: buildSettingsText() });
});
document.getElementById('generate').addEventListener('click', () => {
vscode.postMessage({ command: 'generate', text: buildSettingsText() });
});
window.addEventListener('message', (event) => {
if (event.data.command === 'insertVariables') {
const next = insertTextAtSelectionValue(
sizes.value,
sizes.selectionStart,
sizes.selectionEnd,
event.data.text,
);
sizes.value = next.value;
sizes.focus();
sizes.setSelectionRange(next.selectionStart, next.selectionEnd);
return;
}
output.textContent = event.data.text;
});
</script>
</body>
</html>`;
}
function openGenerator(context) {
const panel = vscode.window.createWebviewPanel(
'fluidTypographyGenerator',
'Fluid Typography Generator',
vscode.ViewColumn.One,
{
enableScripts: true,
localResourceRoots: [context.extensionUri],
},
);
panel.webview.html = getWebviewHtml(panel.webview, getExtensionSettings());
panel.webview.onDidReceiveMessage(async (message) => {
if (!message || typeof message.command !== 'string') {
return;
}
try {
if (message.command === 'pasteVariables') {
const text = await vscode.env.clipboard.readText();
panel.webview.postMessage({ command: 'insertVariables', text });
return;
}
if (typeof message.text !== 'string') {
return;
}
const css = generateCss(message.text, getExtensionSettings());
if (message.command === 'preview') {
panel.webview.postMessage({ text: css });
return;
}
if (message.command === 'generate') {
await openGeneratedCss(css);
}
} catch (error) {
panel.webview.postMessage({ text: error.message });
}
});
}
function activate(context) {
context.subscriptions.push(
vscode.commands.registerCommand('fluidTypography.generateFromFile', generateFromFile),
vscode.commands.registerCommand('fluidTypography.generateFromSelection', generateFromSelection),
vscode.commands.registerCommand('fluidTypography.openGenerator', () => openGenerator(context)),
);
}
function deactivate() {}
module.exports = {
activate,
deactivate,
};