はじめに
素晴らしい DESIGN.md の例がすでに、このすごいリポジトリにたくさん用意されていることが分かりました:
軽い紹介:このリポジトリは、実際のWebサイトから抽出した、すぐに使える DESIGN.md ファイルの厳選コレクションです。AIエージェントがUIをより視覚的に一貫したものとして生成できるようにするために設計されています。
awesome-design-md のメンテナーおよびコントリビューターの皆さんには、この方向性を前進させ、デザインシステムの知識をより再利用しやすくしてくれたことに大きく感謝します。
とはいえ、リポジトリに含まれていないものを作りたいと思うこともあります。特に、ちょっと昔ながらの気分になって、誰にも真似できない自分だけのレトロなテイストが欲しいときですね。;)
そこで、このチュートリアルでは楽しい実験を行います:C# でのカスタム DESIGN.md ジェネレーターアプリです。
このアプリは以下を行います:
- 対象ページをクロールする。
- 視覚トークン(色、フォント、セマンティックなクラス)を抽出する。
- LLM にフルの
DESIGN.mdを合成させる。 - その
DESIGN.mdから、検証用のindex.htmlを LLM に生成させる。 - 両方のファイルをローカルに保存する。
使用するもの:
.NET 8OllamaSharphttp://localhost:11434nemotron-3-super:cloud
我々が作るもの
対象URL
-> DesignMdGenerator.CrawlAndExtractMetadataAsync
-> トークン化されたデザインメタデータ(タイトル/色/フォント/クラス)
-> Ollama(nemotron-3-super:cloud)
-> DESIGN.md
-> Ollama の検証ラウンド
-> index.html
-> Output/DesignMdGenerator に保存
前提条件
- Visual Studio または VS Code
- .NET 8 SDK
- Ollama をローカルで起動していること
- Ollama 環境で利用可能なモデルがあること
このデモで使うモデルで Ollama を実行してください:
ollama run nemotron-3-super:cloud
Ollama サービスがすでに http://localhost:11434 で動作しているなら大丈夫です。
ステップ1:新しいコンソールアプリを作成する
dotnet new console -n DesignMdGeneratorDemo
cd DesignMdGeneratorDemo
dotnet add package OllamaSharp
ステップ2:プロジェクトファイル(参考)
.csproj には少なくとも以下が含まれている必要があります:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="OllamaSharp" Version="5.4.25" />
</ItemGroup>
</Project>
ステップ3:Program.cs を追加する
このバージョンは DESIGN.md のデモフローにだけ焦点を当てています。
using DesignMdGeneratorDemo.Services;
Console.OutputEncoding = System.Text.Encoding.UTF8;
string targetUrl = args.Length > 0 ? args[0] : "https://www.yahoo.com";
string modelName = args.Length > 1 ? args[1] : "nemotron-3-super:cloud";
string ollamaApiUrl = args.Length > 2 ? args[2] : "http://localhost:11434";
var generator = new DesignMdGenerator(ollamaApiUrl, modelName);
var outputDirectory = Path.Combine(Directory.GetCurrentDirectory(), "Output", "DesignMdGenerator");
Console.WriteLine("カスタム DESIGN.md ジェネレーターのデモ");
Console.WriteLine("-------------------------------");
Console.WriteLine($"対象URL : {targetUrl}");
Console.WriteLine($"モデル : {modelName}");
Console.WriteLine($"Ollama URL : {ollamaApiUrl}");
Console.WriteLine();
var result = await generator.GenerateArtifactsAsync(targetUrl, outputDirectory);
Console.WriteLine("生成が完了しました。");
Console.WriteLine($"DESIGN.md : {result.DesignMarkdownPath}");
Console.WriteLine($"index.html: {result.VerificationHtmlPath}");
if (!string.IsNullOrWhiteSpace(result.WarningMessage))
{
Console.WriteLine();
Console.WriteLine("警告:");
Console.WriteLine(result.WarningMessage);
}
ステップ 4: Services/DesignMdGenerator.cs を追加する
Services フォルダーを作成し、次の完全なサービス クラスを追加します。
using OllamaSharp;
using System.Net;
using System.Text;
using System.Text.RegularExpressions;
namespace DesignMdGeneratorDemo.Services;
public sealed class DesignMdGenerationResult
{
public required string TargetUrl { get; init; }
public required string DesignMarkdown { get; init; }
public required string DesignMarkdownPath { get; init; }
public required string VerificationHtml { get; init; }
public required string VerificationHtmlPath { get; init; }
public required DesignSiteMetadata Metadata { get; init; }
public string? WarningMessage { get; init; }
}
public sealed class DesignSiteMetadata
{
public string Title { get; init; } = string.Empty;
public IReadOnlyList<string> Colors { get; init; } = [];
public IReadOnlyList<string> Fonts { get; init; } = [];
public IReadOnlyList<string> StructuralClasses { get; init; } = [];
public IReadOnlyList<string> StylesheetUrls { get; init; } = [];
}
返却形式: {"translated": "翻訳されたHTML"}public sealed class DesignMdGenerator
{
private static readonly Regex TitleRegex = new("<title[^>]*>(?<title>.*?)</title>", RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled);
private static readonly Regex StyleBlockRegex = new("<style[^>]*>(?<style>.*?)</style>", RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled);
private static readonly Regex InlineStyleRegex = new("style\\s*=\\s*[\"'](?<value>.*?)[\"']", RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled);
private static readonly Regex StylesheetHrefRegex = new("<link[^>]*rel=[\"'][^\"']*stylesheet[^\"']*[\"'][^>]*href=[\"'](?<href>[^\"']+)[\"'][^>]*>", RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled);
private static readonly Regex ClassRegex = new("class\\s*=\\s*[\"'](?<value>[^\"']+)[\"']", RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled);
private static readonly Regex HexColorRegex = new("#([0-9a-fA-F]{3,8})\\b", RegexOptions.Compiled);
private static readonly Regex RgbColorRegex = new("rgba?\\(\\s*\\d+\\s*,\\s*\\d+\\s*,\\s*\\d+\\s*(?:,\\s*[0-9.]+\\s*)?\\)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex FontFamilyRegex = new("font-family\\s*:\\s*(?<value>[^;}]+)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly string[] StructuralClassKeywords =
[
"btn", "button", "nav", "header", "footer", "card", "menu", "hero", "logo", "input"
];
private readonly OllamaApiClient _ollamaClient;
private readonly HttpClient _httpClient;
private readonly string _ollamaApiUrl;
private readonly string _modelName;
public DesignMdGenerator(
string ollamaApiUrl = "http://localhost:11434",
string modelName = "nemotron-3-super:cloud",
HttpClient? httpClient = null)
{
_ollamaApiUrl = ollamaApiUrl;
_modelName = modelName;_ollamaClient = new OllamaApiClient(new Uri(ollamaApiUrl))
{
SelectedModel = modelName
};
_httpClient = httpClient ?? new HttpClient();
if (_httpClient.DefaultRequestHeaders.UserAgent.Count == 0)
{
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36");
}
}
public async Task<DesignMdGenerationResult> GenerateArtifactsAsync(
string targetUrl,
string? outputDirectory = null,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(targetUrl))
{
throw new ArgumentException("対象URLが必要です。", nameof(targetUrl));
}
targetUrl = NormalizeTargetUrl(targetUrl);
outputDirectory = string.IsNullOrWhiteSpace(outputDirectory)
? Directory.GetCurrentDirectory()
: outputDirectory;
Directory.CreateDirectory(outputDirectory);
var metadata = await CrawlAndExtractMetadataAsync(targetUrl, cancellationToken);
var warnings = new List<string>();
var designMarkdown = await GenerateDesignMarkdownAsync(metadata, targetUrl, warnings, cancellationToken);
var designMarkdownPath = Path.Combine(outputDirectory, "DESIGN.md");
await File.WriteAllTextAsync(designMarkdownPath, designMarkdown, cancellationToken);
var verificationHtml = await GenerateVerificationHtmlAsync(designMarkdown, targetUrl, warnings, cancellationToken);
var verificationHtmlPath = Path.Combine(outputDirectory, "index.html");
await File.WriteAllTextAsync(verificationHtmlPath, verificationHtml, cancellationToken);return new DesignMdGenerationResult
{
TargetUrl = targetUrl,
DesignMarkdown = designMarkdown,
DesignMarkdownPath = designMarkdownPath,
VerificationHtml = verificationHtml,
VerificationHtmlPath = verificationHtmlPath,
Metadata = metadata,
WarningMessage = warnings.Count > 0 ? string.Join(Environment.NewLine, warnings) : null
};
}
public async Task<string> GenerateDesignMarkdownAsync(
DesignSiteMetadata metadata,
string targetUrl,
List<string>? warnings = null,
CancellationToken cancellationToken = default)
{
var prompt = BuildDesignMarkdownPrompt(metadata, targetUrl);
try
{
var markdown = await GenerateTextAsync(prompt, cancellationToken);
return markdown.Trim();
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.Forbidden)
{
warnings?.Add($"Ollamaの生成が禁止されました(403)。モデル '{}{_modelName}}' が対象 ' }{_ollamaApiUrl}'. 代わりにヒューリスティックなDESIGN.mdのフォールバックを生成します。");
return BuildFallbackDesignMarkdown(metadata, targetUrl);
}
}
public async Task<string> GenerateVerificationHtmlAsync(
string designMarkdown,
string targetUrl,
List<string>? warnings = null,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(designMarkdown))
{
throw new ArgumentException("デザインのマークダウンは必須です。", nameof(designMarkdown));
}
var prompt = BuildVerificationPrompt(designMarkdown, targetUrl);
try
{
var rawHtml = await GenerateTextAsync(prompt, cancellationToken);
return CleanModelCodeFence(rawHtml);
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.Forbidden)
{
warnings?.Add($"OllamaのHTML検証が禁止されました(403)。モデル '{}{_modelName}}'. 代わりに基本的なHTMLのフォールバックを生成します。");
return BuildFallbackVerificationHtml(targetUrl);
}}
private async Task<string> GenerateTextAsync(string prompt, CancellationToken cancellationToken)
{
var builder = new StringBuilder();
await foreach (var chunk in _ollamaClient.GenerateAsync(prompt, context: null, cancellationToken: cancellationToken))
{
if (!string.IsNullOrWhiteSpace(chunk?.Response))
{
builder.Append(chunk.Response);
}
}
var response = builder.ToString();
if (string.IsNullOrWhiteSpace(response))
{
throw new InvalidOperationException("The model response was empty.");
}
return response;
}
private static string BuildDesignMarkdownPrompt(DesignSiteMetadata metadata, string targetUrl)
{
return $$"""
あなた は シニア の デザイン-システム アナリスト。
抽出された Webサイト デザイン メタデータ を 分析し、 完全な DESIGN.md ドキュメントを 返してください。
出力 は markdown のみに。 前置き は不要。 コード フェンス は不要。
対象 URL: {{targetUrl}}
ページ タイトル: {{metadata.Title}}
抽出された 色:
- {{string.Join("
- ", metadata.Colors.DefaultIfEmpty("n/a"))}}
抽出された フォント:
- {{string.Join("
- ", metadata.Fonts.DefaultIfEmpty("n/a"))}}
構造的な クラス:
- {{string.Join("
- ", metadata.StructuralClasses.DefaultIfEmpty("n/a"))}}
外部 スタイルシート の サンプル:
- {{string.Join("
- ", metadata.StylesheetUrls.DefaultIfEmpty("n/a"))}}
返却形式: {"translated": "翻訳されたHTML"}必須 セクション と 順序:
1. 概要
2. 色 (ブランド & アクセント, サーフェス, テキスト, セマンティック)
3. タイポグラフィ (フォント ファミリー, 階層 テーブル, 原則)
4. レイアウト (スペーシング システム, グリッド & コンテナ, ホワイトスペース の哲学)
5. 標高 & 深さ (テーブル)
6. 図形 (ボーダー 半径 スケール テーブル, 写真 & イラスト)
7. コンポーネント (トップ ナビゲーション, ボタン, カード, 入力, バッジ, タブ, フッター)
8. やるべきこと と やってはいけないこと’ガイダンス
9. レスポンシブ 挙動 (ブレークポイント テーブル, タッチ ターゲット, 折りたたみ 戦略)
10. イテレーション ガイド
11. 既知の ギャップ
実装-指向 で 具体的に 保つ。
""";
}
private static string BuildVerificationPrompt(string designMarkdown, string targetUrl)
{
return $$"""
あなたは DESIGN.md 仕様を 検証する シニア フロントエンド エンジニアです。
DESIGN.md から 単一ファイル の index.html を 作成してください。
HTMLのみ を返してください。
ターゲットの ブランド参照: {{targetUrl}}
DESIGN.md:
---
{{designMarkdown}}
---
制約:
- Tailwind CSSの CDNを 使用する。
- ナビゲーション, ヒーロー, コンテンツ/機能セクション, および フッターを 含める。
- テーマ値を DESIGN.mdの トークンを 反映するように 設定する。
- レイアウトを レスポンシブに 保つ。
- すべてのコードを 1つの HTMLファイル 内に まとめる。
""";
}
private static string CleanModelCodeFence(string modelOutput)
{
if (string.IsNullOrWhiteSpace(modelOutput))
{
return string.Empty;
}
返却形式: {"translated": "翻訳されたHTML"}var cleaned = modelOutput.Trim();
cleaned = Regex.Replace(cleaned, "^```
{% endraw %}
(?:html)?\s*", string.Empty, RegexOptions.IgnoreCase);
cleaned = Regex.Replace(cleaned, "\s*
{% raw %}
```$", string.Empty, RegexOptions.IgnoreCase);
return cleaned.Trim();
}
private async Task<DesignSiteMetadata> CrawlAndExtractMetadataAsync(string targetUrl, CancellationToken cancellationToken)
{
targetUrl = NormalizeTargetUrl(targetUrl);
var pageUri = new Uri(targetUrl, UriKind.Absolute);
var pageHtml = await _httpClient.GetStringAsync(pageUri, cancellationToken);
var cssBuilder = new StringBuilder();
foreach (Match styleMatch in StyleBlockRegex.Matches(pageHtml))
{
var css = styleMatch.Groups["style"].Value;
if (!string.IsNullOrWhiteSpace(css))
{
cssBuilder.AppendLine(css);
}
}
foreach (Match inlineStyleMatch in InlineStyleRegex.Matches(pageHtml))
{
var inlineStyle = WebUtility.HtmlDecode(inlineStyleMatch.Groups["value"].Value);
if (!string.IsNullOrWhiteSpace(inlineStyle))
{
cssBuilder.AppendLine(inlineStyle);
}
}
var stylesheetUrls = ExtractStylesheetUrls(pageHtml, pageUri, 3);
foreach (var stylesheetUrl in stylesheetUrls)
{
try
{
var stylesheetContent = await _httpClient.GetStringAsync(stylesheetUrl, cancellationToken);
if (!string.IsNullOrWhiteSpace(stylesheetContent))
{
cssBuilder.AppendLine(stylesheetContent);
}
}
catch
{
// スタイルシートの取得に失敗しても処理を続行する
}
}
var rawCss = cssBuilder.ToString();
return new DesignSiteMetadata
{
Title = ExtractTitle(pageHtml),
Colors = ExtractUniqueColors(rawCss, 30),
Fonts = ExtractUniqueFonts(rawCss, 12),
StructuralClasses = ExtractStructuralClasses(pageHtml, 25),
StylesheetUrls = stylesheetUrls
};
}
private static string ExtractTitle(string html)
{
var titleMatch = TitleRegex.Match(html);
if (!titleMatch.Success)
{
return "不明なタイトル";
}
return WebUtility.HtmlDecode(titleMatch.Groups["title"].Value).Trim();
}
private static IReadOnlyList<string> ExtractStylesheetUrls(string html, Uri pageUri, int maxStylesheets)
{
var urls = new List<string>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (Match match in StylesheetHrefRegex.Matches(html))
{
if (urls.Count >= maxStylesheets)
{
break;
}
var href = WebUtility.HtmlDecode(match.Groups["href"].Value).Trim();
if (string.IsNullOrWhiteSpace(href))
{
continue;
}
if (!Uri.TryCreate(pageUri, href, out var stylesheetUri))
{
continue;
}
var absoluteUrl = stylesheetUri.ToString();
if (seen.Add(absoluteUrl))
{
urls.Add(absoluteUrl);
}
}
return urls;
}
private static IReadOnlyList<string> ExtractUniqueColors(string css, int maxCount)
{
var colors = new HashSet<string>(StringComparer.OrdinalIgnoreCase);foreach (Match hexMatch in HexColorRegex.Matches(css))
{
colors.Add(hexMatch.Value.ToLowerInvariant());
}
foreach (Match rgbMatch in RgbColorRegex.Matches(css))
{
colors.Add(rgbMatch.Value.ToLowerInvariant());
}
return colors.Take(maxCount).ToList();
}
private static IReadOnlyList<string> ExtractUniqueFonts(string css, int maxCount)
{
var fonts = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (Match fontMatch in FontFamilyRegex.Matches(css))
{
var value = fontMatch.Groups["value"].Value
.Replace("\"", string.Empty, StringComparison.Ordinal)
.Replace("'", string.Empty, StringComparison.Ordinal)
.Trim();
if (!string.IsNullOrWhiteSpace(value))
{
fonts.Add(value);
}
}
return fonts.Take(maxCount).ToList();
}
private static IReadOnlyList<string> ExtractStructuralClasses(string html, int maxCount)
{
var classes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (Match classMatch in ClassRegex.Matches(html))
{
var classValue = classMatch.Groups["value"].Value;
if (string.IsNullOrWhiteSpace(classValue))
{
continue;
}foreach (var className in classValue.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
if (StructuralClassKeywords.Any(k => className.Contains(k, StringComparison.OrdinalIgnoreCase)))
{
classes.Add(className);
}
}
}
return classes.Take(maxCount).ToList();
}
private static string NormalizeTargetUrl(string targetUrl)
{
var trimmed = targetUrl.Trim();
if (Uri.TryCreate(trimmed, UriKind.Absolute, out var absoluteUri) &&
(absoluteUri.Scheme == Uri.UriSchemeHttp || absoluteUri.Scheme == Uri.UriSchemeHttps))
{
return absoluteUri.ToString();
}
if (Uri.TryCreate($"https://"{trimmed}", UriKind.Absolute, out var httpsUri))
{
return httpsUri.ToString();
}
throw new ArgumentException("対象URLは有効なHTTP/HTTPS URLである必要があります。", nameof(targetUrl));
}
private static string BuildFallbackDesignMarkdown(DesignSiteMetadata metadata, string targetUrl)
{
return $"""
# 概要
LLM 生成 の 利用不可 に 対する ヒューリスティック フォールバック for {targetUrl}.LLM 生成 は 利用できませんでした。
## 色
### ブランド & アクセント
- {string.Join("
- ", metadata.Colors.Take(6).DefaultIfEmpty("n/a"))}
### サーフェス
- {metadata.Colors.Skip(6).FirstOrDefault() ?? "n/a"}
### テキスト
- {metadata.Colors.Skip(7).FirstOrDefault() ?? "n/a"}
### セマンティック
- 成功: #16a34a
- 警告: #f59e0b
- エラー: #dc2626
"""
;
}## Typography
### Font Family
- {string.Join("
- ", metadata.Fonts.DefaultIfEmpty("system-ui, sans-serif"))}
### Hierarchy
| Token | Size | Weight | Line Height | Letter Spacing | Use |
|---|---:|---:|---:|---:|---|
| h1 | 40px | 700 | 1.2 | -0.02em | Hero |
| h2 | 32px | 700 | 1.25 | -0.01em | Section heading |
| body | 16px | 400 | 1.6 | 0 | Paragraph |
### Principles
- Keep typography readable and consistent.
## Layout
### Spacing System
- 4, 8, 12, 16, 24, 32, 48
### Grid & Container
- Max-width: 1200px
### Whitespace Philosophy
- Favor breathable sections and clear grouping.
## Elevation & Depth
| Level | Treatment | Use |
|---|---|---|
| 0 | none | background |
| 1 | subtle shadow | cards |
| 2 | medium shadow | overlays |
## Shapes
### Border Radius Scale
| Token | Value | Use |
|---|---|---|
| sm | 6px | inputs |
| md | 10px | buttons |
| lg | 16px | cards |
### Photography & Illustrations
- Match page tone and avoid style drift.
## Components
- Top Navigation, Buttons, Cards, Inputs, Badges, Tabs, Footer with consistent spacing and state contrast.
## Do and Don'"t guidance
### Do
- Reuse tokens consistently.
### Don'"t
- Introduce random colors and font mixes.## レスポンシブ 挙動
### ブレークポイント
| 名称 | 幅 | 主な 変更点 |
|---|---|---|
| sm | 640px | stack compact groups |
| md | 768px | 2-column sections |
| lg | 1024px | wider content and nav |
### タッチ ターゲット
- 最小 44x44px.
### 折りたたみ 戦略
- 小さな 画面 で nav と 密な 行 を 折りたたむ。
## 反復 ガイド
1. トークン マップ を 固定 する 。
2. コンポーネント を 検証 する。
3. ブレークポイント を テスト する。
## 既知の 不足点
- 動的な 実行時 の 相互作用 は キャプチャされません。
""";
}
private static string BuildFallbackVerificationHtml(string targetUrl)
{
var encodedTargetUrl = WebUtility.HtmlEncode(targetUrl);
return $$"""
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>デザイン プレビュー</title>
<style>
:root { --bg:#0b1020; --card:#141a2e; --text:#e8ecff; --muted:#9aa3c7; --accent:#6ea8fe; }
* { box-sizing: border-box; }
body { margin:0; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; background:var(--bg); color:var(--text); }
.container { max-width:1100px; margin:0 auto; padding:24px; }
.nav, .card { background:var(--card); border:1px solid #202948; border-radius:14px; }
.nav { padding:14px 18px; display:flex; justify-content:space-between; align-items:center; }
.hero { padding:56px 0 24px; }
.btn { background:var(--accent); color:#071126; border:0; border-radius:10px; padding:10px 16px; font-weight:700; }
.grid { display:grid; grid-template-columns:repeat(auto-fit, minmax(220px, 1fr)); gap:16px; margin-top:20px; }
.card { padding:16px; }
.muted { color:var(--muted);</



