Skip to content
Merged
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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Breaking one means the work is rejected.
| **Never name yourself** | Not in code, docs, commits or PR text. No `Co-Authored-By`, no "Generated with", no tool name. The repo owner is the author. This overrides any default that says otherwise. |
| **No dev server** | No `npm run dev`, no `wxt`. It cannot show you the UI. Ask the owner to check by eye (section 3). |
| **Never commit, push or merge unprompted** | Only when the owner says so in this conversation. If you think one is due, ask and wait for an explicit yes. Never touch `main`. |
| **Friendly Persian UI text** | Buttons and messages sound like a helpful person, not a form. Examples in `src/components/ui/README.md`. |
| **Friendly Persian UI text** | Buttons and messages sound like a helpful person, not a form. Text lives in the catalog under `src/common/i18n/` and is read with `t()`; never write Persian inline. No tanween (`ً`) and no single full stop that ends a sentence (keep `...` / `…`). Full rules in `.agents/TYPOGRAPHY.md`; tone examples in `src/components/ui/README.md`. |
| **Root cause, not symptom** | Trace a bug to where it starts. A patch that hides the symptom is rejected even if it looks fixed. |
| **No opportunistic changes** | Touch only what the task needs. Mention anything else you saw; do not fix it unless asked. |

Expand All @@ -49,6 +49,7 @@ Breaking one means the work is rejected.
- **Old browsers.** Chrome 109 and Firefox 115. See `entrypoints/README.md`; `browser-baseline.test.ts` rejects the known newer APIs.
- **Names that are data.** Storage keys, analytics events and widget ids are written where you cannot reach them. Rename the constant, never the string.
- **Tests.** When a section gains logic worth covering, put it in a dependency free file and test it there. See `src/README.md`.
- **Text only through `t()` from `@/common/i18n`.** A Persian letter in code outside `src/common/i18n/` fails `i18n.test.ts` unless the path is on that test's allowlist. Catalog values must follow `.agents/TYPOGRAPHY.md`: no tanween (`ً`), and no lone `.` that ends a sentence (`...` / `…` stay).
- **Say when you are unsure.** If a package behaves unexpectedly, read the docs for the exact version pinned here before you ship a guess.

## 5. Verify
Expand Down Expand Up @@ -116,7 +117,7 @@ Every contributor's machine is different. Do not assume an operating system, a s
| Layers, where a file goes, names, imports, tests | `src/README.md` |
| Components, modals, accessibility, wording | `src/components/ui/README.md` |
| Colour, themes, radius, motion, stylesheets | `src/styles/README.md` |
| Storage, events, animation, toasts, names that are data | `src/common/README.md` |
| Storage, events, animation, toasts, i18n, names that are data | `src/common/README.md` |
| Server state, query keys and the API docs | `src/services/README.md` |
| Icons | `src/icons/README.md` |
| Features | `src/features/README.md` |
Expand Down
6 changes: 3 additions & 3 deletions src/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,18 +97,18 @@ kebab-case everywhere. A file is a tsx file exactly when it contains JSX. A role
| `src/__tests__/hygiene.test.ts` | no assistant names, a comment count per file that never moves, no stray `console.log` |
| `src/__tests__/data-names.test.ts` | storage keys, analytics events and widget ids keep their names |
| `src/__tests__/browser-baseline.test.ts` | nothing newer than Chrome 109 and Firefox 115 |
| `src/__tests__/i18n.test.ts` | no Persian outside `src/common/i18n/` and a short allowlist; every catalog key is used; `t()` keys are never built |

What no test can show: how a screen looks, whether Persian copy sounds friendly, whether the pet loop moves the same. Those are the owner's visual check.

## Section guides

Each folder with its own README owns the rules for that part.
Each folder README owns the rules for that part.

