Featured image of post 第7回-HTML/JSON形式で目次を出力しよう - PerlとMooで学ぶComposite

第7回-HTML/JSON形式で目次を出力しよう - PerlとMooで学ぶComposite

PerlとMooでMarkdown目次生成器を作る第7回。同じツリー構造からMarkdown、HTML、JSON形式で出力。Compositeパターンの統一インターフェースの威力を体感します。

前回の振り返り

前回は、TOCParserクラスを実装し、見出しの配列からツリー構造を自動構築できるようになりました。現時点では、Markdown形式(インデント付きリスト)でのみ出力しています。

今回は、同じツリー構造からHTML形式やJSON形式でも出力できるようにします。

今回のゴール

ツリー構造を引数で指定されたフォーマット(markdown、html、json)で出力する機能を実装します。

設計アプローチ:renderメソッドの拡張

現在のSectionHeadingLeafHeadingrenderメソッドは、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)を実践し、シリーズを完成させます。

comments powered by Disqus
Hugo で構築されています。
テーマ StackJimmy によって設計されています。