├── .nvmrc ├── .husky ├── .gitignore └── pre-commit ├── .gitattributes ├── .npmrc ├── .prettierignore ├── src ├── vite-env.d.ts ├── style.css ├── components │ ├── defaults │ │ ├── config.mjs │ │ ├── deps.json │ │ └── css.css │ ├── output-tabs.ts │ ├── report.ts │ ├── console.ts │ ├── copy-link.ts │ ├── deps-editor.ts │ ├── config-editor.ts │ ├── code-editor.ts │ └── warnings.ts ├── monaco-editor │ ├── index.ts │ ├── monarch-syntaxes │ │ ├── refs │ │ │ └── tags.ts │ │ ├── stylus.ts │ │ ├── astro.ts │ │ └── svelte.ts │ ├── monaco-loader.ts │ └── monaco-setup.ts ├── utils │ ├── debounce.ts │ └── compress.ts ├── linter-service │ ├── server │ │ ├── extract-json.mjs │ │ └── server.mjs │ ├── installer.ts │ ├── server.ts │ └── index.ts ├── demo.html ├── main.ts ├── demo.css └── demo.ts ├── .stylelintignore ├── public └── favicon.ico ├── .gitignore ├── .vscode └── settings.json ├── netlify.toml ├── .env ├── .editorconfig ├── .github ├── CODEOWNERS ├── dependabot.yml └── workflows │ └── nodejs.yml ├── index.html ├── playwright.config.ts ├── vite.config.ts ├── tsconfig.json ├── vite.config.lib.ts ├── eslint.config.js ├── README.md ├── LICENSE ├── package.json └── tests └── e2e.spec.ts /.nvmrc: -------------------------------------------------------------------------------- 1 | 24 2 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx --no lint-staged 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | src/components/defaults/ 3 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/style.css: -------------------------------------------------------------------------------- 1 | #app { 2 | block-size: 100dvb; 3 | display: grid; 4 | } 5 | -------------------------------------------------------------------------------- /.stylelintignore: -------------------------------------------------------------------------------- 1 | /dist 2 | node_modules 3 | /src/components/defaults/css.css 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stylelint/stylelint-demo/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /src/components/defaults/config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | extends: ['stylelint-config-standard'], 3 | }; 4 | -------------------------------------------------------------------------------- /src/components/defaults/deps.json: -------------------------------------------------------------------------------- 1 | { 2 | "stylelint": "latest", 3 | "stylelint-config-standard": "latest" 4 | } 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | playwright-report 4 | test-results 5 | *.log 6 | .stylelintcache 7 | .eslintcache 8 | -------------------------------------------------------------------------------- /src/components/defaults/css.css: -------------------------------------------------------------------------------- 1 | a { 2 | grid-template-areas: 3 | 'a a' 4 | 'b b b'; 5 | colr: hsla(20, 10%, 30%, 5%); 6 | } 7 | 8 | a { 9 | --Foo: 1rem; 10 | } 11 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.defaultFormatter": "esbenp.prettier-vscode", 4 | "editor.codeActionsOnSave": { 5 | "source.fixAll": "explicit", 6 | "source.fixAll.stylelint": "explicit" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/monaco-editor/index.ts: -------------------------------------------------------------------------------- 1 | export { setupMonacoEditor } from './monaco-setup.js'; 2 | export { loadMonaco } from './monaco-loader.js'; 3 | export type { 4 | MonacoEditor, 5 | MonacoDiffEditor, 6 | MonacoEditorOptions, 7 | MonacoDiffEditorOptions, 8 | } from './monaco-setup.js'; 9 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | command = "npm run build" 3 | publish = "dist/" 4 | 5 | [[headers]] 6 | for = "/*" 7 | [headers.values] 8 | Cross-Origin-Embedder-Policy = "require-corp" 9 | Cross-Origin-Opener-Policy = "same-origin" 10 | Cross-Origin-Resource-Policy = "cross-origin" 11 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | # Environment Config 2 | 3 | # store your secrets and config variables in here 4 | # only invited collaborators will be able to see your .env values 5 | 6 | # reference these in your code with process.env.SECRET 7 | 8 | SECRET= 9 | MADE_WITH= 10 | 11 | # note: .env is a shell file so there can't be spaces around '= 12 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_style = tab 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [package.json] 12 | indent_style = space 13 | indent_size = 2 14 | 15 | [*.{md,css,yml}] 16 | indent_style = space 17 | indent_size = 2 18 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # https://help.github.com/articles/about-codeowners/ 2 | 3 | # All proposed file changes under the `.github/` folder must be approved by the owners team prior to merging. 4 | .github/ @stylelint/owners 5 | 6 | # Require approvals by the owners team when updating dependencies or package metadata. 7 | package.json @stylelint/owners 8 | -------------------------------------------------------------------------------- /src/utils/debounce.ts: -------------------------------------------------------------------------------- 1 | export function debounce void, A extends any[] = never[]>( 2 | fn: F, 3 | interval = 100, 4 | ): F { 5 | let timer: NodeJS.Timeout | undefined; 6 | 7 | return ((...args: any[]) => { 8 | clearTimeout(timer); 9 | timer = setTimeout(() => { 10 | fn(...(args as any)); 11 | }, interval); 12 | }) as unknown as F; 13 | } 14 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Stylelint Online Playground 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from '@playwright/test'; 2 | 3 | export default defineConfig({ 4 | testDir: './tests', 5 | timeout: 60_000, 6 | reporter: 'html', 7 | use: { 8 | baseURL: 'http://localhost:5173', 9 | }, 10 | projects: [ 11 | { 12 | name: 'chromium', 13 | use: { ...devices['Desktop Chrome'] }, 14 | }, 15 | ], 16 | webServer: { 17 | command: 'npm run dev', 18 | url: 'http://localhost:5173', 19 | }, 20 | }); 21 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import monacoEditorPlugin from 'vite-plugin-monaco-editor-esm'; 3 | 4 | export default defineConfig(() => ({ 5 | server: { 6 | headers: { 7 | 'Cross-Origin-Embedder-Policy': 'require-corp', 8 | 'Cross-Origin-Opener-Policy': 'same-origin', 9 | }, 10 | }, 11 | plugins: [ 12 | monacoEditorPlugin({ 13 | languageWorkers: ['editorWorkerService', 'css', 'html', 'json', 'typescript'], 14 | }), 15 | ], 16 | })); 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["ESNext", "DOM"], 7 | "moduleResolution": "Node", 8 | "strict": true, 9 | "resolveJsonModule": true, 10 | "isolatedModules": true, 11 | "esModuleInterop": true, 12 | "noEmit": true, 13 | "noUnusedLocals": true, 14 | "noUnusedParameters": true, 15 | "noImplicitReturns": true, 16 | "skipLibCheck": true, 17 | "checkJs": true, 18 | "allowJs": true 19 | }, 20 | "include": ["src"] 21 | } 22 | -------------------------------------------------------------------------------- /src/utils/compress.ts: -------------------------------------------------------------------------------- 1 | import LZString from 'lz-string'; 2 | 3 | export function compress(data: any) { 4 | try { 5 | return LZString.compressToEncodedURIComponent(JSON.stringify(data)); 6 | } catch { 7 | // return silently 8 | return ''; 9 | } 10 | } 11 | 12 | export function decompress(str: string): any { 13 | try { 14 | const data = JSON.parse(LZString.decompressFromEncodedURIComponent(str)!); 15 | 16 | return typeof data !== 'object' || data === null ? {} : data; 17 | } catch { 18 | // return silently 19 | return {}; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/components/output-tabs.ts: -------------------------------------------------------------------------------- 1 | export type Tabs = { 2 | setChecked: (dataRadioName: string) => void; 3 | }; 4 | export type TabsOptions = { 5 | /** Specify a target element to set up the tabs component. */ 6 | element: HTMLElement; 7 | }; 8 | 9 | /** Setup tabs component. */ 10 | export function setupTabs({ element }: TabsOptions): Tabs { 11 | return { 12 | setChecked: (dataRadioName) => { 13 | const radio = element.querySelector( 14 | `input[data-radio-name="${dataRadioName}"]`, 15 | )!; 16 | 17 | radio.checked = true; 18 | }, 19 | }; 20 | } 21 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: '/' 5 | schedule: 6 | interval: monthly 7 | open-pull-requests-limit: 5 8 | versioning-strategy: increase 9 | labels: 10 | - 'pr: dependencies' 11 | groups: 12 | development-dependencies: 13 | dependency-type: 'development' 14 | cooldown: 15 | default-days: 7 16 | 17 | - package-ecosystem: github-actions 18 | directory: '/' 19 | schedule: 20 | interval: monthly 21 | open-pull-requests-limit: 5 22 | labels: 23 | - 'pr: dependencies' 24 | cooldown: 25 | default-days: 7 26 | -------------------------------------------------------------------------------- /vite.config.lib.ts: -------------------------------------------------------------------------------- 1 | import type { UserConfigFn } from 'vite'; 2 | import baseConfig from './vite.config'; 3 | import { defineConfig } from 'vite'; 4 | import { fileURLToPath } from 'url'; 5 | import path from 'path'; 6 | import pkg from './package.json'; 7 | 8 | const dirname = path.dirname(fileURLToPath(import.meta.url)); 9 | 10 | export default defineConfig(async (env) => { 11 | const base = await (baseConfig as UserConfigFn)(env); 12 | 13 | return { 14 | ...base, 15 | build: { 16 | ...base.build, 17 | lib: { 18 | entry: path.resolve(dirname, './src/demo.ts'), 19 | fileName: 'stylelint-demo', 20 | formats: ['es'], 21 | }, 22 | rollupOptions: { 23 | external: Object.keys(pkg.dependencies), 24 | }, 25 | }, 26 | }; 27 | }); 28 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import globals from 'globals'; 2 | import stylelintConfig from 'eslint-config-stylelint'; 3 | import tseslint from 'typescript-eslint'; 4 | 5 | export default tseslint.config( 6 | { 7 | ignores: ['dist/*'], 8 | }, 9 | ...stylelintConfig, 10 | ...tseslint.configs.recommended, 11 | { 12 | files: ['**/*.ts'], 13 | languageOptions: { 14 | globals: { 15 | ...globals.browser, 16 | }, 17 | }, 18 | rules: { 19 | 'no-shadow': 'off', 20 | 'no-use-before-define': 'off', 21 | '@typescript-eslint/consistent-type-imports': 'error', 22 | '@typescript-eslint/no-explicit-any': 'off', 23 | '@typescript-eslint/no-non-null-assertion': 'off', 24 | 'n/no-missing-import': 'off', 25 | 'n/no-unpublished-import': 'off', 26 | 'n/no-unsupported-features/es-syntax': 'off', 27 | }, 28 | }, 29 | ); 30 | -------------------------------------------------------------------------------- /src/linter-service/server/extract-json.mjs: -------------------------------------------------------------------------------- 1 | const DIRECTIVE_OPEN = '{{{sd-json-start}}}'; 2 | const DIRECTIVE_CLOSE = '{{{sd-json-end}}}'; 3 | 4 | /** 5 | * If the value is JSON enclosed in directives, extract the value and parse the JSON to get the value. 6 | * @param {string} str 7 | */ 8 | export function extractJson(str) { 9 | if (!str.startsWith(DIRECTIVE_OPEN) || !str.endsWith(DIRECTIVE_CLOSE)) { 10 | return null; 11 | } 12 | 13 | return JSON.parse(str.slice(DIRECTIVE_OPEN.length, -DIRECTIVE_CLOSE.length)); 14 | } 15 | 16 | /** 17 | * Make the payload a string enclosed in directives. 18 | * @param {any} payload 19 | * @param {(key: string, value: any) => any} [replacer] 20 | */ 21 | export function createJsonPayload(payload, replacer) { 22 | return DIRECTIVE_OPEN + JSON.stringify(payload, replacer) + DIRECTIVE_CLOSE; 23 | } 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # stylelint-demo 2 | 3 | [![Build Status](https://github.com/stylelint/stylelint-demo/workflows/CI/badge.svg)](https://github.com/stylelint/stylelint-demo/actions) 4 | [![Netlify Status](https://api.netlify.com/api/v1/badges/9525ba74-5a0f-4ec7-8f36-3a305d880e55/deploy-status)](https://app.netlify.com/sites/chimerical-trifle-8d3c21/deploys) 5 | 6 | An online demo of [Stylelint](https://github.com/stylelint/stylelint). 7 | 8 | ## Development 9 | 10 | - `npm install` 11 | - `npm run dev` 12 | - Go to `http://localhost:5174/` 13 | 14 | ## Build static files 15 | 16 | - `npm install` 17 | - `npm run build` 18 | - Output `./dist/` 19 | 20 | ## Build lib 21 | 22 | - `npm install` 23 | - `npm run build:lib` 24 | - Output `./dist/` 25 | 26 | ## About 27 | 28 | This demo works by calling Stylelint in a Node.js process launched inside the browser using [WebContainers](https://webcontainers.io/). 29 | 30 | It is [deployed to Netlify](https://chimerical-trifle-8d3c21.netlify.app/). 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 - present Mike Allanson & Richard Hallows 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /src/components/report.ts: -------------------------------------------------------------------------------- 1 | export type ReportOptions = { 2 | element: HTMLElement; 3 | getData: () => ReportData; 4 | }; 5 | 6 | export type ReportData = { 7 | config: string; 8 | configFormat: string; 9 | code: string; 10 | fileName: string; 11 | packages: { name: string; version: string }[]; 12 | }; 13 | 14 | export function setupReport({ element, getData }: ReportOptions) { 15 | const link = element.querySelector('a'); 16 | 17 | if (link) { 18 | link.addEventListener('click', () => { 19 | const data = getData(); 20 | const url = new URL(link.href); 21 | 22 | const code = data.code.trim(); 23 | const codeLang = data.fileName.split('.').pop() ?? 'css'; 24 | const config = data.config.trim(); 25 | const configLang = data.configFormat.split('.').pop() ?? 'mjs'; 26 | const packages = data.packages.reduce( 27 | (acc, pkg) => ({ ...acc, [pkg.name]: pkg.version }), 28 | {}, 29 | ); 30 | const packagesJson = JSON.stringify(packages, null, 2); 31 | 32 | url.searchParams.set('reproduce-bug', `\`\`\`${codeLang}\n${code}\n\`\`\``); 33 | url.searchParams.set('stylelint-configuration', `\`\`\`${configLang}\n${config}\n\`\`\``); 34 | url.searchParams.set('stylelint-run', `[Demo](${window.location.href})`); 35 | url.searchParams.set('stylelint-version', `\`\`\`json\n${packagesJson}\n\`\`\``); 36 | link.href = url.toString(); 37 | }); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/components/console.ts: -------------------------------------------------------------------------------- 1 | import '@xterm/xterm/css/xterm.css'; 2 | import { FitAddon } from '@xterm/addon-fit'; 3 | import { Terminal } from '@xterm/xterm'; 4 | export type ConsoleOutput = { 5 | appendLine: (string: string) => void; 6 | append: (string: string) => void; 7 | clear: () => void; 8 | }; 9 | export type ConsoleOutputOptions = { 10 | /** Specify a target element to set up the console output. */ 11 | element: HTMLElement; 12 | }; 13 | 14 | /** Setup a console output component. */ 15 | export function setupConsoleOutput({ element }: ConsoleOutputOptions): ConsoleOutput { 16 | const elementStyle = window.getComputedStyle(element); 17 | const term = new Terminal({ 18 | fontSize: 12, 19 | theme: { 20 | background: elementStyle.backgroundColor, 21 | foreground: elementStyle.color, 22 | }, 23 | }); 24 | const fitAddon = new FitAddon(); 25 | 26 | term.loadAddon(fitAddon); 27 | term.open(element); 28 | 29 | const resizeObserver = new ResizeObserver(() => { 30 | if (element.clientWidth) { 31 | fitAddon.fit(); 32 | } 33 | }); 34 | 35 | resizeObserver.observe(element); 36 | fitAddon.fit(); 37 | 38 | const consoleOutput: ConsoleOutput = { 39 | appendLine: (string: string) => { 40 | term.writeln(string); 41 | }, 42 | append: (string: string) => { 43 | term.write(string); 44 | }, 45 | clear: () => { 46 | term.clear(); 47 | }, 48 | }; 49 | 50 | return consoleOutput; 51 | } 52 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - '**' 10 | 11 | jobs: 12 | lint: 13 | uses: stylelint/.github/.github/workflows/call-lint.yml@f34faf02df201c8c204df4ae2543523ad54ceb07 # 0.3.1 14 | permissions: 15 | contents: read 16 | 17 | test: 18 | uses: stylelint/.github/.github/workflows/call-test.yml@f34faf02df201c8c204df4ae2543523ad54ceb07 # 0.3.1 19 | with: 20 | node-version-file: .nvmrc 21 | permissions: 22 | contents: read 23 | 24 | e2e: 25 | permissions: 26 | contents: read 27 | timeout-minutes: 10 28 | runs-on: ubuntu-latest 29 | steps: 30 | - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 31 | - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 32 | with: 33 | node-version-file: .nvmrc 34 | cache: npm 35 | - name: Install dependencies 36 | run: npm ci 37 | - name: Install Playwright Browsers 38 | run: npx playwright install --with-deps 39 | - name: Run Playwright tests 40 | run: npx playwright test 41 | - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 42 | if: ${{ !cancelled() }} 43 | with: 44 | name: playwright-report 45 | path: playwright-report/ 46 | retention-days: 5 47 | -------------------------------------------------------------------------------- /src/components/copy-link.ts: -------------------------------------------------------------------------------- 1 | export type CopyLinkOptions = { 2 | element: HTMLElement; 3 | }; 4 | 5 | export function setupCopyLink({ element }: CopyLinkOptions) { 6 | const button = document.createElement('button'); 7 | const buttonText = 'Copy link'; 8 | 9 | button.textContent = buttonText; 10 | button.addEventListener('click', copy); 11 | element.replaceChildren(button); 12 | 13 | async function copy() { 14 | try { 15 | // eslint-disable-next-line n/no-unsupported-features/node-builtins 16 | await navigator.clipboard.writeText(getShareableUrl()); 17 | 18 | button.textContent = 'Copied!'; 19 | setTimeout(() => { 20 | button.textContent = buttonText; 21 | }, 2000); 22 | } catch { 23 | // ignore 24 | } 25 | } 26 | } 27 | 28 | // Get the most appropriate URL to share. 29 | function getShareableUrl(): string { 30 | if (window.parent === window) { 31 | return window.location.href; 32 | } 33 | 34 | try { 35 | return window.parent.location.href; 36 | } catch { 37 | // For cross-origin errors in iframe scenarios, fall back to referrer. 38 | const referrer = document.referrer; 39 | 40 | if (referrer) { 41 | const referrerUrl = new URL(referrer); 42 | 43 | referrerUrl.hash = window.location.hash; 44 | referrerUrl.search = window.location.search; 45 | 46 | // NOTE: This relies on the parent website's implementation, so we need to ensure it's consistent. 47 | referrerUrl.pathname = '/demo/'; 48 | 49 | return referrerUrl.href; 50 | } 51 | 52 | return window.location.href; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/demo.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 9 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 |
    30 |
    31 |
    32 | 33 | 36 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | Report a bug 51 | 52 | 53 | 54 |
    55 | -------------------------------------------------------------------------------- /src/monaco-editor/monarch-syntaxes/refs/tags.ts: -------------------------------------------------------------------------------- 1 | // developer.mozilla.org/en-US/docs/Web/HTML/Element 2 | export const tagKeywords = [ 3 | 'a', 4 | 'abbr', 5 | 'address', 6 | 'area', 7 | 'article', 8 | 'aside', 9 | 'audio', 10 | 'b', 11 | 'base', 12 | 'bdi', 13 | 'bdo', 14 | 'bgsound', 15 | 'blockquote', 16 | 'body', 17 | 'br', 18 | 'button', 19 | 'canvas', 20 | 'caption', 21 | 'cite', 22 | 'code', 23 | 'col', 24 | 'colgroup', 25 | 'data', 26 | 'datalist', 27 | 'dd', 28 | 'del', 29 | 'details', 30 | 'dfn', 31 | 'div', 32 | 'dl', 33 | 'dt', 34 | 'em', 35 | 'embed', 36 | 'fieldset', 37 | 'figcaption', 38 | 'figure', 39 | 'footer', 40 | 'form', 41 | 'h1', 42 | 'h2', 43 | 'h3', 44 | 'h4', 45 | 'h5', 46 | 'h6', 47 | 'head', 48 | 'header', 49 | 'hgroup', 50 | 'hr', 51 | 'html', 52 | 'i', 53 | 'iframe', 54 | 'img', 55 | 'input', 56 | 'ins', 57 | 'kbd', 58 | 'keygen', 59 | 'label', 60 | 'legend', 61 | 'li', 62 | 'link', 63 | 'main', 64 | 'map', 65 | 'mark', 66 | 'marquee', 67 | 'menu', 68 | 'menuitem', 69 | 'meta', 70 | 'meter', 71 | 'nav', 72 | 'nobr', 73 | 'noframes', 74 | 'noscript', 75 | 'object', 76 | 'ol', 77 | 'optgroup', 78 | 'option', 79 | 'output', 80 | 'p', 81 | 'param', 82 | 'pre', 83 | 'progress', 84 | 'q', 85 | 'rp', 86 | 'rt', 87 | 'ruby', 88 | 's', 89 | 'samp', 90 | 'script', 91 | 'section', 92 | 'select', 93 | 'small', 94 | 'source', 95 | 'span', 96 | 'strong', 97 | 'style', 98 | 'sub', 99 | 'summary', 100 | 'sup', 101 | 'table', 102 | 'tbody', 103 | 'td', 104 | 'textarea', 105 | 'tfoot', 106 | 'th', 107 | 'thead', 108 | 'time', 109 | 'tr', 110 | 'track', 111 | 'u', 112 | 'ul', 113 | 'var', 114 | 'video', 115 | ]; 116 | -------------------------------------------------------------------------------- /src/components/deps-editor.ts: -------------------------------------------------------------------------------- 1 | import defaultDeps from './defaults/deps.json?raw'; 2 | import { setupMonacoEditor } from '../monaco-editor/monaco-setup.js'; 3 | 4 | export type DepsEditorOptions = { 5 | /** Specify a target element to set up the dependencies editor. */ 6 | element: HTMLElement; 7 | /** Specify the initial values. */ 8 | init: { 9 | /** Dependency packages text. */ 10 | value?: string; 11 | }; 12 | /** Event listeners. */ 13 | listeners: { 14 | /** Notifies that the dependency packages text have changed. */ 15 | onChangeValue: (value: string) => void; 16 | }; 17 | }; 18 | export type PackageJsonData = { name: string; version: string; homepage?: string }; 19 | /** Setup a dependencies editor component. */ 20 | export async function setupDepsEditor({ element, listeners, init }: DepsEditorOptions) { 21 | const versionsPanel = element.querySelector('sd-deps-installed ul')!; 22 | 23 | const monacoEditor = await setupMonacoEditor({ 24 | element: element.querySelector('sd-deps-monaco')!, 25 | init: { 26 | language: 'json', 27 | value: init?.value ?? defaultDeps, 28 | }, 29 | listeners, 30 | useDiffEditor: false, 31 | }); 32 | 33 | let installedPackages: PackageJsonData[] = []; 34 | 35 | return { 36 | ...monacoEditor, 37 | setPackages(packages: PackageJsonData[]) { 38 | installedPackages = packages; 39 | versionsPanel.innerHTML = ''; 40 | 41 | for (const pkg of packages) { 42 | const li = document.createElement('li'); 43 | const nameLink = document.createElement('a'); 44 | 45 | nameLink.textContent = pkg.name; 46 | nameLink.href = pkg.homepage || `https://www.npmjs.com/package/${pkg.name}`; 47 | nameLink.target = '_blank'; 48 | li.appendChild(nameLink); 49 | li.appendChild(document.createTextNode(`@${pkg.version}`)); 50 | versionsPanel.appendChild(li); 51 | } 52 | }, 53 | getPackages() { 54 | return installedPackages; 55 | }, 56 | }; 57 | } 58 | -------------------------------------------------------------------------------- /src/linter-service/installer.ts: -------------------------------------------------------------------------------- 1 | import type { WebContainer, WebContainerProcess } from '@webcontainer/api'; 2 | import type { ConsoleOutput } from '../components/console'; 3 | import type { Tabs } from '../components/output-tabs'; 4 | 5 | export class Installer { 6 | private readonly webContainer: WebContainer; 7 | private readonly consoleOutput: ConsoleOutput; 8 | private readonly outputTabs: Tabs; 9 | private installProcess: Promise | undefined; 10 | 11 | constructor({ 12 | consoleOutput, 13 | outputTabs, 14 | webContainer, 15 | }: { 16 | webContainer: WebContainer; 17 | consoleOutput: ConsoleOutput; 18 | outputTabs: Tabs; 19 | }) { 20 | this.webContainer = webContainer; 21 | this.consoleOutput = consoleOutput; 22 | this.outputTabs = outputTabs; 23 | } 24 | 25 | /** Run `npm install` to install dependencies. */ 26 | public async install() { 27 | this.outputTabs.setChecked('console'); 28 | this.consoleOutput.appendLine('Installing dependencies...'); 29 | 30 | if (this.installProcess != null) { 31 | (await this.installProcess).kill(); 32 | } 33 | 34 | this.installProcess = installDependencies(this.webContainer, this.consoleOutput); 35 | 36 | return (await this.installProcess).exit; 37 | } 38 | 39 | /** Returns the exit code for the install command process. */ 40 | public async getExitCode(): Promise { 41 | return (await this.installProcess!).exit; 42 | } 43 | } 44 | 45 | async function installDependencies(webContainer: WebContainer, consoleOutput: ConsoleOutput) { 46 | const installProcess = await webContainer.spawn('npm', ['install']); 47 | 48 | void installProcess.output.pipeTo( 49 | new WritableStream({ 50 | write(data) { 51 | consoleOutput.append(data); 52 | }, 53 | }), 54 | ); 55 | installProcess.exit.then((exitCode) => { 56 | if (exitCode !== 0) { 57 | consoleOutput.appendLine('Installation failed'); 58 | } else { 59 | consoleOutput.appendLine('Installation succeeded'); 60 | } 61 | }); 62 | 63 | return installProcess; 64 | } 65 | -------------------------------------------------------------------------------- /src/monaco-editor/monaco-loader.ts: -------------------------------------------------------------------------------- 1 | import * as monaco from 'monaco-editor'; 2 | import schemaStylelintrc from '../schema/stylelintrc.json'; 3 | 4 | type Monaco = typeof monaco; 5 | 6 | let monacoPromise: Promise | null = null; 7 | 8 | /** Load the Monaco editor object. */ 9 | export function loadMonaco(): Promise { 10 | return ( 11 | monacoPromise || 12 | (monacoPromise = (async () => { 13 | monaco.languages.css.cssDefaults.setOptions({ 14 | validate: false, //Turn off CSS built-in validation. 15 | }); 16 | 17 | monaco.languages.json.jsonDefaults.setDiagnosticsOptions({ 18 | validate: true, 19 | enableSchemaRequest: false, // TODO: When switching a remote schema, enable it. 20 | schemas: [ 21 | { 22 | // TODO: Note that this schema URL is actually absent. It's the same as `schemaStylelintrc.$id`. 23 | // We need to rewrite it when switching to a remote schema in the future. 24 | uri: 'https://stylelint.io/schema/stylelintrc.json', 25 | fileMatch: ['.stylelintrc.json'], 26 | 27 | // TODO: When switching to a remote schema in the future, delete it and its file. 28 | // Currently, schemastore.org doesn't support a schema for new Stylelint versions, so we shouldn't use it yet. 29 | // See https://github.com/stylelint/stylelint-demo/pull/425#issuecomment-2349046490 30 | schema: schemaStylelintrc, 31 | }, 32 | ], 33 | }); 34 | 35 | setupEnhancedLanguages(monaco); 36 | 37 | return monaco; 38 | })()) 39 | ); 40 | } 41 | 42 | function setupEnhancedLanguages(monaco: Monaco) { 43 | monaco.languages.register({ id: 'astro' }); 44 | monaco.languages.registerTokensProviderFactory('astro', { 45 | async create() { 46 | const astro = await import('./monarch-syntaxes/astro'); 47 | 48 | return astro.language; 49 | }, 50 | }); 51 | monaco.languages.register({ id: 'stylus', aliases: ['styl'] }); 52 | monaco.languages.registerTokensProviderFactory('stylus', { 53 | async create() { 54 | const stylus = await import('./monarch-syntaxes/stylus'); 55 | 56 | return stylus.language; 57 | }, 58 | }); 59 | monaco.languages.register({ id: 'svelte' }); 60 | monaco.languages.registerTokensProviderFactory('svelte', { 61 | async create() { 62 | const svelte = await import('./monarch-syntaxes/svelte'); 63 | 64 | return svelte.language; 65 | }, 66 | }); 67 | } 68 | -------------------------------------------------------------------------------- /src/components/config-editor.ts: -------------------------------------------------------------------------------- 1 | import defaultConfigRaw from './defaults/config.mjs?raw'; 2 | import { setupMonacoEditor } from '../monaco-editor/monaco-setup.js'; 3 | 4 | const FORMATS: ConfigFormat[] = [ 5 | 'stylelint.config.mjs', 6 | 'stylelint.config.cjs', 7 | '.stylelintrc.json', 8 | '.stylelintrc.yaml', 9 | ]; 10 | 11 | export type ConfigFormat = 12 | | 'stylelint.config.mjs' 13 | | 'stylelint.config.cjs' 14 | | '.stylelintrc.json' 15 | | '.stylelintrc.yaml'; 16 | 17 | export type ConfigEditorOptions = { 18 | /** Specify a target element to set up the config editor. */ 19 | element: HTMLElement; 20 | /** Specify the initial values. */ 21 | init: { 22 | /** Config text. */ 23 | value?: string; 24 | /** Config text format. */ 25 | format?: ConfigFormat; 26 | }; 27 | /** Event listeners. */ 28 | listeners: { 29 | /** Notifies that the config value have changed. */ 30 | onChangeValue: (value: string) => void; 31 | /** Notifies that the config format have changed. */ 32 | onChangeFormat: (format: ConfigFormat) => void; 33 | }; 34 | }; 35 | 36 | /** 37 | * Setup a config editor component. 38 | * This component has a config format select and a config editor. 39 | */ 40 | export async function setupConfigEditor({ element, listeners, init }: ConfigEditorOptions) { 41 | const formatSelect = element.querySelector('#sd-config-format')!; 42 | 43 | formatSelect.innerHTML = ''; 44 | 45 | for (const format of FORMATS) { 46 | const option = document.createElement('option'); 47 | 48 | option.value = format; 49 | option.textContent = format; 50 | formatSelect.appendChild(option); 51 | } 52 | 53 | const initFormat = ( 54 | FORMATS.includes(init?.format as ConfigFormat) ? init?.format : 'stylelint.config.mjs' 55 | ) as ConfigFormat; 56 | 57 | const monacoEditor = await setupMonacoEditor({ 58 | element: element.querySelector('sd-config-monaco')!, 59 | init: { 60 | language: getLanguage(initFormat), 61 | value: init?.value ?? defaultConfigRaw, 62 | fileName: initFormat, 63 | }, 64 | listeners, 65 | useDiffEditor: false, 66 | }); 67 | 68 | formatSelect.value = initFormat; 69 | formatSelect.addEventListener('change', () => { 70 | const format = formatSelect.value as ConfigFormat; 71 | 72 | monacoEditor.setModelLanguage(getLanguage(format)); 73 | listeners.onChangeFormat(format); 74 | }); 75 | 76 | return { 77 | ...monacoEditor, 78 | getFormat() { 79 | return formatSelect.value as ConfigFormat; 80 | }, 81 | }; 82 | 83 | function getLanguage(format: ConfigFormat) { 84 | if (format.endsWith('.mjs') || format.endsWith('.cjs')) { 85 | return 'javascript'; 86 | } 87 | 88 | return format.split('.').pop() ?? 'json'; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/monaco-editor/monarch-syntaxes/stylus.ts: -------------------------------------------------------------------------------- 1 | import type { languages } from 'monaco-editor'; 2 | import { tagKeywords } from './refs/tags.js'; 3 | export const language: languages.IMonarchLanguage = { 4 | tagKeywords, 5 | keywords: [ 6 | 'true', 7 | 'false', 8 | 'null', 9 | 'return', 10 | 'else', 11 | 'for', 12 | 'unless', 13 | 'if', 14 | 'else', 15 | 'arguments', 16 | // "!important", 17 | 'in', 18 | // "is defined", 19 | // "is a", 20 | ], 21 | brackets: [ 22 | { open: '{', close: '}', token: 'delimiter.curly' }, 23 | { open: '[', close: ']', token: 'delimiter.bracket' }, 24 | { open: '(', close: ')', token: 'delimiter.parenthesis' }, 25 | ], 26 | digits: /\d+/u, 27 | escapes: /\\./u, 28 | tokenizer: { 29 | root: [ 30 | [/is defined\b/u, 'keyword'], 31 | [/is a\b/u, 'keyword'], 32 | [/!important\b/u, 'keyword'], 33 | [/@[\w-]*/u, 'keyword'], 34 | // Mixin / Function 35 | [/[a-z][\w-]*(?=\()/u, 'tag'], 36 | // identifiers 37 | [ 38 | /[$\-_a-z][\w$-]*/u, 39 | { 40 | cases: { 41 | '@keywords': 'keyword', 42 | '@tagKeywords': 'tag', 43 | '@default': 'identifier', 44 | }, 45 | }, 46 | ], 47 | // ID selector 48 | [/#[a-z][\w-]*/u, 'tag'], 49 | // Class selector 50 | [/\.[a-z][\w-]*/u, 'tag'], 51 | 52 | [/[,;]/u, 'delimiter'], 53 | [/[()[\]{}]/u, '@brackets'], 54 | 55 | // numbers 56 | { include: '@numbers' }, 57 | 58 | // whitespace 59 | [/[\t\n\f\r ]+/u, ''], 60 | { include: '@comments' }, 61 | 62 | // strings 63 | { include: '@strings' }, 64 | ], 65 | numbers: [ 66 | [/(@digits)[Ee]([+-]?(@digits))?/u, 'attribute.value.number', '@units'], 67 | [/(@digits)\.(@digits)([Ee][+-]?(@digits))?/u, 'attribute.value.number', '@units'], 68 | [/(@digits)/u, 'attribute.value.number', '@units'], 69 | [/#[\dA-Fa-f]{3}([\dA-Fa-f]([\dA-Fa-f]{2}){0,2})?\b(?!-)/u, 'attribute.value.hex'], 70 | ], 71 | comments: [ 72 | [/\/\*/u, 'comment', '@commentBody'], 73 | [/\/\/.*$/u, 'comment'], 74 | ], 75 | strings: [ 76 | [/"([^"\\]|\\.)*$/u, 'string.invalid'], // non-teminated string 77 | [/'([^'\\]|\\.)*$/u, 'string.invalid'], // non-teminated string 78 | [/"/u, 'string', '@stringDoubleBody'], 79 | [/'/u, 'string', '@stringSingleBody'], 80 | ], 81 | 82 | commentBody: [ 83 | [/[^*/]+/u, 'comment'], 84 | [/\*\//u, 'comment', '@pop'], 85 | [/[*/]/u, 'comment'], 86 | ], 87 | stringDoubleBody: [ 88 | [/[^"\\]+/u, 'string'], 89 | [/@escapes/u, 'string.escape'], 90 | [/\\./u, 'string.escape.invalid'], 91 | [/"/u, 'string', '@pop'], 92 | ], 93 | stringSingleBody: [ 94 | [/[^'\\]+/u, 'string'], 95 | [/@escapes/u, 'string.escape'], 96 | [/\\./u, 'string.escape.invalid'], 97 | [/'/u, 'string', '@pop'], 98 | ], 99 | units: [ 100 | [ 101 | /((em|ex|ch|rem|vmin|vmax|vw|vh|vm|cm|mm|in|px|pt|pc|deg|grad|rad|turn|s|ms|Hz|kHz|%)\b)?/u, 102 | 'attribute.value.unit', 103 | '@pop', 104 | ], 105 | ], 106 | }, 107 | }; 108 | -------------------------------------------------------------------------------- /src/components/code-editor.ts: -------------------------------------------------------------------------------- 1 | import defaultCSS from './defaults/css.css?raw'; 2 | import { setupMonacoEditor } from '../monaco-editor/monaco-setup.js'; 3 | 4 | export type CodeEditorOptions = { 5 | /** Specify a target element to set up the code editor. */ 6 | element: HTMLElement; 7 | /** Specify the initial values. */ 8 | init: { 9 | /** Code text to lint. */ 10 | value?: string; 11 | /** The file name of the code. */ 12 | fileName?: string; 13 | }; 14 | /** Event listeners. */ 15 | listeners: { 16 | /** Notifies that the code value have changed. */ 17 | onChangeValue: (value: string) => void; 18 | /** Notifies that the code file name have changed. */ 19 | onChangeFileName: (value: string) => void; 20 | }; 21 | }; 22 | /** 23 | * Setup a code editor component. 24 | * This component has a filename input and a code editor. 25 | */ 26 | export async function setupCodeEditor({ element, listeners, init }: CodeEditorOptions) { 27 | const fileNameInput = element.querySelector('#sd-code-file-name')!; 28 | const initFileName = adjustFileName(init.fileName); 29 | const monacoEditor = await setupMonacoEditor({ 30 | element: element.querySelector('sd-code-monaco')!, 31 | init: { 32 | language: getLanguage(initFileName), 33 | value: init.value ?? defaultCSS, 34 | }, 35 | listeners: { 36 | onChangeValue: listeners.onChangeValue, 37 | }, 38 | useDiffEditor: true, 39 | }); 40 | 41 | fileNameInput.value = initFileName; 42 | fileNameInput.addEventListener('input', () => { 43 | const fileName = adjustFileName(fileNameInput.value); 44 | 45 | if (fileNameInput.value && fileNameInput.value !== fileName) { 46 | fileNameInput.value = fileName; 47 | } 48 | 49 | monacoEditor.setModelLanguage(getLanguage(fileName)); 50 | listeners.onChangeFileName(fileName); 51 | }); 52 | 53 | return { 54 | ...monacoEditor, 55 | getFileName() { 56 | return adjustFileName(fileNameInput.value); 57 | }, 58 | }; 59 | 60 | function adjustFileName(fileName: string | undefined) { 61 | return fileName?.trim() || 'example.css'; 62 | } 63 | 64 | function getLanguage(fileName: string) { 65 | const lower = fileName.toLowerCase(); 66 | 67 | // TODO: Ternary formatting by Prettier breaks. Maybe https://github.com/prettier/prettier/issues/15655 68 | // prettier-ignore 69 | return lower.endsWith('.css') 70 | ? 'css' 71 | : lower.endsWith('.scss') 72 | ? 'scss' 73 | : lower.endsWith('.less') 74 | ? 'less' 75 | : lower.endsWith('.sass') 76 | ? 'sass' 77 | : lower.endsWith('.html') || lower.endsWith('.vue') 78 | ? 'html' 79 | : lower.endsWith('.js') || lower.endsWith('.mjs') || lower.endsWith('.cjs') 80 | ? 'javascript' 81 | : lower.endsWith('.jsx') 82 | ? 'javascriptreact' 83 | : lower.endsWith('.ts') || lower.endsWith('.mts') || lower.endsWith('.cts') 84 | ? 'typescript' 85 | : lower.endsWith('.tsx') 86 | ? 'typescriptreact' 87 | : lower.endsWith('.svelte') 88 | ? 'svelte' 89 | : lower.endsWith('.astro') 90 | ? 'astro' 91 | : lower.endsWith('.stylus') || lower.endsWith('.styl') 92 | ? 'stylus' 93 | : 'css'; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import './style.css'; 2 | import { compress, decompress } from './utils/compress'; 3 | import type { ConfigFormat } from './components/config-editor'; 4 | import { debounce } from './utils/debounce'; 5 | import defaultConfig from './components/defaults/config.mjs'; 6 | import defaultDeps from './components/defaults/deps.json'; 7 | import { mount } from './demo'; 8 | 9 | const syntaxes = [ 10 | { 11 | fileName: 'example.scss', 12 | id: 'scss', 13 | customSyntax: 'postcss-scss', 14 | }, 15 | { 16 | fileName: 'example.sass', 17 | id: 'sass', 18 | customSyntax: 'postcss-sass', 19 | }, 20 | { 21 | fileName: 'example.less', 22 | id: 'less', 23 | customSyntax: 'postcss-less', 24 | }, 25 | { 26 | fileName: 'example.sss', 27 | id: 'sugarss', 28 | customSyntax: 'sugarss', 29 | }, 30 | { 31 | fileName: 'example.html', 32 | id: 'html', 33 | customSyntax: 'postcss-html', 34 | }, 35 | ]; 36 | 37 | const hashData = window.location.hash.slice(window.location.hash.indexOf('#') + 1); 38 | const queryParam = decompress(hashData); 39 | 40 | if (queryParam.syntax) { 41 | // Backward compatibility 42 | const syntax = syntaxes.find((s) => s.id === queryParam.syntax); 43 | 44 | if (syntax) { 45 | try { 46 | const customSyntax = syntax.customSyntax; 47 | 48 | if (!queryParam.deps) { 49 | const deps = { [customSyntax]: 'latest', ...defaultDeps }; 50 | 51 | queryParam.deps = JSON.stringify(deps, null, 2); 52 | } 53 | 54 | if (!queryParam.fileName) { 55 | queryParam.fileName = syntax.fileName; 56 | } 57 | 58 | const config = { 59 | customSyntax, 60 | ...(queryParam.config ? JSON.parse(queryParam.config) : defaultConfig), 61 | }; 62 | 63 | queryParam.config = JSON.stringify(config, null, 2); 64 | } catch { 65 | // ignore 66 | } 67 | } 68 | } 69 | 70 | // Backward compatibility for old file format picker 71 | if (queryParam.configFormat) { 72 | const format = queryParam.configFormat.trim().toLowerCase(); 73 | 74 | if (format === 'json') { 75 | queryParam.configFormat = '.stylelintrc.json'; 76 | } else if (format === 'yaml') { 77 | queryParam.configFormat = '.stylelintrc.yaml'; 78 | } else if (format === 'js') { 79 | queryParam.configFormat = 'stylelint.config.cjs'; 80 | } 81 | } 82 | 83 | const { 84 | code: codeQueryParam, 85 | fileName: fileNameQueryParam, 86 | config: configQueryParam, 87 | configFormat: configFormatQueryParam, 88 | deps: depsQueryParam, 89 | } = queryParam; 90 | 91 | mount({ 92 | element: document.querySelector('#app')!, 93 | init: { 94 | code: codeQueryParam, 95 | fileName: fileNameQueryParam, 96 | config: configQueryParam, 97 | configFormat: configFormatQueryParam, 98 | deps: depsQueryParam, 99 | }, 100 | listeners: { 101 | onChange: debounce( 102 | (values: { 103 | code: string; 104 | fileName: string; 105 | config: string; 106 | configFormat: ConfigFormat; 107 | deps: string; 108 | }) => { 109 | const query = compress(values); 110 | 111 | window.location.hash = query; 112 | 113 | if (window.parent) { 114 | window.parent.postMessage(query, '*'); 115 | } 116 | }, 117 | 250, 118 | ), 119 | }, 120 | }); 121 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stylelint-demo", 3 | "version": "0.0.0", 4 | "private": true, 5 | "description": "Stylelint Demo", 6 | "repository": "stylelint/stylelint-demo", 7 | "license": "MIT", 8 | "type": "module", 9 | "scripts": { 10 | "build": "vite build", 11 | "build:lib": "vite build -c vite.config.lib.ts", 12 | "clean": "node -e \"fs.rmSync('dist', {force: true, recursive: true})\"", 13 | "dev": "vite", 14 | "e2e": "playwright test", 15 | "format": "prettier . --write", 16 | "lint": "npm-run-all --parallel lint:*", 17 | "lint:css": "stylelint src/**/*.css", 18 | "lint:formatting": "prettier . --check", 19 | "lint:js": "eslint", 20 | "lint:md": "remark . --quiet --frail --ignore-path .gitignore", 21 | "lint:types": "tsc", 22 | "prepare": "husky", 23 | "pretest": "npm run lint", 24 | "test": "npm run build" 25 | }, 26 | "lint-staged": { 27 | "*.css": "stylelint --cache --fix", 28 | "*.{js,mjs,ts}": "eslint --cache --fix", 29 | "*.{css,js,mjs,ts,json,md,yml}": "prettier --write" 30 | }, 31 | "browserslist": [ 32 | "defaults" 33 | ], 34 | "prettier": "@stylelint/prettier-config", 35 | "remarkConfig": { 36 | "plugins": [ 37 | "@stylelint/remark-preset" 38 | ] 39 | }, 40 | "stylelint": { 41 | "extends": [ 42 | "stylelint-config-standard" 43 | ], 44 | "plugins": [ 45 | "stylelint-order", 46 | "stylelint-use-logical-spec" 47 | ], 48 | "rules": { 49 | "declaration-property-value-no-unknown": true, 50 | "declaration-property-unit-allowed-list": { 51 | "/^border$|^border.*(width$|block$|inline$|start$|end$/|radius$)": [ 52 | "px" 53 | ], 54 | "/^((min|max)-)?(block-size$|inline-size$)/": [ 55 | "%", 56 | "ch", 57 | "dvb", 58 | "rem", 59 | "vb" 60 | ], 61 | "/^font|^gap/|^inset|^margin|^padding/": [ 62 | "rem" 63 | ] 64 | }, 65 | "font-weight-notation": "numeric", 66 | "liberty/use-logical-spec": "always", 67 | "media-feature-name-unit-allowed-list": { 68 | "/(width|height)$/": [ 69 | "em" 70 | ] 71 | }, 72 | "no-descending-specificity": null, 73 | "order/order": [ 74 | [ 75 | "custom-properties", 76 | "declarations", 77 | "rules", 78 | "at-rules" 79 | ], 80 | { 81 | "severity": "warning" 82 | } 83 | ], 84 | "order/properties-alphabetical-order": [ 85 | true, 86 | { 87 | "severity": "warning" 88 | } 89 | ] 90 | } 91 | }, 92 | "dependencies": { 93 | "@webcontainer/api": "^1.6.1", 94 | "@xterm/addon-fit": "^0.10.0", 95 | "@xterm/xterm": "^5.5.0", 96 | "ansi-regex": "^6.2.2", 97 | "lz-string": "^1.5.0", 98 | "monaco-editor": "^0.54.0" 99 | }, 100 | "devDependencies": { 101 | "@playwright/test": "^1.56.1", 102 | "@stylelint/prettier-config": "^4.0.0", 103 | "@stylelint/remark-preset": "^5.1.1", 104 | "eslint": "^9.39.1", 105 | "eslint-config-stylelint": "^25.0.1", 106 | "globals": "^16.5.0", 107 | "husky": "^9.1.7", 108 | "lint-staged": "^16.2.7", 109 | "npm-run-all": "^4.1.5", 110 | "prettier": "^3.6.2", 111 | "remark-cli": "^12.0.1", 112 | "stylelint": "^16.26.0", 113 | "stylelint-config-standard": "^39.0.1", 114 | "stylelint-order": "^7.0.0", 115 | "stylelint-use-logical-spec": "^5.0.1", 116 | "typescript": "^5.9.3", 117 | "typescript-eslint": "^8.47.0", 118 | "vite": "^7.2.4", 119 | "vite-plugin-monaco-editor-esm": "^2.0.2" 120 | }, 121 | "engines": { 122 | "node": "24" 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /tests/e2e.spec.ts: -------------------------------------------------------------------------------- 1 | import { type Page, expect, test } from '@playwright/test'; 2 | 3 | test.describe.serial('Stylelint demo', () => { 4 | let page: Page; 5 | 6 | let inputTabs: ReturnType; 7 | let codeMonaco: ReturnType; 8 | let config: ReturnType; 9 | let configMonaco: ReturnType; 10 | let depsMonaco: ReturnType; 11 | let depsInstalled: ReturnType; 12 | let warnings: ReturnType; 13 | let console: ReturnType; 14 | 15 | test.beforeAll(async ({ browser }) => { 16 | page = await browser.newPage(); 17 | await page.goto('/'); 18 | 19 | inputTabs = page.locator('sd-input-tabs'); 20 | codeMonaco = page.locator('sd-code-monaco').getByRole('code').first(); 21 | config = page.locator('sd-config'); 22 | configMonaco = page.locator('sd-config-monaco').getByRole('code').first(); 23 | depsMonaco = page.locator('sd-deps-monaco').getByRole('code').first(); 24 | depsInstalled = page.locator('sd-deps-installed'); 25 | warnings = page.locator('sd-warnings'); 26 | console = page.locator('sd-console'); 27 | }); 28 | 29 | test.afterAll(async () => { 30 | await page.close(); 31 | }); 32 | 33 | test('server starts', async () => { 34 | await expect(console).toContainText('Server started', { timeout: 60_000 }); 35 | }); 36 | 37 | test('warnings for invalid CSS ', async () => { 38 | await expect(warnings).toContainText('Expected same number of cell tokens in each string'); 39 | }); 40 | 41 | test('no warnings for valid CSS', async () => { 42 | await expect(codeMonaco).toBeVisible(); 43 | await codeMonaco.click(); 44 | await pressBackspace(); 45 | await page.keyboard.type('a { color: #fff; }'); 46 | await expect 47 | .poll(async () => { 48 | const ul = warnings.locator('ul'); 49 | 50 | return await ul.evaluate((el: Element) => { 51 | return window.getComputedStyle(el, '::before').content; 52 | }); 53 | }) 54 | .toContain('No problems'); 55 | }); 56 | 57 | test('changing config and format', async () => { 58 | await inputTabs.getByText('Config').click(); 59 | await config.getByRole('combobox').selectOption('.stylelintrc.json'); 60 | await expect(configMonaco).toBeVisible(); 61 | await configMonaco.click(); 62 | await pressBackspace(100); 63 | await page.keyboard.type('{ "rules": { "color-no-hex": true }}'); 64 | await expect(warnings).toContainText('Unexpected hex color'); 65 | }); 66 | 67 | test('changing dependencies', async () => { 68 | await inputTabs.getByText('Dependencies').click(); 69 | await expect(depsMonaco).toBeVisible(); 70 | await depsMonaco.click(); 71 | await pressBackspace(); 72 | await page.keyboard.type('{"stylelint": "16.0.0"}'); 73 | await expect(depsInstalled).toContainText('stylelint'); 74 | await expect(depsInstalled).toContainText('16.0.0', { timeout: 60_000 }); 75 | }); 76 | 77 | test('copying the link', async () => { 78 | await page.context().grantPermissions(['clipboard-read', 'clipboard-write']); 79 | await page.getByRole('button', { name: 'Copy link' }).click(); 80 | 81 | /* eslint-disable-next-line n/no-unsupported-features/node-builtins */ 82 | const clipboardText = await page.evaluate(() => navigator.clipboard.readText()); 83 | 84 | await expect(clipboardText).toContain(page.url()); 85 | }); 86 | 87 | test('reporting a bug', async () => { 88 | const [bugPage] = await Promise.all([ 89 | page.context().waitForEvent('page'), 90 | page.getByRole('link', { name: 'Report a bug' }).click(), 91 | ]); 92 | const bugUrl = bugPage.url(); 93 | 94 | await expect(bugUrl).toContain('template%3DREPORT_A_BUG.yml'); 95 | await expect(bugUrl).toContain('reproduce-bug%3D'); 96 | await expect(bugUrl).toContain('stylelint-configuration%3D'); 97 | await expect(bugUrl).toContain('stylelint-run%3D'); 98 | await expect(bugUrl).toContain('stylelint-version%3D'); 99 | }); 100 | 101 | // Workaround for Ctrl+A in Playwright and Monaco editor being buggy 102 | async function pressBackspace(times = 250) { 103 | for (let i = 0; i < times; i++) { 104 | await page.keyboard.press('Backspace'); 105 | } 106 | } 107 | }); 108 | -------------------------------------------------------------------------------- /src/components/warnings.ts: -------------------------------------------------------------------------------- 1 | import type { LinterServiceResult } from '../linter-service'; 2 | import type { Warning } from 'stylelint'; 3 | import ansiRegex from 'ansi-regex'; 4 | 5 | export type WarningsPanelOptions = { 6 | /** Specify a target element to set up the warnings panel component. */ 7 | element: HTMLElement; 8 | /** Event listeners. */ 9 | listeners: { 10 | /** Notify the click event of the warning element. */ 11 | onClickWaning: (warning: Warning) => void; 12 | }; 13 | }; 14 | 15 | /** Setup a component to display warnings. */ 16 | export function setupWarningsPanel({ element, listeners }: WarningsPanelOptions) { 17 | return { 18 | setResult: (result: LinterServiceResult) => { 19 | const ul = document.createElement('ul'); 20 | 21 | element.replaceChildren(ul); 22 | 23 | if (result.returnCode !== 0) { 24 | const li = document.createElement('li'); 25 | 26 | li.textContent = result.result.replace(ansiRegex(), ''); 27 | ul.appendChild(li); 28 | 29 | return; 30 | } 31 | 32 | const { invalidOptionWarnings } = result.result; 33 | 34 | if (invalidOptionWarnings.length > 0) { 35 | ul.replaceChildren(...createInvalidOptionWarnings(invalidOptionWarnings)); 36 | 37 | return; 38 | } 39 | 40 | const ruleMetadata = result.ruleMetadata; 41 | 42 | for (const warning of [...result.result.warnings].sort( 43 | (a, b) => 44 | a.line - b.line || 45 | a.column - b.column || 46 | (a.endLine != null && b.endLine != null && a.endLine - b.endLine) || 47 | (a.endColumn != null && b.endColumn != null && a.endColumn - b.endColumn) || 48 | 0, 49 | )) { 50 | const li = document.createElement('li'); 51 | 52 | const lineNumbers = document.createElement('span'); 53 | const ln = formatPosition(warning.line, warning.endLine); 54 | const col = formatPosition(warning.column, warning.endColumn); 55 | 56 | lineNumbers.textContent = `${ln}:${col}`; 57 | li.appendChild(lineNumbers); 58 | 59 | lineNumbers.addEventListener('click', () => listeners.onClickWaning(warning)); 60 | 61 | const ruleLinkText = `(${warning.rule})`; 62 | const ruleUrl = ruleMetadata[warning.rule]?.url; 63 | 64 | li.appendChild(createSeverity(warning.severity)); 65 | 66 | const message = document.createElement('span'); 67 | 68 | li.appendChild(message); 69 | 70 | if (ruleUrl) { 71 | const index = warning.text.lastIndexOf(ruleLinkText); 72 | 73 | if (index >= 0) { 74 | message.textContent = `${warning.text.slice(0, index).trim()}`; 75 | } else { 76 | message.textContent = `${warning.text.trim()}`; 77 | } 78 | 79 | const ruleLink = document.createElement('a'); 80 | 81 | ruleLink.textContent = ruleLinkText; 82 | ruleLink.href = ruleUrl; 83 | ruleLink.target = '_blank'; 84 | li.appendChild(ruleLink); 85 | 86 | // Add a span if the message is included after the rule name. 87 | if (index >= 0) { 88 | const afterMessage = warning.text.slice(index + ruleLinkText.length).trim(); 89 | 90 | if (afterMessage) { 91 | const afterSpan = document.createElement('span'); 92 | 93 | afterSpan.textContent = afterMessage; 94 | li.appendChild(afterSpan); 95 | afterSpan.addEventListener('click', () => listeners.onClickWaning(warning)); 96 | } 97 | } 98 | } else { 99 | message.textContent = `${warning.text.trim()}`; 100 | } 101 | 102 | ul.appendChild(li); 103 | } 104 | }, 105 | }; 106 | } 107 | 108 | function formatPosition(start: number, end: number | undefined) { 109 | return start === end || !end ? String(start) : [start, end].join('-'); 110 | } 111 | 112 | function createSeverity(severity: Warning['severity']) { 113 | const el = document.createElement('span'); 114 | 115 | el.textContent = severity; 116 | el.setAttribute('data-sd-severity', severity); 117 | 118 | return el; 119 | } 120 | 121 | function createInvalidOptionWarnings(optionWarnings: ReadonlyArray<{ text: string }>) { 122 | return optionWarnings.map(({ text }) => { 123 | const li = document.createElement('li'); 124 | 125 | li.appendChild(document.createElement('span')); // dummy 126 | li.appendChild(createSeverity('error')); 127 | 128 | const message = document.createElement('span'); 129 | 130 | message.textContent = text; 131 | 132 | li.appendChild(message); 133 | li.appendChild(document.createElement('span')); // dummy 134 | 135 | return li; 136 | }); 137 | } 138 | -------------------------------------------------------------------------------- /src/linter-service/server.ts: -------------------------------------------------------------------------------- 1 | import type { WebContainer, WebContainerProcess } from '@webcontainer/api'; 2 | import { createJsonPayload, extractJson } from './server/extract-json.mjs'; 3 | import type { ConsoleOutput } from '../components/console'; 4 | import type { Tabs } from '../components/output-tabs'; 5 | 6 | export class Server { 7 | private readonly webContainer: WebContainer; 8 | private readonly consoleOutput: ConsoleOutput; 9 | private readonly outputTabs: Tabs; 10 | private waitPromise: Promise; 11 | private server: ServerInternal | undefined; 12 | 13 | constructor({ 14 | consoleOutput, 15 | outputTabs, 16 | webContainer, 17 | }: { 18 | webContainer: WebContainer; 19 | consoleOutput: ConsoleOutput; 20 | outputTabs: Tabs; 21 | }) { 22 | this.webContainer = webContainer; 23 | this.consoleOutput = consoleOutput; 24 | this.outputTabs = outputTabs; 25 | 26 | this.waitPromise = Promise.resolve(undefined as any); 27 | } 28 | 29 | /** 30 | * Request to the server. 31 | * Start the server if it is not already started or if the server was stopped. 32 | */ 33 | public request(data: any, test: (res: any) => boolean): Promise { 34 | const result = this.waitPromise.then(async () => { 35 | let server = this.server; 36 | 37 | if (!server) { 38 | this.outputTabs.setChecked('console'); 39 | this.consoleOutput.appendLine('Starting server...'); 40 | server = await this._serverStart(); 41 | } 42 | 43 | await server.ready; 44 | 45 | if (server.isExit) { 46 | await this.restart(); 47 | await server.ready; 48 | } 49 | 50 | if (server.isExit) { 51 | throw new Error('Server could not be started.'); 52 | } 53 | 54 | return server.request(data, test); 55 | }); 56 | 57 | this.waitPromise = result.catch(() => undefined); 58 | 59 | return result; 60 | } 61 | 62 | /** Restart the server. */ 63 | public restart(): Promise { 64 | const result = this.waitPromise.then(async () => { 65 | if (this.server) { 66 | this.server.process.kill(); 67 | await this.server.process.exit; 68 | this.outputTabs.setChecked('console'); 69 | this.consoleOutput.appendLine('Restarting server...'); 70 | } else { 71 | this.outputTabs.setChecked('console'); 72 | this.consoleOutput.appendLine('Starting server...'); 73 | } 74 | 75 | await this._serverStart(); 76 | }); 77 | 78 | this.waitPromise = result.catch(() => undefined); 79 | 80 | return result; 81 | } 82 | 83 | private async _serverStart() { 84 | this.server = await startServerInternal(this.webContainer); 85 | this.server.ready.then(() => { 86 | this.consoleOutput.appendLine('Server started'); 87 | }); 88 | 89 | return this.server; 90 | } 91 | } 92 | 93 | type ServerInternal = { 94 | process: WebContainerProcess; 95 | request: (data: any, test: (data: any) => boolean) => Promise; 96 | ready: Promise; 97 | isExit: boolean; 98 | }; 99 | 100 | async function startServerInternal(webContainer: WebContainer): Promise { 101 | const serverProcess = await webContainer.spawn('node', ['./server.mjs']); 102 | 103 | let boot = false; 104 | const callbacks: ((json: string) => void)[] = []; 105 | 106 | serverProcess.output.pipeTo( 107 | new WritableStream({ 108 | write(str) { 109 | if (!callbacks.length) { 110 | // eslint-disable-next-line no-console 111 | if (!boot) console.log(str); 112 | 113 | return; 114 | } 115 | 116 | const output = extractJson(str); 117 | 118 | if (!output) { 119 | // eslint-disable-next-line no-console 120 | if (!boot) console.log(str); 121 | 122 | return; 123 | } 124 | 125 | callbacks.forEach((f) => f(output)); 126 | }, 127 | }), 128 | ); 129 | 130 | const writer = serverProcess.input.getWriter(); 131 | 132 | async function request(data: any, test: (data: any) => boolean): Promise { 133 | writer.write(createJsonPayload(data)); 134 | 135 | return new Promise((resolve) => { 136 | const callback = (output: string) => { 137 | if (test(output)) { 138 | const i = callbacks.indexOf(callback); 139 | 140 | if (i > 0) callbacks.splice(i); 141 | 142 | resolve(output); 143 | } 144 | }; 145 | 146 | callbacks.push(callback); 147 | }); 148 | } 149 | 150 | const serverInternal = { 151 | process: serverProcess, 152 | request, 153 | ready: request('ok?', (res) => res === 'ok' || res === 'boot').then(async () => { 154 | await new Promise((resolve) => setTimeout(resolve, 100)); 155 | boot = true; 156 | }), 157 | isExit: false, 158 | }; 159 | 160 | serverProcess.exit.then(() => { 161 | serverInternal.isExit = true; 162 | }); 163 | 164 | return serverInternal; 165 | } 166 | -------------------------------------------------------------------------------- /src/linter-service/index.ts: -------------------------------------------------------------------------------- 1 | import type { LintResult, RuleMeta } from 'stylelint'; 2 | import type { ConfigFormat } from '../components/config-editor.js'; 3 | import type { ConsoleOutput } from '../components/console'; 4 | import type { FileSystemTree } from '@webcontainer/api'; 5 | import { Installer } from './installer'; 6 | import { Server } from './server'; 7 | import type { Tabs } from '../components/output-tabs'; 8 | import { WebContainer } from '@webcontainer/api'; 9 | 10 | export type LinterServiceResult = LinterServiceResultSuccess | LinterServiceResultError; 11 | export type LinterServiceResultSuccess = { 12 | version: number; 13 | returnCode: 0; 14 | result: LintResult; 15 | fixResult: LintResult; 16 | output: string; 17 | ruleMetadata: { [ruleName: string]: Partial }; 18 | }; 19 | export type LinterServiceResultError = { 20 | version: number; 21 | returnCode: 1; 22 | result: string; 23 | }; 24 | export type LintInput = { 25 | /** Input version. Check if it matches the version returned. */ 26 | version: number; 27 | code: string; 28 | fileName: string; 29 | config: string; 30 | configFormat: ConfigFormat; 31 | }; 32 | 33 | export interface LinterService { 34 | /** 35 | * Run linting. 36 | * However, if called consecutively, it returns the result of the last call. 37 | * Check the `version` and qualitatively check if it is the desired result. 38 | */ 39 | lint: (input: LintInput) => Promise; 40 | /** Update dependency packages. */ 41 | updateDependencies: (pkg: any) => Promise; 42 | /** Install dependencies. */ 43 | install: () => Promise; 44 | /** Restart the server. */ 45 | restart: () => Promise; 46 | /** Read a file in the server. */ 47 | readFile: (path: string) => Promise; 48 | 49 | teardown: () => Promise; 50 | } 51 | 52 | /** Setup a linter service. */ 53 | export async function setupLintServer({ 54 | consoleOutput, 55 | outputTabs, 56 | }: { 57 | consoleOutput: ConsoleOutput; 58 | outputTabs: Tabs; 59 | }): Promise { 60 | outputTabs.setChecked('console'); 61 | consoleOutput.appendLine('Starting WebContainer...'); 62 | 63 | const webContainer = await WebContainer.boot(); 64 | const serverFiles: FileSystemTree = {}; 65 | 66 | for (const [file, contents] of Object.entries( 67 | import.meta.glob('./server/**/*.mjs', { query: '?raw', import: 'default' }), 68 | ).map(([file, load]) => { 69 | return [file.slice(9), load() as Promise] as const; 70 | })) { 71 | serverFiles[file] = { 72 | file: { 73 | contents: await contents, 74 | }, 75 | }; 76 | } 77 | 78 | await webContainer.mount(serverFiles); 79 | 80 | let updatingDependencies = Promise.resolve(); 81 | const installer = new Installer({ webContainer, consoleOutput, outputTabs }); 82 | 83 | async function installDeps() { 84 | await updatingDependencies; 85 | 86 | const exitCode = await installer.install(); 87 | 88 | if (exitCode !== 0) { 89 | throw new Error('Installation failed'); 90 | } 91 | } 92 | 93 | const server = new Server({ webContainer, consoleOutput, outputTabs }); 94 | 95 | let processing: Promise | null = null; 96 | let next: (() => Promise) | null = null; 97 | let last: Promise | null = null; 98 | 99 | async function setLintProcess( 100 | run: () => Promise, 101 | ): Promise { 102 | if (processing) { 103 | next = run; 104 | while (processing) { 105 | await processing; 106 | } 107 | 108 | return last!; 109 | } 110 | 111 | const promise = run(); 112 | 113 | processing = promise.then(() => { 114 | processing = null; 115 | 116 | if (next) { 117 | setLintProcess(next); 118 | next = null; 119 | } 120 | }); 121 | last = promise; 122 | 123 | return promise; 124 | } 125 | 126 | return { 127 | async lint(input: LintInput) { 128 | const exitCode = await installer.getExitCode(); 129 | 130 | if (exitCode !== 0) { 131 | throw new Error('Installation failed'); 132 | } 133 | 134 | // Returns the result of the last linting process. 135 | return setLintProcess(() => lint(server, input)); 136 | }, 137 | async updateDependencies(deps) { 138 | updatingDependencies = webContainer.fs.writeFile( 139 | '/package.json', 140 | JSON.stringify({ devDependencies: deps }, null, 2), 141 | ); 142 | await updatingDependencies; 143 | }, 144 | async install() { 145 | await installDeps(); 146 | }, 147 | async restart() { 148 | await server.restart(); 149 | }, 150 | readFile: async (path) => { 151 | return webContainer.fs.readFile(path, 'utf8'); 152 | }, 153 | 154 | async teardown() { 155 | webContainer.teardown(); 156 | }, 157 | }; 158 | } 159 | 160 | async function lint(server: Server, input: LintInput) { 161 | const content = await server.request(input, (content) => content.version >= input.version); 162 | 163 | return content; 164 | } 165 | -------------------------------------------------------------------------------- /src/linter-service/server/server.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * The server waits for stdin, and when stdin is received, 3 | * it starts linting based on that information. 4 | * The linting result is written to stdout. 5 | * 6 | * Always pass data with a directive open prefix and a directive close suffix. 7 | */ 8 | import { createJsonPayload, extractJson } from './extract-json.mjs'; 9 | import fs from 'fs'; 10 | import path from 'path'; 11 | import process from 'process'; 12 | import stylelint from 'stylelint'; 13 | 14 | const rootDir = path.resolve(); 15 | const SRC_DIR = path.join(rootDir, 'src'); 16 | 17 | const RESERVED_FILE_NAMES = [ 18 | 'server.mjs', 19 | 'extract-json.mjs', 20 | 'package.json', 21 | 'package-lock.json', 22 | 'node_modules', 23 | '.stylelintrc', 24 | '.stylelintrc.cjs', 25 | '.stylelintrc.js', 26 | '.stylelintrc.json', 27 | '.stylelintrc.yaml', 28 | '.stylelintrc.yml', 29 | 'stylelint.config.cjs', 30 | 'stylelint.config.js', 31 | 'stylelint.config.mjs', 32 | '.stylelintignore', 33 | ]; 34 | 35 | const CONFIG_FORMATS = [ 36 | 'stylelint.config.mjs', 37 | 'stylelint.config.cjs', 38 | '.stylelintrc.json', 39 | '.stylelintrc.yaml', 40 | ]; 41 | 42 | /** 43 | * @typedef {import('../index').LintInput} LintInput 44 | * @typedef {import('../index').LinterServiceResult} LinterServiceResult 45 | */ 46 | 47 | main(); 48 | 49 | function main() { 50 | // eslint-disable-next-line no-console 51 | console.log('Start server'); 52 | 53 | process.stdin.setEncoding('utf8'); 54 | process.stdin.setRawMode(true); 55 | process.stdin.resume(); 56 | 57 | process.stdin.on('data', (data) => { 58 | const input = extractJson(data.toString()); 59 | 60 | if (!input) return; 61 | 62 | // Health check. 63 | if (input === 'ok?') { 64 | process.stdout.write(createJsonPayload('ok')); 65 | 66 | return; 67 | } 68 | 69 | // Request linting. 70 | lint(input); 71 | }); 72 | 73 | // Notify the start of boot. 74 | process.stdout.write(createJsonPayload('boot')); 75 | } 76 | 77 | /** 78 | * Linting with stylelint 79 | * @param {LintInput} input 80 | */ 81 | async function lint(input) { 82 | // eslint-disable-next-line no-console 83 | console.log('Linting file: ', input.fileName); 84 | 85 | try { 86 | const targetFile = path.normalize(path.join(SRC_DIR, input.fileName)); 87 | 88 | if (!targetFile.startsWith(SRC_DIR)) { 89 | throw new Error('An out-of-scope path was specified.'); 90 | } 91 | 92 | if ( 93 | RESERVED_FILE_NAMES.some( 94 | (f) => 95 | targetFile.endsWith(f) || 96 | targetFile.includes(`/${f}/`) || 97 | targetFile.includes(`\\${f}\\`), 98 | ) 99 | ) { 100 | throw new Error( 101 | 'The specified file name cannot be used as a linting file name on this demo site.', 102 | ); 103 | } 104 | 105 | /** @type {string} */ 106 | let filename = input.configFormat; 107 | 108 | // Workaround to bypass the module cache 109 | // See: https://github.com/stylelint/stylelint-demo/issues/418 110 | if (filename.endsWith('.mjs') || filename.endsWith('.cjs')) { 111 | const ext = path.extname(filename); 112 | const base = path.basename(filename, ext); 113 | 114 | filename = `${base}-${Date.now()}${ext}`; 115 | } 116 | 117 | const configFile = path.join(SRC_DIR, filename); 118 | 119 | fs.mkdirSync(path.dirname(targetFile), { recursive: true }); 120 | fs.mkdirSync(path.dirname(configFile), { recursive: true }); 121 | 122 | for (const configFormat of CONFIG_FORMATS) { 123 | if (configFormat === input.configFormat) continue; 124 | 125 | const otherConfigFile = path.join(SRC_DIR, configFormat); 126 | 127 | if (fs.existsSync(otherConfigFile)) fs.unlinkSync(otherConfigFile); 128 | } 129 | 130 | fs.writeFileSync(targetFile, input.code, 'utf8'); 131 | fs.writeFileSync(configFile, input.config, 'utf8'); 132 | 133 | const result = await stylelint.lint({ files: [targetFile], configFile, computeEditInfo: true }); 134 | const fixResult = await stylelint.lint({ files: [targetFile], configFile, fix: true }); 135 | const fixedFile = fs.readFileSync(targetFile, 'utf8'); 136 | 137 | // Continuation of module cache workaround 138 | if (configFile.endsWith('.mjs') || configFile.endsWith('.cjs')) { 139 | fs.unlinkSync(configFile); 140 | } 141 | 142 | /** @type {LinterServiceResult} */ 143 | const output = { 144 | version: input.version, 145 | returnCode: 0, 146 | result: result.results[0], 147 | fixResult: fixResult.results[0], 148 | output: fixedFile, 149 | ruleMetadata: result.ruleMetadata, 150 | }; 151 | 152 | // Write the linting result to the stdout. 153 | process.stdout.write( 154 | createJsonPayload(output, (key, value) => { 155 | if (key.startsWith('_')) return undefined; 156 | 157 | return value; 158 | }), 159 | ); 160 | } catch (e) { 161 | console.error(e); 162 | /** @type {LinterServiceResult} */ 163 | const output = { 164 | version: input.version, 165 | returnCode: 1, 166 | result: /** @type {any} */ (e).message, 167 | }; 168 | 169 | process.stdout.write(createJsonPayload(output)); 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /src/monaco-editor/monarch-syntaxes/astro.ts: -------------------------------------------------------------------------------- 1 | import type { languages } from 'monaco-editor'; 2 | export const language: languages.IMonarchLanguage = { 3 | defaultToken: '', 4 | tokenPostfix: '.astro', 5 | ignoreCase: false, 6 | 7 | // non matched elements 8 | empty: [ 9 | 'area', 10 | 'base', 11 | 'basefont', 12 | 'br', 13 | 'col', 14 | 'frame', 15 | 'hr', 16 | 'img', 17 | 'input', 18 | 'isindex', 19 | 'link', 20 | 'meta', 21 | 'param', 22 | ], 23 | 24 | // The main tokenizer for our languages 25 | tokenizer: { 26 | root: [ 27 | [/)/, ['delimiter', 'tag', '', 'delimiter']], 32 | [/(<)(script)/, [{ token: 'delimiter' }, { token: 'tag', next: '@script' }]], 33 | [/(<)(style)/, [{ token: 'delimiter' }, { token: 'tag', next: '@style' }]], 34 | [/(<)((?:[\w-]+:)?[\w-]+)/, [{ token: 'delimiter' }, { token: 'tag', next: '@otherTag' }]], 35 | [/(<\/)((?:[\w-]+:)?[\w-]+)/, [{ token: 'delimiter' }, { token: 'tag', next: '@otherTag' }]], 36 | [/]+/, 'metatag.content'], 42 | [/>/, 'metatag', '@pop'], 43 | ], 44 | 45 | frontmatter: [ 46 | [/^---/, { token: 'comment', next: '@pop', nextEmbedded: '@pop' }], 47 | [ 48 | /./, 49 | { 50 | token: '@rematch', 51 | next: '@frontmatterEmbedded', 52 | nextEmbedded: 'text/javascript', 53 | }, 54 | ], 55 | ], 56 | 57 | frontmatterEmbedded: [ 58 | [/[^-]+|-[^-]{2,}/, { token: '@rematch', next: '@pop' }], 59 | [/^---/, { token: 'comment', next: '@root', nextEmbedded: '@pop' }], 60 | ], 61 | 62 | expression: [ 63 | [ 64 | /[^<{}]/, 65 | { 66 | token: '@rematch', 67 | next: '@expressionEmbedded', 68 | nextEmbedded: 'text/javascript', 69 | }, 70 | ], 71 | [//, 'comment', '@pop'], 83 | [/[^-]+/, 'comment.content'], 84 | [/./, 'comment.content'], 85 | ], 86 | 87 | otherTag: [ 88 | [/\/?>/, 'delimiter', '@pop'], 89 | [/"([^"]*)"/, 'attribute.value'], 90 | [/'([^']*)'/, 'attribute.value'], 91 | [/[\w-]+/, 'attribute.name'], 92 | [/[=]/, 'delimiter'], 93 | [/[\t\n\r ]+/, ''], // whitespace 94 | ], 95 | 96 | // -- BEGIN