💯

Flutter Webでブログを作ってSEOスコア100点を取ってみた

2022/12/11に公開

Flutter Advent Calendar 2022 の11日目の記事です。

https://qiita.com/advent-calendar/2022/flutter

概要

みなさん、Flutter Webはどれくらい活用していますか?

先日開催された『Flutter Kaigi 2022』でもFlutter Webを活用した実例のセッションがありました。
まだVueやReactに取って代わることはないですが、Flutter WebでSPAなWebアプリを開発するという選択も視野に入ってきました。

https://flutterkaigi.jp/2022/

しかしながら、Flutter WebはSSG未対応なため、NuxtやNextと異なりFlutter Webでメディア制作をするのには適していません。
そこで、今回はFlutter Webを使ってSPAなブログを制作したうえで、SEOスコア100点を取るためにやったことのうち、Flutter関連のものを洗い出してみました。

今回作ったサイトと PageSpeedInsights のスコアを貼っておきます。


TOPページ

https://column.hhg-exe.jp/


記事詳細

https://column.hhg-exe.jp/articles/c-s6tLx9YOhgJFPcMGwdiA

https://column.hhg-exe.jp/articles/c-YFCgZR4H8kBNn7S1bGM2

PageSpeedInsightsのスコア

使ったツール

ブログ制作に使ったツールを紹介します。
厳密にはpackage等もありますが、それは個別の機能説明の際に紹介します。

  • Flutter Web
  • Spearly CMS
  • Firebase(Hosting, Functions)

Flutter Web

今回の主役のFlutter Webです。
Flutterのweb開発機能は正式版として提供されているので、プロジェクトを作成するだけでweb開発機能も利用できます。

既存プロジェクトでweb開発機能を有効にするには各々で設定が必要です。

https://docs.flutter.dev/get-started/web

Spearly CMS

毎度紹介しているヘッドレスCMSです。
今回Spearlyを利用した理由は以下の2つの理由です。

  1. api実装をせずに、埋込タグを設置するだけでブログ一覧・ブログ詳細ページが作成できる
  2. アップロードした画像をwebpに変換できる

https://cms.spearly.com/

https://developers.google.com/speed/webp

Firebase(Hosting, Functions)

こちらも説明は不要なほど有名なGoogleのmBaaSです。
今回はサイトのホスティングとSEO対策のサイトマップ生成に利用しました。

https://firebase.google.com/

https://firebase.google.com/docs/hosting

https://firebase.google.com/docs/functions

実装(対応)した機能

ブログとしての必須機能やSEO対策に必要な実装として以下の機能を実装しました。

  • 記事一覧
  • 記事詳細
  • ルーティング
  • バージョン管理
  • SNSシェア
  • サイトマップ
  • RSSフィード
  • OGP

この機能のうち、サイトマップ以降はFlutter関係なく必要な実装なので今回は説明を割愛します。
実際やっていることはFunctionsからSpearly CMSのapiを叩いて、HTMLやXMLを生成しているだけなので

実装内容

リポジトリが整理できていないので、フォルダ構成だけ記載します。
基本的には、flutter create で生成されたファイルと firebase init で生成したHosting, Functionsのファイルがあるのみです。
しかし、いくつか注意すべき実装があったので、一部抜粋して紹介します。

├── assets
│   ├── scss
│   └── view
├── firebase.json
├── functions
│   ├── index.ts
│   ├── package.json
│   ├── src
│   ├── tsconfig.dev.json
│   └── tsconfig.json
├── lib
│   ├── main.dart
│   └── provider
├── package.json
├── pubspec.yaml
└── web
    ├── favicon.png
    ├── icons
    ├── index.html
    ├── manifest.json
    ├── ogp.jpg
    └── robots.txt

記事一覧・記事詳細

ブログの各ページの実装については、Spearly CMSの埋め込みタグを HtmlElementView で読み込んで実装しています。
HtmlElementView 生成については以下の記事で細かく記載していますが、要するに 「ブログ用のiframe」を設置し、その中に記事情報を描画する ようにしました。

https://zenn.dev/qst/articles/e97b005c536cccc69006

なぜなら、FlutterはWidgetでUIを作成しており、ヘッドレスCMSのapiが返すHTMLに対応するWidgetを描画するのが難しいと考えたためです。
(いずれはHTMLに呼応するWidgetを生成するようにしたい…)

