Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,9 +150,26 @@ With this configuration:
|----------|-------------|
| `{{@index}}` | Document list table (default columns: Title, Date, Author) |
| `{{@index{@id\|@title\|@status}}}` | Custom format table with specified columns |
| `{{@index{@id\|@title,asc:@id}}}` | Custom format with sort specification |
| `{{@index{@id\|@title\|@date,desc:@date}}}` | Sort by date descending |

Available columns: `@id`, `@title`, `@date`, `@name`, `@status`

### Index Sorting

You can specify a sort order using the `,asc:@field` or `,desc:@field` syntax after the column specification.

**Default sort behavior** (when no sort is specified):
- If documents have `@id`: sort by `@id` ascending
- Else if documents have `@date`: sort by `@date` descending
- Else: sort by file modification time descending

**Examples:**
```markdown
{{@index{@id|@title|@author,asc:@id}}} <!-- Sort by ID ascending -->
{{@index{@title|@date,desc:@date}}} <!-- Sort by date descending -->
```

## Custom Templates

Create your own templates in `.draft/templates/`:
Expand Down
266 changes: 261 additions & 5 deletions src/index.zig
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,28 @@ pub const DocumentMeta = struct {
date: []const u8,
name: []const u8,
status: []const u8,
mtime: i128, // File modification time in nanoseconds
};

