sass-site/source/assets/js/playground/playground.ts

292 lines
8.1 KiB
TypeScript
Raw Normal View History

2023-06-05 19:55:56 +02:00
import { Diagnostic, setDiagnostics } from '@codemirror/lint';
import { Text } from '@codemirror/state';
import { EditorView } from 'codemirror';
2023-06-08 20:40:42 +02:00
import debounce from 'lodash.debounce';
import {
compileString,
Exception,
2023-06-07 21:13:23 +02:00
Logger,
OutputStyle,
2023-06-07 21:13:23 +02:00
SourceSpan,
Syntax,
} from '../vendor/playground';
2023-06-02 16:53:32 +02:00
import { editorSetup, outputSetup } from './editor-setup.js';
2023-06-07 21:13:23 +02:00
type ConsoleLogDebug = {
options: {
span: SourceSpan;
};
message: string;
type: 'debug';
};
type ConsoleLogWarning = {
options: {
deprecation: boolean;
span?: SourceSpan | undefined;
stack?: string | undefined;
};
message: string;
type: 'warn';
};
type ConsoleLogError = {
type: 'error';
error: Exception | unknown;
};
type ConsoleLog = ConsoleLogDebug | ConsoleLogWarning | ConsoleLogError;
2023-06-06 22:02:26 +02:00
type PlaygroundState = {
inputFormat: Syntax;
outputFormat: OutputStyle;
inputValue: string;
compilerHasError: boolean;
2023-06-07 21:13:23 +02:00
debugOutput: ConsoleLog[];
2023-06-06 22:02:26 +02:00
};
2023-06-08 20:30:30 +02:00
/**
* Encode the HTML in a user-submitted string to print safely using innerHTML
* Adapted from https://vanillajstoolkit.com/helpers/encodehtml/
* @param {string} str The user-submitted string
* @return {string} The sanitized string
*/
function encodeHTML(str: string): string {
return str.replace(/[^\w-_. ]/gi, function (c) {
return `&#${c.charCodeAt(0)};`;
});
}
2023-06-06 17:33:41 +02:00
function setupPlayground() {
2023-06-07 15:46:39 +02:00
const initialState: PlaygroundState = {
2023-06-06 22:02:26 +02:00
inputFormat: 'scss',
outputFormat: 'expanded',
compilerHasError: false,
inputValue: '',
2023-06-07 21:13:23 +02:00
debugOutput: [],
2023-06-06 22:02:26 +02:00
};
2023-06-07 15:46:39 +02:00
const playgroundState = new Proxy(initialState, {
set(state: PlaygroundState, prop: keyof PlaygroundState, ...rest) {
// Set state first so called functions have access
const set = Reflect.set(state, prop, ...rest);
if (['inputFormat', 'outputFormat'].includes(prop)) {
updateButtonState();
debouncedUpdateCSS();
} else if (prop === 'compilerHasError') {
updateErrorState();
} else if (prop === 'inputValue') {
debouncedUpdateCSS();
}
return set;
},
});
const editor = new EditorView({
extensions: [
...editorSetup,
EditorView.updateListener.of((v) => {
if (v.docChanged) {
2023-06-06 22:02:26 +02:00
playgroundState.inputValue = editor.state.doc.toString();
}
}),
],
parent: document.getElementById('editor') || document.body,
});
// Setup CSS view
const viewer = new EditorView({
extensions: [...outputSetup],
parent: document.getElementById('css-view') || document.body,
});
type TabbarItemDataset =
| {
value: Syntax;
setting: 'inputFormat';
}
| {
value: OutputStyle;
setting: 'outputFormat';
};
function attachListeners() {
function clickHandler(event: Event) {
if (event.currentTarget instanceof HTMLElement) {
const settings = event.currentTarget.dataset as TabbarItemDataset;
if (settings.setting === 'inputFormat') {
playgroundState.inputFormat = settings.value;
} else {
playgroundState.outputFormat = settings.value;
}
}
}
2023-06-06 22:02:26 +02:00
const options = document.querySelectorAll('[data-value]');
Array.from(options).forEach((option) => {
option.addEventListener('click', clickHandler);
});
}
2023-06-07 15:46:39 +02:00
/**
* updateButtonState
* Applies playgroundState to the buttons
* Called by state's proxy setter
*/
2023-06-06 22:02:26 +02:00
function updateButtonState() {
const inputFormatTab = document.querySelector(
'[data-setting="inputFormat"]',
) as HTMLDivElement;
inputFormatTab.dataset.active = playgroundState.inputFormat;
const outputFormatTab = document.querySelector(
'[data-setting="outputFormat"]',
) as HTMLDivElement;
outputFormatTab.dataset.active = playgroundState.outputFormat;
}
2023-06-07 15:46:39 +02:00
/**
* updateErrorState
* Applies error state
* Called by state's proxy setter
*/
2023-06-06 22:02:26 +02:00
function updateErrorState() {
const editorWrapper = document.querySelector(
'[data-compiler-has-error]',
) as HTMLDivElement;
editorWrapper.dataset.compilerHasError =
playgroundState.compilerHasError.toString();
}
2023-06-07 21:13:23 +02:00
function updateDebugOutput() {
const console = document.querySelector('.console') as HTMLDivElement;
console.innerHTML = playgroundState.debugOutput
.map(displayForConsoleLog)
.join('\n');
}
function lineNumberFormatter(number?: number): string {
2023-06-08 20:30:30 +02:00
if (typeof number === 'undefined') return '';
2023-06-07 21:13:23 +02:00
number = number + 1;
return `${number} `;
}
function displayForConsoleLog(item: ConsoleLog): string {
2023-06-08 21:06:03 +02:00
const data: { type: string; lineNumber?: number; message: string } = {
type: item.type,
lineNumber: undefined,
message: '',
};
2023-06-07 21:13:23 +02:00
if (item.type === 'error') {
if (item.error instanceof Exception) {
2023-06-08 21:06:03 +02:00
data.lineNumber = item.error.span.start.line;
2023-06-07 21:13:23 +02:00
}
2023-06-08 21:06:03 +02:00
data.message = item.error?.toString() || '';
2023-06-07 21:13:23 +02:00
} else if (['debug', 'warn'].includes(item.type)) {
2023-06-08 21:06:03 +02:00
data.message = item.message;
2023-06-08 20:30:30 +02:00
let lineNumber = item.options.span?.start?.line;
if (typeof lineNumber === 'undefined') {
const stack = 'stack' in item.options ? item.options.stack : '';
const needleFromStackRegex = /^- (\d+):/;
const match = stack?.match(needleFromStackRegex);
if (match && match[1]) {
// Stack trace starts at 1, all others come from span, which starts at 0, so adjust before formatting.
lineNumber = parseInt(match[1]) - 1;
}
}
2023-06-08 21:06:03 +02:00
data.lineNumber = lineNumber;
}
return `<p><span class="console-type console-type-${data.type}">@${
data.type
}</span>:${lineNumberFormatter(data.lineNumber)} ${encodeHTML(
data.message,
)}</p>`;
2023-06-07 21:13:23 +02:00
}
function updateCSS() {
2023-06-07 21:13:23 +02:00
playgroundState.debugOutput = [];
2023-06-06 22:02:26 +02:00
const result = parse(playgroundState.inputValue);
if ('css' in result) {
2023-06-05 19:55:56 +02:00
const text = Text.of(result.css.split('\n'));
viewer.dispatch({
changes: {
from: 0,
to: viewer.state.doc.toString().length,
insert: text,
},
});
editor.dispatch(setDiagnostics(editor.state, []));
2023-06-06 22:02:26 +02:00
playgroundState.compilerHasError = false;
2023-06-05 19:55:56 +02:00
} else {
const diagnostic = errorToDiagnostic(result.error);
const transaction = setDiagnostics(editor.state, [diagnostic]);
editor.dispatch(transaction);
2023-06-06 22:02:26 +02:00
playgroundState.compilerHasError = true;
2023-06-07 21:13:23 +02:00
playgroundState.debugOutput = [
...playgroundState.debugOutput,
{ type: 'error', error: result.error },
];
2023-06-05 19:55:56 +02:00
}
2023-06-07 21:13:23 +02:00
updateDebugOutput();
}
2023-06-08 20:40:42 +02:00
const debouncedUpdateCSS = debounce(updateCSS, 200);
2023-06-07 21:13:23 +02:00
const logger: Logger = {
warn(message, options) {
playgroundState.debugOutput = [
...playgroundState.debugOutput,
{ message, options, type: 'warn' },
];
},
debug(message, options) {
playgroundState.debugOutput = [
...playgroundState.debugOutput,
{ message, options, type: 'debug' },
];
},
};
2023-06-05 19:55:56 +02:00
type ParseResultSuccess = { css: string };
type ParseResultError = { error: Exception | unknown };
2023-06-05 19:55:56 +02:00
type ParseResult = ParseResultSuccess | ParseResultError;
function parse(css: string): ParseResult {
try {
2023-06-05 19:55:56 +02:00
const result = compileString(css, {
2023-06-06 22:02:26 +02:00
syntax: playgroundState.inputFormat,
style: playgroundState.outputFormat,
2023-06-07 21:13:23 +02:00
logger: logger,
2023-06-05 19:55:56 +02:00
});
return { css: result.css };
} catch (error) {
2023-06-05 19:55:56 +02:00
return { error };
}
2023-06-05 19:55:56 +02:00
}
function errorToDiagnostic(error: Exception | unknown): Diagnostic {
if (error instanceof Exception) {
return {
from: error.span.start.offset,
to: error.span.end.offset,
severity: 'error',
message: error.toString(),
};
} else {
let errorString = 'Unknown compilation error';
if (typeof error === 'string') errorString = error;
else if (typeof error?.toString() === 'string')
errorString = error.toString();
return {
from: 0,
to: 0,
severity: 'error',
message: errorString,
};
}
}
attachListeners();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', setupPlayground);
} else {
setupPlayground();
}