しかし、これだとiframe内の更新があっても親のURLが変わらないため、実際のURLとiframe内のコンテンツのズレが生じてしまいます。
そのため、WebAPIの window.postMessage を使って、一覧のリンクがクリックされたことを検知してFlutter内の状態を更新する方針で実装しました。

https://developer.mozilla.org/ja/docs/Web/API/Window/postMessage

実際のコードとともに簡単に役割をまとめると以下のとおりです。

main.dart

  • HtmlElementView で記事描画用のiframeを生成する
  • 現在のルーティングを元にiframeへ渡すHTMLファイルを更新する
  • postMessage で受け取ったidの記事詳細ページに遷移する

index.html

  • 記事一覧を生成する
  • リンクのクリックを検知して、クリックした記事のidを postMessage でFlutterに伝える

item.html

  • 記事詳細を生成する
main.dart
class HomePage extends StatelessWidget {
  HomePage({Key? key}) : super(key: key);
  final viewId = 'column';

  
  Widget build(BuildContext context) {
    final screenWidth = MediaQuery.of(context).size.width;
    final screenHeight = MediaQuery.of(context).size.height;

    final IdProvider idProvider =
        Provider.of<IdProvider>(context, listen: true);
    final VersionProvider versionProvider =
        Provider.of<VersionProvider>(context, listen: true);

    // ignore: undefined_prefixed_name
    ui.platformViewRegistry.registerViewFactory(
        viewId,
        (int id) => html.IFrameElement()
          ..width = MediaQuery.of(context).size.width.toString()
          ..height = MediaQuery.of(context).size.height.toString()
          ..src = _fileName(idProvider.id, versionProvider.version)
          ..style.border = 'none');

    window.addEventListener("message", (event) {
      final data = (event as MessageEvent).data ?? '-';
      final contentId = data.substring(22);
      if (contentId.contains('c-') && contentId != idProvider.id) {
        idProvider.updateId(contentId);
        context.push('/column/$contentId');
      }
    });

    return MaterialApp(
      title: 'メタバースで遊ぶ鹿児島のアプリエンジニア',
      home: Scaffold(
          appBar: AppBar(...),
          body: Column(
            children: [
              SizedBox(
                width: screenWidth,
                height: screenHeight - 106,
                child: HtmlElementView(
                  viewType: viewId,
                ),
              ),
              _footer(context)
            ],
          ),
          floatingActionButton: _floatingActionButton(context)),
    );
  }
 }
index.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
    <link href="https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@400;500;700&display=swap" rel="stylesheet">
    <link rel="stylesheet" href="./css/style.css">
    <title>メタバースで遊ぶ鹿児島のアプリエンジニア</title>
    <script src="https://static.spearly.com/js/cms.js" defer></script>
    <script>window.addEventListener('DOMContentLoaded',()=>{const t=document.querySelectorAll(':not(:defined)');for(const e of t) {e.style.visibility="hidden";}; window.spearly.config.AUTH_KEY="{Spearly Docを参照}"},{once:true})</script>
</head>
<body>
<ul>
    <li cms-loop cms-content-type="column" cms-option-order_direction>
        <a href="#" onclick="sendData('{%= column_#url %}')" class="column-list">
            <div class="column-list__cell">
                <img alt="{%= column_title %}" class="column-list__thumbnail" decoding="async" loading="lazy"
                     src="{%= column_image %}"/>
                <h3 class="column-list__title">{%= column_title %}</h3>
            </div>
            <div class="column-list__meta">
                <ul class="column-list__tag">
                    <li cms-loop cms-field="tag">
                        {%= column_tag_title %}
                    </li>
                </ul>
                {%= column_created_at %}
            </div>
        </a>
    </li>
</ul>
<script>
  const sendData = (url) => {
    window.parent.postMessage(url, "*");
  }
</script>
<script src="./js/main.js" defer></script>
</body>
</html>
item.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
    <link href="https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@400;500;700&display=swap" rel="stylesheet">
    <link href="https://use.fontawesome.com/releases/v5.15.4/css/all.css" rel="stylesheet">
    <link rel="stylesheet" href="./css/style.css">
    <title>メタバースで遊ぶ鹿児島のアプリエンジニア</title>
    <script src="https://static.spearly.com/js/cms.js" defer></script>
    <script>window.addEventListener('DOMContentLoaded',()=>{const t=document.querySelectorAll(':not(:defined)');for(const e of t) {e.style.visibility="hidden";}; window.spearly.config.AUTH_KEY="{Spearly Docを参照}"},{once:true})</script>
