DartでDNSクエリを投げてみる

ゴールデンウィーク中に書いているコードで、DNSクエリを一から書いてみるかということで雑に実装したコードを残しておきます。
ちゃんとした仕様通りの実装をしているわけではないので解説はしませんが、UDP通信したい人や別のものを参考にする場合には利用できるかもしれません。

実行する場合には dart main.dart zuki.dev A のように指定すると動作します。

import 'dart:convert';
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';

class RecordType {
  final int flag;
  final Function decode;

  RecordType._(this.flag, this.decode);

  static RecordType fromFlag(int flag) {
    return [
      A,
      AAAA,
      NS,
      TXT,
    ].firstWhere((type) => type.flag == flag);
  }

  static RecordType fromName(String name) {
    switch (name) {
      case 'A':
        return A;
      case 'AAAA':
        return AAAA;
      case 'NS':
        return NS;
      case 'TXT':
        return TXT;
      default:
        throw Exception('Unknown record type: ${name}');
    }
  }

  static final A = RecordType._(
    0x01,
    (ByteData data, int offset) {
      final octets = List.generate(4, (index) => data.getUint8(offset + index));
      final address = InternetAddress.fromRawAddress(Uint8List.fromList(octets));
      return address.address;
    },
  );
  static final AAAA = RecordType._(
    0x1c,
    (ByteData data, int offset) {
      final octets = List.generate(16, (index) => data.getUint8(offset + index));
      final address = InternetAddress.fromRawAddress(Uint8List.fromList(octets));
      return address.address;
    },
  );
  static final NS = RecordType._(
    0x02,
    (ByteData data, int offset) {
      final name = _decodeDomainName(data, offset);
      return name;
    },
  );
  static final TXT = RecordType._(
    0x10,
    (ByteData data, int offset) {
      final length = data.getUint8(offset);
      final text = utf8.decode(data.buffer.asUint8List(offset + 1, length));
      return text;
    },
  );

  static String _decodeDomainName(ByteData data, int offset) {
    final labels = <String>[];
    int current = offset;

    while (true) {
      if (current >= data.lengthInBytes) {
        break;
      }
      final length = data.getUint8(current);
      if (length == 0) {
        break;
      }

      if ((length & 0xc0) == 0xc0) {
        final pointer = ((length & 0x3f) << 8) + data.getUint8(current + 1);
        labels.addAll(_decodeDomainName(data, pointer).split('.'));
        current += 2;
        break;
      }

      final label = utf8.decode(data.buffer.asUint8List(current + 1, length));
      labels.add(label);
      current += length + 1;
    }

    return labels.join('.');
  }
}

enum ClassCode {
  IN(0x01),
  ;

  final int flag;

  const ClassCode(this.flag);
}

class DNS {
  List<String> nameServers = const ['1.1.1.1', '1.0.0.1'];
  int port = 53;

  DNS({
    this.nameServers = const ['1.1.1.1', '1.0.0.1'],
    port = 53,
  });

  Future<void> request(String name, RecordType type) async {
    final socket = await RawDatagramSocket.bind(InternetAddress.anyIPv4, 0);
    final query = _buildQuery(name, type, ClassCode.IN);

    try {
      socket.send(query, InternetAddress(nameServers[0]), port);
      print('Sent datagram: ${name} to ${nameServers[0]}:${port}');
      socket.listen((RawSocketEvent event) {
        if (event == RawSocketEvent.read) {
          final datagram = socket.receive();
          if (datagram != null) {
            final message = _parseResponse(datagram.data);
            print('Received message:\n${message}');
            socket.close();
          }
        }
      });
    } catch (e) {
      print('Error: $e');
      socket.close();
    }
  }

