Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ pnpm test # run Playwright E2E tests (requires dev serve
pnpm lint # ESLint
pnpm format # Prettier
pnpm run generate:git-metadata # regenerate src/data/git-metadata.json from git history
pnpm run generate:dark-variants # bulk-generate draft -dark.html files for components missing one
```

## Architecture
Expand All @@ -31,7 +32,7 @@ The three categories map to Astro content collections defined in `src/content.co

The actual component markup lives in `public/examples/{category}/{slug}/{n}.html` (light) and `public/examples/{category}/{slug}/{n}-dark.html` (dark). These are standalone HTML pages loaded in iframes by the `<component-preview>` custom element.

Dark variants can be generated using the browser-based dark mode generator tool at `/tools/dark-mode-generator`.
Dark variants can be generated with `pnpm run generate:dark-variants`, which bulk-generates draft `-dark.html` files (using default shade/color mappings) for every component missing one — review each result and add `dark: true` to its MDX entry by hand. Pass `--category=<name>` and/or `--slug=<name>` to scope a run to one collection instead of scanning every category (e.g. `pnpm run generate:dark-variants --category=application --slug=badges`). For a single component that needs hand-tuning, use the browser-based dark mode generator tool at `/tools/dark-mode-generator` instead.

### Preview System

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
"format": "prettier --write . --ignore-path .prettierignore",
"css:blog": "npx @tailwindcss/cli -i ./src/styles/blog.css -o ./public/blog.css -m",
"css:component": "npx @tailwindcss/cli -i ./src/styles/component.css -o ./public/component.css -m",
"generate:git-metadata": "node scripts/generate-git-metadata.js"
"generate:git-metadata": "node scripts/generate-git-metadata.js",
"generate:dark-variants": "node scripts/generate-dark-variants.js"
},
"dependencies": {
"@astrojs/check": "^0.9.10",
Expand Down
150 changes: 150 additions & 0 deletions scripts/generate-dark-variants.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
#!/usr/bin/env node

import { fileURLToPath } from 'node:url'

import fs from 'node:fs'
import path from 'node:path'

import { transformHtmlString } from '../src/lib/dark-mode/transform-html.js'
import { DEFAULT_CONFIG } from '../src/lib/dark-mode/config.js'

const scriptFilePath = fileURLToPath(import.meta.url)
const repositoryRootPath = path.resolve(path.dirname(scriptFilePath), '..')
const examplesRootPath = path.join(repositoryRootPath, 'public/examples')

const componentCategories = ['application', 'marketing', 'neobrutalism', 'templates']

const cliCategoryFilter = process.argv
.find((argValue) => argValue.startsWith('--category='))
?.split('=')[1]
const cliSlugFilter = process.argv.find((argValue) => argValue.startsWith('--slug='))?.split('=')[1]

function findComponentSlugFolders(categoryPath) {
return fs
.readdirSync(categoryPath)
.filter((entryName) => fs.statSync(path.join(categoryPath, entryName)).isDirectory())
}

function findMissingDarkVariantsInFolder(categoryName, componentSlug, componentFolderPath) {
const htmlFileNames = fs
.readdirSync(componentFolderPath)
.filter((fileName) => fileName.endsWith('.html'))

const darkFileNameSet = new Set(
htmlFileNames.filter((fileName) => fileName.endsWith('-dark.html')),
)
const lightFileNames = htmlFileNames.filter((fileName) => !fileName.endsWith('-dark.html'))

return lightFileNames
.map((lightFileName) => lightFileName.replace(/\.html$/, '-dark.html'))
.filter((darkFileName) => !darkFileNameSet.has(darkFileName))
.map((darkFileName) => {
const lightFileName = darkFileName.replace(/-dark\.html$/, '.html')

return {
categoryName,
componentSlug,
lightFilePath: path.join(componentFolderPath, lightFileName),
darkFilePath: path.join(componentFolderPath, darkFileName),
}
})
}

function isDarkModeSupportedForCollection(categoryName, componentSlug) {
const mdxFilePath = path.join(
repositoryRootPath,
'src/content/collection',
categoryName,
`${componentSlug}.mdx`,
)

if (!fs.existsSync(mdxFilePath)) {
return true
}

const mdxFileContent = fs.readFileSync(mdxFilePath, 'utf8')
const frontmatterMatch = mdxFileContent.match(/^---\n([\s\S]*?)\n---/)

if (!frontmatterMatch) {
return true
}

return !/^dark:\s*false\s*$/m.test(frontmatterMatch[1])
}

function findMissingDarkVariants() {
const missingDarkVariants = []

for (const categoryName of componentCategories) {
if (cliCategoryFilter && categoryName !== cliCategoryFilter) {
continue
}

const categoryPath = path.join(examplesRootPath, categoryName)

if (!fs.existsSync(categoryPath)) {
continue
}

for (const componentSlug of findComponentSlugFolders(categoryPath)) {
if (cliSlugFilter && componentSlug !== cliSlugFilter) {
continue
}

if (!isDarkModeSupportedForCollection(categoryName, componentSlug)) {
continue
}

const componentFolderPath = path.join(categoryPath, componentSlug)

missingDarkVariants.push(
...findMissingDarkVariantsInFolder(categoryName, componentSlug, componentFolderPath),
)
}
}

return missingDarkVariants
}

function generateDarkVariants() {
if (process.env.NODE_ENV === 'production') {
console.error('❌ Error: This script is blocked in production.')

process.exit(1)
}

const missingDarkVariants = findMissingDarkVariants()

if (missingDarkVariants.length === 0) {
console.log('✅ Every component already has a dark variant')

return
}

console.log(`🔍 Found ${missingDarkVariants.length} file(s) without a dark variant\n`)

const touchedComponentKeys = new Set()

for (const missingVariant of missingDarkVariants) {
const lightHtmlContent = fs.readFileSync(missingVariant.lightFilePath, 'utf8')
const darkHtmlContent = transformHtmlString(lightHtmlContent, DEFAULT_CONFIG)

fs.writeFileSync(missingVariant.darkFilePath, darkHtmlContent, 'utf8')

const relativeDarkFilePath = path.relative(repositoryRootPath, missingVariant.darkFilePath)

console.log(`✨ Created: ${relativeDarkFilePath}`)

touchedComponentKeys.add(`${missingVariant.categoryName}/${missingVariant.componentSlug}`)
}

console.log(`\n✅ Generated ${missingDarkVariants.length} dark variant(s)`)
console.log('👋 These are drafts — review each rendering, then for every touched component')
console.log(' add `dark: true` to the matching entry in its .mdx `components` array:\n')

for (const touchedComponentKey of [...touchedComponentKeys].sort()) {
console.log(` - src/content/collection/${touchedComponentKey}.mdx`)
}
}

generateDarkVariants()
1 change: 1 addition & 0 deletions src/content.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ const collection = z.object({
title: z.string(),
wrapper: z.string().default('h-[600px]'),
pattern: z.url().optional(),
dark: z.boolean().default(true),
components: z.array(
z.object({
contributors: z.array(z.string()).default(['markmead']),
Expand Down
1 change: 1 addition & 0 deletions src/content/collection/application/grids.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ description: Responsive grid layouts and grid systems for web applications with
category: application
slug: grids
wrapper: h-[400px]
dark: false
terms:
- layout
components:
Expand Down
1 change: 1 addition & 0 deletions src/content/collection/application/media.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ description: Responsive media and avatar components for user profiles, image gal
category: application
slug: media
wrapper: h-[400px]
dark: false
terms:
- avatar
- image
Expand Down
37 changes: 21 additions & 16 deletions src/layouts/ComponentPost.astro
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const {
wrapper,
pattern,
updated,
dark: darkModeSupported,
} = Astro.props

const darkCount = components.filter(({ dark }) => !!dark).length
Expand Down Expand Up @@ -105,23 +106,27 @@ const componentPageSchema = {

<div class="mx-auto max-w-7xl space-y-8 px-4">
<div class="flex flex-wrap items-center gap-2 text-sm text-gray-600">
<span>
{darkCount}/{totalCount} Dark Mode{' '}
{
darkCount !== totalCount && (
<a
href="https://github.com/markmead/hyperui/issues/new"
target="_blank"
rel="noreferrer"
class="text-gray-500 transition-colors hover:text-gray-700"
>
(Request)
</a>
)
}
</span>
{
darkModeSupported && (
<>
<span>
{darkCount}/{totalCount} Dark Mode{' '}
{darkCount !== totalCount && (
<a
href="https://github.com/markmead/hyperui/issues/new"
target="_blank"
rel="noreferrer"
class="text-gray-500 transition-colors hover:text-gray-700"
>
(Request)
</a>
)}
</span>

<span class="size-1 rounded-full bg-gray-400" aria-hidden="true"></span>
<span class="size-1 rounded-full bg-gray-400" aria-hidden="true" />
</>
)
}

{
pattern && (
Expand Down