diff --git a/src/1-one/output/api/html.js b/src/1-one/output/api/html.js
new file mode 100644
index 0000000..70a32cf
--- /dev/null
+++ b/src/1-one/output/api/html.js
@@ -0,0 +1,77 @@
+const isClass = /^\../
+const isId = /^#./
+
+const escapeXml = str => {
+ str = str.replaceAll('&', '&')
+ str = str.replaceAll('<', '<')
+ str = str.replaceAll('>', '>')
+ str = str.replaceAll('"', '"')
+ str = str.replaceAll("'", ''')
+ return str
+}
+
+// interpret .class, #id, tagName
+const toTag = function (k) {
+ let start = ''
+ let end = ''
+ k = escapeXml(k)
+ if (isClass.test(k)) {
+ start = ``
+ }
+ start += '>'
+ return { start, end }
+}
+
+const getIndex = function (doc, obj) {
+ const starts = {}
+ const ends = {}
+ Object.keys(obj).forEach(k => {
+ let res = obj[k]
+ const tag = toTag(k)
+ if (typeof res === 'string') {
+ res = doc.match(res)
+ }
+ res.docs.forEach(terms => {
+ // don't highlight implicit terms
+ if (terms.every(t => t.implicit)) {
+ return
+ }
+ const a = terms[0].id
+ starts[a] ||= []
+ starts[a].push(tag.start)
+ const b = terms.at(-1).id
+ ends[b] ||= []
+ ends[b].push(tag.end)
+ })
+ })
+ return { starts, ends }
+}
+
+const html = function (obj) {
+ // index ids to highlight
+ const { starts, ends } = getIndex(this, obj)
+ // create the text output
+ let out = ''
+ this.docs.forEach(terms => {
+ for (let i = 0; i < terms.length; i += 1) {
+ const t = terms[i]
+ // do a span tag
+ if (Object.hasOwn(starts, t.id)) {
+ out += starts[t.id].join('')
+ }
+ out += t.pre || ''
+ out += t.text || ''
+ if (Object.hasOwn(ends, t.id)) {
+ out += ends[t.id].join('')
+ }
+ out += t.post || ''
+ }
+ })
+ return out
+}
+export default { html }
diff --git a/tests/01-one/output/html.test.js b/tests/01-one/output/html.test.js
new file mode 100644
index 0000000..7553ab3
--- /dev/null
+++ b/tests/01-one/output/html.test.js
@@ -0,0 +1,61 @@
+import test from 'tape'
+import nlp from '../../lib/one.js'
+const here = '[one/output/html] '
+
+test('html-match', function (t) {
+ let doc = nlp(`match one two. one match two. one two match.`)
+ let html = doc.html({ i: 'match' })
+ t.equal(html, `match one two. one match two. one two match.`, here + 'html tag')
+
+ doc = nlp(`match one two. one match two.`)
+ html = doc.html({ '.foo': 'match' })
+ t.equal(html, `match one two. one match two.`, here + 'html class')
+
+ doc = nlp(`one match match two.`)
+ html = doc.html({ '#foo': 'match+' })
+ t.equal(html, `one match match two.`, here + 'html id')
+
+ doc = nlp(`one match two.`)
+ html = doc.html({ '.red': 'match+', '.blue': doc.match('two') })
+ t.equal(html, `one match two.`, here + 'html two classes')
+
+ doc = nlp(`if i can recall, my grey dog loves pizza crusts (they are really good).`)
+ html = doc.html({ '.red': 'my grey dog', '.blue': doc.match('loves') })
+ t.equal(
+ html,
+ `if i can recall, my grey dog loves pizza crusts (they are really good).`,
+ here + 'html pre test'
+ )
+
+ t.end()
+})
+
+test('html-nest', function (t) {
+ const doc = nlp(`one match two.`)
+ let html = doc.html({
+ i: 'match',
+ b: 'one match two',
+ })
+ t.equal(html, `one match two.`, here + 'easy nest')
+
+ html = doc.html({
+ b: 'match two',
+ i: 'match',
+ })
+ t.equal(html, `one match two.`, here + 'hard nest')
+
+ // html = doc.html({
+ // i: 'match',
+ // b: 'match two',
+ // })
+ // t.equal(html, `one match two.`, here + 'harder nest')
+
+ t.end()
+})
+
+test('html-implicit', function (t) {
+ const doc = nlp(`he's cool`)
+ const out = doc.html({ '.foo': '#Verb' })
+ t.equal(out, `he's cool`, here + 'implict')
+ t.end()
+})