</head>
<body>
<main>
    <article cms-item cms-content-type="column" cms-content="" class="column">
        <h1 class="column__title">{%= column_title %}</h1>
        <ul class="column__tag">
            <li cms-loop cms-field="tag">
                {%= column_tag_title %}
            </li>
        </ul>
        <div class="column__meta">
            <span>投稿日: {%= column_created_at %}</span>
            <span>更新日: {%= column_updated_at %}</span>
        </div>
        <img alt="{%= column_title %}" class="column__hero" decoding="async" loading="lazy"
             src="{%= column_image %}"/>
        <div class="column__body">
            {%= column_description %}
        </div>
    </article>
</main>
<script src="./js/main.js" defer></script>
</body>
</html>

https://note.com/monan0417/n/nd761b134bad2

https://qiita.com/Yuta_spade/items/5f7067f6933e74061ff4

https://news.spearly.com/release/c-XATOFUazRrtdeQBh2oul

ルーティング

ルーティング管理用のpackageとして go_router を利用しました。

https://pub.dev/packages/go_router

ルーティング用に /column/{id}id をProviderで管理しています。

provider/id_provider.dart
import 'package:flutter/material.dart';

class IdProvider with ChangeNotifier {
  String? id;

  IdProvider(String? value) {
    updateId(value);
  }

  void updateId(String? value) {
    id = value;
    notifyListeners();
  }
}

idが変わっても描画するWidgetは変わりません。
同じWidget内の _fileName 関数で記事一覧か記事詳細(詳細の場合にはそのid)を判定して、iframeに渡すHTMLを決定するようにしています。

main.dart
final GoRouter _router = GoRouter(
  routes: <GoRoute>[
    GoRoute(
      path: '/',
      builder: (BuildContext context, GoRouterState state) {
        print(state);
        return HomePage();
      },
    ),
    GoRoute(
      path: '/column/:id',
      builder: (BuildContext context, GoRouterState state) {
        final id = state.params['id'];
        final IdProvider idProvider =
            Provider.of<IdProvider>(context, listen: true);
        idProvider.updateId(id);

        return HomePage();
      },
    ),
    GoRoute(
        path: '/co1umn/:id',
        redirect: (GoRouterState state) {
          final id = state.params['id'];
          if (id != null) {
            return '/column/$id';
          }
          return "/";
        }),
  ],
);

class Sample extends StatelessWidget {
  String _fileName(String? id, String version) {
    const isRelease = bool.fromEnvironment('dart.vm.product');
    if (isRelease) {
      return 'assets/assets/view/${_baseFile(id, version)}';
    } else {
      return 'assets/view/${_baseFile(id, version)}';
    }
  }

  String _baseFile(String? id, String version) {
    if (id != null) {
      return 'item.html?contentId=$id&t=$version';
    } else {
      return 'index.html?t=$version';
    }
  }
}

バージョン管理

PageSpeedInsights のアドバイスもあり、HTMLやCSS,JS等のassetsはキャッシュ期間を長めに設定しています。
しかしキャッシュ期間が長いと、ちょっとしたデザイン変更でCSSを更新してもキャッシュが効いているために反映されない問題が起こりえます。

そこで、ファイル末尾に ?v=xxx というクエリを加える常套手段をとることにしました。
クエリを加えることでキャッシュを効かせつつ、assetsの更新は即座に反映できます。
URLのパラメータにはFlutterのpackageVersionを使うことでリリースのたびに自然にキャッシュ更新をできる想定です。

Future<void> main() async {
  setUrlStrategy(PathUrlStrategy());
  String? id = Uri.base.queryParameters["id"];
  String? token = Uri.base.queryParameters["token"];
  PackageInfo packageInfo = await PackageInfo.fromPlatform();

  runApp(MyApp(id: id, token: token, version: packageInfo.version));
}

バージョン情報については、idと同じようにProviderで管理しています。

provider/version_provider.dart
import 'package:flutter/material.dart';

class VersionProvider with ChangeNotifier {
  String version = '0';

  VersionProvider(String value) {
    updateVersion(value);
  }

  void updateVersion(String value) {
    version = value;
    notifyListeners();
  }
}

