diff --git a/bun.lock b/bun.lock index b9ae47c..eb943e3 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 1, "workspaces": { "": { "name": "forge", @@ -9,6 +8,9 @@ }, "devDependencies": { "@types/bun": "latest", + "@types/prismjs": "^1.26.5", + "prismjs": "^1.30.0", + "snarkdown": "^2.0.0", }, "peerDependencies": { "typescript": "^5", @@ -20,10 +22,16 @@ "@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="], + "@types/prismjs": ["@types/prismjs@1.26.5", "", {}, "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ=="], + "bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="], "hono": ["hono@4.11.3", "", {}, "sha512-PmQi306+M/ct/m5s66Hrg+adPnkD5jiO6IjA7WhWw0gSBSo1EcRegwuI1deZ+wd5pzCGynCcn2DprnE4/yEV4w=="], + "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="], + + "snarkdown": ["snarkdown@2.0.0", "", {}, "sha512-MgL/7k/AZdXCTJiNgrO7chgDqaB9FGM/1Tvlcenenb7div6obaDATzs16JhFyHHBGodHT3B7RzRc5qk8pFhg3A=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], diff --git a/examples/landing.tsx b/examples/landing.tsx new file mode 100644 index 0000000..7f54513 --- /dev/null +++ b/examples/landing.tsx @@ -0,0 +1,547 @@ +import { createScope, Styles } from '../src' +import { theme } from './ssr/themes' +import markdown from 'snarkdown' +import Prism from 'prismjs' +import 'prismjs/components/prism-javascript' +import 'prismjs/components/prism-jsx' +import 'prismjs/components/prism-typescript' +import 'prismjs/components/prism-tsx' + +const { define } = createScope('Landing') + +const Page = define('Page', { + base: 'body', + + margin: 0, + padding: theme('spacing-xl'), + minHeight: '100vh', + fontFamily: theme('fonts-mono'), + background: theme('colors-bg'), + color: theme('colors-fg'), +}) + +const Container = define('Container', { + maxWidth: 800, + margin: '0 auto', +}) + +const Pre = define('Pre', { + base: 'pre', + + fontSize: 14, + lineHeight: 1.4, + marginBottom: theme('spacing-xl'), + color: theme('colors-fg'), + whiteSpace: 'pre', + borderBottom: '1px solid var(--theme-colors-border)', +}) + +const P = define('P', { + base: 'p', + + fontSize: 16, + lineHeight: 1.6, + marginBottom: theme('spacing-xl'), + color: theme('colors-fgMuted'), +}) + +const LinkSection = define('LinkSection', { + marginBottom: theme('spacing-xl'), +}) + +const Link = define('Link', { + base: 'a', + + display: 'inline-block', + marginRight: theme('spacing-xl'), + padding: `${theme('spacing-sm')} ${theme('spacing-lg')}`, + background: theme('colors-bgElevated'), + border: `1px solid ${theme('colors-border')}`, + color: theme('colors-fg'), + textDecoration: 'none', + fontSize: 14, + + states: { + ':hover': { + background: theme('colors-bgHover'), + borderColor: theme('colors-borderActive'), + } + } +}) + +const ThemeToggle = define('ThemeToggle', { + position: 'fixed', + top: theme('spacing-lg'), + right: theme('spacing-lg'), + padding: `${theme('spacing-sm')} ${theme('spacing-lg')}`, + background: theme('colors-bgElevated'), + border: `1px solid ${theme('colors-border')}`, + color: theme('colors-fg'), + fontSize: 14, + cursor: 'pointer', + fontFamily: theme('fonts-mono'), + + states: { + ':hover': { + background: theme('colors-bgHover'), + borderColor: theme('colors-borderActive'), + } + } +}) + +// Helper to highlight code blocks in markdown HTML +function highlightCodeBlocks(html: string): string { + return html.replace(/
([\s\S]*?)<\/code><\/pre>/g, (_, lang, code) => {
+ // Decode HTML entities
+ const decoded = code
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/&/g, '&')
+ .replace(/"/g, '"')
+ .replace(/'/g, "'")
+
+ // Map language to Prism grammar (tsx/typescript)
+ const grammar = lang === 'tsx' ? Prism.languages.tsx : Prism.languages.typescript
+
+ const highlighted = Prism.highlight(decoded, grammar!, lang)
+ return `${highlighted}
`
+ })
+}
+
+export const LandingPage = () => {
+ const themeScript = `
+ function switchTheme(themeName) {
+ document.body.setAttribute('data-theme', themeName)
+ localStorage.setItem('theme', themeName)
+ updateThemeToggle()
+ }
+
+ function updateThemeToggle() {
+ const currentTheme = document.body.getAttribute('data-theme')
+ const toggle = document.getElementById('theme-toggle')
+ if (toggle) {
+ toggle.textContent = currentTheme === 'dark' ? '☀ light' : '🌙 dark'
+ }
+ }
+
+ function toggleTheme() {
+ const currentTheme = document.body.getAttribute('data-theme')
+ const newTheme = currentTheme === 'dark' ? 'light' : 'dark'
+ switchTheme(newTheme)
+ }
+
+ window.switchTheme = switchTheme
+ window.toggleTheme = toggleTheme
+
+ // Load saved theme or default to dark
+ const savedTheme = localStorage.getItem('theme') || 'dark'
+ document.body.setAttribute('data-theme', savedTheme)
+ updateThemeToggle()
+ `
+
+ const prismTheme = `
+ code[class*="language-"],
+ pre[class*="language-"] {
+ color: var(--theme-colors-fg);
+ background: none;
+ font-family: var(--theme-fonts-mono);
+ text-align: left;
+ white-space: pre;
+ word-spacing: normal;
+ word-break: normal;
+ word-wrap: normal;
+ line-height: 1.5;
+ tab-size: 4;
+ hyphens: none;
+ }
+
+ pre[class*="language-"] {
+ padding: 1em;
+ margin: 0.5em 0;
+ overflow: auto;
+ background: var(--theme-colors-bgElevated);
+ border: 1px solid var(--theme-colors-border);
+ border-radius: 4px;
+ }
+
+ /* Dark theme colors */
+ [data-theme="dark"] .token.comment,
+ [data-theme="dark"] .token.prolog,
+ [data-theme="dark"] .token.doctype,
+ [data-theme="dark"] .token.cdata {
+ color: #999999;
+ }
+
+ [data-theme="dark"] .token.punctuation {
+ color: #808080;
+ }
+
+ [data-theme="dark"] .token.tag {
+ color: #ff6b6b;
+ }
+
+ [data-theme="dark"] .token.property,
+ [data-theme="dark"] .token.boolean,
+ [data-theme="dark"] .token.number,
+ [data-theme="dark"] .token.constant,
+ [data-theme="dark"] .token.symbol,
+ [data-theme="dark"] .token.deleted {
+ color: #ffd93d;
+ }
+
+ [data-theme="dark"] .token.selector,
+ [data-theme="dark"] .token.attr-name,
+ [data-theme="dark"] .token.string,
+ [data-theme="dark"] .token.char,
+ [data-theme="dark"] .token.builtin,
+ [data-theme="dark"] .token.inserted {
+ color: #ff6b6b;
+ }
+
+ [data-theme="dark"] .token.operator,
+ [data-theme="dark"] .token.entity,
+ [data-theme="dark"] .token.url,
+ [data-theme="dark"] .language-css .token.string,
+ [data-theme="dark"] .style .token.string {
+ color: #d4d4d4;
+ }
+
+ [data-theme="dark"] .token.atrule,
+ [data-theme="dark"] .token.attr-value,
+ [data-theme="dark"] .token.keyword {
+ color: #bb86fc;
+ }
+
+ [data-theme="dark"] .token.function,
+ [data-theme="dark"] .token.class-name {
+ color: #4dd0e1;
+ }
+
+ [data-theme="dark"] .token.regex,
+ [data-theme="dark"] .token.important,
+ [data-theme="dark"] .token.variable {
+ color: #bb86fc;
+ }
+
+ /* Light theme colors */
+ [data-theme="light"] .token.comment,
+ [data-theme="light"] .token.prolog,
+ [data-theme="light"] .token.doctype,
+ [data-theme="light"] .token.cdata {
+ color: #888888;
+ }
+
+ [data-theme="light"] .token.punctuation {
+ color: #666666;
+ }
+
+ [data-theme="light"] .token.tag {
+ color: #dc143c;
+ }
+
+ [data-theme="light"] .token.property,
+ [data-theme="light"] .token.boolean,
+ [data-theme="light"] .token.number,
+ [data-theme="light"] .token.constant,
+ [data-theme="light"] .token.symbol,
+ [data-theme="light"] .token.deleted {
+ color: #c9a700;
+ }
+
+ [data-theme="light"] .token.selector,
+ [data-theme="light"] .token.attr-name,
+ [data-theme="light"] .token.string,
+ [data-theme="light"] .token.char,
+ [data-theme="light"] .token.builtin,
+ [data-theme="light"] .token.inserted {
+ color: #dc143c;
+ }
+
+ [data-theme="light"] .token.operator,
+ [data-theme="light"] .token.entity,
+ [data-theme="light"] .token.url,
+ [data-theme="light"] .language-css .token.string,
+ [data-theme="light"] .style .token.string {
+ color: #000000;
+ }
+
+ [data-theme="light"] .token.atrule,
+ [data-theme="light"] .token.attr-value,
+ [data-theme="light"] .token.keyword {
+ color: #9333ea;
+ }
+
+ [data-theme="light"] .token.function,
+ [data-theme="light"] .token.class-name {
+ color: #0891b2;
+ }
+
+ [data-theme="light"] .token.regex,
+ [data-theme="light"] .token.important,
+ [data-theme="light"] .token.variable {
+ color: #9333ea;
+ }
+ `
+
+ return (
+
+
+
+
+ forge
+
+
+
+
+ 🌙 dark
+
+ {`╔═╝╔═║╔═║╔═╝╔═╝
+╔═╝║ ║╔╔╝║ ║╔═╝
+╝ ══╝╝ ╝══╝══╝`}
+
+
+ SSR demos →
+ SPA demos →
+
+
+
+
+
+
+
+ )
+}
+
+
+const MARKDOWN_CONTENT = `
+## overview
+
+Forge is a typed, local, variant-driven way to organize CSS and create
+self-contained TSX components out of discrete parts.
+
+## css problems
+
+- Styles are global and open - anything can override anything anywhere.
+- No IDE-friendly link between the class name in markup and its definition.
+- Organizational techniques are patterns a human must know and follow, not APIs.
+- Errors happen silently.
+
+## forge solutions
+
+- All styles are local to your TSX components.
+- Styles are defined using TS typing.
+- Component styles are made up of independently styled "Parts".
+- "Variants" replace inline styles with typed, declarative parameters.
+- Style composition is deterministic.
+- Themes are easy.
+- Errors and feedback are provided.
+
+## examples
+
+### styles
+
+\`\`\`tsx
+import { define } from "forge"
+
+export const Container = define("Container", {
+ width: 600,
+ margin: '0 auto',
+})
+
+export const Button = define({
+ base: "button",
+
+ padding: 20,
+ background: "blue",
+})
+
+// Usage
+
+
+
+\`\`\`
+
+### variants
+
+\`\`\`tsx
+import { define } from "forge"
+
+export const Button = define({
+ base: "button",
+
+ padding: 20,
+ background: "blue",
+
+ variants: {
+ status: {
+ danger: { background: "red" },
+ warning: { background: "yellow" },
+ }
+ },
+})
+
+// Usage
+
+
+
+\`\`\`
+
+### parts + \`render()\`
+
+\`\`\`typescript
+export const Profile = define("div", {
+ padding: 50,
+ background: "red",
+
+ parts: {
+ Header: { display: "flex" },
+ Avatar: { base: "img", width: 50 },
+ Bio: { color: "gray" },
+ },
+
+ variants: {
+ size: {
+ small: {
+ parts: { Avatar: { width: 20 } },
+ },
+ },
+ },
+
+ render({ props, parts: { Root, Header, Avatar, Bio } }) {
+ return (
+
+
+
+ {props.bio}
+
+
+ )
+ },
+})
+
+// Usage:
+import { Profile } from "./whatever"
+
+console.log( )
+console.log( )
+\`\`\`
+
+### selectors
+
+Use \`selectors\` to write custom CSS selectors. Reference the current
+element with \`&\` and other parts with \`@PartName\`:
+
+\`\`\`tsx
+const Checkbox = define("Checkbox", {
+ parts: {
+ Input: {
+ base: "input[type=checkbox]",
+ display: "none",
+ },
+ Label: {
+ base: "label",
+ padding: 10,
+ cursor: "pointer",
+ color: "gray",
+
+ selectors: {
+ // style Label when Input is checked
+ "@Input:checked + &": {
+ color: "green",
+ fontWeight: "bold",
+ },
+ // style Label when Input is disabled
+ "@Input:disabled + &": {
+ opacity: 0.5,
+ cursor: "not-allowed",
+ },
+ },
+ },
+ },
+
+ render({ props, parts: { Root, Input, Label } }) {
+ return (
+
+
+
+ )
+ },
+})
+
+// Usage
+
+\`\`\`
+
+## themes
+
+built-in support for CSS variables with full type safety:
+
+\`\`\`tsx
+// themes.tsx - Define your themes
+import { createThemes } from "forge"
+
+export const theme = createThemes({
+ dark: {
+ bgColor: "#0a0a0a",
+ fgColor: "#00ff00",
+ sm: 12,
+ lg: 24,
+ },
+ light: {
+ bgColor: "#f5f5f0",
+ fgColor: "#0a0a0a",
+ sm: 12,
+ lg: 24,
+ },
+})
+
+// Use theme() in your components
+import { define } from "forge"
+import { theme } from "./themes"
+
+const Button = define("Button", {
+ padding: theme("spacing-sm"),
+ background: theme("colors-bg"),
+ color: theme("colors-fg"),
+})
+\`\`\`
+
+Theme switching is done via the \`data-theme\` attribute:
+
+\`\`\`tsx
+// Toggle between themes
+document.body.setAttribute("data-theme", "dark")
+document.body.setAttribute("data-theme", "light")
+\`\`\`
+
+The \`theme()\` function is fully typed based on your theme keys, giving
+you autocomplete and type checking throughout your codebase.
+
+## scopes
+
+Sometimes you want your parts named things like ButtonRow, ButtonCell,
+ButtonTable, etc, but all those Button's are repetitive:
+
+\`\`\`typescript
+const { define } = createScope("Button")
+
+// css class becomes "Button"
+const Button = define("Root", {
+ // becomes "Button"
+ // ...
+})
+
+// css class becomes "ButtonRow"
+const ButtonRow = define("Row", {
+ // ...
+})
+
+// css class becomes "ButtonContainer"
+const ButtonContainer = define("Container", {
+ // ...
+})
+\`\`\`
+`
\ No newline at end of file
diff --git a/examples/spa/app.tsx b/examples/spa/app.tsx
index a92d035..142f724 100644
--- a/examples/spa/app.tsx
+++ b/examples/spa/app.tsx
@@ -108,7 +108,7 @@ const Link = define('Link', {
}
})
-const Nav = define('Nav', {
+const Nav = define({
base: 'nav',
display: 'flex',
diff --git a/examples/ssr/landing.tsx b/examples/ssr/landing.tsx
deleted file mode 100644
index 2abd520..0000000
--- a/examples/ssr/landing.tsx
+++ /dev/null
@@ -1,116 +0,0 @@
-import { createScope, Styles } from '../../src'
-import { theme } from './themes'
-
-const { define } = createScope('Landing')
-
-const Page = define('Page', {
- base: 'body',
-
- margin: 0,
- padding: theme('spacing-xl'),
- minHeight: '100vh',
- fontFamily: theme('fonts-mono'),
- background: theme('colors-bg'),
- color: theme('colors-fg'),
-})
-
-const Container = define('Container', {
- maxWidth: 800,
- margin: '0 auto',
-})
-
-const Pre = define('Pre', {
- base: 'pre',
-
- fontSize: 14,
- lineHeight: 1.4,
- marginBottom: theme('spacing-xl'),
- color: theme('colors-fg'),
- whiteSpace: 'pre',
- borderBottom: '1px solid var(--theme-colors-border)',
-})
-
-const P = define('P', {
- base: 'p',
-
- fontSize: 16,
- lineHeight: 1.6,
- marginBottom: theme('spacing-xl'),
- color: theme('colors-fgMuted'),
-})
-
-const LinkSection = define('LinkSection', {
- marginTop: theme('spacing-xxl'),
- paddingTop: theme('spacing-xl'),
- borderTop: `1px solid ${theme('colors-border')}`,
-})
-
-const Link = define('Link', {
- base: 'a',
-
- display: 'inline-block',
- marginRight: theme('spacing-xl'),
- padding: `${theme('spacing-sm')} ${theme('spacing-lg')}`,
- background: theme('colors-bgElevated'),
- border: `1px solid ${theme('colors-border')}`,
- color: theme('colors-fg'),
- textDecoration: 'none',
- fontSize: 14,
-
- states: {
- ':hover': {
- background: theme('colors-bgHover'),
- borderColor: theme('colors-borderActive'),
- }
- }
-})
-
-export const LandingPage = () => {
- const themeScript = `
- function switchTheme(themeName) {
- document.body.setAttribute('data-theme', themeName)
- localStorage.setItem('theme', themeName)
- }
-
- window.switchTheme = switchTheme
-
- // Load saved theme or default to dark
- const savedTheme = localStorage.getItem('theme') || 'dark'
- document.body.setAttribute('data-theme', savedTheme)
- `
-
- return (
-
-
-
-
- forge
-
-
-
-
- {`╔═╝╔═║╔═║╔═╝╔═╝
-╔═╝║ ║╔╔╝║ ║╔═╝
-╝ ══╝╝ ╝══╝══╝`}
-
-
- Typed, local, variant-driven CSS. No globals, no selector hell, no inline styles.
- Built for TSX. Compiles to real CSS.
-
-
-
- CSS is hostile to humans at scale. Forge fixes that by making styles local,
- typed, and composable. Parts replace selectors. Variants replace inline styles.
- Everything deterministic.
-
-
-
- SSR demos →
- SPA demos →
-
-
-
-
-
- )
-}
diff --git a/package.json b/package.json
index 0104871..5afd0ed 100644
--- a/package.json
+++ b/package.json
@@ -8,7 +8,10 @@
"build:spa": "bun build examples/spa/index.tsx --outfile dist/spa.js --target browser"
},
"devDependencies": {
- "@types/bun": "latest"
+ "@types/bun": "latest",
+ "@types/prismjs": "^1.26.5",
+ "prismjs": "^1.30.0",
+ "snarkdown": "^2.0.0"
},
"peerDependencies": {
"typescript": "^5"
diff --git a/server.tsx b/server.tsx
index 90f9d17..c027b4d 100644
--- a/server.tsx
+++ b/server.tsx
@@ -1,6 +1,6 @@
import { Hono } from 'hono'
import { IndexPage, ProfileExamplesPage, ButtonExamplesPage, NavigationExamplesPage, FormExamplesPage } from './examples/ssr/pages'
-import { LandingPage } from './examples/ssr/landing'
+import { LandingPage } from './examples/landing'
import { stylesToCSS } from './src'
const app = new Hono()
diff --git a/src/tests/index.test.tsx b/src/tests/index.test.tsx
index 486c098..808691b 100644
--- a/src/tests/index.test.tsx
+++ b/src/tests/index.test.tsx
@@ -38,8 +38,8 @@ describe('define - basic functionality', () => {
const html2 = renderToString(Component2({}))
// Should have different auto-generated names
- expect(html1).toMatch(/class="Def\d+"/)
- expect(html2).toMatch(/class="Def\d+"/)
+ expect(html1).toMatch(/class="Div\d*"/)
+ expect(html2).toMatch(/class="Div\d*"/)
expect(html1).not.toBe(html2)
})
@@ -891,7 +891,7 @@ describe('edge cases', () => {
const html = renderToString(Component({}))
expect(html).toBeDefined()
- expect(html).toMatch(/class="Def\d+"/)
+ expect(html).toMatch(/class="Div\d*"/)
})
test('handles definition with only parts', () => {