@lexibase/rdx-ui 1.1.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,7 @@
1
1
  'use client'
2
2
 
3
+ import { useMessages } from '../providers/locale-provider'
4
+
3
5
  import { Tabs as TabsPrimitive } from '@base-ui/react/tabs'
4
6
  import { Check, Copy } from 'lucide-react'
5
7
  import { useEffect, useRef, useState } from 'react'
@@ -23,8 +25,13 @@ function panelText(node: Node): string {
23
25
  )
24
26
  )
25
27
  return ''
26
- if (node.matches('[data-rdx-code-block]')) {
27
- return `${node.querySelector('pre')?.textContent ?? ''}\n`
28
+ if (node.matches('[data-rdx-code-block], [data-slot="code-block"]')) {
29
+ const pre = node.querySelector('pre')
30
+ const lines = pre?.querySelectorAll('[data-slot="code-block-line-content"]')
31
+ const text = lines?.length
32
+ ? Array.from(lines, (line) => line.textContent ?? '').join('\n')
33
+ : pre?.textContent
34
+ return `${node.dataset.rdxCodeValue ?? text ?? ''}\n`
28
35
  }
29
36
  if (node.tagName === 'PRE') return `${node.textContent ?? ''}\n`
30
37
  if (node.tagName === 'BR') return '\n'
@@ -43,6 +50,7 @@ function panelText(node: Node): string {
43
50
  }
44
51
 
45
52
  function CopyTabButton() {
53
+ const messages = useMessages()
46
54
  const [status, setStatus] = useState<'idle' | 'copied' | 'error'>('idle')
47
55
  const pending = useRef(false)
48
56
  const mounted = useRef(false)
@@ -92,13 +100,13 @@ function CopyTabButton() {
92
100
  type="button"
93
101
  variant="ghost"
94
102
  size="icon"
95
- aria-label="Copy active tab"
103
+ aria-label={messages.copyTab}
96
104
  title={
97
105
  status === 'error'
98
- ? 'Could not copy. Select the tab content and copy it manually.'
106
+ ? messages.copyTabError
99
107
  : status === 'copied'
100
- ? 'Copied!'
101
- : 'Copy active tab'
108
+ ? messages.copied
109
+ : messages.copyTab
102
110
  }
103
111
  data-status={status}
104
112
  className="mr-1.5 size-7 shrink-0 text-muted-foreground hover:text-foreground data-[status=copied]:text-emerald-700 dark:data-[status=copied]:text-emerald-400 data-[status=error]:text-amber-600 [&>svg]:size-3.5"
@@ -113,9 +121,9 @@ function CopyTabButton() {
113
121
  className="sr-only"
114
122
  >
115
123
  {status === 'copied'
116
- ? 'Tab content copied to clipboard.'
124
+ ? messages.tabCopied
117
125
  : status === 'error'
118
- ? 'Could not copy. Select the tab content and copy it manually.'
126
+ ? messages.copyTabError
119
127
  : ''}
120
128
  </span>
121
129
  </>
@@ -172,7 +180,7 @@ function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
172
180
  <TabsPrimitive.Panel
173
181
  data-slot="tabs-content"
174
182
  className={mergeStateClassName(
175
- 'min-w-0 rounded-b-lg p-3 outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring [&>:first-child]:mt-0 [&>:last-child]:mb-0 [&>[data-rdx-code-block]]:my-3 [&_[data-rdx-code-block]>pre]:p-3',
183
+ 'min-w-0 rounded-b-lg p-3 outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring [&>:first-child]:mt-0 [&>:last-child]:mb-0 [&>[data-rdx-code-block]]:my-3 [&_[data-rdx-code-block]>pre]:p-3 font-mono',
176
184
  className
177
185
  )}
178
186
  {...props}
@@ -1,9 +1,13 @@
1
1
  'use client'
2
2
 
3
+ import { useMessages } from '../providers/locale-provider'
4
+
3
5
  import { Archive, Bird, PackageOpen } from 'lucide-react'
4
6
  import Link from 'next/link'
5
7
  import type { ReactNode } from 'react'
6
8
 
9
+ import { formatMessage } from '../lib/i18n'
10
+
7
11
  import { Button } from './button'
8
12
  import {
9
13
  DropdownMenu,
@@ -42,6 +46,7 @@ export function VersionDropdown({
42
46
  onSelect?: (id: string) => void
43
47
  footer?: ReactNode
44
48
  }) {
49
+ const messages = useMessages()
45
50
  const current =
46
51
  options.find((option) => option.id === currentId) ?? options[0]
47
52
  if (!current) return null
@@ -87,7 +92,9 @@ export function VersionDropdown({
87
92
  variant="outline"
88
93
  size="sm"
89
94
  className="min-w-0 capitalize sm:min-w-[134px]"
90
- aria-label={`Documentation version: ${current.label}`}
95
+ aria-label={formatMessage(messages.documentationVersion, {
96
+ version: current.label,
97
+ })}
91
98
  />
92
99
  }
93
100
  >
@@ -119,7 +126,7 @@ export function VersionDropdown({
119
126
  {active.length > 0 && <DropdownMenuSeparator />}
120
127
  <DropdownMenuGroup>
121
128
  <DropdownMenuLabel className="text-center py-px cursor-default">
122
- Archived
129
+ {messages.archived}
123
130
  </DropdownMenuLabel>
124
131
  <DropdownMenuSeparator />
125
132
  {archived.map(item)}
@@ -1,3 +1,4 @@
1
+ import { getMessages } from '../lib/i18n'
1
2
  import Link from 'next/link'
2
3
  import { ArrowRight } from 'lucide-react'
3
4
 
@@ -5,29 +6,30 @@ import { Callout } from './callout'
5
6
 
6
7
  /** Presentation only: version selection remains the responsibility of the caller. */
7
8
  export function VersionNotice({
9
+ lang,
8
10
  kind,
9
11
  currentHref,
10
12
  }: {
13
+ lang?: string
11
14
  kind: 'canary' | 'outdated'
12
15
  currentHref: string
13
16
  }) {
17
+ const messages = getMessages(lang)
14
18
  const isCanary = kind === 'canary'
15
19
  return (
16
20
  <Callout
17
21
  data-rdx-version-notice={kind}
18
22
  variant={isCanary ? 'tip' : 'warning'}
19
- title={isCanary ? 'Pre-release documentation' : 'Outdated documentation'}
23
+ title={isCanary ? messages.preReleaseTitle : messages.outdatedTitle}
20
24
  className="mt-0 mb-5"
21
25
  >
22
26
  <p>
23
- {isCanary
24
- ? 'This is a pre-release version of the documentation.'
25
- : 'This is an outdated version of the documentation.'}{' '}
27
+ {isCanary ? messages.preReleaseMessage : messages.outdatedMessage}{' '}
26
28
  <Link
27
29
  href={currentHref}
28
30
  className="font-medium text-foreground underline decoration-current/40 underline-offset-4 transition-colors hover:decoration-current focus-visible:rounded-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
29
31
  >
30
- Go back to the current version
32
+ {messages.backToCurrent}
31
33
  <ArrowRight
32
34
  aria-hidden="true"
33
35
  className="ml-1 inline size-3.5 align-text-bottom"
@@ -0,0 +1,75 @@
1
+ import * as React from 'react'
2
+
3
+ import { loadHighlighter } from '../lib/highlighter'
4
+
5
+ export function useHighlightedCode(value: string, language: string) {
6
+ // A trailing newline would render as an extra empty line.
7
+ const code = value.endsWith('\n') ? value.slice(0, -1) : value
8
+ const [loaded, setLoaded] = React.useState<{
9
+ language: string
10
+ highlighter: Awaited<ReturnType<typeof loadHighlighter>>
11
+ } | null>(null)
12
+ const highlighter = loaded?.language === language ? loaded.highlighter : null
13
+
14
+ React.useEffect(() => {
15
+ let cancelled = false
16
+
17
+ loadHighlighter(language)
18
+ .then((next) => {
19
+ if (!cancelled) {
20
+ setLoaded({ language, highlighter: next })
21
+ }
22
+ })
23
+ .catch(() => {})
24
+
25
+ return () => {
26
+ cancelled = true
27
+ }
28
+ }, [language])
29
+
30
+ // The complete lines, highlighted. While the code streams, only the new
31
+ // lines are tokenized, starting from the grammar state of the last one.
32
+ const [done, setDone] = React.useState<{
33
+ highlighter: typeof highlighter
34
+ text: string
35
+ lines: React.ReactNode[]
36
+ state?: Parameters<NonNullable<typeof highlighter>['highlight']>[1]
37
+ }>({ highlighter: null, text: '', lines: [] })
38
+
39
+ let next = done
40
+
41
+ if (next.highlighter !== highlighter || !code.startsWith(next.text)) {
42
+ next = { highlighter, text: '', lines: [] }
43
+ }
44
+
45
+ const end = code.lastIndexOf('\n') + 1
46
+
47
+ if (highlighter && end > next.text.length) {
48
+ const result = highlighter.highlight(
49
+ code.slice(next.text.length, end - 1),
50
+ next.state
51
+ )
52
+
53
+ next = {
54
+ highlighter,
55
+ text: code.slice(0, end),
56
+ lines: [...next.lines, ...result.lines],
57
+ state: result.state,
58
+ }
59
+ }
60
+
61
+ if (next !== done) {
62
+ setDone(next)
63
+ }
64
+
65
+ // The last line is still being written, so it is tokenized on every render.
66
+ const tail = code.slice(end)
67
+
68
+ return React.useMemo(() => {
69
+ if (!highlighter) {
70
+ return code.split('\n')
71
+ }
72
+
73
+ return [...next.lines, highlighter.highlight(tail, next.state).lines[0]]
74
+ }, [code, highlighter, next, tail])
75
+ }
@@ -0,0 +1,124 @@
1
+ import * as React from 'react'
2
+ import {
3
+ createHighlighterCore,
4
+ type GrammarState,
5
+ type HighlighterCore,
6
+ } from 'shiki/core'
7
+ import { createJavaScriptRegexEngine } from 'shiki/engine/javascript'
8
+
9
+ // Add the languages you need. Each one loads the first time it is used.
10
+ const languages = {
11
+ bash: () => import('shiki/langs/shellscript.mjs'),
12
+ css: () => import('shiki/langs/css.mjs'),
13
+ diff: () => import('shiki/langs/diff.mjs'),
14
+ html: () => import('shiki/langs/html.mjs'),
15
+ javascript: () => import('shiki/langs/javascript.mjs'),
16
+ json: () => import('shiki/langs/json.mjs'),
17
+ jsx: () => import('shiki/langs/jsx.mjs'),
18
+ markdown: () => import('shiki/langs/markdown.mjs'),
19
+ python: () => import('shiki/langs/python.mjs'),
20
+ sql: () => import('shiki/langs/sql.mjs'),
21
+ tsx: () => import('shiki/langs/tsx.mjs'),
22
+ typescript: () => import('shiki/langs/typescript.mjs'),
23
+ yaml: () => import('shiki/langs/yaml.mjs'),
24
+ }
25
+
26
+ const aliases: Record<string, keyof typeof languages> = {
27
+ js: 'javascript',
28
+ md: 'markdown',
29
+ py: 'python',
30
+ sh: 'bash',
31
+ shell: 'bash',
32
+ shellscript: 'bash',
33
+ ts: 'typescript',
34
+ yml: 'yaml',
35
+ zsh: 'bash',
36
+ }
37
+
38
+ let highlighter: Promise<HighlighterCore> | undefined
39
+
40
+ function getHighlighter() {
41
+ highlighter ??= createHighlighterCore({
42
+ themes: [
43
+ import('shiki/themes/github-light.mjs'),
44
+ import('shiki/themes/github-dark.mjs'),
45
+ ],
46
+ langs: [],
47
+ engine: createJavaScriptRegexEngine({ forgiving: true }),
48
+ })
49
+
50
+ return highlighter
51
+ }
52
+
53
+ function getLanguage(language: string) {
54
+ const name = language.toLowerCase()
55
+
56
+ if (name === 'ansi') {
57
+ return name
58
+ }
59
+
60
+ if (name in languages) {
61
+ return name as keyof typeof languages
62
+ }
63
+
64
+ return aliases[name] ?? null
65
+ }
66
+
67
+ // Loads a language and returns a highlighter for it, or null for a language
68
+ // that is not listed.
69
+ export async function loadHighlighter(language: string) {
70
+ const lang = getLanguage(language)
71
+
72
+ if (!lang) {
73
+ return null
74
+ }
75
+
76
+ const shiki = await getHighlighter()
77
+
78
+ if (lang !== 'ansi' && !shiki.getLoadedLanguages().includes(lang)) {
79
+ await shiki.loadLanguage(languages[lang])
80
+ }
81
+
82
+ return {
83
+ // Returns one React node per line, and the grammar state after the last
84
+ // line. Pass that state back to continue where the code left off.
85
+ highlight(code: string, state?: GrammarState) {
86
+ const tokenize = (text: string) =>
87
+ shiki.codeToTokens(text, {
88
+ lang,
89
+ themes: { light: 'github-light', dark: 'github-dark' },
90
+ defaultColor: false,
91
+ grammarState: state,
92
+ }).tokens
93
+
94
+ // ANSI has no grammar state, so each line is tokenized on its own and
95
+ // gets the same colors whether it arrives in one piece or streams.
96
+ const tokens =
97
+ lang === 'ansi'
98
+ ? code.split('\n').flatMap((line) => tokenize(line))
99
+ : tokenize(code)
100
+
101
+ return {
102
+ lines: tokens.map((line) =>
103
+ line.map((token, index) => (
104
+ <span
105
+ key={index}
106
+ style={token.htmlStyle as React.CSSProperties}
107
+ className="text-(--shiki-light) dark:text-(--shiki-dark)"
108
+ >
109
+ {token.content}
110
+ </span>
111
+ ))
112
+ ),
113
+ state: shiki.getLastGrammarState(tokens),
114
+ }
115
+ },
116
+ }
117
+ }
118
+
119
+ // Returns one React node per line, or null for a language that is not listed.
120
+ export async function highlightCode(code: string, language: string) {
121
+ const highlighter = await loadHighlighter(language)
122
+
123
+ return highlighter ? highlighter.highlight(code).lines : null
124
+ }
@@ -0,0 +1,156 @@
1
+ /** Shared labels only: document content and author-provided names stay untouched. */
2
+ const en = {
3
+ skipContent: 'Skip to content',
4
+ mainNavigation: 'Main navigation',
5
+ docs: 'Docs',
6
+ documentationPages: 'Documentation pages',
7
+ documentationNavigation: 'Documentation navigation',
8
+ openSidebar: 'Open sidebar',
9
+ closeSidebar: 'Close sidebar',
10
+ previous: 'Previous',
11
+ next: 'Next',
12
+ previousPage: 'Previous page',
13
+ nextPage: 'Next page',
14
+ onThisPage: 'On this page',
15
+ search: 'Search…',
16
+ searchDocumentation: 'Search documentation',
17
+ searchPlaceholder: 'Search documentation…',
18
+ searchDescription:
19
+ 'Search pages and sections in {version}. Use arrow keys to choose a result and Enter to open it.',
20
+ loadingSearch: 'Loading search…',
21
+ searchUnavailable: 'Search is unavailable.',
22
+ tryAgain: 'Try again',
23
+ typeToSearch: 'Type to search this documentation.',
24
+ noResults: 'No results found.',
25
+ navigate: 'Navigate',
26
+ open: 'Open',
27
+ close: 'Close',
28
+ closeSearch: 'Close search',
29
+ enlargeImage: 'Enlarge image: {alt}',
30
+ closeImage: 'Close image',
31
+ imagePreviewDescription:
32
+ 'Press Escape or use the close button to return to the document.',
33
+ copyCode: 'Copy code',
34
+ wrapCode: 'Wrap lines',
35
+ codeCopied: 'Code copied to clipboard.',
36
+ copyCodeError: 'Could not copy. Select the code and copy it manually.',
37
+ copyRetry: 'Could not copy. Try again or copy manually.',
38
+ copied: 'Copied!',
39
+ copyTab: 'Copy active tab',
40
+ tabCopied: 'Tab content copied to clipboard.',
41
+ copyTabError: 'Could not copy. Select the tab content and copy it manually.',
42
+ code: 'Code',
43
+ folderTree: 'Folder',
44
+ table: 'Table',
45
+ current: 'Current',
46
+ version: 'Version {version}',
47
+ documentationVersion: 'Documentation version: {version}',
48
+ archived: 'Archived',
49
+ archivedVersion: 'Archived',
50
+ loadingVersions: 'Loading versions',
51
+ preReleaseTitle: 'Pre-release documentation',
52
+ outdatedTitle: 'Outdated documentation',
53
+ preReleaseMessage: 'This is a pre-release version of the documentation.',
54
+ outdatedMessage: 'This is an outdated version of the documentation.',
55
+ backToCurrent: 'Go back to the current version',
56
+ poweredBy: 'Powered by',
57
+ developedBy: 'Developed by',
58
+ modeToggle: 'Mode Toggle',
59
+ documentationReady: 'Your documentation is ready.',
60
+ addDocument: 'Add an .mdx file inside docs/.',
61
+ notFound: 'Page not found',
62
+ notFoundDescription: "We couldn't find what you were looking for.",
63
+ brokenLink:
64
+ 'Please contact the owner of the site that led you to this link and let them know the link is broken.',
65
+ backToDocs: 'Go back to documentation',
66
+ } satisfies Record<string, string>
67
+
68
+ export type InterfaceMessages = { [Key in keyof typeof en]: string }
69
+ export type InterfaceLocale = 'en' | 'pt-BR'
70
+
71
+ const ptBR = {
72
+ skipContent: 'Ir para o conteúdo',
73
+ mainNavigation: 'Navegação principal',
74
+ docs: 'Docs',
75
+ documentationPages: 'Páginas da documentação',
76
+ documentationNavigation: 'Navegação da documentação',
77
+ openSidebar: 'Abrir menu lateral',
78
+ closeSidebar: 'Fechar menu lateral',
79
+ previous: 'Anterior',
80
+ next: 'Próxima',
81
+ previousPage: 'Página anterior',
82
+ nextPage: 'Próxima página',
83
+ onThisPage: 'Nesta página',
84
+ search: 'Buscar…',
85
+ searchDocumentation: 'Buscar na documentação',
86
+ searchPlaceholder: 'Buscar na documentação…',
87
+ searchDescription:
88
+ 'Busque páginas e seções em {version}. Use as setas para escolher um resultado e Enter para abri-lo.',
89
+ loadingSearch: 'Carregando busca…',
90
+ searchUnavailable: 'A busca está indisponível.',
91
+ tryAgain: 'Tentar novamente',
92
+ typeToSearch: 'Digite para buscar nesta documentação.',
93
+ noResults: 'Nenhum resultado encontrado.',
94
+ navigate: 'Navegar',
95
+ open: 'Abrir',
96
+ close: 'Fechar',
97
+ closeSearch: 'Fechar busca',
98
+ enlargeImage: 'Ampliar imagem: {alt}',
99
+ closeImage: 'Fechar imagem',
100
+ imagePreviewDescription:
101
+ 'Pressione Escape ou use o botão de fechar para voltar ao documento.',
102
+ copyCode: 'Copiar código',
103
+ wrapCode: 'Quebrar linhas',
104
+ codeCopied: 'Código copiado para a área de transferência.',
105
+ copyCodeError:
106
+ 'Não foi possível copiar. Selecione o código e copie manualmente.',
107
+ copyRetry: 'Não foi possível copiar. Tente novamente ou copie manualmente.',
108
+ copied: 'Copiado!',
109
+ copyTab: 'Copiar aba ativa',
110
+ tabCopied: 'Conteúdo da aba copiado para a área de transferência.',
111
+ copyTabError:
112
+ 'Não foi possível copiar. Selecione o conteúdo da aba e copie manualmente.',
113
+ code: 'Código',
114
+ folderTree: 'Pastas',
115
+ table: 'Tabela',
116
+ current: 'Atual',
117
+ version: 'Versão {version}',
118
+ documentationVersion: 'Versão da documentação: {version}',
119
+ archived: 'Arquivadas',
120
+ archivedVersion: 'Arquivada',
121
+ loadingVersions: 'Carregando versões',
122
+ preReleaseTitle: 'Documentação de pré-lançamento',
123
+ outdatedTitle: 'Documentação desatualizada',
124
+ preReleaseMessage: 'Esta é uma versão de pré-lançamento da documentação.',
125
+ outdatedMessage: 'Esta é uma versão desatualizada da documentação.',
126
+ backToCurrent: 'Voltar para a versão atual',
127
+ poweredBy: 'Feito com',
128
+ developedBy: 'Desenvolvido por',
129
+ modeToggle: 'Alternar tema',
130
+ documentationReady: 'Sua documentação está pronta.',
131
+ addDocument: 'Adicione um arquivo .mdx dentro de docs/.',
132
+ notFound: 'Página não encontrada',
133
+ notFoundDescription: 'Não encontramos o que você estava procurando.',
134
+ brokenLink:
135
+ 'Entre em contato com o responsável pelo site que indicou este link e informe que ele não está funcionando.',
136
+ backToDocs: 'Voltar para a documentação',
137
+ } satisfies InterfaceMessages
138
+
139
+ /** Match the locale, then its base language; unsupported languages use English. */
140
+ export function resolveInterfaceLocale(lang = 'en'): InterfaceLocale {
141
+ const normalized = lang.trim().replaceAll('_', '-').toLowerCase()
142
+ if (normalized === 'pt-br' || normalized.split('-')[0] === 'pt')
143
+ return 'pt-BR'
144
+ return 'en'
145
+ }
146
+
147
+ export function getMessages(lang?: string): InterfaceMessages {
148
+ return resolveInterfaceLocale(lang) === 'pt-BR' ? ptBR : en
149
+ }
150
+
151
+ export function formatMessage(message: string, values: Record<string, string>) {
152
+ return message.replace(
153
+ /\{(\w+)\}/g,
154
+ (placeholder, key: string) => values[key] ?? placeholder
155
+ )
156
+ }
@@ -0,0 +1,29 @@
1
+ 'use client'
2
+
3
+ import { createContext, useContext, type ReactNode } from 'react'
4
+ import { getMessages, resolveInterfaceLocale } from '../lib/i18n'
5
+
6
+ const LocaleContext = createContext('en')
7
+
8
+ /** Use the configured locale on both server rendering and hydration. */
9
+ export function LocaleProvider({
10
+ lang,
11
+ children,
12
+ }: {
13
+ lang?: string
14
+ children: ReactNode
15
+ }) {
16
+ return (
17
+ <LocaleContext.Provider value={resolveInterfaceLocale(lang)}>
18
+ {children}
19
+ </LocaleContext.Provider>
20
+ )
21
+ }
22
+
23
+ export function useInterfaceLocale() {
24
+ return useContext(LocaleContext)
25
+ }
26
+
27
+ export function useMessages() {
28
+ return getMessages(useInterfaceLocale())
29
+ }
@@ -2,6 +2,7 @@
2
2
  @source "../**/*.{ts,tsx}";
3
3
 
4
4
  @import 'tw-animate-css';
5
+ @import './shadcn.css';
5
6
 
6
7
  @custom-variant dark (&:is(.dark *));
7
8