Skip to content

Commit ee3c15c

Browse files
authored
feat: typographic quotes (#239)
1 parent 4d20d69 commit ee3c15c

File tree

4 files changed

+50
-8
lines changed

4 files changed

+50
-8
lines changed

workspace/aubade/src/artisan/context.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ function extract(token: Token): string {
6969
return 'text' in token ? token.text : '';
7070
}
7171

72-
const util = {
72+
export const util = {
7373
commit<T>(array: T[], item: T) {
7474
if (item !== util.last(array)) array.push(item);
7575
return item;

workspace/aubade/src/artisan/example.spec.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe } from 'vitest';
2-
import { forge } from './index.js';
2+
import { engrave, forge } from './index.js';
33

44
// `deny` disallow features, either:
55
// - outright, like indented code block and setext headings
@@ -767,6 +767,16 @@ describe('libretto', ({ concurrent: it }) => {
767767
'<figure>\n<img src="img.png" alt="alt" />\n<figcaption>annotated <em>title</em> for caption</figcaption>\n</figure>',
768768
],
769769

770+
'quotes#double/1': ['"hello"', '<p>“hello”</p>'],
771+
'quotes#double/2': ['"a *b* c"', '<p>“a <em>b</em> c”</p>'],
772+
'quotes#single/1': ["'hello'", '<p>‘hello’</p>'],
773+
'quotes#single/2': ["'a 'b' c'", '<p>‘a ‘b’ c’</p>'],
774+
'quotes#mixed/1': [`"a 'b' c"`, '<p>“a ‘b’ c”</p>'],
775+
'quotes#mixed/2': [`'a "b" c'`, '<p>‘a “b” c’</p>'],
776+
'quotes#mixed/3': [`'a "b' c"`, '<p>‘a “b’ c”</p>'],
777+
'quotes#mixed/4': [`'"hello"'`, '<p>‘“hello”’</p>'],
778+
'quotes#mixed/5': [`"'hello'"`, '<p>“‘hello’”</p>'],
779+
770780
'strike#single': ['~strike~', '<p>~strike~</p>'],
771781
'strike#normal': ['~~strike~~', '<p><del>strike</del></p>'],
772782
'strike#code/1': ['~~`a~~`', '<p>~~<code>a~~</code></p>'],
@@ -796,7 +806,7 @@ describe('libretto', ({ concurrent: it }) => {
796806
'strike#newline/1': ['~~a\n~~', '<p>~~a\n~~</p>'],
797807
'strike#newline/2': ['~~\na~~', '<p>~~\na~~</p>'],
798808
'strike#newline/3': ['~~\na\n~~', '<p>~~\na\n~~</p>'],
799-
'strike#between/1': ['a~~"b"~~', '<p>a~~&quot;b&quot;~~</p>'],
809+
'strike#between/1': ['a~~"b"~~', '<p>a~~“b”~~</p>'],
800810
'strike#between/2|todo': ['-~~~~;~~~~~~', '<p>-<del><del>;</del></del>~~</p>'],
801811

802812
'table#normal': [
@@ -809,7 +819,6 @@ describe('libretto', ({ concurrent: it }) => {
809819
],
810820
};
811821

812-
const mark = forge();
813822
for (const test in suite) {
814823
const [input, output] = suite[test];
815824
const [, ...props] = test.split('|');
@@ -820,7 +829,7 @@ describe('libretto', ({ concurrent: it }) => {
820829
todo: props.includes('todo'),
821830
};
822831
it(test, options, ({ expect }) => {
823-
expect(mark(input).html()).toBe(output);
832+
expect(engrave(input).html()).toBe(output);
824833
});
825834
}
826835
});

workspace/aubade/src/artisan/index.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { Token } from './registry.js';
2-
import { escape } from './utils.js';
2+
import { escape, typographic } from './utils.js';
33
import { annotate, compose } from './engine.js';
44
import { base, standard } from './resolver.js';
55

@@ -23,8 +23,8 @@ export interface Options {
2323
quotes?: 'original' | 'typewriter' | 'typographic';
2424
}
2525

26-
export const engrave = forge({});
27-
export function forge({ directive = {}, renderer = {} }: Options = {}) {
26+
export const engrave = forge({ quotes: 'typographic' });
27+
export function forge({ directive = {}, renderer = {}, quotes }: Options = {}) {
2828
const resolver = {
2929
...standard,
3030
...renderer,
@@ -60,6 +60,11 @@ export function forge({ directive = {}, renderer = {} }: Options = {}) {
6060

6161
return (input: string) => {
6262
let { children: stream } = compose(input);
63+
64+
if (quotes === 'typographic') {
65+
stream = stream.map((t) => walk(t, { 'inline:text': typographic }));
66+
}
67+
6368
return {
6469
get tokens() {
6570
return stream;

workspace/aubade/src/artisan/utils.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import type { Token } from './registry.js';
2+
import { util } from './context.js';
3+
14
export function decode(cpt: number): string {
25
if (cpt === 0x00 || cpt > 0x10ffff) return '\uFFFD'; // HTML5 replacement character
36
if (cpt >= 0xd800 && cpt <= 0xdfff) return '\uFFFD'; // surrogate pair range
@@ -13,3 +16,28 @@ export function escape(source: string) {
1316
.replace(/&(?!(?:[a-zA-Z][a-zA-Z0-9]{1,31}|#[0-9]{1,7}|#x[0-9a-fA-F]{1,6});)/g, '&amp;')
1417
.replace(/[<>"]/g, (s) => symbols[s as keyof typeof symbols]);
1518
}
19+
20+
// should probably work on 'block:paragraph' instead of individual text nodes
21+
export function typographic(token: Extract<Token, { type: 'inline:text' }>) {
22+
let result = '';
23+
for (let i = 0; i < token.text.length; i++) {
24+
const char = token.text[i];
25+
if (char !== "'" && char !== '"') {
26+
result += char;
27+
continue;
28+
}
29+
30+
const prev = token.text[i - 1];
31+
const next = token.text[i + 1];
32+
33+
const left = util.is['left-flanking'](prev || ' ', next || ' ');
34+
const right = util.is['right-flanking'](prev || ' ', next || ' ');
35+
36+
const double = left ? '“' : right ? '”' : char;
37+
const single = left ? '‘' : right ? '’' : char;
38+
39+
result += char === '"' ? double : single;
40+
}
41+
token.text = result;
42+
return token;
43+
}

0 commit comments

Comments
 (0)