sugar-high 2.2.2 → 2.3.1

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.
package/README.md CHANGED
@@ -9,6 +9,32 @@ returns HTML without requiring a DOM.
9
9
 
10
10
  ![Sugar High preview](https://repository-images.githubusercontent.com/453236442/9aa2144a-3a4c-4a93-a87f-92ca6a37ded6)
11
11
 
12
+ ## Benchmarks
13
+
14
+ Sugar High, PrismJS, and highlight.js highlighting the same generated TypeScript files:
15
+
16
+ <!-- benchmark:start -->
17
+ Measured 2026-09-04 with Node v24.18.0, darwin arm64, Apple M4 Pro.
18
+
19
+ | TypeScript | Sugar High 2.2.2 | PrismJS 1.30.0 | highlight.js 11.12.0 |
20
+ | --- | ---: | ---: | ---: |
21
+ | Minified (KiB) | 9.90 | 14.63 | 29.54 |
22
+ | Gzip (KiB) | 4.35 | 5.47 | 11.12 |
23
+ | 11 KiB | 1.78 | 1.18 | 2.11 |
24
+ | 100 KiB | 18.02 | 15.25 | 23.05 |
25
+ | 500 KiB | 90.98 | 96.41 | 118.39 |
26
+
27
+ Median milliseconds per file; lower is better. 5 timed samples after warmup.
28
+ Sizes are TypeScript-only browser bundles, minified with Bun; gzip uses level 9. Theme CSS is excluded.
29
+ Loading and initialization are excluded. Each library highlights the same generated TypeScript
30
+ into HTML using an explicit language. Grammars and HTML output differ; this is not a measure
31
+ of highlighting quality or browser rendering speed. Results vary by machine and workload.
32
+ <!-- benchmark:end -->
33
+
34
+ Run `pnpm --filter sugar-high benchmark:large --write` to refresh this table and the
35
+ [website comparison](https://sugar-high.vercel.app/#benchmarks) from the same measurement.
36
+ See [benchmark methodology and options](../../docs/BENCHMARK.md).
37
+
12
38
  ## Install
13
39
 
14
40
  ```sh
@@ -57,7 +83,7 @@ behavior.
57
83
 
58
84
  `javascript`, `typescript`, `css`, `python`, `c`, `go`, `java`, `rust`, `json`, `diff`, `shell`,
59
85
  `cpp`, `csharp`, `sql`, `html`, `yaml`, `markdown`, `plaintext`, `ruby`, `kotlin`, `swift`, `php`,
60
- `toml`, `powershell`, `dockerfile`, `graphql`, and `hcl`.
86
+ `toml`, `powershell`, `dockerfile`, `graphql`, `hcl`, `zig`, and `lua`.
61
87
 
62
88
  Related dialects share one implementation: JavaScript includes JSX, TypeScript includes TSX, JSON
63
89
  includes JSONC comments, Shell includes sh/Bash/Zsh, and HCL includes Terraform.
package/lib/core.d.ts CHANGED
@@ -89,7 +89,13 @@ export type ParseOptions = {
89
89
  keywords?: Set<string>
90
90
  typeKeywords?: Set<string>
91
91
  onCommentStart?: (curr: string, next: string, index: number, code: string) => number | boolean
92
- onCommentEnd?: (prev: string, curr: string, index: number, code: string) => number | boolean
92
+ onCommentEnd?: (
93
+ prev: string,
94
+ curr: string,
95
+ index: number,
96
+ code: string,
97
+ start: number,
98
+ ) => number | boolean
93
99
  onLiteral?: (curr: string, index: number, code: string) => number | null | undefined
94
100
  onQuote?: (curr: string, index: number, code: string) => number | null | undefined
95
101
  quotedKeys?: boolean
package/lib/core.js CHANGED
@@ -53,7 +53,7 @@ function tokenize(code, options) {
53
53
  if (commentType) {
54
54
  const start = i++
55
55
  while (i < code.length) {
56
- if (onCommentEnd(code[i - 1], code[i], i, code) == commentType) {
56
+ if (onCommentEnd(code[i - 1], code[i], i, code, start) == commentType) {
57
57
  i++
58
58
  break
59
59
  }
@@ -110,6 +110,10 @@ function tokenize(code, options) {
110
110
  if (isWord(curr)) {
111
111
  const start = i++
112
112
  while (i < code.length && isWord(code[i])) i++
113
+ if (/^\d/.test(curr) && code[i] === '.' && /\d/.test(code[i + 1] || '')) {
114
+ i++
115
+ while (i < code.length && isWord(code[i])) i++
116
+ }
113
117
  const value = code.slice(start, i)
114
118
  const normalized = normalize(value)
115
119
  const type = typeKeywords.has(normalized)
@@ -154,7 +158,7 @@ export { generate, parse, render, SugarHigh, tokenize }
154
158
  * @property {Set<string>} [keywords]
155
159
  * @property {Set<string>} [typeKeywords]
156
160
  * @property {(curr: string, next: string, index: number, code: string) => number | boolean} [onCommentStart]
157
- * @property {(prev: string, curr: string, index: number, code: string) => number | boolean} [onCommentEnd]
161
+ * @property {(prev: string, curr: string, index: number, code: string, start: number) => number | boolean} [onCommentEnd]
158
162
  * @property {(curr: string, index: number, code: string) => number | null | undefined} [onLiteral]
159
163
  * @property {(curr: string, index: number, code: string) => number | null | undefined} [onQuote]
160
164
  * @property {boolean} [quotedKeys]
package/lib/index.d.ts CHANGED
@@ -35,6 +35,8 @@ export type LanguageName =
35
35
  | 'dockerfile'
36
36
  | 'graphql'
37
37
  | 'hcl'
38
+ | 'zig'
39
+ | 'lua'
38
40
 
39
41
  export type HighlightOptions = DisplayOptions & {
40
42
  /** Canonical language name. Fence and extension aliases must be normalized first. */
@@ -0,0 +1,10 @@
1
+ export const keywords: Set<string>;
2
+ export function onCommentStart(curr: string, next: string, index: number, code: string): number;
3
+ export function onCommentEnd(
4
+ prev: string,
5
+ curr: string,
6
+ index: number,
7
+ code: string,
8
+ start: number,
9
+ ): number;
10
+ export function onLiteral(curr: string, index: number, code: string): number;
@@ -0,0 +1,45 @@
1
+ // @ts-check
2
+
3
+ export const keywords = new Set([
4
+ 'and', 'break', 'do', 'else', 'elseif', 'end', 'false', 'for', 'function', 'goto', 'if', 'in',
5
+ 'local', 'nil', 'not', 'or', 'repeat', 'return', 'then', 'true', 'until', 'while',
6
+ ])
7
+
8
+ /**
9
+ * @param {string} curr
10
+ * @param {string} next
11
+ * @param {number} index
12
+ * @param {string} code
13
+ */
14
+ export function onCommentStart(curr, next, index, code) {
15
+ if (curr === '#' && index === 0 && next === '!') return 1
16
+ if (curr + next !== '--') return 0
17
+ return /^--\[=*\[/.test(code.slice(index)) ? 2 : 1
18
+ }
19
+
20
+ /**
21
+ * @param {string} _prev
22
+ * @param {string} curr
23
+ * @param {number} index
24
+ * @param {string} code
25
+ * @param {number} start
26
+ */
27
+ export function onCommentEnd(_prev, curr, index, code, start) {
28
+ if (curr === '\n') return 1
29
+ if (curr !== ']') return 0
30
+ const opener = code.slice(start).match(/^--\[(=*)\[/)
31
+ if (!opener) return 0
32
+ const closing = `]${opener[1]}]`
33
+ if (code.slice(index - closing.length + 1, index + 1) === closing) return 2
34
+ return 0
35
+ }
36
+
37
+ /** @param {string} curr @param {number} index @param {string} code */
38
+ export function onLiteral(curr, index, code) {
39
+ if (curr !== '[') return 0
40
+ const opener = code.slice(index).match(/^\[(=*)\[/)
41
+ if (!opener) return 0
42
+ const closing = `]${opener[1]}]`
43
+ const end = code.indexOf(closing, index + opener[0].length)
44
+ return end === -1 ? code.length - index : end + closing.length - index
45
+ }
@@ -1,4 +1,5 @@
1
1
  export const keywords: Set<never>;
2
+ export function tokenize(code: string): Array<[number, string]>;
2
3
  export function annotateLine(line: any): void;
3
4
  import { onCommentEnd } from '../presets/plain-base.js';
4
5
  import { onCommentStart } from '../presets/plain-base.js';
@@ -1,8 +1,91 @@
1
1
  // @ts-check
2
+ import { T_IDENTIFIER, T_SIGN } from '../shared.js'
2
3
  import { onCommentEnd, onCommentStart } from '../presets/plain-base.js'
3
4
 
4
5
  export const keywords = new Set([])
5
6
 
7
+ /** @param {string} code */
8
+ export const tokenize = (code) => {
9
+ /** @type {Array<[number, string]>} */
10
+ const tokens = []
11
+ let fence = ''
12
+ let fenceQuoted = false
13
+
14
+ /** @param {number} type @param {string} value */
15
+ const append = (type, value) => {
16
+ if (!value) return
17
+ const previous = tokens[tokens.length - 1]
18
+ if (previous?.[0] === type) previous[1] += value
19
+ else tokens.push([type, value])
20
+ }
21
+
22
+ for (const part of code.match(/[^\n]*(?:\n|$)/g) || []) {
23
+ if (!part) continue
24
+ const newline = part.endsWith('\n') ? '\n' : ''
25
+ const line = newline ? part.slice(0, -1) : part
26
+ /** @type {Array<[number, number]>} */
27
+ const ranges = []
28
+ const container = line.match(/^(?: {0,3}>[ \t]?)*/)?.[0] || ''
29
+ if (!fence || fenceQuoted) {
30
+ for (const match of container.matchAll(/>/g)) {
31
+ ranges.push([match.index, match.index + 1])
32
+ }
33
+ }
34
+
35
+ let start = container.length
36
+ let spaces = 0
37
+ while (spaces < 3 && line[start] === ' ') {
38
+ start++
39
+ spaces++
40
+ }
41
+ const fenceMarker = line.slice(start).match(/^(`{3,}|~{3,})/)?.[1]
42
+
43
+ if (fence) {
44
+ if (
45
+ fenceMarker?.[0] === fence[0] &&
46
+ fenceMarker.length >= fence.length &&
47
+ /^\s*$/.test(line.slice(start + fenceMarker.length))
48
+ ) {
49
+ ranges.push([start, start + fenceMarker.length])
50
+ fence = ''
51
+ fenceQuoted = false
52
+ }
53
+ } else if (fenceMarker) {
54
+ ranges.push([start, start + fenceMarker.length])
55
+ fence = fenceMarker
56
+ fenceQuoted = container.includes('>')
57
+ } else {
58
+ const prefix = line.slice(start).match(
59
+ /^(#{1,6}(?=\s)|(?:[-+*]|\d+[.)])(?=\s)|(?:(?:\*\s*){3,}|(?:-\s*){3,}|(?:_\s*){3,})$)/,
60
+ )?.[1]
61
+ if (prefix) ranges.push([start, start + prefix.length])
62
+
63
+ for (const match of line.matchAll(/(`+)(.*?)\1|(\*{1,3}|_{1,3}|~{2})(?=\S)(.*?\S)\3/g)) {
64
+ const marker = match[1] || match[3]
65
+ if (
66
+ line[match.index - 1] === '\\' ||
67
+ (marker[0] === '_' && /\w/.test(line[match.index - 1] || ''))
68
+ ) continue
69
+ ranges.push(
70
+ [match.index, match.index + marker.length],
71
+ [match.index + match[0].length - marker.length, match.index + match[0].length],
72
+ )
73
+ }
74
+ }
75
+
76
+ let offset = 0
77
+ for (const [rangeStart, rangeEnd] of ranges.sort((a, b) => a[0] - b[0])) {
78
+ if (rangeStart < offset) continue
79
+ append(T_IDENTIFIER, line.slice(offset, rangeStart))
80
+ append(T_SIGN, line.slice(rangeStart, rangeEnd))
81
+ offset = rangeEnd
82
+ }
83
+ append(T_IDENTIFIER, line.slice(offset) + newline)
84
+ }
85
+
86
+ return tokens
87
+ }
88
+
6
89
  export const annotateLine = (line) => {
7
90
  let annotation = ''
8
91
  if (/^#{1,6}\s/.test(line.value)) annotation = 'markdown-heading'
@@ -0,0 +1,5 @@
1
+ export const keywords: Set<string>;
2
+ export const typeKeywords: Set<string>;
3
+ export function onCommentStart(curr: string, next: string): number;
4
+ export function onCommentEnd(prev: string, curr: string): number;
5
+ export function onLiteral(curr: string, index: number, code: string): number;
@@ -0,0 +1,29 @@
1
+ // @ts-check
2
+
3
+ export const keywords = new Set([
4
+ 'addrspace', 'align', 'allowzero', 'and', 'anyframe', 'anytype', 'asm', 'async', 'await', 'break',
5
+ 'callconv', 'catch', 'comptime', 'const', 'continue', 'defer', 'else', 'enum', 'errdefer', 'error',
6
+ 'export', 'extern', 'false', 'fn', 'for', 'if', 'inline', 'linksection', 'noalias', 'noinline',
7
+ 'nosuspend', 'null', 'opaque', 'or', 'orelse', 'packed', 'pub', 'resume', 'return', 'struct',
8
+ 'suspend', 'switch', 'test', 'threadlocal', 'true', 'try', 'undefined', 'union', 'unreachable',
9
+ 'usingnamespace', 'var', 'volatile', 'while',
10
+ ])
11
+
12
+ export const typeKeywords = new Set([
13
+ 'anyerror', 'anyopaque', 'bool', 'c_char', 'c_int', 'c_long', 'c_longdouble', 'c_longlong',
14
+ 'c_short', 'c_uint', 'c_ulong', 'c_ulonglong', 'c_ushort', 'comptime_float', 'comptime_int',
15
+ 'f16', 'f32', 'f64', 'f80', 'f128', 'i8', 'i16', 'i32', 'i64', 'i128', 'isize', 'noreturn',
16
+ 'type', 'u8', 'u16', 'u32', 'u64', 'u128', 'usize', 'void',
17
+ ])
18
+
19
+ export const onCommentStart = (curr, next) => curr + next === '//' ? 1 : 0
20
+ export const onCommentEnd = (_prev, curr) => curr === '\n' ? 1 : 0
21
+
22
+ /** @param {string} curr @param {number} index @param {string} code */
23
+ export function onLiteral(curr, index, code) {
24
+ if (curr !== '\\' || code[index + 1] !== '\\') return 0
25
+ const lineStart = code.lastIndexOf('\n', index - 1) + 1
26
+ if (code.slice(lineStart, index).trim()) return 0
27
+ const lineEnd = code.indexOf('\n', index + 2)
28
+ return (lineEnd === -1 ? code.length : lineEnd) - index
29
+ }
package/lib/lang.js CHANGED
@@ -2,8 +2,8 @@
2
2
 
3
3
  import {
4
4
  c, cpp, csharp, css, diff, dockerfile, go, graphql, hcl, html, java, javascript, json,
5
- kotlin, markdown, nonJavaScript, php, plaintext, powershell, python, ruby, rust, shell, sql,
6
- swift, toml, typescript, yaml,
5
+ kotlin, lua, markdown, nonJavaScript, php, plaintext, powershell, python, ruby, rust, shell, sql,
6
+ swift, toml, typescript, yaml, zig,
7
7
  } from './presets/configs.js'
8
8
 
9
9
  /**
@@ -45,6 +45,8 @@ const languages = [
45
45
  { id: 'dockerfile', extension: 'dockerfile', aliases: ['docker'], config: nonJavaScript(dockerfile) },
46
46
  { id: 'graphql', extension: 'graphql', aliases: ['gql'], config: nonJavaScript(graphql) },
47
47
  { id: 'hcl', extension: 'hcl', aliases: ['terraform', 'tf'], config: nonJavaScript(hcl) },
48
+ { id: 'zig', extension: 'zig', aliases: [], config: nonJavaScript(zig) },
49
+ { id: 'lua', extension: 'lua', aliases: [], config: nonJavaScript(lua) },
48
50
  ]
49
51
 
50
52
  /** @param {string} value */
@@ -2,11 +2,11 @@
2
2
  export const onCommentStart = (currentChar, nextChar) => {
3
3
  const pair = currentChar + nextChar
4
4
  if (pair === '//') return 1
5
- if (pair === '/*') return 1
5
+ if (pair === '/*') return 2
6
6
  return 0
7
7
  }
8
8
 
9
9
  export const onCommentEnd = (prevChar, currChar) => {
10
10
  if (currChar === '\n') return 1
11
- return prevChar + currChar === '*/' ? 1 : 0
11
+ return prevChar + currChar === '*/' ? 2 : 0
12
12
  }
@@ -14,6 +14,7 @@ import * as java from '../lang/java.js'
14
14
  import * as javascript from '../lang/javascript.js'
15
15
  import * as json from '../lang/json.js'
16
16
  import * as kotlin from '../lang/kotlin.js'
17
+ import * as lua from '../lang/lua.js'
17
18
  import * as markdown from '../lang/markdown.js'
18
19
  import * as php from '../lang/php.js'
19
20
  import * as plaintext from '../lang/plaintext.js'
@@ -27,6 +28,7 @@ import * as swift from '../lang/swift.js'
27
28
  import * as toml from '../lang/toml.js'
28
29
  import * as typescript from '../lang/typescript.js'
29
30
  import * as yaml from '../lang/yaml.js'
31
+ import * as zig from '../lang/zig.js'
30
32
 
31
33
  /**
32
34
  * Disable JavaScript-only scanner modes for a non-JavaScript language.
@@ -71,6 +73,8 @@ function createConfigs() {
71
73
  dockerfile: nonJavaScript(dockerfile),
72
74
  graphql: nonJavaScript(graphql),
73
75
  hcl: nonJavaScript(hcl),
76
+ zig: nonJavaScript(zig),
77
+ lua: nonJavaScript(lua),
74
78
  }
75
79
  }
76
80
 
@@ -83,6 +87,6 @@ function configFor(name) {
83
87
 
84
88
  export {
85
89
  c, configFor, configs, cpp, csharp, css, diff, dockerfile, go, graphql, hcl, html, java,
86
- javascript, json, kotlin, markdown, nonJavaScript, php, plaintext, powershell, python, ruby,
87
- rust, shell, sql, swift, toml, typescript, yaml,
90
+ javascript, json, kotlin, lua, markdown, nonJavaScript, php, plaintext, powershell, python, ruby,
91
+ rust, shell, sql, swift, toml, typescript, yaml, zig,
88
92
  }
@@ -283,6 +283,7 @@ function tokenize(code, options) {
283
283
  /** @type {0 | 1 | 2} 0 = none; 1 = inside `<open`; 2 = inside `</close` */
284
284
  let __jsxTag = 0
285
285
  let __jsxExpr = false
286
+ let __jsxTagExpr = 0
286
287
 
287
288
  /** Nested `<open>…</open>` depth (content between tags, including nested elements). */
288
289
  let __jsxStack = 0
@@ -323,6 +324,8 @@ function tokenize(code, options) {
323
324
  // classify jsx open tag
324
325
  if ((lastToken === '<' || lastToken === '</'))
325
326
  return T_ENTITY
327
+ if (!__jsxTagExpr && /^\s+$/.test(tokens[tokens.length - 1]?.[1] || ''))
328
+ return T_PROPERTY
326
329
  }
327
330
  }
328
331
  // Then determine if they're jsx literals
@@ -343,7 +346,7 @@ function tokenize(code, options) {
343
346
  } else if (token.split('').every(isSign)) {
344
347
  return T_SIGN
345
348
  } else if (isCls(token)) {
346
- return inJsxTag() ? T_IDENTIFIER : T_CLS_NUMBER
349
+ return inJsxTag() && !__jsxTagExpr ? T_IDENTIFIER : T_CLS_NUMBER
347
350
  } else {
348
351
  if (isIdentifier(token)) {
349
352
  const isLastPropDot = last[1] === '.' && isIdentifier(beforeLast[1])
@@ -372,6 +375,10 @@ function tokenize(code, options) {
372
375
  beforeLast = last
373
376
  last = pair
374
377
  }
378
+ if (inJsxTag() && type === T_SIGN) {
379
+ if (current === '{') __jsxTagExpr++
380
+ if (current === '}') __jsxTagExpr--
381
+ }
375
382
  tokens.push(pair)
376
383
  }
377
384
  current = ''
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sugar-high",
3
- "version": "2.2.2",
3
+ "version": "2.3.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/huozhi/sugar-high.git",