pub fn extractDocumentMeta(allocator: mem.Allocator, filename: []const u8, content: []const u8) !DocumentMeta {
pub const SortOrder = enum {
asc,
desc,
};

pub const SortConfig = struct {
field: []const u8,
order: SortOrder,
};

pub fn extractDocumentMeta(allocator: mem.Allocator, filename: []const u8, content: []const u8, mtime: i128) !DocumentMeta {
var meta = DocumentMeta{
.filename = try allocator.dupe(u8, filename),
.id = try allocator.dupe(u8, ""),
.title = try allocator.dupe(u8, ""),
.date = try allocator.dupe(u8, ""),
.name = try allocator.dupe(u8, ""),
.status = try allocator.dupe(u8, ""),
.mtime = mtime,
};

// Extract ID from filename (first 3 chars if numeric)
Expand Down Expand Up @@ -60,6 +72,27 @@ pub fn extractDocumentMeta(allocator: mem.Allocator, filename: []const u8, conte
return meta;
}

/// Extract sort configuration from template
/// Returns null if no sort specification found in template
pub fn extractSortConfigFromTemplate(template: []const u8) ?SortConfig {
const index_start = "{{@index";
if (mem.indexOf(u8, template, index_start)) |start_idx| {
const after_start = template[start_idx + index_start.len ..];

if (after_start.len > 0 and after_start[0] == '{') {
// Custom format: {{@index{@id|@title|@status,asc:@id}}}
if (mem.indexOf(u8, after_start, "}}}")) |close_idx| {
const spec = after_start[1..close_idx];
const parsed = parseIndexSpec(spec);
if (parsed.sort_spec) |sort_spec| {
return parseSortSpec(sort_spec);
}
}
}
}
return null;
}

pub fn expandIndex(allocator: mem.Allocator, template: []const u8, docs: []const DocumentMeta) ![]const u8 {
var result = try allocator.dupe(u8, template);

Expand All @@ -72,9 +105,11 @@ pub fn expandIndex(allocator: mem.Allocator, template: []const u8, docs: []const
var end_idx: usize = 0;

if (after_start.len > 0 and after_start[0] == '{') {
// Custom format: {{@index{@id|@title|@status}}}
// Custom format: {{@index{@id|@title|@status}}} or {{@index{@id|@title,asc:@id}}}
if (mem.indexOf(u8, after_start, "}}}")) |close_idx| {
format = after_start[1..close_idx];
const spec = after_start[1..close_idx];
const parsed = parseIndexSpec(spec);
format = parsed.format;
end_idx = start_idx + index_start.len + close_idx + 3;
}
} else if (mem.indexOf(u8, after_start, "}}")) |close_idx| {
Expand Down Expand Up @@ -172,6 +207,87 @@ pub fn getColumnValue(doc: DocumentMeta, col: []const u8) []const u8 {
return "";
}

/// Parse sort specification from format string (e.g., "asc:@id" or "desc:@date")
pub fn parseSortSpec(sort_spec: []const u8) ?SortConfig {
if (mem.startsWith(u8, sort_spec, "asc:")) {
return SortConfig{
.field = sort_spec[4..],
.order = .asc,
};
} else if (mem.startsWith(u8, sort_spec, "desc:")) {
return SortConfig{
.field = sort_spec[5..],
.order = .desc,
};
}
return null;
}

/// Determine default sort configuration based on document metadata
/// - If documents have id: sort by id ascending
/// - Else if documents have date: sort by date descending
/// - Else: sort by mtime descending
pub fn getDefaultSortConfig(docs: []const DocumentMeta) SortConfig {
// Check if any document has a non-empty id
var has_id = false;
var has_date = false;

for (docs) |doc| {
if (doc.id.len > 0) has_id = true;
if (doc.date.len > 0) has_date = true;
}

if (has_id) {
return SortConfig{ .field = "@id", .order = .asc };
} else if (has_date) {
return SortConfig{ .field = "@date", .order = .desc };
} else {
return SortConfig{ .field = "@mtime", .order = .desc };
}
}

/// Sort documents by the given configuration
pub fn sortDocuments(docs: []DocumentMeta, sort_config: SortConfig) void {
const Context = struct {
config: SortConfig,

fn compare(ctx: @This(), a: DocumentMeta, b: DocumentMeta) bool {
const order_result = if (mem.eql(u8, ctx.config.field, "@mtime"))
compareMtime(a, b)
else
compareStrings(getColumnValue(a, ctx.config.field), getColumnValue(b, ctx.config.field));

return if (ctx.config.order == .asc) order_result == .lt else order_result == .gt;
}

fn compareMtime(a: DocumentMeta, b: DocumentMeta) std.math.Order {
return std.math.order(a.mtime, b.mtime);
}

fn compareStrings(a_val: []const u8, b_val: []const u8) std.math.Order {
return mem.order(u8, a_val, b_val);
}
};

std.mem.sort(DocumentMeta, docs, Context{ .config = sort_config }, Context.compare);
}

/// Parse format and sort specification from index tag
/// Format: "@col1|@col2|@col3,asc:@field" or "@col1|@col2|@col3"
/// Returns (format, sort_spec)
pub fn parseIndexSpec(spec: []const u8) struct { format: []const u8, sort_spec: ?[]const u8 } {
if (mem.lastIndexOfScalar(u8, spec, ',')) |comma_idx| {
return .{
.format = spec[0..comma_idx],
.sort_spec = spec[comma_idx + 1 ..],
};
}
return .{
.format = spec,
.sort_spec = null,
};
}

// =============================================================================
// Tests
// =============================================================================
Expand All @@ -191,7 +307,7 @@ test "extractDocumentMeta: basic extraction" {
\\## Context
;

const meta = try extractDocumentMeta(allocator, "001-auth.md", content);
const meta = try extractDocumentMeta(allocator, "001-auth.md", content, 1000000);
defer {
allocator.free(meta.filename);
allocator.free(meta.id);
Expand All @@ -207,13 +323,14 @@ test "extractDocumentMeta: basic extraction" {
try testing.expectEqualStrings("2026-01-18", meta.date);
try testing.expectEqualStrings("linyows", meta.name);
try testing.expectEqualStrings("Accepted", meta.status);
try testing.expectEqual(@as(i128, 1000000), meta.mtime);
}

test "extractDocumentMeta: id from filename" {
const allocator = testing.allocator;
const content = "# My Title\n\nSome content";

const meta = try extractDocumentMeta(allocator, "042-my-doc.md", content);
const meta = try extractDocumentMeta(allocator, "042-my-doc.md", content, 2000000);
defer {
allocator.free(meta.filename);
allocator.free(meta.id);
Expand Down Expand Up @@ -244,6 +361,7 @@ test "getColumnValue: returns correct values" {
.date = "2026-01-18",
.name = "linyows",
.status = "Proposed",
.mtime = 1000000,
};

try testing.expectEqualStrings("001", getColumnValue(doc, "@id"));
Expand All @@ -264,6 +382,7 @@ test "buildIndexTable: default format" {
.date = "2026-01-18",
.name = "linyows",
.status = "Accepted",
.mtime = 1000000,
},
};

Expand All @@ -286,6 +405,7 @@ test "buildIndexTable: custom format with id and status" {
.date = "2026-01-18",
.name = "linyows",
.status = "Accepted",
.mtime = 1000000,
},
};

Expand All @@ -308,6 +428,7 @@ test "expandIndex: default format" {
.date = "2026-01-18",
.name = "linyows",
.status = "Proposed",
.mtime = 1000000,
},
};

Expand All @@ -331,6 +452,7 @@ test "expandIndex: custom format" {
.date = "2026-01-18",
.name = "linyows",
.status = "Accepted",
.mtime = 1000000,
},
};

Expand All @@ -341,3 +463,137 @@ test "expandIndex: custom format" {
try testing.expect(mem.indexOf(u8, result, "[001](./001-test.md)") != null);
try testing.expect(mem.indexOf(u8, result, "Accepted") != null);
}

test "expandIndex: custom format with sort spec" {
const allocator = testing.allocator;
const template = "# ADR\n\n{{@index{@id|@title,asc:@id}}}\n";
var docs = [_]DocumentMeta{
.{
.filename = "001-test.md",
.id = "001",
.title = "Test",
.date = "2026-01-18",
.name = "linyows",
.status = "Accepted",
.mtime = 1000000,
},
};

const result = try expandIndex(allocator, template, &docs);
defer allocator.free(result);

try testing.expect(mem.indexOf(u8, result, "| ID | Title |") != null);
try testing.expect(mem.indexOf(u8, result, "[001](./001-test.md)") != null);
}

test "parseSortSpec: parses asc" {
const config = parseSortSpec("asc:@id");
try testing.expect(config != null);
try testing.expectEqualStrings("@id", config.?.field);
try testing.expectEqual(SortOrder.asc, config.?.order);
}

test "parseSortSpec: parses desc" {
const config = parseSortSpec("desc:@date");
try testing.expect(config != null);
try testing.expectEqualStrings("@date", config.?.field);
try testing.expectEqual(SortOrder.desc, config.?.order);
}

test "parseSortSpec: returns null for invalid" {
try testing.expect(parseSortSpec("invalid") == null);
try testing.expect(parseSortSpec("@id") == null);
}

test "parseIndexSpec: with sort spec" {
const result = parseIndexSpec("@id|@title|@date,asc:@id");
try testing.expectEqualStrings("@id|@title|@date", result.format);
try testing.expect(result.sort_spec != null);
try testing.expectEqualStrings("asc:@id", result.sort_spec.?);
}

test "parseIndexSpec: without sort spec" {
const result = parseIndexSpec("@id|@title|@date");
try testing.expectEqualStrings("@id|@title|@date", result.format);
try testing.expect(result.sort_spec == null);
}

test "getDefaultSortConfig: with id" {
var docs = [_]DocumentMeta{
.{ .filename = "a.md", .id = "001", .title = "", .date = "", .name = "", .status = "", .mtime = 0 },
};
const config = getDefaultSortConfig(&docs);
try testing.expectEqualStrings("@id", config.field);
try testing.expectEqual(SortOrder.asc, config.order);
}

test "getDefaultSortConfig: with date only" {
var docs = [_]DocumentMeta{
.{ .filename = "a.md", .id = "", .title = "", .date = "2026-01-18", .name = "", .status = "", .mtime = 0 },
};
const config = getDefaultSortConfig(&docs);
try testing.expectEqualStrings("@date", config.field);
try testing.expectEqual(SortOrder.desc, config.order);
}

test "getDefaultSortConfig: no id or date" {
var docs = [_]DocumentMeta{
.{ .filename = "a.md", .id = "", .title = "", .date = "", .name = "", .status = "", .mtime = 0 },
};
const config = getDefaultSortConfig(&docs);
try testing.expectEqualStrings("@mtime", config.field);
try testing.expectEqual(SortOrder.desc, config.order);
}

test "sortDocuments: by id ascending" {
var docs = [_]DocumentMeta{
.{ .filename = "b.md", .id = "002", .title = "B", .date = "", .name = "", .status = "", .mtime = 0 },
.{ .filename = "a.md", .id = "001", .title = "A", .date = "", .name = "", .status = "", .mtime = 0 },
.{ .filename = "c.md", .id = "003", .title = "C", .date = "", .name = "", .status = "", .mtime = 0 },
};
sortDocuments(&docs, .{ .field = "@id", .order = .asc });
try testing.expectEqualStrings("001", docs[0].id);
try testing.expectEqualStrings("002", docs[1].id);
try testing.expectEqualStrings("003", docs[2].id);
}

test "sortDocuments: by date descending" {
var docs = [_]DocumentMeta{
.{ .filename = "a.md", .id = "", .title = "", .date = "2026-01-15", .name = "", .status = "", .mtime = 0 },
.{ .filename = "b.md", .id = "", .title = "", .date = "2026-01-20", .name = "", .status = "", .mtime = 0 },
.{ .filename = "c.md", .id = "", .title = "", .date = "2026-01-10", .name = "", .status = "", .mtime = 0 },
};
sortDocuments(&docs, .{ .field = "@date", .order = .desc });
try testing.expectEqualStrings("2026-01-20", docs[0].date);
try testing.expectEqualStrings("2026-01-15", docs[1].date);
try testing.expectEqualStrings("2026-01-10", docs[2].date);
}

test "sortDocuments: by mtime descending" {
var docs = [_]DocumentMeta{
.{ .filename = "a.md", .id = "", .title = "", .date = "", .name = "", .status = "", .mtime = 1000 },
.{ .filename = "b.md", .id = "", .title = "", .date = "", .name = "", .status = "", .mtime = 3000 },
.{ .filename = "c.md", .id = "", .title = "", .date = "", .name = "", .status = "", .mtime = 2000 },
};
sortDocuments(&docs, .{ .field = "@mtime", .order = .desc });
try testing.expectEqual(@as(i128, 3000), docs[0].mtime);
try testing.expectEqual(@as(i128, 2000), docs[1].mtime);
try testing.expectEqual(@as(i128, 1000), docs[2].mtime);
}

test "extractSortConfigFromTemplate: with sort spec" {
const config = extractSortConfigFromTemplate("# Index\n\n{{@index{@id|@title,asc:@id}}}\n");
try testing.expect(config != null);
try testing.expectEqualStrings("@id", config.?.field);
try testing.expectEqual(SortOrder.asc, config.?.order);
}

test "extractSortConfigFromTemplate: without sort spec" {
const config = extractSortConfigFromTemplate("# Index\n\n{{@index{@id|@title}}}\n");
try testing.expect(config == null);
}

test "extractSortConfigFromTemplate: default format" {
const config = extractSortConfigFromTemplate("# Index\n\n{{@index}}\n");
try testing.expect(config == null);
}
Loading