Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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
40 changes: 40 additions & 0 deletions src/main/java/io/jenkins/plugins/designlibrary/Home.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,14 @@
import hudson.Extension;
import hudson.PluginWrapper;
import hudson.model.RootAction;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ThreadLocalRandom;
import jenkins.model.Jenkins;
import org.kohsuke.stapler.StaplerRequest2;
import org.kohsuke.stapler.StaplerResponse2;

/**
* Entry point to all the UI samples.
Expand Down Expand Up @@ -54,6 +58,42 @@
return null;
}

/**
* Serves LLM-friendly content as plain text markdown.
*
* <ul>
* <li>{@code llms.txt} - index of all components</li>
* <li>{@code llms-all.txt} - all component documentation in a single file</li>
* </ul>
*/
public void doDynamic(StaplerRequest2 req, StaplerResponse2 rsp) throws IOException {
String restOfPath = req.getRestOfPath();

Check warning

Code scanning / Jenkins Security Scan

Stapler: Missing permission check Warning

Potential missing permission check in Home#doDynamic

Check warning

Code scanning / Jenkins Security Scan

Stapler: Missing POST/RequirePOST annotation Warning

Potential CSRF vulnerability: If Home#doDynamic connects to user-specified URLs, modifies state, or is expensive to run, it should be annotated with @POST or @RequirePOST
if (restOfPath.startsWith("/")) {

Check warning on line 71 in src/main/java/io/jenkins/plugins/designlibrary/Home.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 71 is only partially covered, one branch is missing
restOfPath = restOfPath.substring(1);
}

String content = resolveLlmContent(restOfPath, req);
if (content != null) {

Check warning on line 76 in src/main/java/io/jenkins/plugins/designlibrary/Home.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 76 is only partially covered, one branch is missing
rsp.setContentType("text/plain;charset=UTF-8");
try (PrintWriter w = rsp.getWriter()) {
w.write(content);
}
return;
}

rsp.sendError(404);
}

Check warning on line 85 in src/main/java/io/jenkins/plugins/designlibrary/Home.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 84-85 are not covered by tests

private String resolveLlmContent(String name, StaplerRequest2 req) {
if ("llms.txt".equals(name)) {
return LlmContent.generateIndex();
}
if ("llms-all.txt".equals(name)) {

Check warning on line 91 in src/main/java/io/jenkins/plugins/designlibrary/Home.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 91 is only partially covered, one branch is missing
return LlmContent.generateAll(req.getServletContext());
}
return null;

Check warning on line 94 in src/main/java/io/jenkins/plugins/designlibrary/Home.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered line

Line 94 is not covered by tests
}

