Featured image of post 第2回-見出しやコードブロックも表現しよう - PerlとMooでドキュメント変換ツールを作ってみよう

第2回-見出しやコードブロックも表現しよう - PerlとMooでドキュメント変換ツールを作ってみよう

Heading、Paragraph、CodeBlockクラスを作成し、Markdownの様々な要素を適切に表現できるようにします。継承を使った要素クラスの分離を学びましょう。

@nqounetです。

前回は、Elementクラスを作成し、Markdownの段落をオブジェクトとして表現できるようにしました。今回は、見出しやコードブロックなど、様々な種類の要素を表現できるように拡張していきます。

このシリーズについて

このシリーズは「Mooで覚えるオブジェクト指向プログラミング」シリーズを読了した方を対象に、実践的なドキュメント変換ツールを作りながらオブジェクト指向設計を深く学ぶシリーズです。

今のコードの問題点

Elementを基底クラスとした継承階層の図解

前回のコードでは、全ての行が同じelementタイプとして扱われていました。

1
2
3
for my $elem (@elements) {
    say "タイプ: " . $elem->type;  # すべて "element"
}

でも、Markdownには見出し(# 見出し)やコードブロック(バッククォート3つ)など、様々な構造があります。これらを区別できないと、変換処理を書くときに困ります。

継承で要素クラスを分離する

Elementを基底クラスとして、各種要素を表すサブクラスを作りましょう。

Paragraphクラス(段落)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
package Paragraph;
# 言語: perl
# バージョン: 5.36以上
# 依存: Moo, Element

use Moo;
use experimental qw(signatures);
extends 'Element';

sub type ($self) {
    return 'paragraph';
}

1;

普通のテキスト行を表すクラスです。

Headingクラス(見出し)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
package Heading;
# 言語: perl
# バージョン: 5.36以上
# 依存: Moo, Element

use Moo;
use experimental qw(signatures);
extends 'Element';

has level => (
    is      => 'ro',
    default => 1,
);

sub type ($self) {
    return 'heading';
}

1;

見出しは「レベル」(h1, h2, h3など)を持つので、level属性を追加しています。

CodeBlockクラス(コードブロック)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
package CodeBlock;
# 言語: perl
# バージョン: 5.36以上
# 依存: Moo, Element

use Moo;
use experimental qw(signatures);
extends 'Element';

has language => (
    is      => 'ro',
    default => '',
);

sub type ($self) {
    return 'code_block';
}

1;

コードブロックは言語指定(perlやpythonなど)を持てるので、language属性を追加しています。

クラス階層の図解

作成したクラスの関係を図にすると、以下のようになります。

	classDiagram
    class Element {
        +content
        +type()
    }
    class Paragraph {
        +type()
    }
    class Heading {
        +level
        +type()
    }
    class CodeBlock {
        +language
        +type()
    }
    
    Element <|-- Paragraph : extends
    Element <|-- Heading : extends
    Element <|-- CodeBlock : extends

パーサーを更新する

要素クラスが増えたので、パーサーも適切なクラスを生成するように更新しましょう。

 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
package Parser;
# 言語: perl
# バージョン: 5.36以上
# 依存: Moo, Paragraph, Heading, CodeBlock

use Moo;
use experimental qw(signatures);
use Paragraph;
use Heading;
use CodeBlock;

sub parse ($self, $text) {
    my @elements;
    my @lines = split /\n/, $text;
    
    my $in_code_block = 0;
    my $code_content = '';
    my $code_lang = '';
    
    for my $line (@lines) {
        # コードブロックの開始/終了
        if ($line =~ /^```(\w*)/) {
            if ($in_code_block) {
                # コードブロック終了
                push @elements, CodeBlock->new(
                    content  => $code_content,
                    language => $code_lang,
                );
                $code_content = '';
                $code_lang = '';
                $in_code_block = 0;
            } else {
                # コードブロック開始
                $code_lang = $1 // '';
                $in_code_block = 1;
            }
            next;
        }
        
        # コードブロック内
        if ($in_code_block) {
            $code_content .= $line . "\n";
            next;
        }
        
        # 空行はスキップ
        next if $line =~ /^\s*$/;
        
        # 見出し
        if ($line =~ /^(#+)\s+(.+)/) {
            my $level = length($1);
            my $content = $2;
            push @elements, Heading->new(
                content => $content,
                level   => $level,
            );
            next;
        }
        
        # 段落
        push @elements, Paragraph->new(content => $line);
    }
    
    return @elements;
}

1;

見出し(#で始まる行)とコードブロック(バッククォート3つで囲まれた範囲)を検出して、適切なクラスのオブジェクトを作成しています。

注意: ここでは学習用に機能を絞っています。扱っているのは「行頭の#による見出し」と「```で囲まれたフェンスドコードブロック」だけです。実際のMarkdownではリスト、引用、複数行段落、インラインコードなどがあるため、必要に応じてルールを増やす前提で進めます。

動かしてみよう

様々な要素を含むMarkdownをパースしてみましょう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
#!/usr/bin/env perl
# 言語: perl
# バージョン: 5.36以上
# 依存: Parser

use v5.36;
use lib '.';
use Parser;

my $markdown = <<'MARKDOWN';
# ドキュメント変換ツール

これはサンプルの段落です

## 特徴

コードを含む例:

```perl
say "Hello, World!";

普通の文章も書けます。 MARKDOWN

my $parser = Parser->new(); my @elements = $parser->parse($markdown);

say “パース結果: " . scalar(@elements) . " 個の要素”; say “=” x 50;

for my $elem (@elements) { say “タイプ: " . $elem->type;

if ($elem->type eq 'heading') {
    say "レベル: " . $elem->level;
}
if ($elem->type eq 'code_block') {
    say "言語: " . ($elem->language || '(なし)');
}

say "内容: " . $elem->content;
say "-" x 50;

}

1
2

実行結果:

パース結果: 6 個の要素

タイプ: heading レベル: 1 内容: ドキュメント変換ツール

タイプ: paragraph 内容: これはサンプルの段落です。

タイプ: heading レベル: 2 内容: 特徴

タイプ: paragraph 内容: コードを含む例:

タイプ: code_block 言語: perl 内容: say “Hello, World!”;


タイプ: paragraph 内容: 普通の文章も書けます。

  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
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148

見出し、段落、コードブロックがそれぞれ適切なタイプとして認識されています。

## 今回のポイント

今回は以下のことを学びました。

- 継承(extends)を使って要素クラスを分離
- 各要素に固有の属性を追加(level, language
- パーサーで適切なクラスを生成するロジック

## 今回の完成コード

以下が今回作成したコードの完成版です。

```perl
#!/usr/bin/env perl
# 言語: perl
# バージョン: 5.36以上
# 依存: Moo

use v5.36;

# === Element(基底クラス) ===
package Element {
    use Moo;
    use experimental qw(signatures);

    has content => (
        is       => 'ro',
        required => 1,
    );

    sub type ($self) {
        return 'element';
    }
}

# === Paragraph ===
package Paragraph {
    use Moo;
    use experimental qw(signatures);
    extends 'Element';

    sub type ($self) {
        return 'paragraph';
    }
}

# === Heading ===
package Heading {
    use Moo;
    use experimental qw(signatures);
    extends 'Element';

    has level => (
        is      => 'ro',
        default => 1,
    );

    sub type ($self) {
        return 'heading';
    }
}

# === CodeBlock ===
package CodeBlock {
    use Moo;
    use experimental qw(signatures);
    extends 'Element';

    has language => (
        is      => 'ro',
        default => '',
    );

    sub type ($self) {
        return 'code_block';
    }
}

# === Parser ===
package Parser {
    use Moo;
    use experimental qw(signatures);

    sub parse ($self, $text) {
        my @elements;
        my @lines = split /\n/, $text;
        
        my $in_code_block = 0;
        my $code_content = '';
        my $code_lang = '';
        
        for my $line (@lines) {
            if ($line =~ /^```(\w*)/) {
                if ($in_code_block) {
                    push @elements, CodeBlock->new(
                        content  => $code_content,
                        language => $code_lang,
                    );
                    $code_content = '';
                    $code_lang = '';
                    $in_code_block = 0;
                } else {
                    $code_lang = $1 // '';
                    $in_code_block = 1;
                }
                next;
            }
            
            if ($in_code_block) {
                $code_content .= $line . "\n";
                next;
            }
            
            next if $line =~ /^\s*$/;
            
            if ($line =~ /^(#+)\s+(.+)/) {
                my $level = length($1);
                my $content = $2;
                push @elements, Heading->new(
                    content => $content,
                    level   => $level,
                );
                next;
            }
            
            push @elements, Paragraph->new(content => $line);
        }
        
        return @elements;
    }
}

# === メイン処理 ===
package main {
    my $markdown = <<'MARKDOWN';
# ドキュメント変換ツール

これはサンプルの段落です。

## 特徴

コードを含む例:

```perl
say "Hello, World!";

普通の文章も書けます。 MARKDOWN

my $parser = Parser->new();
my @elements = $parser->parse($markdown);

say "パース結果: " . scalar(@elements) . " 個の要素";
say "=" x 50;

for my $elem (@elements) {
    say "タイプ: " . $elem->type;
    
    if ($elem->type eq 'heading') {
        say "レベル: " . $elem->level;
    }
    if ($elem->type eq 'code_block') {
        say "言語: " . ($elem->language || '(なし)');
    }
    
    say "内容: " . $elem->content;
    say "-" x 50;
}

}

1
2
3
4
5
6

## 次回予告

次回は、パースした要素をHTML形式に変換する機能を追加します。でも、要素タイプごとに変換処理を分岐させると、あるパターンが見えてきます...if/elseの地獄へようこそ!

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