diff --git a/src/playground/buildBiomeConfiguration.ts b/src/playground/buildBiomeConfiguration.ts new file mode 100644 index 000000000..77d2ddedb --- /dev/null +++ b/src/playground/buildBiomeConfiguration.ts @@ -0,0 +1,458 @@ +import type { Configuration } from "@biomejs/wasm-web"; +import { LINT_RULES } from "@/playground/generated/lintRules.ts"; +import { + ArrowParentheses, + AttributePosition, + defaultPlaygroundState, + Expand, + IndentStyle, + OperatorLinebreak, + type PlaygroundSettings, + QuoteProperties, + QuoteStyle, + Semicolons, +} from "@/playground/types"; + +type LinterRules = NonNullable["rules"]>; +type LintRuleGroup = Exclude; + +const LINT_RULE_GROUPS = Object.entries(LINT_RULES).filter((entry) => { + return typeof entry[1] === "object"; +}) as Array<[LintRuleGroup, Record]>; + +function getBiomeIndentStyle(indentStyle: PlaygroundSettings["indentStyle"]) { + return indentStyle === IndentStyle.Tab ? IndentStyle.Tab : IndentStyle.Space; +} + +function getPlaygroundIndentStyle(indentStyle: IndentStyle | undefined) { + if (indentStyle === IndentStyle.Space) { + return IndentStyle.Space; + } + + if (indentStyle === IndentStyle.Tab) { + return IndentStyle.Tab; + } + + return undefined; +} + +function getBiomeAttributePosition( + attributePosition: PlaygroundSettings["attributePosition"], +) { + return attributePosition === AttributePosition.Auto + ? AttributePosition.Auto + : AttributePosition.Multiline; +} + +function getPlaygroundAttributePosition( + attributePosition: AttributePosition | undefined, +) { + if (attributePosition === AttributePosition.Multiline) { + return AttributePosition.Multiline; + } + + if (attributePosition === AttributePosition.Auto) { + return AttributePosition.Auto; + } + + return undefined; +} + +function getBiomeExpand(expand: PlaygroundSettings["expand"]) { + switch (expand) { + case Expand.Always: + return Expand.Always; + case Expand.Never: + return Expand.Never; + default: + return Expand.Auto; + } +} + +function getPlaygroundExpand(expand: Expand | undefined) { + switch (expand) { + case Expand.Always: + return Expand.Always; + case Expand.Never: + return Expand.Never; + case Expand.Auto: + return Expand.Auto; + default: + return undefined; + } +} + +function getBiomeQuoteStyle(quoteStyle: PlaygroundSettings["quoteStyle"]) { + return quoteStyle === QuoteStyle.Double + ? QuoteStyle.Double + : QuoteStyle.Single; +} + +function getPlaygroundQuoteStyle(quoteStyle: QuoteStyle | undefined) { + if (quoteStyle === QuoteStyle.Single) { + return QuoteStyle.Single; + } + + if (quoteStyle === QuoteStyle.Double) { + return QuoteStyle.Double; + } + + return undefined; +} + +function getBiomeQuoteProperties( + quoteProperties: PlaygroundSettings["quoteProperties"], +) { + return quoteProperties === QuoteProperties.Preserve ? "preserve" : "asNeeded"; +} + +function getPlaygroundQuoteProperties( + quoteProperties: "preserve" | "asNeeded" | undefined, +) { + if (quoteProperties === "preserve") { + return QuoteProperties.Preserve; + } + + if (quoteProperties === "asNeeded") { + return QuoteProperties.AsNeeded; + } + + return undefined; +} + +function getBiomeSemicolons(semicolons: PlaygroundSettings["semicolons"]) { + return semicolons === Semicolons.Always ? "always" : "asNeeded"; +} + +function getPlaygroundSemicolons( + semicolons: "always" | "asNeeded" | undefined, +) { + if (semicolons === "asNeeded") { + return Semicolons.AsNeeded; + } + + if (semicolons === "always") { + return Semicolons.Always; + } + + return undefined; +} + +function getBiomeArrowParentheses( + arrowParentheses: PlaygroundSettings["arrowParentheses"], +) { + return arrowParentheses === ArrowParentheses.Always ? "always" : "asNeeded"; +} + +function getPlaygroundArrowParentheses( + arrowParentheses: "always" | "asNeeded" | undefined, +) { + if (arrowParentheses === "asNeeded") { + return ArrowParentheses.AsNeeded; + } + + if (arrowParentheses === "always") { + return ArrowParentheses.Always; + } + + return undefined; +} + +function getBiomeOperatorLinebreak( + operatorLinebreak: PlaygroundSettings["operatorLinebreak"], +) { + return operatorLinebreak === OperatorLinebreak.Before + ? OperatorLinebreak.Before + : OperatorLinebreak.After; +} + +function getPlaygroundOperatorLinebreak( + operatorLinebreak: OperatorLinebreak | undefined, +) { + if (operatorLinebreak === OperatorLinebreak.Before) { + return OperatorLinebreak.Before; + } + + if (operatorLinebreak === OperatorLinebreak.After) { + return OperatorLinebreak.After; + } + + return undefined; +} + +function getLintRuleGroup( + lintRule: PlaygroundSettings["lintRules"], +): LintRuleGroup | undefined { + for (const [groupName, rules] of LINT_RULE_GROUPS) { + if (lintRule in rules) { + return groupName; + } + } + + return undefined; +} + +function getLintRulesConfiguration( + lintRules: PlaygroundSettings["lintRules"], +): LinterRules { + switch (lintRules) { + case LINT_RULES.recommended: + return { + nursery: { + recommended: false, + }, + }; + case LINT_RULES.all: + return { + a11y: "on", + nursery: "on", + complexity: "on", + correctness: "on", + performance: "on", + security: "on", + style: "on", + suspicious: "on", + }; + default: { + const lintRuleGroup = getLintRuleGroup(lintRules); + + if (lintRuleGroup !== undefined) { + return { + recommended: false, + [lintRuleGroup]: { + [lintRules]: "on", + }, + } as LinterRules; + } + + return { + recommended: false, + }; + } + } +} + +function getPlaygroundLintRules(linterRules: LinterRules | undefined) { + if (!linterRules || typeof linterRules !== "object") { + return undefined; + } + + const rules = linterRules as Record; + const enabledGroups = [ + rules.a11y, + rules.nursery, + rules.complexity, + rules.correctness, + rules.performance, + rules.security, + rules.style, + rules.suspicious, + ]; + + for (const [groupName, groupRules] of LINT_RULE_GROUPS) { + const groupConfiguration = rules[groupName]; + + if (!groupConfiguration || typeof groupConfiguration !== "object") { + continue; + } + + for (const lintRule of Object.keys(groupRules)) { + if ((groupConfiguration as Record)[lintRule] === "on") { + return lintRule as PlaygroundSettings["lintRules"]; + } + } + } + + if (enabledGroups.every((value) => value === "on")) { + return LINT_RULES.all; + } + + if ( + "nursery" in rules && + typeof rules.nursery === "object" && + rules.nursery !== null && + "recommended" in rules.nursery && + (rules.nursery as { recommended?: unknown }).recommended === false + ) { + return LINT_RULES.recommended; + } + + return undefined; +} + +export function buildBiomeConfiguration( + settings: PlaygroundSettings, +): Configuration { + const { + lineWidth, + indentStyle, + indentWidth, + quoteStyle, + jsxQuoteStyle, + quoteProperties, + lintRules, + enabledLinting, + trailingCommas, + semicolons, + arrowParentheses, + operatorLinebreak, + bracketSpacing, + bracketSameLine, + expand, + indentScriptAndStyle, + whitespaceSensitivity, + enabledAssist, + unsafeParameterDecoratorsEnabled, + allowComments, + attributePosition, + ruleDomains, + experimentalEmbeddedSnippetsEnabled, + experimentalFullSupportEnabled, + cssModules, + tailwindDirectives, + } = settings; + + const configuration: Configuration = { + formatter: { + enabled: true, + formatWithErrors: true, + lineWidth, + indentStyle: getBiomeIndentStyle(indentStyle), + indentWidth, + attributePosition: getBiomeAttributePosition(attributePosition), + expand: getBiomeExpand(expand), + }, + linter: { + enabled: enabledLinting, + domains: ruleDomains, + rules: getLintRulesConfiguration(lintRules), + }, + assist: { + enabled: enabledAssist, + }, + javascript: { + formatter: { + quoteStyle: getBiomeQuoteStyle(quoteStyle), + jsxQuoteStyle: getBiomeQuoteStyle(jsxQuoteStyle), + quoteProperties: getBiomeQuoteProperties(quoteProperties), + trailingCommas, + semicolons: getBiomeSemicolons(semicolons), + arrowParentheses: getBiomeArrowParentheses(arrowParentheses), + operatorLinebreak: getBiomeOperatorLinebreak(operatorLinebreak), + bracketSpacing, + bracketSameLine, + attributePosition: getBiomeAttributePosition(attributePosition), + }, + parser: { + unsafeParameterDecoratorsEnabled, + }, + experimentalEmbeddedSnippetsEnabled, + }, + css: { + formatter: { + quoteStyle: getBiomeQuoteStyle(quoteStyle), + }, + parser: { + allowWrongLineComments: true, + cssModules, + tailwindDirectives, + }, + }, + json: { + parser: { + allowComments, + }, + }, + html: { + formatter: { + enabled: true, + indentScriptAndStyle, + whitespaceSensitivity, + }, + experimentalFullSupportEnabled, + }, + }; + + return configuration; +} + +export function parseBiomeConfiguration( + configuration: Configuration, + currentSettings: PlaygroundSettings = defaultPlaygroundState.settings, +): PlaygroundSettings { + const defaults = currentSettings; + const formatter = configuration.formatter; + const javascript = configuration.javascript; + const javascriptFormatter = javascript?.formatter; + const javascriptParser = javascript?.parser; + const css = configuration.css; + const cssFormatter = css?.formatter; + const cssParser = css?.parser; + const json = configuration.json; + const jsonParser = json?.parser; + const html = configuration.html; + const htmlFormatter = html?.formatter; + const linter = configuration.linter; + const assist = configuration.assist; + + return { + ...defaults, + lineWidth: formatter?.lineWidth ?? defaults.lineWidth, + indentStyle: + getPlaygroundIndentStyle(formatter?.indentStyle) ?? defaults.indentStyle, + indentWidth: formatter?.indentWidth ?? defaults.indentWidth, + quoteStyle: + getPlaygroundQuoteStyle(javascriptFormatter?.quoteStyle) ?? + getPlaygroundQuoteStyle(cssFormatter?.quoteStyle) ?? + defaults.quoteStyle, + jsxQuoteStyle: + getPlaygroundQuoteStyle(javascriptFormatter?.jsxQuoteStyle) ?? + defaults.jsxQuoteStyle, + quoteProperties: + getPlaygroundQuoteProperties(javascriptFormatter?.quoteProperties) ?? + defaults.quoteProperties, + trailingCommas: + javascriptFormatter?.trailingCommas ?? defaults.trailingCommas, + semicolons: + getPlaygroundSemicolons(javascriptFormatter?.semicolons) ?? + defaults.semicolons, + arrowParentheses: + getPlaygroundArrowParentheses(javascriptFormatter?.arrowParentheses) ?? + defaults.arrowParentheses, + operatorLinebreak: + getPlaygroundOperatorLinebreak(javascriptFormatter?.operatorLinebreak) ?? + defaults.operatorLinebreak, + attributePosition: + getPlaygroundAttributePosition(javascriptFormatter?.attributePosition) ?? + getPlaygroundAttributePosition(formatter?.attributePosition) ?? + defaults.attributePosition, + bracketSpacing: + javascriptFormatter?.bracketSpacing ?? defaults.bracketSpacing, + bracketSameLine: + javascriptFormatter?.bracketSameLine ?? defaults.bracketSameLine, + expand: getPlaygroundExpand(formatter?.expand) ?? defaults.expand, + lintRules: getPlaygroundLintRules(linter?.rules) ?? defaults.lintRules, + enabledLinting: linter?.enabled ?? defaults.enabledLinting, + analyzerFixMode: defaults.analyzerFixMode, + enabledAssist: assist?.enabled ?? defaults.enabledAssist, + unsafeParameterDecoratorsEnabled: + javascriptParser?.unsafeParameterDecoratorsEnabled ?? + defaults.unsafeParameterDecoratorsEnabled, + allowComments: jsonParser?.allowComments ?? defaults.allowComments, + ruleDomains: linter?.domains ?? defaults.ruleDomains, + indentScriptAndStyle: + htmlFormatter?.indentScriptAndStyle ?? defaults.indentScriptAndStyle, + whitespaceSensitivity: + htmlFormatter?.whitespaceSensitivity ?? defaults.whitespaceSensitivity, + experimentalEmbeddedSnippetsEnabled: + javascript?.experimentalEmbeddedSnippetsEnabled ?? + defaults.experimentalEmbeddedSnippetsEnabled, + experimentalFullSupportEnabled: + html?.experimentalFullSupportEnabled ?? + defaults.experimentalFullSupportEnabled, + cssModules: cssParser?.cssModules ?? defaults.cssModules, + tailwindDirectives: + cssParser?.tailwindDirectives ?? defaults.tailwindDirectives, + gritTargetLanguage: defaults.gritTargetLanguage, + }; +} diff --git a/src/playground/components/SettingsJsonEditorModal.tsx b/src/playground/components/SettingsJsonEditorModal.tsx new file mode 100644 index 000000000..f5a6c9aae --- /dev/null +++ b/src/playground/components/SettingsJsonEditorModal.tsx @@ -0,0 +1,184 @@ +import type { Configuration } from "@biomejs/wasm-web"; +import { json } from "@codemirror/lang-json"; +import { useEffect, useRef, useState } from "react"; +import { + buildBiomeConfiguration, + parseBiomeConfiguration, +} from "@/playground/buildBiomeConfiguration"; +import CodeMirror from "@/playground/CodeMirror"; +import type { PlaygroundSettings } from "@/playground/types"; + +interface SettingsJsonEditorModalProps { + isOpen: boolean; + settings: PlaygroundSettings; + onApply: (settings: PlaygroundSettings) => void; + onClose: () => void; +} + +function createEditableConfiguration( + settings: PlaygroundSettings, +): Configuration { + return { + $schema: "https://biomejs.dev/schemas//schema.json", + ...buildBiomeConfiguration(settings), + }; +} + +export default function SettingsJsonEditorModal({ + isOpen, + settings, + onApply, + onClose, +}: SettingsJsonEditorModalProps) { + const dialogRef = useRef(null); + const copyStatusTimeoutRef = useRef(null); + const wasOpenRef = useRef(false); + const [configurationAsJson, setConfigurationAsJson] = useState(() => + JSON.stringify(createEditableConfiguration(settings), null, 2), + ); + const [jsonError, setJsonError] = useState(null); + const [copyStatus, setCopyStatus] = useState<"idle" | "copied">("idle"); + + useEffect(() => { + return () => { + if (copyStatusTimeoutRef.current !== null) { + window.clearTimeout(copyStatusTimeoutRef.current); + } + }; + }, []); + + useEffect(() => { + const dialog = dialogRef.current; + + if (isOpen && !wasOpenRef.current) { + setConfigurationAsJson( + JSON.stringify(createEditableConfiguration(settings), null, 2), + ); + setJsonError(null); + if (copyStatusTimeoutRef.current !== null) { + window.clearTimeout(copyStatusTimeoutRef.current); + copyStatusTimeoutRef.current = null; + } + setCopyStatus("idle"); + } + + if (!isOpen) { + if (dialog?.open) { + dialog.close(); + } + wasOpenRef.current = isOpen; + return; + } + + if (dialog === null || dialog.open) { + wasOpenRef.current = isOpen; + return; + } + + dialog.showModal(); + wasOpenRef.current = isOpen; + }, [isOpen, settings]); + + async function copyJsonSettings() { + await navigator.clipboard.writeText(configurationAsJson); + + if (copyStatusTimeoutRef.current !== null) { + window.clearTimeout(copyStatusTimeoutRef.current); + } + + setCopyStatus("copied"); + copyStatusTimeoutRef.current = window.setTimeout(() => { + setCopyStatus("idle"); + copyStatusTimeoutRef.current = null; + }, 2000); + } + + function applyJsonSettings() { + try { + const parsed = JSON.parse(configurationAsJson) as Configuration; + onApply(parseBiomeConfiguration(parsed, settings)); + onClose(); + } catch (error) { + setJsonError( + error instanceof Error ? error.message : "Invalid JSON settings.", + ); + } + } + + return ( + { + if (event.target === event.currentTarget) { + onClose(); + } + }} + onKeyDown={(event) => { + if ( + event.target === event.currentTarget && + (event.key === "Enter" || event.key === " ") + ) { + event.preventDefault(); + onClose(); + } + }} + onClose={onClose} + onCancel={(event) => { + event.preventDefault(); + onClose(); + }} + > +
{ + event.preventDefault(); + }} + > +
+

