Skip to content

Commit d837dd6

Browse files
committed
feat: bibliography/bibcite構文を実装
- ((bibcite label)) インライン構文のパーサーを追加 - [[bibliography]]...[[/bibliography]] ブロック構文のパーサーを追加 - レンダラーで番号付き参照リンクとエントリ一覧を出力 - AST型定義にentries配列を追加
1 parent 32776eb commit d837dd6

File tree

12 files changed

+1118
-694
lines changed

12 files changed

+1118
-694
lines changed

packages/ast/src/element.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -391,7 +391,7 @@ export interface BibliographyCiteData {
391391
}
392392

393393
export interface BibliographyBlockData {
394-
index: number;
394+
entries: DefinitionListItem[];
395395
title: string | null;
396396
hide: boolean;
397397
}

packages/parser/src/parser/parse.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export class Parser {
3636
htmlBlocks: [],
3737
// State flags
3838
footnoteBlockParsed: false,
39+
bibcites: [],
3940
// Rules (injected to avoid circular dependency)
4041
blockRules,
4142
blockFallbackRule,
Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
/**
2+
* Bibliography block rule: [[bibliography]] ... [[/bibliography]]
3+
*
4+
* Contains definition list items that define bibliography entries.
5+
* Format inside:
6+
* : label : Citation text
7+
*
8+
* Works with ((bibcite label)) inline elements.
9+
*/
10+
import type { Element, DefinitionListItem } from "@wdprlib/ast";
11+
import type { BlockRule, ParseContext, RuleResult } from "../types";
12+
import { currentToken } from "../types";
13+
import { parseBlockName, parseAttributes } from "./utils";
14+
import { parseInlineUntil } from "../inline/utils";
15+
16+
interface BibliographyEntry {
17+
label: string;
18+
key: Element[];
19+
content: Element[];
20+
}
21+
22+
/**
23+
* Parse a single bibliography entry
24+
* Format: : label : content
25+
*/
26+
function parseBibliographyEntry(
27+
ctx: ParseContext,
28+
startPos: number,
29+
): { entry: BibliographyEntry; consumed: number } | null {
30+
let pos = startPos;
31+
let consumed = 0;
32+
33+
// Expect COLON at line start
34+
const colonToken = ctx.tokens[pos];
35+
if (!colonToken || colonToken.type !== "COLON" || !colonToken.lineStart) {
36+
return null;
37+
}
38+
pos++;
39+
consumed++;
40+
41+
// Wikidot requires whitespace after first colon
42+
const whitespaceAfterColon = ctx.tokens[pos];
43+
if (!whitespaceAfterColon || whitespaceAfterColon.type !== "WHITESPACE") {
44+
return null;
45+
}
46+
47+
// Skip whitespace after first colon
48+
while (ctx.tokens[pos]?.type === "WHITESPACE") {
49+
pos++;
50+
consumed++;
51+
}
52+
53+
// Collect label tokens until second COLON
54+
let label = "";
55+
let foundSecondColon = false;
56+
const keyNodes: Element[] = [];
57+
58+
while (pos < ctx.tokens.length) {
59+
const token = ctx.tokens[pos];
60+
if (!token || token.type === "NEWLINE" || token.type === "EOF") {
61+
break;
62+
}
63+
if (token.type === "COLON") {
64+
foundSecondColon = true;
65+
pos++;
66+
consumed++;
67+
break;
68+
}
69+
70+
// For bibliography, key is just the label (identifier)
71+
label += token.value;
72+
keyNodes.push({ element: "text", data: token.value });
73+
pos++;
74+
consumed++;
75+
}
76+
77+
if (!foundSecondColon) {
78+
return null;
79+
}
80+
81+
label = label.trim();
82+
83+
// Skip whitespace after second colon
84+
while (ctx.tokens[pos]?.type === "WHITESPACE") {
85+
pos++;
86+
consumed++;
87+
}
88+
89+
// Parse content (rest of line, can continue with line breaks)
90+
const contentNodes: Element[] = [];
91+
while (pos < ctx.tokens.length) {
92+
const token = ctx.tokens[pos];
93+
if (!token || token.type === "EOF") {
94+
break;
95+
}
96+
97+
// Check for [[/bibliography]]
98+
if (token.type === "BLOCK_END_OPEN") {
99+
const closeNameResult = parseBlockName(ctx, pos + 1);
100+
if (closeNameResult?.name === "bibliography") {
101+
break;
102+
}
103+
}
104+
105+
// Check for end of entry (newline followed by new entry or end)
106+
if (token.type === "NEWLINE") {
107+
const nextToken = ctx.tokens[pos + 1];
108+
// Look ahead for new entry or block end
109+
if (nextToken?.type === "COLON" && nextToken.lineStart) {
110+
// New entry starts
111+
pos++;
112+
consumed++;
113+
break;
114+
}
115+
if (nextToken?.type === "BLOCK_END_OPEN") {
116+
// Block end
117+
pos++;
118+
consumed++;
119+
break;
120+
}
121+
if (nextToken?.type === "NEWLINE" || !nextToken || nextToken.type === "EOF") {
122+
// Double newline or end
123+
pos++;
124+
consumed++;
125+
break;
126+
}
127+
// Single newline - continue (becomes line break)
128+
}
129+
130+
// Parse inline content
131+
const inlineCtx: ParseContext = { ...ctx, pos };
132+
const result = parseInlineUntil(inlineCtx, "NEWLINE");
133+
if (result.elements.length > 0) {
134+
contentNodes.push(...result.elements);
135+
pos += result.consumed;
136+
consumed += result.consumed;
137+
} else {
138+
pos++;
139+
consumed++;
140+
}
141+
}
142+
143+
// Trim key nodes
144+
while (keyNodes.length > 0) {
145+
const lastNode = keyNodes[keyNodes.length - 1];
146+
if (
147+
lastNode &&
148+
lastNode.element === "text" &&
149+
typeof lastNode.data === "string" &&
150+
lastNode.data.trim() === ""
151+
) {
152+
keyNodes.pop();
153+
} else {
154+
break;
155+
}
156+
}
157+
158+
return {
159+
entry: {
160+
label,
161+
key: keyNodes,
162+
content: contentNodes,
163+
},
164+
consumed,
165+
};
166+
}
167+
168+
export const bibliographyRule: BlockRule = {
169+
name: "bibliography",
170+
startTokens: ["BLOCK_OPEN"],
171+
requiresLineStart: false,
172+
173+
parse(ctx: ParseContext): RuleResult<Element> {
174+
const openToken = currentToken(ctx);
175+
if (openToken.type !== "BLOCK_OPEN") {
176+
return { success: false };
177+
}
178+
179+
let pos = ctx.pos + 1;
180+
let consumed = 1;
181+
182+
// Parse block name
183+
const nameResult = parseBlockName(ctx, pos);
184+
if (!nameResult || nameResult.name !== "bibliography") {
185+
return { success: false };
186+
}
187+
188+
pos += nameResult.consumed;
189+
consumed += nameResult.consumed;
190+
191+
// Parse optional attributes (title, hide)
192+
const attrResult = parseAttributes(ctx, pos);
193+
pos += attrResult.consumed;
194+
consumed += attrResult.consumed;
195+
196+
// Expect ]]
197+
if (ctx.tokens[pos]?.type !== "BLOCK_CLOSE") {
198+
return { success: false };
199+
}
200+
pos++;
201+
consumed++;
202+
203+
// Skip newline after opening tag
204+
if (ctx.tokens[pos]?.type === "NEWLINE") {
205+
pos++;
206+
consumed++;
207+
}
208+
209+
// Parse bibliography entries
210+
const entries: BibliographyEntry[] = [];
211+
212+
while (pos < ctx.tokens.length) {
213+
const token = ctx.tokens[pos];
214+
if (!token || token.type === "EOF") {
215+
break;
216+
}
217+
218+
// Check for [[/bibliography]]
219+
if (token.type === "BLOCK_END_OPEN") {
220+
const closeNameResult = parseBlockName(ctx, pos + 1);
221+
if (closeNameResult?.name === "bibliography") {
222+
// Consume [[/bibliography]]
223+
pos++;
224+
consumed++;
225+
pos += closeNameResult.consumed;
226+
consumed += closeNameResult.consumed;
227+
// Skip whitespace
228+
while (ctx.tokens[pos]?.type === "WHITESPACE") {
229+
pos++;
230+
consumed++;
231+
}
232+
// Expect ]]
233+
if (ctx.tokens[pos]?.type === "BLOCK_CLOSE") {
234+
pos++;
235+
consumed++;
236+
}
237+
break;
238+
}
239+
}
240+
241+
// Skip whitespace and newlines
242+
if (token.type === "WHITESPACE" || token.type === "NEWLINE") {
243+
pos++;
244+
consumed++;
245+
continue;
246+
}
247+
248+
// Parse entry
249+
if (token.type === "COLON" && token.lineStart) {
250+
const result = parseBibliographyEntry(ctx, pos);
251+
if (result) {
252+
entries.push(result.entry);
253+
pos += result.consumed;
254+
consumed += result.consumed;
255+
continue;
256+
}
257+
}
258+
259+
// Skip unknown tokens
260+
pos++;
261+
consumed++;
262+
}
263+
264+
// Convert to definition list format for AST storage
265+
const definitionItems: DefinitionListItem[] = entries.map((entry) => ({
266+
key_string: entry.label,
267+
key: entry.key,
268+
value: entry.content,
269+
}));
270+
271+
// Get attributes
272+
const title = attrResult.attrs.title ?? null;
273+
const hide = attrResult.attrs.hide === "true" || attrResult.attrs.hide === "";
274+
275+
return {
276+
success: true,
277+
elements: [
278+
{
279+
element: "bibliography-block",
280+
data: {
281+
entries: definitionItems,
282+
title: typeof title === "string" ? title : null,
283+
hide,
284+
},
285+
},
286+
],
287+
consumed,
288+
};
289+
},
290+
};

packages/parser/src/parser/rules/block/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { iframeRule } from "./iframe";
2727
import { iftagsRule } from "./iftags";
2828
import { tocRule } from "./toc";
2929
import { orphanLiRule } from "./orphan-li";
30+
import { bibliographyRule } from "./bibliography";
3031

3132
export { headingRule } from "./heading";
3233
export { horizontalRuleRule } from "./horizontal-rule";
@@ -56,6 +57,7 @@ export { iframeRule } from "./iframe";
5657
export { iftagsRule } from "./iftags";
5758
export { tocRule } from "./toc";
5859
export { orphanLiRule } from "./orphan-li";
60+
export { bibliographyRule } from "./bibliography";
5961

6062
/**
6163
* All block rules in priority order
@@ -88,6 +90,7 @@ export const blockRules: BlockRule[] = [
8890
embedBlockRule,
8991
iframeRule,
9092
iftagsRule,
93+
bibliographyRule,
9194
divRule,
9295
// paragraphRule is not included - used as fallback
9396
];

0 commit comments

Comments
 (0)