  Uint8List _buildQuery(String name, RecordType type, ClassCode classCode) {
    final List<int> query = [];

    query.addAll([
      0x12, 0x34, // Transaction ID
      0x01, 0x00, // Flags: Standard query
      0x00, 0x01, // Questions: 1
      0x00, 0x00, // Answer RRs: 0
      0x00, 0x00, // Authority RRs: 0
      0x00, 0x00, // Additional RRs: 0
    ]);

    final List<String> labels = name.split('.');
    for (final label in labels) {
      query.add(label.length);
      query.addAll(utf8.encode(label));
    }
    query.add(0x00); // Null terminator

    query.addAll([
      0x00, type.flag, // Record Type
      0x00, classCode.flag, // Class Code
    ]);

    return Uint8List.fromList(query);
  }

  String _parseResponse(Uint8List response) {
    final ByteData data = ByteData.view(response.buffer);
    int offset = 0;

    final transactionId = data.getUint16(offset);
    offset += 2;
    final flags = data.getUint16(offset);
    offset += 2;
    final questions = data.getUint16(offset);
    offset += 2;
    final answerRRs = data.getUint16(offset);
    offset += 2;
    final authorityRRs = data.getUint16(offset);
    offset += 2;
    final additionalRRs = data.getUint16(offset);
    offset += 2;

    for (int i = 0; i < questions; i++) {
      offset = _skipDomainName(data, offset);
      offset += 4; // Skip Type and Class
    }

    var records = <String>[];
    print('Answer RRs: ${answerRRs}');
    for (int i = 0; i < answerRRs; i++) {
      offset = _skipDomainName(data, offset);
      final type = data.getUint16(offset);
      offset += 2;
      final classCode = data.getUint16(offset);
      offset += 2;
      final ttl = data.getUint32(offset);
      offset += 4;
      final dataLength = data.getUint16(offset);
      offset += 2;

      RecordType? recordType;
      try {
        recordType = RecordType.fromFlag(type);
      } catch (e) {
        print('Error: $e');
        break;
      }

      if (recordType == null) {
        throw Exception('Unknown record type: ${type}');
      }
      final record = recordType.decode(data, offset);
      records.add(record);
      offset += dataLength;
    }

    return records.join('\n');
  }

  int _skipDomainName(ByteData data, int offset) {
    while(true) {
      final length = data.getUint8(offset);
      offset += 1;

      if (length == 0) {
        return offset;
      }
      if ((length & 0xc0) == 0xc0) {
        return offset + 1;
      }
      offset += length;
    }
  }
}

Future<void> main(List<String> args) async {
  final name = args[0];
  final type = RecordType.fromName(args[1]);
  final dns = DNS();
  await dns.request(name, type);
}

WEB+DB Press総集編のファイル名をいい感じにする

WEB+DB PRESSが休刊してからしばらく経ち、総集編が販売開始されました。

gihyo.jp

今回はこのDVDに含まれるファイルの名前をPowerShellでいい感じに変更する話になります。

前提

DVDには、webdb_vol01.pdfのような形式でファイルが保存されています。
これをGihyo Digital Publishingで個別購入した場合のファイル名に近い形式である WEB+DB PRESS Vol.1.pdf に変更します。

スクリプト

0埋めせずにVol.1.pdfのような形式にしたい場合は以下を参考にしてください。

$files = Get-ChildItem -Path . -Filter "webdb_vol*.pdf"

foreach ($file in $files) {
    $volNumber = $file.Name -replace "webdb_vol(\d+)\.pdf", '$1'
    $newFileName = "WEB+DB PRESS Vol.$volNumber.pdf"
    Rename-Item -Path $file.FullName -NewName $newFileName
}

ソートを意識して0埋めするVol.001.pdfのような名前にする場合のスクリプトは以下です。

$files = Get-ChildItem -Path . -Filter "webdb_vol*.pdf"

foreach ($file in $files) {
    $volNumber = $file.Name -replace "webdb_vol(\d+)\.pdf", '$1'
    $newFileName = "WEB+DB PRESS Vol.{0}.pdf" -f [int]$volNumber
    Rename-Item -Path $file.FullName -NewName $newFileName
}

これでファイル名がいい感じになりました。

