diff --git a/docs/modules/ROOT/nav.adoc b/docs/modules/ROOT/nav.adoc
index cca808f6788..47a3ca7d22f 100644
--- a/docs/modules/ROOT/nav.adoc
+++ b/docs/modules/ROOT/nav.adoc
@@ -102,6 +102,7 @@
** xref:extending/meta-build.adoc[]
** xref:extending/example-typescript-support.adoc[]
** xref:extending/example-python-support.adoc[]
+** xref:large/multi-language-builds.adoc[]
// This section focuses on diving into deeper, more advanced topics for Mill.
// These are things that most Mill developers would not encounter day to day,
// but people developing Mill plugins or working on particularly large or
diff --git a/docs/modules/ROOT/pages/large/multi-language-builds.adoc b/docs/modules/ROOT/pages/large/multi-language-builds.adoc
new file mode 100644
index 00000000000..606fe3dc68f
--- /dev/null
+++ b/docs/modules/ROOT/pages/large/multi-language-builds.adoc
@@ -0,0 +1,4 @@
+= Multi-Language Builds
+:page-aliases: Multi_Language_Builds.adoc
+
+include::partial$example/large/multi/14-multi-language.adoc[]
diff --git a/example/large/multi/14-multi-language/build.mill b/example/large/multi/14-multi-language/build.mill
new file mode 100644
index 00000000000..375d51905f8
--- /dev/null
+++ b/example/large/multi/14-multi-language/build.mill
@@ -0,0 +1,76 @@
+package build
+import mill._, javascriptlib._, pythonlib._, javalib._
+
+object client extends ReactScriptsModule
+
+object `sentiment-analysis` extends PythonModule {
+ def mainScript = Task.Source { millSourcePath / "src" / "foo.py" }
+
+ def pythonDeps = Seq("textblob==0.19.0")
+
+ object test extends PythonTests with pythonlib.TestModule.Unittest
+}
+
+object server extends JavaModule {
+ def ivyDeps = Agg(
+ ivy"org.springframework.boot:spring-boot-starter-web:2.5.6",
+ ivy"org.springframework.boot:spring-boot-starter-actuator:2.5.6"
+ )
+
+ /** Bundle client & sentiment-analysis as resource */
+ def resources = Task.Sources {
+ os.copy(client.bundle().path, Task.dest / "static")
+ os.makeDir.all(Task.dest / "analysis")
+ os.copy(`sentiment-analysis`.bundle().path, Task.dest / "analysis" / "analysis.pex")
+ super.resources() ++ Seq(PathRef(Task.dest))
+ }
+
+ object test extends JavaTests with javalib.TestModule.Junit5 {
+ def ivyDeps = super.ivyDeps() ++ Agg(
+ ivy"org.springframework.boot:spring-boot-starter-test:2.5.6"
+ )
+ }
+}
+
+// This example demonstrates a simple multi-langauge project,
+// running a `spring boot webserver` serving a `react client` and interacting with a `python binary`
+// through the web-server api.
+
+/** Usage
+
+> mill client.test
+PASS src/test/App.test.tsx
+...Text Analysis Tool
+...renders the app with initial UI...
+...displays sentiment result...
+...
+Test Suites:...1 passed, 1 total
+Tests:...2 passed, 2 total
+...
+
+> mill sentiment-analysis.test
+...
+test_negative_sentiment... ok
+test_neutral_sentiment... ok
+test_positive_sentiment... ok
+...
+Ran 3 tests...
+...
+OK
+...
+
+> mill server.test
+...com.example.ServerTest#shouldReturnStaticPage() finished...
+...com.example.ServerTest#shouldReturnPositiveAnalysis() finished...
+...com.example.ServerTest#shouldReturnNegativeAnalysis() finished...
+
+> mill server.runBackground
+
+> curl http://localhost:8086
+...
Sentiment Analysis Tool...
+
+> curl -X POST http://localhost:8086/api/analysis -H "Content-Type: text/plain" --data "This is awesome!" # Make request to the analysis api
+Positive sentiment (polarity: 1.0)
+
+> mill clean server.runBackground
+*/
diff --git a/example/large/multi/14-multi-language/client/public/index.html b/example/large/multi/14-multi-language/client/public/index.html
new file mode 100644
index 00000000000..0901c54eb79
--- /dev/null
+++ b/example/large/multi/14-multi-language/client/public/index.html
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ Sentiment Analysis Tool
+
+
+
+
+
+
+
diff --git a/example/large/multi/14-multi-language/client/src/App.css b/example/large/multi/14-multi-language/client/src/App.css
new file mode 100644
index 00000000000..76ea8e4f8cc
--- /dev/null
+++ b/example/large/multi/14-multi-language/client/src/App.css
@@ -0,0 +1,90 @@
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+ font-family: 'Arial', sans-serif;
+}
+
+body {
+ background: #f4f4f4;
+ height: 100vh;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+}
+
+.app-container {
+ background: #fff;
+ border-radius: 12px;
+ padding: 40px;
+ width: 400px;
+ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
+ text-align: center;
+}
+
+h1 {
+ margin-bottom: 20px;
+ font-size: 1.8rem;
+ color: #333;
+}
+
+.analysis-form {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+textarea {
+ width: 100%;
+ height: 120px;
+ padding: 10px;
+ font-size: 1rem;
+ border: 1px solid #ccc;
+ border-radius: 8px;
+ resize: none;
+ outline: none;
+}
+
+textarea:focus {
+ border-color: #007bff;
+}
+
+button {
+ padding: 10px;
+ background: #007bff;
+ color: #fff;
+ border: none;
+ border-radius: 8px;
+ cursor: pointer;
+ font-size: 1rem;
+}
+
+button:hover {
+ background: #0056b3;
+}
+
+button:disabled {
+ background: #b0c4de;
+ cursor: not-allowed;
+}
+
+.result-container {
+ margin-top: 20px;
+ padding: 15px;
+ border-radius: 8px;
+ color: #fff;
+ font-weight: bold;
+}
+
+/* Sentiment-based styles */
+.result-container.positive {
+ background-color: #28a745; /* Green for positive */
+}
+
+.result-container.negative {
+ background-color: #dc3545; /* Red for negative */
+}
+
+.result-container.neutral {
+ background-color: #007bff; /* Blue for neutral */
+}
\ No newline at end of file
diff --git a/example/large/multi/14-multi-language/client/src/app/App.tsx b/example/large/multi/14-multi-language/client/src/app/App.tsx
new file mode 100644
index 00000000000..ece1582abb8
--- /dev/null
+++ b/example/large/multi/14-multi-language/client/src/app/App.tsx
@@ -0,0 +1,76 @@
+import React, {useState} from 'react';
+import 'src/App.css';
+
+const App = () => {
+ const [inputText, setInputText] = useState('');
+ const [result, setResult] = useState('');
+ const [loading, setLoading] = useState(false);
+ const [sentiment, setSentiment] = useState('neutral');
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ setLoading(true);
+ setResult('');
+ setSentiment('neutral');
+
+ try {
+ const response = await fetch('http://localhost:8086/api/analysis', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'text/plain',
+ },
+ body: inputText,
+ });
+
+ if (response.ok) {
+ const responseData = await response.text();
+ setResult(responseData);
+
+ // Determine sentiment from the response
+ const polarityMatch = responseData.match(/polarity: ([+-]?[0-9]*\.?[0-9]+)/);
+ if (polarityMatch) {
+ const polarity = parseFloat(polarityMatch[1]);
+
+ if (polarity > 0) {
+ setSentiment('positive');
+ } else if (polarity < 0) {
+ setSentiment('negative');
+ } else {
+ setSentiment('neutral');
+ }
+ }
+ } else {
+ setResult('Error occurred during analysis.');
+ }
+ } catch (error) {
+ setResult('Network error: Could not connect to the server.');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
Text Analysis Tool
+
+ {result && (
+
+
Analysis Result:
+
{result}
+
+ )}
+
+ );
+};
+
+export default App;
\ No newline at end of file
diff --git a/example/large/multi/14-multi-language/client/src/index.tsx b/example/large/multi/14-multi-language/client/src/index.tsx
new file mode 100644
index 00000000000..7037d531cd0
--- /dev/null
+++ b/example/large/multi/14-multi-language/client/src/index.tsx
@@ -0,0 +1,12 @@
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import App from './app/App';
+
+const root = ReactDOM.createRoot(
+ document.getElementById('root') as HTMLElement
+);
+root.render(
+
+
+
+);
diff --git a/example/large/multi/14-multi-language/client/src/test/App.test.tsx b/example/large/multi/14-multi-language/client/src/test/App.test.tsx
new file mode 100644
index 00000000000..dad90002553
--- /dev/null
+++ b/example/large/multi/14-multi-language/client/src/test/App.test.tsx
@@ -0,0 +1,47 @@
+import React from 'react';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import '@testing-library/jest-dom'; // Import jest-dom matchers
+import App from 'app/App';
+
+// Mock the fetch API
+global.fetch = jest.fn();
+
+describe('Text Analysis Tool', () => {
+ beforeEach(() => {
+ (fetch as jest.Mock).mockClear();
+ });
+
+ test('renders the app with initial UI', () => {
+ render();
+
+ // Check for the page title
+ expect(screen.getByText('Text Analysis Tool')).toBeInTheDocument();
+
+ // Check for the input form
+ expect(screen.getByPlaceholderText('Enter your text here...')).toBeInTheDocument();
+ expect(screen.getByText('Analyze')).toBeInTheDocument();
+ });
+
+ test('displays sentiment result', async () => {
+ // Mock the fetch response for positive sentiment
+ (fetch as jest.Mock).mockResolvedValueOnce({
+ ok: true,
+ text: async () => 'Positive sentiment (polarity: 0.8)',
+ });
+
+ render();
+
+ // Simulate user input and form submission
+ fireEvent.change(screen.getByPlaceholderText('Enter your text here...'), {
+ target: { value: 'This is amazing!' },
+ });
+ fireEvent.click(screen.getByText('Analyze'));
+
+ // Wait for the result to appear
+ await waitFor(() => screen.getByText('Analysis Result:'));
+
+ // Check that the result is displayed
+ expect(screen.getByText('Positive sentiment (polarity: 0.8)')).toBeInTheDocument();
+ expect(screen.getByText('Analysis Result:').parentElement).toHaveClass('positive');
+ });
+});
\ No newline at end of file
diff --git a/example/large/multi/14-multi-language/sentiment-analysis/src/foo.py b/example/large/multi/14-multi-language/sentiment-analysis/src/foo.py
new file mode 100644
index 00000000000..67b3737460b
--- /dev/null
+++ b/example/large/multi/14-multi-language/sentiment-analysis/src/foo.py
@@ -0,0 +1,22 @@
+import sys
+from textblob import TextBlob
+
+def analyze_sentiment(text):
+ blob = TextBlob(text)
+ polarity = blob.sentiment.polarity
+
+ if polarity > 0:
+ return f"Positive sentiment (polarity: {polarity})"
+ elif polarity < 0:
+ return f"Negative sentiment (polarity: {polarity})"
+ else:
+ return "Neutral sentiment (polarity: 0)"
+
+if __name__ == "__main__":
+ if len(sys.argv) < 2:
+ print("Usage: python sentiment.py ")
+ sys.exit(1)
+
+ input_text = " ".join(sys.argv[1:])
+ result = analyze_sentiment(input_text)
+ print(result)
\ No newline at end of file
diff --git a/example/large/multi/14-multi-language/sentiment-analysis/test/src/test.py b/example/large/multi/14-multi-language/sentiment-analysis/test/src/test.py
new file mode 100644
index 00000000000..f625b9bbed1
--- /dev/null
+++ b/example/large/multi/14-multi-language/sentiment-analysis/test/src/test.py
@@ -0,0 +1,23 @@
+import unittest
+from foo import analyze_sentiment
+
+class TestSentimentAnalysis(unittest.TestCase):
+
+ def test_positive_sentiment(self):
+ text = "This is amazing!"
+ result = analyze_sentiment(text)
+ self.assertTrue(result.startswith("Positive sentiment"))
+
+ def test_negative_sentiment(self):
+ text = "This is terrible!"
+ result = analyze_sentiment(text)
+ self.assertTrue(result.startswith("Negative sentiment"))
+
+ def test_neutral_sentiment(self):
+ text = "You suck"
+ result = analyze_sentiment(text)
+ self.assertEqual(result, "Neutral sentiment (polarity: 0)")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/example/large/multi/14-multi-language/server/resources/application.properties b/example/large/multi/14-multi-language/server/resources/application.properties
new file mode 100644
index 00000000000..6ad153e359d
--- /dev/null
+++ b/example/large/multi/14-multi-language/server/resources/application.properties
@@ -0,0 +1 @@
+server.port=8086
\ No newline at end of file
diff --git a/example/large/multi/14-multi-language/server/src/com/example/AnalysisController.java b/example/large/multi/14-multi-language/server/src/com/example/AnalysisController.java
new file mode 100644
index 00000000000..bcc0f929d23
--- /dev/null
+++ b/example/large/multi/14-multi-language/server/src/com/example/AnalysisController.java
@@ -0,0 +1,68 @@
+package com.example;
+
+import java.io.*;
+import java.nio.file.Files;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequestMapping("/api")
+public class AnalysisController {
+
+ @PostMapping("/analysis")
+ public ResponseEntity analyzeText(@RequestBody String text) {
+ try {
+ // Read the binary from resources
+ byte[] analysisBinary = readResourceAsBytes("analysis/analysis.pex");
+ if (analysisBinary == null) {
+ return ResponseEntity.status(500).body("Analysis binary not found");
+ }
+
+ // Write binary to a temporary file
+ File tempBinary = File.createTempFile("analysis", ".pex");
+ tempBinary.deleteOnExit(); // Auto-delete on app exit
+ Files.write(tempBinary.toPath(), analysisBinary);
+ tempBinary.setExecutable(true); // Ensure it's executable
+
+ // Run the Python binary with the input text
+ ProcessBuilder processBuilder = new ProcessBuilder(tempBinary.getAbsolutePath(), text);
+ processBuilder.redirectErrorStream(true);
+ Process process = processBuilder.start();
+
+ // Read output from the Python process
+ StringBuilder output = new StringBuilder();
+ try (BufferedReader reader =
+ new BufferedReader(new InputStreamReader(process.getInputStream()))) {
+ String line;
+ while ((line = reader.readLine()) != null) {
+ output.append(line).append("\n");
+ }
+ }
+
+ // Check the exit code
+ int exitCode = process.waitFor();
+ if (exitCode != 0) {
+ return ResponseEntity.status(500).body("Error running analysis");
+ }
+
+ return ResponseEntity.ok(output.toString());
+
+ } catch (Exception e) {
+ e.printStackTrace();
+ return ResponseEntity.status(500).body("Server error");
+ }
+ }
+
+ private static byte[] readResourceAsBytes(String resourceName) {
+ try (InputStream resourceStream =
+ AnalysisController.class.getClassLoader().getResourceAsStream(resourceName)) {
+ if (resourceStream == null) {
+ return null;
+ }
+ return resourceStream.readAllBytes();
+ } catch (IOException e) {
+ e.printStackTrace();
+ return null;
+ }
+ }
+}
diff --git a/example/large/multi/14-multi-language/server/src/com/example/Server.java b/example/large/multi/14-multi-language/server/src/com/example/Server.java
new file mode 100644
index 00000000000..02f87724adb
--- /dev/null
+++ b/example/large/multi/14-multi-language/server/src/com/example/Server.java
@@ -0,0 +1,12 @@
+package com.example;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class Server {
+
+ public static void main(String[] args) {
+ SpringApplication.run(Server.class, args);
+ }
+}
diff --git a/example/large/multi/14-multi-language/server/src/com/example/StaticPageController.java b/example/large/multi/14-multi-language/server/src/com/example/StaticPageController.java
new file mode 100644
index 00000000000..36eced60349
--- /dev/null
+++ b/example/large/multi/14-multi-language/server/src/com/example/StaticPageController.java
@@ -0,0 +1,52 @@
+package com.example;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Controller;
+import org.springframework.web.bind.annotation.GetMapping;
+
+@Controller
+public class StaticPageController {
+
+ @GetMapping("/")
+ public ResponseEntity serveIndexPage() {
+ String indexHtml = readResource("static/index.html");
+
+ if (indexHtml == null) {
+ return ResponseEntity.status(404).body("Resource not found");
+ }
+
+ return ResponseEntity.ok()
+ .header("Content-Type", "text/html; charset=UTF-8")
+ .body(indexHtml);
+ }
+
+ private static String readResource(String resourceName) {
+ try {
+ return new String(
+ StaticPageController.class
+ .getClassLoader()
+ .getResourceAsStream(resourceName)
+ .readAllBytes(),
+ StandardCharsets.UTF_8);
+ } catch (IOException | NullPointerException e) {
+ e.printStackTrace();
+ return null;
+ }
+ }
+}
+
+// package com.example;
+//
+// import org.springframework.stereotype.Controller;
+// import org.springframework.web.bind.annotation.GetMapping;
+//
+// @Controller
+// public class StaticPageController {
+//
+// @GetMapping("/")
+// public String serveIndexPage() {
+// return "index"; // Will serve static/index.html
+// }
+// }
diff --git a/example/large/multi/14-multi-language/server/test/src/com/example/ServerTest.java b/example/large/multi/14-multi-language/server/test/src/com/example/ServerTest.java
new file mode 100644
index 00000000000..66850d02257
--- /dev/null
+++ b/example/large/multi/14-multi-language/server/test/src/com/example/ServerTest.java
@@ -0,0 +1,44 @@
+package com.example;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.web.client.TestRestTemplate;
+import org.springframework.boot.web.server.LocalServerPort;
+
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+public class ServerTest {
+
+ @LocalServerPort
+ private int port;
+
+ @Autowired
+ private TestRestTemplate restTemplate;
+
+ // Test static page
+ @Test
+ public void shouldReturnStaticPage() {
+ String response = restTemplate.getForObject("http://localhost:" + port + "/", String.class);
+ assertTrue(response.contains("Sentiment Analysis Tool"));
+ }
+
+ // Test positive sentiment analysis
+ @Test
+ public void shouldReturnPositiveAnalysis() {
+ String text = "this is awesome";
+ String response = restTemplate.postForObject(
+ "http://localhost:" + port + "/api/analysis", text, String.class);
+ assertTrue(response.contains("Positive sentiment"));
+ }
+
+ // Test negative sentiment analysis
+ @Test
+ public void shouldReturnNegativeAnalysis() {
+ String text = "this sucks";
+ String response = restTemplate.postForObject(
+ "http://localhost:" + port + "/api/analysis", text, String.class);
+ assertTrue(response.contains("Negative sentiment"));
+ }
+}
diff --git a/example/pythonlib/basic/1-simple/build.mill b/example/pythonlib/basic/1-simple/build.mill
index 4492515b6c6..d6cd6e31930 100644
--- a/example/pythonlib/basic/1-simple/build.mill
+++ b/example/pythonlib/basic/1-simple/build.mill
@@ -11,6 +11,21 @@ object foo extends PythonModule {
}
+// > mill server.test
+//...com.example.ServerTest#shouldReturnStaticPage() finished...
+//...com.example.ServerTest#shouldReturnPositiveAnalysis() finished...
+//...com.example.ServerTest#shouldReturnNegativeAnalysis() finished...
+//
+//> mill server.runBackground
+//
+//> curl http://localhost:8086
+//...Sentiment Analysis Tool...
+//
+//> curl -X POST http://localhost:8086/api/analysis -H "Content-Type: text/plain" --data "This is awesome!" # Make request to the analysis api
+//Positive sentiment (polarity: 1.0)
+//
+//> mill clean server.runBackground
+
// This is a basic Mill build for a single `PythonModule`, with one
// dependency and a test suite using the `Unittest` Library.
//