generate-theme-tokens.ts 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136
  1. #!/usr/bin/env bun
  2. import { Schema, SchemaAST } from "effect"
  3. import { format } from "prettier"
  4. import { ThemeDefinition, ThemeDocument } from "@opencode-ai/theme/tui"
  5. const target = import.meta.dir + "/../snippets/generated/theme-tokens.mdx"
  6. const root = requireObject(ThemeDefinition.ast)
  7. const hue = requireObject(requireField(root, "hue").type)
  8. const hueNames = hue.propertySignatures.map((field) => String(field.name))
  9. const hueSteps = requireObject(requireField(hue, hueNames[0]).type).propertySignatures.map((field) =>
  10. String(field.name),
  11. )
  12. const contexts = root.propertySignatures
  13. .map((field) => String(field.name))
  14. .filter((name) => name.startsWith("@context:"))
  15. const tokens = root.propertySignatures
  16. .filter((field) => {
  17. const name = String(field.name)
  18. return name !== "hue" && name !== "categorical" && !name.startsWith("@context:")
  19. })
  20. .flatMap((field) => tokenPaths(field.type, String(field.name)))
  21. const groups = Map.groupBy(tokens, (token) =>
  22. token
  23. .split(".")
  24. .slice(0, token.split(".").length > 2 ? 2 : 1)
  25. .join("."),
  26. )
  27. const table = [...groups]
  28. .map(([group, values]) => `| \`${group}\` | ${values.map((value) => `\`${value}\``).join("<br />")} |`)
  29. .join("\n")
  30. const example = {
  31. version: 2,
  32. light: {
  33. hue: {
  34. accent: "$hue.purple",
  35. interactive: "$hue.purple",
  36. },
  37. text: {
  38. default: "$hue.neutral.900",
  39. },
  40. background: {
  41. default: "#fafafa",
  42. },
  43. },
  44. dark: {
  45. mergeMode: true,
  46. text: {
  47. default: "$hue.neutral.100",
  48. },
  49. background: {
  50. default: "#101014",
  51. },
  52. },
  53. } satisfies ThemeDocument
  54. Schema.decodeUnknownSync(ThemeDocument)(example)
  55. const output = await format(
  56. `{/* Generated by packages/www/script/generate-theme-tokens.ts. Do not edit. */}
  57. \`\`\`json title="my-theme.json"
  58. ${JSON.stringify(example, null, 2)}
  59. \`\`\`
  60. ## Token reference
  61. This reference is generated from the Effect schema in
  62. \`@opencode-ai/theme/tui\`. Changes to the runtime schema update
  63. this section through \`bun run generate\`.
  64. ### Hue tokens
  65. Every hue is a ${hueSteps.length}-step scale. Define a scale with all of these
  66. steps, or alias it to another hue with a value such as \`$hue.blue\`.
  67. | | Values |
  68. | --- | --- |
  69. | Hues | ${hueNames.map((name) => `\`${name}\``).join(", ")} |
  70. | Steps | ${hueSteps.map((step) => `\`${step}\``).join(", ")} |
  71. Reference a hue color as \`$hue.<name>.<step>\`, for example
  72. \`$hue.interactive.500\`.
  73. ### Semantic tokens
  74. Semantic values can reference another token by prefixing its path with \`$\`,
  75. for example \`$text.default\`. Stateful tokens inherit their \`default\`
  76. value when a state is omitted.
  77. | Group | Tokens |
  78. | --- | --- |
  79. ${table}
  80. ### Contexts
  81. ${contexts.map((context) => `\`${context}\``).join(" and ")} accept partial
  82. overrides of the semantic tokens above. Components apply these contexts to
  83. surfaces that need different contrast without changing the base theme.
  84. `,
  85. { parser: "mdx", printWidth: 120, semi: false },
  86. )
  87. if (process.argv.includes("--check")) {
  88. const current = await Bun.file(target).text()
  89. if (current === output) process.exit(0)
  90. console.error("Generated theme token documentation is stale. Run `bun run generate` from packages/www.")
  91. process.exit(1)
  92. }
  93. await Bun.write(target, output)
  94. function requireObject(ast: SchemaAST.AST): SchemaAST.Objects {
  95. if (SchemaAST.isObjects(ast)) return ast
  96. if (SchemaAST.isUnion(ast)) {
  97. const object = ast.types.map(findObject).find((value) => value !== undefined)
  98. if (object) return object
  99. }
  100. throw new Error(`Expected an object schema, received ${ast._tag}`)
  101. }
  102. function findObject(ast: SchemaAST.AST): SchemaAST.Objects | undefined {
  103. if (SchemaAST.isObjects(ast)) return ast
  104. if (SchemaAST.isUnion(ast)) return ast.types.map(findObject).find((value) => value !== undefined)
  105. if (SchemaAST.isSuspend(ast)) return findObject(ast.thunk())
  106. }
  107. function requireField(ast: SchemaAST.Objects, name: string) {
  108. const field = ast.propertySignatures.find((field) => String(field.name) === name)
  109. if (field) return field
  110. throw new Error(`Theme schema field not found: ${name}`)
  111. }
  112. function tokenPaths(ast: SchemaAST.AST, prefix: string): string[] {
  113. const object = findObject(ast)
  114. if (!object || object.propertySignatures.length === 0) return [prefix]
  115. return object.propertySignatures.flatMap((field) => tokenPaths(field.type, `${prefix}.${String(field.name)}`))
  116. }