Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 22 additions & 25 deletions .github/workflows/auto-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ on:

permissions:
contents: write
actions: write

jobs:
release:
Expand All @@ -17,7 +18,24 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}

- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'

- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4

- name: Clean Gradle transforms cache
run: rm -rf ~/.gradle/caches/*/transforms

- name: Build
run: ./gradlew build

- name: Verify Plugin
run: ./gradlew verifyPlugin

- name: Determine bump type
id: bump
Expand Down Expand Up @@ -81,7 +99,6 @@ jobs:
run: |
DATE=$(date +%Y-%m-%d)
ENTRY=$(printf '## [%s] - %s\n\n- %s (#%s)\n' "$VERSION" "$DATE" "$PR_TITLE" "$PR_NUMBER")
# Insert new entry before the first existing ## [ section
LINE=$(grep -n '^## \[' CHANGELOG.md | head -1 | cut -d: -f1)
if [ -n "$LINE" ]; then
head -n "$((LINE - 1))" CHANGELOG.md > CHANGELOG.tmp
Expand All @@ -103,28 +120,8 @@ jobs:
git tag "v$NEW_VERSION"
git push origin master --tags

- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'

- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4

- name: Build
run: ./gradlew build

- name: Verify Plugin
run: ./gradlew verifyPlugin

- name: Publish to JetBrains Marketplace
env:
PUBLISH_TOKEN: ${{ secrets.PUBLISH_TOKEN }}
run: ./gradlew publishPlugin

- name: Create GitHub Release
- name: Trigger release workflow
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ steps.version.outputs.new }}
run: gh release create "v$VERSION" build/distributions/*.zip --title "v$VERSION" --generate-notes
TAG: v${{ steps.version.outputs.new }}
run: gh workflow run release.yml --ref "$TAG"
3 changes: 3 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4

- name: Clean Gradle transforms cache
run: rm -rf ~/.gradle/caches/*/transforms

- name: Build
run: ./gradlew build

Expand Down
18 changes: 17 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,25 @@ on:
push:
tags:
- 'v*'
workflow_dispatch:

permissions:
contents: write

jobs:
release:
runs-on: ubuntu-latest

