v3: Technical refactor (#302)

This commit is contained in:
Mathieu Schimmerling
2025-09-21 23:13:53 +02:00
committed by GitHub
parent f9cf2c60a5
commit 54eeee51b4
125 changed files with 4918 additions and 9071 deletions

View File

@@ -1,5 +0,0 @@
{
"plugins": [
["@babel/plugin-proposal-decorators", { "decoratorsBeforeExport": true }]
]
}

View File

@@ -1,2 +0,0 @@
const _require = require("esm")(module);
module.exports = _require("./.eslintrc.esm.mjs").default;

View File

@@ -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;
}, []),
};

View File

@@ -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

View File

@@ -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

40
.github/workflows/ci.yml vendored Normal file
View File

@@ -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

View File

@@ -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

36
.github/workflows/setup-node-pnpm.yml vendored Normal file
View File

@@ -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

18
.gitignore vendored
View File

@@ -1,3 +1,4 @@
test-results
# Logs # Logs
logs logs
*.log *.log
@@ -7,13 +8,21 @@ yarn-error.log*
pnpm-debug.log* pnpm-debug.log*
lerna-debug.log* lerna-debug.log*
# Package manager files
package-lock.json
yarn.lock
.npm
.yarn
node_modules node_modules
dist dist
dist-ssr dist-ssr
*.local *.local
# Prettier cache
.cache
# Lint # Lint
.eslintcache
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*
@@ -30,4 +39,9 @@ src/generatedContent
archive archive
vite.config.js.timestamp* vite.config.js.timestamp*
playwright-report
test-results
public/_redirects

View File

@@ -1 +0,0 @@
pnpm lint-staged

1
.nvmrc
View File

@@ -1 +0,0 @@
22.14.0

156
.oxlintrc.json Normal file
View File

@@ -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"
}
}
]
}

View File

@@ -1,4 +1,11 @@
{ {
"trailingComma": "es5", "plugins": ["prettier-plugin-svelte"],
"plugins": ["prettier-plugin-svelte"] "overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
]
} }

View File

@@ -1,9 +1,7 @@
{ {
"recommendations": [ "recommendations": [
"esbenp.prettier-vscode", "esbenp.prettier-vscode",
"dbaeumer.vscode-eslint",
"svelte.svelte-vscode", "svelte.svelte-vscode",
"antfu.unocss" "bradlc.vscode-tailwindcss"
], ]
"unwantedRecommendations": []
} }

15
.vscode/launch.json vendored
View File

@@ -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}"
}
]
}

View File

@@ -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"
}
}

View File

@@ -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 1. Fork the project and create a new branch
2. Add the new framework SVG logo in `public/framework` 2. Add the new framework SVG logo in `public/framework`
3. Install the ESLint plugin associated to the framework 3. In `frameworks.ts`, add a new entry with SVG link
4. In `frameworks.mjs`, add a new entry with SVG link and ESLint configuration 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. If the framework needs a language syntax highlight, add it to the call to `getHighlighter`s `langs` argument in `build/lib/generateContent.js` 5. To make a playground link in `build/lib/playgroundUrlByFramework.ts`.
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 frameworks online REPL with those files loaded.
3. Register its export in `build/lib/playground/index.js`
## Improve website ## Improve website
@@ -23,4 +19,4 @@ pnpm i
pnpm run dev 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.

View File

@@ -433,7 +433,7 @@ How do we solve this ? Developers love having framework overview by examples. It
<details> <details>
<summary> <summary>
<img width="18" height="18" src="public/framework/ember.svg" /> <img width="18" height="18" src="public/framework/ember.svg" />
<b>Ember Polaris (preview)</b> <b>Ember Polaris</b>
<img src="https://us-central1-progress-markdown.cloudfunctions.net/progress/91" /> <img src="https://us-central1-progress-markdown.cloudfunctions.net/progress/91" />
</summary> </summary>
@@ -657,6 +657,44 @@ How do we solve this ? Developers love having framework overview by examples. It
- [x] Fetch data - [x] Fetch data
</details> </details>
<details>
<summary>
<img width="18" height="18" src="public/framework/ripple.svg" />
<b>Ripple</b>
<img src="https://us-central1-progress-markdown.cloudfunctions.net/progress/91" />
</summary>
- [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
</details>
<!-- progression end --> <!-- progression end -->
@@ -671,7 +709,7 @@ pnpm i
pnpm run dev 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 ### 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 1. Fork the project and create a new branch
2. Add the new framework SVG logo in `public/framework` 2. Add the new framework SVG logo in `public/framework`
3. Install the ESLint plugin associated to the framework 3. In `frameworks.ts`, add a new entry with SVG link
4. In `frameworks.mjs`, add a new entry with SVG link and ESLint configuration 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. If the framework needs a language syntax highlight, add it to the call to `getHighlighter`s `langs` argument in `build/lib/generateContent.js` 5. To make a playground link in `build/lib/playgroundUrlByFramework.ts`.
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 frameworks online REPL with those files loaded.
## 🧑‍💻 Contributors ## 🧑‍💻 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) [![Contributors](https://opencollective.com/component-party/contributors.svg?width=890&button=false)](https://github.com/matschik/component-party/graphs/contributors)
## ⚖️ License ## ⚖️ License

View File

@@ -1,20 +1,21 @@
import generateContent from "./lib/generateContent.js"; import generateContent from "./lib/generateContent";
import { createFsCache } from "micache"; import { createFsCache } from "micache";
import { hashElement } from "folder-hash"; 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"); const contentDirFsCache = await createFsCache("pluginGenerateFrameworkContent");
export default function pluginGenerateFrameworkContent() { export default function pluginGenerateFrameworkContent() {
const name = "generateFrameworkContent"; const name = "generateFrameworkContent";
function logInfo(...args) { function logInfo(...args: unknown[]) {
console.info(`[${name}]`, ...args); console.info(`[${name}]`, ...args);
} }
let buildIsRunning = false; let buildIsRunning = false;
async function build() { async function build(): Promise<void> {
if (buildIsRunning) { if (buildIsRunning) {
return; return;
} }
@@ -23,7 +24,7 @@ export default function pluginGenerateFrameworkContent() {
const contentDirHash = const contentDirHash =
(await hashElement("content")).hash + (await hashElement("content")).hash +
(await hashElement("build")).hash + (await hashElement("build")).hash +
(await hashElement("frameworks.mjs")).hash; (await hashElement("frameworks.ts")).hash;
const contentDirLastHash = await contentDirFsCache.get("contentDirHash"); const contentDirLastHash = await contentDirFsCache.get("contentDirHash");
if (contentDirHash !== contentDirLastHash) { if (contentDirHash !== contentDirLastHash) {
@@ -36,14 +37,14 @@ export default function pluginGenerateFrameworkContent() {
buildIsRunning = false; buildIsRunning = false;
} }
let fsContentWatcher; let fsContentWatcher: FSWatcher | undefined;
if (process.env.NODE_ENV === "development") { if (process.env.NODE_ENV === "development") {
fsContentWatcher = chokidar.watch(["content"]).on("change", build); fsContentWatcher = chokidar.watch(["content"]).on("change", build);
} }
return { return {
name, name,
async buildStart() { async buildStart(): Promise<void> {
try { try {
await build(); await build();
} catch (error) { } catch (error) {
@@ -51,8 +52,10 @@ export default function pluginGenerateFrameworkContent() {
throw error; throw error;
} }
}, },
async buildEnd() { async buildEnd(): Promise<void> {
fsContentWatcher && (await fsContentWatcher.close()); await fsContentWatcher?.close();
// Dispose of highlighter instances to prevent memory leaks
await disposeHighlighter();
}, },
}; };
} }

6
build/lib/angularHighlighter.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
export function mustUseAngularHighlighter(fileContent: string): boolean;
export function highlightAngularComponent(
fileContent: string,
fileExt: string,
): Promise<string>;

View File

@@ -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 ( return (
fileContent.includes("@angular/core") && fileContent.includes("template") fileContent.includes("@angular/core") && fileContent.includes("template")
); );
} }
export async function highlightAngularComponent(fileContent, fileExt) { export async function highlightAngularComponent(
fileContent: string,
fileExt: string,
): Promise<string> {
const templateCode = getAngularTemplateCode(fileContent); const templateCode = getAngularTemplateCode(fileContent);
let codeHighlighted = ""; let codeHighlighted = "";
@@ -15,26 +18,26 @@ export async function highlightAngularComponent(fileContent, fileExt) {
removeAngularTemplateContent(fileContent); removeAngularTemplateContent(fileContent);
const templateCodeHighlighted = await codeToHighlightCodeHtml( const templateCodeHighlighted = await codeToHighlightCodeHtml(
templateCode, templateCode,
"html" "html",
); );
const componentWithoutTemplateHighlighted = await codeToHighlightCodeHtml( const componentWithoutTemplateHighlighted = await codeToHighlightCodeHtml(
componentWithEmptyTemplate, componentWithEmptyTemplate,
fileExt fileExt,
); );
codeHighlighted = componentWithoutTemplateHighlighted.replace( codeHighlighted = componentWithoutTemplateHighlighted.replace(
"template", "template",
"template: `" + removeCodeWrapper(templateCodeHighlighted) + "`," "template: `" + removeCodeWrapper(templateCodeHighlighted) + "`,",
); );
} else { } else {
codeHighlighted = codeToHighlightCodeHtml(fileContent, fileExt); codeHighlighted = await codeToHighlightCodeHtml(fileContent, fileExt);
} }
return codeHighlighted; return codeHighlighted;
} }
function getAngularTemplateCode(fileContent) { function getAngularTemplateCode(fileContent: string): string {
// regex to grab what is inside angular component template inside backticks // regex to grab what is inside angular component template inside backticks
const regex = /template:\s*`([\s\S]*?)`/gm; const regex = /template:\s*`([\s\S]*?)`/gm;
@@ -46,17 +49,17 @@ function getAngularTemplateCode(fileContent) {
return ""; return "";
} }
function removeAngularTemplateContent(fileContent) { function removeAngularTemplateContent(fileContent: string): string {
const componentWithoutContentInsideTemplate = fileContent.replace( const componentWithoutContentInsideTemplate = fileContent.replace(
/template:\s*`([\s\S]*?)([^*])`,?/gm, /template:\s*`([\s\S]*?)([^*])`,?/gm,
"template" "template",
); );
return componentWithoutContentInsideTemplate; return componentWithoutContentInsideTemplate;
} }
function removeCodeWrapper(html) { function removeCodeWrapper(html: string): string {
const regexForWrapper = /<pre([\s\S]*?)><code>([\s\S]*?)<\/code><\/pre>/gm; const regexForWrapper = /<pre([\s\S]*?)><code>([\s\S]*?)<\/code><\/pre>/gm;
const code = regexForWrapper.exec(html); const code = regexForWrapper.exec(html);
return code[2]; return code ? code[2] : "";
} }

View File

@@ -1,35 +1,19 @@
export default { import { type ThemeRegistration } from "shiki";
export const componentPartyShikiTheme = {
name: "one-dark-pro-for-component-party", name: "one-dark-pro-for-component-party",
type: "dark", type: "dark",
semanticHighlighting: true, semanticHighlighting: true,
semanticTokenColors: { semanticTokenColors: {
enumMember: { enumMember: "#56b6c2",
foreground: "#56b6c2", "variable.constant": "#d19a66",
}, "variable.defaultLibrary": "#e5c07b",
"variable.constant": { "variable:dart": "#d19a66",
foreground: "#d19a66", "property:dart": "#d19a66",
}, "annotation:dart": "#d19a66",
"variable.defaultLibrary": { "parameter.label:dart": "#abb2bf",
foreground: "#e5c07b", macro: "#d19a66",
}, tomlArrayKey: "#e5c07b",
"variable:dart": {
foreground: "#d19a66",
},
"property:dart": {
foreground: "#d19a66",
},
"annotation:dart": {
foreground: "#d19a66",
},
"parameter.label:dart": {
foreground: "#abb2bf",
},
macro: {
foreground: "#d19a66",
},
tomlArrayKey: {
foreground: "#e5c07b",
},
}, },
tokenColors: [ tokenColors: [
{ {
@@ -2110,4 +2094,4 @@ export default {
"walkThrough.embeddedEditorBackground": "#2e3440", "walkThrough.embeddedEditorBackground": "#2e3440",
"welcomePage.buttonHoverBackground": "#404754", "welcomePage.buttonHoverBackground": "#404754",
}, },
}; } as const satisfies ThemeRegistration;

View File

@@ -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;
}

View File

@@ -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<boolean> {
try {
await fs.access(path);
return true;
} catch {
return false;
}
}
async function shouldRegenerateFiles(
contentPath: string,
generatedContentDirPath: string,
frameworkDirPath: string,
): Promise<boolean> {
// 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<void> {
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<string, FrameworkSnippet[]> = {};
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<any>;
};
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<void> {
const codeFormatted = await prettier.format(jsCode, { parser: "babel" });
await fs.writeFile(filepath, codeFormatted);
}
async function writeDtsFile(filepath: string, dtsCode: string): Promise<void> {
const codeFormatted = await prettier.format(dtsCode, {
parser: "typescript",
});
await fs.writeFile(filepath, codeFormatted);
}
async function generatePlaygroundURL(
frameworkId: string,
files: File[],
title: string,
): Promise<string | undefined> {
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<string, string>, 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<void> {
// 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`,
);
}

8
build/lib/highlighter.d.ts vendored Normal file
View File

@@ -0,0 +1,8 @@
export function codeToHighlightCodeHtml(
code: string,
language: string,
): Promise<string>;
export function markdownToHighlightedHtml(markdown: string): Promise<string>;
export function disposeHighlighter(): Promise<void>;

View File

@@ -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;
}

94
build/lib/highlighter.ts Normal file
View File

@@ -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<BundledLanguage, BundledTheme> | null =
null;
let md: MarkdownIt | null = null;
let highlighterPromise: Promise<
HighlighterGeneric<BundledLanguage, BundledTheme>
> | null = null;
async function getHighlighter(): Promise<
HighlighterGeneric<BundledLanguage, BundledTheme>
> {
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<MarkdownIt> {
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<string> {
const highlighterInstance = await getHighlighter();
const html = await highlighterInstance.codeToHtml(code, {
lang,
theme: componentPartyShikiTheme.name,
});
return html;
}
export async function markdownToHighlightedHtml(
markdownText: string,
): Promise<string> {
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<void> {
if (highlighter) {
await highlighter.dispose();
highlighter = null;
}
highlighterPromise = null;
md = null;
}

View File

@@ -1,16 +1,49 @@
import nodePath from "node:path"; import nodePath from "node:path";
import { compressToURL } from "@matschik/lz-string"; import LZString from "lz-string";
import { getParameters } from "codesandbox/lib/api/define.js";
export default { interface PlaygroundFunction {
vue3: (contentByFilename) => { (
contentByFilename: Record<string, string>,
title?: string,
): string | Promise<string | undefined>;
}
interface SveltePlaygroundOptions {
version: number;
contentByFilename: Record<string, string>;
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<string, PlaygroundFunction> = {
vue3: (contentByFilename: Record<string, string>) => {
const BASE_URL = "https://sfc.vuejs.org/#"; const BASE_URL = "https://sfc.vuejs.org/#";
function utoa(data) { function utoa(data: string): string {
return btoa(unescape(encodeURIComponent(data))); return btoa(unescape(encodeURIComponent(data)));
} }
function generateURLFromData(data) { function generateURLFromData(data: unknown): string {
return `${BASE_URL}${utoa(JSON.stringify(data))}`; return `${BASE_URL}${utoa(JSON.stringify(data))}`;
} }
const data = Object.assign({}, contentByFilename, { const data = Object.assign({}, contentByFilename, {
@@ -21,21 +54,27 @@ export default {
const url = generateURLFromData(data); const url = generateURLFromData(data);
return url; return url;
}, },
svelte4: async (contentByFilename, title) => { svelte4: async (
contentByFilename: Record<string, string>,
title?: string,
) => {
return generateSveltePlaygroundURL({ return generateSveltePlaygroundURL({
version: 4, version: 4,
contentByFilename, contentByFilename,
title, title,
}); });
}, },
svelte5: async (contentByFilename, title) => { svelte5: async (
contentByFilename: Record<string, string>,
title?: string,
) => {
return generateSveltePlaygroundURL({ return generateSveltePlaygroundURL({
version: 5, version: 5,
contentByFilename, contentByFilename,
title, title,
}); });
}, },
alpine: (contentByFilename) => { alpine: (contentByFilename: Record<string, string>) => {
const BASE_URL = const BASE_URL =
"https://codesandbox.io/api/v1/sandboxes/define?embed=1&parameters="; "https://codesandbox.io/api/v1/sandboxes/define?embed=1&parameters=";
const BASE_PREFIX = `<!DOCTYPE html>\n<html lang="en">\n <head>\n <meta charset="UTF-8" />\n <meta name="viewport" content="width=device-width, initial-scale=1.0" />\n <meta http-equiv="X-UA-Compatible" content="ie=edge" />\n <title>Alpine.js Playground</title>\n <script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>\n </head>\n <body>\n\n`; const BASE_PREFIX = `<!DOCTYPE html>\n<html lang="en">\n <head>\n <meta charset="UTF-8" />\n <meta name="viewport" content="width=device-width, initial-scale=1.0" />\n <meta http-equiv="X-UA-Compatible" content="ie=edge" />\n <title>Alpine.js Playground</title>\n <script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>\n </head>\n <body>\n\n`;
@@ -49,9 +88,7 @@ export default {
}, },
"index.html": { "index.html": {
content: content:
BASE_PREFIX + BASE_PREFIX + (contentByFilename["index.html"] || "") + BASE_SUFFIX,
(contentByFilename["index.html"]?.content || "") +
BASE_SUFFIX,
}, },
"sandbox.config.json": { "sandbox.config.json": {
content: '{\n "template": "static"\n}', content: '{\n "template": "static"\n}',
@@ -61,14 +98,14 @@ export default {
return `${BASE_URL}${parameters}`; return `${BASE_URL}${parameters}`;
}, },
solid: (contentByFilename) => { solid: (contentByFilename: Record<string, string>) => {
const BASE_URL = "https://playground.solidjs.com/#"; const BASE_URL = "https://playground.solidjs.com/#";
const SOURCE_PREFIX = `import { render } from "solid-js/web";\n`; const SOURCE_PREFIX = `import { render } from "solid-js/web";\n`;
const getSourceSuffix = (componentName) => const getSourceSuffix = (componentName: string) =>
`\n\nrender(() => <${componentName} />, document.getElementById("app"));\n`; `\n\nrender(() => <${componentName} />, document.getElementById("app"));\n`;
function generateURLFromData(data) { function generateURLFromData(data: unknown): string {
return `${BASE_URL}${compressToURL(JSON.stringify(data))}`; return `${BASE_URL}${LZString.compressToEncodedURIComponent(JSON.stringify(data))}`;
} }
const data = Object.keys(contentByFilename).map((filename) => { const data = Object.keys(contentByFilename).map((filename) => {
@@ -92,10 +129,10 @@ export default {
return generateURLFromData(data); return generateURLFromData(data);
}, },
marko: async (contentByFilename) => { marko: async (contentByFilename: Record<string, string>) => {
let firstFile = true; let firstFile = true;
const data = Object.entries(contentByFilename).map(([path, content]) => ({ const data = Object.entries(contentByFilename).map(([path, content]) => ({
path: firstFile ? (firstFile = false) || "index.marko" : path, path: firstFile ? ((firstFile = false), "index.marko") : path,
content, content,
})); }));
@@ -106,11 +143,13 @@ export default {
}, },
}; };
export default playgroundUrlByFramework;
async function generateSveltePlaygroundURL({ async function generateSveltePlaygroundURL({
version, version,
contentByFilename, contentByFilename,
title, title,
}) { }: SveltePlaygroundOptions): Promise<string | undefined> {
const BASE_URL = `https://svelte.dev/playground/untitled?version=${version}#`; const BASE_URL = `https://svelte.dev/playground/untitled?version=${version}#`;
const filenames = Object.keys(contentByFilename); const filenames = Object.keys(contentByFilename);
@@ -118,7 +157,7 @@ async function generateSveltePlaygroundURL({
return; return;
} }
const files = filenames.map((filename, index) => { const files: File[] = filenames.map((filename, index) => {
const contents = contentByFilename[filename]; const contents = contentByFilename[filename];
const name = index === 0 ? "App.svelte" : nodePath.parse(filename).base; const name = index === 0 ? "App.svelte" : nodePath.parse(filename).base;
return { 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)); const hash = await compress_and_encode_text(JSON.stringify(payload));
if (!hash) {
return;
}
const url = `${BASE_URL}${hash}`; 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 // 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<string> {
const reader = new Blob([input]) const reader = new Blob([input])
.stream() .stream()
.pipeThrough(new CompressionStream("gzip")) .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 // 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<string> {
const stream = new CompressionStream("gzip"); const stream = new CompressionStream("gzip");
const writer = stream.writable.getWriter(); const writer = stream.writable.getWriter();
writer.write(new TextEncoder().encode(value)); writer.write(new TextEncoder().encode(value));

View File

@@ -7,7 +7,7 @@
<img src="/popper.svg" alt="logo" class="size-7" /> <img src="/popper.svg" alt="logo" class="size-7" />
<span>Component party</span> <span>Component party</span>
</a> </a>
<p class="text-sm leading-6 text-gray-300"> <p class="text-gray-300">
Web component JavaScript frameworks overview by their syntax and Web component JavaScript frameworks overview by their syntax and
features features
</p> </p>

View File

@@ -7,7 +7,7 @@ export default function CssStyle() {
m( m(
"div", "div",
m("h1.title", "I am red"), m("h1.title", "I am red"),
m("button", { style: { fontSize: "10rem" } }, "I am a button") m("button", { style: { fontSize: "10rem" } }, "I am a button"),
), ),
}; };
} }

View File

@@ -12,7 +12,7 @@ export class ColorsList extends LitElement {
${repeat( ${repeat(
this.colors, this.colors,
(color) => color, (color) => color,
(color) => html`<li>${color}</li>` (color) => html`<li>${color}</li>`,
)} )}
</ul> </ul>
`; `;

View File

@@ -6,7 +6,7 @@ export default function Colors() {
view: () => view: () =>
m( m(
"ul", "ul",
colors.map((color, idx) => m("li", { key: idx }, color)) colors.map((color, idx) => m("li", { key: idx }, color)),
), ),
}; };
} }

View File

@@ -10,10 +10,7 @@ export default {
<template> <template>
<ul> <ul>
<li <li v-for="color in colors" :key="color">
v-for="color in colors"
:key="color"
>
{{ color }} {{ color }}
</li> </li>
</ul> </ul>

View File

@@ -4,10 +4,7 @@ const colors = ["red", "green", "blue"];
<template> <template>
<ul> <ul>
<li <li v-for="color in colors" :key="color">
v-for="color in colors"
:key="color"
>
{{ color }} {{ color }}
</li> </li>
</ul> </ul>

View File

@@ -8,7 +8,7 @@ export default function Counter() {
m( m(
"div", "div",
m("p", `Counter: ${count}`), m("p", `Counter: ${count}`),
m("button", { onclick: incrementCount }, "+1") m("button", { onclick: incrementCount }, "+1"),
), ),
}; };
} }

View File

@@ -1,4 +1,9 @@
import { afterNextRender, Component, ElementRef, viewChild } from "@angular/core"; import {
afterNextRender,
Component,
ElementRef,
viewChild,
} from "@angular/core";
@Component({ @Component({
selector: "app-input-focused", selector: "app-input-focused",

View File

@@ -7,5 +7,5 @@ export default {
</script> </script>
<template> <template>
<input ref="inputElement"> <input ref="inputElement" />
</template> </template>

View File

@@ -9,5 +9,5 @@ onMounted(() => {
</script> </script>
<template> <template>
<input ref="inputElement"> <input ref="inputElement" />
</template> </template>

View File

@@ -24,7 +24,7 @@ export default function TrafficLight() {
"div", "div",
m("button", { onclick: nextLight }, "Next light"), m("button", { onclick: nextLight }, "Next light"),
m("p", `Light is: ${currentLight()}`), m("p", `Light is: ${currentLight()}`),
m("p", "You must ", m("span", instructions())) m("p", "You must ", m("span", instructions())),
), ),
}; };
} }

View File

@@ -9,7 +9,7 @@ export class TimeComponent implements OnDestroy {
timer = setInterval( timer = setInterval(
() => this.time.set(new Date().toLocaleTimeString()), () => this.time.set(new Date().toLocaleTimeString()),
1000 1000,
); );
ngOnDestroy() { ngOnDestroy() {

View File

@@ -7,6 +7,6 @@ export const UserProfile = () => ({
m("p", `My name is ${name}!`), m("p", `My name is ${name}!`),
m("p", `My age is ${age}!`), m("p", `My age is ${age}!`),
m("p", `My favourite colors are ${favouriteColors.join(", ")}!`), m("p", `My favourite colors are ${favouriteColors.join(", ")}!`),
m("p", `I am ${isAvailable ? "available" : "not available"}`) m("p", `I am ${isAvailable ? "available" : "not available"}`),
), ),
}); });

View File

@@ -3,7 +3,7 @@ import { mergeProps } from "solid-js";
export default function UserProfile(props) { export default function UserProfile(props) {
const merged = mergeProps( const merged = mergeProps(
{ name: "", age: null, favouriteColors: [], isAvailable: false }, { name: "", age: null, favouriteColors: [], isAvailable: false },
props props,
); );
return ( return (

View File

@@ -4,6 +4,6 @@ export const AnswerButton = ({ attrs: { onYes, onNo } }) => ({
m( m(
"div", "div",
m("button", { onclick: onYes }, "YES"), m("button", { onclick: onYes }, "YES"),
m("button", { onclick: onNo }, "NO") m("button", { onclick: onNo }, "NO"),
), ),
}); });

View File

@@ -12,7 +12,7 @@ export default function App() {
"", "",
m("p", "Are you happy?"), m("p", "Are you happy?"),
m("p", { style: { fontSize: 50 } }, isHappy ? "😀" : "😥"), m("p", { style: { fontSize: 50 } }, isHappy ? "😀" : "😥"),
m(AnswerButton, { onYes: onAnswerYes, onNo: onAnswerNo }) m(AnswerButton, { onYes: onAnswerYes, onNo: onAnswerNo }),
), ),
}; };
} }

View File

@@ -23,10 +23,7 @@ export default {
<template> <template>
<div> <div>
<p>Are you happy?</p> <p>Are you happy?</p>
<AnswerButton <AnswerButton @yes="onAnswerYes" @no="onAnswerNo" />
@yes="onAnswerYes"
@no="onAnswerNo"
/>
<p style="font-size: 50px"> <p style="font-size: 50px">
{{ isHappy ? "😀" : "😥" }} {{ isHappy ? "😀" : "😥" }}
</p> </p>

View File

@@ -15,10 +15,7 @@ function onAnswerYes() {
<template> <template>
<p>Are you happy?</p> <p>Are you happy?</p>
<AnswerButton <AnswerButton @yes="onAnswerYes" @no="onAnswerNo" />
@yes="onAnswerYes"
@no="onAnswerNo"
/>
<p style="font-size: 50px"> <p style="font-size: 50px">
{{ isHappy ? "😀" : "😥" }} {{ isHappy ? "😀" : "😥" }}
</p> </p>

View File

@@ -18,6 +18,6 @@ export const FunnyButton = () => ({
outline: "0", outline: "0",
}, },
}, },
m(children) m(children),
), ),
}); });

View File

@@ -17,6 +17,6 @@ export const FunnyButton = ({ children }) => ({
outline: "0", outline: "0",
}, },
}, },
children || m("span", "No content found") children || m("span", "No content found"),
), ),
}); });

View File

@@ -15,7 +15,7 @@ export default function App() {
m( m(
"", "",
m("h1", `Welcome Back, ${user.username}`), m("h1", `Welcome Back, ${user.username}`),
m(UserProfile, { user, updateUsername }) m(UserProfile, { user, updateUsername }),
), ),
}; };
} }

View File

@@ -15,8 +15,8 @@ export default function UserProfile() {
m( m(
"button", "button",
{ onclick: () => updateUsername("Jane") }, { onclick: () => updateUsername("Jane") },
"Update username to Jane" "Update username to Jane",
) ),
), ),
}; };
} }