これでHTMLについては **.html?t=xxx というURLパラメータが付加できましたが、CSSは更新できていません。
CSSはassets内にあり、Flutterからは干渉できないのでjsでHTMLのパラメータをCSSにも付加するようにしました。

main.js
const getParam = (name, url)  => {
  if (!url) url = window.location.href;
  name = name.replace(/[\[\]]/g, "\\$&");
  const regex = new RegExp("[?&]" + name + "(=([^&#]*)|&|#|$)");
  const results = regex.exec(url);
  if (!results) return null;
  if (!results[2]) return '';

  return decodeURIComponent(results[2].replace(/\+/g, " "));
}

const stylesheets = document.querySelectorAll('link[rel="stylesheet"]');
stylesheets.forEach(function(value) {
  const version = getParam('t') ?? '0';
  value.href = value.href.replace("style.css", `style.css?v=${version}`);
});

ここまで実装することで、pubspec.yamlversion を更新するだけで、HTMLやCSS, JSのassetsが更新可能になりました。
(厳密には上で示した main.js は更新できないので、js更新の際には新規のjsファイルを追加する必要があります。)

また、キャッシュの管理についてはFirebaseの設定になるのでここでは割愛します。

https://firebase.google.com/docs/hosting/manage-cache

SNSシェア

ブログによくあるSNSシェアのボタンも設置しました。

こちらは share_plus のpackageを導入してその機能を呼び出しただけなのですが、PCでシェア機能を呼び出すとメーラーが起動してしまう問題が発生しました。

https://pub.dev/packages/share_plus

そのため、下記のようにスマホとPC版で表示するボタンを変えるレスポンシブ対応を実装することで、メーラーが起動する問題を解消しました。
(もし良い方法を知っている人がいたらぜひ教えて下さい!)

main.dart
class Sample extends StatelessWidget {
  Widget _floatingActionButton(BuildContext context) {
    if (isSp(context)) {
      return _shareButton(context);
    } else {
      return _twitterButton(context);
    }
  }

  Widget _shareButton(BuildContext context) {
    return FloatingActionButton(
      backgroundColor: Colors.lime,
      child: FaIcon(
        FontAwesomeIcons.shareNodes,
        color: Colors.white,
        semanticLabel: 'share',
      ),
      onPressed: () async {
        await Share.share("メタバースで遊ぶ鹿児島のアプリエンジニア ${window.location.href}");
      },
    );
  }

  Widget _twitterButton(BuildContext context) {
    return FloatingActionButton(
      backgroundColor: Colors.blue,
      child: FaIcon(
        FontAwesomeIcons.twitter,
        color: Colors.white,
        semanticLabel: 'share',
      ),
      onPressed: () async {
        final Map<String, dynamic> tweetQuery = {
          "text": "メタバースで遊ぶ鹿児島のアプリエンジニア",
          "url": window.location.href,
        };

        final Uri tweetScheme =
        Uri(scheme: "twitter", host: "post", queryParameters: tweetQuery);

        final Uri tweetIntentUrl =
        Uri.https("twitter.com", "/intent/tweet", tweetQuery);

        await canLaunchUrl(Uri.parse(tweetScheme.toString()))
            ? await _launchURL(tweetScheme.toString())
            : await _launchURL(tweetIntentUrl.toString());
      },
    );
  }
}

スマホでみたSNSシェア

PCでみたSNSシェア

その他

この記事には記載していないTipsがありますが、それは以下の記事の内容を無思考に実装してるものです。

https://zenn.dev/qst/articles/c11c37a3531ce5476de7

実際のコード

それらのTipsも含めた実際にブログを動かしているコードについてもここに貼り付けます。
個人用途なのでちゃんと切り分けられていない…

main.dart
// ignore: avoid_web_libraries_in_flutter
import 'dart:html' as html;
import 'dart:html';
import 'dart:ui' as ui;

import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_web_plugins/flutter_web_plugins.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:go_router/go_router.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart';
import 'package:url_launcher/url_launcher.dart';

import 'provider/id_provider.dart';
import 'provider/version_provider.dart';

Future<void> main() async {
  setUrlStrategy(PathUrlStrategy());
  String? id = Uri.base.queryParameters["id"];
  String? token = Uri.base.queryParameters["token"];
  PackageInfo packageInfo = await PackageInfo.fromPlatform();

  runApp(MyApp(id: id, token: token, version: packageInfo.version));
}

