├── .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 | [](https://github.com/stylelint/stylelint-demo/actions)
4 | [](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 | [/, 'delimiter'],
37 | [/[^<{]+/, ''], // text
38 | ],
39 |
40 | doctype: [
41 | [/[^>]+/, '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 | [/, { token: '@rematch', next: '@pop' }],
72 | [/\}/, { token: '', next: '@pop' }],
73 | ],
74 |
75 | expressionEmbedded: [
76 | [/\{/, '@rematch', '@push'],
77 | [/, { token: '@rematch', next: '@pop', nextEmbedded: '@pop' }],
78 | [/\}/, { token: '@rematch', next: '@pop', nextEmbedded: '@pop' }],
79 | ],
80 |
81 | comment: [
82 | [/-->/, '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