View File

@@ -11,6 +11,6 @@ export default {
<template> <template>
<div> <div>
<p>{{ text }}</p> <p>{{ text }}</p>
<input v-model="text"> <input v-model="text" />
</div> </div>
</template> </template>

View File

@@ -5,5 +5,5 @@ const text = ref("Hello World");
<template> <template>
<p>{{ text }}</p> <p>{{ text }}</p>
<input v-model="text"> <input v-model="text" />
</template> </template>

View File

@@ -15,7 +15,7 @@ export default function IsAvailable() {
checked: isAvailable, checked: isAvailable,
onchange: onUpdate, onchange: onUpdate,
}), }),
m("label", { for: "is-available" }, "Is available") m("label", { for: "is-available" }, "Is available"),
), ),
}; };
} }

View File

@@ -10,11 +10,7 @@ export default {
<template> <template>
<div> <div>
<input <input id="is-available" v-model="isAvailable" type="checkbox" />
id="is-available"
v-model="isAvailable"
type="checkbox"
>
<label for="is-available">Is available</label> <label for="is-available">Is available</label>
</div> </div>
</template> </template>

View File

@@ -5,10 +5,6 @@ const isAvailable = ref(true);
</script> </script>
<template> <template>
<input <input id="is-available" v-model="isAvailable" type="checkbox" />
id="is-available"
v-model="isAvailable"
type="checkbox"
>
<label for="is-available">Is available</label> <label for="is-available">Is available</label>
</template> </template>

View File

@@ -20,9 +20,9 @@ export default function PickPill() {
value: pill, value: pill,
onchange: handleChange, onchange: handleChange,
}), }),
m("label", { for: pill }, pill) m("label", { for: pill }, pill),
) ),
) ),
), ),
}; };
} }

View File

@@ -12,20 +12,10 @@ export default {
<div> <div>
<div>Picked: {{ picked }}</div> <div>Picked: {{ picked }}</div>
<input <input id="blue-pill" v-model="picked" type="radio" value="blue" />
id="blue-pill"
v-model="picked"
type="radio"
value="blue"
>
<label for="blue-pill">Blue pill</label> <label for="blue-pill">Blue pill</label>
<input <input id="red-pill" v-model="picked" type="radio" value="red" />
id="red-pill"
v-model="picked"
type="radio"
value="red"
>
<label for="red-pill">Red pill</label> <label for="red-pill">Red pill</label>
</div> </div>
</template> </template>

View File

@@ -7,19 +7,9 @@ const picked = ref("red");
<template> <template>
<div>Picked: {{ picked }}</div> <div>Picked: {{ picked }}</div>
<input <input id="blue-pill" v-model="picked" type="radio" value="blue" />
id="blue-pill"
v-model="picked"
type="radio"
value="blue"
>
<label for="blue-pill">Blue pill</label> <label for="blue-pill">Blue pill</label>
<input <input id="red-pill" v-model="picked" type="radio" value="red" />
id="red-pill"
v-model="picked"
type="radio"
value="red"
>
<label for="red-pill">Red pill</label> <label for="red-pill">Red pill</label>
</template> </template>

View File

@@ -6,7 +6,7 @@ import { FormsModule } from "@angular/forms";
selector: "app-color-select", selector: "app-color-select",
template: ` template: `
<select [(ngModel)]="selectedColorId"> <select [(ngModel)]="selectedColorId">
@for (let color of colors; track: color) { @for (let color of colors; track color) {
<option [value]="color.id" [disabled]="color.isDisabled"> <option [value]="color.id" [disabled]="color.isDisabled">
{{ color.text }} {{ color.text }}
</option> </option>

View File

@@ -31,7 +31,7 @@ export class ColorSelect extends LitElement {
?disabled=${color.isDisabled} ?disabled=${color.isDisabled}
> >
${color.text} ${color.text}
</option>` </option>`,
)} )}
</select> </select>
`; `;

View File

@@ -17,8 +17,8 @@ export default function ColorSelect() {
"select", "select",
{ value: selectedColorId, onchange: handleSelect }, { value: selectedColorId, onchange: handleSelect },
colors.map(({ id, text, isDisabled }) => colors.map(({ id, text, isDisabled }) =>
m("option", { key: id, id, disabled: isDisabled, value: id }, text) m("option", { key: id, id, disabled: isDisabled, value: id }, text),
) ),
), ),
}; };
} }

View File

@@ -5,5 +5,5 @@ import App from "./App";
ReactDOM.createRoot(document.getElementById("app")).render( ReactDOM.createRoot(document.getElementById("app")).render(
<React.StrictMode> <React.StrictMode>
<App /> <App />
</React.StrictMode> </React.StrictMode>,
); );

View File

@@ -1,6 +1,5 @@
import { UseFetchUsers } from "./UseFetchUsers"; import { UseFetchUsers } from "./UseFetchUsers";
export class App { export class App {
// eslint-disable-next-line no-unused-vars
constructor(private useFetchUsers: UseFetchUsers) {} constructor(private useFetchUsers: UseFetchUsers) {}
} }

View File

@@ -18,7 +18,7 @@ export class XApp extends LitElement {
<img src=${user.picture.thumbnail} alt="user" /> <img src=${user.picture.thumbnail} alt="user" />
<p>${user.name.first} ${user.name.last}</p> <p>${user.name.first} ${user.name.last}</p>
</li> </li>
` `,
)} )}
</ul> </ul>
`, `,

View File

@@ -9,7 +9,7 @@ export default function App() {
isLoading = true; isLoading = true;
try { try {
const { results } = await m.request( const { results } = await m.request(
"https://randomuser.me/api/?results=3" "https://randomuser.me/api/?results=3",
); );
users = results; users = results;
} catch (err) { } catch (err) {
@@ -28,8 +28,8 @@ export default function App() {
"li", "li",
{ key: user.login.uuid }, { key: user.login.uuid },
m("img", { src: user.picture.thumbnail, alt: "user" }), m("img", { src: user.picture.thumbnail, alt: "user" }),
m("p", `${user.name.first} ${user.name.last}`) m("p", `${user.name.first} ${user.name.last}`),
) ),
); );
}, },
}; };

View File