public String getPluginVersion() {
Jenkins jenkins = Jenkins.get();
PluginWrapper plugin = jenkins.getPluginManager().getPlugin("design-library");
Expand Down
123 changes: 123 additions & 0 deletions src/main/java/io/jenkins/plugins/designlibrary/LlmContent.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package io.jenkins.plugins.designlibrary;

import jakarta.servlet.ServletContext;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.kohsuke.accmod.Restricted;
import org.kohsuke.accmod.restrictions.NoExternalUse;

@Restricted(NoExternalUse.class)
class LlmContent {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't workable, better to just commit markdown files.

@janfaracik think its worth trying to generate jelly from markdown or just keep a markdown copy and a jelly version?

Copy link
Copy Markdown
Contributor Author

@SohamJuneja SohamJuneja Apr 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah that's fair, the Jelly-parsing approach technically works but it's fragile. Any new pattern someone adds to a component Jelly file would need another special case in the parser.

Even just building this I ran into a bunch of edge cases while manually checking each page, things like half-encoded tag names in property values, 'br' inside i18n strings causing two sentences to run together, Java generics inside code container spans getting stripped by the tag cleaner, and Colors using j:forEach which makes the Jelly basically unparseable statically. Each one took a while to track down. So I get why this isn't the right long-term approach.

One option, we could use the current implementation as a one-time generation tool to get all the content out, then delete LlmContent.java entirely and just commit the generated .md files directly. That way the content isn't lost but there's no fragile parser living in the codebase.

What do you think?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah give it a go, will need to run it past others of whether we want:

  • both committed
  • markdown as the master and then generate jelly from it

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Where should the .md files live? Either alongside each component's existing resources (src/main/resources/.../AppBars/index.md) or in a dedicated top-level folder like docs/llms/app-bars.md?

  2. Should the HTTP endpoints still work and serve these files, or are the committed files enough? If the URLs need to keep working, we still need some Java code.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. alongside the jelly files I think
  2. not sure what you mean, the markdown files need to be served


private static final Pattern CODE_FILE_PATTERN = Pattern.compile("file=\"([^\"]+)\"");

private LlmContent() {}

static String generateIndex() {
StringBuilder sb = new StringBuilder();
sb.append("# Jenkins Design Library\n\n");
sb.append("> A reference library of UI components and patterns ");
sb.append("for building Jenkins plugin interfaces.\n\n");

for (Map.Entry<Category, List<UISample>> entry : UISample.getGrouped().entrySet()) {
sb.append("## ").append(entry.getKey().getDisplayName()).append('\n');
for (UISample sample : entry.getValue()) {
sb.append("- ").append(sample.getDisplayName()).append(": ");
sb.append(sample.getDescription()).append('\n');
}
sb.append('\n');
}

return sb.toString();
}

static String generateAll(ServletContext context) {
StringBuilder sb = new StringBuilder();
sb.append("# Jenkins Design Library\n\n");
sb.append("> A reference library of UI components and patterns ");
sb.append("for building Jenkins plugin interfaces.\n\n");

for (Map.Entry<Category, List<UISample>> entry : UISample.getGrouped().entrySet()) {
sb.append("## ").append(entry.getKey().getDisplayName()).append("\n\n");
for (UISample sample : entry.getValue()) {
sb.append(generateComponentMarkdown(sample, context));
sb.append("\n---\n\n");
}
}

return sb.toString();
}

private static String generateComponentMarkdown(UISample sample, ServletContext context) {
StringBuilder sb = new StringBuilder();
sb.append("# ").append(sample.getDisplayName()).append("\n\n");
sb.append("> ").append(sample.getDescription()).append("\n\n");
sb.append("**Category:** ")
.append(sample.getCategory().getDisplayName())
.append('\n');

if (sample.getSince() != null) {
sb.append("**Since:** ").append(sample.getSince()).append('\n');
}
sb.append('\n');

String componentName = sample.getClass().getSimpleName();
List<String> snippetFiles = findSnippetReferences(sample);

for (String filename : snippetFiles) {
String path = "/" + componentName + "/" + filename;
String content = readResource(context, path);

if (content != null && !content.isBlank()) {

Check warning on line 77 in src/main/java/io/jenkins/plugins/designlibrary/LlmContent.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 77 is only partially covered, 3 branches are missing
String label = filename.replaceFirst("\\.[^.]+$", "");
String language = filename.endsWith(".js") ? "javascript" : "xml";
sb.append("### ").append(label).append("\n\n");
sb.append("```").append(language).append('\n');
sb.append(content.strip()).append('\n');
sb.append("```\n\n");

Check warning on line 83 in src/main/java/io/jenkins/plugins/designlibrary/LlmContent.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 78-83 are not covered by tests
}
}

return sb.toString();
}

private static List<String> findSnippetReferences(UISample sample) {
List<String> files = new ArrayList<>();
String jellyPath = sample.getClass().getName().replace('.', '/') + "/index.jelly";

try (InputStream is = sample.getClass().getClassLoader().getResourceAsStream(jellyPath)) {
if (is == null) {

Check warning on line 95 in src/main/java/io/jenkins/plugins/designlibrary/LlmContent.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 95 is only partially covered, one branch is missing
return files;

Check warning on line 96 in src/main/java/io/jenkins/plugins/designlibrary/LlmContent.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered line

Line 96 is not covered by tests
}
String jelly = new String(is.readAllBytes(), StandardCharsets.UTF_8);
Matcher matcher = CODE_FILE_PATTERN.matcher(jelly);
while (matcher.find()) {
String file = matcher.group(1);
if (!files.contains(file)) {
files.add(file);
}
}
} catch (IOException e) {

Check warning on line 106 in src/main/java/io/jenkins/plugins/designlibrary/LlmContent.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered line

Line 106 is not covered by tests
// skip
}

return files;
}

private static String readResource(ServletContext context, String path) {
try (InputStream is = context.getResourceAsStream(path)) {
if (is == null) {

Check warning on line 115 in src/main/java/io/jenkins/plugins/designlibrary/LlmContent.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 115 is only partially covered, one branch is missing
return null;
}
return new String(is.readAllBytes(), StandardCharsets.UTF_8);

Check warning on line 118 in src/main/java/io/jenkins/plugins/designlibrary/LlmContent.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered line

Line 118 is not covered by tests
} catch (IOException e) {

Check warning on line 119 in src/main/java/io/jenkins/plugins/designlibrary/LlmContent.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 119 is only partially covered, one branch is missing
return null;

Check warning on line 120 in src/main/java/io/jenkins/plugins/designlibrary/LlmContent.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered line

Line 120 is not covered by tests
}
}
}
11 changes: 11 additions & 0 deletions src/main/resources/images/symbols/intellij.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,21 @@
</div>
</s:section>

<s:section title="${%integrate}">
<div class="app-home__side-by-side">
<s:card icon="symbol-intellij plugin-design-library"
title="${%get-plugin}"
description="${%get-plugin-description}"
href="https://plugins.jetbrains.com/plugin/1885-jenkins-development-support"
tag="${%Updated}" />
<s:card icon="symbol-sparkles-outline plugin-ionicons-api"
title="${%use-with-ai}"
description="${%use-with-ai-description}"
href="llms.txt"
tag="${%New}" />
</div>
</s:section>

<s:section title="${%get-involved}">
<div class="app-home__side-by-side">
<s:card icon="symbol-gitter plugin-design-library"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,8 @@ gitter-description=Join the UX discussion on Gitter
ux-youtube=Watch the latest Jenkins UX meetings
ux-wip-core=View in-progress UX work
contribute=Contribute to Jenkins Design Library
integrate=Integrate with your workflow
get-plugin=Get the IntelliJ plugin
get-plugin-description=Jenkins development support for Jelly, Stapler, and Symbols autocompletion.
use-with-ai=Use with AI
use-with-ai-description=Provide structured design guidance to coding assistants, agents, and other LLM tools.
1 change: 0 additions & 1 deletion src/main/resources/scss/pages/_home.scss
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@
display: grid;
grid-template-columns: 1fr 1fr;
grid-gap: var(--jdl-spacing);
overflow: hidden;

@media (max-width: 970px) {
display: flex;
Expand Down
57 changes: 57 additions & 0 deletions src/test/java/LlmContentTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS;

import org.htmlunit.Page;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
import org.jvnet.hudson.test.JenkinsRule;
import org.jvnet.hudson.test.junit.jupiter.WithJenkins;

@WithJenkins
@TestInstance(PER_CLASS)
class LlmContentTest {

private JenkinsRule jenkins;

@BeforeAll
void beforeAll(JenkinsRule jenkins) {
this.jenkins = jenkins;
}

@Test
void llmsTxtContainsIndex() throws Exception {
try (var webClient = jenkins.createWebClient()) {
Page page = webClient.getPage(jenkins.getURL() + "design-library/llms.txt");
String content = page.getWebResponse().getContentAsString();

assertThat(content).startsWith("# Jenkins Design Library");
assertThat(content).contains("## Components");
assertThat(content).contains("## Patterns");
assertThat(content).contains("Buttons");
}
}

@Test
void llmsAllTxtContainsAllComponents() throws Exception {
try (var webClient = jenkins.createWebClient()) {
Page page = webClient.getPage(jenkins.getURL() + "design-library/llms-all.txt");
String content = page.getWebResponse().getContentAsString();

assertThat(content).startsWith("# Jenkins Design Library");
assertThat(content).contains("# Buttons");
assertThat(content).contains("# Cards");
assertThat(content).contains("# Colors");
}
}

@Test
void existingComponentPagesStillWork() throws Exception {
try (var webClient = jenkins.createWebClient().withJavaScriptEnabled(false)) {
webClient.getOptions().setThrowExceptionOnFailingStatusCode(false);
webClient.getOptions().setPrintContentOnFailingStatusCode(false);
Page page = webClient.getPage(jenkins.getURL() + "design-library/buttons");
assertThat(page.getWebResponse().getStatusCode()).isEqualTo(200);
}
}
}
7 changes: 4 additions & 3 deletions src/test/java/ValidRelativeUrlsTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
import static org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS;

import io.jenkins.plugins.designlibrary.UISample;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;
import org.htmlunit.html.DomNode;
Expand All @@ -22,6 +21,8 @@ class ValidRelativeUrlsTest {

private List<UISample> samples;

private final List<String> otherUrls = List.of("llms.txt");

@BeforeAll
void beforeAll(JenkinsRule jenkins) {
this.jenkins = jenkins;
Expand All @@ -39,8 +40,8 @@ void validRelativeUrls(String url) throws Exception {
webClient.getOptions().setPrintContentOnFailingStatusCode(false);
webClient.getOptions().setThrowExceptionOnFailingStatusCode(false);

List<String> validUrls =
new ArrayList<>(samples.stream().map(UISample::getUrlName).toList());
List<String> validUrls = Stream.concat(samples.stream().map(UISample::getUrlName), otherUrls.stream())
.toList();

var page = webClient.goTo(url);
var links = page.querySelectorAll(".jdl-section a");
Expand Down
Loading