前回の振り返り
前回は、TOCParserクラスを実装し、見出しの配列からツリー構造を自動構築できるようになりました。現時点では、Markdown形式(インデント付きリスト)でのみ出力しています。
今回は、同じツリー構造からHTML形式やJSON形式でも出力できるようにします。
今回のゴール
ツリー構造を引数で指定されたフォーマット(markdown、html、json)で出力する機能を実装します。
設計アプローチ:renderメソッドの拡張
現在のSectionHeadingとLeafHeadingのrenderメソッドは、Markdown形式固定です。これをformat引数付きに拡張します。
SectionHeadingクラスの改良
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
| package SectionHeading;
use v5.36;
use Moo;
use JSON::PP;
with 'Heading';
has 'level' => (
is => 'ro',
required => 1,
);
has 'text' => (
is => 'ro',
required => 1,
);
has 'children' => (
is => 'ro',
default => sub { [] },
);
sub add_child ($self, $child) {
push $self->children->@*, $child;
return $self;
}
sub render ($self, $indent = 0, $format = 'markdown') {
if ($format eq 'markdown') {
return $self->_render_markdown($indent);
} elsif ($format eq 'html') {
return $self->_render_html($indent);
} elsif ($format eq 'json') {
return $self->_to_hash;
}
die "Unknown format: $format";
}
sub _render_markdown ($self, $indent) {
my $spaces = ' ' x $indent;
my @lines = ($spaces . '- ' . $self->text);
for my $child ($self->children->@*) {
push @lines, $child->render($indent + 1, 'markdown');
}
return join("\n", @lines);
}
sub _render_html ($self, $indent) {
my $spaces = ' ' x $indent;
my @lines;
push @lines, "$spaces<li>" . $self->text;
if ($self->children->@*) {
push @lines, "$spaces <ul>";
for my $child ($self->children->@*) {
push @lines, $child->render($indent + 2, 'html');
}
push @lines, "$spaces </ul>";
}
push @lines, "$spaces</li>";
return join("\n", @lines);
}
sub _to_hash ($self) {
return {
level => $self->level,
text => $self->text,
children => [map { $_->render(0, 'json') } $self->children->@*],
};
}
1;
|
コードの解説
フォーマット分岐
1
2
3
4
5
6
7
8
9
10
| sub render ($self, $indent = 0, $format = 'markdown') {
if ($format eq 'markdown') {
return $self->_render_markdown($indent);
} elsif ($format eq 'html') {
return $self->_render_html($indent);
} elsif ($format eq 'json') {
return $self->_to_hash;
}
die "Unknown format: $format";
}
|
format引数でどの形式で出力するかを切り替えます。
HTML形式
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| sub _render_html ($self, $indent) {
my $spaces = ' ' x $indent;
my @lines;
push @lines, "$spaces<li>" . $self->text;
if ($self->children->@*) {
push @lines, "$spaces <ul>";
for my $child ($self->children->@*) {
push @lines, $child->render($indent + 2, 'html');
}
push @lines, "$spaces </ul>";
}
push @lines, "$spaces</li>";
return join("\n", @lines);
}
|
子要素がある場合のみ<ul>を入れ子にします。再帰的にrenderを呼び出すことで、深い階層も正しく処理されます。
JSON形式(ハッシュに変換)
1
2
3
4
5
6
7
| sub _to_hash ($self) {
return {
level => $self->level,
text => $self->text,
children => [map { $_->render(0, 'json') } $self->children->@*],
};
}
|
ハッシュリファレンスを返します。最終的にJSON::PPでJSON文字列に変換します。
LeafHeadingクラスの改良
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
| package LeafHeading;
use v5.36;
use Moo;
with 'Heading';
has 'level' => (
is => 'ro',
required => 1,
);
has 'text' => (
is => 'ro',
required => 1,
);
sub render ($self, $indent = 0, $format = 'markdown') {
if ($format eq 'markdown') {
return $self->_render_markdown($indent);
} elsif ($format eq 'html') {
return $self->_render_html($indent);
} elsif ($format eq 'json') {
return $self->_to_hash;
}
die "Unknown format: $format";
}
sub _render_markdown ($self, $indent) {
my $spaces = ' ' x $indent;
return $spaces . '- ' . $self->text;
}
sub _render_html ($self, $indent) {
my $spaces = ' ' x $indent;
return "$spaces<li>" . $self->text . "</li>";
}
sub _to_hash ($self) {
return {
level => $self->level,
text => $self->text,
};
}
1;
|
LeafHeadingには子要素がないため、よりシンプルな実装になります。
TOCParserクラスの改良
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
| package TOCParser;
use v5.36;
use Moo;
use JSON::PP;
use SectionHeading;
use LeafHeading;
has 'headings' => (
is => 'ro',
required => 1,
);
has 'root' => (
is => 'lazy',
builder => '_build_root',
);
sub _build_root ($self) {
my $root = SectionHeading->new(
level => 0,
text => 'ROOT',
);
my @stack = ($root);
for my $h ($self->headings->@*) {
my $level = $h->{level};
my $text = $h->{text};
while (@stack > 1 && $stack[-1]->level >= $level) {
pop @stack;
}
my $parent = $stack[-1];
my $new_heading = SectionHeading->new(
level => $level,
text => $text,
);
$parent->add_child($new_heading);
push @stack, $new_heading;
}
return $root;
}
sub render ($self, $format = 'markdown') {
if ($format eq 'json') {
my @data = map { $_->render(0, 'json') } $self->root->children->@*;
return JSON::PP->new->pretty->encode(\@data);
}
my @lines;
if ($format eq 'html') {
push @lines, '<ul>';
}
for my $child ($self->root->children->@*) {
push @lines, $child->render($format eq 'html' ? 1 : 0, $format);
}
if ($format eq 'html') {
push @lines, '</ul>';
}
return join("\n", @lines);
}
1;
|
使用例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
| #!/usr/bin/env perl
use v5.36;
use lib '.';
use MarkdownReader;
use HeadingExtractor;
use TOCParser;
my $reader = MarkdownReader->new(
filepath => 'sample.md',
);
my $extractor = HeadingExtractor->new(
lines => $reader->lines,
);
my $parser = TOCParser->new(
headings => $extractor->headings,
);
say "=== Markdown形式 ===";
say $parser->render('markdown');
say "";
say "=== HTML形式 ===";
say $parser->render('html');
say "";
say "=== JSON形式 ===";
say $parser->render('json');
|
実行結果
Markdown形式:
1
2
3
4
5
| === Markdown形式 ===
- はじめに
- 第1章
- セクション1.1
- 第2章
|
HTML形式:
1
2
3
4
5
6
7
8
9
10
11
12
13
| === HTML形式 ===
<ul>
<li>はじめに
<ul>
<li>第1章
<ul>
<li>セクション1.1</li>
</ul>
</li>
<li>第2章</li>
</ul>
</li>
</ul>
|
JSON形式:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| === JSON形式 ===
[
{
"children" : [
{
"children" : [
{
"children" : [],
"level" : 3,
"text" : "セクション1.1"
}
],
"level" : 2,
"text" : "第1章"
},
{
"children" : [],
"level" : 2,
"text" : "第2章"
}
],
"level" : 1,
"text" : "はじめに"
}
]
|
セキュリティ上の注意
本シリーズで生成したHTML出力をWebページに埋め込む場合、XSS(クロスサイトスクリプティング)対策が必要です。
ユーザー入力を直接HTML内に埋め込む場合は、必ずHTMLエスケープ処理を行ってください。
1
2
| use HTML::Entities;
my $escaped = encode_entities($text);
|
関連:Strategyパターンとの違い
複数フォーマット出力は、Strategyパターンでも実現可能です:
違いは、Compositeはツリー構造の統一的扱いが主、Strategyはアルゴリズム切り替えが主です。
まとめ
今回は、同じツリー構造から複数フォーマット(Markdown、HTML、JSON)で出力する機能を実装しました。
学んだこと:
renderメソッドへのフォーマット引数追加- 再帰的なHTML生成(入れ子の
<ul><li>) JSON::PPによるPerlデータ構造のJSON変換- Compositeパターンの「統一インターフェース」の威力
次回は、見出しテキストからアンカーID(#introduction等)を自動生成し、リンク付きの目次を実現します。また、開放閉鎖原則(OCP)を実践し、シリーズを完成させます。