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.
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "airbnb-base",
|
||||
"env": {
|
||||
"commonjs": true,
|
||||
"node": true,
|
||||
"es2022": true
|
||||
},
|
||||
"rules": {
|
||||
"import/no-extraneous-dependencies": [
|
||||
"error",
|
||||
{
|
||||
"devDependencies": ["test/**/*.js"]
|
||||
}
|
||||
],
|
||||
"import/no-unresolved": ["error", { "ignore": ["^vscode$"] }],
|
||||
"no-plusplus": "off"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
*~
|
||||
.DS_Store
|
||||
node_modules
|
||||
notes/
|
||||
phpcs-results.txt
|
||||
Vendored
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Run Extension",
|
||||
"type": "extensionHost",
|
||||
"request": "launch",
|
||||
"args": ["--extensionDevelopmentPath=${workspaceFolder}"],
|
||||
"outFiles": []
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"workbench.colorCustomizations": {
|
||||
"tree.indentGuidesStroke": "#3d92ec",
|
||||
"activityBar.background": "#1F321F",
|
||||
"titleBar.activeBackground": "#2B472B",
|
||||
"titleBar.activeForeground": "#F9FBF9",
|
||||
"titleBar.inactiveBackground": "#1F321F",
|
||||
"titleBar.inactiveForeground": "#F9FBF9",
|
||||
"statusBar.background": "#1F321F",
|
||||
"statusBar.foreground": "#F9FBF9",
|
||||
"statusBar.debuggingBackground": "#1F321F",
|
||||
"statusBar.debuggingForeground": "#F9FBF9",
|
||||
"statusBar.noFolderBackground": "#1F321F",
|
||||
"statusBar.noFolderForeground": "#F9FBF9"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
.vscode/**
|
||||
test/**
|
||||
node_modules/**
|
||||
*.vsix
|
||||
*.tgz
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Keith Solomon
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Fluid Font Size Generator
|
||||
|
||||
A VS Code extension that generates fluid typography CSS variables from breakpoint and font-size settings.
|
||||
|
||||
## Commands
|
||||
|
||||
- `Fluid Typography: Generate CSS From File`
|
||||
- `Fluid Typography: Generate CSS From Selection`
|
||||
- `Fluid Typography: Open Generator`
|
||||
|
||||
## Input Format
|
||||
|
||||
```plain
|
||||
CSS: tailwind
|
||||
Round: yes, 2px
|
||||
Low: 360px
|
||||
High: 1920px
|
||||
|
||||
text-14px: 12px-14px
|
||||
text-16px: 14px-16px
|
||||
```
|
||||
|
||||
`CSS` can be `tailwind` or `vanilla`. Tailwind emits an `@theme static` block; vanilla emits `:root`.
|
||||
|
||||
`Round` can be `yes, 2px` or `no`. When enabled, generated values use `round(down, clamp(...), value)`.
|
||||
|
||||
## Settings
|
||||
|
||||
These settings work globally or per workspace:
|
||||
|
||||
- `fluidTypography.cssMode`
|
||||
- `fluidTypography.round.enabled`
|
||||
- `fluidTypography.round.value`
|
||||
- `fluidTypography.breakpoints.low`
|
||||
- `fluidTypography.breakpoints.high`
|
||||
+390
@@ -0,0 +1,390 @@
|
||||
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,
|
||||
};
|
||||
Binary file not shown.
Generated
+3219
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,96 @@
|
||||
{
|
||||
"name": "fluid-font-size-vscode-generator",
|
||||
"displayName": "Fluid Font Size Generator",
|
||||
"description": "Generate fluid typography CSS variables from breakpoint and font-size settings.",
|
||||
"version": "0.1.0",
|
||||
"publisher": "local-dev",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "ssh://git@git.keithsolomon.net:222/keith/VSCode-Fluid-Font-Generator.git"
|
||||
},
|
||||
"homepage": "https://git.keithsolomon.net/keith/VSCode-Fluid-Font-Generator",
|
||||
"engines": {
|
||||
"vscode": "^1.85.0"
|
||||
},
|
||||
"categories": [
|
||||
"Other"
|
||||
],
|
||||
"activationEvents": [
|
||||
"onCommand:fluidTypography.generateFromFile",
|
||||
"onCommand:fluidTypography.generateFromSelection",
|
||||
"onCommand:fluidTypography.openGenerator"
|
||||
],
|
||||
"main": "./extension.js",
|
||||
"contributes": {
|
||||
"commands": [
|
||||
{
|
||||
"command": "fluidTypography.generateFromFile",
|
||||
"title": "Fluid Typography: Generate CSS From File"
|
||||
},
|
||||
{
|
||||
"command": "fluidTypography.generateFromSelection",
|
||||
"title": "Fluid Typography: Generate CSS From Selection"
|
||||
},
|
||||
{
|
||||
"command": "fluidTypography.openGenerator",
|
||||
"title": "Fluid Typography: Open Generator"
|
||||
}
|
||||
],
|
||||
"configuration": {
|
||||
"title": "Fluid Typography",
|
||||
"properties": {
|
||||
"fluidTypography.cssMode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"tailwind",
|
||||
"vanilla"
|
||||
],
|
||||
"default": "tailwind",
|
||||
"description": "Default output format. Tailwind emits @theme static; vanilla emits :root."
|
||||
},
|
||||
"fluidTypography.round.enabled": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Wrap generated clamp() values in CSS round(down, ..., value)."
|
||||
},
|
||||
"fluidTypography.round.value": {
|
||||
"type": "string",
|
||||
"default": "2px",
|
||||
"pattern": "^\\d+(\\.\\d+)?(px|rem|em)$",
|
||||
"description": "Default rounding interval used when rounding is enabled."
|
||||
},
|
||||
"fluidTypography.breakpoints.low": {
|
||||
"type": "number",
|
||||
"default": 360,
|
||||
"minimum": 1,
|
||||
"description": "Default lower viewport breakpoint in pixels for the generator form and inputs that omit Low."
|
||||
},
|
||||
"fluidTypography.breakpoints.high": {
|
||||
"type": "number",
|
||||
"default": 1920,
|
||||
"minimum": 1,
|
||||
"description": "Default upper viewport breakpoint in pixels for the generator form and inputs that omit High."
|
||||
}
|
||||
}
|
||||
},
|
||||
"menus": {
|
||||
"editor/context": [
|
||||
{
|
||||
"command": "fluidTypography.generateFromSelection",
|
||||
"when": "editorHasSelection",
|
||||
"group": "navigation"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": "node --test",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-airbnb-base": "^15.0.0",
|
||||
"eslint-plugin-import": "^2.29.0"
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
# Project: Fluid Typography Generator extension for VSCode
|
||||
|
||||
## Description
|
||||
|
||||
A VSCode extension to generate fluid typography CSS variables for themes.
|
||||
|
||||
Given a set of font sizes, this tool calculates the appropriate CSS `clamp()` values (with `round()` support) to create fluid typography that scales smoothly between a defined minimum and maximum size based on the viewport width. The generated CSS variables can be used in WordPress themes to ensure consistent and responsive font sizing across different screen sizes.
|
||||
|
||||
## Input
|
||||
|
||||
A text file containing a list of settings (low/high screen size breakpoints, variable names, and font sizes in pixels), one per line. For example:
|
||||
|
||||
```plain
|
||||
Low: 360px
|
||||
High: 1920px
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
Optionally, the inputs can be provided through a form in the VSCode extension interface, allowing users to easily customize their settings without editing a text file.
|
||||
|
||||
## Output
|
||||
|
||||
A CSS file containing the generated fluid typography variables and comments. See `typography.css` for an example.
|
||||
@@ -0,0 +1,231 @@
|
||||
const DEFAULT_VIEWPORTS = [360, 640, 768, 1024, 1280, 1440, 1920];
|
||||
const BASE_FONT_SIZE = 16;
|
||||
const DEFAULT_OPTIONS = {
|
||||
css: 'tailwind',
|
||||
round: true,
|
||||
roundValue: '2px',
|
||||
low: null,
|
||||
high: null,
|
||||
};
|
||||
|
||||
function formatNumber(value, decimals = 4) {
|
||||
return Number(value.toFixed(decimals)).toString();
|
||||
}
|
||||
|
||||
function formatTableNumber(value) {
|
||||
return value.toFixed(2);
|
||||
}
|
||||
|
||||
function parsePixelValue(raw) {
|
||||
const match = raw.trim().match(/^(\d+(?:\.\d+)?)px$/i);
|
||||
return match ? Number(match[1]) : null;
|
||||
}
|
||||
|
||||
function normalizeOptions(options = {}) {
|
||||
return {
|
||||
...DEFAULT_OPTIONS,
|
||||
...options,
|
||||
};
|
||||
}
|
||||
|
||||
function parseRoundValue(raw) {
|
||||
const value = raw.trim();
|
||||
return /^(\d+(?:\.\d+)?)(px|rem|em)$/i.test(value) ? value.toLowerCase() : null;
|
||||
}
|
||||
|
||||
function parseSettings(input, options = {}) {
|
||||
const defaults = normalizeOptions(options);
|
||||
const settings = {
|
||||
css: defaults.css,
|
||||
round: defaults.round,
|
||||
roundValue: defaults.roundValue,
|
||||
low: defaults.low,
|
||||
high: defaults.high,
|
||||
sizes: [],
|
||||
};
|
||||
const errors = [];
|
||||
|
||||
input.split(/\r?\n/).forEach((rawLine, index) => {
|
||||
const lineNumber = index + 1;
|
||||
const line = rawLine.trim();
|
||||
|
||||
if (!line || line.startsWith('#') || line.startsWith('//')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cssMatch = line.match(/^CSS:\s*(vanilla|tailwind)$/i);
|
||||
if (cssMatch) {
|
||||
settings.css = cssMatch[1].toLowerCase();
|
||||
return;
|
||||
}
|
||||
|
||||
const roundMatch = line.match(/^Round:\s*(yes|no)(?:\s*,\s*(.+))?$/i);
|
||||
if (roundMatch) {
|
||||
settings.round = roundMatch[1].toLowerCase() === 'yes';
|
||||
|
||||
if (roundMatch[2]) {
|
||||
const roundValue = parseRoundValue(roundMatch[2]);
|
||||
|
||||
if (roundValue === null) {
|
||||
errors.push(`Line ${lineNumber}: Round value must use px, rem, or em.`);
|
||||
return;
|
||||
}
|
||||
|
||||
settings.roundValue = roundValue;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const breakpointMatch = line.match(/^(Low|High):\s*(.+)$/i);
|
||||
if (breakpointMatch) {
|
||||
const key = breakpointMatch[1].toLowerCase();
|
||||
const value = parsePixelValue(breakpointMatch[2]);
|
||||
|
||||
if (value === null) {
|
||||
errors.push(`Line ${lineNumber}: ${breakpointMatch[1]} must be a px value.`);
|
||||
return;
|
||||
}
|
||||
|
||||
settings[key] = value;
|
||||
return;
|
||||
}
|
||||
|
||||
const sizeMatch = line.match(/^([a-zA-Z][\w-]*):\s*(\d+(?:\.\d+)?)px\s*-\s*(\d+(?:\.\d+)?)px$/);
|
||||
if (!sizeMatch) {
|
||||
errors.push(`Line ${lineNumber}: expected "name: minpx-maxpx".`);
|
||||
return;
|
||||
}
|
||||
|
||||
settings.sizes.push({
|
||||
name: sizeMatch[1],
|
||||
min: Number(sizeMatch[2]),
|
||||
max: Number(sizeMatch[3]),
|
||||
});
|
||||
});
|
||||
|
||||
if (settings.low === null) {
|
||||
errors.unshift('Missing Low breakpoint.');
|
||||
}
|
||||
|
||||
if (settings.high === null) {
|
||||
errors.unshift('Missing High breakpoint.');
|
||||
}
|
||||
|
||||
if (settings.low !== null && settings.high !== null && settings.low >= settings.high) {
|
||||
errors.push('Low breakpoint must be smaller than High breakpoint.');
|
||||
}
|
||||
|
||||
if (settings.sizes.length === 0) {
|
||||
errors.push('Add at least one font-size variable.');
|
||||
}
|
||||
|
||||
const reversedRange = settings.sizes.find((size) => size.min > size.max);
|
||||
if (reversedRange) {
|
||||
errors.push(`${reversedRange.name} minimum size must be less than or equal to maximum size.`);
|
||||
}
|
||||
|
||||
if (errors.length) {
|
||||
throw new Error(errors.join('\n'));
|
||||
}
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
function clampValueAtViewport(size, low, high, viewport) {
|
||||
const progress = (viewport - low) / (high - low);
|
||||
const value = size.min + ((size.max - size.min) * progress);
|
||||
|
||||
return Math.min(size.max, Math.max(size.min, value));
|
||||
}
|
||||
|
||||
function buildClamp(size, settings) {
|
||||
const { low, high } = settings;
|
||||
const slope = ((size.max - size.min) * 100) / (high - low);
|
||||
const intercept = size.min - ((slope * low) / 100);
|
||||
const minRem = size.min / BASE_FONT_SIZE;
|
||||
const maxRem = size.max / BASE_FONT_SIZE;
|
||||
const interceptRem = intercept / BASE_FONT_SIZE;
|
||||
const clamp = `clamp(${formatNumber(minRem)}rem, ${formatNumber(interceptRem)}rem + ${formatNumber(slope)}vw, ${formatNumber(maxRem)}rem)`;
|
||||
|
||||
if (!settings.round) {
|
||||
return clamp;
|
||||
}
|
||||
|
||||
return `round(down, ${clamp}, ${settings.roundValue})`;
|
||||
}
|
||||
|
||||
function buildComment(settings, viewports) {
|
||||
const headerRows = [
|
||||
'/* Basic typographical styles */',
|
||||
'',
|
||||
'/**',
|
||||
` * Fluid font sizes between ${settings.low}px and ${settings.high}px viewport widths.`,
|
||||
' * Generated values use 16px as the base font size.',
|
||||
];
|
||||
|
||||
settings.sizes.forEach((size) => {
|
||||
headerRows.push(` * ${size.name}: ${size.min}px-${size.max}px`);
|
||||
});
|
||||
|
||||
const nameWidth = Math.max(
|
||||
' '.length,
|
||||
...settings.sizes.map((size) => size.name.length),
|
||||
);
|
||||
const viewportHeaders = viewports.map((viewport) => `${viewport}px`);
|
||||
const viewportWidths = viewportHeaders.map((header, column) => Math.max(
|
||||
header.length,
|
||||
...settings.sizes.map((size) => formatTableNumber(
|
||||
clampValueAtViewport(size, settings.low, settings.high, viewports[column]),
|
||||
).length),
|
||||
));
|
||||
|
||||
headerRows.push(' *');
|
||||
headerRows.push(' * Font sizes at standard viewport widths:');
|
||||
headerRows.push(` * ${''.padEnd(nameWidth)} | ${viewportHeaders.map((header, index) => header.padStart(viewportWidths[index])).join(' | ')}`);
|
||||
headerRows.push(` * ${''.padEnd(nameWidth, '-')} | ${viewportWidths.map((width) => ''.padEnd(width, '-')).join(' | ')}`);
|
||||
|
||||
settings.sizes.forEach((size) => {
|
||||
const values = viewports.map((viewport, index) => formatTableNumber(
|
||||
clampValueAtViewport(size, settings.low, settings.high, viewport),
|
||||
).padStart(viewportWidths[index]));
|
||||
headerRows.push(` * ${size.name.padEnd(nameWidth)} | ${values.join(' | ')}`);
|
||||
});
|
||||
|
||||
headerRows.push(' */');
|
||||
|
||||
return headerRows.join('\n');
|
||||
}
|
||||
|
||||
function generateCss(input, options = {}) {
|
||||
const settings = typeof input === 'string' ? parseSettings(input, options) : {
|
||||
...normalizeOptions(options),
|
||||
...input,
|
||||
};
|
||||
const viewports = options.viewports || DEFAULT_VIEWPORTS;
|
||||
const lines = [
|
||||
buildComment(settings, viewports),
|
||||
'',
|
||||
settings.css === 'tailwind' ? '@theme static {' : ':root {',
|
||||
' --text-base: 1rem;',
|
||||
];
|
||||
|
||||
settings.sizes.forEach((size) => {
|
||||
lines.push(` --${size.name}: ${buildClamp(size, settings)};`);
|
||||
});
|
||||
|
||||
lines.push('}');
|
||||
lines.push('');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_VIEWPORTS,
|
||||
DEFAULT_OPTIONS,
|
||||
buildClamp,
|
||||
clampValueAtViewport,
|
||||
formatNumber,
|
||||
generateCss,
|
||||
parseSettings,
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
function insertTextAtSelectionValue(value, selectionStart, selectionEnd, text) {
|
||||
const start = Number.isInteger(selectionStart) ? selectionStart : value.length;
|
||||
const end = Number.isInteger(selectionEnd) ? selectionEnd : start;
|
||||
const nextValue = `${value.slice(0, start)}${text}${value.slice(end)}`;
|
||||
const nextSelection = start + text.length;
|
||||
|
||||
return {
|
||||
value: nextValue,
|
||||
selectionStart: nextSelection,
|
||||
selectionEnd: nextSelection,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
insertTextAtSelectionValue,
|
||||
};
|
||||
@@ -0,0 +1,118 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
|
||||
const {
|
||||
formatNumber,
|
||||
generateCss,
|
||||
parseSettings,
|
||||
} = require('../src/fluidTypography');
|
||||
|
||||
const sampleInput = `Low: 360px
|
||||
High: 1920px
|
||||
|
||||
text-14px: 12px-14px
|
||||
text-18px: 16px-18px`;
|
||||
|
||||
test('parseSettings reads breakpoints and font-size variable ranges', () => {
|
||||
assert.deepEqual(parseSettings(sampleInput), {
|
||||
css: 'tailwind',
|
||||
round: true,
|
||||
roundValue: '2px',
|
||||
low: 360,
|
||||
high: 1920,
|
||||
sizes: [
|
||||
{ name: 'text-14px', min: 12, max: 14 },
|
||||
{ name: 'text-18px', min: 16, max: 18 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('parseSettings reads CSS and rounding overrides', () => {
|
||||
assert.deepEqual(parseSettings(`CSS: vanilla
|
||||
Round: no
|
||||
Low: 320px
|
||||
High: 1440px
|
||||
text-20px: 18px-20px`), {
|
||||
css: 'vanilla',
|
||||
round: false,
|
||||
roundValue: '2px',
|
||||
low: 320,
|
||||
high: 1440,
|
||||
sizes: [
|
||||
{ name: 'text-20px', min: 18, max: 20 },
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(parseSettings(`Round: yes, 4px
|
||||
Low: 360px
|
||||
High: 1920px
|
||||
text-20px: 18px-20px`).roundValue, '4px');
|
||||
});
|
||||
|
||||
test('parseSettings uses provided defaults when input omits optional settings', () => {
|
||||
const settings = parseSettings(sampleInput, {
|
||||
css: 'vanilla',
|
||||
round: false,
|
||||
roundValue: '1px',
|
||||
});
|
||||
|
||||
assert.equal(settings.css, 'vanilla');
|
||||
assert.equal(settings.round, false);
|
||||
assert.equal(settings.roundValue, '1px');
|
||||
});
|
||||
|
||||
test('parseSettings reports missing breakpoints and malformed ranges', () => {
|
||||
assert.throws(
|
||||
() => parseSettings('Low: 360px\ntext-14px: 12px/14px'),
|
||||
/Missing High breakpoint[\s\S]*Line 2/,
|
||||
);
|
||||
});
|
||||
|
||||
test('formatNumber trims unnecessary trailing zeros', () => {
|
||||
assert.equal(formatNumber(1), '1');
|
||||
assert.equal(formatNumber(1.25), '1.25');
|
||||
assert.equal(formatNumber(0.721153846), '0.7212');
|
||||
});
|
||||
|
||||
test('generateCss emits comments, viewport table, and rounded clamp variables', () => {
|
||||
const css = generateCss(sampleInput);
|
||||
|
||||
assert.match(css, /text-14px: 12px-14px/);
|
||||
assert.match(css, /Font sizes at standard viewport widths:/);
|
||||
assert.match(css, /text-14px \| 12\.00 \| 12\.36/);
|
||||
assert.match(
|
||||
css,
|
||||
/--text-14px: round\(down, clamp\(0\.75rem, 0\.7212rem \+ 0\.1282vw, 0\.875rem\), 2px\);/,
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/--text-18px: round\(down, clamp\(1rem, 0\.9712rem \+ 0\.1282vw, 1\.125rem\), 2px\);/,
|
||||
);
|
||||
});
|
||||
|
||||
test('generateCss emits vanilla CSS and unrounded clamp values', () => {
|
||||
const css = generateCss(`CSS: vanilla
|
||||
Round: no
|
||||
Low: 360px
|
||||
High: 1920px
|
||||
text-14px: 12px-14px`);
|
||||
|
||||
assert.match(css, /:root \{/);
|
||||
assert.doesNotMatch(css, /@theme static/);
|
||||
assert.doesNotMatch(css, /round\(down/);
|
||||
assert.match(
|
||||
css,
|
||||
/--text-14px: clamp\(0\.75rem, 0\.7212rem \+ 0\.1282vw, 0\.875rem\);/,
|
||||
);
|
||||
});
|
||||
|
||||
test('generateCss uses configured defaults unless project input overrides them', () => {
|
||||
const css = generateCss(sampleInput, {
|
||||
css: 'vanilla',
|
||||
round: true,
|
||||
roundValue: '4px',
|
||||
});
|
||||
|
||||
assert.match(css, /:root \{/);
|
||||
assert.match(css, /round\(down, clamp\(0\.75rem, 0\.7212rem \+ 0\.1282vw, 0\.875rem\), 4px\);/);
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
|
||||
const { insertTextAtSelectionValue } = require('../src/webviewText');
|
||||
|
||||
test('insertTextAtSelectionValue inserts clipboard text at the caret', () => {
|
||||
assert.deepEqual(
|
||||
insertTextAtSelectionValue('text-14px: 12px-14px', 20, 20, '\ntext-16px: 14px-16px'),
|
||||
{
|
||||
value: 'text-14px: 12px-14px\ntext-16px: 14px-16px',
|
||||
selectionStart: 41,
|
||||
selectionEnd: 41,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('insertTextAtSelectionValue replaces selected text', () => {
|
||||
assert.deepEqual(
|
||||
insertTextAtSelectionValue('before OLD after', 7, 10, 'NEW'),
|
||||
{
|
||||
value: 'before NEW after',
|
||||
selectionStart: 10,
|
||||
selectionEnd: 10,
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
/* Basic typographical styles */
|
||||
|
||||
/**
|
||||
* All font sizes are based on 16px base font size and 1920px wide screen
|
||||
* Default size is expressed as percentage of screen width.
|
||||
* text-14px: 12px-27px, default: 14px
|
||||
* text-16px: 14px-28px, default: 16px
|
||||
* text-18px: 14px-30px, default: 18px
|
||||
* text-20px: 16px-32px, default: 20px
|
||||
* text-22px: 17px-33px, default: 22px
|
||||
* text-25px: 18px-35px, default: 25px
|
||||
* text-30px: 19px-37px, default: 30px
|
||||
* text-35px: 20px-40px, default: 35px
|
||||
* text-38px: 22px-48px, default: 38px
|
||||
* text-40px: 24px-56px, default: 40px
|
||||
* text-45px: 25px-64px, default: 45px
|
||||
* text-50px: 27px-72px, default: 50px
|
||||
* text-55px: 28px-76px, default: 55px
|
||||
* text-60px: 30px-80px, default: 60px
|
||||
* text-70px: 30px-76px, default: 70px
|
||||
* text-75px: 32px-80px, default: 75px
|
||||
*
|
||||
* Font sizes at standard viewport widths:
|
||||
* | 360px | 640px | 768px | 1024px | 1280px | 1440px | 1920px
|
||||
* |-------|-------|-------|--------|--------|--------|-------
|
||||
* text-14px | 12.00 | 12.36 | 12.52 | 12.85 | 13.18 | 13.38 | 14.00
|
||||
* text-16px | 14.00 | 14.36 | 14.52 | 14.85 | 15.18 | 15.38 | 16.00
|
||||
* text-18px | 14.00 | 14.72 | 15.05 | 15.70 | 16.36 | 16.77 | 18.00
|
||||
* text-20px | 16.00 | 16.72 | 17.05 | 17.70 | 18.36 | 18.77 | 20.00
|
||||
* text-22px | 17.60 | 18.36 | 18.75 | 19.47 | 20.19 | 20.65 | 22.00
|
||||
* text-25px | 18.00 | 19.26 | 19.83 | 20.98 | 22.13 | 22.85 | 25.00
|
||||
* text-30px | 18.96 | 20.89 | 21.85 | 23.66 | 25.47 | 26.60 | 30.00
|
||||
* text-35px | 20.00 | 22.69 | 23.92 | 26.38 | 28.85 | 30.38 | 35.00
|
||||
* text-38px | 22.40 | 24.85 | 26.48 | 29.04 | 31.60 | 33.20 | 38.00
|
||||
* text-40px | 24.00 | 26.87 | 28.18 | 30.81 | 33.44 | 35.08 | 40.00
|
||||
* text-45px | 25.60 | 29.22 | 30.67 | 33.86 | 37.04 | 39.03 | 45.00
|
||||
* text-50px | 27.20 | 31.58 | 33.16 | 36.90 | 40.65 | 42.98 | 50.00
|
||||
* text-70px | 30.40 | 37.01 | 40.76 | 47.26 | 53.75 | 57.82 | 70.00
|
||||
* text-75px | 32.00 | 39.46 | 43.25 | 50.30 | 57.36 | 61.77 | 75.00
|
||||
*/
|
||||
|
||||
@theme static {
|
||||
--text-base: 1rem;
|
||||
--text-14px: round(down, clamp(0.75rem, 0.7212rem + 0.1282vw, 0.875rem), 2px);
|
||||
--text-16px: round(down, clamp(0.875rem, 0.8462rem + 0.1282vw, 1rem), 2px);
|
||||
--text-18px: round(down, clamp(0.875rem, 0.8173rem + 0.2564vw, 1.125rem), 2px);
|
||||
--text-20px: round(down, clamp(1rem, 0.9423rem + 0.2564vw, 1.25rem), 2px);
|
||||
--text-22px: round(down, clamp(1.1rem, 1.0365rem + 0.2821vw, 1.375rem), 2px);
|
||||
--text-25px: round(down, clamp(1.125rem, 1.024rem + 0.4487vw, 1.5625rem), 2px);
|
||||
--text-30px: round(down, clamp(1.185rem, 1.0258rem + 0.7077vw, 1.875rem), 2px);
|
||||
--text-35px: round(down, clamp(1.25rem, 1.0337rem + 0.9615vw, 2.1875rem), 2px);
|
||||
--text-38px: round(down, clamp(1.4rem, 1.175rem + 1vw, 2.375rem), 2px);
|
||||
--text-40px: round(down, clamp(2.25rem, 1.2692rem + 1.0256vw, 2.5rem), 2px);
|
||||
--text-45px: round(down, clamp(1.6rem, 1.3202rem + 1.2436vw, 2.8125rem), 2px);
|
||||
--text-50px: round(down, clamp(1.7rem, 1.3712rem + 1.4615vw, 3.125rem), 2px);
|
||||
--text-70px: round(down, clamp(1.9rem, 1.3288rem + 2.5385vw, 4.375rem), 2px);
|
||||
--text-75px: round(down, clamp(2rem, 1.3798rem + 2.7564vw, 4.6875rem), 2px);
|
||||
|
||||
--h1: var(--text-70px);
|
||||
--h2: var(--text-50px);
|
||||
--h3: var(--text-35px);
|
||||
--h4: var(--text-30px);
|
||||
--h5: var(--text-25px);
|
||||
--h6: var(--text-20px);
|
||||
}
|
||||
Reference in New Issue
Block a user