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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,8 @@ content/
└── api.md
```

Folders become sidebar groups, and frontmatter can define titles and tags.
Folders become sidebar groups, and frontmatter can define `title`, `tags`,
`description`, and `order`. Folder order is configured in `src/config/nav.ts`.

## Development Checks

Expand Down
2 changes: 2 additions & 0 deletions content/features/semantic-search.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
---
title: Semantic Search - AI-Powered Search
description: How vector embeddings let search match on meaning rather than keywords, and how this theme wires them up.
order: 1
tags: [semantic-search, embeddings, ai, vector-search]
---

Expand Down
2 changes: 2 additions & 0 deletions content/getting-started/welcome.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
---
title: Welcome to Your Docs
description: Set up semantic-docs, index your first markdown files, and learn how the theme fits together.
order: 1
tags: [getting-started, introduction]
---

Expand Down
2 changes: 2 additions & 0 deletions content/theme/overview.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
---
title: Semantic Docs Theme Overview
description: A tour of the Astro theme - hybrid rendering, the libSQL-backed sidebar, and the pieces you customize.
order: 1
tags: [astro, theme, documentation, semantic-search]
---

Expand Down
7 changes: 7 additions & 0 deletions docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,13 @@ After upgrading, confirm the index is populated rather than assuming it:
SELECT count(*) FROM articles_cf_bgem3_1024_fts;
```

Sidebar ordering adds two columns, `sort_order` and `description`. Both
`pnpm db:init` and `pnpm index` add them to an existing table, so upgrading
needs no separate migration; existing rows are preserved and the values are
filled in on the next `pnpm index`. Rendering reads these columns, so a
deployment serving pages from a database that has never run either command
fails with `no such column: sort_order` rather than quietly dropping the order.

## Containers

Two Dockerfiles are provided. Both index content during the build, and indexing
Expand Down
11 changes: 11 additions & 0 deletions docs/GETTING_STARTED.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ Example:
```markdown
---
title: Getting Started
description: What this page covers, used for the meta description and link previews.
order: 1
tags: [tutorial, beginner]
---

Expand All @@ -70,6 +72,15 @@ tags: [tutorial, beginner]
Your content here.
```

`order` sets the position in the sidebar within its folder, ascending. Articles
without one sort after those that have one, by title. `description` fills the
page's `<meta name="description">` and `og:description`, falling back to the
title when absent. Both are read at index time, so rerun `pnpm index` after
changing them.

Folder order is configured in `src/config/nav.ts`. Folders left out of that
list render after the listed ones, alphabetically.

After adding or changing content, rebuild the index before building or
deploying:

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
"astro": "^7.2.10",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"gray-matter": "^4.0.3",
"logan-logger": "^2.5.3",
"lucide-react": "^1.41.0",
"marked": "^18.0.11",
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

54 changes: 54 additions & 0 deletions scripts/index-content-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,4 +126,58 @@ describe('runContentIndexing', () => {
expect(await runContentIndexing(operations, logger)).toBe(1);
expect(rebuildKeywordIndex).not.toHaveBeenCalled();
});

it('applies navigation frontmatter after the rows are written', async () => {
const order: string[] = [];
const logger = createLogger();
const operations: IndexingOperations = {
createTable: vi.fn().mockResolvedValue(undefined),
indexContent: vi.fn(async () => {
order.push('indexContent');
return { success: 1, total: 1, failed: 0 };
}),
applyNavFrontmatter: vi.fn(async () => {
order.push('applyNavFrontmatter');
return 3;
}),
};

expect(await runContentIndexing(operations, logger)).toBe(0);
// indexContent clears and reinserts the table, so an earlier pass would be
// overwritten.
expect(order).toEqual(['indexContent', 'applyNavFrontmatter']);
expect(logger.info).toHaveBeenCalledWith(
'Applied navigation frontmatter to 3 documents',
);
});

it('skips the frontmatter pass when no operation is supplied', async () => {
const logger = createLogger();
const operations: IndexingOperations = {
createTable: vi.fn().mockResolvedValue(undefined),
indexContent: vi.fn().mockResolvedValue({
success: 1,
total: 1,
failed: 0,
}),
};

expect(await runContentIndexing(operations, logger)).toBe(0);
expect(logger.info).not.toHaveBeenCalledWith(
expect.stringContaining('navigation frontmatter'),
);
});

