feat(nvim): add automatic tagging for draft content

This commit is contained in:
Rasyidan Akbar F. 2026-09-20 21:26:48 +07:00
commit 7624d54f51
2 changed files with 77 additions and 35 deletions

View file

@ -13,6 +13,8 @@ calls the existing Bun scripts.
`~/Development/MyWeb/magnum-opus/main`. `~/Development/MyWeb/magnum-opus/main`.
- `direnv` is installed and the website `.envrc` is allowed. - `direnv` is installed and the website `.envrc` is allowed.
- The website development shell provides Bun. - The website development shell provides Bun.
- `TYPESAFE_API_KEY` or `TYPESAFEAI_API_KEY` is available for tag classification.
- `OPENROUTER_API_KEY`, `OPENAI_API_KEY`, or `TAG_GENERATOR_API_KEY` is available when a new tag must be created.
Set `RASYIDANAF_SITE_ROOT` before starting Neovim to use another checkout: Set `RASYIDANAF_SITE_ROOT` before starting Neovim to use another checkout:
@ -26,9 +28,10 @@ The Notes group uses the local leader (`\`).
| Command | Mapping | Action | | Command | Mapping | Action |
| --- | --- | --- | | --- | --- | --- |
| `:NotesNewLog` | `\nl` | Prompt for an optional title and tags, create a Log draft, and open it | | `:NotesNewLog` | `\nl` | Prompt for an optional title, create an untagged Log draft, and open it |
| `:NotesNewNote` | `\nn` | Prompt for a required title and optional tags, create a Note draft, and open it | | `:NotesNewNote` | `\nn` | Prompt for a required title, create an untagged Note draft, and open it |
| `:NotesPublish` | `\np` | Save and publish the current Entry after confirmation | | `:NotesPublish` | `\np` | Save and publish the current Entry after confirmation |
| `:ContentAutoTag` | `\nt` | Save and automatically tag the current Draft Note or Blog Post |
| `:NotesValidate` | `\nv` | Validate public Notes metadata, routes, lifecycle, tags, and media | | `:NotesValidate` | `\nv` | Validate public Notes metadata, routes, lifecycle, tags, and media |
| `:NotesBuild` | `\nb` | Run the complete production build | | `:NotesBuild` | `\nb` | Run the complete production build |
@ -36,13 +39,19 @@ The Notes group uses the local leader (`\`).
does not commit, push, or deploy. Review the Jujutsu diff and push the website does not commit, push, or deploy. Review the Jujutsu diff and push the website
repository separately. repository separately.
`ContentAutoTag` accepts Drafts in `vault/public-notes/` and `vault/blog/`.
TypeSafe selects and validates tags. The command reuses tags collected from both
content areas. If no existing tag fits, an OpenAI-compatible generator proposes
new tags and TypeSafe validates them before the command updates frontmatter.
## Normal workflow ## Normal workflow
1. Press `\nl` for a Log or `\nn` for a titled Note. 1. Press `\nl` for a Log or `\nn` for a titled Note.
2. Write and save the Entry. 2. Write and save the Entry.
3. Press `\np` and confirm publication. 3. Press `\nt` to generate tags and review the notification.
4. Press `\nv`, then `\nb`. 4. Press `\np` and confirm publication.
5. Review, describe, and push the website change with Jujutsu. 5. Press `\nv`, then `\nb`.
6. Review, describe, and push the website change with Jujutsu.
The publication command reloads the current buffer after the repository script The publication command reloads the current buffer after the repository script
writes `publishedAt` and processes referenced images. writes `publishedAt` and processes referenced images.

View file

@ -3,6 +3,7 @@ local M = {}
local default_root = vim.fn.expand("~/Development/MyWeb/magnum-opus/main") local default_root = vim.fn.expand("~/Development/MyWeb/magnum-opus/main")
local root = vim.fs.normalize(vim.env.RASYIDANAF_SITE_ROOT or default_root) local root = vim.fs.normalize(vim.env.RASYIDANAF_SITE_ROOT or default_root)
local notes_directory = vim.fs.normalize(root .. "/vault/public-notes") local notes_directory = vim.fs.normalize(root .. "/vault/public-notes")
local blog_directory = vim.fs.normalize(root .. "/vault/blog")
local function notify(message, level) local function notify(message, level)
vim.notify(message, level or vim.log.levels.INFO, { title = "Notes" }) vim.notify(message, level or vim.log.levels.INFO, { title = "Notes" })
@ -78,18 +79,6 @@ local function run(arguments, options)
) )
end end
local function prompt_tags(callback)
vim.ui.input({
prompt = "Tags (comma-separated, optional): ",
}, function(tags)
if tags == nil then
return
end
callback(vim.trim(tags))
end)
end
local function open_created_entry(output) local function open_created_entry(output)
local relative_path = last_line(output) local relative_path = last_line(output)
if not relative_path then if not relative_path then
@ -106,17 +95,13 @@ local function open_created_entry(output)
notify("Draft created: " .. relative_path) notify("Draft created: " .. relative_path)
end end
local function create_entry(kind, title, tags) local function create_entry(kind, title)
local arguments = { "notes:new", "--", "--kind", kind } local arguments = { "notes:new", "--", "--kind", kind }
if title and title ~= "" then if title and title ~= "" then
vim.list_extend(arguments, { "--title", title }) vim.list_extend(arguments, { "--title", title })
end end
if tags ~= "" then
vim.list_extend(arguments, { "--tags", tags })
end
run(arguments, { run(arguments, {
progress = "Creating " .. kind .. " draft…", progress = "Creating " .. kind .. " draft…",
on_success = open_created_entry, on_success = open_created_entry,
@ -131,9 +116,7 @@ function M.new_log()
return return
end end
prompt_tags(function(tags) create_entry("log", vim.trim(title))
create_entry("log", vim.trim(title), tags)
end)
end) end)
end end
@ -151,18 +134,24 @@ function M.new_note()
return return
end end
prompt_tags(function(tags) create_entry("note", title)
create_entry("note", title, tags)
end)
end) end)
end end
local function current_entry() local function current_path()
local path = vim.fs.normalize(vim.api.nvim_buf_get_name(0)) local path = vim.fs.normalize(vim.api.nvim_buf_get_name(0))
if path == "" then if path == "" then
notify("The current buffer has no file.", vim.log.levels.ERROR) notify("The current buffer has no file.", vim.log.levels.ERROR)
return nil return nil
end end
return path
end
local function current_entry()
local path = current_path()
if not path then
return nil
end
if not vim.startswith(path, notes_directory .. "/") or not path:match("%.md$") then if not vim.startswith(path, notes_directory .. "/") or not path:match("%.md$") then
notify("Open a Markdown file inside vault/public-notes/ first.", vim.log.levels.ERROR) notify("Open a Markdown file inside vault/public-notes/ first.", vim.log.levels.ERROR)
@ -172,18 +161,43 @@ local function current_entry()
return path return path
end end
local function current_taggable_content()
local path = current_path()
if not path then
return nil
end
local is_public_note = vim.startswith(path, notes_directory .. "/") and path:match("%.md$")
local is_blog_post = vim.startswith(path, blog_directory .. "/")
and (path:match("/index%.md$") or path:match("/index%.mdx$"))
if not is_public_note and not is_blog_post then
notify("Open a Draft in vault/public-notes/ or vault/blog/ first.", vim.log.levels.ERROR)
return nil
end
return path
end
local function save_current_buffer()
if not vim.bo.modified then
return true
end
local saved, error_message = pcall(vim.cmd.write)
if not saved then
notify("Could not save the content: " .. tostring(error_message), vim.log.levels.ERROR)
return false
end
return true
end
function M.publish() function M.publish()
local path = current_entry() local path = current_entry()
if not path then if not path then
return return
end end
if vim.bo.modified then if not save_current_buffer() then
local saved, error_message = pcall(vim.cmd.write) return
if not saved then
notify("Could not save the Entry: " .. tostring(error_message), vim.log.levels.ERROR)
return
end
end end
vim.ui.select({ "Publish", "Cancel" }, { vim.ui.select({ "Publish", "Cancel" }, {
@ -203,6 +217,21 @@ function M.publish()
end) end)
end end
function M.auto_tag()
local path = current_taggable_content()
if not path or not save_current_buffer() then
return
end
run({ "content:tag", path }, {
progress = "Generating tags…",
on_success = function(output)
vim.cmd.edit(vim.fn.fnameescape(path))
notify(last_line(output) or "Tags applied.")
end,
})
end
function M.validate() function M.validate()
run({ "notes:validate" }, { run({ "notes:validate" }, {
progress = "Validating Notes…", progress = "Validating Notes…",
@ -226,6 +255,9 @@ vim.api.nvim_create_user_command("NotesNewNote", M.new_note, {
vim.api.nvim_create_user_command("NotesPublish", M.publish, { vim.api.nvim_create_user_command("NotesPublish", M.publish, {
desc = "Publish the current public Notes Entry", desc = "Publish the current public Notes Entry",
}) })
vim.api.nvim_create_user_command("ContentAutoTag", M.auto_tag, {
desc = "Automatically tag the current Draft Note or Blog Post",
})
vim.api.nvim_create_user_command("NotesValidate", M.validate, { vim.api.nvim_create_user_command("NotesValidate", M.validate, {
desc = "Validate public Notes", desc = "Validate public Notes",
}) })
@ -236,6 +268,7 @@ vim.api.nvim_create_user_command("NotesBuild", M.build, {
vim.keymap.set("n", "<localleader>nl", M.new_log, { desc = "New log" }) vim.keymap.set("n", "<localleader>nl", M.new_log, { desc = "New log" })
vim.keymap.set("n", "<localleader>nn", M.new_note, { desc = "New note" }) vim.keymap.set("n", "<localleader>nn", M.new_note, { desc = "New note" })
vim.keymap.set("n", "<localleader>np", M.publish, { desc = "Publish current note" }) vim.keymap.set("n", "<localleader>np", M.publish, { desc = "Publish current note" })
vim.keymap.set("n", "<localleader>nt", M.auto_tag, { desc = "Auto-tag current draft" })
vim.keymap.set("n", "<localleader>nv", M.validate, { desc = "Validate notes" }) vim.keymap.set("n", "<localleader>nv", M.validate, { desc = "Validate notes" })
vim.keymap.set("n", "<localleader>nb", M.build, { desc = "Build website" }) vim.keymap.set("n", "<localleader>nb", M.build, { desc = "Build website" })