diff --git a/jig-core/src/main/java/org/dddjava/jig/HandleResultImpl.java b/jig-core/src/main/java/org/dddjava/jig/HandleResultImpl.java index 6c375a30b..eac86d985 100644 --- a/jig-core/src/main/java/org/dddjava/jig/HandleResultImpl.java +++ b/jig-core/src/main/java/org/dddjava/jig/HandleResultImpl.java @@ -54,7 +54,8 @@ public boolean isOutputDiagram() { Insight, Sequence, Glossary, - PackageSummary -> false; + PackageSummary, + ListOutput -> false; }; } diff --git a/jig-core/src/main/java/org/dddjava/jig/adapter/JigDocumentGenerator.java b/jig-core/src/main/java/org/dddjava/jig/adapter/JigDocumentGenerator.java index f2a1dee89..af3647456 100644 --- a/jig-core/src/main/java/org/dddjava/jig/adapter/JigDocumentGenerator.java +++ b/jig-core/src/main/java/org/dddjava/jig/adapter/JigDocumentGenerator.java @@ -68,6 +68,7 @@ public JigDocumentGenerator(JigDocumentContext jigDocumentContext, JigService ji compositeAdapter.register(new SummaryAdapter(jigService, new ThymeleafSummaryWriter(templateEngine, jigDocumentContext))); compositeAdapter.register(new InsightAdapter(jigService, templateEngine, jigDocumentContext)); compositeAdapter.register(new RepositorySummaryAdapter(jigService, templateEngine, jigDocumentContext)); + compositeAdapter.register(new ListOutputAdapter(jigService, templateEngine, jigDocumentContext)); } public JigResult generate(JigRepository jigRepository) { @@ -131,7 +132,7 @@ HandleResult generateDocument(JigDocument jigDocument, Path outputDirectory, Jig case DomainSummary, ApplicationSummary, UsecaseSummary, EntrypointSummary, PackageRelationDiagram, BusinessRuleRelationDiagram, CategoryDiagram, CategoryUsageDiagram, ServiceMethodCallHierarchyDiagram, - BusinessRuleList, ApplicationList, + BusinessRuleList, ApplicationList, ListOutput, RepositorySummary, Insight, Sequence -> compositeAdapter.invoke(jigDocument, jigRepository); }; @@ -165,6 +166,7 @@ private void generateAssets() { copyAsset("package.js", assetsPath); copyAsset("glossary.js", assetsPath); copyAsset("insight.js", assetsPath); + copyAsset("list-output.js", assetsPath); } catch (IOException e) { throw new UncheckedIOException(e); } diff --git a/jig-core/src/main/java/org/dddjava/jig/adapter/thymeleaf/ListOutputAdapter.java b/jig-core/src/main/java/org/dddjava/jig/adapter/thymeleaf/ListOutputAdapter.java new file mode 100644 index 000000000..4869f6b37 --- /dev/null +++ b/jig-core/src/main/java/org/dddjava/jig/adapter/thymeleaf/ListOutputAdapter.java @@ -0,0 +1,88 @@ +package org.dddjava.jig.adapter.thymeleaf; + +import org.dddjava.jig.adapter.HandleDocument; +import org.dddjava.jig.adapter.JigDocumentWriter; +import org.dddjava.jig.application.JigService; +import org.dddjava.jig.domain.model.data.members.fields.JigFieldId; +import org.dddjava.jig.domain.model.data.types.TypeId; +import org.dddjava.jig.domain.model.documents.documentformat.JigDocument; +import org.dddjava.jig.domain.model.documents.stationery.JigDocumentContext; +import org.dddjava.jig.domain.model.information.JigRepository; +import org.dddjava.jig.domain.model.information.inputs.Entrypoint; +import org.dddjava.jig.domain.model.information.inputs.InputAdapters; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.context.Context; + +import java.nio.file.Path; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.stream.Collectors; + +@HandleDocument +public class ListOutputAdapter { + + private final JigService jigService; + private final TemplateEngine templateEngine; + private final JigDocumentContext jigDocumentContext; + + public ListOutputAdapter(JigService jigService, TemplateEngine templateEngine, JigDocumentContext jigDocumentContext) { + this.jigService = jigService; + this.templateEngine = templateEngine; + this.jigDocumentContext = jigDocumentContext; + } + + @HandleDocument(JigDocument.ListOutput) + public List invoke(JigRepository repository, JigDocument jigDocument) { + InputAdapters inputAdapters = jigService.inputAdapters(repository); + String controllerJson = inputAdapters.listEntrypoint().stream() + .map(this::formatControllerJson) + .collect(Collectors.joining(",", "[", "]")); + + String listJson = """ + {"controllers": %s} + """.formatted(controllerJson); + + JigDocumentWriter jigDocumentWriter = new JigDocumentWriter(jigDocument, jigDocumentContext.outputDirectory()); + Map contextMap = Map.of( + "title", jigDocumentWriter.jigDocument().label(), + "listJson", listJson + ); + + Context context = new Context(Locale.ROOT, contextMap); + String template = jigDocumentWriter.jigDocument().fileName(); + + jigDocumentWriter.writeTextAs(".html", + writer -> templateEngine.process(template, context, writer)); + return jigDocumentWriter.outputFilePaths(); + } + + private String formatControllerJson(Entrypoint entrypoint) { + String usingFieldTypesJson = entrypoint.jigMethod().usingFields().jigFieldIds().stream() + .map(JigFieldId::declaringTypeId) + .map(TypeId::asSimpleText) + .sorted() + .map(this::escape) + .map(value -> "\"" + value + "\"") + .collect(Collectors.joining(",", "[", "]")); + return """ + {"packageName": "%s", "typeName": "%s", "methodSignature": "%s", "returnType": "%s", "typeLabel": "%s", "usingFieldTypes": %s, "cyclomaticComplexity": %d, "path": "%s"} + """.formatted( + escape(entrypoint.packageId().asText()), + escape(entrypoint.typeId().asSimpleText()), + escape(entrypoint.jigMethod().simpleMethodSignatureText()), + escape(entrypoint.jigMethod().returnType().simpleName()), + escape(entrypoint.jigType().label()), + usingFieldTypesJson, + entrypoint.jigMethod().instructions().cyclomaticComplexity(), + escape(entrypoint.fullPathText())); + } + + private String escape(String string) { + return string + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\r", "\\r") + .replace("\n", "\\n"); + } +} diff --git a/jig-core/src/main/java/org/dddjava/jig/domain/model/documents/documentformat/JigDocument.java b/jig-core/src/main/java/org/dddjava/jig/domain/model/documents/documentformat/JigDocument.java index 62f7549a9..678010d6b 100644 --- a/jig-core/src/main/java/org/dddjava/jig/domain/model/documents/documentformat/JigDocument.java +++ b/jig-core/src/main/java/org/dddjava/jig/domain/model/documents/documentformat/JigDocument.java @@ -77,6 +77,14 @@ public enum JigDocument { ApplicationList( JigDocumentLabel.of("機能一覧", "ApplicationList"), "application"), + /** + * 一覧出力 + * + * 一覧をHTMLで出力する。 + */ + ListOutput( + JigDocumentLabel.of("一覧出力", "ListOutput"), + "list-output"), /** * サービスメソッド呼び出し図 diff --git a/jig-core/src/main/resources/templates/assets/list-output.js b/jig-core/src/main/resources/templates/assets/list-output.js new file mode 100644 index 000000000..c76a4e43e --- /dev/null +++ b/jig-core/src/main/resources/templates/assets/list-output.js @@ -0,0 +1,138 @@ +function getListData() { + const jsonText = document.getElementById("list-data")?.textContent || "{}"; + /** @type {{controllers?: Array<{ + * packageName: string, + * typeName: string, + * methodSignature: string, + * returnType: string, + * typeLabel: string, + * usingFieldTypes: string[], + * cyclomaticComplexity: number, + * path: string + * }>} | Array<{ + * packageName: string, + * typeName: string, + * methodSignature: string, + * returnType: string, + * typeLabel: string, + * usingFieldTypes: string[], + * cyclomaticComplexity: number, + * path: string + * }>} */ + const listData = JSON.parse(jsonText); + if (Array.isArray(listData)) { + return listData; + } + return listData.controllers ?? []; +} + +function escapeCsvValue(value) { + const text = String(value ?? "") + .replace(/\r\n/g, "\n") + .replace(/\r/g, "\n"); + return `"${text.replace(/"/g, "\"\"")}"`; +} + +function formatFieldTypes(fieldTypes) { + if (!fieldTypes) return ""; + if (Array.isArray(fieldTypes)) { + return fieldTypes.join("\n"); + } + return String(fieldTypes); +} + +function buildControllerCsv(items) { + const header = [ + "パッケージ名", + "クラス名", + "メソッドシグネチャ", + "メソッド戻り値の型", + "クラス別名", + "使用しているフィールドの型", + "循環的複雑度", + "パス", + ]; + const rows = items.map(item => [ + item.packageName ?? "", + item.typeName ?? "", + item.methodSignature ?? "", + item.returnType ?? "", + item.typeLabel ?? "", + formatFieldTypes(item.usingFieldTypes), + item.cyclomaticComplexity ?? "", + item.path ?? "", + ]); + const lines = [header, ...rows].map(row => row.map(escapeCsvValue).join(",")); + return lines.join("\r\n"); +} + +function downloadCsv(text, filename) { + const blob = new Blob([text], {type: "text/csv;charset=utf-8;"}); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); +} + +function renderControllerTable(items) { + const tableBody = document.querySelector("#controller-list tbody"); + if (!tableBody) return; + tableBody.innerHTML = ""; + + const fragment = document.createDocumentFragment(); + items.forEach(item => { + const row = document.createElement("tr"); + const values = [ + item.packageName, + item.typeName, + item.methodSignature, + item.returnType, + item.typeLabel, + formatFieldTypes(item.usingFieldTypes), + item.cyclomaticComplexity, + item.path, + ]; + values.forEach((value, index) => { + const cell = document.createElement("td"); + if (index === 6) { + cell.className = "number"; + } + cell.textContent = value ?? ""; + row.appendChild(cell); + }); + fragment.appendChild(row); + }); + + tableBody.appendChild(fragment); +} + +if (typeof document !== "undefined") { + document.addEventListener("DOMContentLoaded", function () { + if (!document.body.classList.contains("list-output")) return; + const items = getListData(); + renderControllerTable(items); + + const exportButton = document.getElementById("export-csv"); + if (exportButton) { + exportButton.addEventListener("click", () => { + const csvText = buildControllerCsv(items); + downloadCsv(csvText, "list-output.csv"); + }); + } + }); +} + +// Nodeのテスト用エクスポート。ブラウザでは無視される。 +if (typeof module !== "undefined" && module.exports) { + module.exports = { + getListData, + escapeCsvValue, + formatFieldTypes, + buildControllerCsv, + renderControllerTable, + }; +} diff --git a/jig-core/src/main/resources/templates/assets/style.css b/jig-core/src/main/resources/templates/assets/style.css index c6894979f..8ab1513e7 100644 --- a/jig-core/src/main/resources/templates/assets/style.css +++ b/jig-core/src/main/resources/templates/assets/style.css @@ -465,6 +465,11 @@ label { display: none; } +/* 一覧出力のヘッダは折り返さない */ +.list-output table thead th { + white-space: nowrap; +} + /* テーブルの行をゼブラスタイルにする */ table.zebra tbody tr:nth-child(odd) { background-color: #f9f9f9; diff --git a/jig-core/src/main/resources/templates/index.html b/jig-core/src/main/resources/templates/index.html index 6fd20cf9e..dc960c104 100644 --- a/jig-core/src/main/resources/templates/index.html +++ b/jig-core/src/main/resources/templates/index.html @@ -27,6 +27,12 @@

概要: HTML

  • インサイト (incubate)
  • +
    +

    一覧: HTML

    + +

    一覧: Excel

      @@ -51,4 +57,4 @@

      XXX

      - \ No newline at end of file + diff --git a/jig-core/src/main/resources/templates/list-output.html b/jig-core/src/main/resources/templates/list-output.html new file mode 100644 index 000000000..fc72dbe0b --- /dev/null +++ b/jig-core/src/main/resources/templates/list-output.html @@ -0,0 +1,66 @@ + + + + + + + + 一覧出力 + + +
      たいとる
      +
      +

      一覧出力

      + + + +
      +

      CONTROLLER

      +
      + +
      + + + + + + + + + + + + + + +
      パッケージ名クラス名メソッドシグネチャメソッド戻り値の型クラス別名使用しているフィールドの型循環的複雑度パス
      +
      +
      + + + + + + + + + + + diff --git a/jig-core/src/test/js/list-output.test.js b/jig-core/src/test/js/list-output.test.js new file mode 100644 index 000000000..f0a3e2a67 --- /dev/null +++ b/jig-core/src/test/js/list-output.test.js @@ -0,0 +1,102 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const listOutput = require('../../main/resources/templates/assets/list-output.js'); + +class Element { + constructor(tagName) { + this.tagName = tagName; + this.children = []; + this.textContent = ''; + this.innerHTML = ''; + this.className = ''; + this.parentElement = null; + } + + appendChild(child) { + child.parentElement = this; + this.children.push(child); + return child; + } +} + +class DocumentStub { + constructor() { + this.elementsById = new Map(); + } + + createElement(tagName) { + return new Element(tagName); + } + + createDocumentFragment() { + return new Element('fragment'); + } + + getElementById(id) { + return this.elementsById.get(id) || null; + } +} + +function setupDocument() { + const doc = new DocumentStub(); + global.document = doc; + return doc; +} + +test.describe('list-output.js CSV', () => { + test('CSV値はクォートし、改行とダブルクォートを処理する', () => { + const value = '"a"\r\nline'; + + const escaped = listOutput.escapeCsvValue(value); + + assert.equal(escaped, '"""a""\nline"'); + }); + + test('CSVにヘッダーと行を出力する', () => { + const items = [ + { + packageName: 'com.example', + typeName: 'ExampleController', + methodSignature: 'getExample()', + returnType: 'Example', + typeLabel: '例', + usingFieldTypes: ['ExampleRepository', 'AnotherType'], + cyclomaticComplexity: 2, + path: 'GET /example', + }, + ]; + + const csv = listOutput.buildControllerCsv(items); + + assert.equal( + csv, + '"パッケージ名","クラス名","メソッドシグネチャ","メソッド戻り値の型","クラス別名","使用しているフィールドの型","循環的複雑度","パス"\r\n' + + '"com.example","ExampleController","getExample()","Example","例","ExampleRepository\nAnotherType","2","GET /example"' + ); + }); +}); + +test.describe('list-output.js データ読み込み', () => { + test('list-dataから一覧を取得する', () => { + const doc = setupDocument(); + const dataElement = new Element('script'); + dataElement.textContent = JSON.stringify({ + controllers: [{typeName: 'ExampleController'}], + }); + doc.elementsById.set('list-data', dataElement); + + const items = listOutput.getListData(); + + assert.equal(items.length, 1); + assert.equal(items[0].typeName, 'ExampleController'); + }); +}); + +test.describe('list-output.js 表示用整形', () => { + test('使用フィールド型を改行で連結する', () => { + const formatted = listOutput.formatFieldTypes(['A', 'B']); + + assert.equal(formatted, 'A\nB'); + }); +});