steps:
- name: Validate tag ref
env:
REF_NAME: ${{ github.ref_name }}
run: |
if [[ ! "$REF_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::This workflow must be triggered with a version tag (e.g., v1.5.1), got: $REF_NAME"
exit 1
fi

- uses: actions/checkout@v4

- name: Set up JDK 21
Expand All @@ -21,6 +34,9 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4

- name: Clean Gradle transforms cache
run: rm -rf ~/.gradle/caches/*/transforms

- name: Build
run: ./gradlew build

Expand All @@ -36,4 +52,4 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.ref_name }}
run: gh release create "$TAG" build/distributions/*.zip --generate-notes
run: gh release create "$TAG" build/distributions/*.zip --title "$TAG" --generate-notes
3 changes: 3 additions & 0 deletions .github/workflows/verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4

- name: Clean Gradle transforms cache
run: rm -rf ~/.gradle/caches/*/transforms

- name: Run Plugin Verifier
run: ./gradlew verifyPlugin

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,13 @@ IntelliJ plugin for [Mermaid](https://mermaid.js.org/) diagrams — live preview
- Export diagrams as SVG or PNG — copy to clipboard or save to file
- Scroll synchronization between the text editor and the preview
- Automatic dark/light theme detection and switching
- Uses the official Mermaid.js library (v11.13.0) — supports all 24+ diagram types
- Uses the official Mermaid.js library (v11.14.0) — supports all 27+ diagram types
- Works offline — Mermaid.js is bundled, no CDN required

### Code Intelligence
- **Syntax highlighting** — keywords, diagram types, arrows, strings, comments, punctuation
- **Customizable colors** via Settings > Editor > Color Scheme > Mermaid
- **Code completion** — 24 diagram types, context-sensitive keywords, node/participant names, arrows, directives
- **Code completion** — 27 diagram types, context-sensitive keywords, node/participant names, arrows, directives
- **Go to Definition** (Ctrl+B) — navigate to node/participant declarations
- **Find Usages** (Alt+F7) — find all references to a node across the diagram
- **Rename** (Shift+F6) — rename nodes/participants with all references updated
Expand Down
108 changes: 95 additions & 13 deletions build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import java.net.HttpURLConnection
import java.net.URI
import java.nio.file.AtomicMoveNotSupportedException
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.security.MessageDigest
import java.util.Base64

plugins {
id("org.jetbrains.kotlin.jvm") version "2.1.20"
Expand Down Expand Up @@ -91,28 +97,38 @@ tasks.named("compileJava") {

tasks.register("updateMermaid") {
group = "mermaid"
description = "Downloads the latest mermaid.min.js from npm/jsdelivr, replaces the bundled copy, and updates the version file"
description = "Downloads the latest mermaid.min.js from npm/jsdelivr, verifies integrity, replaces the bundled copy, and updates the version file"

val webDir = layout.projectDirectory.dir("src/main/resources/web")
val versionFile = webDir.file("mermaid.version")
val targetFile = webDir.file("mermaid.min.js")

doLast {
val latestVersion: String
try {
val registryUrl = URI("https://registry.npmjs.org/mermaid/latest").toURL()
val conn = registryUrl.openConnection() as java.net.HttpURLConnection
fun fetchJson(url: String, errorContext: String): String {
val conn = URI(url).toURL().openConnection() as HttpURLConnection
conn.connectTimeout = 15_000
conn.readTimeout = 15_000
conn.setRequestProperty("Accept", "application/json")
try {
val json = conn.inputStream.bufferedReader().use { it.readText() }
val versionMatch = Regex(""""version"\s*:\s*"([^"]+)"""").find(json)
latestVersion = versionMatch?.groupValues?.get(1)
?: error("Could not parse version from npm registry response: ${json.take(500)}")
val responseCode = conn.responseCode
if (responseCode != 200) {
error("$errorContext returned HTTP $responseCode")
}
return conn.inputStream.bufferedReader().use { it.readText() }
} finally {
conn.disconnect()
}
}

val latestVersion: String
try {
val json = fetchJson(
"https://registry.npmjs.org/mermaid/latest",
"npm registry",
)
val versionMatch = Regex(""""version"\s*:\s*"([^"]+)"""").find(json)
latestVersion = versionMatch?.groupValues?.get(1)
?: error("Could not parse version from npm registry response: ${json.take(500)}")
} catch (e: Exception) {
throw GradleException(
"Failed to fetch latest Mermaid version from npm registry. " +
Expand All @@ -128,13 +144,32 @@ tasks.register("updateMermaid") {
return@doLast
}

// Fetch expected file hash from jsdelivr data API for integrity verification
val expectedHash: String
try {
val dataJson = fetchJson(
"https://data.jsdelivr.com/v1/packages/npm/mermaid@$latestVersion?structure=flat",
"jsdelivr data API",
)
val hashPattern = Regex(""""name"\s*:\s*"/dist/mermaid\.min\.js"\s*,\s*"hash"\s*:\s*"([^"]+)"""")
val hashMatch = hashPattern.find(dataJson)
expectedHash = hashMatch?.groupValues?.get(1)
?: error("Could not find hash for /dist/mermaid.min.js in jsdelivr data API response")
} catch (e: Exception) {
throw GradleException(
"Failed to fetch file hash from jsdelivr data API for integrity verification. " +
"Error: ${e.message}", e
)
}
println("Expected SHA-256 hash: $expectedHash")

val cdnUrl = URI("https://cdn.jsdelivr.net/npm/mermaid@$latestVersion/dist/mermaid.min.js").toURL()
val tFile = targetFile.asFile
val tmpFile = File(tFile.parentFile, "${tFile.name}.tmp")

try {
println("Downloading from $cdnUrl ...")
val conn = cdnUrl.openConnection() as java.net.HttpURLConnection
val conn = cdnUrl.openConnection() as HttpURLConnection
conn.connectTimeout = 15_000
conn.readTimeout = 30_000
try {
Expand Down Expand Up @@ -163,8 +198,55 @@ tasks.register("updateMermaid") {
)
}

tmpFile.renameTo(tFile)
vFile.writeText(latestVersion)
println("Updated mermaid.min.js to v$latestVersion (${tFile.length() / 1024} KB)")
// Verify integrity against expected hash from jsdelivr data API
val digest = MessageDigest.getInstance("SHA-256")
tmpFile.inputStream().use { input ->
val buffer = ByteArray(8192)
var bytesRead: Int
while (input.read(buffer).also { bytesRead = it } != -1) {
digest.update(buffer, 0, bytesRead)
}
}
val actualHash = Base64.getEncoder().encodeToString(digest.digest())

if (actualHash != expectedHash) {
tmpFile.delete()
throw GradleException(
"Integrity check FAILED for mermaid.min.js!\n" +
" Expected (SHA-256): $expectedHash\n" +
" Actual (SHA-256): $actualHash\n" +
"The existing file has NOT been modified. " +
"This could indicate a supply-chain attack or CDN issue."
)
}
println("Integrity check passed (SHA-256)")

// Atomic file replacement — version written only after confirmed move
try {
try {
Files.move(
tmpFile.toPath(),
tFile.toPath(),
StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING,
)
} catch (_: AtomicMoveNotSupportedException) {
println("Atomic move not supported, using standard move")

Files.move(
tmpFile.toPath(),
tFile.toPath(),
StandardCopyOption.REPLACE_EXISTING,
)
}
vFile.writeText(latestVersion)
println("Updated mermaid.min.js to v$latestVersion (${tFile.length() / 1024} KB)")
} catch (e: Exception) {
tmpFile.delete()
throw GradleException(
"Failed to replace mermaid.min.js. The existing file may NOT have been modified. " +
"Error: ${e.message}", e
)
}
}
}
7 changes: 6 additions & 1 deletion src/main/grammars/Mermaid.flex
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ import static com.intellij.psi.TokenType.*;
"group", "service", "junction",
// Venn
"set", "union",
// Wardley
"component", "pipeline", "evolve", "evolution", "size", "anchor", "source",
// Requirement diagram
"element", "requirement", "functionalRequirement", "interfaceRequirement",
"performanceRequirement", "designConstraint",
Expand Down Expand Up @@ -127,7 +129,10 @@ HYPHEN_ID = [a-zA-Z_] {ID_CHAR}* ("-" {ID_CHAR}+)*
| "packet-beta"
| "architecture-beta"
| "venn-beta"
| "ishikawa-beta" { yybegin(NORMAL); return DIAGRAM_TYPE; }
| "ishikawa-beta"
| "wardley-beta"
| "treeView-beta"
| "treemap-beta" { yybegin(NORMAL); return DIAGRAM_TYPE; }

"---" { yybegin(FRONTMATTER); return DIRECTIVE; }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,18 +58,21 @@ internal fun copyPngToClipboard(b64: String, project: Project?) {
notifyMermaid(project, MyMessageBundle.message("markdown.export.copy.failed"), NotificationType.ERROR)
return
}
try {
val pngBytes = Base64.getDecoder().decode(b64)
val image = ImageIO.read(ByteArrayInputStream(pngBytes))
?: throw IllegalStateException("Failed to decode PNG image from ${pngBytes.size} bytes")
CopyPasteManager.getInstance().setContents(ImageTransferable(image))
notifyMermaid(project, MyMessageBundle.message("markdown.export.copy.png.success"), NotificationType.INFORMATION)
} catch (e: IllegalArgumentException) {
LOG.error("Failed to copy PNG: invalid base64 (length=${b64.length})", e)
notifyMermaid(project, MyMessageBundle.message("markdown.export.copy.failed"), NotificationType.ERROR)
} catch (e: Exception) {
LOG.error("Failed to copy PNG", e)
notifyMermaid(project, MyMessageBundle.message("markdown.export.copy.failed"), NotificationType.ERROR)
ApplicationManager.getApplication().executeOnPooledThread {
try {
val pngBytes = Base64.getDecoder().decode(b64)
val image = ImageIO.read(ByteArrayInputStream(pngBytes))
?: throw IllegalStateException("Failed to decode PNG image from ${pngBytes.size} bytes")
ApplicationManager.getApplication().invokeLater {
CopyPasteManager.getInstance().setContents(ImageTransferable(image))
notifyMermaid(project, MyMessageBundle.message("markdown.export.copy.png.success"), NotificationType.INFORMATION)
}
} catch (e: Exception) {
LOG.error("Failed to copy PNG", e)
ApplicationManager.getApplication().invokeLater {
notifyMermaid(project, MyMessageBundle.message("markdown.export.copy.failed"), NotificationType.ERROR)
}
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -163,8 +163,7 @@ internal class MermaidPreviewFileEditor(

private fun restartAnalyzer() {
val psiFile = PsiManager.getInstance(project).findFile(file) ?: return
@Suppress("DEPRECATION")
DaemonCodeAnalyzer.getInstance(project).restart(psiFile)
DaemonCodeAnalyzer.getInstance(project).restart(psiFile, "Mermaid render error updated")
}

fun attachEditor(editor: Editor) {
Expand Down
Loading
Loading