fix(web): keep dedicated previews and the read-card lang domain

The shared code-language table must not claim a suffix a dedicated viewer
declares, and the Host read card must keep the `lang` values it already
persisted.

- Drop `csv` from the shared table: Code registers before Spreadsheet, so
  the earlier body would take `.csv` and the browser snapshot gate fails.
- Add `readLangHintForPath`, the read card's legacy projection over the
  shared table: the 43 pre-unification suffixes keep their old short ids
  byte for byte, new suffixes use the canonical id.
- Add an ownership regression test over the full preview registry that
  turns red when `csv` returns, plus byte-level and alias-normalized tests
  for the read projection.
This commit is contained in:
mektpoy committed 2026-09-21 19:14:08 +08:00
1 parent 6945da14cb
commit b0ad792e6b
11 files changed
+238 -44

No files matched your search

@@ -230,13 +230,22 @@ const LANG_ALIASES = new Map<string, string>([
['gradle', 'groovy'],
])
/**
* Resolve a language hint to the grammar id {@link LANG_ALIASES} selects.
* @param lang - Language hint from a code surface: a shared canonical id or a legacy read-card hint.
* @returns The resolved grammar id, or `undefined` when the table aliases no grammar.
*/
export function grammarForHint(lang: string | undefined): string | undefined {
return lang === undefined ? undefined : LANG_ALIASES.get(lang.toLowerCase())
}
/**
* Whether a language hint can use the shared syntax highlighter.
* @param lang - Language hint from a code surface.
* @returns Whether the hint resolves to a supported grammar.
*/
export function supportsHighlighting(lang: string | undefined): boolean {
return lang !== undefined && LANG_ALIASES.has(lang.toLowerCase())
return grammarForHint(lang) !== undefined
}
/** All token colors resolve through `--shiki-*` custom properties (theme package sheets). */
@@ -2,9 +2,9 @@
import { afterEach, describe, expect, it } from 'vitest'
import { cleanup, renderHook, waitFor } from '@testing-library/react'
import { languageForPath as sharedLanguageForPath } from '@deepseek-ai/dsh-util-code-language'
import { languageForPath as sharedLanguageForPath, readLangHintForPath } from '@deepseek-ai/dsh-util-code-language'
import { CODE_HIGHLIGHT_EXTENSIONS, languageForPath, useCodeHighlighter } from '../src/code-highlighting.ts'
import { supportsHighlighting } from '../src/markdown/highlight.ts'
import { grammarForHint, supportsHighlighting } from '../src/markdown/highlight.ts'
afterEach(cleanup)
@@ -28,7 +28,7 @@ describe('code highlighting', () => {
it.each([
['build.bat', 'bat'], ['build.cmd', 'bat'], ['deploy.ps1', 'powershell'],
['module.psm1', 'powershell'], ['config.fish', 'fish'], ['app.properties', 'ini'],
['app.conf', 'ini'], ['.env', 'dotenv'], ['server.log', 'log'], ['rows.csv', 'csv'],
['app.conf', 'ini'], ['.env', 'dotenv'], ['server.log', 'log'],
['change.diff', 'diff'], ['fix.patch', 'diff'], ['api.http', 'http'], ['notebook.ipynb', 'json'],
['guide.rst', 'rst'], ['paper.tex', 'latex'], ['style.sty', 'latex'], ['doc.cls', 'latex'],
['refs.bib', 'bibtex'], ['Info.plist', 'xml'], ['logo.svg', 'xml'], ['manual.adoc', 'asciidoc'],
@@ -46,11 +46,25 @@ describe('code highlighting', () => {
})
it('resolves through the one shared extension table, never a local copy', () => {
// A second table re-introduced here fails this identity assertion before it
// can drift from the read card's file-extension hints.
// A second canonical table re-introduced here fails this identity assertion
// before it can drift from the shared extension table.
expect(languageForPath).toBe(sharedLanguageForPath)
})
it('reaches one grammar from the shared canonical id and the read card legacy hint', () => {
// The read card persists the old short ids while this surface reads the
// canonical table directly; both must select the same grammar, so a
// persisted hint can never render differently from this surface's own
// selection. This is the alias-normalized check the identity assertion
// cannot make now that the read projection intentionally differs.
for (const extension of CODE_HIGHLIGHT_EXTENSIONS) {
const path = `file.${extension}`
const readHint = readLangHintForPath(path)
expect(readHint, extension).toBeDefined()
expect(grammarForHint(readHint), extension).toBe(grammarForHint(languageForPath(path)))
}
})
it('keeps every registered suffix highlightable by the shared primitive', () => {
expect(new Set(CODE_HIGHLIGHT_EXTENSIONS).size).toBe(CODE_HIGHLIGHT_EXTENSIONS.length)
for (const extension of CODE_HIGHLIGHT_EXTENSIONS) {
@@ -77,7 +77,7 @@ async function boot() {
const fiber = ctx.plugin({ inject: [...inject], apply })
onTestFinished(async () => { await fiber.dispose() })
await fiber.await()
return { tabs, registered, dictionaries, fiber, read, readBytes }
return { ctx, tabs, registered, dictionaries, fiber, read, readBytes }
}
describe('ui-sidebar-documentpreview apply', () => {
@@ -114,6 +114,36 @@ describe('ui-sidebar-documentpreview apply', () => {
expect(dictionaries.size).toBe(0)
})
it('leaves every declared suffix with its owning preview and never lets the shared highlighter claim a later viewer\'s suffix', async () => {
const { ctx } = await boot()
const previews = ctx.get('documentPreviews')
if (previews === undefined) throw new Error('documentPreviews was not provided')
// apply() registers in production order: text, markdown, html, image, pdf,
// code, office, excel. The shared Code body is the generic highlighter, so
// where it collides with an EARLIER dedicated body the earlier body keeps the
// suffix; any other collision means a later dedicated viewer lost a suffix it
// must render. Listing csv under Code makes Spreadsheet the loser.
const sharedHighlighter = '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/code'
const owners = new Map<string, string>()
for (const definition of previews.getSnapshot()) {
for (const extension of new Set([...definition.extensions, ...definition.binaryExtensions ?? []])) {
const winner = previews.candidates(`file.${extension}`)[0]
const earlier = owners.get(extension)
if (earlier === undefined) {
expect(winner?.id, `${definition.id} declares .${extension}`).toBe(definition.id)
} else {
expect(winner?.id, `.${extension} must stay with the earlier ${earlier}`).toBe(earlier)
expect(definition.id, `later body ${definition.id} must not collide on .${extension}`).toBe(sharedHighlighter)
}
owners.set(extension, earlier ?? definition.id)
}
}
// Delimited spreadsheets stay with the Spreadsheet viewer, not the Code body.
const excel = '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/excel'
expect(previews.candidates('table.csv')[0]?.id).toBe(excel)
expect(previews.candidates('table.tsv')[0]?.id).toBe(excel)
})
it('injects paged and byte Remote reads independently of resource metadata', async () => {
const { registered, read, readBytes } = await boot()
const registration = registered.find(entry => entry.component === TextPreview)
+9 -6
View File
@@ -170,14 +170,17 @@ ${body}
}
/**
* Derive a syntax-highlighting language id from a read path's file extension via
* the extension table shared with the Client code surfaces; see
* `@deepseek-ai/dsh-util-code-language` for the table and the path rules (both
* separators, a leading dot as the extension separator, and prototype-key safety).
* Derive the persisted `lang` hint from a read path's file extension. The shared
* table in `@deepseek-ai/dsh-util-code-language` owns the recognized suffixes and
* the path rules (both separators, a leading dot as the extension separator, and
* prototype-key safety); `readLangHintForPath` projects the read card's
* historical short ids over it, so a suffix the pre-unification read table
* recognized keeps its byte-identical old value while a newly added suffix uses
* the canonical id.
* @param path - the model-facing path the read reported.
* @returns the language id, or `undefined` when the extension maps to none.
* @returns the persisted language hint, or `undefined` when the extension maps to none.
*/
export { languageForPath as langFromPath } from '@deepseek-ai/dsh-util-code-language'
export { readLangHintForPath as langFromPath } from '@deepseek-ai/dsh-util-code-language'
/**
* The `read` tool's private `tool/result` `meta` payload: the structured
+61 -14
View File
@@ -6,7 +6,7 @@
*/
import { describe, expect, it } from 'vitest'
import { languageForPath } from '@deepseek-ai/dsh-util-code-language'
import { CODE_HIGHLIGHT_EXTENSIONS, languageForPath } from '@deepseek-ai/dsh-util-code-language'
import { buildWindow, langFromPath, readMetaFromMeta, READ_MAX_BYTES, READ_MAX_LINE_LENGTH } from '../src/read-render.ts'
import type { ReadWindow } from '../src/read-render.ts'
@@ -118,30 +118,77 @@ describe('buildWindow', () => {
})
})
/**
* The pre-unification read table's extension-to-hint mapping, transcribed
* verbatim. A read card persisted one of these strings, so changing any value
* changes replay-visible output for an already-recorded session.
*/
const MASTER_READ_LANG_BY_EXTENSION: Readonly<Record<string, string>> = {
ts: 'ts', tsx: 'tsx', mts: 'ts', cts: 'ts',
js: 'js', jsx: 'jsx', mjs: 'js', cjs: 'js',
json: 'json', jsonc: 'json',
py: 'py', rb: 'rb', go: 'go', rs: 'rs', java: 'java',
c: 'c', h: 'c', cc: 'cpp', cpp: 'cpp', hpp: 'cpp', cxx: 'cpp',
cs: 'cs', kt: 'kotlin', swift: 'swift', php: 'php',
sh: 'sh', bash: 'sh', zsh: 'sh',
yaml: 'yaml', yml: 'yaml', toml: 'toml', ini: 'ini',
md: 'md', markdown: 'md', mdx: 'mdx',
html: 'html', htm: 'html', css: 'css', scss: 'scss', less: 'less',
sql: 'sql', xml: 'xml', lua: 'lua',
}
describe('langFromPath', () => {
it('resolves through the one shared extension table, never a local copy', () => {
// The read card and the Client code surfaces must agree on every path; a
// local table re-introduced on either side makes one of these identity
// assertions fail before the two can drift in language hints.
expect(langFromPath).toBe(languageForPath)
for (const path of ['src/a.ts', 'conf.yml', 'README.md', 'build.PS1', 'C:\\src\\main.rs']) {
expect(langFromPath(path), path).toBe(languageForPath(path))
it('keeps every pre-unification suffix byte-identical to the old persisted hint', () => {
expect(Object.keys(MASTER_READ_LANG_BY_EXTENSION)).toHaveLength(43)
for (const [extension, hint] of Object.entries(MASTER_READ_LANG_BY_EXTENSION)) {
expect(langFromPath(`file.${extension}`), extension).toBe(hint)
}
})
it('maps a known extension to its canonical language id, case-insensitively', () => {
expect(langFromPath('src/a.ts')).toBe('typescript')
expect(langFromPath('src/a.TSX')).toBe('typescript')
expect(langFromPath('/abs/module.mjs')).toBe('javascript')
it('uses the shared canonical id for every suffix the old read table did not know', () => {
// The legacy projection is keyed by exactly the old extensions, so a suffix
// the shared table adds later must fall through to its canonical id instead
// of inheriting a stale short hint.
const legacy = new Set(Object.keys(MASTER_READ_LANG_BY_EXTENSION))
for (const extension of CODE_HIGHLIGHT_EXTENSIONS) {
if (legacy.has(extension)) continue
expect(langFromPath(`file.${extension}`), extension).toBe(languageForPath(`file.${extension}`))
}
})
it('gives a newly added suffix its shared canonical id', () => {
// These suffixes were undefined in the old build, so they are free to carry
// the canonical grammar id instead of a short hint.
expect(langFromPath('build.ps1')).toBe('powershell')
expect(langFromPath('deploy.bat')).toBe('bat')
expect(langFromPath('.env')).toBe('dotenv')
expect(langFromPath('server.log')).toBe('log')
expect(langFromPath('message.proto')).toBe('proto')
expect(langFromPath('infra.tf')).toBe('hcl')
expect(langFromPath('paper.tex')).toBe('latex')
})
it('maps a known extension to its old short hint, case-insensitively', () => {
expect(langFromPath('src/a.ts')).toBe('ts')
expect(langFromPath('src/a.TSX')).toBe('tsx')
expect(langFromPath('/abs/module.mjs')).toBe('js')
expect(langFromPath('conf.yml')).toBe('yaml')
expect(langFromPath('README.md')).toBe('markdown')
expect(langFromPath('README.md')).toBe('md')
})
it('keeps the canonical ids the Client code surfaces use out of the persisted hint', () => {
// The Client reads the shared table directly; only this projection owns the
// persisted value, so the two intentionally differ for the old suffixes.
expect(languageForPath('src/a.ts')).toBe('typescript')
expect(languageForPath('README.md')).toBe('markdown')
expect(langFromPath('src/a.ts')).not.toBe(languageForPath('src/a.ts'))
})
it('reads the extension after the last path segment and last dot', () => {
expect(langFromPath('a.py.bak')).toBeUndefined()
expect(langFromPath('archive.tar.gz')).toBeUndefined()
expect(langFromPath('/dir.py/plain')).toBeUndefined()
expect(langFromPath('C:\\src\\main.rs')).toBe('rust')
expect(langFromPath('C:\\src\\main.rs')).toBe('rs')
})
it('returns undefined for a dotfile, an extensionless name, and an unknown extension', () => {
+2 -2
View File
@@ -352,7 +352,7 @@ describe('read tool', () => {
offset: 1,
lines: [{ number: 1, text: 'const x = 1' }, { number: 2, text: 'const y = 2' }],
totalLines: 2,
lang: 'typescript',
lang: 'ts',
})
const view = ctx.tools.get('read')?.presentResult?.({ file_path: 'a.ts' }, result)
expect(view).toEqual({
@@ -361,7 +361,7 @@ describe('read tool', () => {
offset: 1,
lines: [{ number: 1, text: 'const x = 1' }, { number: 2, text: 'const y = 2' }],
totalLines: 2,
lang: 'typescript',
lang: 'ts',
content: [{ type: 'text', text: '1: const x = 1\n2: const y = 2\n\n(End of file - total 2 lines)' }],
})
})
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/util/code-language/README.md
README.md: a65c28cc11e5aa112b5450b9ec42b23dce764c9d
README.zh.md: cc15751853a8ab8b796bf16f989ee001d7347d9e
README.md: adb04ba3283e38d1b708f1831ffd41c0f08ce967
README.zh.md: c2ced098f48946abdc4affae6b7b3fa09bd439fb
+2 -2
View File
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
The repository's one file-extension to syntax-highlighting language table, shared by the Client's document Code preview and diff review and by the Host read tool's persisted `lang` hint. `languageForPath` maps a filename or path to a canonical grammar id case-insensitively, recognizing both path separators and treating a leading dot as the extension separator (`.env` resolves to `dotenv`). `CODE_HIGHLIGHT_EXTENSIONS` lists every recognized suffix for a preview registry. The package is browser-safe, has no Cordis service or runtime state, and leaves the actual tokenization to the Client highlighter.
The repository's one file-extension to syntax-highlighting language table, shared by the Client's document Code preview and diff review and by the Host read tool's persisted `lang` hint. `languageForPath` maps a path to a canonical grammar id case-insensitively, recognizing both path separators and treating a leading dot as the separator (`.env` is `dotenv`); `CODE_HIGHLIGHT_EXTENSIONS` lists every suffix for a preview registry. `readLangHintForPath` projects the read card's older short ids over the table so previously persisted values stay byte-identical. The package is browser-safe, stateless, and leaves tokenization to the Client highlighter.
## Table of Contents
@@ -24,7 +24,7 @@ The repository's one file-extension to syntax-highlighting language table, share
The table lives in [`src/index.ts`](src/index.ts). Each key is a canonical language id — the grammar id the Client highlighter resolves — and each value is the lowercase extensions without the dot that select it. `languageForPath(path)` takes the text after the last dot of the final path segment, lowercases it, and looks it up in a `Map`; a `Map` keeps a filename such as `foo.constructor` from resolving an `Object.prototype` member. An unlisted dotfile (`.gitignore`), an absent extension, a trailing dot, and an unknown suffix all return `undefined`, which every consumer renders as plain text; a leading dot is still the separator, so `.env` resolves to `dotenv`. Both `/` and `\` separate path segments, so a Windows path resolves like a POSIX one.
The set is curated for common source, config, script, data, and markup extensions rather than mirroring a full language registry. Extensions with no matching grammar map to the nearest one (`properties` to `ini`, whose registration carries the `properties` alias); certificate and lock extensions (`pem`, `crt`, `key`, `cer`, `lock`) stay unlisted. A consumer that needs the Client highlighter to actually tokenize a language still depends on that grammar being registered there — an id without a loaded grammar renders as plain text rather than failing.
The set is curated for common source, config, script, data, and markup extensions rather than mirroring a full language registry. Extensions with no matching grammar map to the nearest one (`properties` to `ini`, whose registration carries the `properties` alias); certificate and lock extensions (`pem`, `crt`, `key`, `cer`, `lock`) stay unlisted, and `csv` stays unlisted because the Spreadsheet preview declares the suffix and must keep it, so the earlier Code registration cannot claim it. `readLangHintForPath` is a projection over this table: a suffix the pre-unification read table recognized returns its old short id, a suffix present only here returns its canonical id, and an unrecognized suffix returns `undefined`. A consumer that needs the Client highlighter to actually tokenize a language still depends on that grammar being registered there — an id without a loaded grammar renders as plain text rather than failing.
-----
+2 -2
View File
@@ -9,7 +9,7 @@ kind: "package-library"
## 概述
本仓库唯一的「文件扩展名 → 语法高亮语言」表,供 Client 的文档 Code 预览、diff 审阅与 Host read 工具持久化的 `lang` 提示共同使用。`languageForPath` 以大小写不敏感的方式把文件名或路径映射为规范化 grammar id,识别两种路径分隔符,并把前导点视为扩展名分隔符(`.env` 解析为 `dotenv`)。`CODE_HIGHLIGHT_EXTENSIONS` 列出全部已识别后缀,供预览注册使用。该包可在浏览器使用,不提供 Cordis service,也不持有运行时状态;真正的分词仍由 Client 高亮器负责。
本仓库唯一的「文件扩展名 → 语法高亮语言」表,供 Client 的文档 Code 预览、diff 审阅与 Host read 工具持久化的 `lang` 提示共同使用。`languageForPath` 以大小写不敏感的方式把文件名或路径映射为规范化 grammar id,识别两种路径分隔符,并把前导点视为扩展名分隔符(`.env` 解析为 `dotenv`)。`CODE_HIGHLIGHT_EXTENSIONS` 列出全部已识别后缀,供预览注册使用。Host read 卡片保留统一前的短 id:`readLangHintForPath` 在同一张表上做投影——旧 read 表已识别的后缀保持逐字节相同的旧值,新增后缀则使用规范 id。该包可在浏览器使用,不提供 Cordis service,也不持有运行时状态;真正的分词仍由 Client 高亮器负责。
## 目录
@@ -24,7 +24,7 @@ kind: "package-library"
表位于 [`src/index.ts`](src/index.ts)。每个键是规范化语言 id——即 Client 高亮器解析的 grammar id;每个值是选中它的、不含点的小写扩展名。`languageForPath(path)` 取路径最后一段的最后一个点之后的文本、转小写后在 `Map` 中查找;使用 `Map` 可避免 `foo.constructor` 这类文件名解析到 `Object.prototype` 成员。未列入表的 dotfile(`.gitignore`)、无扩展名、结尾点、未知后缀都返回 `undefined`,各消费方一律按纯文本渲染;前导点仍算分隔符,因此 `.env` 解析为 `dotenv`。`/` 与 `\` 都算路径分隔符,因此 Windows 路径与 POSIX 路径解析一致。
该集合按常见源码、配置、脚本、数据与标记扩展名精选,并非完整语言登记表。没有对应 grammar 的扩展名映射到最接近的 grammar(`properties` 映射到 `ini`,该 grammar 自带 `properties` alias);证书与锁文件扩展名(`pem`、`crt`、`key`、`cer`、`lock`)保持不表。消费方若希望 Client 高亮器真正分词,仍取决于该 grammar 已在其中注册——没有加载 grammar 的 id 会按纯文本渲染,而不是报错。
该集合按常见源码、配置、脚本、数据与标记扩展名精选,并非完整语言登记表。没有对应 grammar 的扩展名映射到最接近的 grammar(`properties` 映射到 `ini`,该 grammar 自带 `properties` alias);证书与锁文件扩展名(`pem`、`crt`、`key`、`cer`、`lock`)保持不表,`csv` 同样保持不表:Spreadsheet 预览声明该后缀且必须保留,因此更早注册的 Code 不能抢占它。消费方若希望 Client 高亮器真正分词,仍取决于该 grammar 已在其中注册——没有加载 grammar 的 id 会按纯文本渲染,而不是报错。
-----
+53 -7
View File
@@ -5,6 +5,9 @@
* highlighter's alias table resolves, so a language returned here reaches
* `highlightLines`/`highlightToHtml` unchanged; a filename outside the table, or
* one naming a language the highlighter does not register, renders as plain text.
* The read card predates the shared table, so {@link readLangHintForPath} projects
* its historical short ids over this table for the suffixes the old read table
* already recognized, keeping those persisted values byte-identical.
* @module @deepseek-ai/dsh-util-code-language
*/
@@ -17,7 +20,9 @@
* grammar does not exist map to the nearest available grammar
* (`properties` to `ini`, whose registration carries the `properties` alias) or
* stay unlisted. Certificate and lock extensions (`pem`, `crt`, `key`, `cer`,
* `lock`) intentionally stay unlisted.
* `lock`) stay unlisted, and `csv` is deliberately absent: the Spreadsheet
* preview declares it and must keep it, so listing it here would let the earlier
* Code registration take the suffix.
*/
const LANGUAGE_EXTENSIONS: Readonly<Record<string, readonly string[]>> = {
typescript: ['ts', 'tsx', 'mts', 'cts'],
@@ -41,7 +46,6 @@ const LANGUAGE_EXTENSIONS: Readonly<Record<string, readonly string[]>> = {
ini: ['ini', 'conf', 'cfg', 'properties'],
dotenv: ['env'],
log: ['log'],
csv: ['csv'],
diff: ['diff', 'patch'],
http: ['http'],
markdown: ['md', 'markdown'],
@@ -95,6 +99,34 @@ const LANGUAGES = new Map(Object.entries(LANGUAGE_EXTENSIONS)
/** Every recognized filename suffix, one entry each; a document-preview registry uses it to claim Code-rendered bodies. */
export const CODE_HIGHLIGHT_EXTENSIONS: readonly string[] = [...LANGUAGES.keys()]
/**
* The read card's historical short language hints for the suffixes its
* pre-unification table recognized. Keys are shared-table extensions, so the
* recognized set still derives from {@link LANGUAGE_EXTENSIONS}; values preserve
* the old persisted `lang` strings byte for byte. An extension absent here but
* present in the shared table is new and uses its canonical id.
*/
const READ_LANG_BY_EXTENSION = new Map<string, string>(Object.entries({
ts: 'ts', tsx: 'tsx', mts: 'ts', cts: 'ts',
js: 'js', jsx: 'jsx', mjs: 'js', cjs: 'js',
json: 'json', jsonc: 'json',
py: 'py', rb: 'rb', go: 'go', rs: 'rs', java: 'java',
c: 'c', h: 'c', cc: 'cpp', cpp: 'cpp', hpp: 'cpp', cxx: 'cpp',
cs: 'cs', kt: 'kotlin', swift: 'swift', php: 'php',
sh: 'sh', bash: 'sh', zsh: 'sh',
yaml: 'yaml', yml: 'yaml', toml: 'toml', ini: 'ini',
md: 'md', markdown: 'md', mdx: 'mdx',
html: 'html', htm: 'html', css: 'css', scss: 'scss', less: 'less',
sql: 'sql', xml: 'xml', lua: 'lua',
}))
function extensionForPath(path: string): string | undefined {
const slash = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
const base = path.slice(slash + 1)
const dot = base.lastIndexOf('.')
return dot < 0 ? undefined : base.slice(dot + 1).toLowerCase()
}
/**
* Derive the syntax-highlighting language from a filename or path, case
* insensitively. The extension is the text after the last dot of the final path
@@ -106,9 +138,23 @@ export const CODE_HIGHLIGHT_EXTENSIONS: readonly string[] = [...LANGUAGES.keys()
* @returns the canonical language id, or `undefined` for an unrecognized or absent suffix.
*/
export function languageForPath(path: string): string | undefined {
const slash = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
const base = path.slice(slash + 1)
const dot = base.lastIndexOf('.')
if (dot < 0) return undefined
return LANGUAGES.get(base.slice(dot + 1).toLowerCase())
const extension = extensionForPath(path)
return extension === undefined ? undefined : LANGUAGES.get(extension)
}
/**
* Derive the Host read card's persisted `lang` hint from a read path's
* extension. This is the legacy projection the read card writes: a suffix the
* pre-unification read table recognized keeps its old short id (`ts`, `md`,
* `cpp`, …), while a suffix only the shared table knows (`.ps1`, `.env`,
* `.tf`, …) yields its canonical id. An unrecognized suffix stays
* `undefined`, so the card renders as plain text.
* @param path - the model-facing path the read reported.
* @returns the persisted language hint, or `undefined` when the extension maps to none.
*/
export function readLangHintForPath(path: string): string | undefined {
const extension = extensionForPath(path)
if (extension === undefined) return undefined
const canonical = LANGUAGES.get(extension)
return canonical === undefined ? undefined : READ_LANG_BY_EXTENSION.get(extension) ?? canonical
}
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { CODE_HIGHLIGHT_EXTENSIONS, languageForPath } from '../src/index.ts'
import { CODE_HIGHLIGHT_EXTENSIONS, languageForPath, readLangHintForPath } from '../src/index.ts'
describe('languageForPath', () => {
it.each([
@@ -8,7 +8,7 @@ describe('languageForPath', () => {
['module.psm1', 'powershell'], ['data.psd1', 'powershell'], ['config.fish', 'fish'],
// Config, data, and text extensions.
['app.properties', 'ini'], ['app.conf', 'ini'], ['app.cfg', 'ini'],
['.env', 'dotenv'], ['server.log', 'log'], ['rows.csv', 'csv'],
['.env', 'dotenv'], ['server.log', 'log'],
['change.diff', 'diff'], ['fix.patch', 'diff'], ['api.http', 'http'],
['notebook.ipynb', 'json'],
// Documentation and markup extensions.
@@ -79,6 +79,51 @@ describe('languageForPath', () => {
expect(languageForPath(`secret.${extension}`), extension).toBeUndefined()
}
})
it('leaves delimited-spreadsheet suffixes to the Spreadsheet preview', () => {
// The Code preview registers before the Spreadsheet preview, so listing csv
// here would make Code the earlier candidate and steal the suffix from the
// viewer that must render it.
for (const extension of ['csv', 'tsv']) {
expect(languageForPath(`table.${extension}`), extension).toBeUndefined()
}
})
})
describe('readLangHintForPath', () => {
it('keeps the read card pre-unification short hints for the old suffixes', () => {
expect(readLangHintForPath('src/a.ts')).toBe('ts')
expect(readLangHintForPath('src/a.tsx')).toBe('tsx')
expect(readLangHintForPath('src/a.mts')).toBe('ts')
expect(readLangHintForPath('src/a.cts')).toBe('ts')
expect(readLangHintForPath('src/a.js')).toBe('js')
expect(readLangHintForPath('src/a.jsx')).toBe('jsx')
expect(readLangHintForPath('src/a.mjs')).toBe('js')
expect(readLangHintForPath('src/a.cjs')).toBe('js')
expect(readLangHintForPath('src/a.jsonc')).toBe('json')
expect(readLangHintForPath('src/a.cc')).toBe('cpp')
expect(readLangHintForPath('src/a.hpp')).toBe('cpp')
expect(readLangHintForPath('src/a.sh')).toBe('sh')
expect(readLangHintForPath('src/a.kt')).toBe('kotlin')
expect(readLangHintForPath('README.md')).toBe('md')
expect(readLangHintForPath('README.markdown')).toBe('md')
expect(readLangHintForPath('page.htm')).toBe('html')
})
it('uses the canonical id for a suffix added after the old read table', () => {
expect(readLangHintForPath('build.ps1')).toBe('powershell')
expect(readLangHintForPath('.env')).toBe('dotenv')
expect(readLangHintForPath('infra.tf')).toBe('hcl')
expect(readLangHintForPath('paper.tex')).toBe('latex')
expect(readLangHintForPath('server.log')).toBe('log')
expect(readLangHintForPath('message.proto')).toBe('proto')
})
it('returns undefined exactly when the shared table does not recognize the suffix', () => {
for (const path of ['.gitignore', '/etc/hosts', 'trailingdot.', 'data.unknownext', 'foo.constructor']) {
expect(readLangHintForPath(path), path).toBeUndefined()
}
})
})
describe('CODE_HIGHLIGHT_EXTENSIONS', () => {