From 54eeee51b4c45e7d930ed06f36f762399153e096 Mon Sep 17 00:00:00 2001 From: Mathieu Schimmerling Date: Sun, 21 Sep 2025 23:13:53 +0200 Subject: [PATCH] v3: Technical refactor (#302) --- .babelrc | 5 - .eslintrc.cjs | 2 - .eslintrc.esm.mjs | 24 - .github/actions/setup-node-pnpm/action.yml | 30 + .github/workflows/build.yml | 34 - .github/workflows/ci.yml | 40 + .github/workflows/e2e.yml | 16 - .github/workflows/setup-node-pnpm.yml | 36 + .gitignore | 18 +- .husky/pre-commit | 1 - .nvmrc | 1 - .oxlintrc.json | 156 + .prettierrc | 11 +- .vscode/extensions.json | 6 +- .vscode/launch.json | 15 - .vscode/settings.json | 8 - CONTRIBUTING.md | 12 +- README.md | 53 +- ...Plugin.js => generateContentVitePlugin.ts} | 21 +- build/lib/angularHighlighter.d.ts | 6 + ...arHighlighter.js => angularHighlighter.ts} | 27 +- ...kiTheme.js => componentPartyShikiTheme.ts} | 42 +- build/lib/generateContent.js | 227 - build/lib/generateContent.ts | 446 + build/lib/highlighter.d.ts | 8 + build/lib/highlighter.js | 46 - build/lib/highlighter.ts | 94 + ...amework.js => playgroundUrlByFramework.ts} | 88 +- build/template/footer.html | 2 +- .../2-styling/mithril/CssStyle.js | 2 +- .../2-templating/3-loop/lit/colors-list.js | 2 +- content/2-templating/3-loop/mithril/Colors.js | 2 +- content/2-templating/3-loop/vue2/Colors.vue | 5 +- content/2-templating/3-loop/vue3/Colors.vue | 5 +- .../4-event-click/mithril/Counter.js | 2 +- .../inputfocused.component.ts | 7 +- .../5-dom-ref/vue2/InputFocused.vue | 2 +- .../5-dom-ref/vue3/InputFocused.vue | 2 +- .../6-conditional/mithril/TrafficLight.js | 2 +- .../angularRenaissance/time.component.ts | 2 +- .../1-props/mithril/UserProfile.js | 2 +- .../1-props/solid/UserProfile.jsx | 2 +- .../2-emit-to-parent/mithril/AnswerButton.js | 2 +- .../2-emit-to-parent/mithril/App.js | 2 +- .../2-emit-to-parent/vue2/App.vue | 5 +- .../2-emit-to-parent/vue3/App.vue | 5 +- .../3-slot/mithril/FunnyButton.js | 2 +- .../4-slot-fallback/mithril/FunnyButton.js | 2 +- .../5-context/mithril/App.js | 2 +- .../5-context/mithril/UserProfile.js | 4 +- .../1-input-text/vue2/InputHello.vue | 2 +- .../1-input-text/vue3/InputHello.vue | 2 +- .../2-checkbox/mithril/IsAvailable.js | 2 +- .../2-checkbox/vue2/IsAvailable.vue | 6 +- .../2-checkbox/vue3/IsAvailable.vue | 6 +- .../6-form-input/3-radio/mithril/PickPill.js | 6 +- .../6-form-input/3-radio/vue2/PickPill.vue | 14 +- .../6-form-input/3-radio/vue3/PickPill.vue | 14 +- .../color-select.component.ts | 2 +- .../6-form-input/4-select/lit/color-select.js | 2 +- .../4-select/mithril/ColorSelect.js | 4 +- .../1-render-app/react/main.jsx | 2 +- .../2-fetch-data/aurelia2/app.ts | 1 - .../2-fetch-data/lit/x-app.js | 2 +- .../2-fetch-data/mithril/App.js | 6 +- .../2-fetch-data/vue2/App.vue | 10 +- .../2-fetch-data/vue3/App.vue | 10 +- cypress.config.ts | 16 - cypress/e2e/spec.cy.ts | 56 - cypress/fixtures/example.json | 5 - cypress/support/commands.ts | 37 - cypress/support/e2e.ts | 20 - eslint.config.ts | 45 + frameworks.mjs => frameworks.ts | 385 +- index.html | 138 +- jsconfig.json | 34 - lefthook.yml | 10 + package.json | 131 +- playwright.config.ts | 31 + pnpm-lock.yaml | 8539 +++-------------- public/manifest.json | 74 + public/robots.txt | 6 + public/sitemap.xml | 231 + scripts/generateContent.js | 3 - scripts/generateContent.ts | 16 + ...eProgress.js => generateReadMeProgress.ts} | 76 +- scripts/generateSitemap.ts | 120 + ...pmDownloadStats.js => npmDownloadStats.ts} | 19 +- src/App.svelte | 13 +- src/Index.svelte | 683 +- src/Router.svelte | 75 - src/app.css | 14 + src/components/AppNotificationCenter.svelte | 40 - src/components/Aside.svelte | 93 +- src/components/CodeEditor.svelte | 68 +- src/components/FrameworkLabel.svelte | 24 +- src/components/GithubIcon.svelte | 16 - src/components/GithubStarButton.svelte | 115 +- src/components/Header.svelte | 35 +- src/components/NotificationCenter.svelte | 96 - src/components/TransitionWithClass.svelte | 30 +- src/constants.ts | 2 + ...{copyToClipboard.js => copyToClipboard.ts} | 6 +- src/lib/createLocaleStorage.js | 29 - src/lib/createLocaleStorage.ts | 37 + src/lib/throttle.js | 14 - src/main.js | 9 - src/main.ts | 7 + src/router.ts | 6 + src/svelte.d.ts | 5 + src/vite-env.d.ts | 11 + svelte.config.js | 11 - svelte.config.ts | 10 + test/e2e/config/test-config.ts | 31 + test/e2e/framework-comparison.test.ts | 102 + test/e2e/home.test.ts | 86 + test/e2e/integration.test.ts | 105 + test/e2e/utils/test-helpers.ts | 83 + tsconfig.app.json | 9 - tsconfig.json | 42 +- tsconfig.node.json | 28 + types/micache.d.ts | 10 + uno.config.ts | 11 - vite.config.js | 179 - vite.config.ts | 235 + 125 files changed, 4918 insertions(+), 9071 deletions(-) delete mode 100644 .babelrc delete mode 100644 .eslintrc.cjs delete mode 100644 .eslintrc.esm.mjs create mode 100644 .github/actions/setup-node-pnpm/action.yml delete mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/ci.yml delete mode 100644 .github/workflows/e2e.yml create mode 100644 .github/workflows/setup-node-pnpm.yml delete mode 100755 .husky/pre-commit delete mode 100644 .nvmrc create mode 100644 .oxlintrc.json delete mode 100644 .vscode/launch.json delete mode 100644 .vscode/settings.json rename build/{generateContentVitePlugin.js => generateContentVitePlugin.ts} (68%) create mode 100644 build/lib/angularHighlighter.d.ts rename build/lib/{angularHighlighter.js => angularHighlighter.ts} (68%) rename build/lib/{componentPartyShikiTheme.js => componentPartyShikiTheme.ts} (98%) delete mode 100644 build/lib/generateContent.js create mode 100644 build/lib/generateContent.ts create mode 100644 build/lib/highlighter.d.ts delete mode 100644 build/lib/highlighter.js create mode 100644 build/lib/highlighter.ts rename build/lib/{playgroundUrlByFramework.js => playgroundUrlByFramework.ts} (68%) delete mode 100644 cypress.config.ts delete mode 100644 cypress/e2e/spec.cy.ts delete mode 100644 cypress/fixtures/example.json delete mode 100644 cypress/support/commands.ts delete mode 100644 cypress/support/e2e.ts create mode 100644 eslint.config.ts rename frameworks.mjs => frameworks.ts (52%) delete mode 100644 jsconfig.json create mode 100644 lefthook.yml create mode 100644 playwright.config.ts create mode 100644 public/manifest.json create mode 100644 public/sitemap.xml delete mode 100644 scripts/generateContent.js create mode 100644 scripts/generateContent.ts rename scripts/{generateReadMeProgress.js => generateReadMeProgress.ts} (66%) create mode 100644 scripts/generateSitemap.ts rename scripts/{npmDownloadStats.js => npmDownloadStats.ts} (71%) delete mode 100644 src/Router.svelte delete mode 100644 src/components/AppNotificationCenter.svelte delete mode 100644 src/components/GithubIcon.svelte delete mode 100644 src/components/NotificationCenter.svelte create mode 100644 src/constants.ts rename src/lib/{copyToClipboard.js => copyToClipboard.ts} (79%) delete mode 100644 src/lib/createLocaleStorage.js create mode 100644 src/lib/createLocaleStorage.ts delete mode 100644 src/lib/throttle.js delete mode 100644 src/main.js create mode 100644 src/main.ts create mode 100644 src/router.ts create mode 100644 src/svelte.d.ts create mode 100644 src/vite-env.d.ts delete mode 100644 svelte.config.js create mode 100644 svelte.config.ts create mode 100644 test/e2e/config/test-config.ts create mode 100644 test/e2e/framework-comparison.test.ts create mode 100644 test/e2e/home.test.ts create mode 100644 test/e2e/integration.test.ts create mode 100644 test/e2e/utils/test-helpers.ts delete mode 100644 tsconfig.app.json create mode 100644 tsconfig.node.json create mode 100644 types/micache.d.ts delete mode 100644 uno.config.ts delete mode 100644 vite.config.js create mode 100644 vite.config.ts diff --git a/.babelrc b/.babelrc deleted file mode 100644 index 5631f93..0000000 --- a/.babelrc +++ /dev/null @@ -1,5 +0,0 @@ -{ - "plugins": [ - ["@babel/plugin-proposal-decorators", { "decoratorsBeforeExport": true }] - ] -} diff --git a/.eslintrc.cjs b/.eslintrc.cjs deleted file mode 100644 index 02f35b2..0000000 --- a/.eslintrc.cjs +++ /dev/null @@ -1,2 +0,0 @@ -const _require = require("esm")(module); -module.exports = _require("./.eslintrc.esm.mjs").default; diff --git a/.eslintrc.esm.mjs b/.eslintrc.esm.mjs deleted file mode 100644 index 5677945..0000000 --- a/.eslintrc.esm.mjs +++ /dev/null @@ -1,24 +0,0 @@ -import FRAMEWORKS from "./frameworks.mjs"; - -/** - * @type {import("eslint").Linter.Config} - */ -export default { - parserOptions: { - ecmaVersion: 2022, - sourceType: "module", - }, - env: { - browser: true, - }, - plugins: ["prettier"], - overrides: FRAMEWORKS.reduce((acc, { eslint }) => { - if (Array.isArray(eslint)) { - acc.push(...eslint); - } else { - acc.push(eslint); - } - - return acc; - }, []), -}; diff --git a/.github/actions/setup-node-pnpm/action.yml b/.github/actions/setup-node-pnpm/action.yml new file mode 100644 index 0000000..21abb84 --- /dev/null +++ b/.github/actions/setup-node-pnpm/action.yml @@ -0,0 +1,30 @@ +name: "Setup Node.js and pnpm" +description: "Setup Node.js and pnpm with caching" +inputs: + node-version: + description: "Node.js version to use" + required: false + default: "22.18.0" + pnpm-version: + description: "pnpm version to use" + required: false + default: "10.14.0" + +runs: + using: "composite" + steps: + - name: Setup PNPM + uses: pnpm/action-setup@v4 + with: + version: ${{ inputs.pnpm-version }} + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ inputs.node-version }} + cache: "pnpm" + + - name: Install dependencies + shell: bash + run: pnpm install --frozen-lockfile diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index 66a5433..0000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Node.js Build - -on: - push: - pull_request: - -env: - NODE_OPTIONS: --max_old_space_size=4096 - -jobs: - build: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v3 - - # Read Node version from .nvmrc - - name: Read Node version from .nvmrc - id: nvm - run: echo "NODE_VERSION=$(cat .nvmrc)" >> $GITHUB_OUTPUT - - # Setup Node.js with the version from .nvmrc - - name: Use Node.js version from .nvmrc - uses: actions/setup-node@v3 - with: - node-version: ${{ steps.nvm.outputs.NODE_VERSION }} - - - name: Setup PNPM - uses: pnpm/action-setup@v4.0.0 - - - run: pnpm install --frozen-lockfile - - # - run: pnpm run lint # uncomment when eslint is fixed - - run: pnpm run build \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..120fb60 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,40 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +env: + NODE_OPTIONS: --max_old_space_size=4096 + +jobs: + build-and-test: + timeout-minutes: 30 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js and pnpm + uses: ./.github/actions/setup-node-pnpm + + - name: Build application + run: pnpm run build + + - name: Run checks + run: pnpm check:ci + + - name: Install Playwright Chromium + run: pnpm run test:e2e:install + + - name: Run Playwright tests + run: pnpm run test:e2e + + - name: Upload test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml deleted file mode 100644 index 6a76a25..0000000 --- a/.github/workflows/e2e.yml +++ /dev/null @@ -1,16 +0,0 @@ -name: End-to-end tests -on: push -jobs: - cypress-run: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Setup PNPM - uses: pnpm/action-setup@v4.0.0 - - name: Cypress run - uses: cypress-io/github-action@v6 - with: - build: pnpm build - start: pnpm preview --port 5173 - browser: chrome diff --git a/.github/workflows/setup-node-pnpm.yml b/.github/workflows/setup-node-pnpm.yml new file mode 100644 index 0000000..053a856 --- /dev/null +++ b/.github/workflows/setup-node-pnpm.yml @@ -0,0 +1,36 @@ +name: Setup Node.js and pnpm + +on: + workflow_call: + inputs: + node-version: + description: "Node.js version to use" + required: false + type: string + default: "22.18.0" + pnpm-version: + description: "pnpm version to use" + required: false + type: string + default: "10.14.0" + +jobs: + setup: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup PNPM + uses: pnpm/action-setup@v4 + with: + version: ${{ inputs.pnpm-version }} + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ inputs.node-version }} + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile diff --git a/.gitignore b/.gitignore index b13bd78..679a072 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +test-results # Logs logs *.log @@ -7,13 +8,21 @@ yarn-error.log* pnpm-debug.log* lerna-debug.log* +# Package manager files +package-lock.json +yarn.lock +.npm +.yarn + node_modules dist dist-ssr *.local +# Prettier cache +.cache + # Lint -.eslintcache # Editor directories and files .vscode/* @@ -30,4 +39,9 @@ src/generatedContent archive -vite.config.js.timestamp* \ No newline at end of file +vite.config.js.timestamp* + +playwright-report +test-results + +public/_redirects \ No newline at end of file diff --git a/.husky/pre-commit b/.husky/pre-commit deleted file mode 100755 index e02c24e..0000000 --- a/.husky/pre-commit +++ /dev/null @@ -1 +0,0 @@ -pnpm lint-staged \ No newline at end of file diff --git a/.nvmrc b/.nvmrc deleted file mode 100644 index adb5558..0000000 --- a/.nvmrc +++ /dev/null @@ -1 +0,0 @@ -22.14.0 \ No newline at end of file diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000..1ece355 --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,156 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["typescript", "unicorn"], + "categories": { + "correctness": "off" + }, + "env": { + "builtin": true, + "browser": true, + "commonjs": true, + "node": true, + "shared-node-browser": true + }, + "ignorePatterns": [ + "**/logs", + "**/*.log", + "**/npm-debug.log*", + "**/yarn-debug.log*", + "**/yarn-error.log*", + "**/pnpm-debug.log*", + "**/lerna-debug.log*", + "**/node_modules", + "**/dist", + "**/dist-ssr", + "**/*.local", + ".vscode/*", + "!.vscode/extensions.json", + "**/.idea", + "**/.DS_Store", + "**/*.suo", + "**/*.ntvs*", + "**/*.njsproj", + "**/*.sln", + "**/*.sw?", + "src/generatedContent", + "**/archive", + "**/vite.config.js.timestamp*", + "content/**" + ], + "rules": { + "for-direction": "error", + "no-async-promise-executor": "error", + "no-case-declarations": "error", + "no-class-assign": "error", + "no-compare-neg-zero": "error", + "no-cond-assign": "error", + "no-const-assign": "error", + "no-constant-binary-expression": "error", + "no-constant-condition": "error", + "no-control-regex": "error", + "no-debugger": "error", + "no-delete-var": "error", + "no-dupe-class-members": "error", + "no-dupe-else-if": "error", + "no-dupe-keys": "error", + "no-duplicate-case": "error", + "no-empty": "error", + "no-empty-character-class": "error", + "no-empty-pattern": "error", + "no-empty-static-block": "error", + "no-ex-assign": "error", + "no-extra-boolean-cast": "error", + "no-fallthrough": "error", + "no-func-assign": "error", + "no-global-assign": "error", + "no-import-assign": "error", + "no-invalid-regexp": "error", + "no-irregular-whitespace": "error", + "no-loss-of-precision": "error", + "no-new-native-nonconstructor": "error", + "no-nonoctal-decimal-escape": "error", + "no-obj-calls": "error", + "no-prototype-builtins": "error", + "no-redeclare": "error", + "no-regex-spaces": "error", + "no-self-assign": "error", + "no-setter-return": "error", + "no-shadow-restricted-names": "error", + "no-sparse-arrays": "error", + "no-this-before-super": "error", + "no-unexpected-multiline": "off", + "no-unsafe-finally": "error", + "no-unsafe-negation": "error", + "no-unsafe-optional-chaining": "error", + "no-unused-labels": "error", + "no-unused-private-class-members": "error", + "no-unused-vars": "error", + "no-useless-backreference": "error", + "no-useless-catch": "error", + "no-useless-escape": "error", + "no-with": "error", + "require-yield": "error", + "use-isnan": "error", + "valid-typeof": "error", + "@typescript-eslint/ban-ts-comment": "error", + "no-array-constructor": "error", + "@typescript-eslint/no-duplicate-enum-values": "error", + "@typescript-eslint/no-empty-object-type": "error", + "@typescript-eslint/no-explicit-any": "error", + "@typescript-eslint/no-extra-non-null-assertion": "error", + "@typescript-eslint/no-misused-new": "error", + "@typescript-eslint/no-namespace": "error", + "@typescript-eslint/no-non-null-asserted-optional-chain": "error", + "@typescript-eslint/no-require-imports": "error", + "@typescript-eslint/no-this-alias": "error", + "@typescript-eslint/no-unnecessary-type-constraint": "error", + "@typescript-eslint/no-unsafe-declaration-merging": "error", + "@typescript-eslint/no-unsafe-function-type": "error", + "no-unused-expressions": "error", + "@typescript-eslint/no-wrapper-object-types": "error", + "@typescript-eslint/prefer-as-const": "error", + "@typescript-eslint/prefer-namespace-keyword": "error", + "@typescript-eslint/triple-slash-reference": "error", + "curly": "off", + "unicorn/empty-brace-spaces": "off", + "unicorn/no-nested-ternary": "off", + "unicorn/number-literal-case": "off" + }, + "overrides": [ + { + "files": ["**/*.ts", "**/*.tsx", "**/*.mts", "**/*.cts"], + "rules": { + "no-class-assign": "off", + "no-const-assign": "off", + "no-dupe-class-members": "off", + "no-dupe-keys": "off", + "no-func-assign": "off", + "no-import-assign": "off", + "no-new-native-nonconstructor": "off", + "no-obj-calls": "off", + "no-redeclare": "off", + "no-setter-return": "off", + "no-this-before-super": "off", + "no-unsafe-negation": "off", + "no-var": "error", + "no-with": "off", + "prefer-rest-params": "error", + "prefer-spread": "error" + } + }, + { + "files": ["*.svelte", "**/*.svelte"], + "rules": { + "no-inner-declarations": "off", + "no-self-assign": "off" + } + }, + { + "files": ["*.svelte", "**/*.svelte"], + "rules": { + "no-inner-declarations": "off", + "no-self-assign": "off" + } + } + ] +} diff --git a/.prettierrc b/.prettierrc index dc9ff05..05dafbf 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,4 +1,11 @@ { - "trailingComma": "es5", - "plugins": ["prettier-plugin-svelte"] + "plugins": ["prettier-plugin-svelte"], + "overrides": [ + { + "files": "*.svelte", + "options": { + "parser": "svelte" + } + } + ] } diff --git a/.vscode/extensions.json b/.vscode/extensions.json index e0b0168..3758820 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,9 +1,7 @@ { "recommendations": [ "esbenp.prettier-vscode", - "dbaeumer.vscode-eslint", "svelte.svelte-vscode", - "antfu.unocss" - ], - "unwantedRecommendations": [] + "bradlc.vscode-tailwindcss" + ] } diff --git a/.vscode/launch.json b/.vscode/launch.json deleted file mode 100644 index 4b1147d..0000000 --- a/.vscode/launch.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - // Use IntelliSense to learn about possible attributes. - // Hover to view descriptions of existing attributes. - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 - "version": "0.2.0", - "configurations": [ - { - "type": "chrome", - "request": "launch", - "name": "Launch Chrome against localhost", - "url": "http://localhost:3000", - "webRoot": "${workspaceFolder}" - } - ] -} diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 9efb6a0..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "editor.defaultFormatter": "esbenp.prettier-vscode", - // Runs Prettier, then ESLint - "editor.codeActionsOnSave": ["source.formatDocument", "source.fixAll.eslint"], - "[svelte]": { - "editor.defaultFormatter": "svelte.svelte-vscode" - } -} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 97dab29..da8b97d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,13 +6,9 @@ This site is built with [Vite](https://vitejs.dev) and [Svelte](https://svelte.d 1. Fork the project and create a new branch 2. Add the new framework SVG logo in `public/framework` -3. Install the ESLint plugin associated to the framework -4. In `frameworks.mjs`, add a new entry with SVG link and ESLint configuration -5. If the framework needs a language syntax highlight, add it to the call to `getHighlighter`’s `langs` argument in `build/lib/generateContent.js` -6. To make a playground link: - 1. Add a `create${FRAMEWORK}Playground.js` file in `build/lib/playground`. - 2. That file should export a function that returns an object with a `fromContentByFilename` method that accepts an object of filepath keys and file content values, then returns an absolute URL to a framework’s online REPL with those files loaded. - 3. Register its export in `build/lib/playground/index.js` +3. In `frameworks.ts`, add a new entry with SVG link +4. If the framework needs a language syntax highlight, add it to the call to `getHighlighter`’s `langs` argument in `build/lib/generateContent.ts` +5. To make a playground link in `build/lib/playgroundUrlByFramework.ts`. ## Improve website @@ -23,4 +19,4 @@ pnpm i pnpm run dev ``` -This project requires Node.js to be `v14.0.0` or higher, because we use new JavaScript features in our code, such as optional chaining. +This project requires Node.js to be `v22.18.0` or higher, because we use new JavaScript features in our code, such as optional chaining. diff --git a/README.md b/README.md index b2a8b96..c89c6c4 100644 --- a/README.md +++ b/README.md @@ -433,7 +433,7 @@ How do we solve this ? Developers love having framework overview by examples. It
- Ember Polaris (preview) + Ember Polaris @@ -657,6 +657,44 @@ How do we solve this ? Developers love having framework overview by examples. It - [x] Fetch data
+ +
+ + + Ripple + + + +- [x] Reactivity + - [x] Declare state + - [x] Update state + - [x] Computed state +- [x] Templating + - [x] Minimal template + - [x] Styling + - [x] Loop + - [x] Event click + - [x] Dom ref + - [x] Conditional +- [x] Lifecycle + - [x] On mount + - [x] On unmount +- [ ] Component composition + - [x] Props + - [x] Emit to parent + - [x] Slot + - [x] Slot fallback + - [ ] Context +- [x] Form input + - [x] Input text + - [x] Checkbox + - [x] Radio + - [x] Select +- [ ] Webapp features + - [x] Render app + - [ ] Fetch data + +
@@ -671,7 +709,7 @@ pnpm i pnpm run dev ``` -This project requires Node.js to be `v20` or higher. +This project requires Node.js to be `v22.18.0` or higher. ### Principle when add/edit a framework snippet @@ -684,16 +722,13 @@ We believe that deep understanding should precede optimization, enabling learner 1. Fork the project and create a new branch 2. Add the new framework SVG logo in `public/framework` -3. Install the ESLint plugin associated to the framework -4. In `frameworks.mjs`, add a new entry with SVG link and ESLint configuration -5. If the framework needs a language syntax highlight, add it to the call to `getHighlighter`’s `langs` argument in `build/lib/generateContent.js` -6. To make a playground link: - 1. In file `build/lib/playgroundUrlByFramework.js`, add your framework id. - 2. The method accepts an object of filepath keys and file content values, then returns a playground URL to the framework’s online REPL with those files loaded. +3. In `frameworks.ts`, add a new entry with SVG link +4. If the framework needs a language syntax highlight, add it to the call to `getHighlighter`’s `langs` argument in `build/lib/generateContent.ts` +5. To make a playground link in `build/lib/playgroundUrlByFramework.ts`. ## 🧑‍💻 Contributors -This project exists thanks to all the people who contribute. \[[Contribute](CONTRIBUTING.md)]. +This project exists thanks to all the people who contribute. [Contribute](./CONTRIBUTING.md) [![Contributors](https://opencollective.com/component-party/contributors.svg?width=890&button=false)](https://github.com/matschik/component-party/graphs/contributors) ## ⚖️ License diff --git a/build/generateContentVitePlugin.js b/build/generateContentVitePlugin.ts similarity index 68% rename from build/generateContentVitePlugin.js rename to build/generateContentVitePlugin.ts index 00ac401..3f191b6 100644 --- a/build/generateContentVitePlugin.js +++ b/build/generateContentVitePlugin.ts @@ -1,20 +1,21 @@ -import generateContent from "./lib/generateContent.js"; +import generateContent from "./lib/generateContent"; import { createFsCache } from "micache"; import { hashElement } from "folder-hash"; -import chokidar from "chokidar"; +import chokidar, { type FSWatcher } from "chokidar"; +import { disposeHighlighter } from "./lib/highlighter.ts"; const contentDirFsCache = await createFsCache("pluginGenerateFrameworkContent"); export default function pluginGenerateFrameworkContent() { const name = "generateFrameworkContent"; - function logInfo(...args) { + function logInfo(...args: unknown[]) { console.info(`[${name}]`, ...args); } let buildIsRunning = false; - async function build() { + async function build(): Promise { if (buildIsRunning) { return; } @@ -23,7 +24,7 @@ export default function pluginGenerateFrameworkContent() { const contentDirHash = (await hashElement("content")).hash + (await hashElement("build")).hash + - (await hashElement("frameworks.mjs")).hash; + (await hashElement("frameworks.ts")).hash; const contentDirLastHash = await contentDirFsCache.get("contentDirHash"); if (contentDirHash !== contentDirLastHash) { @@ -36,14 +37,14 @@ export default function pluginGenerateFrameworkContent() { buildIsRunning = false; } - let fsContentWatcher; + let fsContentWatcher: FSWatcher | undefined; if (process.env.NODE_ENV === "development") { fsContentWatcher = chokidar.watch(["content"]).on("change", build); } return { name, - async buildStart() { + async buildStart(): Promise { try { await build(); } catch (error) { @@ -51,8 +52,10 @@ export default function pluginGenerateFrameworkContent() { throw error; } }, - async buildEnd() { - fsContentWatcher && (await fsContentWatcher.close()); + async buildEnd(): Promise { + await fsContentWatcher?.close(); + // Dispose of highlighter instances to prevent memory leaks + await disposeHighlighter(); }, }; } diff --git a/build/lib/angularHighlighter.d.ts b/build/lib/angularHighlighter.d.ts new file mode 100644 index 0000000..51e933a --- /dev/null +++ b/build/lib/angularHighlighter.d.ts @@ -0,0 +1,6 @@ +export function mustUseAngularHighlighter(fileContent: string): boolean; + +export function highlightAngularComponent( + fileContent: string, + fileExt: string, +): Promise; diff --git a/build/lib/angularHighlighter.js b/build/lib/angularHighlighter.ts similarity index 68% rename from build/lib/angularHighlighter.js rename to build/lib/angularHighlighter.ts index d638c88..23b9ce2 100644 --- a/build/lib/angularHighlighter.js +++ b/build/lib/angularHighlighter.ts @@ -1,12 +1,15 @@ -import { codeToHighlightCodeHtml } from "./highlighter.js"; +import { codeToHighlightCodeHtml } from "./highlighter.ts"; -export function mustUseAngularHighlighter(fileContent) { +export function mustUseAngularHighlighter(fileContent: string): boolean { return ( fileContent.includes("@angular/core") && fileContent.includes("template") ); } -export async function highlightAngularComponent(fileContent, fileExt) { +export async function highlightAngularComponent( + fileContent: string, + fileExt: string, +): Promise { const templateCode = getAngularTemplateCode(fileContent); let codeHighlighted = ""; @@ -15,26 +18,26 @@ export async function highlightAngularComponent(fileContent, fileExt) { removeAngularTemplateContent(fileContent); const templateCodeHighlighted = await codeToHighlightCodeHtml( templateCode, - "html" + "html", ); const componentWithoutTemplateHighlighted = await codeToHighlightCodeHtml( componentWithEmptyTemplate, - fileExt + fileExt, ); codeHighlighted = componentWithoutTemplateHighlighted.replace( "template", - "template: `" + removeCodeWrapper(templateCodeHighlighted) + "`," + "template: `" + removeCodeWrapper(templateCodeHighlighted) + "`,", ); } else { - codeHighlighted = codeToHighlightCodeHtml(fileContent, fileExt); + codeHighlighted = await codeToHighlightCodeHtml(fileContent, fileExt); } return codeHighlighted; } -function getAngularTemplateCode(fileContent) { +function getAngularTemplateCode(fileContent: string): string { // regex to grab what is inside angular component template inside backticks const regex = /template:\s*`([\s\S]*?)`/gm; @@ -46,17 +49,17 @@ function getAngularTemplateCode(fileContent) { return ""; } -function removeAngularTemplateContent(fileContent) { +function removeAngularTemplateContent(fileContent: string): string { const componentWithoutContentInsideTemplate = fileContent.replace( /template:\s*`([\s\S]*?)([^*])`,?/gm, - "template" + "template", ); return componentWithoutContentInsideTemplate; } -function removeCodeWrapper(html) { +function removeCodeWrapper(html: string): string { const regexForWrapper = /([\s\S]*?)<\/code><\/pre>/gm; const code = regexForWrapper.exec(html); - return code[2]; + return code ? code[2] : ""; } diff --git a/build/lib/componentPartyShikiTheme.js b/build/lib/componentPartyShikiTheme.ts similarity index 98% rename from build/lib/componentPartyShikiTheme.js rename to build/lib/componentPartyShikiTheme.ts index 8c57958..e8aff1e 100644 --- a/build/lib/componentPartyShikiTheme.js +++ b/build/lib/componentPartyShikiTheme.ts @@ -1,35 +1,19 @@ -export default { +import { type ThemeRegistration } from "shiki"; + +export const componentPartyShikiTheme = { name: "one-dark-pro-for-component-party", type: "dark", semanticHighlighting: true, semanticTokenColors: { - enumMember: { - foreground: "#56b6c2", - }, - "variable.constant": { - foreground: "#d19a66", - }, - "variable.defaultLibrary": { - foreground: "#e5c07b", - }, - "variable:dart": { - foreground: "#d19a66", - }, - "property:dart": { - foreground: "#d19a66", - }, - "annotation:dart": { - foreground: "#d19a66", - }, - "parameter.label:dart": { - foreground: "#abb2bf", - }, - macro: { - foreground: "#d19a66", - }, - tomlArrayKey: { - foreground: "#e5c07b", - }, + enumMember: "#56b6c2", + "variable.constant": "#d19a66", + "variable.defaultLibrary": "#e5c07b", + "variable:dart": "#d19a66", + "property:dart": "#d19a66", + "annotation:dart": "#d19a66", + "parameter.label:dart": "#abb2bf", + macro: "#d19a66", + tomlArrayKey: "#e5c07b", }, tokenColors: [ { @@ -2110,4 +2094,4 @@ export default { "walkThrough.embeddedEditorBackground": "#2e3440", "welcomePage.buttonHoverBackground": "#404754", }, -}; +} as const satisfies ThemeRegistration; diff --git a/build/lib/generateContent.js b/build/lib/generateContent.js deleted file mode 100644 index d9be044..0000000 --- a/build/lib/generateContent.js +++ /dev/null @@ -1,227 +0,0 @@ -import fs from "node:fs/promises"; -import { packageDirectory } from "pkg-dir"; -import path from "node:path"; -import kebabCase from "lodash.kebabcase"; -import FRAMEWORKS from "../../frameworks.mjs"; -import playgroundUrlByFramework from "./playgroundUrlByFramework.js"; -import prettier from "prettier"; -import { - highlightAngularComponent, - mustUseAngularHighlighter, -} from "./angularHighlighter.js"; -import { - codeToHighlightCodeHtml, - markdownToHighlightedHtml, -} from "./highlighter.js"; - -async function pathExists(path) { - try { - await fs.access(path); - return true; - } catch (error) { - return false; - } -} - -export default async function generateContent() { - const rootDir = await packageDirectory(); - const contentPath = path.join(rootDir, "content"); - const sectionDirNames = await fs.readdir(contentPath); - - const treePayload = { - sections: [], - snippets: [], - }; - - const byFrameworkId = {}; - - for (const sectionDirName of sectionDirNames) { - const sectionTitle = dirNameToTitle(sectionDirName); - const sectionId = kebabCase(sectionTitle); - - treePayload.sections.push({ - sectionId, - sectionDirName, - title: sectionTitle, - }); - - const snippetsDirPath = path.join(contentPath, sectionDirName); - const snippetDirNames = await fs.readdir(snippetsDirPath); - - for (const snippetDirName of snippetDirNames) { - const title = dirNameToTitle(snippetDirName); - const snippetId = kebabCase(title); - - treePayload.snippets.push({ - sectionId, - snippetId, - snippetDirName, - sectionDirName, - title, - }); - - const frameworksDirPath = path.join(snippetsDirPath, snippetDirName); - const frameworkIds = FRAMEWORKS.map(({ id }) => id); - - await Promise.all( - frameworkIds.map(async (frameworkId) => { - const frameworkSnippet = { - frameworkId, - snippetId, - files: [], - playgroundURL: "", - markdownFiles: [], - snippetEditHref: `https://github.com/matschik/component-party/tree/main/content/${sectionDirName}/${snippetDirName}/${frameworkId}`, - }; - - const codeFilesDirPath = path.join(frameworksDirPath, frameworkId); - if (!(await pathExists(codeFilesDirPath))) { - byFrameworkId[frameworkId].push(frameworkSnippet); - return; - } - const codeFileNames = await fs.readdir(codeFilesDirPath); - - for (const codeFileName of codeFileNames) { - const codeFilePath = path.join(codeFilesDirPath, codeFileName); - const ext = path.parse(codeFilePath).ext.split(".").pop(); - const content = await fs.readFile(codeFilePath, "utf-8"); - - const file = { - fileName: codeFileName, - ext, - content, - contentHtml: "", - }; - - if (ext === "md") { - file.contentHtml = await markdownToHighlightedHtml(content); - frameworkSnippet.markdownFiles.push(file); - } else { - file.contentHtml = mustUseAngularHighlighter(content) - ? await highlightAngularComponent(content, ext) - : await codeToHighlightCodeHtml(content, ext); - - frameworkSnippet.files.push(file); - } - } - - if (frameworkSnippet.files.length > 0) { - const { filesSorter } = FRAMEWORKS.find( - (f) => f.id === frameworkId - ); - frameworkSnippet.files = filesSorter(frameworkSnippet.files); - const playgroundURL = await generatePlaygroundURL( - frameworkId, - frameworkSnippet.files, - title - ); - - if (playgroundURL) { - frameworkSnippet.playgroundURL = playgroundURL; - } - - // Remove content key, not used anymore - frameworkSnippet.files = frameworkSnippet.files.map((file) => ({ - ...file, - content: undefined, - })); - } - - if (!byFrameworkId[frameworkId]) { - byFrameworkId[frameworkId] = []; - } - - byFrameworkId[frameworkId].push(frameworkSnippet); - }) - ); - } - } - - const generatedContentDirPath = path.join(rootDir, "src/generatedContent"); - const frameworkDirPath = path.join(generatedContentDirPath, "framework"); - const treeFilePath = path.join(generatedContentDirPath, "tree.js"); - const frameworkIndexPath = path.join(frameworkDirPath, "index.js"); - const commentDisclaimer = `// File generated from "node scripts/generateContent.js", DO NOT EDIT/COMMIT`; - - if (!(await pathExists(generatedContentDirPath))) { - await fs.mkdir(generatedContentDirPath, { recursive: true }); - } - - await writeJsFile( - treeFilePath, - ` - ${commentDisclaimer} - export const sections = ${JSON.stringify(treePayload.sections, null, 2)}; - export const snippets = ${JSON.stringify(treePayload.snippets, null, 2)}; - ` - ); - - if (!(await pathExists(frameworkDirPath))) { - await fs.mkdir(frameworkDirPath, { recursive: true }); - } - - await Promise.all( - Object.keys(byFrameworkId).map((frameworkId) => { - const frameworkFilePath = path.join( - frameworkDirPath, - `${frameworkId}.js` - ); - return writeJsFile( - frameworkFilePath, - ` - ${commentDisclaimer} - export default ${JSON.stringify(byFrameworkId[frameworkId], null, 2)} - ` - ); - }) - ); - - await writeJsFile( - frameworkIndexPath, - ` - ${commentDisclaimer} - export default { - ${Object.keys(byFrameworkId) - .map( - (frameworkId) => - `${frameworkId}: () => import("./${frameworkId}.js")` - ) - .join(",\n")} - - }; - ` - ); -} - -function dirNameToTitle(dirName) { - return capitalize(dirName.split("-").splice(1).join(" ")); -} - -function capitalize(string) { - return string.charAt(0).toUpperCase() + string.slice(1); -} - -async function writeJsFile(filepath, jsCode) { - const codeFormatted = await prettier.format(jsCode, { parser: "babel" }); - await fs.writeFile(filepath, codeFormatted); -} - -async function generatePlaygroundURL(frameworkId, files, title) { - const frameworkIdPlayground = playgroundUrlByFramework[frameworkId]; - if (!frameworkIdPlayground) { - return; - } - - const frameworkConfig = FRAMEWORKS.find((f) => f.id === frameworkId); - - const contentByFilename = frameworkConfig - .filesSorter(files) - .reduce((acc, file) => { - acc[file.fileName] = file.content; - return acc; - }, {}); - - const playgroundURL = await frameworkIdPlayground(contentByFilename, title); - - return playgroundURL; -} diff --git a/build/lib/generateContent.ts b/build/lib/generateContent.ts new file mode 100644 index 0000000..891ed36 --- /dev/null +++ b/build/lib/generateContent.ts @@ -0,0 +1,446 @@ +import fs from "node:fs/promises"; +import { packageDirectory } from "package-directory"; +import path from "node:path"; +import { frameworks } from "../../frameworks.ts"; +import playgroundUrlByFramework from "./playgroundUrlByFramework.ts"; +import prettier from "prettier"; +import { + highlightAngularComponent, + mustUseAngularHighlighter, +} from "./angularHighlighter.ts"; +import { + codeToHighlightCodeHtml, + markdownToHighlightedHtml, +} from "./highlighter.ts"; +import kebabCase from "just-kebab-case"; + +interface File { + fileName: string; + ext: string; + content?: string; + contentHtml: string; +} + +interface FrameworkFile { + fileName: string; + [key: string]: unknown; +} + +interface FrameworkSnippet { + frameworkId: string; + snippetId: string; + files: File[]; + playgroundURL: string; + markdownFiles: File[]; + snippetEditHref: string; +} + +interface Section { + sectionId: string; + sectionDirName: string; + title: string; +} + +interface Snippet { + sectionId: string; + snippetId: string; + snippetDirName: string; + sectionDirName: string; + title: string; +} + +interface TreePayload { + sections: Section[]; + snippets: Snippet[]; +} + +async function pathExists(path: string): Promise { + try { + await fs.access(path); + return true; + } catch { + return false; + } +} + +async function shouldRegenerateFiles( + contentPath: string, + generatedContentDirPath: string, + frameworkDirPath: string, +): Promise { + // Check if any generated files exist + const treeFilePath = path.join(generatedContentDirPath, "tree.js"); + const treeDtsFilePath = path.join(generatedContentDirPath, "tree.d.ts"); + const frameworkIndexPath = path.join(frameworkDirPath, "index.js"); + const frameworkIndexDtsPath = path.join(frameworkDirPath, "index.d.ts"); + + const generatedFilesExist = await Promise.all([ + pathExists(treeFilePath), + pathExists(treeDtsFilePath), + pathExists(frameworkIndexPath), + pathExists(frameworkIndexDtsPath), + ]); + + // If any generated file is missing, regenerate + if (!generatedFilesExist.every(Boolean)) { + return true; + } + + // Get the modification time of the content directory + const contentStats = await fs.stat(contentPath); + const contentModTime = contentStats.mtime; + + // Check if any generated file is older than the content directory + const generatedFilePaths = [ + treeFilePath, + treeDtsFilePath, + frameworkIndexPath, + frameworkIndexDtsPath, + ]; + + for (const filePath of generatedFilePaths) { + try { + const fileStats = await fs.stat(filePath); + if (fileStats.mtime < contentModTime) { + return true; + } + } catch { + // If we can't stat a file, regenerate to be safe + return true; + } + } + + return false; +} + +export default async function generateContent( + options: { noCache?: boolean } = {}, +): Promise { + const rootDir = await packageDirectory(); + if (!rootDir) { + throw new Error("Could not find package directory"); + } + const contentPath = path.join(rootDir, "content"); + const generatedContentDirPath = path.join(rootDir, "src/generatedContent"); + const frameworkDirPath = path.join(generatedContentDirPath, "framework"); + + // Check if we should skip generation due to cache + if (!options.noCache) { + const shouldRegenerate = await shouldRegenerateFiles( + contentPath, + generatedContentDirPath, + frameworkDirPath, + ); + + if (!shouldRegenerate) { + console.info("Generated content is up to date, skipping generation."); + return; + } + } + + const sectionDirNames = await fs.readdir(contentPath); + + const treePayload: TreePayload = { + sections: [], + snippets: [], + }; + + const byFrameworkId: Record = {}; + + for (const sectionDirName of sectionDirNames) { + const sectionTitle = dirNameToTitle(sectionDirName); + const sectionId = kebabCase(sectionTitle); + + treePayload.sections.push({ + sectionId, + sectionDirName, + title: sectionTitle, + }); + + const snippetsDirPath = path.join(contentPath, sectionDirName); + const snippetDirNames = await fs.readdir(snippetsDirPath); + + for (const snippetDirName of snippetDirNames) { + const title = dirNameToTitle(snippetDirName); + const snippetId = kebabCase(title); + + treePayload.snippets.push({ + sectionId, + snippetId, + snippetDirName, + sectionDirName, + title, + }); + + const frameworksDirPath = path.join(snippetsDirPath, snippetDirName); + const frameworkIds = frameworks.map(({ id }) => id); + + await Promise.all( + frameworkIds.map(async (frameworkId: string) => { + const frameworkSnippet: FrameworkSnippet = { + frameworkId, + snippetId, + files: [], + playgroundURL: "", + markdownFiles: [], + snippetEditHref: `https://github.com/matschik/component-party/tree/main/content/${sectionDirName}/${snippetDirName}/${frameworkId}`, + }; + + const codeFilesDirPath = path.join(frameworksDirPath, frameworkId); + if (!(await pathExists(codeFilesDirPath))) { + byFrameworkId[frameworkId].push(frameworkSnippet); + return; + } + const codeFileNames = await fs.readdir(codeFilesDirPath); + + for (const codeFileName of codeFileNames) { + const codeFilePath = path.join(codeFilesDirPath, codeFileName); + const ext = path.parse(codeFilePath).ext.split(".").pop() || ""; + const content = await fs.readFile(codeFilePath, "utf-8"); + + const file: File = { + fileName: codeFileName, + ext, + content, + contentHtml: "", + }; + + if (ext === "md") { + file.contentHtml = await markdownToHighlightedHtml(content); + frameworkSnippet.markdownFiles.push(file); + } else { + file.contentHtml = mustUseAngularHighlighter(content) + ? await highlightAngularComponent(content, ext) + : await codeToHighlightCodeHtml(content, ext); + + frameworkSnippet.files.push(file); + } + } + + if (frameworkSnippet.files.length > 0) { + const frameworkConfig = frameworks.find( + (f) => f.id === frameworkId, + ); + if (frameworkConfig) { + frameworkSnippet.files = frameworkConfig.filesSorter( + frameworkSnippet.files as unknown as FrameworkFile[], + ) as unknown as File[]; + } + const playgroundURL = await generatePlaygroundURL( + frameworkId, + frameworkSnippet.files, + title, + ); + + if (playgroundURL) { + frameworkSnippet.playgroundURL = playgroundURL; + } + + // Remove content key, not used anymore + frameworkSnippet.files = frameworkSnippet.files.map((file) => ({ + fileName: file.fileName, + ext: file.ext, + contentHtml: file.contentHtml, + })); + } + + if (!byFrameworkId[frameworkId]) { + byFrameworkId[frameworkId] = []; + } + + byFrameworkId[frameworkId].push(frameworkSnippet); + }), + ); + } + } + + const treeFilePath = path.join(generatedContentDirPath, "tree.js"); + const treeDtsFilePath = path.join(generatedContentDirPath, "tree.d.ts"); + const frameworkIndexPath = path.join(frameworkDirPath, "index.js"); + const frameworkIndexDtsPath = path.join(frameworkDirPath, "index.d.ts"); + const commentDisclaimer = `// File generated from "node scripts/generateContent.js", DO NOT EDIT/COMMIT`; + + if (!(await pathExists(generatedContentDirPath))) { + await fs.mkdir(generatedContentDirPath, { recursive: true }); + } + + await writeJsFile( + treeFilePath, + ` + ${commentDisclaimer} + export const sections = ${JSON.stringify(treePayload.sections, null, 2)}; + export const snippets = ${JSON.stringify(treePayload.snippets, null, 2)}; + `, + ); + + await writeDtsFile( + treeDtsFilePath, + ` + ${commentDisclaimer} + export interface Section { + sectionId: string; + sectionDirName: string; + title: string; + } + + export interface Snippet { + sectionId: string; + snippetId: string; + snippetDirName: string; + sectionDirName: string; + title: string; + } + + export declare const sections: Section[]; + export declare const snippets: Snippet[]; + `, + ); + + if (!(await pathExists(frameworkDirPath))) { + await fs.mkdir(frameworkDirPath, { recursive: true }); + } + + await Promise.all( + Object.keys(byFrameworkId).map((frameworkId) => { + const frameworkFilePath = path.join( + frameworkDirPath, + `${frameworkId}.js`, + ); + return writeJsFile( + frameworkFilePath, + ` + ${commentDisclaimer} + export default ${JSON.stringify(byFrameworkId[frameworkId], null, 2)} + `, + ); + }), + ); + + await writeJsFile( + frameworkIndexPath, + ` + ${commentDisclaimer} + export default { + ${Object.keys(byFrameworkId) + .map( + (frameworkId) => + `${frameworkId}: () => import("./${frameworkId}.js")`, + ) + .join(",\n")} + + }; + `, + ); + + await writeDtsFile( + frameworkIndexDtsPath, + ` + ${commentDisclaimer} + declare const snippetsImporterByFrameworkId: { + [key: string]: () => Promise; + }; + + export default snippetsImporterByFrameworkId; + `, + ); + + // Generate _redirects file for Cloudflare Pages + await generateRedirectsFile(rootDir); +} + +function dirNameToTitle(dirName: string): string { + return capitalize(dirName.split("-").splice(1).join(" ")); +} + +function capitalize(string: string): string { + return string.charAt(0).toUpperCase() + string.slice(1); +} + +async function writeJsFile(filepath: string, jsCode: string): Promise { + const codeFormatted = await prettier.format(jsCode, { parser: "babel" }); + await fs.writeFile(filepath, codeFormatted); +} + +async function writeDtsFile(filepath: string, dtsCode: string): Promise { + const codeFormatted = await prettier.format(dtsCode, { + parser: "typescript", + }); + await fs.writeFile(filepath, codeFormatted); +} + +async function generatePlaygroundURL( + frameworkId: string, + files: File[], + title: string, +): Promise { + const frameworkIdPlayground = playgroundUrlByFramework[frameworkId]; + if (!frameworkIdPlayground) { + return; + } + + const frameworkConfig = frameworks.find((f) => f.id === frameworkId); + if (!frameworkConfig) { + return; + } + + const contentByFilename = frameworkConfig + .filesSorter(files as unknown as FrameworkFile[]) + .reduce((acc: Record, file) => { + if ((file as { content?: string }).content) { + acc[(file as { fileName: string }).fileName] = ( + file as unknown as { content: string } + ).content; + } + return acc; + }, {}); + + const playgroundURL = await frameworkIdPlayground(contentByFilename, title); + + return playgroundURL; +} + +async function generateRedirectsFile(rootDir: string): Promise { + // Generate all possible framework combinations + const frameworkIds = frameworks.map((f) => f.id); + const redirects: string[] = []; + + // Generate redirects for all framework pairs (both directions) + for (let i = 0; i < frameworkIds.length; i++) { + for (let j = 0; j < frameworkIds.length; j++) { + if (i !== j) { + const framework1 = frameworkIds[i]; + const framework2 = frameworkIds[j]; + const redirectPath = `/compare/${framework1}-vs-${framework2}`; + const targetUrl = `/?f=${framework1}-${framework2}`; + redirects.push(`${redirectPath} ${targetUrl} 301`); + } + } + } + + // Generate dynamic redirects for comma-separated patterns using placeholders + const dynamicRedirects = [ + // Single framework redirects (static for better performance) + ...frameworkIds.map((id) => `/?f=${id} / 301`), + // Dynamic redirects for comma-separated patterns - matches any comma-separated values + // This will catch patterns like /?f=react,vue3,angular or /?f=mithril,alpine,etc + "/?f=:frameworks / 301", + ]; + + redirects.push(...dynamicRedirects); + + // Add specific compare patterns that don't match our framework pairs + const specificCompareRedirects = ["/compare/emberPolaris-vs-angular / 301"]; + redirects.push(...specificCompareRedirects); + + const redirectsContent = `# File generated from "node scripts/generateContent.js", DO NOT EDIT/COMMIT +${redirects.join("\n")} +`; + + const publicDir = path.join(rootDir, "public"); + const redirectsFilePath = path.join(publicDir, "_redirects"); + + await fs.writeFile(redirectsFilePath, redirectsContent); + console.info( + `Generated _redirects file for Cloudflare Pages with ${redirects.length} redirects`, + ); +} diff --git a/build/lib/highlighter.d.ts b/build/lib/highlighter.d.ts new file mode 100644 index 0000000..0f6dbf1 --- /dev/null +++ b/build/lib/highlighter.d.ts @@ -0,0 +1,8 @@ +export function codeToHighlightCodeHtml( + code: string, + language: string, +): Promise; + +export function markdownToHighlightedHtml(markdown: string): Promise; + +export function disposeHighlighter(): Promise; diff --git a/build/lib/highlighter.js b/build/lib/highlighter.js deleted file mode 100644 index 2ce6336..0000000 --- a/build/lib/highlighter.js +++ /dev/null @@ -1,46 +0,0 @@ -import { createHighlighter } from "shiki"; -import MarkdownIt from "markdown-it"; -import Shiki from "@shikijs/markdown-it"; -import componentPartyShikiTheme from "./componentPartyShikiTheme.js"; - -const highlighter = await createHighlighter({ - theme: componentPartyShikiTheme, - langs: [ - "javascript", - "svelte", - "html", - "hbs", - "gjs", - "tsx", - "jsx", - "vue", - "marko", - ], - langAlias: { - ripple: "jsx", // until ripple is supported by shiki - }, -}); - -const md = MarkdownIt({ - html: true, -}); - -md.use( - await Shiki({ - theme: componentPartyShikiTheme, - }) -); - -export async function codeToHighlightCodeHtml(code, lang) { - const html = await highlighter.codeToHtml(code, { - lang, - theme: componentPartyShikiTheme, - }); - - return html; -} - -export async function markdownToHighlightedHtml(markdownText) { - const html = md.render(markdownText); - return html; -} diff --git a/build/lib/highlighter.ts b/build/lib/highlighter.ts new file mode 100644 index 0000000..5b66fca --- /dev/null +++ b/build/lib/highlighter.ts @@ -0,0 +1,94 @@ +import { + createHighlighter, + type HighlighterGeneric, + type BundledLanguage, + type BundledTheme, +} from "shiki"; +import MarkdownIt from "markdown-it"; +import { fromHighlighter } from "@shikijs/markdown-it/core"; +import { componentPartyShikiTheme } from "./componentPartyShikiTheme.ts"; + +// Singleton instances +let highlighter: HighlighterGeneric | null = + null; +let md: MarkdownIt | null = null; +let highlighterPromise: Promise< + HighlighterGeneric +> | null = null; + +async function getHighlighter(): Promise< + HighlighterGeneric +> { + if (!highlighterPromise) { + highlighterPromise = createHighlighter({ + themes: [componentPartyShikiTheme], + langs: [ + "javascript", + "svelte", + "html", + "hbs", + "gjs", + "tsx", + "jsx", + "vue", + "marko", + ], + langAlias: { + ripple: "jsx", // until ripple is supported by shiki + }, + }); + } + + if (!highlighter) { + highlighter = await highlighterPromise; + } + + return highlighter; +} + +async function getMarkdownIt(): Promise { + if (!md) { + md = MarkdownIt({ + html: true, + }); + + const highlighterInstance = await getHighlighter(); + md.use( + fromHighlighter(highlighterInstance, { + theme: componentPartyShikiTheme, + }), + ); + } + return md; +} + +export async function codeToHighlightCodeHtml( + code: string, + lang: string, +): Promise { + const highlighterInstance = await getHighlighter(); + const html = await highlighterInstance.codeToHtml(code, { + lang, + theme: componentPartyShikiTheme.name, + }); + + return html; +} + +export async function markdownToHighlightedHtml( + markdownText: string, +): Promise { + const mdInstance = await getMarkdownIt(); + const html = mdInstance.render(markdownText); + return html; +} + +// Function to dispose of instances when no longer needed +export async function disposeHighlighter(): Promise { + if (highlighter) { + await highlighter.dispose(); + highlighter = null; + } + highlighterPromise = null; + md = null; +} diff --git a/build/lib/playgroundUrlByFramework.js b/build/lib/playgroundUrlByFramework.ts similarity index 68% rename from build/lib/playgroundUrlByFramework.js rename to build/lib/playgroundUrlByFramework.ts index cfdd5dd..b7098c6 100644 --- a/build/lib/playgroundUrlByFramework.js +++ b/build/lib/playgroundUrlByFramework.ts @@ -1,16 +1,49 @@ import nodePath from "node:path"; -import { compressToURL } from "@matschik/lz-string"; -import { getParameters } from "codesandbox/lib/api/define.js"; +import LZString from "lz-string"; -export default { - vue3: (contentByFilename) => { +interface PlaygroundFunction { + ( + contentByFilename: Record, + title?: string, + ): string | Promise; +} + +interface SveltePlaygroundOptions { + version: number; + contentByFilename: Record; + title?: string; +} + +interface File { + name: string; + basename: string; + contents: string; + text: boolean; + type: string; +} + +interface PlaygroundData { + title: string; + files: File[]; +} + +// Replacement for codesandbox's getParameters function +function getParameters(parameters: unknown): string { + return LZString.compressToBase64(JSON.stringify(parameters)) + .replace(/\+/g, "-") // Convert '+' to '-' + .replace(/\//g, "_") // Convert '/' to '_' + .replace(/=+$/, ""); // Remove ending '=' +} + +const playgroundUrlByFramework: Record = { + vue3: (contentByFilename: Record) => { const BASE_URL = "https://sfc.vuejs.org/#"; - function utoa(data) { + function utoa(data: string): string { return btoa(unescape(encodeURIComponent(data))); } - function generateURLFromData(data) { + function generateURLFromData(data: unknown): string { return `${BASE_URL}${utoa(JSON.stringify(data))}`; } const data = Object.assign({}, contentByFilename, { @@ -21,21 +54,27 @@ export default { const url = generateURLFromData(data); return url; }, - svelte4: async (contentByFilename, title) => { + svelte4: async ( + contentByFilename: Record, + title?: string, + ) => { return generateSveltePlaygroundURL({ version: 4, contentByFilename, title, }); }, - svelte5: async (contentByFilename, title) => { + svelte5: async ( + contentByFilename: Record, + title?: string, + ) => { return generateSveltePlaygroundURL({ version: 5, contentByFilename, title, }); }, - alpine: (contentByFilename) => { + alpine: (contentByFilename: Record) => { const BASE_URL = "https://codesandbox.io/api/v1/sandboxes/define?embed=1¶meters="; const BASE_PREFIX = `\n\n \n \n \n \n Alpine.js Playground\n \n \n \n\n`; @@ -49,9 +88,7 @@ export default { }, "index.html": { content: - BASE_PREFIX + - (contentByFilename["index.html"]?.content || "") + - BASE_SUFFIX, + BASE_PREFIX + (contentByFilename["index.html"] || "") + BASE_SUFFIX, }, "sandbox.config.json": { content: '{\n "template": "static"\n}', @@ -61,14 +98,14 @@ export default { return `${BASE_URL}${parameters}`; }, - solid: (contentByFilename) => { + solid: (contentByFilename: Record) => { const BASE_URL = "https://playground.solidjs.com/#"; const SOURCE_PREFIX = `import { render } from "solid-js/web";\n`; - const getSourceSuffix = (componentName) => + const getSourceSuffix = (componentName: string) => `\n\nrender(() => <${componentName} />, document.getElementById("app"));\n`; - function generateURLFromData(data) { - return `${BASE_URL}${compressToURL(JSON.stringify(data))}`; + function generateURLFromData(data: unknown): string { + return `${BASE_URL}${LZString.compressToEncodedURIComponent(JSON.stringify(data))}`; } const data = Object.keys(contentByFilename).map((filename) => { @@ -92,10 +129,10 @@ export default { return generateURLFromData(data); }, - marko: async (contentByFilename) => { + marko: async (contentByFilename: Record) => { let firstFile = true; const data = Object.entries(contentByFilename).map(([path, content]) => ({ - path: firstFile ? (firstFile = false) || "index.marko" : path, + path: firstFile ? ((firstFile = false), "index.marko") : path, content, })); @@ -106,11 +143,13 @@ export default { }, }; +export default playgroundUrlByFramework; + async function generateSveltePlaygroundURL({ version, contentByFilename, title, -}) { +}: SveltePlaygroundOptions): Promise { const BASE_URL = `https://svelte.dev/playground/untitled?version=${version}#`; const filenames = Object.keys(contentByFilename); @@ -118,7 +157,7 @@ async function generateSveltePlaygroundURL({ return; } - const files = filenames.map((filename, index) => { + const files: File[] = filenames.map((filename, index) => { const contents = contentByFilename[filename]; const name = index === 0 ? "App.svelte" : nodePath.parse(filename).base; return { @@ -130,9 +169,12 @@ async function generateSveltePlaygroundURL({ }; }); - const payload = { title, files }; + const payload: PlaygroundData = { title: title || "", files }; const hash = await compress_and_encode_text(JSON.stringify(payload)); + if (!hash) { + return; + } const url = `${BASE_URL}${hash}`; @@ -140,7 +182,7 @@ async function generateSveltePlaygroundURL({ } // method `compress_and_encode_text` from https://github.com/sveltejs/svelte.dev/blob/main/apps/svelte.dev/src/routes/(authed)/playground/%5Bid%5D/gzip.js -async function compress_and_encode_text(input) { +async function compress_and_encode_text(input: string): Promise { const reader = new Blob([input]) .stream() .pipeThrough(new CompressionStream("gzip")) @@ -161,7 +203,7 @@ async function compress_and_encode_text(input) { } // method `compress` from https://github.com/marko-js/website/blob/main/src/util/hasher.ts#L8-L25 -export async function markoCompress(value) { +export async function markoCompress(value: string): Promise { const stream = new CompressionStream("gzip"); const writer = stream.writable.getWriter(); writer.write(new TextEncoder().encode(value)); diff --git a/build/template/footer.html b/build/template/footer.html index 7e4c930..7f04f70 100644 --- a/build/template/footer.html +++ b/build/template/footer.html @@ -7,7 +7,7 @@ logo Component party -

+

Web component JavaScript frameworks overview by their syntax and features

diff --git a/content/2-templating/2-styling/mithril/CssStyle.js b/content/2-templating/2-styling/mithril/CssStyle.js index 4cc1ad4..1483cf6 100644 --- a/content/2-templating/2-styling/mithril/CssStyle.js +++ b/content/2-templating/2-styling/mithril/CssStyle.js @@ -7,7 +7,7 @@ export default function CssStyle() { m( "div", m("h1.title", "I am red"), - m("button", { style: { fontSize: "10rem" } }, "I am a button") + m("button", { style: { fontSize: "10rem" } }, "I am a button"), ), }; } diff --git a/content/2-templating/3-loop/lit/colors-list.js b/content/2-templating/3-loop/lit/colors-list.js index dc083d5..7692ec1 100644 --- a/content/2-templating/3-loop/lit/colors-list.js +++ b/content/2-templating/3-loop/lit/colors-list.js @@ -12,7 +12,7 @@ export class ColorsList extends LitElement { ${repeat( this.colors, (color) => color, - (color) => html`
  • ${color}
  • ` + (color) => html`
  • ${color}
  • `, )} `; diff --git a/content/2-templating/3-loop/mithril/Colors.js b/content/2-templating/3-loop/mithril/Colors.js index 40e522d..ec86fcc 100644 --- a/content/2-templating/3-loop/mithril/Colors.js +++ b/content/2-templating/3-loop/mithril/Colors.js @@ -6,7 +6,7 @@ export default function Colors() { view: () => m( "ul", - colors.map((color, idx) => m("li", { key: idx }, color)) + colors.map((color, idx) => m("li", { key: idx }, color)), ), }; } diff --git a/content/2-templating/3-loop/vue2/Colors.vue b/content/2-templating/3-loop/vue2/Colors.vue index 193cbe2..cdcf916 100644 --- a/content/2-templating/3-loop/vue2/Colors.vue +++ b/content/2-templating/3-loop/vue2/Colors.vue @@ -10,10 +10,7 @@ export default {