final GoRouter _router = GoRouter(
  routes: <GoRoute>[
    GoRoute(
      path: '/',
      builder: (BuildContext context, GoRouterState state) {
        print(state);
        return HomePage();
      },
    ),
    GoRoute(
      path: '/column/:id',
      builder: (BuildContext context, GoRouterState state) {
        final id = state.params['id'];
        final IdProvider idProvider =
            Provider.of<IdProvider>(context, listen: true);
        idProvider.updateId(id);

        return HomePage();
      },
    ),
    GoRoute(
        path: '/co1umn/:id',
        redirect: (GoRouterState state) {
          final id = state.params['id'];
          if (id != null) {
            return '/column/$id';
          }
          return "/";
        }),
  ],
);

// ignore: must_be_immutable
class MyApp extends StatelessWidget {
  String? token, id;
  String version;
  MyApp({Key? key, required this.version, this.id, this.token})
      : super(key: key);

  
  Widget build(BuildContext context) {
    return MultiProvider(
      providers: [
        ChangeNotifierProvider<IdProvider>(
          create: (context) => IdProvider(id),
        ),
        ChangeNotifierProvider<VersionProvider>(
          create: (context) => VersionProvider(version),
        ),
      ],
      child: MaterialApp.router(
        routeInformationProvider: _router.routeInformationProvider,
        routeInformationParser: _router.routeInformationParser,
        routerDelegate: _router.routerDelegate,
        title: 'メタバースで遊ぶ鹿児島のアプリエンジニア',
        theme: ThemeData(
          primarySwatch: Colors.lightGreen,
        ),
        localizationsDelegates: const [
          GlobalMaterialLocalizations.delegate,
          GlobalWidgetsLocalizations.delegate,
          GlobalCupertinoLocalizations.delegate,
        ],
        supportedLocales: const [
          Locale('ja', 'JP'),
        ],
        locale: const Locale('ja', 'JP'),
      ),
    );
  }
}

class HomePage extends StatelessWidget {
  HomePage({Key? key}) : super(key: key);
  final viewId = 'column';

  
  Widget build(BuildContext context) {
    final screenWidth = MediaQuery.of(context).size.width;
    final screenHeight = MediaQuery.of(context).size.height;

    final IdProvider idProvider =
        Provider.of<IdProvider>(context, listen: true);
    final VersionProvider versionProvider =
        Provider.of<VersionProvider>(context, listen: true);

    // ignore: undefined_prefixed_name
    ui.platformViewRegistry.registerViewFactory(
        viewId,
        (int id) => html.IFrameElement()
          ..width = MediaQuery.of(context).size.width.toString()
          ..height = MediaQuery.of(context).size.height.toString()
          ..src = _fileName(idProvider.id, versionProvider.version)
          ..style.border = 'none');

    window.addEventListener("message", (event) {
      final data = (event as MessageEvent).data ?? '-';
      final contentId = data.substring(22);
      if (contentId.contains('c-') && contentId != idProvider.id) {
        idProvider.updateId(contentId);
        context.push('/column/$contentId');
      }
    });

    return MaterialApp(
      title: 'メタバースで遊ぶ鹿児島のアプリエンジニア',
      home: Scaffold(
          appBar: AppBar(
            backgroundColor: Colors.lime,
            centerTitle: true,
            title: TextButton(
                onPressed: () {
                  // TODO: パフォーマンスが落ちるので消す
                  window.location.href = '/';
                },
                child: Text(
                  'メタバースで遊ぶ鹿児島のアプリエンジニア',
                  style: TextStyle(
                      color: Colors.white, fontSize: isSp(context) ? 16 : 18),
                  textAlign: TextAlign.center,
                )),
            actions: [
              isSp(context)
                  ? Container()
                  : IconButton(
                      icon: const FaIcon(
                        FontAwesomeIcons.user,
                        color: Colors.white,
                        semanticLabel: 'Spatial',
                      ),
                      onPressed: () {
                        _launchURL(
                            'https://www.spatial.io/s/qst-exe-room-636f64a8a6af41000126501f?share=70500357164265573');
                      }),
              IconButton(
                  icon: const FaIcon(
                    FontAwesomeIcons.blog,
                    color: Colors.white,
                    semanticLabel: 'blog',
                  ),
                  onPressed: () {
                    _launchURL('https://blog.hhg-exe.jp/');
                  }),
              IconButton(
                  icon: const FaIcon(
                    FontAwesomeIcons.twitter,
                    color: Colors.white,
                    semanticLabel: 'Twitter',
                  ),
                  onPressed: () {
                    _launchURL('https://twitter.com/qst_exe');
                  }),
              isSp(context)
                  ? Container()
                  : IconButton(
                      icon: const FaIcon(
                        FontAwesomeIcons.rss,
                        color: Colors.white,
                        semanticLabel: 'RSS',
                      ),
                      onPressed: () {
                        _launchURL('https://column.hhg-exe.jp/feed.xml');
                      }),
            ],
          ),
          body: Column(
            children: [
              SizedBox(
                width: screenWidth,
                height: screenHeight - 106,
                child: HtmlElementView(
                  viewType: viewId,
                ),
              ),
              _footer(context)
            ],
          ),
          floatingActionButton: _floatingActionButton(context)),
    );
  }