| Section | README |
|---|---|
| UI primitives | `components/ui/README.md` |
| Shared components | `components/README.md` |
| Storage, events, motion, toast | `common/README.md` |
| Storage, events, motion, toast, i18n | `common/README.md` |
| Hooks, context | `hooks/README.md`, `context/README.md` |
| Server state | `services/README.md` |
| Icons | `icons/README.md` |
Expand Down
7 changes: 5 additions & 2 deletions src/__tests__/architecture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -464,7 +464,9 @@ describe('imports', () => {
it('keep index files to the barrels', () => {
const extra = srcFiles.filter(
(path) =>
/^index\.tsx?$/.test(nameOf(path)) && !BARRELS.includes(parentOf(path))
/^index\.tsx?$/.test(nameOf(path)) &&
!BARRELS.includes(parentOf(path)) &&
!path.startsWith('src/common/i18n/')
)
expect(extra).toEqual([])
})
Expand Down Expand Up @@ -780,12 +782,13 @@ describe('gateways', () => {
describe('global folders', () => {
it('keep src/common to its gateways and role folders', () => {
const gateways = ['motion.tsx', 'storage.ts', 'toast.tsx', 'README.md']
const roleFolders = ['constants', 'types', 'utils', '__tests__']
const roleFolders = ['constants', 'types', 'utils', '__tests__', 'i18n']
const stray = srcFiles
.filter((path) => path.startsWith('src/common/'))
.filter((path) => {
const parts = path.slice('src/common/'.length).split('/')
if (parts.length === 1) return !gateways.includes(parts[0])
if (parts[0] === 'i18n') return false
return parts.length > 2 || !roleFolders.includes(parts[0])
})
expect(stray).toEqual([])
Expand Down
2 changes: 1 addition & 1 deletion src/__tests__/docs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const OTHER_DOCS = [
'.github/Api-doc.fa.md',
]

const UNCHECKED_DOCS = ['AGENTS.local.md', 'SOCIAL_ROADMAP.md']
const UNCHECKED_DOCS = ['AGENTS.local.md', 'SOCIAL_ROADMAP.md', '.agents/TYPOGRAPHY.md']

const WIDGET_README = /^src\/features\/widgets\/[a-z0-9-]+\/README\.md$/
const LONG_READMES: Record<string, number> = {
Expand Down
2 changes: 0 additions & 2 deletions src/__tests__/hygiene.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,9 @@ const COMMENT_BASELINE: Record<string, number> = {
'src/features/widgets/bookmark/components/modal/advanced-modal.tsx': 1,
'src/features/widgets/clock/variants/clock-analog.tsx': 9,
'src/features/widgets/clock/variants/clock-flip.tsx': 1,
'src/features/widgets/components/widget-help-modal.tsx': 5,
'src/features/widgets/google-calendar/hooks/use-google-calendar-schedule.ts': 1,
'src/features/widgets/utils/browser-bookmarks.ts': 7,
'src/features/widgets/utils/icon.ts': 1,
'src/features/widgets/utils/layout-engine/types.ts': 1,
'src/features/widgets/utils/migration.ts': 3,
'src/services/auth/auth-service.hook.ts': 1,
'src/services/date/get-events.hook.ts': 1,
Expand Down
279 changes: 279 additions & 0 deletions src/__tests__/i18n.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,279 @@
import { describe, expect, it } from 'bun:test'
import { readdirSync, readFileSync, statSync } from 'node:fs'
import { join } from 'node:path'
import ts from 'typescript'
import { fa } from '@/common/i18n/fa'
import { t } from '@/common/i18n'

const PERSIAN = /[\u0600-\u06FF]/
const ROOTS = ['src', 'entrypoints', 'background']

const ALLOWLIST: { path: string; reason: string }[] = [
{
path: 'src/common/utils/date-events.ts',
reason: 'Hijri month-name table indexed by calendar code; locale data, not UI copy',
},
{
path: 'src/common/constants/config-keys.ts',
reason: 'ConfigKey.VERSION_NAME is a saved/compared value; display uses common.config.versionName',
},
{
path: 'src/common/constants/default-wallpaper.ts',
reason: 'DEFAULT_WALLPAPER.name is persisted wallpaper identity data, not movable UI copy',
},
{
path: 'src/features/widgets/constants.ts',
reason: 'PERSIAN_WEEKDAYS short/full table indexed by calendar code; locale data, not UI copy',
},
{
path: 'src/features/widgets/tools/components/religious-time.tsx',
reason: 'DAILY_ZIKR keys are weekday names matched against moment-jalaali dddd output; locale lookup keys',
},
{
path: 'src/features/widgets/weather/utils/clean-city-name.ts',
reason: 'COUNTY_PREFIX regex strips شهرستان from city names; locale processing, not UI copy',
},
{
path: 'src/features/widgets/wigi-arz/utils/get-price-change.ts',
reason: 'Percent formatting uses toLocaleString(fa-IR) plus ٪; number/digit work out of i18n scope',
},
]

function walk(dir: string): string[] {
return readdirSync(dir).flatMap((name) => {
const path = join(dir, name)
if (statSync(path).isDirectory()) {
if (name === '__tests__' || name === 'i18n' || name === 'node_modules')
return []
return walk(path)
}
return [path.split('\\').join('/')]
})
}

function scanFiles(): string[] {
return ROOTS.flatMap((root) => {
try {
return walk(root)
} catch {
return []
}
}).filter((path) => /\.(ts|tsx|html|css)$/.test(path) && !path.endsWith('.d.ts'))
}

type Hit = { path: string; line: number; text: string }

function scanFile(path: string): Hit[] {
const text = readFileSync(path, 'utf8')
if (/\.(html|css)$/.test(path)) {
return text
.split(/\r?\n/)
.flatMap((line, index) =>
PERSIAN.test(line)
? [{ path, line: index + 1, text: line.trim().slice(0, 120) }]
: []
)
}
const kind = path.endsWith('x') ? ts.ScriptKind.TSX : ts.ScriptKind.TS
const source = ts.createSourceFile(path, text, ts.ScriptTarget.Latest, true, kind)
const hits: Hit[] = []
const visit = (node: ts.Node) => {
if (
ts.isStringLiteral(node) ||
ts.isNoSubstitutionTemplateLiteral(node) ||
ts.isRegularExpressionLiteral(node)
) {
if (PERSIAN.test(node.text)) {
const { line } = source.getLineAndCharacterOfPosition(
node.getStart(source)
)
hits.push({ path, line: line + 1, text: node.text.slice(0, 120) })
}
} else if (ts.isTemplateExpression(node)) {
const parts = [
node.head.text,
...node.templateSpans.map((span) => span.literal.text),
]
if (parts.some((part) => PERSIAN.test(part))) {
const { line } = source.getLineAndCharacterOfPosition(
node.getStart(source)
)
hits.push({
path,
line: line + 1,
text: parts.join('[…]').slice(0, 120),
})
}
} else if (ts.isJsxText(node)) {
const value = node.getText(source)
if (PERSIAN.test(value)) {
const { line } = source.getLineAndCharacterOfPosition(
node.getStart(source)
)
hits.push({ path, line: line + 1, text: value.trim().slice(0, 120) })
}
} else if (ts.isIdentifier(node) && PERSIAN.test(node.text)) {
const { line } = source.getLineAndCharacterOfPosition(node.getStart(source))
hits.push({ path, line: line + 1, text: node.text })
}
ts.forEachChild(node, visit)
}
visit(source)
return hits
}

function persianCommentCount(): number {
let count = 0
for (const path of scanFiles().filter((p) => /\.tsx?$/.test(p))) {
const text = readFileSync(path, 'utf8')
if (!PERSIAN.test(text) || (!text.includes('//') && !text.includes('/*')))
continue
const kind = path.endsWith('x') ? ts.ScriptKind.TSX : ts.ScriptKind.TS
const source = ts.createSourceFile(path, text, ts.ScriptTarget.Latest, true, kind)
const seen = new Map<number, ts.CommentRange>()
const visit = (node: ts.Node) => {
for (const range of ts.getLeadingCommentRanges(text, node.getFullStart()) ??
[]) {
seen.set(range.pos, range)
}
for (const range of ts.getTrailingCommentRanges(text, node.getEnd()) ?? []) {
seen.set(range.pos, range)
}
ts.forEachChild(node, visit)
}
visit(source)
for (const range of seen.values()) {
if (PERSIAN.test(text.slice(range.pos, range.end))) count += 1
}
}
return count
}

function collectHits(): Hit[] {
const allow = new Set(ALLOWLIST.map((entry) => entry.path))
return scanFiles().flatMap((path) => (allow.has(path) ? [] : scanFile(path)))
}

describe('i18n catalog', () => {
it('keeps no Persian text outside the catalog and the allowlist', () => {
const hits = collectHits()
const report = hits.map(
(hit) =>
`${hit.path}:${hit.line}: ${JSON.stringify(hit.text)} — move this text to src/common/i18n/fa/<area>.ts and read it with t()`
)
expect(report).toEqual([])
})

it('keeps every allowlist entry justified by a real hit', () => {
const stale = ALLOWLIST.filter((entry) => scanFile(entry.path).length === 0).map(
(entry) =>
`${entry.path}: allowlisted (${entry.reason}) but has no Persian hit`
)
expect(stale).toEqual([])
})

it('reports how many Persian comments remain in code', () => {
expect(persianCommentCount()).toBeGreaterThanOrEqual(0)
})

it('references every catalog key from at least one t() call or MessageKey map', () => {
const keys = new Set(Object.keys(fa))
const used = new Set<string>()
const callPattern = /\bt\(\s*['"]([^'"]+)['"]/g
const mark = (text: string) => {
for (const match of text.matchAll(callPattern)) {
used.add(match[1])
}
const kind = text.includes('</') ? ts.ScriptKind.TSX : ts.ScriptKind.TS
const source = ts.createSourceFile(
'scan.ts',
text,
ts.ScriptTarget.Latest,
true,
kind
)
const visit = (node: ts.Node) => {
if (ts.isStringLiteral(node) && keys.has(node.text)) {
used.add(node.text)
}
ts.forEachChild(node, visit)
}
visit(source)
}
for (const path of scanFiles().filter((p) => /\.tsx?$/.test(p))) {
mark(readFileSync(path, 'utf8'))
}
for (const path of walk('src/common/__tests__').filter((p) =>
p.endsWith('.ts')
)) {
mark(readFileSync(path, 'utf8'))
}
const dead = [...keys].filter((key) => !used.has(key))
expect(dead).toEqual([])
})

it('never builds a t() key from a template or concatenation', () => {
const bad: string[] = []
for (const path of scanFiles().filter((p) => /\.tsx?$/.test(p))) {
const text = readFileSync(path, 'utf8')
const kind = path.endsWith('x') ? ts.ScriptKind.TSX : ts.ScriptKind.TS
const source = ts.createSourceFile(
path,
text,
ts.ScriptTarget.Latest,
true,
kind
)
const visit = (node: ts.Node) => {
if (
ts.isCallExpression(node) &&
ts.isIdentifier(node.expression) &&
node.expression.text === 't' &&
node.arguments[0]
) {
const arg = node.arguments[0]
const ok =
ts.isStringLiteral(arg) ||
ts.isNoSubstitutionTemplateLiteral(arg) ||
ts.isIdentifier(arg) ||
ts.isPropertyAccessExpression(arg) ||
ts.isElementAccessExpression(arg)
if (
!ok ||
ts.isTemplateExpression(arg) ||
ts.isBinaryExpression(arg)
) {
const { line } = source.getLineAndCharacterOfPosition(
node.getStart(source)
)
bad.push(`${path}:${line + 1}`)
}
}
ts.forEachChild(node, visit)
}
visit(source)
}
expect(bad).toEqual([])
})

it('exports a working t helper', () => {
expect(typeof t).toBe('function')
expect(t('common.coin.name')).toBe('ویج‌کوین')
})

it('keeps catalog values free of tanween and lone sentence periods', () => {
const bad = Object.entries(fa).flatMap(([key, value]) => {
const hits: string[] = []
if (/\u064B/.test(value)) {
hits.push(`${key}: tanween (ً) — write فعلا/مثلا/دقیقا without it`)
}
if (/(?<![A-Za-z0-9.])\.(?![A-Za-z0-9.])/.test(value)) {
hits.push(
`${key}: lone sentence period — drop the final . (keep ... / …)`
)
}
return hits
})
expect(bad).toEqual([])
})
})
7 changes: 6 additions & 1 deletion src/common/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ Shared code with no UI of its own, except `motion.tsx` and `toast.tsx`, which ar
| `toast.tsx` | `showToast`: the only door to react-hot-toast |
| `utils/cn.ts` | `cn()`, class merging (clsx and tailwind-merge) |
| `utils/` | Small helpers: colour, dates, error translation, timeouts |
| `types/` | Shapes shared by many features (`*.interface.ts`) |
| `i18n/` | Persian message catalog and `t()`; the only place UI copy lives |
| `types/` | Shapes shared by many features (`*.interface.ts`) |

## Storage

Expand Down Expand Up @@ -45,6 +46,10 @@ Storage keys, analytics event names and widget ids live in places you do not con

Events go through `Analytics` from `@/analytics`. The user can turn it off in settings, and the function checks that before it sends. Event names are data, like storage keys. The first argument must be a string literal, and parameters must not carry personal or free-text data.

## i18n

UI copy lives in `src/common/i18n/fa/<area>.ts` and is read with `t()` from `@/common/i18n`. Keys are flat (`area.scope.name`), typed as `MessageKey`, and never built from a template or concatenation. Name a key after its purpose (`market.coin.amountLabel`, `setting.auth.continue`), never after a file path and never with a numeric suffix. Parameters use `{name}` and are typed from the message text. A constant that needs a label stores a `MessageKey` (`labelKey`, `titleKey`, …) and the render site calls `t(...)`. Primitives take finished text from props; they do not import the catalog. Catalog wording follows `.agents/TYPOGRAPHY.md`: write `فعلا` / `مثلا` / `دقیقا` without tanween (`ً`), and do not end a UI sentence with a lone `.` (ellipsis `...` / `…` is fine; domains like `widgetify.ir` stay). Numbers and digits are not converted by i18n: a digit stays as written, and existing `toLocaleString('fa-IR')` / Jalali formatting is left alone. The short allowlist in `src/__tests__/i18n.test.ts` is only for locale data and saved/compared values, not for UI copy. A second locale later is a new `<locale>/` folder with the same keys plus a way to pick it; that is not built now.

## Animation

- Use `Motion` and `Presence` from `@/common/motion`, never `framer-motion`. The wrappers are what make optimisation mode work.
Expand Down
Loading
Loading