法人設立した話

先日法人を登記してきたので、その大まかな流れについてお話ししたいと思います。
法人設立を検討している方にとって、参考になる情報があれば幸いです。

TL;DR

  • K@zuki. は、会社員個人事業主(茨谷企画)代表社員 の掛け持ち状態になっている
  • 個人事業の方が圧倒的に楽なので、そのことを踏まえて法人設立を検討することが大切
  • 登記するエリアは、手続き先の煩雑さや法人税率、補助金の観点からよく検討することをオススメ
  • 英語名表記は正当性を出すために、最初から定款に含めておくことが良さそう
  • Money Forward クラウド会社設立は便利

はじめに

K@zuki. は、会社員個人事業主(茨谷企画)代表社員 の掛け持ち状態になっているので、会社員としては現役で仕事をしていまし、個人事業主としても働いています。
個人事業主の一部事業を法人化したというイメージをしてもらいつつ、会社員業もしていると思っていればとOKです。

法人設立の理由

私が法人設立を決めた主な理由は二つあります。

  • 法人向けでしか商取引できない商品があったこと
  • 思いついたアイデアを個人とは別人格で試してみたかったこと

特に後者のアイデアを試す人格として法人を設立すること自体には意義がありました。
個人事業主でも良かったのではないか?というのはありますが、人生は短いですし何事も経験しておきたいなという意味合いでもありました。

経緯

さて法人設立する経緯なんですが、実は約2年間悩んでいました。
しかし、人生は思っているよりも短いと感じ、30歳になったことをきっかけに、「とりあえずやってみる」という精神で決断しました。

設立の流れ

雇用先との調整を含めず、準備を開始してから税務署への設立届を提出し終えるまで約3週間ほどかかりました。
前提として事業化したい内容についてはある程度決めている前提で話を進める点について留意してください。

1. 雇用先との調整

役員報酬が発生する場合は、社会保険料源泉徴収で調整が必要なケースがあるため、事前に相談しておく必要があります。
また、会社の労務規定上、他社の役員になることを許していないケースもあるので早めに目を通しておく必要があります。

2. 法人形態を決める

法人の形態(株式会社・合同会社)を決める必要があります。
法人の形態は、費用や資金調達の有無などの観点から選ぶと良いかと思います。

今回は合同会社で設立することにしました。

3. 会社名を決める

会社名は、日本語名・英語名両方検討しておいた方が良さそうです。
特にアプリ開発をする場合や、海外との取引をする場合には D-U-N-S® Number が必要になるケースもあるので、それを踏まえて日本語名・英語ともにできる限り被らない名前が良さそうです。

www.tsr-net.co.jp

また、英語名を考える時はドメイン名が取得できるかどうかも検討すると良さそうです。

4. 登記するエリアでバーチャルオフィスの契約

事務所がない場合の話になりますが、登記可能なバーチャルオフィスやレンタルオフィスを契約します。

この時、登記するエリアはしっかりと考えた方が良いです。 理由に関しては後からもでてきますが、補助金制度の充実度や、法人税率の違い、手続きする役所の多さに影響してきます。 自分が実際に事業をするエリアで検討することも重要ですが、今一度この辺に関してはチェックしておいた方が良さそうです。

5. Money Forward クラウド会社設立

ここからはMoney Forward クラウド会社設立を使って法人登記の準備を進めました。

biz.moneyforward.com

基本的にこれに従って印鑑の作成や定款の準備を進めて問題なさそうです。

6. 定款の作成

Money Forward クラウド会社設立のサービス内で、提携法人へ定款作成依頼をすることができます。
定款に関しては行政書士さんが詳しいかと思うので、どういった内容がいいのかは書きませんが、1点だけあると定款に含めると良さそうなものがあります。
それは「会社名の英語表記を定款に含める」です。
私は士業をやっているわけではないので正確なことは分かりませんが、公式文書に正式な英語名を記載しておく安心感は一定程度ありそうです。