  Widget _floatingActionButton(BuildContext context) {
    if (isSp(context)) {
      return _shareButton(context);
    } else {
      return _twitterButton(context);
    }
  }

  Widget _shareButton(BuildContext context) {
    return FloatingActionButton(
      backgroundColor: Colors.lime,
      child: FaIcon(
        FontAwesomeIcons.shareNodes,
        color: Colors.white,
        semanticLabel: 'share',
      ),
      onPressed: () async {
        await Share.share("メタバースで遊ぶ鹿児島のアプリエンジニア ${window.location.href}");
      },
    );
  }

  Widget _twitterButton(BuildContext context) {
    return FloatingActionButton(
      backgroundColor: Colors.blue,
      child: FaIcon(
        FontAwesomeIcons.twitter,
        color: Colors.white,
        semanticLabel: 'share',
      ),
      onPressed: () async {
        final Map<String, dynamic> tweetQuery = {
          "text": "メタバースで遊ぶ鹿児島のアプリエンジニア",
          "url": window.location.href,
        };

        final Uri tweetScheme =
            Uri(scheme: "twitter", host: "post", queryParameters: tweetQuery);

        final Uri tweetIntentUrl =
            Uri.https("twitter.com", "/intent/tweet", tweetQuery);

        await canLaunchUrl(Uri.parse(tweetScheme.toString()))
            ? await _launchURL(tweetScheme.toString())
            : await _launchURL(tweetIntentUrl.toString());
      },
    );
  }

  Widget _footer(BuildContext context) {
    final screenWidth = MediaQuery.of(context).size.width;

    return Container(
      color: Colors.blueGrey[100],
      width: screenWidth,
      height: 50,
      child: const Center(
        child: Text(
          '© 2022 メタバースで遊ぶ鹿児島のアプリエンジニア',
          style: TextStyle(color: Colors.black),
        ),
      ),
    );
  }

  bool isSp(BuildContext context) {
    return MediaQuery.of(context).size.width < 765;
  }

  String _fileName(String? id, String version) {
    const isRelease = bool.fromEnvironment('dart.vm.product');
    if (isRelease) {
      return 'assets/assets/view/${_baseFile(id, version)}';
    } else {
      return 'assets/view/${_baseFile(id, version)}';
    }
  }

  String _baseFile(String? id, String version) {
    if (id != null) {
      return 'item.html?contentId=$id&t=$version';
    } else {
      return 'index.html?t=$version';
    }
  }

  Future _launchURL(String url) async {
    final Uri uri = Uri.parse(url);
    if (!await launchUrl(uri)) {
      throw 'Could not launch $url';
    }
  }
}

最後に

以上のような方法でSPAなブログを作成できました。
どの実装も無理くり丸め込んだような実装だったので、もし良い実装方法を知っている人がいればぜひ教えて下さい!

作成したブログはコラム的な内容を書く用途として使い、技術系の話(特にコメントが欲しいもの)はこちらのZennかQiitaに書いていくつもりです。
SEOスコアを100点にするための作業のうちFlutterが関与しない部分は、次回(12/25)のアドベントカレンダーで記載していきます。

https://qiita.com/advent-calendar/2022/cms

最後まで読んでいただいてありがとうございました!

よろしければ、いいね!やコメントをしていただけるととっても嬉しいです!!

おじぎ

Discussion