Skip to content

Commit 9409450

Browse files
clydindevversion
authored andcommitted
feat(ng_examples_db): add initial ng_examples_db rule
This commit introduces the `ng_examples_db` Bazel rule and its supporting files. The rule generates a SQLite database from Markdown files containing YAML frontmatter. It parses frontmatter to extract metadata such as title, summary, keywords, and related concepts, and stores this information along with the content in a searchable database.
1 parent 9b751f8 commit 9409450

5 files changed

Lines changed: 260 additions & 0 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"rxjs": "^7.8.2",
4040
"tinyglobby": "0.2.12",
4141
"tslib": "^2.8.1",
42+
"zod": "^4.1.13",
4243
"zone.js": "^0.15.0"
4344
}
4445
}

pnpm-lock.yaml

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/ng_examples_db/BUILD.bazel

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
load("@aspect_rules_js//js:defs.bzl", "js_binary")
2+
3+
js_binary(
4+
name = "bin",
5+
data = [
6+
"db_generator.mjs",
7+
"//:node_modules/zod",
8+
],
9+
entry_point = "db_generator.mjs",
10+
)
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import {globSync, readdirSync, readFileSync, mkdirSync, existsSync, rmSync} from 'node:fs';
10+
import {resolve, dirname, join} from 'node:path';
11+
import {DatabaseSync} from 'node:sqlite';
12+
import {z} from 'zod';
13+
14+
/**
15+
* A simple YAML front matter parser.
16+
*
17+
* This function extracts the YAML block enclosed by `---` at the beginning of a string
18+
* and parses it into a JavaScript object. It is not a full YAML parser and only
19+
* supports simple key-value pairs and string arrays.
20+
*
21+
* @param content The string content to parse.
22+
* @returns A record containing the parsed front matter data.
23+
*/
24+
function parseFrontmatter(content) {
25+
const match = content.match(/^---\r?\n(.*?)\r?\n---/s);
26+
if (!match) {
27+
return {};
28+
}
29+
30+
const frontmatter = match[1];
31+
const data = {};
32+
const lines = frontmatter.split(/\r?\n/);
33+
34+
let currentKey = '';
35+
let isArray = false;
36+
const arrayValues = [];
37+
38+
for (const line of lines) {
39+
const keyValueMatch = line.match(/^([^:]+):\s*(.*)/);
40+
if (keyValueMatch) {
41+
if (currentKey && isArray) {
42+
data[currentKey] = arrayValues.slice();
43+
arrayValues.length = 0;
44+
}
45+
46+
const [, key, value] = keyValueMatch;
47+
currentKey = key.trim();
48+
isArray = value.trim() === '';
49+
50+
if (!isArray) {
51+
const trimmedValue = value.trim();
52+
if (trimmedValue === 'true') {
53+
data[currentKey] = true;
54+
} else if (trimmedValue === 'false') {
55+
data[currentKey] = false;
56+
} else {
57+
data[currentKey] = trimmedValue;
58+
}
59+
}
60+
} else {
61+
const arrayItemMatch = line.match(/^\s*-\s*(.*)/);
62+
if (arrayItemMatch && currentKey && isArray) {
63+
let value = arrayItemMatch[1].trim();
64+
// Unquote if the value is quoted.
65+
if (
66+
(value.startsWith("'") && value.endsWith("'")) ||
67+
(value.startsWith('"') && value.endsWith('"'))
68+
) {
69+
value = value.slice(1, -1);
70+
}
71+
arrayValues.push(value);
72+
}
73+
}
74+
}
75+
76+
if (currentKey && isArray) {
77+
data[currentKey] = arrayValues;
78+
}
79+
80+
return data;
81+
}
82+
83+
export function generate(inPath, outPath) {
84+
const dbPath = outPath;
85+
mkdirSync(dirname(outPath), {recursive: true});
86+
87+
if (existsSync(dbPath)) {
88+
rmSync(dbPath);
89+
}
90+
const db = new DatabaseSync(dbPath);
91+
92+
// Create a table to store metadata.
93+
db.exec(`
94+
CREATE TABLE metadata (
95+
key TEXT PRIMARY KEY NOT NULL,
96+
value TEXT NOT NULL
97+
);
98+
`);
99+
100+
db.exec(`
101+
INSERT INTO metadata (key, value) VALUES
102+
('schema_version', '1'),
103+
('created_at', '${new Date().toISOString()}');
104+
`);
105+
106+
// Create a relational table to store the structured example data.
107+
db.exec(`
108+
CREATE TABLE examples (
109+
id INTEGER PRIMARY KEY,
110+
title TEXT NOT NULL,
111+
summary TEXT NOT NULL,
112+
keywords TEXT,
113+
required_packages TEXT,
114+
related_concepts TEXT,
115+
related_tools TEXT,
116+
experimental INTEGER NOT NULL DEFAULT 0,
117+
content TEXT NOT NULL
118+
);
119+
`);
120+
121+
// Create an FTS5 virtual table to provide full-text search capabilities.
122+
db.exec(`
123+
CREATE VIRTUAL TABLE examples_fts USING fts5(
124+
title,
125+
summary,
126+
keywords,
127+
required_packages,
128+
related_concepts,
129+
related_tools,
130+
content,
131+
content='examples',
132+
content_rowid='id',
133+
tokenize = 'porter ascii'
134+
);
135+
`);
136+
137+
// Create triggers to keep the FTS table synchronized with the examples table.
138+
db.exec(`
139+
CREATE TRIGGER examples_after_insert AFTER INSERT ON examples BEGIN
140+
INSERT INTO examples_fts(
141+
rowid, title, summary, keywords, required_packages, related_concepts, related_tools,
142+
content
143+
)
144+
VALUES (
145+
new.id, new.title, new.summary, new.keywords, new.required_packages,
146+
new.related_concepts, new.related_tools, new.content
147+
);
148+
END;
149+
`);
150+
151+
const insertStatement = db.prepare(
152+
'INSERT INTO examples(' +
153+
'title, summary, keywords, required_packages, related_concepts, related_tools, experimental, content' +
154+
') VALUES(?, ?, ?, ?, ?, ?, ?, ?);',
155+
);
156+
157+
const frontmatterSchema = z.object({
158+
title: z.string(),
159+
summary: z.string(),
160+
keywords: z.array(z.string()).optional(),
161+
required_packages: z.array(z.string()).optional(),
162+
related_concepts: z.array(z.string()).optional(),
163+
related_tools: z.array(z.string()).optional(),
164+
experimental: z.boolean().optional(),
165+
});
166+
167+
db.exec('BEGIN TRANSACTION');
168+
const entries = globSync
169+
? globSync('**/*.md', {cwd: resolve(inPath), withFileTypes: true})
170+
: readdirSync(resolve(inPath), {withFileTypes: true});
171+
for (const entry of entries) {
172+
if (!entry.isFile() || !entry.name.endsWith('.md')) {
173+
continue;
174+
}
175+
176+
const content = readFileSync(join(entry.parentPath, entry.name), 'utf-8');
177+
const frontmatter = parseFrontmatter(content);
178+
179+
const validation = frontmatterSchema.safeParse(frontmatter);
180+
if (!validation.success) {
181+
console.error(`Validation failed for example file: ${entry.name}`);
182+
console.error('Issues:', validation.error.issues);
183+
throw new Error(`Invalid front matter in ${entry.name}`);
184+
}
185+
186+
const {
187+
title,
188+
summary,
189+
keywords,
190+
required_packages,
191+
related_concepts,
192+
related_tools,
193+
experimental,
194+
} = validation.data;
195+
insertStatement.run(
196+
title,
197+
summary,
198+
JSON.stringify(keywords ?? []),
199+
JSON.stringify(required_packages ?? []),
200+
JSON.stringify(related_concepts ?? []),
201+
JSON.stringify(related_tools ?? []),
202+
experimental ? 1 : 0,
203+
content,
204+
);
205+
}
206+
db.exec('END TRANSACTION');
207+
208+
db.close();
209+
}
210+
211+
function main() {
212+
const argv = process.argv.slice(2);
213+
if (argv.length !== 2) {
214+
console.error('Must include 2 arguments.');
215+
process.exit(1);
216+
}
217+
218+
const [inPath, outPath] = argv;
219+
220+
try {
221+
generate(inPath, outPath);
222+
} catch (error) {
223+
console.error('An error happened:');
224+
console.error(error);
225+
process.exit(127);
226+
}
227+
}
228+
229+
main();

src/ng_examples_db/index.bzl

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
load("@aspect_rules_js//js:defs.bzl", "js_run_binary")
2+
3+
def ng_examples_db(name, srcs, path, out, data = []):
4+
js_run_binary(
5+
name = name,
6+
outs = [out],
7+
srcs = srcs + data,
8+
tool = ":bin",
9+
progress_message = "Generating code examples database from %s" % path,
10+
mnemonic = "NgExamplesDb",
11+
args = [path, "$(rootpath %s)" % out],
12+
)

0 commit comments

Comments
 (0)