7. 法人登記申請

クラウド会社設立で法務局へ提出するところまで進んだら、登記書類を印刷し押印します。
そして登記住所を管轄しているエリアの法務局に書類を提出しに行きます。
収入印紙を当日買うことになるので必要な金額を持っていけば、後は大体何とかなります。

登記には都道府県によって登記完了まで必要日数が変わるので、登記した際に登記完了予定日を教えてもらっておいてください。

8. 各種書類の準備

クラウド会社設立で進めると、年金事務所や税務署などへ提出する書類の準備が進められます。
登記申請が終わり次第、必要な書類を印刷して準備しておくことが望ましいです。
また各事務所は提出期限があるため、完了予定日次第では遅れることを確認しておいても良いかもしれません。

1点注意点なんですが、クラウド会社設立で表示される税務署などの提出先は正しい情報ではない場合があるので、
税務署や県税事務所・市町村役所のホームページで法人登記のやり方について確認してください。

9. 登記完了の確認

登記完了予定日に担当法務局へ電話するなどして登記が完了しているかを確認します。
不備がなければ終わっているはずですし、不備があれば事前に電話がかかってきます。

10. 印鑑カードの発行と各種証明書の発行

登記が完了していることを確認したら、法務局へ再度趣き、印鑑カードの発行と印鑑証明書・登記簿謄本の発行をします。
銀行口座の開設や、他の法人契約、税務署への法人設立届で何枚必要かを事前に把握しておきます。
こちらも当日収入印紙を購入するので必要な現金を持っていくようにします。

11. 法人設立届などの提出

8で準備した各種書類を年金事務所や、税務署、県税事務所、市町村役所など必要な役所に対して提出しに回ります。
郵送する方法もありますが、私は登記完了予定日が直前過ぎて休みをとって1日かけて巡りました。

非常に疲れました。
これで法人登記で必要な作業は大筋完了しました。

おわりに

以上が、私が経験した法人設立の大まかな流れを振り返りました。
法人設立には様々な手続きが必要ですが、適切な準備と心構えがあれば、スムーズに進められるはずです。
法人設立を検討している方の一助となれば幸いです。

別の記事で銀行口座開設や、D-U-N-S® Numberの取得について書きますね。

Docker Composeを使ってPalWorldのサーバを建てる

最近はPalWorldにどっぷりハマっています。
マルチプレイをする場合は、24時間起動し続けたいわけでなければ無理してサーバを建てるメリットはないんですが、 それでも建てたいかつ、比較的環境を汚したくない人向けの簡単な記事です。

ポート開放や正式な建て方に関しては公式や他のブログを参考にしてください。
あくまで弊宅ではKubernetesクラスタ上でPalWorldを動作させているため、 このやり方はデータのチェックや動作確認で利用していることが多いです。

TL;DR

  • PalWorldSettings.iniとGameUserSettings.iniを用意する
  • 記事のDockerfileとcompose.yamlを参考にして用意する

ディレクトリ構造

最終的なディレクトリ構造は以下のような形になります。

.
├── compose.yaml
├── Dockerfile
├── GameUserSettings.ini
├── PalWorldSettings.ini
└── SaveGames

【任意】SaveGamesを用意する

既にあるセーブデータがあり再利用したい場合は、どうにかしてSaveGamesに以下のような構造でデータを持ってきます。

SaveGames
└── 0
    └── A091215A35CD4039A7860DEE387F2838
        ├── LevelMeta.sav
        ├── Level.sav
        └── Players
            ├── abcedf00000000000.sav
            └── fdecba00000000000.sav

PalWorldSettings.iniを用意する

PalWorldSettings.iniはジェネレーターなどが有志で公開されているので、それを参考に用意してください。

補足

ジェネレーターや他のブログを見ればわかりますが、現状反映されない項目が多くあります。
ですが、誰かがホストとしてゲームを実行している場合はより多くの設定が反映されるため、 現時点ではサーバを用意するメリットは基本的に薄いケースがあります。