+ Effective Playground configuration +

+ +
+
+ biome.json + + Update the $schema version manually. + +
+ { + setConfigurationAsJson(value); + if (jsonError !== null) { + setJsonError(null); + } + }} + placeholder="{}" + autoFocus={true} + /> + {jsonError &&

{jsonError}

} +
+ + +
+ +
+ ); +} diff --git a/src/playground/tabs/SettingsTab.tsx b/src/playground/tabs/SettingsTab.tsx index a4de4203a..4c19962c8 100644 --- a/src/playground/tabs/SettingsTab.tsx +++ b/src/playground/tabs/SettingsTab.tsx @@ -7,6 +7,7 @@ import type { import type React from "react"; import { type Dispatch, type SetStateAction, useId, useState } from "react"; import EnumSelect from "@/playground/components/EnumSelect"; +import SettingsJsonEditorModal from "@/playground/components/SettingsJsonEditorModal"; import { LINT_RULES } from "@/playground/generated/lintRules.ts"; import { ArrowParentheses, @@ -76,6 +77,7 @@ export default function SettingsTab({ experimentalFullSupportEnabled, cssModules, tailwindDirectives, + gritTargetLanguage, }, }, }: SettingsTabProps) { @@ -320,6 +322,39 @@ export default function SettingsTab({ {singleFileMode ? "Multi-file mode" : "Single-file mode"} + {singleFileMode ? ( @@ -757,6 +792,37 @@ function SyntaxSettings({ ); } +function SettingsJsonEditorSection({ + settings, + setPlaygroundState, +}: { + settings: PlaygroundState["settings"]; + setPlaygroundState: Dispatch>; +}) { + const [isJsonModalOpen, setIsJsonModalOpen] = useState(false); + + return ( + <> +
+ +
+ { + setPlaygroundState((state) => ({ + ...state, + settings: nextSettings, + })); + }} + onClose={() => setIsJsonModalOpen(false)} + /> + + ); +} + function FormatterSettings({ lineWidth, setLineWidth, diff --git a/src/styles/playground/_settings.css b/src/styles/playground/_settings.css index 26892c36e..55cf83f34 100644 --- a/src/styles/playground/_settings.css +++ b/src/styles/playground/_settings.css @@ -279,3 +279,111 @@ } } } + +.settings-json-modal { + z-index: 1000; + width: min(840px, calc(100vw - 48px)); + height: min(640px, calc(100vh - 64px)); + padding: 0; + margin: auto; + border: 0; + background: transparent; + color: inherit; + overflow: visible; + max-width: none; + max-height: none; +} + +.settings-json-modal::backdrop { + background: rgb(0 0 0 / 72%); +} + +.settings-json-modal__content { + display: flex; + flex-direction: column; + gap: 0; + width: 100%; + height: 100%; + overflow: hidden; + border: 1px solid var(--sl-border); + border-radius: 14px; + background: #282c34; + color: rgb(226 232 240 / 92%); + box-shadow: 0 28px 80px rgb(0 0 0 / 35%); +} + +.settings-json-modal__header, +.settings-json-modal__actions, +.settings-json-modal__subheader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 10px 14px; + background: #282c34; +} + +.settings-json-modal__header h3 { + margin: 0; + font-size: 0.9375rem; + font-weight: 600; + letter-spacing: 0.01em; +} + +.settings-json-modal__close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + margin-left: auto; + font-size: 1.125rem; + line-height: 1; +} + +.settings-json-modal__actions { + justify-content: flex-end; + border-top: 1px solid var(--sl-border); +} + +.settings-json-modal__header { + border-bottom: 1px solid var(--sl-border); +} + +.settings-json-modal__subheader { + gap: 12px; + border-bottom: 1px solid var(--sl-border); + font-family: var(--code-font); + font-size: 0.75rem; + color: rgb(226 232 240 / 88%); +} + +.settings-json-modal__hint { + opacity: 0.64; + font-size: 0.6875rem; +} + +.settings-json-modal__editor { + flex: 1; + min-height: 0; + background: #282c34; +} + +.settings-json-modal__editor .cm-editor { + height: 100%; + font-size: 0.875rem; + background: #282c34; +} + +.settings-json-modal__editor .cm-scroller { + font-family: var(--code-font); + line-height: 1.6; +} + +.settings-json-modal__error { + margin: 0; + padding: 10px 14px 0; + font-size: 0.75rem; + color: rgb(248 113 113 / 92%); +}