@@ -33,14 +33,8 @@ export default {
<p v-if="isLoading">Fetching users...</p> <p v-if="isLoading">Fetching users...</p>
<p v-else-if="error">An error ocurred while fetching users</p> <p v-else-if="error">An error ocurred while fetching users</p>
<ul v-else-if="users"> <ul v-else-if="users">
<li <li v-for="user in users" :key="user.login.uuid">
v-for="user in users" <img :src="user.picture.thumbnail" alt="user" />
:key="user.login.uuid"
>
<img
:src="user.picture.thumbnail"
alt="user"
>
<p> <p>
{{ user.name.first }} {{ user.name.first }}
{{ user.name.last }} {{ user.name.last }}

View File

@@ -8,14 +8,8 @@ const { isLoading, error, data: users } = useFetchUsers();
<p v-if="isLoading">Fetching users...</p> <p v-if="isLoading">Fetching users...</p>
<p v-else-if="error">An error ocurred while fetching users</p> <p v-else-if="error">An error ocurred while fetching users</p>
<ul v-else-if="users"> <ul v-else-if="users">
<li <li v-for="user in users" :key="user.login.uuid">
v-for="user in users" <img :src="user.picture.thumbnail" alt="user" />
:key="user.login.uuid"
>
<img
:src="user.picture.thumbnail"
alt="user"
>
<p> <p>
{{ user.name.first }} {{ user.name.first }}
{{ user.name.last }} {{ user.name.last }}

View File

@@ -1,16 +0,0 @@
import { defineConfig } from "cypress";
export default defineConfig({
e2e: {
baseUrl: "http://localhost:5173",
setupNodeEvents(on, config) {
// implement node event listeners here
},
},
component: {
devServer: {
framework: "svelte",
bundler: "vite",
},
},
});

View File

@@ -1,56 +0,0 @@
/// <reference types="cypress" />
describe("Initial frameworks", () => {
it("initial frameworks from scratch", () => {
cy.visit("/");
cy.get("[data-framework-id-selected-list]").should(
"have.attr",
"data-framework-id-selected-list",
"react,svelte5"
);
});
it("initial frameworks from local storage", () => {
cy.visit("/", {
onBeforeLoad(win) {
win.localStorage.setItem("framework_display", '["vue3","svelte4"]');
},
});
cy.get("[data-framework-id-selected-list]").should(
"have.attr",
"data-framework-id-selected-list",
"vue3,svelte4"
);
});
it("initial frameworks from query param 'f'", () => {
cy.visit("/?f=react,vue3");
cy.get("[data-framework-id-selected-list]").should(
"have.attr",
"data-framework-id-selected-list",
"react,vue3"
);
});
});
describe("pages", () => {
it("compare page", () => {
cy.visit("/compare/vue2-vs-vue3");
cy.get("[data-framework-id-selected-list]").should(
"have.attr",
"data-framework-id-selected-list",
"vue2,vue3"
);
});
});
// describe("sidenav links", () => {
// it("clicking sidenav links", () => {
// cy.visit("/?f=react");
// cy.get("main a[href='#reactivity']").click();
// cy.get("[data-framework-id-selected-list]").should(
// "have.attr",
// "data-framework-id-selected-list",
// "react"
// );
// });
// })

View File

@@ -1,5 +0,0 @@
{
"name": "Using fixtures to represent data",
"email": "hello@cypress.io",
"body": "Fixtures are a great way to mock data for responses to routes"
}

View File

@@ -1,37 +0,0 @@
/// <reference types="cypress" />
// ***********************************************
// This example commands.ts shows you how to
// create various custom commands and overwrite
// existing commands.
//
// For more comprehensive examples of custom
// commands please read more here:
// https://on.cypress.io/custom-commands
// ***********************************************
//
//
// -- This is a parent command --
// Cypress.Commands.add('login', (email, password) => { ... })
//
//
// -- This is a child command --
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
//
//
// -- This is a dual command --
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
//
//
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
//
// declare global {
// namespace Cypress {
// interface Chainable {
// login(email: string, password: string): Chainable<void>
// drag(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
// dismiss(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
// visit(originalFn: CommandOriginalFn, url: string, options: Partial<VisitOptions>): Chainable<Element>
// }
// }
// }

View File

@@ -1,20 +0,0 @@
// ***********************************************************
// This example support/e2e.ts is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// Import commands.js using ES2015 syntax:
import "./commands";
// Alternatively you can use CommonJS syntax:
// require('./commands')

45
eslint.config.ts Normal file
View File

@@ -0,0 +1,45 @@
import prettier from "eslint-config-prettier";
import js from "@eslint/js";
import { includeIgnoreFile } from "@eslint/compat";
import svelte from "eslint-plugin-svelte";
import globals from "globals";
import { fileURLToPath } from "node:url";
import ts from "typescript-eslint";
import svelteConfig from "./svelte.config";
import oxlint from "eslint-plugin-oxlint";
const gitignorePath = fileURLToPath(new URL("./.gitignore", import.meta.url));
export default [
{
ignores: ["content/**"],
},
includeIgnoreFile(gitignorePath),
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs.recommended,
prettier,
...svelte.configs["flat/prettier"],
{
languageOptions: {
globals: {
...globals.browser,
...globals.node,
},
},
},
{
files: ["**/*.svelte", "**/*.svelte.ts", "**/*.svelte.js"],
ignores: ["eslint.config.js", "svelte.config.js"],
languageOptions: {
parserOptions: {
projectService: true,
extraFileExtensions: [".svelte"],
parser: ts.parser,
svelteConfig,
},
},
},
...oxlint.buildFromOxlintConfigFile("./.oxlintrc.json"),
];

View File

@@ -1,128 +1,89 @@
function sortAllFilenames(files, filenamesSorted) { interface File {
fileName: string;
[key: string]: unknown;
}
function sortAllFilenames(files: File[], filenamesSorted: string[]): File[] {
return [ return [
...filenamesSorted.map((filename) => ...filenamesSorted.map((filename) =>
files.find(({ fileName }) => fileName === filename) files.find(({ fileName }) => fileName === filename),
), ),
...(files.filter(({ fileName }) => !filenamesSorted.includes(fileName)) || ...(files.filter(({ fileName }) => !filenamesSorted.includes(fileName)) ||
[]), []),
].filter(Boolean); ].filter(Boolean) as File[];
} }
const frameworks = [ export interface Framework {
id: string;
title: string;
frameworkName: string;
frameworkNameId: string;
isLatestStable: boolean;
img: string;
playgroundURL: string;
documentationURL: string;
filesSorter: (files: File[]) => File[];
repositoryLink: string;
mainPackageName: string;
releaseDate: string;
}
export const frameworks: Framework[] = [
{ {
id: "svelte5", id: "svelte5",
title: "Svelte 5", title: "Svelte 5",
frameworkName: "Svelte", frameworkName: "Svelte",
isCurrentVersion: false, frameworkNameId: "svelte",
isLatestStable: false,
img: "framework/svelte.svg", img: "framework/svelte.svg",
eslint: { playgroundURL: "https://svelte.dev/playground",
files: ["**/TODO-THIS-IS-DISABLED-svelte5/*.svelte"], documentationURL: "https://svelte.dev",
parser: "svelte-eslint-parser",
},
playgroundURL: "https://svelte-5-preview.vercel.app/",
documentationURL: "https://svelte-5-preview.vercel.app/docs",
filesSorter(files) { filesSorter(files) {
return sortAllFilenames(files, ["index.html", "app.js", "App.svelte"]); return sortAllFilenames(files, ["index.html", "app.js", "App.svelte"]);
}, },
repositoryLink: "https://github.com/sveltejs/svelte", repositoryLink: "https://github.com/sveltejs/svelte",
mainPackageName: "svelte", mainPackageName: "svelte",
releaseDate: "2024-10-01",
}, },
{ {
id: "react", id: "react",
title: "React", title: "React",
frameworkName: "React", frameworkName: "React",
isCurrentVersion: true, frameworkNameId: "react",
isLatestStable: true,
img: "framework/react.svg", img: "framework/react.svg",
eslint: {
files: ["**/react/*.jsx", "**/react/*.tsx"],
extends: [
"eslint:recommended",
"plugin:react/recommended",
"plugin:react/jsx-runtime",
],
settings: {
react: {
version: "detect",
},
},
},
playgroundURL: "https://codesandbox.io/s/mystifying-goldberg-6wx04b", playgroundURL: "https://codesandbox.io/s/mystifying-goldberg-6wx04b",
documentationURL: "https://reactjs.org/docs/getting-started.html", documentationURL: "https://reactjs.org",
filesSorter(files) { filesSorter(files) {
return sortAllFilenames(files, ["index.html", "main.jsx", "App.jsx"]); return sortAllFilenames(files, ["index.html", "main.jsx", "App.jsx"]);
}, },
repositoryLink: "https://github.com/facebook/react", repositoryLink: "https://github.com/facebook/react",
mainPackageName: "react", mainPackageName: "react",
releaseDate: "2013-05-29",
}, },
{ {
id: "vue3", id: "vue3",
title: "Vue 3", title: "Vue 3",
frameworkName: "Vue", frameworkName: "Vue",
isCurrentVersion: true, frameworkNameId: "vue",
isLatestStable: true,
img: "framework/vue.svg", img: "framework/vue.svg",
eslint: { playgroundURL: "https://play.vuejs.org/",
files: ["**/vue3/*.vue"],
env: {
"vue/setup-compiler-macros": true,
},
extends: ["eslint:recommended", "plugin:vue/vue3-recommended"],
rules: {
"vue/multi-word-component-names": "off",
"vue/singleline-html-element-content-newline": "off",
},
},
playgroundURL: "https://sfc.vuejs.org",
documentationURL: "https://vuejs.org/guide", documentationURL: "https://vuejs.org/guide",
filesSorter(files) { filesSorter(files) {
return sortAllFilenames(files, ["index.html", "main.js", "App.vue"]); return sortAllFilenames(files, ["index.html", "main.js", "App.vue"]);
}, },
repositoryLink: "https://github.com/vuejs/core", repositoryLink: "https://github.com/vuejs/core",
mainPackageName: "vue", mainPackageName: "vue",
releaseDate: "2020-09-18",
}, },
{ {
id: "angularRenaissance", id: "angularRenaissance",
title: "Angular Renaissance", title: "Angular Renaissance",
frameworkName: "Angular", frameworkName: "Angular",
isCurrentVersion: true, frameworkNameId: "angular",
isLatestStable: true,
img: "framework/angular-renaissance.svg", img: "framework/angular-renaissance.svg",
eslint: [
{
files: ["**/angular-renaissance/**"],
parserOptions: {
project: ["tsconfig.app.json"],
createDefaultProgram: true,
},
extends: [
"plugin:@angular-eslint/recommended",
// This is required if you use inline templates in Components
"plugin:@angular-eslint/template/process-inline-templates",
],
rules: {
/**
* Any TypeScript source code (NOT TEMPLATE) related rules you wish to use/reconfigure over and above the
* recommended set provided by the @angular-eslint project would go here.
*/
"@angular-eslint/directive-selector": [
"error",
{ type: "attribute", prefix: "app", style: "camelCase" },
],
"@angular-eslint/component-selector": [
"error",
{ type: "element", prefix: "app", style: "kebab-case" },
],
},
},
{
files: ["**/angular-renaissance/*.html"],
extends: ["plugin:@angular-eslint/template/recommended"],
rules: {
/**
* Any template/HTML related rules you wish to use/reconfigure over and above the
* recommended set provided by the @angular-eslint project would go here.
*/
},
},
],
playgroundURL: "https://codesandbox.io/s/angular", playgroundURL: "https://codesandbox.io/s/angular",
documentationURL: "https://angular.io/docs", documentationURL: "https://angular.io/docs",
filesSorter(files) { filesSorter(files) {
@@ -135,51 +96,15 @@ const frameworks = [
}, },
repositoryLink: "https://github.com/angular/angular", repositoryLink: "https://github.com/angular/angular",
mainPackageName: "@angular/core", mainPackageName: "@angular/core",
releaseDate: "2024-11-01",
}, },
{ {
id: "angular", id: "angular",
title: "Angular", title: "Angular",
frameworkName: "Angular", frameworkName: "Angular",
isCurrentVersion: false, frameworkNameId: "angular",
isLatestStable: false,
img: "framework/angular.svg", img: "framework/angular.svg",
eslint: [
{
files: ["**/angular/**"],
parserOptions: {
project: ["tsconfig.app.json"],
createDefaultProgram: true,
},
extends: [
"plugin:@angular-eslint/recommended",
// This is required if you use inline templates in Components
"plugin:@angular-eslint/template/process-inline-templates",
],
rules: {
/**
* Any TypeScript source code (NOT TEMPLATE) related rules you wish to use/reconfigure over and above the
* recommended set provided by the @angular-eslint project would go here.
*/
"@angular-eslint/directive-selector": [
"error",
{ type: "attribute", prefix: "app", style: "camelCase" },
],
"@angular-eslint/component-selector": [
"error",
{ type: "element", prefix: "app", style: "kebab-case" },
],
},
},
{
files: ["**/angular/*.html"],
extends: ["plugin:@angular-eslint/template/recommended"],
rules: {
/**
* Any template/HTML related rules you wish to use/reconfigure over and above the
* recommended set provided by the @angular-eslint project would go here.
*/
},
},
],
playgroundURL: "https://codesandbox.io/s/angular", playgroundURL: "https://codesandbox.io/s/angular",
documentationURL: "https://angular.io/docs", documentationURL: "https://angular.io/docs",
filesSorter(files) { filesSorter(files) {
@@ -192,19 +117,15 @@ const frameworks = [
}, },
repositoryLink: "https://github.com/angular/angular", repositoryLink: "https://github.com/angular/angular",
mainPackageName: "@angular/core", mainPackageName: "@angular/core",
releaseDate: "2010-10-20",
}, },
{ {
id: "lit", id: "lit",
title: "Lit", title: "Lit",
frameworkName: "Lit", frameworkName: "Lit",
isCurrentVersion: true, frameworkNameId: "lit",
isLatestStable: true,
img: "framework/lit.svg", img: "framework/lit.svg",
eslint: {
files: ["**/lit/**"],
plugins: ["lit"],
parser: "@babel/eslint-parser",
extends: ["plugin:lit/recommended"],
},
playgroundURL: "https://lit.dev/playground", playgroundURL: "https://lit.dev/playground",
documentationURL: "https://lit.dev", documentationURL: "https://lit.dev",
filesSorter(files) { filesSorter(files) {
@@ -212,19 +133,15 @@ const frameworks = [
}, },
repositoryLink: "https://github.com/lit/lit", repositoryLink: "https://github.com/lit/lit",
mainPackageName: "lit", mainPackageName: "lit",
releaseDate: "2021-05-27",
}, },
{ {
id: "emberOctane", id: "emberOctane",
title: "Ember Octane", title: "Ember Octane",
frameworkName: "Ember", frameworkName: "Ember",
isCurrentVersion: true, frameworkNameId: "ember",
isLatestStable: true,
img: "framework/ember.svg", img: "framework/ember.svg",
eslint: {
files: ["**/emberOctane/**"],
plugins: ["ember"],
parser: "@babel/eslint-parser",
extends: ["plugin:ember/recommended"],
},
playgroundURL: "https://ember-twiddle.com", playgroundURL: "https://ember-twiddle.com",
documentationURL: "https://emberjs.com", documentationURL: "https://emberjs.com",
filesSorter(files) { filesSorter(files) {
@@ -232,18 +149,15 @@ const frameworks = [
}, },
repositoryLink: "https://github.com/emberjs/ember.js", repositoryLink: "https://github.com/emberjs/ember.js",
mainPackageName: "ember-source", mainPackageName: "ember-source",
releaseDate: "2019-12-01",
}, },
{ {
id: "solid", id: "solid",
title: "Solid.js", title: "Solid.js",
frameworkName: "Solid", frameworkName: "Solid",
isCurrentVersion: true, frameworkNameId: "solid",
isLatestStable: true,
img: "framework/solid.svg", img: "framework/solid.svg",
eslint: {
files: ["**/solid/*.jsx"],
plugins: ["solid"],
extends: ["eslint:recommended", "plugin:solid/recommended"],
},
playgroundURL: "https://playground.solidjs.com/", playgroundURL: "https://playground.solidjs.com/",
documentationURL: "https://www.solidjs.com/", documentationURL: "https://www.solidjs.com/",
filesSorter(files) { filesSorter(files) {
@@ -251,17 +165,15 @@ const frameworks = [
}, },
repositoryLink: "https://github.com/solidjs/solid", repositoryLink: "https://github.com/solidjs/solid",
mainPackageName: "solid-js", mainPackageName: "solid-js",
releaseDate: "2021-06-28",
}, },
{ {
id: "svelte4", id: "svelte4",
title: "Svelte 4", title: "Svelte 4",
frameworkName: "Svelte", frameworkName: "Svelte",
isCurrentVersion: true, frameworkNameId: "svelte",
isLatestStable: true,
img: "framework/svelte.svg", img: "framework/svelte.svg",
eslint: {
files: ["**/svelte4/*.svelte"],
parser: "svelte-eslint-parser",
},
playgroundURL: "https://svelte.dev/repl", playgroundURL: "https://svelte.dev/repl",
documentationURL: "https://svelte.dev/", documentationURL: "https://svelte.dev/",
filesSorter(files) { filesSorter(files) {
@@ -269,21 +181,15 @@ const frameworks = [
}, },
repositoryLink: "https://github.com/sveltejs/svelte", repositoryLink: "https://github.com/sveltejs/svelte",
mainPackageName: "svelte", mainPackageName: "svelte",
releaseDate: "2023-06-01",
}, },
{ {
id: "vue2", id: "vue2",
title: "Vue 2", title: "Vue 2",
frameworkName: "Vue", frameworkName: "Vue",
isCurrentVersion: false, frameworkNameId: "vue",
isLatestStable: false,
img: "framework/vue.svg", img: "framework/vue.svg",
eslint: {
files: ["**/vue2/*.vue"],
extends: ["eslint:recommended", "plugin:vue/recommended"],
rules: {
"vue/multi-word-component-names": "off",
"vue/singleline-html-element-content-newline": "off",
},
},
playgroundURL: "", playgroundURL: "",
documentationURL: "https://v2.vuejs.org", documentationURL: "https://v2.vuejs.org",
filesSorter(files) { filesSorter(files) {
@@ -291,17 +197,15 @@ const frameworks = [
}, },
repositoryLink: "https://github.com/vuejs/vue", repositoryLink: "https://github.com/vuejs/vue",
mainPackageName: "vue@^2", mainPackageName: "vue@^2",
releaseDate: "2016-09-30",
}, },
{ {
id: "alpine", id: "alpine",
title: "Alpine", title: "Alpine",
frameworkName: "Alpine", frameworkName: "Alpine",
isCurrentVersion: true, frameworkNameId: "alpine",
isLatestStable: true,
img: "framework/alpine.svg", img: "framework/alpine.svg",
eslint: {
files: ["**/alpine/**"],
extends: ["eslint:recommended"],
},
playgroundURL: "https://codesandbox.io/s/7br3q8", playgroundURL: "https://codesandbox.io/s/7br3q8",
documentationURL: "https://alpinejs.dev/start-here", documentationURL: "https://alpinejs.dev/start-here",
filesSorter(files) { filesSorter(files) {
@@ -309,23 +213,15 @@ const frameworks = [
}, },
repositoryLink: "https://github.com/alpinejs/alpine", repositoryLink: "https://github.com/alpinejs/alpine",
mainPackageName: "alpinejs", mainPackageName: "alpinejs",
releaseDate: "2019-11-06",
}, },
{ {
id: "emberPolaris", id: "emberPolaris",
title: "Ember Polaris", title: "Ember Polaris",
frameworkName: "Ember", frameworkName: "Ember",
isCurrentVersion: false, frameworkNameId: "ember",
isLatestStable: false,
img: "framework/ember.svg", img: "framework/ember.svg",
eslint: {
files: ["**/emberPolaris/**"],
plugins: ["ember"],
parser: "ember-eslint-parser",
extends: [
"eslint:recommended",
"plugin:ember/recommended",
"plugin:ember/recommended-gjs",
],
},
playgroundURL: "http://new.emberjs.com", playgroundURL: "http://new.emberjs.com",
documentationURL: "https://emberjs.com", documentationURL: "https://emberjs.com",
filesSorter(files) { filesSorter(files) {
@@ -333,22 +229,15 @@ const frameworks = [
}, },
repositoryLink: "https://github.com/emberjs/ember.js", repositoryLink: "https://github.com/emberjs/ember.js",
mainPackageName: "ember-source", mainPackageName: "ember-source",
releaseDate: "2024-12-01",
}, },
{ {
id: "mithril", id: "mithril",
title: "Mithril", title: "Mithril",
frameworkName: "Mithril", frameworkName: "Mithril",
isCurrentVersion: true, frameworkNameId: "mithril",
isLatestStable: true,
img: "framework/mithril.svg", img: "framework/mithril.svg",
eslint: {
env: {
browser: true,
es2021: true,
node: true,
},
files: ["**/mithril/**"],
extends: ["eslint:recommended"],
},
playgroundURL: "https://codesandbox.io/s/q99qzov66", playgroundURL: "https://codesandbox.io/s/q99qzov66",
documentationURL: "https://mithril.js.org/", documentationURL: "https://mithril.js.org/",
filesSorter(files) { filesSorter(files) {
@@ -356,28 +245,15 @@ const frameworks = [
}, },
repositoryLink: "https://github.com/MithrilJS/mithril.js", repositoryLink: "https://github.com/MithrilJS/mithril.js",
mainPackageName: "mithril", mainPackageName: "mithril",
releaseDate: "2014-03-07",
}, },
{ {
id: "aurelia2", id: "aurelia2",
title: "Aurelia 2", title: "Aurelia 2",
frameworkName: "Aurelia", frameworkName: "Aurelia",
isCurrentVersion: true, frameworkNameId: "aurelia",
isLatestStable: true,
img: "framework/aurelia.svg", img: "framework/aurelia.svg",
eslint: {
env: {
browser: true,
es2021: true,
node: true,
},
parser: "@typescript-eslint/parser",
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
files: ["**/aurelia2/**"],
extends: ["eslint:recommended"],
},
playgroundURL: playgroundURL:
"https://stackblitz.com/edit/au2-conventions?file=src%2Fmy-app.html", "https://stackblitz.com/edit/au2-conventions?file=src%2Fmy-app.html",
documentationURL: "http://docs.aurelia.io", documentationURL: "http://docs.aurelia.io",
@@ -391,31 +267,15 @@ const frameworks = [
}, },
repositoryLink: "https://github.com/aurelia/aurelia", repositoryLink: "https://github.com/aurelia/aurelia",
mainPackageName: "aurelia", mainPackageName: "aurelia",
releaseDate: "2021-01-19",
}, },
{ {
id: "qwik", id: "qwik",
title: "Qwik", title: "Qwik",
frameworkName: "Qwik", frameworkName: "Qwik",
isCurrentVersion: true, frameworkNameId: "qwik",
isLatestStable: true,
img: "framework/qwik.svg", img: "framework/qwik.svg",
eslint: {
env: {
browser: true,
es2021: true,
node: true,
},
parser: "@typescript-eslint/parser",
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
files: ["**/qwik/**"],
extends: ["eslint:recommended", "plugin:qwik/recommended"],
rules: {
"qwik/valid-lexical-scope": "off",
},
},
playgroundURL: "https://qwik.builder.io/playground", playgroundURL: "https://qwik.builder.io/playground",
documentationURL: "https://qwik.builder.io/docs/overview", documentationURL: "https://qwik.builder.io/docs/overview",
filesSorter(files) { filesSorter(files) {
@@ -423,16 +283,15 @@ const frameworks = [
}, },
repositoryLink: "https://github.com/BuilderIO/qwik", repositoryLink: "https://github.com/BuilderIO/qwik",
mainPackageName: "@builder.io/qwik", mainPackageName: "@builder.io/qwik",
releaseDate: "2022-09-23",
}, },
{ {
id: "marko", id: "marko",
title: "Marko", title: "Marko",
frameworkName: "Marko", frameworkName: "Marko",
isCurrentVersion: true, frameworkNameId: "marko",
isLatestStable: true,
img: "framework/marko.svg", img: "framework/marko.svg",
eslint: {
files: ["!**"], // Markos linter/prettyprinter doesnt use eslint
},
playgroundURL: "https://markojs.com/playground/", playgroundURL: "https://markojs.com/playground/",
documentationURL: "https://markojs.com/docs/getting-started/", documentationURL: "https://markojs.com/docs/getting-started/",
filesSorter(files) { filesSorter(files) {
@@ -440,28 +299,15 @@ const frameworks = [
}, },
repositoryLink: "https://github.com/marko-js/marko", repositoryLink: "https://github.com/marko-js/marko",
mainPackageName: "marko", mainPackageName: "marko",
releaseDate: "2014-04-09",
}, },
{ {
id: "aurelia1", id: "aurelia1",
title: "Aurelia 1", title: "Aurelia 1",
frameworkName: "Aurelia", frameworkName: "Aurelia",
isCurrentVersion: false, frameworkNameId: "aurelia",
isLatestStable: false,
img: "framework/aurelia.svg", img: "framework/aurelia.svg",
eslint: {
env: {
browser: true,
es2021: true,
node: true,
},
parser: "@typescript-eslint/parser",
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
files: ["**/aurelia1/**"],
extends: ["eslint:recommended"],
},
playgroundURL: "https://codesandbox.io/s/ppmy26opw7", playgroundURL: "https://codesandbox.io/s/ppmy26opw7",
documentationURL: "http://aurelia.io/docs/", documentationURL: "http://aurelia.io/docs/",
filesSorter(files) { filesSorter(files) {
@@ -474,16 +320,15 @@ const frameworks = [
}, },
repositoryLink: "https://github.com/aurelia/framework", repositoryLink: "https://github.com/aurelia/framework",
mainPackageName: "aurelia-framework", mainPackageName: "aurelia-framework",
releaseDate: "2016-01-26",
}, },
{ {
id: "ripple", id: "ripple",
title: "Ripple", title: "Ripple",
frameworkName: "Ripple", frameworkName: "Ripple",
isCurrentVersion: true, frameworkNameId: "ripple",
isLatestStable: true,
img: "framework/ripple.svg", img: "framework/ripple.svg",
eslint: {
files: ["!**"],
},
playgroundURL: "https://www.ripplejs.com/playground", playgroundURL: "https://www.ripplejs.com/playground",
documentationURL: "https://www.ripplejs.com/", documentationURL: "https://www.ripplejs.com/",
filesSorter(files) { filesSorter(files) {
@@ -491,15 +336,71 @@ const frameworks = [
}, },
repositoryLink: "https://github.com/trueadm/ripple", repositoryLink: "https://github.com/trueadm/ripple",
mainPackageName: "ripple", mainPackageName: "ripple",
releaseDate: "2023-01-01",
}, },
]; ];
export function matchFrameworkId(id) { export function matchFrameworkId(id: string): Framework | undefined {
return frameworks.find( // First try to find by exact ID
(framework) => framework.id === id let framework = frameworks.find((f) => f.id === id);
// ||(framework.isCurrentVersion &&
// framework.frameworkName.toLowerCase() === id) // If not found, try to find by framework name ID and return the latest stable version
if (!framework) {
const latestStable = getLatestStableFrameworkByFrameworkName(id);
if (latestStable) {
framework = latestStable;
}
}
return framework;
}
/**
* Get all frameworks that belong to a specific framework name
*/
export function getFrameworksByFrameworkName(
frameworkNameId: string,
): Framework[] {
return frameworks.filter(
(framework) => framework.frameworkNameId === frameworkNameId,
); );
} }
export default frameworks; /**
* Get the latest stable framework for a given framework name
*/
export function getLatestStableFrameworkByFrameworkName(
frameworkNameId: string,
): Framework | undefined {
return frameworks.find(
(framework) =>
framework.frameworkNameId === frameworkNameId && framework.isLatestStable,
);
}
/**
* Get all unique framework name IDs
*/
export function getFrameworkNameIds(): string[] {
return [...new Set(frameworks.map((framework) => framework.frameworkNameId))];
}
/**
* Get framework name information including all versions and latest stable
*/
export function getFrameworkNameInfo(frameworkNameId: string): {
frameworkNameId: string;
frameworks: Framework[];
latestStable: Framework | undefined;
allVersions: string[];
} {
const familyFrameworks = getFrameworksByFrameworkName(frameworkNameId);
const latestStable = getLatestStableFrameworkByFrameworkName(frameworkNameId);
return {
frameworkNameId,
frameworks: familyFrameworks,
latestStable,
allVersions: familyFrameworks.map((f) => f.id),
};
}

View File

@@ -1,9 +1,12 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta name="viewport" content="width=device-width" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title><%= it.title %></title> <title><%= it.title %></title>
<link rel="icon" href="/favicon.png" /> <link rel="icon" href="/favicon.png" />
<link rel="manifest" href="/manifest.json" />
<link rel="canonical" href="<%= it.url %>" />
<link <link
rel="preload" rel="preload"
href="/font/Mona-Sans.woff2" href="/font/Mona-Sans.woff2"
@@ -12,9 +15,34 @@
crossorigin="" crossorigin=""
/> />
<!-- DNS Prefetch for external resources -->
<link rel="dns-prefetch" href="//github.com" />
<link rel="dns-prefetch" href="//codesandbox.io" />
<link rel="dns-prefetch" href="//play.vuejs.org" />
<link rel="dns-prefetch" href="//svelte.dev" />
<link rel="dns-prefetch" href="//playground.solidjs.com" />
<link rel="dns-prefetch" href="//qwik.builder.io" />
<link rel="dns-prefetch" href="//lit.dev" />
<link rel="dns-prefetch" href="//ember-twiddle.com" />
<link rel="dns-prefetch" href="//markojs.com" />
<link rel="dns-prefetch" href="//stackblitz.com" />
<link rel="dns-prefetch" href="//www.ripplejs.com" />
<!-- Preconnect to critical external domains -->
<link rel="preconnect" href="https://github.com" crossorigin />
<link rel="preconnect" href="https://codesandbox.io" crossorigin />
<!-- Primary Meta Tags --> <!-- Primary Meta Tags -->
<meta name="title" content="<%= it.title %>" /> <meta name="title" content="<%= it.title %>" />
<meta name="description" content="<%= it.description %>" /> <meta name="description" content="<%= it.description %>" />
<meta name="keywords" content="<%= it.keywords %>" />
<meta name="author" content="Component Party" />
<meta
name="robots"
content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1"
/>
<meta name="googlebot" content="index, follow" />
<meta name="bingbot" content="index, follow" />
<!-- Open Graph / Facebook --> <!-- Open Graph / Facebook -->
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
@@ -22,6 +50,14 @@
<meta property="og:title" content="<%= it.title %>" /> <meta property="og:title" content="<%= it.title %>" />
<meta property="og:description" content="<%= it.description %>" /> <meta property="og:description" content="<%= it.description %>" />
<meta property="og:image" content="<%= it.image %>" /> <meta property="og:image" content="<%= it.image %>" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta
property="og:image:alt"
content="<%= it.title %> - JavaScript Framework Comparison"
/>
<meta property="og:site_name" content="Component Party" />
<meta property="og:locale" content="en_US" />
<!-- Twitter --> <!-- Twitter -->
<meta property="twitter:card" content="summary_large_image" /> <meta property="twitter:card" content="summary_large_image" />
@@ -29,6 +65,101 @@
<meta property="twitter:title" content="<%= it.title %>" /> <meta property="twitter:title" content="<%= it.title %>" />
<meta property="twitter:description" content="<%= it.description %>" /> <meta property="twitter:description" content="<%= it.description %>" />
<meta property="twitter:image" content="<%= it.image %>" /> <meta property="twitter:image" content="<%= it.image %>" />
<meta
property="twitter:image:alt"
content="<%= it.title %> - JavaScript Framework Comparison"
/>
<meta property="twitter:creator" content="@componentparty" />
<meta property="twitter:site" content="@componentparty" />
<!-- Additional SEO Meta Tags -->
<meta name="theme-color" content="#111827" />
<meta name="msapplication-TileColor" content="#111827" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta
name="apple-mobile-web-app-status-bar-style"
content="black-translucent"
/>
<meta name="apple-mobile-web-app-title" content="Component Party" />
<meta name="application-name" content="Component Party" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="format-detection" content="telephone=no" />
<!-- Structured Data (JSON-LD) -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebApplication",
"name": "Component Party",
"description": "<%= it.description %>",
"url": "<%= it.url %>",
"applicationCategory": "DeveloperApplication",
"operatingSystem": "Web Browser",
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "USD"
},
"author": {
"@type": "Organization",
"name": "Component Party",
"url": "https://component-party.dev"
},
"publisher": {
"@type": "Organization",
"name": "Component Party",
"url": "https://component-party.dev",
"logo": {
"@type": "ImageObject",
"url": "https://component-party.dev/popper.svg"
}
},
"keywords": "<%= it.keywords %>",
"inLanguage": "en-US",
"isAccessibleForFree": true,
"browserRequirements": "Requires JavaScript. Requires HTML5.",
"softwareVersion": "2.0.0",
"datePublished": "2024-01-01",
"dateModified": "2024-12-01",
"mainEntity": {
"@type": "ItemList",
"name": "JavaScript Frameworks Comparison",
"description": "A comprehensive comparison of popular JavaScript frameworks and libraries",
"numberOfItems": "<%= it.frameworkCount || '20+' %>",
"itemListElement": [
{
"@type": "SoftwareApplication",
"name": "React",
"description": "A JavaScript library for building user interfaces",
"applicationCategory": "WebApplication",
"operatingSystem": "Web Browser"
},
{
"@type": "SoftwareApplication",
"name": "Vue.js",
"description": "A progressive JavaScript framework for building user interfaces",
"applicationCategory": "WebApplication",
"operatingSystem": "Web Browser"
},
{
"@type": "SoftwareApplication",
"name": "Angular",
"description": "A platform and framework for building single-page client applications",
"applicationCategory": "WebApplication",
"operatingSystem": "Web Browser"
},
{
"@type": "SoftwareApplication",
"name": "Svelte",
"description": "A radical new approach to building user interfaces",
"applicationCategory": "WebApplication",
"operatingSystem": "Web Browser"
}
]
}
}
</script>
<style> <style>
@font-face { @font-face {
font-family: "Mona Sans"; font-family: "Mona Sans";
@@ -58,10 +189,5 @@
<div id="app" class="min-h-screen"></div> <div id="app" class="min-h-screen"></div>
<!--template:footer--> <!--template:footer-->
<script type="module" src="/src/main.js"></script> <script type="module" src="/src/main.js"></script>
<!-- 100% privacy-first analytics -->
<script
async
src="https://scripts.simpleanalyticscdn.com/latest.js"
></script>
</body> </body>
</html> </html>

View File

@@ -1,34 +0,0 @@
{
"compilerOptions": {
"moduleResolution": "Node",
"target": "ESNext",
"module": "ESNext",
/**
* svelte-preprocess cannot figure out whether you have
* a value or a type, so tell TypeScript to enforce using
* `import type` instead of `import` for Types.
*/
"importsNotUsedAsValues": "error",
"isolatedModules": true,
"resolveJsonModule": true,
/**
* To have warnings / errors of the Svelte compiler at the
* correct position, enable source maps by default.
*/
"sourceMap": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
/**
* Typecheck JS in `.svelte` and `.js` files by default.
* Disable this if you'd like to use dynamic types.
*/
"checkJs": true,
"experimentalDecorators": true
},
/**
* Use global.d.ts instead of compilerOptions.types
* to avoid limiting type declarations.
*/
"include": ["src/**/*.d.ts", "src/**/*.js", "src/**/*.svelte"]
}

10
lefthook.yml Normal file
View File

@@ -0,0 +1,10 @@
pre-commit:
commands:
format:
run: pnpm _format --write {staged_files}
glob: "*.{js,ts,svelte,html,md,css}"
stage_fixed: true
update-readme-progress:
run: node scripts/generateReadMeProgress.ts && git add README.md
glob: "content/**/*"
fail_text: "Failed to update README progress"

View File

@@ -3,98 +3,85 @@
"private": true, "private": true,
"version": "2.0.0", "version": "2.0.0",
"type": "module", "type": "module",
"packageManager": "pnpm@10.6.5", "packageManager": "pnpm@10.14.0",
"repository": "github:matschik/component-party.dev", "repository": "github:matschik/component-party.dev",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"lint": "eslint . --fix", "_format": "prettier --cache --cache-location .cache/prettier",
"lint:check": "eslint .", "format": "pnpm _format --write .",
"format": "prettier --cache --ignore-path .gitignore --plugin-search-dir=. --write .", "lint": "oxlint && eslint",
"format:check": "prettier --ignore-path .gitignore --plugin-search-dir=. . --check", "check": "pnpm format && svelte-check --tsconfig ./tsconfig.json && pnpm lint",
"build:content": "node scripts/generateContent.js", "check:ci": "svelte-check --tsconfig ./tsconfig.json && pnpm lint",
"build:progress": "node scripts/generateReadMeProgress.js", "check:watch": "svelte-check --tsconfig ./tsconfig.json --watch",
"prepare": "husky", "build:content": "node scripts/generateContent.ts --no-cache",
"cy:open-e2e": "cypress open --e2e --browser chrome", "build:progress": "node scripts/generateReadMeProgress.ts",
"cy:open-unit": "cypress open --component --browser chrome", "build:sitemap": "node scripts/generateSitemap.ts",
"cy:run-e2e": "cypress run --e2e", "prepare": "lefthook install",
"cy:e2e": "start-server-and-test dev http-get://localhost:5173 cy:open-e2e" "test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:headed": "playwright test --headed",
"test:e2e:debug": "playwright test --debug",
"test:e2e:report": "playwright show-report",
"test:e2e:install": "playwright install chromium",
"test": "pnpm run test:e2e",
"check:package-manager": "node scripts/check-package-manager.js"
}, },
"dependencies": { "dependencies": {
"@iconify-json/heroicons": "^1.2.2", "@iconify-json/heroicons": "^1.2.3",
"classnames": "^2.5.1", "just-throttle": "^4.2.0",
"radix3": "^1.1.2" "lz-string": "^1.5.0",
"runed": "^0.32.0",
"sv-router": "^0.10.0"
}, },
"devDependencies": { "devDependencies": {
"@angular-eslint/eslint-plugin": "^19.3.0", "@eslint/compat": "^1.3.2",
"@angular-eslint/eslint-plugin-template": "^19.3.0", "@eslint/js": "^9.36.0",
"@angular-eslint/template-parser": "^19.3.0", "eslint": "^9.36.0",
"@angular/core": "^19.2.7", "@iconify/json": "^2.2.386",
"@aurelia/router": "2.0.0-beta.23", "@iconify/tailwind4": "^1.0.6",
"@babel/core": "^7.26.10", "@playwright/test": "^1.55.0",
"@babel/eslint-parser": "^7.27.0", "@shikijs/markdown-it": "^3.13.0",
"@babel/plugin-proposal-decorators": "^7.25.9", "@sveltejs/vite-plugin-svelte": "6.2.0",
"@builder.io/qwik": "^1.13.0", "@sveltejs/vite-plugin-svelte-inspector": "5.0.1",
"@lit/context": "^1.1.5", "@tailwindcss/vite": "^4.1.13",
"@matschik/lz-string": "^0.0.2", "@types/folder-hash": "^4.0.4",
"@shikijs/markdown-it": "^3.3.0", "@types/html-minifier-terser": "^7.0.2",
"@sveltejs/vite-plugin-svelte": "5.0.3", "@types/markdown-it": "^14.1.2",
"@sveltejs/vite-plugin-svelte-inspector": "4.0.1",
"@typescript-eslint/eslint-plugin": "^8.31.0",
"@typescript-eslint/parser": "^8.31.0",
"@unocss/preset-icons": "66.1.0-beta.12",
"@unocss/preset-typography": "66.1.0-beta.12",
"@unocss/preset-wind": "66.1.0-beta.12",
"@unocss/reset": "66.1.0-beta.12",
"aurelia": "2.0.0-beta.23",
"aurelia-framework": "^1.4.1",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"chokidar": "^4.0.3", "chokidar": "^4.0.3",
"codesandbox": "^2.2.3", "eslint-config-prettier": "^10.1.8",
"cypress": "^14.3.2", "eslint-plugin-oxlint": "^1.16.0",
"ember-eslint-parser": "^0.5.9", "eslint-plugin-svelte": "^3.12.4",
"eslint": "^9.25.1", "eta": "^4.0.1",
"eslint-plugin-ember": "^12.5.0",
"eslint-plugin-lit": "^2.1.1",
"eslint-plugin-prettier": "^5.2.6",
"eslint-plugin-qwik": "^1.13.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-solid": "^0.14.5",
"eslint-plugin-svelte": "^3.5.1",
"eslint-plugin-vue": "^10.0.0",
"esm": "^3.2.25",
"eta": "^3.5.0",
"folder-hash": "^4.1.1", "folder-hash": "^4.1.1",
"globals": "^16.4.0",
"html-minifier-terser": "^7.2.0", "html-minifier-terser": "^7.2.0",
"husky": "^9.1.7", "just-kebab-case": "^4.2.0",
"lint-staged": "^15.5.1", "lefthook": "^1.13.1",
"lodash.kebabcase": "^4.1.1",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
"micache": "^2.4.1", "micache": "^2.4.1",
"pkg-dir": "^8.0.0", "oxlint": "^1.16.0",
"prettier": "^3.5.3", "package-directory": "^8.1.0",
"prettier-plugin-svelte": "^3.3.3", "prettier": "^3.6.2",
"react": "^19.1.0", "prettier-plugin-svelte": "^3.4.0",
"react-dom": "^19.1.0", "shiki": "^3.13.0",
"shiki": "^3.3.0", "svelte": "5.39.3",
"solid-js": "^1.9.5", "svelte-check": "^4.3.1",
"start-server-and-test": "^2.0.11", "tailwindcss": "^4.1.13",
"svelte": "5.28.2",
"tslib": "^2.8.1", "tslib": "^2.8.1",
"typescript": "^5.8.3", "typescript": "^5.9.2",
"unocss": "66.1.0-beta.12", "typescript-eslint": "^8.44.0",
"vite": "^6.3.2", "vite": "^7.1.6",
"vite-plugin-html": "^3.2.2", "vite-plugin-html": "^3.2.2"
"vue": "^3.5.13"
},
"lint-staged": {
"*.{js,jsx,ts,tsx,svelte,vue,html,md,css,hbs}": "prettier --cache --write"
}, },
"pnpm": { "pnpm": {
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [
"cypress",
"esbuild" "esbuild"
] ]
},
"engines": {
"node": ">=22.18.0"
} }
} }

31
playwright.config.ts Normal file
View File

@@ -0,0 +1,31 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "test/e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [
["html", { open: "never" }],
["json", { outputFile: "test-results/results.json" }],
["junit", { outputFile: "test-results/results.xml" }],
],
use: {
baseURL: "http://localhost:4144",
trace: "on-first-retry",
screenshot: "only-on-failure",
video: "retain-on-failure",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
webServer: {
command: "pnpm run build && pnpm run preview --port 4144",
port: 4144,
reuseExistingServer: !process.env.CI,
},
});

8539
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

74
public/manifest.json Normal file
View File

@@ -0,0 +1,74 @@
{
"name": "Component Party - JavaScript Framework Comparison",
"short_name": "Component Party",
"description": "Compare JavaScript frameworks side-by-side: React, Vue, Angular, Svelte, Solid.js, and more. See syntax differences, features, and code examples for web development frameworks.",
"start_url": "/",
"display": "standalone",
"background_color": "#111827",
"theme_color": "#111827",
"orientation": "portrait-primary",
"scope": "/",
"lang": "en-US",
"categories": ["developer", "productivity", "utilities"],
"icons": [
{
"src": "/favicon.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/favicon.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"screenshots": [
{
"src": "/banner2.png",
"sizes": "1200x630",
"type": "image/png",
"form_factor": "wide",
"label": "Component Party Framework Comparison Interface"
}
],
"shortcuts": [
{
"name": "React vs Vue",
"short_name": "React vs Vue",
"description": "Compare React and Vue.js frameworks",
"url": "/?f=react,vue3",
"icons": [
{
"src": "/favicon.png",
"sizes": "192x192"
}
]
},
{
"name": "React vs Angular",
"short_name": "React vs Angular",
"description": "Compare React and Angular frameworks",
"url": "/?f=react,angularRenaissance",
"icons": [
{
"src": "/favicon.png",
"sizes": "192x192"
}
]
},
{
"name": "Vue vs Svelte",
"short_name": "Vue vs Svelte",
"description": "Compare Vue.js and Svelte frameworks",
"url": "/?f=vue3,svelte5",
"icons": [
{
"src": "/favicon.png",
"sizes": "192x192"
}
]
}
]
}

View File

@@ -2,3 +2,9 @@
User-agent: * User-agent: *
Allow: / Allow: /
# Sitemap
Sitemap: https://component-party.dev/sitemap.xml
# Crawl-delay for respectful crawling
Crawl-delay: 1

231
public/sitemap.xml Normal file
View File

@@ -0,0 +1,231 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://component-party.dev/</loc>
<lastmod>2025-09-21</lastmod>
<changefreq>weekly</changefreq>
<priority>1</priority>
</url>
<url>
<loc>https://component-party.dev/?f=svelte5</loc>
<lastmod>2025-09-21</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://component-party.dev/?f=react</loc>
<lastmod>2025-09-21</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://component-party.dev/?f=vue3</loc>
<lastmod>2025-09-21</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://component-party.dev/?f=angularRenaissance</loc>
<lastmod>2025-09-21</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://component-party.dev/?f=angular</loc>
<lastmod>2025-09-21</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://component-party.dev/?f=lit</loc>
<lastmod>2025-09-21</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://component-party.dev/?f=emberOctane</loc>
<lastmod>2025-09-21</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://component-party.dev/?f=solid</loc>
<lastmod>2025-09-21</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://component-party.dev/?f=svelte4</loc>
<lastmod>2025-09-21</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://component-party.dev/?f=vue2</loc>
<lastmod>2025-09-21</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://component-party.dev/?f=alpine</loc>
<lastmod>2025-09-21</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://component-party.dev/?f=emberPolaris</loc>
<lastmod>2025-09-21</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://component-party.dev/?f=mithril</loc>
<lastmod>2025-09-21</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://component-party.dev/?f=aurelia2</loc>
<lastmod>2025-09-21</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://component-party.dev/?f=qwik</loc>
<lastmod>2025-09-21</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://component-party.dev/?f=marko</loc>
<lastmod>2025-09-21</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://component-party.dev/?f=aurelia1</loc>
<lastmod>2025-09-21</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://component-party.dev/?f=ripple</loc>
<lastmod>2025-09-21</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://component-party.dev/?f=react,vue3</loc>
<lastmod>2025-09-21</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://component-party.dev/?f=react,angularRenaissance</loc>
<lastmod>2025-09-21</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://component-party.dev/?f=react,svelte5</loc>
<lastmod>2025-09-21</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://component-party.dev/?f=react,solid</loc>
<lastmod>2025-09-21</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://component-party.dev/?f=vue3,angularRenaissance</loc>
<lastmod>2025-09-21</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://component-party.dev/?f=vue3,svelte5</loc>
<lastmod>2025-09-21</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://component-party.dev/?f=vue3,solid</loc>
<lastmod>2025-09-21</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://component-party.dev/?f=angularRenaissance,svelte5</loc>
<lastmod>2025-09-21</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://component-party.dev/?f=angularRenaissance,solid</loc>
<lastmod>2025-09-21</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://component-party.dev/?f=svelte5,solid</loc>
<lastmod>2025-09-21</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://component-party.dev/?f=react,vue3,angularRenaissance</loc>
<lastmod>2025-09-21</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://component-party.dev/?f=react,svelte5,solid</loc>
<lastmod>2025-09-21</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://component-party.dev/?f=vue3,svelte5,solid</loc>
<lastmod>2025-09-21</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://component-party.dev/?f=react,vue3,svelte5</loc>
<lastmod>2025-09-21</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://component-party.dev/?f=svelte4,svelte5</loc>
<lastmod>2025-09-21</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://component-party.dev/?f=vue2,vue3</loc>
<lastmod>2025-09-21</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://component-party.dev/?f=angular,angularRenaissance</loc>
<lastmod>2025-09-21</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://component-party.dev/?f=aurelia1,aurelia2</loc>
<lastmod>2025-09-21</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://component-party.dev/?f=emberOctane,emberPolaris</loc>
<lastmod>2025-09-21</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
</urlset>

View File

@@ -1,3 +0,0 @@
import generateContent from "../build/lib/generateContent.js";
generateContent();

View File

@@ -0,0 +1,16 @@
#!/usr/bin/env node
import generateContent from "../build/lib/generateContent.ts";
const args = process.argv.slice(2);
const noCache = args.includes("--no-cache") || args.includes("--force");
console.log(`Generating content${noCache ? " (no cache)" : ""}...`);
try {
await generateContent({ noCache });
console.log("Content generation completed successfully!");
} catch (error) {
console.error("Error generating content:", error);
process.exit(1);
}

View File

@@ -1,11 +1,36 @@
import fs from "fs/promises"; import fs from "fs/promises";
import { packageDirectory } from "pkg-dir"; import { packageDirectory } from "package-directory";
import path from "node:path"; import path from "node:path";
import kebabCase from "lodash.kebabcase"; import { frameworks } from "../frameworks.ts";
import FRAMEWORKS from "../frameworks.mjs";
import prettier from "prettier"; import prettier from "prettier";
import kebabCase from "just-kebab-case";
async function main() { interface File {
path: string;
fileName: string;
ext: string;
}
interface FrameworkChild {
dirName: string;
path: string;
files: File[];
}
interface SubSection {
id: string;
path: string;
dirName: string;
title: string;
children: FrameworkChild[];
}
interface Section {
title: string;
children: SubSection[];
}
async function main(): Promise<void> {
const contentTree = await parseContentDir(); const contentTree = await parseContentDir();
const readmeContent = await fs.readFile("README.md", "utf8"); const readmeContent = await fs.readFile("README.md", "utf8");
@@ -14,12 +39,12 @@ async function main() {
const MARKER_START = "<!-- progression start -->"; const MARKER_START = "<!-- progression start -->";
const MARKER_END = "<!-- progression end -->"; const MARKER_END = "<!-- progression end -->";
const progressionContentRegex = new RegExp( const progressionContentRegex = new RegExp(
`${MARKER_START}([\\s\\S]*?)${MARKER_END}` `${MARKER_START}([\\s\\S]*?)${MARKER_END}`,
); );
const newReadmeContent = readmeContent.replace( const newReadmeContent = readmeContent.replace(
progressionContentRegex, progressionContentRegex,
`${MARKER_START}\n${progressionContent}\n${MARKER_END}` `${MARKER_START}\n${progressionContent}\n${MARKER_END}`,
); );
await fs.writeFile("README.md", newReadmeContent); await fs.writeFile("README.md", newReadmeContent);
@@ -27,35 +52,38 @@ async function main() {
main().catch(console.error); main().catch(console.error);
async function parseContentDir() { async function parseContentDir(): Promise<Section[]> {
const rootDir = await packageDirectory(); const rootDir = await packageDirectory();
if (!rootDir) {
throw new Error("Could not find package directory");
}
const contentPath = path.join(rootDir, "content"); const contentPath = path.join(rootDir, "content");
const rootDirs = await fs.readdir(contentPath); const rootDirs = await fs.readdir(contentPath);
function dirNameToTitle(dirName) { function dirNameToTitle(dirName: string): string {
return capitalize(dirName.split("-").slice(1).join(" ")); return capitalize(dirName.split("-").slice(1).join(" "));
} }
function capitalize(str) { function capitalize(str: string): string {
return str.charAt(0).toUpperCase() + str.slice(1); return str.charAt(0).toUpperCase() + str.slice(1);
} }
const sections = []; const sections: Section[] = [];
for (const rootDirName of rootDirs) { for (const rootDirName of rootDirs) {
const rootSectionPath = path.join(contentPath, rootDirName); const rootSectionPath = path.join(contentPath, rootDirName);
const subDirs = await fs.readdir(rootSectionPath).catch(() => []); const subDirs = await fs.readdir(rootSectionPath).catch(() => []);
const children = []; const children: SubSection[] = [];
for (const subDir of subDirs) { for (const subDir of subDirs) {
const subDirPath = path.join(rootSectionPath, subDir); const subDirPath = path.join(rootSectionPath, subDir);
const frameworks = await fs.readdir(subDirPath).catch(() => []); const frameworks = await fs.readdir(subDirPath).catch(() => []);
const frameworkChildren = []; const frameworkChildren: FrameworkChild[] = [];
for (const fw of frameworks) { for (const fw of frameworks) {
const fwPath = path.join(subDirPath, fw); const fwPath = path.join(subDirPath, fw);
const fileNames = await fs.readdir(fwPath).catch(() => []); const fileNames = await fs.readdir(fwPath).catch(() => []);
const files = fileNames.map((fileName) => ({ const files: File[] = fileNames.map((fileName) => ({
path: path.join(fwPath, fileName), path: path.join(fwPath, fileName),
fileName, fileName,
ext: path.extname(fileName).slice(1), ext: path.extname(fileName).slice(1),
@@ -81,37 +109,39 @@ async function parseContentDir() {
return sections; return sections;
} }
function mdCheck(b) { function mdCheck(b: boolean): string {
return b ? "x" : " "; return b ? "x" : " ";
} }
async function generateProgressionMarkdown(contentTree) { async function generateProgressionMarkdown(
contentTree: Section[],
): Promise<string> {
let output = ""; let output = "";
for (const framework of FRAMEWORKS) { for (const framework of frameworks) {
const frameworkLines = []; const frameworkLines: string[] = [];
const allChecks = []; const allChecks: boolean[] = [];
for (const root of contentTree) { for (const root of contentTree) {
const sectionChecks = []; const sectionChecks: boolean[] = [];
const subLines = []; const subLines: string[] = [];
for (const sub of root.children) { for (const sub of root.children) {
const fwEntry = sub.children.find((c) => c.dirName === framework.id); const fwEntry = sub.children.find((c) => c.dirName === framework.id);
const hasFiles = fwEntry?.files?.length > 0; const hasFiles = (fwEntry?.files?.length ?? 0) > 0;
sectionChecks.push(!!hasFiles); sectionChecks.push(!!hasFiles);
subLines.push(` * [${mdCheck(hasFiles)}] ${sub.title}`); subLines.push(` * [${mdCheck(hasFiles)}] ${sub.title}`);
} }
frameworkLines.push( frameworkLines.push(
`* [${mdCheck(sectionChecks.every(Boolean))}] ${root.title}` `* [${mdCheck(sectionChecks.every(Boolean))}] ${root.title}`,
); );
frameworkLines.push(...subLines); frameworkLines.push(...subLines);
allChecks.push(...sectionChecks); allChecks.push(...sectionChecks);
} }
const percent = Math.ceil( const percent = Math.ceil(
(allChecks.filter(Boolean).length / allChecks.length) * 100 (allChecks.filter(Boolean).length / allChecks.length) * 100,
); );
const markdown = ` const markdown = `

120
scripts/generateSitemap.ts Normal file
View File

@@ -0,0 +1,120 @@
import fs from "node:fs/promises";
import path from "node:path";
import { frameworks } from "../frameworks.ts";
interface SitemapUrl {
loc: string;
lastmod: string;
changefreq:
| "always"
| "hourly"
| "daily"
| "weekly"
| "monthly"
| "yearly"
| "never";
priority: number;
}
function generateSitemapXml(urls: SitemapUrl[]): string {
const header = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">`;
const footer = `</urlset>`;
const urlEntries = urls
.map(
(url) => ` <url>
<loc>${url.loc}</loc>
<lastmod>${url.lastmod}</lastmod>
<changefreq>${url.changefreq}</changefreq>
<priority>${url.priority}</priority>
</url>`,
)
.join("\n");
return `${header}\n${urlEntries}\n${footer}`;
}
function generateFrameworkCombinations(): string[] {
const combinations: string[] = [];
// Add main page
combinations.push("https://component-party.dev/");
// Add individual framework pages
for (const framework of frameworks) {
combinations.push(`https://component-party.dev/?f=${framework.id}`);
}
// Add popular two-framework combinations
const popularFrameworks = [
"react",
"vue3",
"angularRenaissance",
"svelte5",
"solid",
];
for (let i = 0; i < popularFrameworks.length; i++) {
for (let j = i + 1; j < popularFrameworks.length; j++) {
combinations.push(
`https://component-party.dev/?f=${popularFrameworks[i]},${popularFrameworks[j]}`,
);
}
}
// Add some three-framework combinations
const threeFrameworkCombos = [
["react", "vue3", "angularRenaissance"],
["react", "svelte5", "solid"],
["vue3", "svelte5", "solid"],
["react", "vue3", "svelte5"],
];
for (const combo of threeFrameworkCombos) {
combinations.push(`https://component-party.dev/?f=${combo.join(",")}`);
}
// Add version comparison pages
const versionComparisons = [
["svelte4", "svelte5"],
["vue2", "vue3"],
["angular", "angularRenaissance"],
["aurelia1", "aurelia2"],
["emberOctane", "emberPolaris"],
];
for (const [v1, v2] of versionComparisons) {
combinations.push(`https://component-party.dev/?f=${v1},${v2}`);
}
return combinations;
}
async function generateSitemap(): Promise<void> {
const baseUrl = "https://component-party.dev";
const currentDate = new Date().toISOString().split("T")[0];
const urls: SitemapUrl[] = generateFrameworkCombinations().map((loc) => ({
loc,
lastmod: currentDate,
changefreq: "weekly" as const,
priority: loc === `${baseUrl}/` ? 1.0 : 0.8,
}));
const sitemapXml = generateSitemapXml(urls);
// Write to public directory
const publicDir = path.join(import.meta.dirname, "..", "public");
const sitemapPath = path.join(publicDir, "sitemap.xml");
await fs.writeFile(sitemapPath, sitemapXml, "utf8");
console.log(`Generated sitemap with ${urls.length} URLs at ${sitemapPath}`);
}
// Run if called directly
if (import.meta.url === `file://${process.argv[1]}`) {
generateSitemap().catch(console.error);
}
export default generateSitemap;

View File

@@ -1,24 +1,33 @@
import frameworks from "../frameworks.mjs"; import { frameworks } from "../frameworks";
const mainPackageNames = frameworks.map((f) => f.mainPackageName); const mainPackageNames = frameworks.map((f) => f.mainPackageName);
async function getPackageDownloads(packageName) { interface PackageDownloadStats {
packageName: string;
downloads: number;
}
async function getPackageDownloads(
packageName: string,
): Promise<number | null> {
try { try {
const response = await fetch( const response = await fetch(
`https://api.npmjs.org/downloads/point/last-month/${packageName}` `https://api.npmjs.org/downloads/point/last-month/${packageName}`,
); );
const data = await response.json(); const data = await response.json();
return data.downloads; return data.downloads;
} catch (error) { } catch (error) {
console.error( console.error(
`Failed to fetch download stats for package ${packageName}: `, `Failed to fetch download stats for package ${packageName}: `,
error error,
); );
return null; return null;
} }
} }
async function sortPackagesByDownloads(packages) { async function sortPackagesByDownloads(
packages: string[],
): Promise<PackageDownloadStats[] | null> {
try { try {
const downloadStats = await Promise.all(packages.map(getPackageDownloads)); const downloadStats = await Promise.all(packages.map(getPackageDownloads));

View File

@@ -1,11 +1,6 @@
<script> <script lang="ts">
import Router from "./Router.svelte"; import { Router } from "sv-router";
import Index from "./Index.svelte"; import "./router.ts";
const routes = [
{ path: "/", component: Index },
{ path: "/compare/:versus", component: Index },
];
</script> </script>
<Router {routes} /> <Router />

View File

@@ -1,186 +1,141 @@
<script> <script lang="ts">
import { SvelteMap, SvelteSet } from "svelte/reactivity"; import { SvelteMap, SvelteSet } from "svelte/reactivity";
import c from "classnames"; import { frameworks, matchFrameworkId } from "@frameworks";
import FRAMEWORKS, { matchFrameworkId } from "../frameworks.mjs";
import FrameworkLabel from "./components/FrameworkLabel.svelte"; import FrameworkLabel from "./components/FrameworkLabel.svelte";
import { sections, snippets } from "./generatedContent/tree.js"; import { sections, snippets } from "./generatedContent/tree.js";
import snippetsImporterByFrameworkId from "./generatedContent/framework/index.js"; import snippetsImporterByFrameworkId from "./generatedContent/framework/index.js";
import CodeEditor from "./components/CodeEditor.svelte"; import CodeEditor from "./components/CodeEditor.svelte";
import AppNotificationCenter from "./components/AppNotificationCenter.svelte"; import createLocaleStorage from "./lib/createLocaleStorage.ts";
import createLocaleStorage from "./lib/createLocaleStorage.js"; import { watch } from "runed";
import { getContext, onDestroy, onMount } from "svelte";
import Header from "./components/Header.svelte"; import Header from "./components/Header.svelte";
import Aside from "./components/Aside.svelte"; import Aside from "./components/Aside.svelte";
import GithubIcon from "./components/GithubIcon.svelte"; import {
FRAMEWORK_IDS_FROM_URL_KEY,
FRAMEWORK_SEPARATOR,
} from "./constants.ts";
import { searchParams } from "sv-router";
import { navigate } from "./router.ts";
const { currentRoute, navigate } = getContext("router"); interface File {
fileName: string;
const frameworkIdsStorage = createLocaleStorage("framework_display"); contentHtml: string;
[key: string]: unknown;
function removeSearchParamKeyFromURL(k) {
// Get the current search params as an object
const searchParams = new URLSearchParams(window.location.search);
if (!searchParams.has(k)) {
// The key doesn't exist, so don't do anything
return;
}
// Remove the parameter you want to remove
searchParams.delete(k);
let newUrl = window.location.pathname;
if (searchParams.toString().length > 0) {
// There are still search params, so include the `?` character
newUrl += `?${searchParams}`;
}
// Update the URL without reloading the page
history.replaceState({}, "", newUrl);
} }
const FRAMEWORK_IDS_FROM_URL_KEY = "f"; interface FrameworkSnippet {
const SITE_TITLE = "Component Party"; frameworkId: string;
const MAX_FRAMEWORK_NB_INITIAL_DISPLAYED = 9; snippetId: string;
const FRAMEWORKS_BONUS = FRAMEWORKS.slice(MAX_FRAMEWORK_NB_INITIAL_DISPLAYED); files: File[];
playgroundURL: string;
markdownFiles: File[];
snippetEditHref: string;
}
let frameworkIdsSelected = $state(new SvelteSet()); const MAX_FRAMEWORK_NOBONUS = 9;
let snippetsByFrameworkId = $state(new SvelteMap()); const DEFAULT_FRAMEWORKS = ["react", "svelte5"];
const FRAMEWORKS_BONUS = frameworks.slice(MAX_FRAMEWORK_NOBONUS);
const frameworkIdsStorage = createLocaleStorage<string[]>(
"framework_display",
[],
);
const frameworkIdsSelected = new SvelteSet<string>();
const frameworkIdsSelectedArr = $derived([...frameworkIdsSelected]);
const frameworksSelected = $derived(
frameworkIdsSelectedArr.map((id: string) => matchFrameworkId(id)),
);
const snippetsByFrameworkId = new SvelteMap<string, FrameworkSnippet[]>();
let frameworkIdsSelectedInitialized = $state(false); let frameworkIdsSelectedInitialized = $state(false);
let isVersusFrameworks = $state(false); const isVersusFrameworks = $derived(frameworksSelected.length === 2);
let onMountCallbacks = $state(new SvelteSet()); const siteTitle = $derived(
let isMounted = $state(false); isVersusFrameworks
? `${frameworksSelected.map((f) => f!.title).join(" vs ")} - Component Party`
function handleVersus(versus) { : "Component Party",
const fids = versus.split("-vs-"); );
const frameworkIdsFromSearchParam = $derived.by(() => {
if (fids.length !== 2) { const value = searchParams.get(FRAMEWORK_IDS_FROM_URL_KEY);
return; if (typeof value === "string") {
} return value.split(FRAMEWORK_SEPARATOR).filter(matchFrameworkId);
const frameworks = fids.map(matchFrameworkId);
if (frameworks.some((f) => !f)) {
return;
}
return frameworks;
}
const unsubscribeCurrentRoute = currentRoute.subscribe(($currentRoute) => {
window.scrollTo(0, 0);
isVersusFrameworks = false;
document.title = SITE_TITLE;
if ($currentRoute.path === "/") {
if (isMounted) {
handleInitialFrameworkIdsSelectedFromStorage({ useDefaults: false });
} else {
onMountCallbacks.add(() =>
handleInitialFrameworkIdsSelectedFromStorage({ useDefaults: true })
);
}
} else if ($currentRoute.params?.versus) {
const versusFrameworks = handleVersus($currentRoute.params.versus);
if (versusFrameworks) {
isVersusFrameworks = true;
for (const versusFramework of versusFrameworks) {
frameworkIdsSelected.add(versusFramework.id);
}
frameworkIdsSelectedInitialized = true;
document.title = `${versusFrameworks
.map((f) => f.title)
.join(" vs ")} - ${SITE_TITLE}`;
} else {
navigate("/");
}
} else {
navigate("/");
} }
return [];
}); });
onDestroy(unsubscribeCurrentRoute); // handle on link click
watch(
[() => frameworkIdsFromSearchParam],
() => {
selectFrameworks(frameworkIdsFromSearchParam);
},
{ lazy: true },
);
function handleInitialFrameworkIdsSelectedFromStorage({ useDefaults }) { function selectFrameworks(frameworkIds: string[]) {
if (frameworkIdsSelectedInitialized) { frameworkIdsSelected.clear();
return; for (const frameworkId of frameworkIds) {
frameworkIdsSelected.add(frameworkId);
} }
let frameworkIdsSelectedOnInit = []; navigateWithFrameworkSelection();
}
const url = new URL(window.location.href); function onInit() {
const frameworkIdsFromStorage = frameworkIdsStorage
.getJSON()
.filter((id) => matchFrameworkId(id));
const frameworkIdsFromURLStr = url.searchParams.get( // From search param
FRAMEWORK_IDS_FROM_URL_KEY if (frameworkIdsFromSearchParam.length > 0) {
); selectFrameworks(frameworkIdsFromSearchParam);
} else if (frameworkIdsFromStorage.length > 0) {
if (frameworkIdsFromURLStr) { selectFrameworks(frameworkIdsFromStorage);
const frameworkIdsFromURL = frameworkIdsFromURLStr } else {
.split(",") // Default frameworks
.filter(matchFrameworkId); selectFrameworks(DEFAULT_FRAMEWORKS);
if (frameworkIdsFromURL.length > 0) {
frameworkIdsSelectedOnInit = frameworkIdsFromURL;
} else {
removeSearchParamKeyFromURL(FRAMEWORK_IDS_FROM_URL_KEY);
}
}
if (frameworkIdsSelectedOnInit.length === 0) {
const frameworkIdsFromStorage = frameworkIdsStorage
.getJSON()
?.filter(matchFrameworkId);
if (frameworkIdsFromStorage?.length > 0) {
frameworkIdsSelectedOnInit = frameworkIdsFromStorage;
}
}
if (useDefaults && frameworkIdsSelectedOnInit.length === 0) {
frameworkIdsSelectedOnInit = ["react", "svelte5"];
}
for (const fid of frameworkIdsSelectedOnInit) {
frameworkIdsSelected.add(fid);
} }
frameworkIdsSelectedInitialized = true; frameworkIdsSelectedInitialized = true;
} }
onMount(() => { onInit();
isMounted = true;
for (const callback of onMountCallbacks) {
callback();
}
onMountCallbacks.clear();
});
function saveFrameworkIdsSelectedOnStorage() { function toggleFrameworkId(frameworkId: string) {
frameworkIdsStorage.setJSON([...frameworkIdsSelected]);
removeSearchParamKeyFromURL(FRAMEWORK_IDS_FROM_URL_KEY);
}
function toggleFrameworkId(frameworkId) {
if (frameworkIdsSelected.has(frameworkId)) { if (frameworkIdsSelected.has(frameworkId)) {
frameworkIdsSelected.delete(frameworkId); frameworkIdsSelected.delete(frameworkId);
} else { } else {
frameworkIdsSelected.add(frameworkId); frameworkIdsSelected.add(frameworkId);
} }
frameworkIdsSelected = frameworkIdsSelected; navigateWithFrameworkSelection();
saveFrameworkIdsSelectedOnStorage();
} }
let snippetsByFrameworkIdLoading = $state(new SvelteSet()); async function navigateWithFrameworkSelection() {
let snippetsByFrameworkIdError = $state(new SvelteSet()); if (frameworkIdsSelected.size === 0) {
searchParams.delete(FRAMEWORK_IDS_FROM_URL_KEY);
} else {
searchParams.set(
FRAMEWORK_IDS_FROM_URL_KEY,
frameworkIdsSelectedArr.join(FRAMEWORK_SEPARATOR),
);
}
}
$effect(() => { let snippetsByFrameworkIdLoading = new SvelteSet<string>();
[...frameworkIdsSelected].map((frameworkId) => { let snippetsByFrameworkIdError = new SvelteSet<string>();
watch([() => frameworkIdsSelected.entries()], () => {
for (const frameworkId of frameworkIdsSelectedArr) {
if (!snippetsByFrameworkId.has(frameworkId)) { if (!snippetsByFrameworkId.has(frameworkId)) {
snippetsByFrameworkIdError.delete(frameworkId); snippetsByFrameworkIdError.delete(frameworkId);
snippetsByFrameworkIdLoading.add(frameworkId); snippetsByFrameworkIdLoading.add(frameworkId);
snippetsImporterByFrameworkId[frameworkId]() snippetsImporterByFrameworkId[frameworkId]()
.then(({ default: frameworkSnippets }) => { .then(
snippetsByFrameworkId.set(frameworkId, frameworkSnippets); ({
}) default: frameworkSnippets,
}: {
default: FrameworkSnippet[];
}) => {
snippetsByFrameworkId.set(frameworkId, frameworkSnippets);
},
)
.catch(() => { .catch(() => {
snippetsByFrameworkIdError.add(frameworkId); snippetsByFrameworkIdError.add(frameworkId);
}) })
@@ -188,87 +143,137 @@
snippetsByFrameworkIdLoading.delete(frameworkId); snippetsByFrameworkIdLoading.delete(frameworkId);
}); });
} }
}); }
if (frameworkIdsSelected.size === 0) {
navigate("/");
}
}); });
let showBonusFrameworks = $state(false); let showBonusFrameworks = $state(false);
const frameworksSelected = $derived(
[...frameworkIdsSelected].map(matchFrameworkId)
);
const bonusFrameworks = $derived( const bonusFrameworks = $derived(
FRAMEWORKS_BONUS.filter((f) => !frameworkIdsSelected.has(f.id)) FRAMEWORKS_BONUS.filter(({ id }) => !frameworkIdsSelected.has(id)),
); );
const frameworksNotSelected = $derived( const frameworksNotSelected = $derived(
FRAMEWORKS.filter((f) => !frameworkIdsSelected.has(f.id)) frameworks.filter(({ id }) => id && !frameworkIdsSelected.has(id)),
); );
const headerFrameworks = $derived([ const headerFrameworks = $derived(
...frameworksSelected, [
...frameworksNotSelected.filter( ...frameworksSelected.filter((f) => f),
(f) => !bonusFrameworks.find((bf) => bf.id === f.id) ...frameworksNotSelected.filter(
), (f) => f && !bonusFrameworks.find((bf) => bf.id === f.id),
...(showBonusFrameworks ? bonusFrameworks : []), ),
]); ...(showBonusFrameworks ? bonusFrameworks : []),
].filter((f): f is NonNullable<typeof f> => !!f),
);
</script> </script>
<AppNotificationCenter /> <svelte:head>
<title>{siteTitle}</title>
<meta
name="description"
content={isVersusFrameworks
? `Compare ${frameworksSelected
.map((f) => f?.title)
.filter(Boolean)
.join(
" vs ",
)} frameworks side-by-side. See syntax differences, features, and code examples for ${frameworksSelected
.map((f) => f?.title)
.filter(Boolean)
.join(" and ")}.`
: "Compare JavaScript frameworks side-by-side: React, Vue, Angular, Svelte, Solid.js, and more. See syntax differences, features, and code examples for web development frameworks."}
/>
<meta
name="keywords"
content={isVersusFrameworks
? frameworksSelected
.map((f) => f?.title)
.filter(Boolean)
.join(", ") +
", framework comparison, JavaScript frameworks, web development, frontend development, code comparison"
: "JavaScript frameworks, React, Vue, Angular, Svelte, Solid.js, framework comparison, web development, frontend frameworks, component libraries, JavaScript libraries, code comparison, programming tools, developer tools, web components, JSX, TypeScript, modern JavaScript"}
/>
{#if isVersusFrameworks}
<meta property="og:title" content={siteTitle} />
<meta
property="og:description"
content="Compare {frameworksSelected
.map((f) => f?.title)
.filter(Boolean)
.join(
' vs ',
)} frameworks side-by-side. See syntax differences, features, and code examples for {frameworksSelected
.map((f) => f?.title)
.filter(Boolean)
.join(' and ')}."
/>
<meta property="twitter:title" content={siteTitle} />
<meta
property="twitter:description"
content="Compare {frameworksSelected
.map((f) => f?.title)
.filter(Boolean)
.join(
' vs ',
)} frameworks side-by-side. See syntax differences, features, and code examples for {frameworksSelected
.map((f) => f?.title)
.filter(Boolean)
.join(' and ')}."
/>
{/if}
</svelte:head>
<Header {frameworksSelected} /> <Header />
<div class="flex border-b border-gray-700"> <div class="flex border-b border-gray-700">
<Aside /> <Aside />
<div class="pb-8 w-10 grow"> <div class="pb-8 w-10 grow">
<div <div
class="flex px-6 lg:px-20 py-2 sticky top-0 z-20 w-full backdrop-blur bg-gray-900/80 border-b border-gray-700 whitespace-nowrap overflow-x-auto" class="flex px-6 lg:px-20 py-2 sticky top-0 z-20 w-full backdrop-blur bg-gray-900/80 border-b border-gray-700 whitespace-nowrap overflow-x-auto"
data-framework-id-selected-list={[...frameworkIdsSelected].join(",")} data-framework-id-selected-list={frameworkIdsSelectedArr.join(",")}
data-testid="framework-selection-bar"
> >
{#each headerFrameworks as framework} {#each headerFrameworks as framework (framework.id)}
<button {#if framework}
title={frameworkIdsSelected.has(framework.id) <button
? `Hide ${framework.title}` title={frameworkIdsSelected.has(framework.id)
: `Display ${framework.title}`} ? `Hide ${framework.title}`
class={c( : `Display ${framework.title}`}
"text-sm flex-shrink-0 rounded border px-3 py-1 bg-gray-900 hover:bg-gray-800 transition-all mr-2", class={[
frameworkIdsSelected.has(framework.id) "text-sm flex-shrink-0 rounded border px-3 py-1 bg-gray-900 hover:bg-gray-800 transition-all mr-2",
? "border-blue-900" frameworkIdsSelected.has(framework.id)
: "opacity-70 border-opacity-50 border-gray-700" ? "border-blue-900"
)} : "opacity-70 border-opacity-50 border-gray-700",
onclick={() => { ]}
toggleFrameworkId(framework.id); data-testid={`framework-button-${framework.id}`}
if (isVersusFrameworks && $currentRoute.path !== "/") { onclick={() => {
navigate("/"); toggleFrameworkId(framework.id);
} if (frameworkIdsSelectedArr.length === 0) {
}} frameworkIdsStorage.remove();
> } else {
<FrameworkLabel id={framework.id} size={15} /> frameworkIdsStorage.setJSON(frameworkIdsSelectedArr);
</button> }
}}
>
<FrameworkLabel id={framework.id} size={16} />
</button>
{/if}
{/each} {/each}
{#if bonusFrameworks.length > 0 && !showBonusFrameworks} {#if bonusFrameworks.length > 0 && !showBonusFrameworks}
<button <button
title="show more frameworks" title="show more frameworks"
class="opacity-70 text-sm flex-shrink-0 rounded border border-gray-700 px-3 py-1 border-opacity-50 bg-gray-900 hover:bg-gray-800 transition-all mr-2" class="opacity-70 text-sm flex-shrink-0 rounded border border-gray-700 px-3 py-1 border-opacity-50 bg-gray-900 hover:bg-gray-800 transition-all mr-2 flex items-center justify-center"
data-testid="show-more-frameworks-button"
onclick={() => { onclick={() => {
showBonusFrameworks = !showBonusFrameworks; showBonusFrameworks = !showBonusFrameworks;
}} }}
aria-label="show more frameworks" aria-label="show more frameworks"
> >
<svg <span class="iconify ph--dots-three size-4" aria-hidden="true"></span>
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M6.75 12a.75.75 0 11-1.5 0 .75.75 0 011.5 0zM12.75 12a.75.75 0 11-1.5 0 .75.75 0 011.5 0zM18.75 12a.75.75 0 11-1.5 0 .75.75 0 011.5 0z"
/>
</svg>
</button> </button>
{/if} {/if}
</div> </div>
@@ -276,29 +281,49 @@
<main class="relative pt-6"> <main class="relative pt-6">
<div> <div>
{#if frameworkIdsSelected.size === 0} {#if frameworkIdsSelected.size === 0}
<div class="space-y-4"> <section
class="space-y-4"
data-testid="empty-state"
aria-labelledby="empty-state-heading"
>
<div class="flex justify-center"> <div class="flex justify-center">
<div class="i-heroicons:arrow-up size-6 animate-bounce"></div> <span
class="iconify ph--arrow-up size-6 animate-bounce"
aria-hidden="true"
></span>
</div> </div>
<div class="flex justify-center"> <div class="flex justify-center">
<h1 id="empty-state-heading" class="sr-only">
Select Frameworks to Compare
</h1>
<p <p
class="text-lg opacity-80 flex items-center text-center space-x-3" class="text-lg opacity-80 flex items-center text-center space-x-3"
data-testid="empty-state-message"
> >
<img src="/popper.svg" alt="logo" class="size-6" /> <img
src="/popper.svg"
alt="Component Party logo"
class="size-6"
/>
<span> <span>
Please select a framework to view framework's snippets Please select a framework to view framework's snippets
</span> </span>
<img src="/popper.svg" alt="logo" class="size-6" /> <img
src="/popper.svg"
alt="Component Party logo"
class="size-6"
/>
</p> </p>
</div> </div>
</div> </section>
{:else} {:else}
<div class="space-y-20"> <div class="space-y-20">
{#each sections as section} {#each sections as section (section.sectionId)}
<div class="px-6 md:px-14 lg:px-20 max-w-full"> <div class="px-6 md:px-14 lg:px-20 max-w-full">
<h1 <h2
id={section.sectionId} id={section.sectionId}
class="header-anchor text-2xl font-bold" class="header-anchor text-2xl font-bold"
data-testid={`section-${section.sectionId}`}
> >
{section.title} {section.title}
<a <a
@@ -308,15 +333,20 @@
> >
# #
</a> </a>
</h1> </h2>
<div class="space-y-8 mt-2"> <div class="space-y-8 mt-2">
{#each snippets.filter((s) => s.sectionId === section.sectionId) as snippet} {#each snippets.filter((s) => s.sectionId === section.sectionId) as snippet (snippet.snippetId)}
{@const snippetPathId = {@const snippetPathId =
section.sectionId + "." + snippet.snippetId} section.sectionId + "." + snippet.snippetId}
<div id={snippetPathId} data-snippet-id={snippetPathId}> <div
<h2 id={snippetPathId}
data-snippet-id={snippetPathId}
data-testid={`snippet-${snippetPathId}`}
>
<h3
class="header-anchor sticky py-2 top-[2.94rem] z-10 bg-[var(--bg-color)] font-semibold text-xl" class="header-anchor sticky py-2 top-[2.94rem] z-10 bg-[var(--bg-color)] font-semibold text-xl"
data-testid={`snippet-title-${snippetPathId}`}
> >
{snippet.title} {snippet.title}
<a <a
@@ -326,145 +356,170 @@
> >
# #
</a> </a>
</h2> </h3>
{#if frameworkIdsSelectedInitialized} {#if frameworkIdsSelectedInitialized}
<div <div
class="grid grid-cols-1 2xl:grid-cols-2 gap-10 mt-4" class="grid grid-cols-1 xl:grid-cols-2 gap-y-4 xl:gap-y-8 gap-x-10 mt-2"
> >
{#each [...frameworkIdsSelected] as frameworkId (frameworkId)} {#each frameworkIdsSelectedArr as frameworkId (frameworkId)}
{@const framework = matchFrameworkId(frameworkId)} {@const framework = matchFrameworkId(frameworkId)}
{@const frameworkSnippet = snippetsByFrameworkId {@const frameworkSnippet = snippetsByFrameworkId
.get(frameworkId) .get(frameworkId)
?.find((s) => s.snippetId === snippet.snippetId)} ?.find(
(s: FrameworkSnippet) =>
s.snippetId === snippet.snippetId,
)}
{@const frameworkSnippetIsLoading = {@const frameworkSnippetIsLoading =
snippetsByFrameworkIdLoading.has(frameworkId)} snippetsByFrameworkIdLoading.has(frameworkId)}
{@const frameworkSnippetIsError = {@const frameworkSnippetIsError =
snippetsByFrameworkIdError.has(frameworkId)} snippetsByFrameworkIdError.has(frameworkId)}
<div> {#if framework}
<div <div
class="flex justify-between items-center space-x-3" data-testid={`framework-snippet-${frameworkId}-${snippet.snippetId}`}
> >
<h3 <div
style="margin-top: 0rem; margin-bottom: 0rem;" class="flex justify-between items-center space-x-3"
> >
<FrameworkLabel id={framework.id} /> <h3
</h3> class="m-0"
{#if frameworkSnippet} data-testid={`framework-title-${frameworkId}-${snippet.snippetId}`}
<div class="flex items-center space-x-3"> >
{#if frameworkSnippet.playgroundURL} <FrameworkLabel id={framework.id} />
<a </h3>
href={frameworkSnippet.playgroundURL} {#if frameworkSnippet}
target="_blank" <div class="flex items-center space-x-3">
rel="noreferrer" {#if frameworkSnippet.playgroundURL}
aria-label={`Open playground for ${framework.title}`} <a
> href={frameworkSnippet.playgroundURL}
<button target="_blank"
rel="noreferrer"
class="opacity-50 hover:opacity-100 bg-gray-800 hover:bg-gray-700 py-1 px-2 rounded transition-all flex items-center gap-x-2" class="opacity-50 hover:opacity-100 bg-gray-800 hover:bg-gray-700 py-1 px-2 rounded transition-all flex items-center gap-x-2"
title={`Open playground for ${framework.title}`} title={`Open playground for ${framework.title}`}
aria-label={`Open playground for ${framework.title}`} aria-label={`Open playground for ${framework.title}`}
tabindex="-1" data-testid={`playground-button-${frameworkId}-${snippet.snippetId}`}
> >
<div <span
class="i-heroicons:play size-4" class="iconify ph--play size-4"
></div> aria-hidden="true"
</button> ></span>
</a> </a>
{/if} {/if}
</div> </div>
{/if} {/if}
</div> </div>
<div class="mt-2"> <div class="mt-2">
{#if frameworkSnippet} {#if frameworkSnippet}
{#if frameworkSnippet.files.length > 0} {#if frameworkSnippet.files.length > 0}
<CodeEditor <CodeEditor
files={frameworkSnippet.files} files={frameworkSnippet.files}
snippetEditHref={frameworkSnippet.snippetEditHref} snippetEditHref={frameworkSnippet.snippetEditHref}
/> data-testid={`code-editor-${frameworkId}-${snippet.snippetId}`}
{:else} />
<div {:else}
class="bg-gray-800 text-white rounded-md mx-auto"
>
<div <div
class="text-center py-8 px-4 sm:px-6" class="bg-gray-800 text-white rounded-md mx-auto"
data-testid={`missing-snippet-${frameworkId}-${snippet.snippetId}`}
> >
<div> <div
<span class="text-center py-8 px-4 sm:px-6"
class="block text-2xl tracking-tight font-bold" >
> <div>
Missing snippet <span
</span> class="block text-2xl tracking-tight font-bold"
<span data-testid="missing-snippet-title"
class="block text-lg mt-1 font-semibold space-x-1"
>
<span>
Help us to improve Component Party
</span>
<img
src="/popper.svg"
alt="logo"
class="size-5 m-0 inline-block"
/>
</span>
</div>
<div class="mt-6 flex justify-center">
<div
class="inline-flex rounded-md shadow"
>
<a
class="inline-flex space-x-2 items-center justify-center px-4 py-2 border border-transparent text-base font-medium rounded-md text-white bg-[#161b22] hover:bg-[#161b22]/80 no-underline"
href={frameworkSnippet.snippetEditHref}
> >
<button Missing snippet
class="flex items-center space-x-3" </span>
<span
class="block text-lg mt-1 font-semibold space-x-1"
data-testid="missing-snippet-message"
>
<span>
Help us to improve Component
Party
</span>
<img
src="/popper.svg"
alt="logo"
class="size-5 m-0 inline-block"
/>
</span>
</div>
<div class="mt-6 flex justify-center">
<div
class="inline-flex rounded-md shadow"
>
<a
class="inline-flex space-x-2 items-center justify-center px-4 py-2 border border-transparent text-base font-medium rounded-md text-white bg-[#161b22] hover:bg-[#161b22]/80 no-underline"
href={frameworkSnippet.snippetEditHref}
data-testid={`contribute-link-${frameworkId}-${snippet.snippetId}`}
> >
<span>Contribute on Github</span <button
class="flex items-center space-x-3"
> >
<GithubIcon class="h-5 w-5" /> <span
</button> >Contribute on Github</span
</a> >
<span
class="iconify simple-icons--github size-5"
aria-hidden="true"
></span>
</button>
</a>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> {/if}
{/if} {:else if frameworkSnippetIsLoading}
{:else if frameworkSnippetIsLoading}
<div role="status">
<div <div
class="w-75px h-23px bg-[#0d1117] py-3 px-4 rounded-t" role="status"
data-testid={`loading-snippet-${frameworkId}-${snippet.snippetId}`}
> >
<div <div
class="h-2.5 rounded-full bg-gray-700 w-10 animate-pulse" class="w-75px h-23px bg-[#0d1117] py-3 px-4 rounded-t"
></div> >
</div>
<div
class="w-full h-164px bg-[#0d1117] px-4 py-7"
>
<div class="max-w-sm animate-pulse">
<div <div
class="h-3.5 rounded-full bg-gray-700 w-48 mb-4" class="h-2.5 rounded-full bg-gray-700 w-10 animate-pulse"
></div> ></div>
<div </div>
class="h-3.5 rounded-full bg-gray-700 max-w-[360px] mb-2.5" <div
></div> class="w-full h-164px bg-[#0d1117] px-4 py-7"
<div >
class="h-3.5 rounded-full bg-gray-700 mb-4" <div class="max-w-sm animate-pulse">
></div> <div
<div class="h-3.5 rounded-full bg-gray-700 w-48 mb-4"
class="h-3.5 rounded-full bg-gray-700 max-w-[330px] mb-2.5" ></div>
></div> <div
<span class="sr-only">Loading...</span> class="h-3.5 rounded-full bg-gray-700 max-w-[360px] mb-2.5"
></div>
<div
class="h-3.5 rounded-full bg-gray-700 mb-4"
></div>
<div
class="h-3.5 rounded-full bg-gray-700 max-w-[330px] mb-2.5"
></div>
<span
class="sr-only"
data-testid="loading-text"
>Loading...</span
>
</div>
</div> </div>
</div> </div>
</div> {:else if frameworkSnippetIsError}
{:else if frameworkSnippetIsError} <p
<p class="text-orange-500"> class="text-orange-500"
Error loading snippets. Please reload the data-testid={`error-snippet-${frameworkId}-${snippet.snippetId}`}
page. >
</p> Error loading snippets. Please reload the
{/if} page.
</p>
{/if}
</div>
</div> </div>
</div> {/if}
{/each} {/each}
</div> </div>
{/if} {/if}
@@ -494,6 +549,6 @@
} }
.header-anchor:hover > a { .header-anchor:hover > a {
opacity: 100; opacity: 1;
} }
</style> </style>

View File

@@ -1,75 +0,0 @@
<script>
import { onMount, onDestroy, setContext } from "svelte";
import { writable } from "svelte/store";
import { createRouter } from "radix3";
let { routes = [] } = $props();
const router = createRouter({
routes: routes.reduce((acc, route) => {
acc[route.path] = {
...route,
};
return acc;
}, {}),
});
const currentRoute = writable({ component: null });
function navigate(path, state) {
state = state || {};
const urlParsed = new URL(path, window.location.origin);
const routePayload = router.lookup(urlParsed.pathname);
if (routePayload) {
if (routePayload.component.toString().startsWith("class")) {
currentRoute.set(routePayload);
window.history.pushState(state, "", path);
} else if (typeof routePayload.component === "function") {
currentRoute.set({
...routePayload,
component: routePayload.component,
});
window.history.pushState(state, "", path);
} else {
console.error("Invalid route component");
}
} else {
navigate("/");
}
}
window.onpopstate = () => {
navigate(window.location.href);
};
function handleClick(event) {
const target = event.target.closest("a[href]");
if (
target &&
target.getAttribute("href").startsWith("/") &&
target.getAttribute("target") !== "_blank"
) {
event.preventDefault();
navigate(target.getAttribute("href"));
}
}
onMount(() => {
document.addEventListener("click", handleClick);
navigate(window.location.href, { isInitialNavigation: true });
});
onDestroy(() => {
document.removeEventListener("click", handleClick);
});
setContext("router", {
currentRoute,
navigate,
});
</script>
{#if $currentRoute.component}
{@render $currentRoute.component()}
{/if}

View File

@@ -1,3 +1,17 @@
@import "tailwindcss";
@plugin "@iconify/tailwind4";
@plugin "@iconify/tailwind4" {
prefix: "iconify";
prefixes: ph, simple-icons;
}
@layer base {
button:not(:disabled),
[role="button"]:not(:disabled) {
cursor: pointer;
}
}
@supports (backdrop-filter: blur(10px)) { @supports (backdrop-filter: blur(10px)) {
[class*="bg-"].backdrop-blur { [class*="bg-"].backdrop-blur {
background-color: rgba(0, 0, 0, 0); background-color: rgba(0, 0, 0, 0);

View File

@@ -1,40 +0,0 @@
<script>
import NotificationCenter from "./NotificationCenter.svelte";
import TransitionWithClass from "./TransitionWithClass.svelte";
</script>
<NotificationCenter zIndex={100}>
{#snippet notificationContainer({ title, close })}
<TransitionWithClass
class="pointer-events-auto overflow-hidden rounded-lg bg-[#181622] border border-[#33323e] shadow-lg ring-1 ring-black ring-opacity-5"
enter="transform ease-out duration-200 transition"
enterFrom="translate-y-2 opacity-0 translate-y-0 translate-x-2"
enterTo="translate-y-0 opacity-100 translate-x-0"
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div class="p-4">
<div class="flex items-start">
<div class="flex-shrink-0">
<div class="i-heroicons:check-circle size-6 text-green-400"></div>
</div>
<div class="ml-3 w-0 flex-1 pt-0.5">
<p class="text-sm font-medium">
{title}
</p>
</div>
<div class="ml-4 flex flex-shrink-0">
<button
class="inline-flex rounded-md bg-transparent focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
onclick={close}
>
<span class="sr-only">Close</span>
<div class="i-heroicons:x-mark size-5"></div>
</button>
</div>
</div>
</div>
</TransitionWithClass>
{/snippet}
</NotificationCenter>

View File

@@ -1,14 +1,14 @@
<script> <script lang="ts">
import c from "classnames";
import { sections, snippets } from "../generatedContent/tree.js"; import { sections, snippets } from "../generatedContent/tree.js";
import { onMount, onDestroy } from "svelte"; import { onMount } from "svelte";
import throttle from "../lib/throttle.js"; import throttle from "just-throttle";
let largestVisibleSnippetId = $state(null); let largestVisibleSnippetId: string = $state("");
function getLargestElement(elements) { function getLargestElement(elements: NodeListOf<Element>): Element | null {
let largestArea = 0; let largestArea = 0;
let largestElement = null; let largestElement: Element | null = null;
let firstFullyVisibleElement: Element | null = null;
for (const element of elements) { for (const element of elements) {
const rect = element.getBoundingClientRect(); const rect = element.getBoundingClientRect();
@@ -20,6 +20,19 @@
if (visibleWidth > 0 && visibleHeight > 0) { if (visibleWidth > 0 && visibleHeight > 0) {
const area = visibleWidth * visibleHeight; const area = visibleWidth * visibleHeight;
// Check if element is fully visible
const isFullyVisible =
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= window.innerHeight &&
rect.right <= window.innerWidth;
// Prioritize first fully visible element
if (isFullyVisible && !firstFullyVisibleElement) {
firstFullyVisibleElement = element;
}
// Track largest element as fallback
if (area > largestArea) { if (area > largestArea) {
largestArea = area; largestArea = area;
largestElement = element; largestElement = element;
@@ -27,18 +40,30 @@
} }
} }
return largestElement; // Return first fully visible element if found, otherwise largest element
return firstFullyVisibleElement || largestElement;
} }
function listenLargestSnippetOnScroll() { function scrollToElement(elementId: string) {
const target = document.getElementById(elementId);
if (target) {
// Update URL hash
window.history.pushState(null, "", `#${elementId}`);
// Scroll to target
target.scrollIntoView({ block: "start" });
}
}
onMount(function listenLargestSnippetOnScroll() {
function onScroll() { function onScroll() {
const largestSnippet = getLargestElement( const largestSnippet = getLargestElement(
document.querySelectorAll("[data-snippet-id]") document.querySelectorAll("[data-snippet-id]"),
); );
if (largestSnippet) { if (largestSnippet) {
largestVisibleSnippetId = largestSnippet.dataset.snippetId; largestVisibleSnippetId =
largestSnippet.getAttribute("data-snippet-id") ?? "";
} else { } else {
largestVisibleSnippetId = null; largestVisibleSnippetId = "";
} }
} }
@@ -49,55 +74,45 @@
return () => { return () => {
document.removeEventListener("scroll", throttleOnScroll); document.removeEventListener("scroll", throttleOnScroll);
}; };
}
let unlistenLargestSnippetOnScroll;
onMount(() => {
unlistenLargestSnippetOnScroll = listenLargestSnippetOnScroll();
});
onDestroy(() => {
unlistenLargestSnippetOnScroll && unlistenLargestSnippetOnScroll();
}); });
</script> </script>
<aside <aside
class="no-scroll hidden lg:block sticky flex-shrink-0 w-[300px] overflow-y-auto top-0 pr-8 max-h-screen border-r border-gray-700" class="no-scroll hidden lg:block sticky flex-shrink-0 w-[240px] overflow-y-auto top-0 pr-8 max-h-screen border-r border-gray-700"
> >
<nav class="w-full text-base py-2 pl-4 pb-20"> <nav class="w-full text-base py-2 pl-4 pb-20">
<ul class="space-y-6"> <ul class="space-y-6">
{#each sections as section} {#each sections as section (section.sectionId)}
<li> <li>
<a <button
href={`#${section.sectionId}`} class={[
class={c( "inline-block w-full py-1.5 px-4 text-white font-semibold opacity-90 hover:opacity-100 hover:bg-gray-800 rounded transition-opacity text-left",
"inline-block w-full py-1.5 px-4 text-white font-semibold opacity-90 hover:opacity-100 hover:bg-gray-800 rounded transition-opacity",
{ {
"bg-gray-800": "bg-gray-800":
largestVisibleSnippetId && largestVisibleSnippetId &&
largestVisibleSnippetId.startsWith(section.sectionId), largestVisibleSnippetId.startsWith(section.sectionId),
} },
)} ]}
onclick={() => scrollToElement(section.sectionId)}
> >
{section.title} {section.title}
</a> </button>
<ul> <ul>
{#each snippets.filter((s) => s.sectionId === section.sectionId) as snippet} {#each snippets.filter((s: any) => s.sectionId === section.sectionId) as snippet (snippet.snippetId)}
{@const snippetPathId = {@const snippetPathId =
section.sectionId + "." + snippet.snippetId} section.sectionId + "." + snippet.snippetId}
<li> <li>
<a <button
href={`#${snippetPathId}`} class={[
class={c( "inline-block w-full py-1.5 px-4 text-white font-medium hover:bg-gray-800 rounded hover:opacity-100 transition-opacity text-left",
"inline-block w-full py-1.5 px-4 text-white font-medium hover:bg-gray-800 rounded hover:opacity-100 transition-opacity",
snippetPathId === largestVisibleSnippetId snippetPathId === largestVisibleSnippetId
? "bg-gray-800 opacity-70" ? "bg-gray-800 opacity-70"
: "opacity-50" : "opacity-50",
)} ]}
onclick={() => scrollToElement(snippetPathId)}
> >
{snippet.title} {snippet.title}
</a> </button>
</li> </li>
{/each} {/each}
</ul> </ul>

View File

@@ -1,35 +1,52 @@
<script> <script lang="ts">
import c from "classnames"; import copyToClipboard from "../lib/copyToClipboard.ts";
import { notifications } from "./NotificationCenter.svelte";
import copyToClipboard from "../lib/copyToClipboard.js";
const { files = [], snippetEditHref } = $props(); interface File {
fileName: string;
contentHtml: string;
}
let codeSnippetEl = $state(); interface Props {
files: File[];
snippetEditHref?: string;
"data-testid"?: string;
}
let filenameSelected = $state(files.length > 0 && files[0]?.fileName); const {
files = [],
snippetEditHref,
"data-testid": dataTestId,
}: Props = $props();
const snippet = $derived( let codeSnippetEl: HTMLElement | undefined = $state();
filenameSelected && files.find((s) => s.fileName === filenameSelected)
let filenameSelected: string | undefined = $state(
files.length > 0 ? files[0]?.fileName : undefined,
); );
function copySnippet() { const snippet: File | undefined = $derived(
filenameSelected
? files.find((s) => s.fileName === filenameSelected)
: undefined,
);
function copySnippet(): void {
if (codeSnippetEl) { if (codeSnippetEl) {
copyToClipboard(codeSnippetEl.innerText); copyToClipboard(codeSnippetEl.innerText);
notifications.show({
title: "Snippet copied to clipboard",
});
} }
} }
</script> </script>
<div class="flex space-x-1 items-center ml-0 overflow-x-auto"> <div
class="flex space-x-1 items-center ml-0 overflow-x-auto"
data-testid={dataTestId}
>
{#each files as file (file.fileName)} {#each files as file (file.fileName)}
<button <button
class={c( class={[
"bg-[#0d1117] py-1.5 px-3 flex-shrink-0 text-xs rounded-t inline-block", "bg-[#0d1117] py-1.5 px-3 flex-shrink-0 text-xs rounded-t inline-block transition-all duration-200 hover:opacity-100",
filenameSelected !== file.fileName && "opacity-60" filenameSelected !== file.fileName && "opacity-60",
)} ]}
onclick={() => { onclick={() => {
filenameSelected = file.fileName; filenameSelected = file.fileName;
}} }}
@@ -42,9 +59,12 @@
<div class="relative group"> <div class="relative group">
<div <div
bind:this={codeSnippetEl} bind:this={codeSnippetEl}
class="bg-[#0d1117] px-4 py-3 text-sm overflow-auto" class="bg-[#0d1117] px-4 py-3 text-sm overflow-auto rounded-b rounded-tr"
> >
{@html snippet.contentHtml} {#if snippet}
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html snippet.contentHtml}
{/if}
</div> </div>
<div <div
class="absolute hidden group-hover:block transition-all top-0 right-0 mt-2 mr-2" class="absolute hidden group-hover:block transition-all top-0 right-0 mt-2 mr-2"
@@ -55,17 +75,17 @@
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
aria-label="Edit on Github" aria-label="Edit on Github"
class="px-1.5 bg-[#0d1117] py-1 rounded border opacity-60 hover:opacity-90" class="bg-[#0d1117] rounded border opacity-60 hover:opacity-90 transition-all duration-200 p-1 flex items-center justify-center"
> >
<div class="i-heroicons:pencil size-4"></div> <span class="iconify ph--pencil size-4" aria-hidden="true"></span>
</a> </a>
<button <button
class="px-1.5 bg-[#0d1117] py-1 rounded border opacity-60 hover:opacity-90" class="bg-[#0d1117] rounded border opacity-60 hover:opacity-90 transition-all duration-200 p-1 flex items-center justify-center"
title="Copy to clipboard" title="Copy to clipboard"
aria-label="Copy to clipboard" aria-label="Copy to clipboard"
onclick={copySnippet} onclick={copySnippet}
> >
<div class="i-heroicons:clipboard-document size-4"></div> <span class="iconify ph--clipboard size-4" aria-hidden="true"></span>
</button> </button>
</div> </div>
</div> </div>

View File

@@ -1,24 +1,32 @@
<script> <script lang="ts">
import FRAMEWORKS from "../../frameworks.mjs"; import { frameworks } from "@frameworks";
import type { Framework } from "@frameworks";
let { id, size = 20 } = $props(); interface Props {
id: string;
size?: number;
}
const framework = $derived(FRAMEWORKS.find((f) => f.id === id)); let { id, size = 20 }: Props = $props();
const baseURL = import.meta.env.DEV const framework: Framework | undefined = $derived(
frameworks.find((f) => f.id === id),
);
const baseURL: string = import.meta.env.DEV
? "/" ? "/"
: "https://raw.githubusercontent.com/matschik/component-party/main/public/"; : "https://raw.githubusercontent.com/matschik/component-party/main/public/";
</script> </script>
<div class="flex items-center space-x-1"> <div class="flex items-center space-x-1.5">
{#if framework?.img} {#if framework?.img}
<img <img
src={baseURL + framework.img} src={baseURL + framework.img}
width={size} width={size}
height={size} height={size}
class="inline mr-[5px] mb-0 mt-0" class="flex-shrink-0"
alt={`logo of ${framework.title}`} alt={`logo of ${framework.title}`}
/> />
{/if} {/if}
<span class="flex-shrink-0">{framework.title}</span> <span class="flex-shrink-0 inline-block">{framework?.title || id}</span>
</div> </div>

View File

@@ -1,16 +0,0 @@
<script>
const props = $props();
</script>
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
focusable="false"
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"
/>
</svg>

View File

@@ -1,18 +1,33 @@
<script> <script lang="ts">
import { onMount } from "svelte";
import createLocaleStorage from "../lib/createLocaleStorage"; import createLocaleStorage from "../lib/createLocaleStorage";
import GithubIcon from "./GithubIcon.svelte";
interface StarCountStorageData {
value: string;
fetchedAt: number;
}
interface ShieldsApiResponse {
schemaVersion: number;
label: string;
message: string;
color: string;
}
const REPOSITORY_PATH = "matschik/component-party.dev"; const REPOSITORY_PATH = "matschik/component-party.dev";
const STAR_COUNT_EXPIRES_IN_MS = 1000 * 60 * 2; const STAR_COUNT_EXPIRES_IN_MS = 1000 * 60 * 5; // Shields.io caches for 5-15 minutes
const starCountStorage = createLocaleStorage("github-star-count"); const starCountStorage = createLocaleStorage("github-star-count-v2", {
value: "0",
fetchedAt: 0,
});
let starCount = $state(0); let starCount: string = $state("0");
let isFetchingStarCount = $state(false); let isFetchingStarCount: boolean = $state(false);
async function getRepoStarCount(): Promise<void> {
const starCountStorageData: StarCountStorageData | null =
starCountStorage.getJSON() as StarCountStorageData | null;
async function getRepoStarCount() {
const starCountStorageData = starCountStorage.getJSON();
if (starCountStorageData) { if (starCountStorageData) {
starCount = starCountStorageData.value; starCount = starCountStorageData.value;
if ( if (
@@ -25,35 +40,38 @@
isFetchingStarCount = true; isFetchingStarCount = true;
// Github public API rate limit: 60 requests per hour try {
const data = await fetch( // Using Shields.io JSON endpoint - no rate limits, cached for 5-15 minutes
`https://api.github.com/repos/${REPOSITORY_PATH}`, const data: ShieldsApiResponse = await fetch(
{ `https://img.shields.io/github/stars/${REPOSITORY_PATH}.json`,
headers: { {
Accept: "application/vnd.github.v3.star+json", headers: {
Authorization: "", Accept: "application/json",
},
}, },
} )
) .finally(() => {
.finally(() => { isFetchingStarCount = false;
isFetchingStarCount = false; })
}) .then((r) => r.json());
.then((r) => r.json());
if (data.stargazers_count) { if (data.message) {
starCount = data.stargazers_count; // Use the formatted string directly from Shields.io (e.g., "3.1k", "500")
starCountStorage.setJSON({ starCount = data.message;
value: starCount, starCountStorage.setJSON({
fetchedAt: Date.now(), value: starCount,
}); fetchedAt: Date.now(),
});
}
} catch (error) {
console.warn("Failed to fetch star count from Shields.io:", error);
// Keep the existing cached value if available
} }
} }
onMount(() => { getRepoStarCount();
getRepoStarCount();
});
function onButtonClick() { function onButtonClick(): void {
starCountStorage.remove(); starCountStorage.remove();
} }
</script> </script>
@@ -66,34 +84,21 @@
onclick={onButtonClick} onclick={onButtonClick}
> >
<span class="flex items-center px-3 sm:space-x-2"> <span class="flex items-center px-3 sm:space-x-2">
<GithubIcon class="size-[1.3rem] sm:size-[1.1rem]" /> <span
class="iconify simple-icons--github size-[1.3rem] sm:size-[1.1rem]"
aria-hidden="true"
></span>
<span class="hidden sm:inline">Star</span> <span class="hidden sm:inline">Star</span>
</span> </span>
{#if isFetchingStarCount || starCount !== 0} {#if isFetchingStarCount || starCount !== "0"}
<div <div
class="hidden h-full items-center justify-center px-3 sm:flex border-[#373b43] sm:border-l" class="hidden h-full items-center justify-center px-3 sm:flex border-[#373b43] sm:border-l"
> >
{#if isFetchingStarCount && starCount === 0} {#if isFetchingStarCount && starCount === "0"}
<svg <span
class="animate-spin size-4 mx-1" class="iconify ph--spinner animate-spin size-4 mx-1"
xmlns="http://www.w3.org/2000/svg" aria-hidden="true"
fill="none" ></span>
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
/>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
{:else} {:else}
<span>{starCount}</span> <span>{starCount}</span>
{/if} {/if}

View File

@@ -1,25 +1,5 @@
<script> <script lang="ts">
import { notifications } from "./NotificationCenter.svelte";
import GithubStarButton from "./GithubStarButton.svelte"; import GithubStarButton from "./GithubStarButton.svelte";
import copyToClipboard from "../lib/copyToClipboard.js";
let { frameworksSelected = [] } = $props();
function copyShareLink() {
if (frameworksSelected.length === 0) {
return;
}
let shareURL = `${location.origin}`;
if (frameworksSelected.length === 2) {
shareURL += `/compare/${[...frameworksSelected].map((f) => f.id).join("-vs-")}`;
} else {
shareURL += `?f=${[...frameworksSelected].map((f) => f.id).join(",")}`;
}
copyToClipboard(shareURL);
notifications.show({
title: `Framework selection link copied with ${[...frameworksSelected].map((f) => f.title).join(", ")}`,
});
}
</script> </script>
<header class="backdrop-blur bg-gray-900/80 border-b border-gray-700"> <header class="backdrop-blur bg-gray-900/80 border-b border-gray-700">
@@ -27,21 +7,10 @@
<div class="flex justify-between items-center py-3"> <div class="flex justify-between items-center py-3">
<a class="font-semibold text-lg flex items-center space-x-3" href="/"> <a class="font-semibold text-lg flex items-center space-x-3" href="/">
<img src="/popper.svg" alt="logo" class="size-5" /> <img src="/popper.svg" alt="logo" class="size-5" />
<span>Component party</span> <h1>Component Party</h1>
</a> </a>
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
{#if frameworksSelected.length > 0}
<button
type="button"
class="flex items-center space-x-2 rounded border border-gray-700 border-opacity-50 bg-gray-900 px-3 py-1 text-sm text-white transition-all hover:bg-gray-800"
aria-label="Copy framework selection link"
onclick={copyShareLink}
>
<div class="i-heroicons:link size-[1.3rem] sm:size-[1.1rem]"></div>
<span class="hidden sm:inline">Share</span>
</button>
{/if}
<GithubStarButton /> <GithubStarButton />
</div> </div>
</div> </div>

View File

@@ -1,96 +0,0 @@
<script module>
import { writable } from "svelte/store";
const { subscribe, update } = writable([]);
const DISMISS_TIMEOUT_DEFAULT = 4 * 1000;
export const notifications = {
subscribe,
show(notification) {
notification = {
...notification,
dismissAfter: DISMISS_TIMEOUT_DEFAULT,
close() {
notifications.dismiss(this);
},
};
update((notifications) => [notification, ...notifications]);
},
dismiss(notification) {
update((notifications) =>
notifications.filter((t) => t !== notification)
);
},
dismissAll() {
update(() => []);
},
};
export function addNotificationsMethod(method, cb) {
notifications[method] = (...args) => {
const notification = cb(...args);
notifications.show(notification);
};
}
</script>
<script>
let { zIndex = 20, notificationContainer } = $props();
function dismissAfter(_, notification) {
notification.dismissAfter &&
setTimeout(
() => notifications.dismiss(notification),
notification.dismissAfter
);
}
</script>
<div class="notifications-container" style={`z-index: ${zIndex};`}>
<ul class="notifications-list space-y-4">
{#each $notifications as notification (notification)}
<li use:dismissAfter={notification}>
{@render notificationContainer(notification)}
</li>
{/each}
</ul>
</div>
<style>
.notifications-container {
position: fixed;
top: 0;
right: 0;
display: flex;
align-items: flex-end;
margin: 2.5rem;
}
@media (min-width: 640px) {
.notifications-container {
margin: 1.5rem;
align-items: flex-start;
}
}
.notifications-list {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
}
@media (min-width: 640px) {
.notifications-list {
align-items: flex-end;
}
}
.space-y-4 > * + * {
margin-top: 1rem;
}
li {
width: 20rem;
}
</style>

Some files were not shown because too many files have changed in this diff Show More