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
*.log
@@ -7,13 +8,21 @@ yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Package manager files
package-lock.json
yarn.lock
.npm
.yarn
node_modules
dist
dist-ssr
*.local
# Prettier cache
.cache
# Lint
.eslintcache
# Editor directories and files
.vscode/*
@@ -30,4 +39,9 @@ src/generatedContent
archive
vite.config.js.timestamp*
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": [
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint",
"svelte.svelte-vscode",
"antfu.unocss"
],
"unwantedRecommendations": []
"bradlc.vscode-tailwindcss"
]
}

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
2. Add the new framework SVG logo in `public/framework`
3. Install the ESLint plugin associated to the framework
4. In `frameworks.mjs`, add a new entry with SVG link and ESLint configuration
5. If the framework needs a language syntax highlight, add it to the call to `getHighlighter`s `langs` argument in `build/lib/generateContent.js`
6. To make a playground link:
1. Add a `create${FRAMEWORK}Playground.js` file in `build/lib/playground`.
2. That file should export a function that returns an object with a `fromContentByFilename` method that accepts an object of filepath keys and file content values, then returns an absolute URL to a frameworks online REPL with those files loaded.
3. Register its export in `build/lib/playground/index.js`
3. In `frameworks.ts`, add a new entry with SVG link
4. If the framework needs a language syntax highlight, add it to the call to `getHighlighter`s `langs` argument in `build/lib/generateContent.ts`
5. To make a playground link in `build/lib/playgroundUrlByFramework.ts`.
## Improve website
@@ -23,4 +19,4 @@ pnpm i
pnpm run dev
```
This project requires Node.js to be `v14.0.0` or higher, because we use new JavaScript features in our code, such as optional chaining.
This project requires Node.js to be `v22.18.0` or higher, because we use new JavaScript features in our code, such as optional chaining.

View File

@@ -433,7 +433,7 @@ How do we solve this ? Developers love having framework overview by examples. It
<details>
<summary>
<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" />
</summary>
@@ -657,6 +657,44 @@ How do we solve this ? Developers love having framework overview by examples. It
- [x] Fetch data
</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 -->
@@ -671,7 +709,7 @@ pnpm i
pnpm run dev
```
This project requires Node.js to be `v20` or higher.
This project requires Node.js to be `v22.18.0` or higher.
### Principle when add/edit a framework snippet
@@ -684,16 +722,13 @@ We believe that deep understanding should precede optimization, enabling learner
1. Fork the project and create a new branch
2. Add the new framework SVG logo in `public/framework`
3. Install the ESLint plugin associated to the framework
4. In `frameworks.mjs`, add a new entry with SVG link and ESLint configuration
5. If the framework needs a language syntax highlight, add it to the call to `getHighlighter`s `langs` argument in `build/lib/generateContent.js`
6. To make a playground link:
1. In file `build/lib/playgroundUrlByFramework.js`, add your framework id.
2. The method accepts an object of filepath keys and file content values, then returns a playground URL to the frameworks online REPL with those files loaded.
3. In `frameworks.ts`, add a new entry with SVG link
4. If the framework needs a language syntax highlight, add it to the call to `getHighlighter`s `langs` argument in `build/lib/generateContent.ts`
5. To make a playground link in `build/lib/playgroundUrlByFramework.ts`.
## 🧑‍💻 Contributors
This project exists thanks to all the people who contribute. \[[Contribute](CONTRIBUTING.md)].
This project exists thanks to all the people who contribute. [Contribute](./CONTRIBUTING.md)
[![Contributors](https://opencollective.com/component-party/contributors.svg?width=890&button=false)](https://github.com/matschik/component-party/graphs/contributors)
## ⚖️ License

View File

@@ -1,20 +1,21 @@
import generateContent from "./lib/generateContent.js";
import generateContent from "./lib/generateContent";
import { createFsCache } from "micache";
import { hashElement } from "folder-hash";
import chokidar from "chokidar";
import chokidar, { type FSWatcher } from "chokidar";
import { disposeHighlighter } from "./lib/highlighter.ts";
const contentDirFsCache = await createFsCache("pluginGenerateFrameworkContent");
export default function pluginGenerateFrameworkContent() {
const name = "generateFrameworkContent";
function logInfo(...args) {
function logInfo(...args: unknown[]) {
console.info(`[${name}]`, ...args);
}
let buildIsRunning = false;
async function build() {
async function build(): Promise<void> {
if (buildIsRunning) {
return;
}
@@ -23,7 +24,7 @@ export default function pluginGenerateFrameworkContent() {
const contentDirHash =
(await hashElement("content")).hash +
(await hashElement("build")).hash +
(await hashElement("frameworks.mjs")).hash;
(await hashElement("frameworks.ts")).hash;
const contentDirLastHash = await contentDirFsCache.get("contentDirHash");
if (contentDirHash !== contentDirLastHash) {
@@ -36,14 +37,14 @@ export default function pluginGenerateFrameworkContent() {
buildIsRunning = false;
}
let fsContentWatcher;
let fsContentWatcher: FSWatcher | undefined;
if (process.env.NODE_ENV === "development") {
fsContentWatcher = chokidar.watch(["content"]).on("change", build);
}
return {
name,
async buildStart() {
async buildStart(): Promise<void> {
try {
await build();
} catch (error) {
@@ -51,8 +52,10 @@ export default function pluginGenerateFrameworkContent() {
throw error;
}
},
async buildEnd() {
fsContentWatcher && (await fsContentWatcher.close());
async buildEnd(): Promise<void> {
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 (
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);
let codeHighlighted = "";
@@ -15,26 +18,26 @@ export async function highlightAngularComponent(fileContent, fileExt) {
removeAngularTemplateContent(fileContent);
const templateCodeHighlighted = await codeToHighlightCodeHtml(
templateCode,
"html"
"html",
);
const componentWithoutTemplateHighlighted = await codeToHighlightCodeHtml(
componentWithEmptyTemplate,
fileExt
fileExt,
);
codeHighlighted = componentWithoutTemplateHighlighted.replace(
"template",
"template: `" + removeCodeWrapper(templateCodeHighlighted) + "`,"
"template: `" + removeCodeWrapper(templateCodeHighlighted) + "`,",
);
} else {
codeHighlighted = codeToHighlightCodeHtml(fileContent, fileExt);
codeHighlighted = await codeToHighlightCodeHtml(fileContent, fileExt);
}
return codeHighlighted;
}
function getAngularTemplateCode(fileContent) {
function getAngularTemplateCode(fileContent: string): string {
// regex to grab what is inside angular component template inside backticks
const regex = /template:\s*`([\s\S]*?)`/gm;
@@ -46,17 +49,17 @@ function getAngularTemplateCode(fileContent) {
return "";
}
function removeAngularTemplateContent(fileContent) {
function removeAngularTemplateContent(fileContent: string): string {
const componentWithoutContentInsideTemplate = fileContent.replace(
/template:\s*`([\s\S]*?)([^*])`,?/gm,
"template"
"template",
);
return componentWithoutContentInsideTemplate;
}
function removeCodeWrapper(html) {
function removeCodeWrapper(html: string): string {
const regexForWrapper = /<pre([\s\S]*?)><code>([\s\S]*?)<\/code><\/pre>/gm;
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",
type: "dark",
semanticHighlighting: true,
semanticTokenColors: {
enumMember: {
foreground: "#56b6c2",
},
"variable.constant": {
foreground: "#d19a66",
},
"variable.defaultLibrary": {
foreground: "#e5c07b",
},
"variable:dart": {
foreground: "#d19a66",
},
"property:dart": {
foreground: "#d19a66",
},
"annotation:dart": {
foreground: "#d19a66",
},
"parameter.label:dart": {
foreground: "#abb2bf",
},
macro: {
foreground: "#d19a66",
},
tomlArrayKey: {
foreground: "#e5c07b",
},
enumMember: "#56b6c2",
"variable.constant": "#d19a66",
"variable.defaultLibrary": "#e5c07b",
"variable:dart": "#d19a66",
"property:dart": "#d19a66",
"annotation:dart": "#d19a66",
"parameter.label:dart": "#abb2bf",
macro: "#d19a66",
tomlArrayKey: "#e5c07b",
},
tokenColors: [
{
@@ -2110,4 +2094,4 @@ export default {
"walkThrough.embeddedEditorBackground": "#2e3440",
"welcomePage.buttonHoverBackground": "#404754",
},
};
} as const satisfies ThemeRegistration;

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 { compressToURL } from "@matschik/lz-string";
import { getParameters } from "codesandbox/lib/api/define.js";
import LZString from "lz-string";
export default {
vue3: (contentByFilename) => {
interface PlaygroundFunction {
(
contentByFilename: Record<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/#";
function utoa(data) {
function utoa(data: string): string {
return btoa(unescape(encodeURIComponent(data)));
}
function generateURLFromData(data) {
function generateURLFromData(data: unknown): string {
return `${BASE_URL}${utoa(JSON.stringify(data))}`;
}
const data = Object.assign({}, contentByFilename, {
@@ -21,21 +54,27 @@ export default {
const url = generateURLFromData(data);
return url;
},
svelte4: async (contentByFilename, title) => {
svelte4: async (
contentByFilename: Record<string, string>,
title?: string,
) => {
return generateSveltePlaygroundURL({
version: 4,
contentByFilename,
title,
});
},
svelte5: async (contentByFilename, title) => {
svelte5: async (
contentByFilename: Record<string, string>,
title?: string,
) => {
return generateSveltePlaygroundURL({
version: 5,
contentByFilename,
title,
});
},
alpine: (contentByFilename) => {
alpine: (contentByFilename: Record<string, string>) => {
const BASE_URL =
"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`;
@@ -49,9 +88,7 @@ export default {
},
"index.html": {
content:
BASE_PREFIX +
(contentByFilename["index.html"]?.content || "") +
BASE_SUFFIX,
BASE_PREFIX + (contentByFilename["index.html"] || "") + BASE_SUFFIX,
},
"sandbox.config.json": {
content: '{\n "template": "static"\n}',
@@ -61,14 +98,14 @@ export default {
return `${BASE_URL}${parameters}`;
},
solid: (contentByFilename) => {
solid: (contentByFilename: Record<string, string>) => {
const BASE_URL = "https://playground.solidjs.com/#";
const SOURCE_PREFIX = `import { render } from "solid-js/web";\n`;
const getSourceSuffix = (componentName) =>
const getSourceSuffix = (componentName: string) =>
`\n\nrender(() => <${componentName} />, document.getElementById("app"));\n`;
function generateURLFromData(data) {
return `${BASE_URL}${compressToURL(JSON.stringify(data))}`;
function generateURLFromData(data: unknown): string {
return `${BASE_URL}${LZString.compressToEncodedURIComponent(JSON.stringify(data))}`;
}
const data = Object.keys(contentByFilename).map((filename) => {
@@ -92,10 +129,10 @@ export default {
return generateURLFromData(data);
},
marko: async (contentByFilename) => {
marko: async (contentByFilename: Record<string, string>) => {
let firstFile = true;
const data = Object.entries(contentByFilename).map(([path, content]) => ({
path: firstFile ? (firstFile = false) || "index.marko" : path,
path: firstFile ? ((firstFile = false), "index.marko") : path,
content,
}));
@@ -106,11 +143,13 @@ export default {
},
};
export default playgroundUrlByFramework;
async function generateSveltePlaygroundURL({
version,
contentByFilename,
title,
}) {
}: SveltePlaygroundOptions): Promise<string | undefined> {
const BASE_URL = `https://svelte.dev/playground/untitled?version=${version}#`;
const filenames = Object.keys(contentByFilename);
@@ -118,7 +157,7 @@ async function generateSveltePlaygroundURL({
return;
}
const files = filenames.map((filename, index) => {
const files: File[] = filenames.map((filename, index) => {
const contents = contentByFilename[filename];
const name = index === 0 ? "App.svelte" : nodePath.parse(filename).base;
return {
@@ -130,9 +169,12 @@ async function generateSveltePlaygroundURL({
};
});
const payload = { title, files };
const payload: PlaygroundData = { title: title || "", files };
const hash = await compress_and_encode_text(JSON.stringify(payload));
if (!hash) {
return;
}
const url = `${BASE_URL}${hash}`;
@@ -140,7 +182,7 @@ async function generateSveltePlaygroundURL({
}
// method `compress_and_encode_text` from https://github.com/sveltejs/svelte.dev/blob/main/apps/svelte.dev/src/routes/(authed)/playground/%5Bid%5D/gzip.js
async function compress_and_encode_text(input) {
async function compress_and_encode_text(input: string): Promise<string> {
const reader = new Blob([input])
.stream()
.pipeThrough(new CompressionStream("gzip"))
@@ -161,7 +203,7 @@ async function compress_and_encode_text(input) {
}
// method `compress` from https://github.com/marko-js/website/blob/main/src/util/hasher.ts#L8-L25
export async function markoCompress(value) {
export async function markoCompress(value: string): Promise<string> {
const stream = new CompressionStream("gzip");
const writer = stream.writable.getWriter();
writer.write(new TextEncoder().encode(value));

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,7 +8,7 @@ export default function Counter() {
m(
"div",
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({
selector: "app-input-focused",

View File

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

View File

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

View File

@@ -24,7 +24,7 @@ export default function TrafficLight() {
"div",
m("button", { onclick: nextLight }, "Next light"),
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(
() => this.time.set(new Date().toLocaleTimeString()),
1000
1000,
);
ngOnDestroy() {

View File

@@ -7,6 +7,6 @@ export const UserProfile = () => ({
m("p", `My name is ${name}!`),
m("p", `My age is ${age}!`),
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) {
const merged = mergeProps(
{ name: "", age: null, favouriteColors: [], isAvailable: false },
props
props,
);
return (

View File

@@ -4,6 +4,6 @@ export const AnswerButton = ({ attrs: { onYes, onNo } }) => ({
m(
"div",
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", { 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>
<div>
<p>Are you happy?</p>
<AnswerButton
@yes="onAnswerYes"
@no="onAnswerNo"
/>
<AnswerButton @yes="onAnswerYes" @no="onAnswerNo" />
<p style="font-size: 50px">
{{ isHappy ? "😀" : "😥" }}
</p>

View File

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

View File

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

View File

@@ -17,6 +17,6 @@ export const FunnyButton = ({ children }) => ({
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("h1", `Welcome Back, ${user.username}`),
m(UserProfile, { user, updateUsername })
m(UserProfile, { user, updateUsername }),
),
};
}

View File

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

View File

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

View File

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

View File

@@ -15,7 +15,7 @@ export default function IsAvailable() {
checked: isAvailable,
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>
<div>
<input
id="is-available"
v-model="isAvailable"
type="checkbox"
>
<input id="is-available" v-model="isAvailable" type="checkbox" />
<label for="is-available">Is available</label>
</div>
</template>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,8 +17,8 @@ export default function ColorSelect() {
"select",
{ value: selectedColorId, onchange: handleSelect },
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(
<React.StrictMode>
<App />
</React.StrictMode>
</React.StrictMode>,
);

View File

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

View File

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

View File

@@ -9,7 +9,7 @@ export default function App() {
isLoading = true;
try {
const { results } = await m.request(
"https://randomuser.me/api/?results=3"
"https://randomuser.me/api/?results=3",
);
users = results;
} catch (err) {
@@ -28,8 +28,8 @@ export default function App() {
"li",
{ key: user.login.uuid },
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-else-if="error">An error ocurred while fetching users</p>
<ul v-else-if="users">
<li
v-for="user in users"
:key="user.login.uuid"
>
<img
:src="user.picture.thumbnail"
alt="user"
>
<li v-for="user in users" :key="user.login.uuid">
<img :src="user.picture.thumbnail" alt="user" />
<p>
{{ user.name.first }}
{{ user.name.last }}

View File

@@ -8,14 +8,8 @@ const { isLoading, error, data: users } = useFetchUsers();
<p v-if="isLoading">Fetching users...</p>
<p v-else-if="error">An error ocurred while fetching users</p>
<ul v-else-if="users">
<li
v-for="user in users"
:key="user.login.uuid"
>
<img
:src="user.picture.thumbnail"
alt="user"
>
<li v-for="user in users" :key="user.login.uuid">
<img :src="user.picture.thumbnail" alt="user" />
<p>
{{ user.name.first }}
{{ 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 [
...filenamesSorted.map((filename) =>
files.find(({ fileName }) => fileName === filename)
files.find(({ fileName }) => fileName === 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",
title: "Svelte 5",
frameworkName: "Svelte",
isCurrentVersion: false,
frameworkNameId: "svelte",
isLatestStable: false,
img: "framework/svelte.svg",
eslint: {
files: ["**/TODO-THIS-IS-DISABLED-svelte5/*.svelte"],
parser: "svelte-eslint-parser",
},
playgroundURL: "https://svelte-5-preview.vercel.app/",
documentationURL: "https://svelte-5-preview.vercel.app/docs",
playgroundURL: "https://svelte.dev/playground",
documentationURL: "https://svelte.dev",
filesSorter(files) {
return sortAllFilenames(files, ["index.html", "app.js", "App.svelte"]);
},
repositoryLink: "https://github.com/sveltejs/svelte",
mainPackageName: "svelte",
releaseDate: "2024-10-01",
},
{
id: "react",
title: "React",
frameworkName: "React",
isCurrentVersion: true,
frameworkNameId: "react",
isLatestStable: true,
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",
documentationURL: "https://reactjs.org/docs/getting-started.html",
documentationURL: "https://reactjs.org",
filesSorter(files) {
return sortAllFilenames(files, ["index.html", "main.jsx", "App.jsx"]);
},
repositoryLink: "https://github.com/facebook/react",
mainPackageName: "react",
releaseDate: "2013-05-29",
},
{
id: "vue3",
title: "Vue 3",
frameworkName: "Vue",
isCurrentVersion: true,
frameworkNameId: "vue",
isLatestStable: true,
img: "framework/vue.svg",
eslint: {
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",
playgroundURL: "https://play.vuejs.org/",
documentationURL: "https://vuejs.org/guide",
filesSorter(files) {
return sortAllFilenames(files, ["index.html", "main.js", "App.vue"]);
},
repositoryLink: "https://github.com/vuejs/core",
mainPackageName: "vue",
releaseDate: "2020-09-18",
},
{
id: "angularRenaissance",
title: "Angular Renaissance",
frameworkName: "Angular",
isCurrentVersion: true,
frameworkNameId: "angular",
isLatestStable: true,
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",
documentationURL: "https://angular.io/docs",
filesSorter(files) {
@@ -135,51 +96,15 @@ const frameworks = [
},
repositoryLink: "https://github.com/angular/angular",
mainPackageName: "@angular/core",
releaseDate: "2024-11-01",
},
{
id: "angular",
title: "Angular",
frameworkName: "Angular",
isCurrentVersion: false,
frameworkNameId: "angular",
isLatestStable: false,
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",
documentationURL: "https://angular.io/docs",
filesSorter(files) {
@@ -192,19 +117,15 @@ const frameworks = [
},
repositoryLink: "https://github.com/angular/angular",
mainPackageName: "@angular/core",
releaseDate: "2010-10-20",
},
{
id: "lit",
title: "Lit",
frameworkName: "Lit",
isCurrentVersion: true,
frameworkNameId: "lit",
isLatestStable: true,
img: "framework/lit.svg",
eslint: {
files: ["**/lit/**"],
plugins: ["lit"],
parser: "@babel/eslint-parser",
extends: ["plugin:lit/recommended"],
},
playgroundURL: "https://lit.dev/playground",
documentationURL: "https://lit.dev",
filesSorter(files) {
@@ -212,19 +133,15 @@ const frameworks = [
},
repositoryLink: "https://github.com/lit/lit",
mainPackageName: "lit",
releaseDate: "2021-05-27",
},
{
id: "emberOctane",
title: "Ember Octane",
frameworkName: "Ember",
isCurrentVersion: true,
frameworkNameId: "ember",
isLatestStable: true,
img: "framework/ember.svg",
eslint: {
files: ["**/emberOctane/**"],
plugins: ["ember"],
parser: "@babel/eslint-parser",
extends: ["plugin:ember/recommended"],
},
playgroundURL: "https://ember-twiddle.com",
documentationURL: "https://emberjs.com",
filesSorter(files) {
@@ -232,18 +149,15 @@ const frameworks = [
},
repositoryLink: "https://github.com/emberjs/ember.js",
mainPackageName: "ember-source",
releaseDate: "2019-12-01",
},
{
id: "solid",
title: "Solid.js",
frameworkName: "Solid",
isCurrentVersion: true,
frameworkNameId: "solid",
isLatestStable: true,
img: "framework/solid.svg",
eslint: {
files: ["**/solid/*.jsx"],
plugins: ["solid"],
extends: ["eslint:recommended", "plugin:solid/recommended"],
},
playgroundURL: "https://playground.solidjs.com/",
documentationURL: "https://www.solidjs.com/",
filesSorter(files) {
@@ -251,17 +165,15 @@ const frameworks = [
},
repositoryLink: "https://github.com/solidjs/solid",
mainPackageName: "solid-js",
releaseDate: "2021-06-28",
},
{
id: "svelte4",
title: "Svelte 4",
frameworkName: "Svelte",
isCurrentVersion: true,
frameworkNameId: "svelte",
isLatestStable: true,
img: "framework/svelte.svg",
eslint: {
files: ["**/svelte4/*.svelte"],
parser: "svelte-eslint-parser",
},
playgroundURL: "https://svelte.dev/repl",
documentationURL: "https://svelte.dev/",
filesSorter(files) {
@@ -269,21 +181,15 @@ const frameworks = [
},
repositoryLink: "https://github.com/sveltejs/svelte",
mainPackageName: "svelte",
releaseDate: "2023-06-01",
},
{
id: "vue2",
title: "Vue 2",
frameworkName: "Vue",
isCurrentVersion: false,
frameworkNameId: "vue",
isLatestStable: false,
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: "",
documentationURL: "https://v2.vuejs.org",
filesSorter(files) {
@@ -291,17 +197,15 @@ const frameworks = [
},
repositoryLink: "https://github.com/vuejs/vue",
mainPackageName: "vue@^2",
releaseDate: "2016-09-30",
},
{
id: "alpine",
title: "Alpine",
frameworkName: "Alpine",
isCurrentVersion: true,
frameworkNameId: "alpine",
isLatestStable: true,
img: "framework/alpine.svg",
eslint: {
files: ["**/alpine/**"],
extends: ["eslint:recommended"],
},
playgroundURL: "https://codesandbox.io/s/7br3q8",
documentationURL: "https://alpinejs.dev/start-here",
filesSorter(files) {
@@ -309,23 +213,15 @@ const frameworks = [
},
repositoryLink: "https://github.com/alpinejs/alpine",
mainPackageName: "alpinejs",
releaseDate: "2019-11-06",
},
{
id: "emberPolaris",
title: "Ember Polaris",
frameworkName: "Ember",
isCurrentVersion: false,
frameworkNameId: "ember",
isLatestStable: false,
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",
documentationURL: "https://emberjs.com",
filesSorter(files) {
@@ -333,22 +229,15 @@ const frameworks = [
},
repositoryLink: "https://github.com/emberjs/ember.js",
mainPackageName: "ember-source",
releaseDate: "2024-12-01",
},
{
id: "mithril",
title: "Mithril",
frameworkName: "Mithril",
isCurrentVersion: true,
frameworkNameId: "mithril",
isLatestStable: true,
img: "framework/mithril.svg",
eslint: {
env: {
browser: true,
es2021: true,
node: true,
},
files: ["**/mithril/**"],
extends: ["eslint:recommended"],
},
playgroundURL: "https://codesandbox.io/s/q99qzov66",
documentationURL: "https://mithril.js.org/",
filesSorter(files) {
@@ -356,28 +245,15 @@ const frameworks = [
},
repositoryLink: "https://github.com/MithrilJS/mithril.js",
mainPackageName: "mithril",
releaseDate: "2014-03-07",
},
{
id: "aurelia2",
title: "Aurelia 2",
frameworkName: "Aurelia",
isCurrentVersion: true,
frameworkNameId: "aurelia",
isLatestStable: true,
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:
"https://stackblitz.com/edit/au2-conventions?file=src%2Fmy-app.html",
documentationURL: "http://docs.aurelia.io",
@@ -391,31 +267,15 @@ const frameworks = [
},
repositoryLink: "https://github.com/aurelia/aurelia",
mainPackageName: "aurelia",
releaseDate: "2021-01-19",
},
{
id: "qwik",
title: "Qwik",
frameworkName: "Qwik",
isCurrentVersion: true,
frameworkNameId: "qwik",
isLatestStable: true,
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",
documentationURL: "https://qwik.builder.io/docs/overview",
filesSorter(files) {
@@ -423,16 +283,15 @@ const frameworks = [
},
repositoryLink: "https://github.com/BuilderIO/qwik",
mainPackageName: "@builder.io/qwik",
releaseDate: "2022-09-23",
},
{
id: "marko",
title: "Marko",
frameworkName: "Marko",
isCurrentVersion: true,
frameworkNameId: "marko",
isLatestStable: true,
img: "framework/marko.svg",
eslint: {
files: ["!**"], // Markos linter/prettyprinter doesnt use eslint
},
playgroundURL: "https://markojs.com/playground/",
documentationURL: "https://markojs.com/docs/getting-started/",
filesSorter(files) {
@@ -440,28 +299,15 @@ const frameworks = [
},
repositoryLink: "https://github.com/marko-js/marko",
mainPackageName: "marko",
releaseDate: "2014-04-09",
},
{
id: "aurelia1",
title: "Aurelia 1",
frameworkName: "Aurelia",
isCurrentVersion: false,
frameworkNameId: "aurelia",
isLatestStable: false,
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",
documentationURL: "http://aurelia.io/docs/",
filesSorter(files) {
@@ -474,16 +320,15 @@ const frameworks = [
},
repositoryLink: "https://github.com/aurelia/framework",
mainPackageName: "aurelia-framework",
releaseDate: "2016-01-26",
},
{
id: "ripple",
title: "Ripple",
frameworkName: "Ripple",
isCurrentVersion: true,
frameworkNameId: "ripple",
isLatestStable: true,
img: "framework/ripple.svg",
eslint: {
files: ["!**"],
},
playgroundURL: "https://www.ripplejs.com/playground",
documentationURL: "https://www.ripplejs.com/",
filesSorter(files) {
@@ -491,15 +336,71 @@ const frameworks = [
},
repositoryLink: "https://github.com/trueadm/ripple",
mainPackageName: "ripple",
releaseDate: "2023-01-01",
},
];
export function matchFrameworkId(id) {
return frameworks.find(
(framework) => framework.id === id
// ||(framework.isCurrentVersion &&
// framework.frameworkName.toLowerCase() === id)
export function matchFrameworkId(id: string): Framework | undefined {
// First try to find by exact ID
let framework = frameworks.find((f) => f.id === 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>
<html lang="en">
<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>
<link rel="icon" href="/favicon.png" />
<link rel="manifest" href="/manifest.json" />
<link rel="canonical" href="<%= it.url %>" />
<link
rel="preload"
href="/font/Mona-Sans.woff2"
@@ -12,9 +15,34 @@
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 -->
<meta name="title" content="<%= it.title %>" />
<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 -->
<meta property="og:type" content="website" />
@@ -22,6 +50,14 @@
<meta property="og:title" content="<%= it.title %>" />
<meta property="og:description" content="<%= it.description %>" />
<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 -->
<meta property="twitter:card" content="summary_large_image" />
@@ -29,6 +65,101 @@
<meta property="twitter:title" content="<%= it.title %>" />
<meta property="twitter:description" content="<%= it.description %>" />
<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>
@font-face {
font-family: "Mona Sans";
@@ -58,10 +189,5 @@
<div id="app" class="min-h-screen"></div>
<!--template:footer-->
<script type="module" src="/src/main.js"></script>
<!-- 100% privacy-first analytics -->
<script
async
src="https://scripts.simpleanalyticscdn.com/latest.js"
></script>
</body>
</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,
"version": "2.0.0",
"type": "module",
"packageManager": "pnpm@10.6.5",
"packageManager": "pnpm@10.14.0",
"repository": "github:matschik/component-party.dev",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint . --fix",
"lint:check": "eslint .",
"format": "prettier --cache --ignore-path .gitignore --plugin-search-dir=. --write .",
"format:check": "prettier --ignore-path .gitignore --plugin-search-dir=. . --check",
"build:content": "node scripts/generateContent.js",
"build:progress": "node scripts/generateReadMeProgress.js",
"prepare": "husky",
"cy:open-e2e": "cypress open --e2e --browser chrome",
"cy:open-unit": "cypress open --component --browser chrome",
"cy:run-e2e": "cypress run --e2e",
"cy:e2e": "start-server-and-test dev http-get://localhost:5173 cy:open-e2e"
"_format": "prettier --cache --cache-location .cache/prettier",
"format": "pnpm _format --write .",
"lint": "oxlint && eslint",
"check": "pnpm format && svelte-check --tsconfig ./tsconfig.json && pnpm lint",
"check:ci": "svelte-check --tsconfig ./tsconfig.json && pnpm lint",
"check:watch": "svelte-check --tsconfig ./tsconfig.json --watch",
"build:content": "node scripts/generateContent.ts --no-cache",
"build:progress": "node scripts/generateReadMeProgress.ts",
"build:sitemap": "node scripts/generateSitemap.ts",
"prepare": "lefthook install",
"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": {
"@iconify-json/heroicons": "^1.2.2",
"classnames": "^2.5.1",
"radix3": "^1.1.2"
"@iconify-json/heroicons": "^1.2.3",
"just-throttle": "^4.2.0",
"lz-string": "^1.5.0",
"runed": "^0.32.0",
"sv-router": "^0.10.0"
},
"devDependencies": {
"@angular-eslint/eslint-plugin": "^19.3.0",
"@angular-eslint/eslint-plugin-template": "^19.3.0",
"@angular-eslint/template-parser": "^19.3.0",
"@angular/core": "^19.2.7",
"@aurelia/router": "2.0.0-beta.23",
"@babel/core": "^7.26.10",
"@babel/eslint-parser": "^7.27.0",
"@babel/plugin-proposal-decorators": "^7.25.9",
"@builder.io/qwik": "^1.13.0",
"@lit/context": "^1.1.5",
"@matschik/lz-string": "^0.0.2",
"@shikijs/markdown-it": "^3.3.0",
"@sveltejs/vite-plugin-svelte": "5.0.3",
"@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",
"@eslint/compat": "^1.3.2",
"@eslint/js": "^9.36.0",
"eslint": "^9.36.0",
"@iconify/json": "^2.2.386",
"@iconify/tailwind4": "^1.0.6",
"@playwright/test": "^1.55.0",
"@shikijs/markdown-it": "^3.13.0",
"@sveltejs/vite-plugin-svelte": "6.2.0",
"@sveltejs/vite-plugin-svelte-inspector": "5.0.1",
"@tailwindcss/vite": "^4.1.13",
"@types/folder-hash": "^4.0.4",
"@types/html-minifier-terser": "^7.0.2",
"@types/markdown-it": "^14.1.2",
"autoprefixer": "^10.4.21",
"chokidar": "^4.0.3",
"codesandbox": "^2.2.3",
"cypress": "^14.3.2",
"ember-eslint-parser": "^0.5.9",
"eslint": "^9.25.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",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-oxlint": "^1.16.0",
"eslint-plugin-svelte": "^3.12.4",
"eta": "^4.0.1",
"folder-hash": "^4.1.1",
"globals": "^16.4.0",
"html-minifier-terser": "^7.2.0",
"husky": "^9.1.7",
"lint-staged": "^15.5.1",
"lodash.kebabcase": "^4.1.1",
"just-kebab-case": "^4.2.0",
"lefthook": "^1.13.1",
"markdown-it": "^14.1.0",
"micache": "^2.4.1",
"pkg-dir": "^8.0.0",
"prettier": "^3.5.3",
"prettier-plugin-svelte": "^3.3.3",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"shiki": "^3.3.0",
"solid-js": "^1.9.5",
"start-server-and-test": "^2.0.11",
"svelte": "5.28.2",
"oxlint": "^1.16.0",
"package-directory": "^8.1.0",
"prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0",
"shiki": "^3.13.0",
"svelte": "5.39.3",
"svelte-check": "^4.3.1",
"tailwindcss": "^4.1.13",
"tslib": "^2.8.1",
"typescript": "^5.8.3",
"unocss": "66.1.0-beta.12",
"vite": "^6.3.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"
"typescript": "^5.9.2",
"typescript-eslint": "^8.44.0",
"vite": "^7.1.6",
"vite-plugin-html": "^3.2.2"
},
"pnpm": {
"onlyBuiltDependencies": [
"cypress",
"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: *
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 { packageDirectory } from "pkg-dir";
import { packageDirectory } from "package-directory";
import path from "node:path";
import kebabCase from "lodash.kebabcase";
import FRAMEWORKS from "../frameworks.mjs";
import { frameworks } from "../frameworks.ts";
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 readmeContent = await fs.readFile("README.md", "utf8");
@@ -14,12 +39,12 @@ async function main() {
const MARKER_START = "<!-- progression start -->";
const MARKER_END = "<!-- progression end -->";
const progressionContentRegex = new RegExp(
`${MARKER_START}([\\s\\S]*?)${MARKER_END}`
`${MARKER_START}([\\s\\S]*?)${MARKER_END}`,
);
const newReadmeContent = readmeContent.replace(
progressionContentRegex,
`${MARKER_START}\n${progressionContent}\n${MARKER_END}`
`${MARKER_START}\n${progressionContent}\n${MARKER_END}`,
);
await fs.writeFile("README.md", newReadmeContent);
@@ -27,35 +52,38 @@ async function main() {
main().catch(console.error);
async function parseContentDir() {
async function parseContentDir(): Promise<Section[]> {
const rootDir = await packageDirectory();
if (!rootDir) {
throw new Error("Could not find package directory");
}
const contentPath = path.join(rootDir, "content");
const rootDirs = await fs.readdir(contentPath);
function dirNameToTitle(dirName) {
function dirNameToTitle(dirName: string): string {
return capitalize(dirName.split("-").slice(1).join(" "));
}
function capitalize(str) {
function capitalize(str: string): string {
return str.charAt(0).toUpperCase() + str.slice(1);
}
const sections = [];
const sections: Section[] = [];
for (const rootDirName of rootDirs) {
const rootSectionPath = path.join(contentPath, rootDirName);
const subDirs = await fs.readdir(rootSectionPath).catch(() => []);
const children = [];
const children: SubSection[] = [];
for (const subDir of subDirs) {
const subDirPath = path.join(rootSectionPath, subDir);
const frameworks = await fs.readdir(subDirPath).catch(() => []);
const frameworkChildren = [];
const frameworkChildren: FrameworkChild[] = [];
for (const fw of frameworks) {
const fwPath = path.join(subDirPath, fw);
const fileNames = await fs.readdir(fwPath).catch(() => []);
const files = fileNames.map((fileName) => ({
const files: File[] = fileNames.map((fileName) => ({
path: path.join(fwPath, fileName),
fileName,
ext: path.extname(fileName).slice(1),
@@ -81,37 +109,39 @@ async function parseContentDir() {
return sections;
}
function mdCheck(b) {
function mdCheck(b: boolean): string {
return b ? "x" : " ";
}
async function generateProgressionMarkdown(contentTree) {
async function generateProgressionMarkdown(
contentTree: Section[],
): Promise<string> {
let output = "";
for (const framework of FRAMEWORKS) {
const frameworkLines = [];
const allChecks = [];
for (const framework of frameworks) {
const frameworkLines: string[] = [];
const allChecks: boolean[] = [];
for (const root of contentTree) {
const sectionChecks = [];
const subLines = [];
const sectionChecks: boolean[] = [];
const subLines: string[] = [];
for (const sub of root.children) {
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);
subLines.push(` * [${mdCheck(hasFiles)}] ${sub.title}`);
}
frameworkLines.push(
`* [${mdCheck(sectionChecks.every(Boolean))}] ${root.title}`
`* [${mdCheck(sectionChecks.every(Boolean))}] ${root.title}`,
);
frameworkLines.push(...subLines);
allChecks.push(...sectionChecks);
}
const percent = Math.ceil(
(allChecks.filter(Boolean).length / allChecks.length) * 100
(allChecks.filter(Boolean).length / allChecks.length) * 100,
);
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);
async function getPackageDownloads(packageName) {
interface PackageDownloadStats {
packageName: string;
downloads: number;
}
async function getPackageDownloads(
packageName: string,
): Promise<number | null> {
try {
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();
return data.downloads;
} catch (error) {
console.error(
`Failed to fetch download stats for package ${packageName}: `,
error
error,
);
return null;
}
}
async function sortPackagesByDownloads(packages) {
async function sortPackagesByDownloads(
packages: string[],
): Promise<PackageDownloadStats[] | null> {
try {
const downloadStats = await Promise.all(packages.map(getPackageDownloads));

View File

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

View File

@@ -1,186 +1,141 @@
<script>
<script lang="ts">
import { SvelteMap, SvelteSet } from "svelte/reactivity";
import c from "classnames";
import FRAMEWORKS, { matchFrameworkId } from "../frameworks.mjs";
import { frameworks, matchFrameworkId } from "@frameworks";
import FrameworkLabel from "./components/FrameworkLabel.svelte";
import { sections, snippets } from "./generatedContent/tree.js";
import snippetsImporterByFrameworkId from "./generatedContent/framework/index.js";
import CodeEditor from "./components/CodeEditor.svelte";
import AppNotificationCenter from "./components/AppNotificationCenter.svelte";
import createLocaleStorage from "./lib/createLocaleStorage.js";
import { getContext, onDestroy, onMount } from "svelte";
import createLocaleStorage from "./lib/createLocaleStorage.ts";
import { watch } from "runed";
import Header from "./components/Header.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");
const frameworkIdsStorage = createLocaleStorage("framework_display");
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);
interface File {
fileName: string;
contentHtml: string;
[key: string]: unknown;
}
const FRAMEWORK_IDS_FROM_URL_KEY = "f";
const SITE_TITLE = "Component Party";
const MAX_FRAMEWORK_NB_INITIAL_DISPLAYED = 9;
const FRAMEWORKS_BONUS = FRAMEWORKS.slice(MAX_FRAMEWORK_NB_INITIAL_DISPLAYED);
interface FrameworkSnippet {
frameworkId: string;
snippetId: string;
files: File[];
playgroundURL: string;
markdownFiles: File[];
snippetEditHref: string;
}
let frameworkIdsSelected = $state(new SvelteSet());
let snippetsByFrameworkId = $state(new SvelteMap());
const MAX_FRAMEWORK_NOBONUS = 9;
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 isVersusFrameworks = $state(false);
let onMountCallbacks = $state(new SvelteSet());
let isMounted = $state(false);
function handleVersus(versus) {
const fids = versus.split("-vs-");
if (fids.length !== 2) {
return;
}
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("/");
const isVersusFrameworks = $derived(frameworksSelected.length === 2);
const siteTitle = $derived(
isVersusFrameworks
? `${frameworksSelected.map((f) => f!.title).join(" vs ")} - Component Party`
: "Component Party",
);
const frameworkIdsFromSearchParam = $derived.by(() => {
const value = searchParams.get(FRAMEWORK_IDS_FROM_URL_KEY);
if (typeof value === "string") {
return value.split(FRAMEWORK_SEPARATOR).filter(matchFrameworkId);
}
return [];
});
onDestroy(unsubscribeCurrentRoute);
// handle on link click
watch(
[() => frameworkIdsFromSearchParam],
() => {
selectFrameworks(frameworkIdsFromSearchParam);
},
{ lazy: true },
);
function handleInitialFrameworkIdsSelectedFromStorage({ useDefaults }) {
if (frameworkIdsSelectedInitialized) {
return;
function selectFrameworks(frameworkIds: string[]) {
frameworkIdsSelected.clear();
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(
FRAMEWORK_IDS_FROM_URL_KEY
);
if (frameworkIdsFromURLStr) {
const frameworkIdsFromURL = frameworkIdsFromURLStr
.split(",")
.filter(matchFrameworkId);
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);
// From search param
if (frameworkIdsFromSearchParam.length > 0) {
selectFrameworks(frameworkIdsFromSearchParam);
} else if (frameworkIdsFromStorage.length > 0) {
selectFrameworks(frameworkIdsFromStorage);
} else {
// Default frameworks
selectFrameworks(DEFAULT_FRAMEWORKS);
}
frameworkIdsSelectedInitialized = true;
}
onMount(() => {
isMounted = true;
for (const callback of onMountCallbacks) {
callback();
}
onMountCallbacks.clear();
});
onInit();
function saveFrameworkIdsSelectedOnStorage() {
frameworkIdsStorage.setJSON([...frameworkIdsSelected]);
removeSearchParamKeyFromURL(FRAMEWORK_IDS_FROM_URL_KEY);
}
function toggleFrameworkId(frameworkId) {
function toggleFrameworkId(frameworkId: string) {
if (frameworkIdsSelected.has(frameworkId)) {
frameworkIdsSelected.delete(frameworkId);
} else {
frameworkIdsSelected.add(frameworkId);
}
frameworkIdsSelected = frameworkIdsSelected;
saveFrameworkIdsSelectedOnStorage();
navigateWithFrameworkSelection();
}
let snippetsByFrameworkIdLoading = $state(new SvelteSet());
let snippetsByFrameworkIdError = $state(new SvelteSet());
async function navigateWithFrameworkSelection() {
if (frameworkIdsSelected.size === 0) {
searchParams.delete(FRAMEWORK_IDS_FROM_URL_KEY);
} else {
searchParams.set(
FRAMEWORK_IDS_FROM_URL_KEY,
frameworkIdsSelectedArr.join(FRAMEWORK_SEPARATOR),
);
}
}
$effect(() => {
[...frameworkIdsSelected].map((frameworkId) => {
let snippetsByFrameworkIdLoading = new SvelteSet<string>();
let snippetsByFrameworkIdError = new SvelteSet<string>();
watch([() => frameworkIdsSelected.entries()], () => {
for (const frameworkId of frameworkIdsSelectedArr) {
if (!snippetsByFrameworkId.has(frameworkId)) {
snippetsByFrameworkIdError.delete(frameworkId);
snippetsByFrameworkIdLoading.add(frameworkId);
snippetsImporterByFrameworkId[frameworkId]()
.then(({ default: frameworkSnippets }) => {
snippetsByFrameworkId.set(frameworkId, frameworkSnippets);
})
.then(
({
default: frameworkSnippets,
}: {
default: FrameworkSnippet[];
}) => {
snippetsByFrameworkId.set(frameworkId, frameworkSnippets);
},
)
.catch(() => {
snippetsByFrameworkIdError.add(frameworkId);
})
@@ -188,87 +143,137 @@
snippetsByFrameworkIdLoading.delete(frameworkId);
});
}
});
}
if (frameworkIdsSelected.size === 0) {
navigate("/");
}
});
let showBonusFrameworks = $state(false);
const frameworksSelected = $derived(
[...frameworkIdsSelected].map(matchFrameworkId)
);
const bonusFrameworks = $derived(
FRAMEWORKS_BONUS.filter((f) => !frameworkIdsSelected.has(f.id))
FRAMEWORKS_BONUS.filter(({ id }) => !frameworkIdsSelected.has(id)),
);
const frameworksNotSelected = $derived(
FRAMEWORKS.filter((f) => !frameworkIdsSelected.has(f.id))
frameworks.filter(({ id }) => id && !frameworkIdsSelected.has(id)),
);
const headerFrameworks = $derived([
...frameworksSelected,
...frameworksNotSelected.filter(
(f) => !bonusFrameworks.find((bf) => bf.id === f.id)
),
...(showBonusFrameworks ? bonusFrameworks : []),
]);
const headerFrameworks = $derived(
[
...frameworksSelected.filter((f) => f),
...frameworksNotSelected.filter(
(f) => f && !bonusFrameworks.find((bf) => bf.id === f.id),
),
...(showBonusFrameworks ? bonusFrameworks : []),
].filter((f): f is NonNullable<typeof f> => !!f),
);
</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">
<Aside />
<div class="pb-8 w-10 grow">
<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"
data-framework-id-selected-list={[...frameworkIdsSelected].join(",")}
data-framework-id-selected-list={frameworkIdsSelectedArr.join(",")}
data-testid="framework-selection-bar"
>
{#each headerFrameworks as framework}
<button
title={frameworkIdsSelected.has(framework.id)
? `Hide ${framework.title}`
: `Display ${framework.title}`}
class={c(
"text-sm flex-shrink-0 rounded border px-3 py-1 bg-gray-900 hover:bg-gray-800 transition-all mr-2",
frameworkIdsSelected.has(framework.id)
? "border-blue-900"
: "opacity-70 border-opacity-50 border-gray-700"
)}
onclick={() => {
toggleFrameworkId(framework.id);
if (isVersusFrameworks && $currentRoute.path !== "/") {
navigate("/");
}
}}
>
<FrameworkLabel id={framework.id} size={15} />
</button>
{#each headerFrameworks as framework (framework.id)}
{#if framework}
<button
title={frameworkIdsSelected.has(framework.id)
? `Hide ${framework.title}`
: `Display ${framework.title}`}
class={[
"text-sm flex-shrink-0 rounded border px-3 py-1 bg-gray-900 hover:bg-gray-800 transition-all mr-2",
frameworkIdsSelected.has(framework.id)
? "border-blue-900"
: "opacity-70 border-opacity-50 border-gray-700",
]}
data-testid={`framework-button-${framework.id}`}
onclick={() => {
toggleFrameworkId(framework.id);
if (frameworkIdsSelectedArr.length === 0) {
frameworkIdsStorage.remove();
} else {
frameworkIdsStorage.setJSON(frameworkIdsSelectedArr);
}
}}
>
<FrameworkLabel id={framework.id} size={16} />
</button>
{/if}
{/each}
{#if bonusFrameworks.length > 0 && !showBonusFrameworks}
<button
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={() => {
showBonusFrameworks = !showBonusFrameworks;
}}
aria-label="show more frameworks"
>
<svg
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>
<span class="iconify ph--dots-three size-4" aria-hidden="true"></span>
</button>
{/if}
</div>
@@ -276,29 +281,49 @@
<main class="relative pt-6">
<div>
{#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="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 class="flex justify-center">
<h1 id="empty-state-heading" class="sr-only">
Select Frameworks to Compare
</h1>
<p
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>
Please select a framework to view framework's snippets
</span>
<img src="/popper.svg" alt="logo" class="size-6" />
<img
src="/popper.svg"
alt="Component Party logo"
class="size-6"
/>
</p>
</div>
</div>
</section>
{:else}
<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">
<h1
<h2
id={section.sectionId}
class="header-anchor text-2xl font-bold"
data-testid={`section-${section.sectionId}`}
>
{section.title}
<a
@@ -308,15 +333,20 @@
>
#
</a>
</h1>
</h2>
<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 =
section.sectionId + "." + snippet.snippetId}
<div id={snippetPathId} data-snippet-id={snippetPathId}>
<h2
<div
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"
data-testid={`snippet-title-${snippetPathId}`}
>
{snippet.title}
<a
@@ -326,145 +356,170 @@
>
#
</a>
</h2>
</h3>
{#if frameworkIdsSelectedInitialized}
<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 frameworkSnippet = snippetsByFrameworkId
.get(frameworkId)
?.find((s) => s.snippetId === snippet.snippetId)}
?.find(
(s: FrameworkSnippet) =>
s.snippetId === snippet.snippetId,
)}
{@const frameworkSnippetIsLoading =
snippetsByFrameworkIdLoading.has(frameworkId)}
{@const frameworkSnippetIsError =
snippetsByFrameworkIdError.has(frameworkId)}
<div>
{#if framework}
<div
class="flex justify-between items-center space-x-3"
data-testid={`framework-snippet-${frameworkId}-${snippet.snippetId}`}
>
<h3
style="margin-top: 0rem; margin-bottom: 0rem;"
<div
class="flex justify-between items-center space-x-3"
>
<FrameworkLabel id={framework.id} />
</h3>
{#if frameworkSnippet}
<div class="flex items-center space-x-3">
{#if frameworkSnippet.playgroundURL}
<a
href={frameworkSnippet.playgroundURL}
target="_blank"
rel="noreferrer"
aria-label={`Open playground for ${framework.title}`}
>
<button
<h3
class="m-0"
data-testid={`framework-title-${frameworkId}-${snippet.snippetId}`}
>
<FrameworkLabel id={framework.id} />
</h3>
{#if frameworkSnippet}
<div class="flex items-center space-x-3">
{#if frameworkSnippet.playgroundURL}
<a
href={frameworkSnippet.playgroundURL}
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"
title={`Open playground for ${framework.title}`}
aria-label={`Open playground for ${framework.title}`}
tabindex="-1"
data-testid={`playground-button-${frameworkId}-${snippet.snippetId}`}
>
<div
class="i-heroicons:play size-4"
></div>
</button>
</a>
{/if}
</div>
{/if}
</div>
<div class="mt-2">
{#if frameworkSnippet}
{#if frameworkSnippet.files.length > 0}
<CodeEditor
files={frameworkSnippet.files}
snippetEditHref={frameworkSnippet.snippetEditHref}
/>
{:else}
<div
class="bg-gray-800 text-white rounded-md mx-auto"
>
<span
class="iconify ph--play size-4"
aria-hidden="true"
></span>
</a>
{/if}
</div>
{/if}
</div>
<div class="mt-2">
{#if frameworkSnippet}
{#if frameworkSnippet.files.length > 0}
<CodeEditor
files={frameworkSnippet.files}
snippetEditHref={frameworkSnippet.snippetEditHref}
data-testid={`code-editor-${frameworkId}-${snippet.snippetId}`}
/>
{:else}
<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>
<span
class="block text-2xl tracking-tight font-bold"
>
Missing snippet
</span>
<span
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}
<div
class="text-center py-8 px-4 sm:px-6"
>
<div>
<span
class="block text-2xl tracking-tight font-bold"
data-testid="missing-snippet-title"
>
<button
class="flex items-center space-x-3"
Missing snippet
</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" />
</button>
</a>
<span
>Contribute on Github</span
>
<span
class="iconify simple-icons--github size-5"
aria-hidden="true"
></span>
</button>
</a>
</div>
</div>
</div>
</div>
</div>
{/if}
{:else if frameworkSnippetIsLoading}
<div role="status">
{/if}
{:else if frameworkSnippetIsLoading}
<div
class="w-75px h-23px bg-[#0d1117] py-3 px-4 rounded-t"
role="status"
data-testid={`loading-snippet-${frameworkId}-${snippet.snippetId}`}
>
<div
class="h-2.5 rounded-full bg-gray-700 w-10 animate-pulse"
></div>
</div>
<div
class="w-full h-164px bg-[#0d1117] px-4 py-7"
>
<div class="max-w-sm animate-pulse">
class="w-75px h-23px bg-[#0d1117] py-3 px-4 rounded-t"
>
<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
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">Loading...</span>
</div>
<div
class="w-full h-164px bg-[#0d1117] px-4 py-7"
>
<div class="max-w-sm animate-pulse">
<div
class="h-3.5 rounded-full bg-gray-700 w-48 mb-4"
></div>
<div
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>
{:else if frameworkSnippetIsError}
<p class="text-orange-500">
Error loading snippets. Please reload the
page.
</p>
{/if}
{:else if frameworkSnippetIsError}
<p
class="text-orange-500"
data-testid={`error-snippet-${frameworkId}-${snippet.snippetId}`}
>
Error loading snippets. Please reload the
page.
</p>
{/if}
</div>
</div>
</div>
{/if}
{/each}
</div>
{/if}
@@ -494,6 +549,6 @@
}
.header-anchor:hover > a {
opacity: 100;
opacity: 1;
}
</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)) {
[class*="bg-"].backdrop-blur {
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>
import c from "classnames";
<script lang="ts">
import { sections, snippets } from "../generatedContent/tree.js";
import { onMount, onDestroy } from "svelte";
import throttle from "../lib/throttle.js";
import { onMount } from "svelte";
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 largestElement = null;
let largestElement: Element | null = null;
let firstFullyVisibleElement: Element | null = null;
for (const element of elements) {
const rect = element.getBoundingClientRect();
@@ -20,6 +20,19 @@
if (visibleWidth > 0 && visibleHeight > 0) {
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) {
largestArea = area;
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() {
const largestSnippet = getLargestElement(
document.querySelectorAll("[data-snippet-id]")
document.querySelectorAll("[data-snippet-id]"),
);
if (largestSnippet) {
largestVisibleSnippetId = largestSnippet.dataset.snippetId;
largestVisibleSnippetId =
largestSnippet.getAttribute("data-snippet-id") ?? "";
} else {
largestVisibleSnippetId = null;
largestVisibleSnippetId = "";
}
}
@@ -49,55 +74,45 @@
return () => {
document.removeEventListener("scroll", throttleOnScroll);
};
}
let unlistenLargestSnippetOnScroll;
onMount(() => {
unlistenLargestSnippetOnScroll = listenLargestSnippetOnScroll();
});
onDestroy(() => {
unlistenLargestSnippetOnScroll && unlistenLargestSnippetOnScroll();
});
</script>
<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">
<ul class="space-y-6">
{#each sections as section}
{#each sections as section (section.sectionId)}
<li>
<a
href={`#${section.sectionId}`}
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",
<button
class={[
"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",
{
"bg-gray-800":
largestVisibleSnippetId &&
largestVisibleSnippetId.startsWith(section.sectionId),
}
)}
},
]}
onclick={() => scrollToElement(section.sectionId)}
>
{section.title}
</a>
</button>
<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 =
section.sectionId + "." + snippet.snippetId}
<li>
<a
href={`#${snippetPathId}`}
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",
<button
class={[
"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",
snippetPathId === largestVisibleSnippetId
? "bg-gray-800 opacity-70"
: "opacity-50"
)}
: "opacity-50",
]}
onclick={() => scrollToElement(snippetPathId)}
>
{snippet.title}
</a>
</button>
</li>
{/each}
</ul>

View File

@@ -1,35 +1,52 @@
<script>
import c from "classnames";
import { notifications } from "./NotificationCenter.svelte";
import copyToClipboard from "../lib/copyToClipboard.js";
<script lang="ts">
import copyToClipboard from "../lib/copyToClipboard.ts";
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(
filenameSelected && files.find((s) => s.fileName === filenameSelected)
let codeSnippetEl: HTMLElement | undefined = $state();
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) {
copyToClipboard(codeSnippetEl.innerText);
notifications.show({
title: "Snippet copied to clipboard",
});
}
}
</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)}
<button
class={c(
"bg-[#0d1117] py-1.5 px-3 flex-shrink-0 text-xs rounded-t inline-block",
filenameSelected !== file.fileName && "opacity-60"
)}
class={[
"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",
]}
onclick={() => {
filenameSelected = file.fileName;
}}
@@ -42,9 +59,12 @@
<div class="relative group">
<div
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
class="absolute hidden group-hover:block transition-all top-0 right-0 mt-2 mr-2"
@@ -55,17 +75,17 @@
target="_blank"
rel="noreferrer"
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>
<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"
aria-label="Copy to clipboard"
onclick={copySnippet}
>
<div class="i-heroicons:clipboard-document size-4"></div>
<span class="iconify ph--clipboard size-4" aria-hidden="true"></span>
</button>
</div>
</div>

View File

@@ -1,24 +1,32 @@
<script>
import FRAMEWORKS from "../../frameworks.mjs";
<script lang="ts">
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/";
</script>
<div class="flex items-center space-x-1">
<div class="flex items-center space-x-1.5">
{#if framework?.img}
<img
src={baseURL + framework.img}
width={size}
height={size}
class="inline mr-[5px] mb-0 mt-0"
class="flex-shrink-0"
alt={`logo of ${framework.title}`}
/>
{/if}
<span class="flex-shrink-0">{framework.title}</span>
<span class="flex-shrink-0 inline-block">{framework?.title || id}</span>
</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>
import { onMount } from "svelte";
<script lang="ts">
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 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 isFetchingStarCount = $state(false);
let starCount: string = $state("0");
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) {
starCount = starCountStorageData.value;
if (
@@ -25,35 +40,38 @@
isFetchingStarCount = true;
// Github public API rate limit: 60 requests per hour
const data = await fetch(
`https://api.github.com/repos/${REPOSITORY_PATH}`,
{
headers: {
Accept: "application/vnd.github.v3.star+json",
Authorization: "",
try {
// Using Shields.io JSON endpoint - no rate limits, cached for 5-15 minutes
const data: ShieldsApiResponse = await fetch(
`https://img.shields.io/github/stars/${REPOSITORY_PATH}.json`,
{
headers: {
Accept: "application/json",
},
},
}
)
.finally(() => {
isFetchingStarCount = false;
})
.then((r) => r.json());
)
.finally(() => {
isFetchingStarCount = false;
})
.then((r) => r.json());
if (data.stargazers_count) {
starCount = data.stargazers_count;
starCountStorage.setJSON({
value: starCount,
fetchedAt: Date.now(),
});
if (data.message) {
// Use the formatted string directly from Shields.io (e.g., "3.1k", "500")
starCount = data.message;
starCountStorage.setJSON({
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();
}
</script>
@@ -66,34 +84,21 @@
onclick={onButtonClick}
>
<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>
{#if isFetchingStarCount || starCount !== 0}
{#if isFetchingStarCount || starCount !== "0"}
<div
class="hidden h-full items-center justify-center px-3 sm:flex border-[#373b43] sm:border-l"
>
{#if isFetchingStarCount && starCount === 0}
<svg
class="animate-spin size-4 mx-1"
xmlns="http://www.w3.org/2000/svg"
fill="none"
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>
{#if isFetchingStarCount && starCount === "0"}
<span
class="iconify ph--spinner animate-spin size-4 mx-1"
aria-hidden="true"
></span>
{:else}
<span>{starCount}</span>
{/if}

View File

@@ -1,25 +1,5 @@
<script>
import { notifications } from "./NotificationCenter.svelte";
<script lang="ts">
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>
<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">
<a class="font-semibold text-lg flex items-center space-x-3" href="/">
<img src="/popper.svg" alt="logo" class="size-5" />
<span>Component party</span>
<h1>Component Party</h1>
</a>
<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 />
</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