GameUserSettings.iniを用意する

こちらは他のやり方だとあまり用意するケースはないかと思いますが、 このファイルには起動時に利用するセーブデータの指定を行うことができます。

コンテナの再作成や既にあるサーバのデータを再利用したいケースのために用意しています。

[/Script/Pal.PalGameLocalSettings]
AudioSettings=(Master=0.500000,BGM=1.000000,SE=1.000000,PalVoice=1.000000,HumanVoice=1.000000,Ambient=1.000000,UI=1.000000)
GraphicsLevel=None
DefaultGraphicsLevel=None
bRunedBenchMark=False
bHasAppliedUserSetting=False
DedicatedServerName=A091215A35CD4039A7860DEE387F2838
AntiAliasingType=AAM_TSR
DLSSMode=Performance
GraphicsCommonQuality=0

A091215A35CD4039A7860DEE387F2838 に関しては文字列に書き換えます。
デフォルトでは16進数で命名されていますが、正直なんでも良いです。
既にあるセーブデータを流用する場合は、SaveGames/0配下のディレクトリ名に合わせて指定してください。

SaveGames
└── 0
    └── B091215A35CD4039A7860DEE387F2121

この場合A091215A35CD4039A7860DEE387F2838B091215A35CD4039A7860DEE387F2121に書き換えます。

Dockerfileを用意する

以下を参考にDockerfileを作成します。

FROM ubuntu:22.04

