feat(image): added support for a bunch of aditional languages

This commit is contained in:
Folke Lemaitre 2025-02-16 14:16:13 +01:00
parent 332f4278b0
commit a596f8a9ea
No known key found for this signature in database
GPG key ID: 41F8B1FBACAE2040
21 changed files with 236 additions and 179 deletions

View file

@ -6,160 +6,22 @@ local M = {}
---@field win snacks.win ---@field win snacks.win
---@field buf number ---@field buf number
---@class snacks.image.Query ---@alias snacks.image.transform fun(buf:number, src:string, anchor: TSNode, image: TSNode): string
---@field setup fun():vim.treesitter.Query
---@field query? vim.treesitter.Query|false
---@field transform? fun(buf:number, src:string, anchor: TSNode, image: TSNode): string
---@type table<string, {setup:(fun():vim.treesitter.Query), query?:vim.treesitter.Query|false}> ---@type table<string, snacks.image.transform>
M._queries = { M.transforms = {
markdown = { ---@param anchor TSNode
setup = function() ---@param img TSNode
return vim.treesitter.query.parse( norg = function(buf, _, anchor, img)
"markdown_inline", local row, col = img:start()
[[ local line = vim.api.nvim_buf_get_lines(buf, row, row + 1, false)[1]
(image return line:sub(col + 1)
[ end,
(link_destination) @image
(image_description (shortcut_link (link_text) @image))
]) @anchor
]]
)
end,
transform = function(_, src)
return src:gsub("|.*", ""):gsub("^<", ""):gsub(">$", "")
end,
},
html = {
setup = function()
return vim.treesitter.query.parse(
"html",
[[
(element
(start_tag
(tag_name) @tag (#eq? @tag "img")
(attribute
(attribute_name) @attr_name (#eq? @attr_name "src")
(quoted_attribute_value (attribute_value) @image)
)
)
) @anchor
(self_closing_tag
(tag_name) @tag (#eq? @tag "img")
(attribute
(attribute_name) @attr_name (#eq? @attr_name "src")
(quoted_attribute_value (attribute_value) @image)
)
) @anchor
]]
)
end,
},
tsx = {
setup = function()
return vim.treesitter.query.parse(
"tsx",
[[
(jsx_element
(jsx_opening_element
(identifier) @tag (#eq? @tag "img")
(jsx_attribute
(property_identifier) @attr_name (#eq? @attr_name "src")
(string (string_fragment) @image)
)
)
) @anchor
(jsx_self_closing_element
(identifier) @tag (#eq? @tag "img")
(jsx_attribute
(property_identifier) @attr_name (#eq? @attr_name "src")
(string (string_fragment) @image)
)
) @anchor
]]
)
end,
},
javascript = {
setup = function()
return vim.treesitter.query.parse(
"javascript",
[[
(jsx_element
(jsx_opening_element
(identifier) @tag (#eq? @tag "img")
(jsx_attribute
(property_identifier) @attr_name (#eq? @attr_name "src")
(string (string_fragment) @image)
)
)
) @anchor
(jsx_self_closing_element
(identifier) @tag (#eq? @tag "img")
(jsx_attribute
(property_identifier) @attr_name (#eq? @attr_name "src")
(string (string_fragment) @image)
)
) @anchor
]]
)
end,
},
css = {
setup = function()
return vim.treesitter.query.parse(
"css",
[[
(declaration
(call_expression
(function_name) @fn (#eq? @fn "url")
(arguments [(plain_value) @image (string_value (string_content) @image)]))
) @anchor
]]
)
end,
},
norg = {
setup = function()
return vim.treesitter.query.parse(
"norg",
[[
(infirm_tag
(tag_name) @tag (#eq? @tag "image")
(tag_parameters (tag_param) @image)
) @anchor
]]
)
end,
---@param anchor TSNode
---@param img TSNode
transform = function(buf, _, anchor, img)
local row, col = img:start()
local line = vim.api.nvim_buf_get_lines(buf, row, row + 1, false)[1]
return line:sub(col + 1)
end,
},
} }
local hover ---@type snacks.image.Hover? local hover ---@type snacks.image.Hover?
local uv = vim.uv or vim.loop local uv = vim.uv or vim.loop
function M.queries()
local ret = {} ---@type snacks.image.Query[]
for _, query in pairs(M._queries) do
if query.query == nil then
local ok, q = pcall(query.setup)
query.query = ok and q or false
end
if query.query then
table.insert(ret, query)
end
end
return ret
end
---@param str string ---@param str string
function M.url_decode(str) function M.url_decode(str)
return str:gsub("+", " "):gsub("%%(%x%x)", function(hex) return str:gsub("+", " "):gsub("%%(%x%x)", function(hex)
@ -201,36 +63,39 @@ function M.find(buf, from, to)
end end
parser:parse(from and to and { from, to } or true) parser:parse(from and to and { from, to } or true)
local ret = {} ---@type {id:string, pos:snacks.image.Pos, src:string}[] local ret = {} ---@type {id:string, pos:snacks.image.Pos, src:string}[]
parser:for_each_tree(function(tstree) parser:for_each_tree(function(tstree, tree)
if not tstree then if not tstree then
return return
end end
for _, query in ipairs(M.queries()) do local query = vim.treesitter.query.get(tree:lang(), "images")
for _, match in query.query:iter_matches(tstree:root(), buf, from and from - 1 or nil, to and to - 1 or nil) do if not query then
local src, pos, nid ---@type string, snacks.image.Pos, string return
local anchor, image ---@type TSNode, TSNode end
for id, nodes in pairs(match) do for _, match, meta in query:iter_matches(tstree:root(), buf, from and from - 1 or nil, to and to - 1 or nil) do
nodes = type(nodes) == "userdata" and { nodes } or nodes local src, pos, nid ---@type string, snacks.image.Pos, string
local name = query.query.captures[id] local anchor, image ---@type TSNode, TSNode
for _, node in ipairs(nodes) do for id, nodes in pairs(match) do
if name == "image" then nodes = type(nodes) == "userdata" and { nodes } or nodes
image = node local name = query.captures[id]
src = vim.treesitter.get_node_text(node, buf) for _, node in ipairs(nodes) do
elseif name == "anchor" then if name == "image" then
anchor = node image = node
local range = { node:range() } src = vim.treesitter.get_node_text(node, buf, { metadata = meta[id] })
pos = { range[1] + 1, range[2] } elseif name == "anchor" then
nid = node:id() anchor = node
end local range = { node:range() }
pos = { range[1] + 1, range[2] }
nid = node:id()
end end
end end
if src and pos and nid then end
if query.transform then if src and pos and nid then
src = query.transform(buf, src, anchor, image) local transform = M.transforms[tree:lang()]
end if transform then
src = M.resolve(buf, src) src = transform(buf, src, anchor, image)
ret[#ret + 1] = { id = nid, pos = pos, src = src }
end end
src = M.resolve(buf, src)
ret[#ret + 1] = { id = nid, pos = pos, src = src }
end end
end end
end) end)

View file

@ -51,7 +51,6 @@ local defaults = {
-- a treesitter parser must be available for the enabled languages. -- a treesitter parser must be available for the enabled languages.
-- supported language injections: markdown, html -- supported language injections: markdown, html
enabled = true, enabled = true,
lang = { "markdown", "html", "norg", "tsx", "javascript", "css", "vue", "angular" },
-- render the image inline in the buffer -- render the image inline in the buffer
-- if your env doesn't support unicode placeholders, this will be disabled -- if your env doesn't support unicode placeholders, this will be disabled
-- takes precedence over `opts.float` on supported terminals -- takes precedence over `opts.float` on supported terminals
@ -127,6 +126,14 @@ function M.hover()
M.doc.hover() M.doc.hover()
end end
---@return string[]
function M.langs()
local queries = vim.api.nvim_get_runtime_file("queries/*/images.scm", true)
return vim.tbl_map(function(q)
return q:match("queries/(.-)/images%.scm")
end, queries)
end
---@private ---@private
---@param ev? vim.api.keyset.create_autocmd.callback_args ---@param ev? vim.api.keyset.create_autocmd.callback_args
function M.setup(ev) function M.setup(ev)
@ -156,12 +163,13 @@ function M.setup(ev)
}) })
end end
if M.config.enabled and M.config.doc.enabled then if M.config.enabled and M.config.doc.enabled then
local langs = M.langs()
vim.api.nvim_create_autocmd("FileType", { vim.api.nvim_create_autocmd("FileType", {
group = group, group = group,
callback = function(e) callback = function(e)
local ft = vim.bo[e.buf].filetype local ft = vim.bo[e.buf].filetype
local lang = vim.treesitter.language.get_lang(ft) local lang = vim.treesitter.language.get_lang(ft)
if vim.tbl_contains(M.config.doc.lang, lang) then if vim.tbl_contains(langs, lang) then
vim.schedule(function() vim.schedule(function()
M.doc.attach(e.buf) M.doc.attach(e.buf)
end) end)
@ -208,14 +216,15 @@ function M.health()
) )
) )
M.doc.queries() for _, lang in ipairs(M.langs()) do
for lang, q in pairs(M.doc._queries) do local ok, parser = pcall(vim.treesitter.get_string_parser, "", lang)
if q.query then if ok and parser then
Snacks.health.ok("Images rendering for `" .. lang .. "` is available") Snacks.health.ok("Image rendering for `" .. lang .. "` is available")
else else
Snacks.health.warn("Images rendering for `" .. lang .. "` is not available.\nMissing treesitter parser.") Snacks.health.error("Image rendering for `" .. lang .. "` is not available")
end end
end end
if env.supported then if env.supported then
Snacks.health.ok("your terminal supports the kitty graphics protocol") Snacks.health.ok("your terminal supports the kitty graphics protocol")
elseif M.config.force then elseif M.config.force then

6
queries/css/images.scm Normal file
View file

@ -0,0 +1,6 @@
(declaration
(call_expression
(function_name) @fn (#eq? @fn "url")
(arguments [(plain_value) @image (string_value (string_content) @image)]))
) @anchor

17
queries/html/images.scm Normal file
View file

@ -0,0 +1,17 @@
(element
(start_tag
(tag_name) @tag (#eq? @tag "img")
(attribute
(attribute_name) @attr_name (#eq? @attr_name "src")
(quoted_attribute_value (attribute_value) @image)
)
)
) @anchor
(self_closing_tag
(tag_name) @tag (#eq? @tag "img")
(attribute
(attribute_name) @attr_name (#eq? @attr_name "src")
(quoted_attribute_value (attribute_value) @image)
)
) @anchor

View file

@ -0,0 +1,18 @@
(jsx_element
(jsx_opening_element
(identifier) @tag (#eq? @tag "img")
(jsx_attribute
(property_identifier) @attr_name (#eq? @attr_name "src")
(string (string_fragment) @image)
)
)
) @anchor
(jsx_self_closing_element
(identifier) @tag (#eq? @tag "img")
(jsx_attribute
(property_identifier) @attr_name (#eq? @attr_name "src")
(string (string_fragment) @image)
)
) @anchor

View file

@ -0,0 +1 @@
; extends

View file

@ -0,0 +1,9 @@
(image
[
(link_destination) @image
(image_description (shortcut_link (link_text) @image))
(#gsub! @image "|.*" "") ; remove wikilink image options
(#gsub! @image "^<" "") ; remove bracket link
(#gsub! @image "^>" "")
]) @anchor

5
queries/norg/images.scm Normal file
View file

@ -0,0 +1,5 @@
(infirm_tag
(tag_name) @tag (#eq? @tag "image")
(tag_parameters (tag_param) @image)
) @anchor

11
queries/scss/images.scm Normal file
View file

@ -0,0 +1,11 @@
(declaration
(call_expression
(function_name) @fn (#eq? @fn "url")
(arguments [
(plain_value) @image
(string_value) @image
; Remove quotes from the image URL
(#gsub! @image "^['\"]" "")
(#gsub! @image "['\"]$" "")
]))
) @anchor

18
queries/tsx/images.scm Normal file
View file

@ -0,0 +1,18 @@
(jsx_element
(jsx_opening_element
(identifier) @tag (#eq? @tag "img")
(jsx_attribute
(property_identifier) @attr_name (#eq? @attr_name "src")
(string (string_fragment) @image)
)
)
) @anchor
(jsx_self_closing_element
(identifier) @tag (#eq? @tag "img")
(jsx_attribute
(property_identifier) @attr_name (#eq? @attr_name "src")
(string (string_fragment) @image)
)
) @anchor

2
queries/vue/images.scm Normal file
View file

@ -0,0 +1,2 @@
; inherits: html
; extends

4
tests/image/test.css Normal file
View file

@ -0,0 +1,4 @@
.foo {
background: lightblue url("./test.png") no-repeat fixed center;
content: url(test.png);
}

19
tests/image/test.html Normal file
View file

@ -0,0 +1,19 @@
<body>
<a href="https://github.com/folke/lazy.nvim/releases/latest">
<img
alt="Latest release"
src="https://img.shields.io/github/v/release/folke/lazy.nvim?style=for-the-badge&logo=starship&color=C9CBFF&logoColor=D9E0EE&labelColor=302D41&include_prerelease&sort=semver"
/>
</a>
<a href="https://github.com/folke/lazy.nvim/pulse">
<img
alt="Last commit"
src="https://img.shields.io/github/last-commit/folke/lazy.nvim?style=for-the-badge&logo=starship&color=8bd5ca&logoColor=D9E0EE&labelColor=302D41"
/>
</a>
</body>
<style>
background: lightblue url("./test.png") no-repeat fixed center;
content: url(test.png);
</style>

BIN
tests/image/test.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

10
tests/image/test.jsx Normal file
View file

@ -0,0 +1,10 @@
export const Modal = (props) => {
return (
<Show>
<img src="test.png" />
<img src="https://picsum.photos/200/300"></img>
</Show>
)
}

25
tests/image/test.md Normal file
View file

@ -0,0 +1,25 @@
# test
## Wikilinks
!![[test.png]]
!![[test.png|options]]
## Injected HTML
<img src="test.png" alt="png" width="200" height="30" />
<a href="https://github.com/folke/lazy.nvim/releases/latest">
<img alt="Latest release" src="https://img.shields.io/github/v/release/folke/lazy.nvim?style=for-the-badge&logo=starship&color=C9CBFF&logoColor=D9E0EE&labelColor=302D41&include_prerelease&sort=semver" />
</a>
## Markdown Links
![small](https://picsum.photos/200/30)
![relative png](./test.png)
![png](test.png)
![jpg](test.jpg)

7
tests/image/test.norg Normal file
View file

@ -0,0 +1,7 @@
# Test
- foo
.image ./test.png
.image test.png

BIN
tests/image/test.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 MiB

5
tests/image/test.scss Normal file
View file

@ -0,0 +1,5 @@
.foo {
background: lightblue url("./test.png") no-repeat fixed center;
background: lightblue url("./test.png") no-repeat fixed center;
content: url(test.png);
}

9
tests/image/test.tsx Normal file
View file

@ -0,0 +1,9 @@
export const Modal = (props) => {
return (
<Show>
<img src="test.png" />
<img src="https://picsum.photos/200/300"></img>
</Show>
)
}

17
tests/image/test.vue Normal file
View file

@ -0,0 +1,17 @@
<script setup>
import { ref } from "vue";
const greeting = ref("Hello World!");
</script>
<template>
<p class="greeting">{{ greeting }}</p>
<img src="test.png" alt="test" />
</template>
<style>
.greeting {
color: red;
font-weight: bold;
background: url("test.png");
}
</style>