Skip to content

Commit

Permalink
fix(preset-attributify): autocomplete with prefix (#2643)
Browse files Browse the repository at this point in the history
  • Loading branch information
sapphi-red committed May 22, 2023
1 parent df6894e commit 83574b8
Show file tree
Hide file tree
Showing 3 changed files with 202 additions and 100 deletions.
168 changes: 92 additions & 76 deletions packages/preset-attributify/src/autocomplete.ts
@@ -1,94 +1,110 @@
import type { AutoCompleteExtractor } from '@unocss/core'
import { variantsRE } from './variant'
import type { AttributifyOptions } from '.'

const elementRE = /(<\w[\w:\.$-]*\s)((?:'[^>]*?'|"[^>]*?"|`[^>]*?`|\{[^>]*?\}|[^>]*?)*)/g
const valuedAttributeRE = /([?]|(?!\d|-{2}|-\d)[a-zA-Z0-9\u00A0-\uFFFF-_:%-]+)(?:=("[^"]*|'[^']*))?/g
const splitterRE = /[\s'"`;>]+/

export const autocompleteExtractorAttributify: AutoCompleteExtractor = {
name: 'attributify',
extract: ({ content, cursor }) => {
const matchedElements = content.matchAll(elementRE)
let attrs: string | undefined
let elPos = 0
for (const match of matchedElements) {
const [, prefix, content] = match
const currentPos = match.index! + prefix.length
if (cursor > currentPos && cursor <= currentPos + content.length) {
elPos = currentPos
attrs = content
break
export function autocompleteExtractorAttributify(options?: AttributifyOptions): AutoCompleteExtractor {
return {
name: 'attributify',
extract: ({ content, cursor }) => {
const matchedElements = content.matchAll(elementRE)
let attrs: string | undefined
let elPos = 0
for (const match of matchedElements) {
const [, prefix, content] = match
const currentPos = match.index! + prefix.length
if (cursor > currentPos && cursor <= currentPos + content.length) {
elPos = currentPos
attrs = content
break
}
}
}
if (!attrs)
return null
if (!attrs)
return null

const matchedAttributes = attrs.matchAll(valuedAttributeRE)
let attrsPos = 0
let attrName: string | undefined
let attrValues: string | undefined
for (const match of matchedAttributes) {
const [matched, name, rawValues] = match
const currentPos = elPos + match.index!
if (cursor > currentPos && cursor <= currentPos + matched.length) {
attrsPos = currentPos
attrName = name
attrValues = rawValues?.slice(1)
break
const matchedAttributes = attrs.matchAll(valuedAttributeRE)
let attrsPos = 0
let attrName: string | undefined
let attrValues: string | undefined
for (const match of matchedAttributes) {
const [matched, name, rawValues] = match
const currentPos = elPos + match.index!
if (cursor > currentPos && cursor <= currentPos + matched.length) {
attrsPos = currentPos
attrName = name
attrValues = rawValues?.slice(1)
break
}
}
}
if (!attrName)
return null
if (!attrName)
return null

if (attrName === 'class' || attrName === 'className' || attrName === ':class')
return null
if (attrValues === undefined) {
return {
extracted: attrName,
resolveReplacement(suggestion) {
return {
start: attrsPos,
end: attrsPos + attrName!.length,
replacement: suggestion,
}
},
}
}
if (attrName === 'class' || attrName === 'className' || attrName === ':class')
return null

const hasPrefix = !!options?.prefix && attrName.startsWith(options.prefix)
if (options?.prefixedOnly && !hasPrefix)
return null

const attrValuePos = attrsPos + attrName.length + 2
const attrNameWithoutPrefix = hasPrefix ? attrName.slice(options.prefix!.length) : attrName

let matchSplit = splitterRE.exec(attrValues)
let currentPos = 0
let value: string | undefined
while (matchSplit) {
const [matched] = matchSplit
if (cursor > attrValuePos + currentPos
&& cursor <= attrValuePos + currentPos + matchSplit.index) {
value = attrValues.slice(currentPos, currentPos + matchSplit.index)
break
if (attrValues === undefined) {
return {
extracted: attrNameWithoutPrefix,
transformSuggestions(suggestion) {
if (hasPrefix)
return suggestion.map(s => options.prefix! + s)
else
return suggestion
},
resolveReplacement(suggestion) {
return {
start: attrsPos,
end: attrsPos + attrName!.length,
replacement: suggestion,
}
},
}
}
currentPos += matchSplit.index + matched.length
matchSplit = splitterRE.exec(attrValues.slice(currentPos))
}
if (value === undefined)
value = attrValues.slice(currentPos)

const [, variants = '', body] = value.match(variantsRE) || []
const attrValuePos = attrsPos + attrName.length + 2

return {
extracted: `${variants}${attrName}-${body}`,
transformSuggestions(suggestions) {
return suggestions
.filter(v => v.startsWith(`${variants}${attrName}-`))
.map(v => variants + v.slice(variants.length + attrName!.length + 1))
},
resolveReplacement(suggestion) {
return {
start: currentPos + attrValuePos,
end: currentPos + attrValuePos + value!.length,
replacement: variants + suggestion.slice(variants.length + attrName!.length + 1),
let matchSplit = splitterRE.exec(attrValues)
let currentPos = 0
let value: string | undefined
while (matchSplit) {
const [matched] = matchSplit
if (cursor > attrValuePos + currentPos
&& cursor <= attrValuePos + currentPos + matchSplit.index) {
value = attrValues.slice(currentPos, currentPos + matchSplit.index)
break
}
},
}
},
currentPos += matchSplit.index + matched.length
matchSplit = splitterRE.exec(attrValues.slice(currentPos))
}
if (value === undefined)
value = attrValues.slice(currentPos)

const [, variants = '', body] = value.match(variantsRE) || []

return {
extracted: `${variants}${attrNameWithoutPrefix}-${body}`,
transformSuggestions(suggestions) {
return suggestions
.filter(v => v.startsWith(`${variants}${attrNameWithoutPrefix}-`))
.map(v => variants + v.slice(variants.length + attrNameWithoutPrefix!.length + 1))
},
resolveReplacement(suggestion) {
return {
start: currentPos + attrValuePos,
end: currentPos + attrValuePos + value!.length,
replacement: variants + suggestion.slice(variants.length + attrNameWithoutPrefix!.length + 1),
}
},
}
},
}
}
2 changes: 1 addition & 1 deletion packages/preset-attributify/src/index.ts
Expand Up @@ -24,7 +24,7 @@ function presetAttributify(options: AttributifyOptions = {}): Preset {
extractorAttributify(options),
]
const autocompleteExtractors = [
autocompleteExtractorAttributify,
autocompleteExtractorAttributify(options),
]

return {
Expand Down
132 changes: 109 additions & 23 deletions test/preset-attributify.test.ts
Expand Up @@ -76,34 +76,120 @@ describe('attributify', async () => {
expect(css).toMatchSnapshot()
})

test('autocomplete extractor', async () => {
const res = await autocompleteExtractorAttributify.extract({
content: fixture1,
cursor: 187,
describe('autocomplete extractor', async () => {
test('without prefix', async () => {
const res = await autocompleteExtractorAttributify().extract({
content: fixture1,
cursor: 187,
})

expect(res).not.toBeNull()

expect(res!.extracted).toMatchInlineSnapshot('"bg-blue-400"')
expect(res!.transformSuggestions!([`${res!.extracted}1`, `${res!.extracted}2`]))
.toMatchInlineSnapshot(`
[
"blue-4001",
"blue-4002",
]
`)

const reversed = res!.resolveReplacement(`${res!.extracted}1`)
expect(reversed).toMatchInlineSnapshot(`
{
"end": 192,
"replacement": "blue-4001",
"start": 184,
}
`)

expect(fixture1.slice(reversed.start, reversed.end))
.toMatchInlineSnapshot('"blue-400"')
})

expect(res).not.toBeNull()
test('with prefix', async () => {
const fixtureWithPrefix = `
<div un-text-cent>
<div un-text="cent
</div>
`
const res1 = await autocompleteExtractorAttributify({ prefix: 'un-' }).extract({
content: fixtureWithPrefix,
cursor: 18,
})

expect(res1).not.toBeNull()

expect(res1!.extracted).toMatchInlineSnapshot('"text-cent"')
expect(res1!.transformSuggestions!([`${res1!.extracted}1`, `${res1!.extracted}2`]))
.toMatchInlineSnapshot(`
[
"un-text-cent1",
"un-text-cent2",
]
`)

const reversed1 = res1!.resolveReplacement(`${res1!.extracted}1`)
expect(reversed1).toMatchInlineSnapshot(`
{
"end": 18,
"replacement": "text-cent1",
"start": 6,
}
`)

expect(fixtureWithPrefix.slice(reversed1.start, reversed1.end))
.toMatchInlineSnapshot('"un-text-cent"')

const res2 = await autocompleteExtractorAttributify({ prefix: 'un-' }).extract({
content: fixtureWithPrefix,
cursor: 40,
})

expect(res!.extracted).toMatchInlineSnapshot('"bg-blue-400"')
expect(res!.transformSuggestions!([`${res!.extracted}1`, `${res!.extracted}2`]))
.toMatchInlineSnapshot(`
[
"blue-4001",
"blue-4002",
]
expect(res2).not.toBeNull()

expect(res2!.extracted).toMatchInlineSnapshot('"text-cent"')
expect(res2!.transformSuggestions!([`${res2!.extracted}1`, `${res2!.extracted}2`]))
.toMatchInlineSnapshot(`
[
"cent1",
"cent2",
]
`)

const reversed2 = res2!.resolveReplacement(`${res2!.extracted}1`)
expect(reversed2).toMatchInlineSnapshot(`
{
"end": 40,
"replacement": "cent1",
"start": 36,
}
`)

const reversed = res!.resolveReplacement(`${res!.extracted}1`)
expect(reversed).toMatchInlineSnapshot(`
{
"end": 192,
"replacement": "blue-4001",
"start": 184,
}
`)

expect(fixture1.slice(reversed.start, reversed.end))
.toMatchInlineSnapshot('"blue-400"')
expect(fixtureWithPrefix.slice(reversed2.start, reversed2.end))
.toMatchInlineSnapshot('"cent"')
})

test('only prefix', async () => {
const fixtureOnlyPrefix = `
<div text-cent>
<div text="cent
</div>
`
const res1 = await autocompleteExtractorAttributify({ prefix: 'un-', prefixedOnly: true }).extract({
content: fixtureOnlyPrefix,
cursor: 15,
})

expect(res1).toBeNull()

const res2 = await autocompleteExtractorAttributify({ prefix: 'un-', prefixedOnly: true }).extract({
content: fixtureOnlyPrefix,
cursor: 34,
})

expect(res2).toBeNull()
})
})

test('with trueToNonValued', async () => {
Expand Down

0 comments on commit 83574b8

Please sign in to comment.