it('does not apply frontmatter when indexing throws', async () => {
const logger = createLogger();
const applyNavFrontmatter = vi.fn();
const operations: IndexingOperations = {
createTable: vi.fn().mockResolvedValue(undefined),
indexContent: vi.fn().mockRejectedValue(new Error('embedding failed')),
applyNavFrontmatter,
};

expect(await runContentIndexing(operations, logger)).toBe(1);
expect(applyNavFrontmatter).not.toHaveBeenCalled();
});
});
9 changes: 9 additions & 0 deletions scripts/index-content-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export interface IndexingOperations {
indexContent: (onProgress: IndexProgress) => Promise<IndexingResult>;
/** Repopulate the keyword index from the rows just written. */
rebuildKeywordIndex?: () => Promise<void>;
/** Persist frontmatter fields the library parses but discards. Returns rows updated. */
applyNavFrontmatter?: () => Promise<number>;
}

export interface IndexingLogger {
Expand All @@ -39,6 +41,13 @@ export async function runContentIndexing(
logger.info(`[${current}/${total}] Indexing: ${file}`);
});

// After the vector rows land, because indexContent clears and reinserts the
// table and would otherwise discard these values.
if (operations.applyNavFrontmatter) {
const updated = await operations.applyNavFrontmatter();
logger.info(`Applied navigation frontmatter to ${updated} documents`);
}

// After the vector rows land, so a failed embedding run never leaves a
// keyword index describing content that is not in the articles table.
if (operations.rebuildKeywordIndex) {
Expand Down
32 changes: 29 additions & 3 deletions scripts/index-content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,17 @@ import { createClient } from '@libsql/client';
import { createTable, indexContent } from '@logan/libsql-search';
import { logger } from 'logan-logger';
import { env } from '../src/lib/env';
import { ensureNavColumns } from '../src/lib/navSchema';
import {
EMBEDDING_DIMENSIONS,
getEmbeddingOptions,
SEARCH_FTS_TABLE_NAME,
SEARCH_TABLE_NAME,
} from '../src/lib/searchConfig';
import { runContentIndexing } from './index-content-runner';
import { applyNavFrontmatter, collectNavFrontmatter } from './nav-frontmatter';

const CONTENT_PATH = './content';

// Initialize client (Turso or local libSQL)
const url = process.env.TURSO_DB_URL;
Expand Down Expand Up @@ -66,16 +70,38 @@ async function rebuildKeywordIndex(): Promise<void> {

process.exitCode = await runContentIndexing(
{
createTable: () =>
createTable(client, SEARCH_TABLE_NAME, EMBEDDING_DIMENSIONS),
// Indexing runs against databases created before the navigation columns
// existed, so it migrates rather than assuming db:init was rerun.
createTable: async () => {
await createTable(client, SEARCH_TABLE_NAME, EMBEDDING_DIMENSIONS);
await ensureNavColumns(client, SEARCH_TABLE_NAME);
},
indexContent: (onProgress) =>
indexContent({
client,
contentPath: './content',
contentPath: CONTENT_PATH,
tableName: SEARCH_TABLE_NAME,
embeddingOptions,
onProgress,
}),
applyNavFrontmatter: async () => {
const records = await collectNavFrontmatter(CONTENT_PATH);
const updated = await applyNavFrontmatter(
client,
SEARCH_TABLE_NAME,
records,
);

// A shortfall means a file on disk has no indexed row under the slug this
// pass derived, so its ordering and description are silently missing.
if (updated < records.length) {
logger.warn(
`Navigation frontmatter matched ${updated} of ${records.length} content files; the rest are not in the index under the expected slug`,
);
}

return updated;
},
rebuildKeywordIndex,
},
logger,
Expand Down
12 changes: 12 additions & 0 deletions scripts/init-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import { createClient } from '@libsql/client';
import { createTable } from '@logan/libsql-search';
import { logger } from 'logan-logger';
import { ensureNavColumns } from '../src/lib/navSchema';
import {
EMBEDDING_DIMENSIONS,
SEARCH_FTS_TABLE_NAME,
Expand Down Expand Up @@ -35,6 +36,17 @@ try {
`Created ${SEARCH_TABLE_NAME} with ${EMBEDDING_DIMENSIONS}-dimension embeddings`,
);

// createTable is CREATE TABLE IF NOT EXISTS, so it leaves an existing table
// alone. This is what upgrades a deployment indexed before the navigation
// columns existed, without a separate migration step.
const addedColumns = await ensureNavColumns(client, SEARCH_TABLE_NAME);

if (addedColumns.length > 0) {
logger.info(
`Added navigation columns to ${SEARCH_TABLE_NAME}: ${addedColumns.join(', ')} (reindex to populate)`,
);
}

// Keyword half of hybrid search. Kept as its own table rather than an
// external-content one: the indexer clears and repopulates the articles
// table wholesale, and a standalone index is rebuilt from it in one step
Expand Down
Loading
Loading