RUN apt update -qq \
    && apt install -y vim ca-certificates curl lib32gcc-s1 xdg-user-dirs \
    && apt clean \
    && rm -rf /var/lib/apt/lists/*

RUN useradd -m steam
USER steam

# FYI: https://developer.valvesoftware.com/wiki/SteamCMD#Linux
ENV STEAM_ROOT_PATH /home/steam/Steam
ENV STEAMCMD /home/steam/Steam/steamcmd.sh
RUN mkdir -p $STEAM_ROOT_PATH
WORKDIR $STEAM_ROOT_PATH
RUN curl -sqL "https://steamcdn-a.akamaihd.net/client/installer/steamcmd_linux.tar.gz" | tar zxvf - \
    && $STEAMCMD +quit
RUN mkdir -p $HOME/.steam/sdk64/ \
    && $STEAMCMD +login anonymous +app_update 1007 +quit \
    && cp $STEAM_ROOT_PATH/steamapps/common/Steamworks\ SDK\ Redist/linux64/steamclient.so $HOME/.steam/sdk64/

# # FYI: https://tech.palworldgame.com/dedicated-server-guide#linux
RUN $STEAMCMD +login anonymous +app_update 2394010 validate +quit
WORKDIR $STEAM_ROOT_PATH/steamapps/common/PalServer
RUN mkdir -p Pal/Saved/SaveGames
CMD ["./PalServer.sh"]

copmose.yamlを用意する

以下を参考にcompose.yamlを作成します。

services:
  palworld:
    build: .
    image: palworld
    restart: always
    ports:
      - 32111:8211/udp
    volumes:
      - ./PalWorldSettings.ini:/home/steam/Steam/steamapps/common/PalServer/Pal/Saved/Config/LinuxServer/PalWorldSettings.ini
      - ./GameUserSettings.ini:/home/steam/Steam/steamapps/common/PalServer/Pal/Saved/Config/LinuxServer/GameUserSettings.ini
      - ./SaveGames:/home/steam/Steam/steamapps/common/PalServer/Pal/Saved/SaveGames

ちょっと長いですが、PalWorldSettings.ini、GameUserSettings.ini、SaveGamesをコンテナにマウントしています。

コンテナを起動する

ここまでくれば後は起動は簡単です。

docker compose up -d

と実行し、しばらく待てば接続できるようになります。
停止させたい場合は、

docker compose stop

と入力するだけです。

おまけ

WSL 2で動かしてるけど接続できない

Docker Desktop for Windowsではなく、WSL 2に直接インストールしたDocker Engineの場合、 WSL 2がUDPのポートフォワードに対応していないためアクセスすることができません。

大人しくDocker Desktop for Windowsをインストールしておきましょう。

サーバのアップデートが必要

コンテナを停止した後にコンテナイメージを再作成すればOKです。

# 停止
docker compose stop

# イメージの再作成
docker compose build --no-cache

# 起動
docker compose up -d

Helm ChartでデプロイしているWordPressにads.txtを配置する

元々広告を設置しているWordPressのサイトがあったんですが、本格的にk8sクラスタへ移行するにあたってads.txtを配置する必要がでてきました。
今回はその備忘録になります。

TL;DR

  • BitnamiのWordPressのChartを使っている場合の話に限る
  • このイメージは /opt/bitnami/wordpressWordPressディレクト
  • ここに配置するためにextraVolumesextraVolumeMountsをvaluesとして定義すればOK

ads.txtを配置する

1. ads.txtを準備する

Google Adsenseを使っているので、Google Adsenseからads.txtの中身の情報を取得しておきます。

2. ConfigMap or Secretでads.txtを用意する

以下を参考にしながら、ads.txtの中身を用意します。
正直公開情報になるのでConfigMapで十分だとは思います。

apiVersion: v1
kind: ConfigMap
metadata:
  name: ads-txt-file
data:
  ads.txt: |
    # ここにads.txtの内容を記述します
    # 例:
    google.com, pub-0000000000000000, DIRECT, 123456789

後はこれがデプロイされるようにkustomization.yamlなどを変更しておきます。

3. values.yamlにextraVolumesとextraVolumeMountsを定義する

BitnamiのChartでは、extraVolumesextraVolumeMountsというキーが定義されており、これを定義することで先ほど書いたConfigMapをマウントしてくれるようになります。

extraVolumes:
  - name: ads-txt
    configMap:
      name: ads-txt-file

extraVolumeMounts:
  - name: ads-txt
    mountPath: /opt/bitnami/wordpress/ads.txt
    subPath: ads.txt

これで大まかな作業としては完了で、こちらもデプロイすれば反映自体は完了で、実際にアクセスしたWordPressのサイトでads.txtが確認できるかと思います。

個人開発したアプリをストア申請するまでの手順とハマりどころ

最近個人用にアプリを作っていたのですが、ついでにアプリストアに申請した時のハマりどころがあったのでメモとして残しておきます。

TL;DR

  • 基本的に https://zenn.dev/moutend/articles/feebf0120dce6e6426fa に従って進めるでOK
  • 5.5 inchのシミュレーターがApple Silicionだと動かせない
    • 別のシミュレーターで撮ったスクショをサイズ変形して申請して回避
  • 個人名でアプリが公開される

大まかな手順

色んな方が書いてくれていますが、以下の記事が比較的網羅的かつハマりどころは少なかった印象があります。

zenn.dev

ハマったところ

5.5 inchのスクショをどう作るか

アプリのスクショはシミュレーターを使って撮るのが一般的かと思いますが、 iPhone向けのスクショは、6.5 inchと5.5 inchが必須になっています。

ですが、Apple SiliconなMacでは5.5 inchのシミュレーターを動かすことができませんでした。
なので今回は、6.5 inchのスクショをサイズ変形して登録しました。

個人アカウントで開発者登録するとアカウントに登録している名前が公表される

今回は諦めましたが、個人アカウントで開発者登録するとアプリで表示される開発チーム名?が個人名になります。
気になる人は屋号付きで開業届を出して、DUNSナンバーの申請をして法人登録した方が良さそうです。

既に準備を始めているので、もしアプリが公開できたらすぐに移管していそうです。

サポートページ・プライバシーポリシーのページ作成が面倒

これは完全に俺個人の感想ですが、この手のページのコンテンツをそれなりに考えるのが面倒くさいです。
個人情報の収集や課金コンテンツなどはないので、法的にそこまでやらなくても問題ないかもしれませんが、なんとなくプレッシャーとして感じます。

LLMを使ってテンプレートを作ってもらいましたが、他の方々がテンプレートや事例を公開してくれているので、 それを参考にすると良いかもしれません。

GitHub PagesとGoogle Formでアプリ申請に必要なページを最小限の労力で作成する

最近個人用にアプリを作っていたのですが、ついでにアプリストアリリースする気持ちになったので、そこで必要になるページをGitHub Pages+Google Formで作成した話です。

執筆時点ではまだ申請中なので、申請が却下されたり許可が通ったらしたら追記します。 申請通ったので参考にしても問題はないはず。

TL; DR

  • アプリ申請では、サポートページとプライバシーポリシーについてのページが必要となる
  • サポートページとプライバシーポリシーはdocsディレクトリにマークダウンファイルとして保存する
  • そこからの問い合わせ先としてGoogle Formを用意する

bookil.zuki.dev

ストア申請に必要なページ

アプリ申請に必要なページは

  • サポートのページ
  • プライバシーポリシーのページ

の2つが必要になります。

以下の記事を参考にしながら進めさせてもらい、実際に必要なことも確認しました。

zenn.dev

今回は最小限の労力でいきたかったので、GitHub Pagesですぐに使えるJekyllと、問い合わせフォーム用にGoogle Formを用意して進めることにしました。

GitHub PagesとJekyll

GitHub PagesはGitHubで利用できる無料のWebページのホスティングサービスです。
このGitHub Pagesでは、GitHub Actionsなどを自分で書かなくてもJekyllを使ってサイトをすぐに公開することができるため、他の静的サイトジェネレータより手軽に始めることができます。

とりあえずはデザインにこだわる必要も現時点ではないため、雑に作るために採用しています。

docs.github.com

Markdownファイルを用意する

Jekyllでページを生成してもらうために、アプリを管理しているプライベートリポジトリにdocsというディレクトリを作成し、そこに以下のような構成でファイルを作成しました。

docs
├── _config.yml # Jekyllの設定ファイル 
├── how_to_use.md # 使い方ページ
├── index.md # ルートページ
├── privacy_policy.md # プライバシーポリシーページ
└── support.md # サポートページ

各ファイルの内容に関しては、実際のサイトを見てください。

bookil.zuki.dev

_config.ymlはJekyll用の設定ファイルになり、今回は以下のような最小限の内容に留めています。

title: Bookil
theme: minima

header_pages:
- how_to_use.md
- support.md
- privacy_policy.md

これをコミット&プッシュしておいて、ファイルの準備は完了です。

ちなみにファイルのポリシーに関しては、LLMを利用してテンプレートを生成してもらって、書き換えたりセクションを追加しています。

GitHub Pagesの設定をする

GitHub Pagesの設定を行うんですが、かなり楽でBuild and deploymentBranchの設定を行うだけになります。

  • Branch ... main
  • Direcotry ... /docs

これ以外の設定は不要で、すぐにビルドが実行されてGitHub Pagesが公開されます。
カスタムドメインを設定する場合は、公式の案内に従って設定してください。

これでほぼ完了ではあるんですが、問い合わせ先がないとサポートページとプライバシーポリシーのページは完成とはいえないのでフォームを作ります。

Google Formで問い合わせフォームを作成する

実際のフォームは以下のようになりますが、これをGoogle Formでサクッと作成します。

forms.gle

特筆する点としては、

  • メールアドレスを取得しない(Googleアカウント無しでも利用可)
  • Spreadsheetsとの連携を有効にして見返したり、集計しやすくする

という点ぐらいで、それ以外は何か細かい要件はないかと思います。

これで問い合わせフォームの作成が終わったら、サポートページやプライバシポリシーのページにリンクを貼り、コミット&プッシュをするとGitHub Pagesにすぐに反映して確認できるかと思います。

これで申請に必要なページを最小限の労力で作れたかと思います。