refactor git provider and site structures
This commit is contained in:
@@ -23,12 +23,21 @@ public class GitConfig {
|
|||||||
|
|
||||||
@PostConstruct
|
@PostConstruct
|
||||||
public void init() throws IOException {
|
public void init() throws IOException {
|
||||||
|
if (reposPath == null) return;
|
||||||
Path path = Paths.get(reposPath);
|
Path path = Paths.get(reposPath);
|
||||||
if (!Files.exists(path)) {
|
if (!Files.exists(path)) {
|
||||||
Files.createDirectories(path);
|
Files.createDirectories(path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void setAuthorName(String authorName) {
|
||||||
|
this.authorName = authorName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAuthorEmail(String authorEmail) {
|
||||||
|
this.authorEmail = authorEmail;
|
||||||
|
}
|
||||||
|
|
||||||
public Path getReposPath() {
|
public Path getReposPath() {
|
||||||
return Paths.get(reposPath);
|
return Paths.get(reposPath);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,17 +34,6 @@ public class SiteStructure {
|
|||||||
private String seoTitle;
|
private String seoTitle;
|
||||||
private String seoDescription;
|
private String seoDescription;
|
||||||
private int orderIndex;
|
private int orderIndex;
|
||||||
private List<ComponentStructure> components;
|
private String rawHtml;
|
||||||
}
|
|
||||||
|
|
||||||
@Data
|
|
||||||
@AllArgsConstructor
|
|
||||||
@Builder
|
|
||||||
public static class ComponentStructure {
|
|
||||||
private String id;
|
|
||||||
private String type;
|
|
||||||
private int orderIndex;
|
|
||||||
private Map<String, Object> config;
|
|
||||||
private Map<String, Object> styles;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.krrishg.service;
|
||||||
|
|
||||||
|
import com.krrishg.model.GitRemote;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
class GitHubPagesProvider implements PagesProvider {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String branchName() {
|
||||||
|
return "gh-pages";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Path contentRoot(Path tempDir) {
|
||||||
|
return tempDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void writeProviderFiles(Path tempDir) throws IOException {
|
||||||
|
Files.writeString(tempDir.resolve(".nojekyll"), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String buildPagesUrl(GitRemote remote) {
|
||||||
|
String url = GitService.normalizeHttpsUrl(remote.getRemoteUrl());
|
||||||
|
String path = url.substring(url.indexOf("github.com") + "github.com".length() + 1);
|
||||||
|
if (path.endsWith(".git")) path = path.substring(0, path.length() - 4);
|
||||||
|
if (path.endsWith("/")) path = path.substring(0, path.length() - 1);
|
||||||
|
String[] parts = path.split("/", 2);
|
||||||
|
if (parts.length == 2) {
|
||||||
|
return "https://" + parts[0] + ".github.io/" + parts[1] + "/";
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package com.krrishg.service;
|
||||||
|
|
||||||
|
import com.krrishg.model.GitRemote;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
class GitLabPagesProvider implements PagesProvider {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String branchName() {
|
||||||
|
return "gl-pages";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Path contentRoot(Path tempDir) {
|
||||||
|
return tempDir.resolve("public");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void writeProviderFiles(Path tempDir) throws IOException {
|
||||||
|
Files.writeString(tempDir.resolve(".gitlab-ci.yml"),
|
||||||
|
"pages:\n" +
|
||||||
|
" stage: deploy\n" +
|
||||||
|
" script:\n" +
|
||||||
|
" - echo \"Deploying GitLab Pages\"\n" +
|
||||||
|
" artifacts:\n" +
|
||||||
|
" paths:\n" +
|
||||||
|
" - public\n" +
|
||||||
|
" rules:\n" +
|
||||||
|
" - if: '$CI_COMMIT_BRANCH == \"gl-pages\"'\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String buildPagesUrl(GitRemote remote) {
|
||||||
|
String url = GitService.normalizeHttpsUrl(remote.getRemoteUrl());
|
||||||
|
String path = url.substring(url.indexOf("gitlab.com") + "gitlab.com".length() + 1);
|
||||||
|
if (path.endsWith(".git")) path = path.substring(0, path.length() - 4);
|
||||||
|
if (path.endsWith("/")) path = path.substring(0, path.length() - 1);
|
||||||
|
String[] parts = path.split("/", 2);
|
||||||
|
if (parts.length == 2) {
|
||||||
|
return "https://" + parts[0] + ".gitlab.io/" + parts[1] + "/";
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import org.springframework.stereotype.Service;
|
|||||||
|
|
||||||
import java.io.ByteArrayOutputStream;
|
import java.io.ByteArrayOutputStream;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
@@ -194,7 +195,7 @@ public class GitService {
|
|||||||
|
|
||||||
public record CommitInfo(String hash, String message, String timestamp, String author) {}
|
public record CommitInfo(String hash, String message, String timestamp, String author) {}
|
||||||
|
|
||||||
private String normalizeHttpsUrl(String url) {
|
static String normalizeHttpsUrl(String url) {
|
||||||
if (url.startsWith("https://")) {
|
if (url.startsWith("https://")) {
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
@@ -218,38 +219,15 @@ public class GitService {
|
|||||||
String authUrl = httpsUrl.replace("https://",
|
String authUrl = httpsUrl.replace("https://",
|
||||||
"https://git:" + remote.getEncryptedToken() + "@");
|
"https://git:" + remote.getEncryptedToken() + "@");
|
||||||
|
|
||||||
boolean isGitLab = remote.getRemoteUrl().contains("gitlab.com");
|
PagesProvider provider = PagesProvider.forRemote(remote);
|
||||||
String branch = isGitLab ? "gl-pages" : "gh-pages";
|
|
||||||
|
|
||||||
Path tempDir = Files.createTempDirectory("indie-pages-" + siteId);
|
Path tempDir = Files.createTempDirectory("indie-pages-" + siteId);
|
||||||
try {
|
try {
|
||||||
Path contentRoot = isGitLab ? tempDir.resolve("public") : tempDir;
|
Path contentRoot = provider.contentRoot(tempDir);
|
||||||
Files.createDirectories(contentRoot);
|
Files.createDirectories(contentRoot);
|
||||||
|
pushPagesBranch(tempDir, authUrl, provider.branchName(), contentRoot, provider, files, "Deploy site");
|
||||||
|
|
||||||
for (Map.Entry<String, byte[]> entry : files.entrySet()) {
|
log.info("Pushed site {} to {} branch {}", siteId, remote.getRemoteUrl(), provider.branchName());
|
||||||
Path filePath = contentRoot.resolve(entry.getKey());
|
return provider.buildPagesUrl(remote);
|
||||||
Files.createDirectories(filePath.getParent());
|
|
||||||
Files.write(filePath, entry.getValue());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isGitLab) {
|
|
||||||
Files.writeString(tempDir.resolve(".gitlab-ci.yml"),
|
|
||||||
"pages:\n" +
|
|
||||||
" stage: deploy\n" +
|
|
||||||
" script:\n" +
|
|
||||||
" - echo \"Deploying GitLab Pages\"\n" +
|
|
||||||
" artifacts:\n" +
|
|
||||||
" paths:\n" +
|
|
||||||
" - public\n" +
|
|
||||||
" rules:\n" +
|
|
||||||
" - if: '$CI_COMMIT_BRANCH == \"gl-pages\"'\n");
|
|
||||||
} else {
|
|
||||||
Files.writeString(tempDir.resolve(".nojekyll"), "");
|
|
||||||
}
|
|
||||||
pushOrphanBranch(tempDir, authUrl, branch);
|
|
||||||
|
|
||||||
log.info("Pushed site {} to {} branch {}", siteId, remote.getRemoteUrl(), branch);
|
|
||||||
return buildPagesUrl(remote);
|
|
||||||
} finally {
|
} finally {
|
||||||
deleteDirectory(tempDir);
|
deleteDirectory(tempDir);
|
||||||
}
|
}
|
||||||
@@ -258,19 +236,82 @@ public class GitService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void pushPagesBranch(Path tempDir, String authUrl, String branch,
|
||||||
|
Path contentRoot, PagesProvider provider, Map<String, byte[]> files,
|
||||||
|
String commitMessage) throws Exception {
|
||||||
|
String authorConfig = "-c user.name='" + gitConfig.getAuthorName()
|
||||||
|
+ "' -c user.email='" + gitConfig.getAuthorEmail() + "'";
|
||||||
|
|
||||||
|
runGit(tempDir, "git init", "git remote add origin " + authUrl);
|
||||||
|
|
||||||
private void pushOrphanBranch(Path tempDir, String authUrl, String branch) throws Exception {
|
if (branchExistsOnRemote(authUrl, branch)) {
|
||||||
String authorConfig = "-c user.name='" + gitConfig.getAuthorName() + "' -c user.email='" + gitConfig.getAuthorEmail() + "'";
|
runGit(tempDir,
|
||||||
runGit(tempDir,
|
"git fetch origin " + branch,
|
||||||
"git init",
|
"git checkout -b " + branch + " origin/" + branch,
|
||||||
"git " + authorConfig + " commit --allow-empty -m 'Initial commit'",
|
"git rm -rf ."
|
||||||
"git push --force " + authUrl + " HEAD:main",
|
);
|
||||||
"git checkout --orphan " + branch,
|
writeSiteFilesTo(contentRoot, files);
|
||||||
"git add -A",
|
provider.writeProviderFiles(tempDir);
|
||||||
"git " + authorConfig + " commit -m 'Deploy site'",
|
runGit(tempDir,
|
||||||
"git push --force " + authUrl + " HEAD:" + branch
|
"git add -A",
|
||||||
);
|
"git " + authorConfig + " commit -m '" + commitMessage + "'",
|
||||||
|
"git fetch origin " + branch,
|
||||||
|
"git merge origin/" + branch + " -X ours",
|
||||||
|
"git push origin " + branch
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
runGit(tempDir, "git checkout --orphan " + branch);
|
||||||
|
writeSiteFilesTo(contentRoot, files);
|
||||||
|
provider.writeProviderFiles(tempDir);
|
||||||
|
runGit(tempDir,
|
||||||
|
"git add -A",
|
||||||
|
"git " + authorConfig + " commit -m '" + commitMessage + "'",
|
||||||
|
"git push origin " + branch
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void writeSiteFilesTo(Path contentRoot, Map<String, byte[]> files) throws IOException {
|
||||||
|
for (Map.Entry<String, byte[]> entry : files.entrySet()) {
|
||||||
|
Path filePath = contentRoot.resolve(entry.getKey());
|
||||||
|
Files.createDirectories(filePath.getParent());
|
||||||
|
Files.write(filePath, entry.getValue());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean branchExistsOnRemote(String authUrl, String branch) throws Exception {
|
||||||
|
ProcessBuilder pb = new ProcessBuilder("sh", "-c",
|
||||||
|
"git ls-remote --exit-code " + authUrl + " " + branch);
|
||||||
|
Process process = pb.start();
|
||||||
|
return process.waitFor() == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, byte[]> createRemovalPage() {
|
||||||
|
String html = """
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Site Removed</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
display: flex; justify-content: center; align-items: center;
|
||||||
|
min-height: 100vh; margin: 0; background: #f5f5f5; color: #333;
|
||||||
|
text-align: center; }
|
||||||
|
h1 { font-size: 2rem; margin-bottom: 0.5rem; }
|
||||||
|
p { color: #666; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div>
|
||||||
|
<h1>This site has been unpublished</h1>
|
||||||
|
<p>The owner has removed this site.</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
""";
|
||||||
|
return Map.of("index.html", html.getBytes(StandardCharsets.UTF_8));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void runGit(Path workDir, String... commands) throws Exception {
|
private void runGit(Path workDir, String... commands) throws Exception {
|
||||||
@@ -287,36 +328,6 @@ public class GitService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private String buildPagesUrl(GitRemote remote) {
|
|
||||||
String url = normalizeHttpsUrl(remote.getRemoteUrl());
|
|
||||||
String owner = "";
|
|
||||||
String repo = "";
|
|
||||||
|
|
||||||
if (url.contains("github.com")) {
|
|
||||||
String path = url.substring(url.indexOf("github.com") + "github.com".length() + 1);
|
|
||||||
if (path.endsWith(".git")) path = path.substring(0, path.length() - 4);
|
|
||||||
if (path.endsWith("/")) path = path.substring(0, path.length() - 1);
|
|
||||||
String[] parts = path.split("/", 2);
|
|
||||||
if (parts.length == 2) {
|
|
||||||
owner = parts[0];
|
|
||||||
repo = parts[1];
|
|
||||||
}
|
|
||||||
return "https://" + owner + ".github.io/" + repo + "/";
|
|
||||||
} else if (url.contains("gitlab.com")) {
|
|
||||||
String path = url.substring(url.indexOf("gitlab.com") + "gitlab.com".length() + 1);
|
|
||||||
if (path.endsWith(".git")) path = path.substring(0, path.length() - 4);
|
|
||||||
if (path.endsWith("/")) path = path.substring(0, path.length() - 1);
|
|
||||||
String[] parts = path.split("/", 2);
|
|
||||||
if (parts.length == 2) {
|
|
||||||
owner = parts[0];
|
|
||||||
repo = parts[1];
|
|
||||||
}
|
|
||||||
return "https://" + owner + ".gitlab.io/" + repo + "/";
|
|
||||||
}
|
|
||||||
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void removePages(String siteId) {
|
public void removePages(String siteId) {
|
||||||
GitRemote remote = gitRemoteRepository.findBySiteId(UUID.fromString(siteId))
|
GitRemote remote = gitRemoteRepository.findBySiteId(UUID.fromString(siteId))
|
||||||
.orElse(null);
|
.orElse(null);
|
||||||
@@ -330,20 +341,21 @@ public class GitService {
|
|||||||
String authUrl = httpsUrl.replace("https://",
|
String authUrl = httpsUrl.replace("https://",
|
||||||
"https://git:" + remote.getEncryptedToken() + "@");
|
"https://git:" + remote.getEncryptedToken() + "@");
|
||||||
|
|
||||||
boolean isGitLab = remote.getRemoteUrl().contains("gitlab.com");
|
PagesProvider provider = PagesProvider.forRemote(remote);
|
||||||
String branch = isGitLab ? "gl-pages" : "gh-pages";
|
String branch = provider.branchName();
|
||||||
|
|
||||||
|
if (!branchExistsOnRemote(authUrl, branch)) {
|
||||||
|
log.info("No {} branch found for site {}, nothing to remove", branch, siteId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
Path tempDir = Files.createTempDirectory("indie-pages-remove-" + siteId);
|
Path tempDir = Files.createTempDirectory("indie-pages-remove-" + siteId);
|
||||||
try {
|
try {
|
||||||
String authorConfig = "-c user.name='" + gitConfig.getAuthorName() + "' -c user.email='" + gitConfig.getAuthorEmail() + "'";
|
Path contentRoot = provider.contentRoot(tempDir);
|
||||||
runGit(tempDir,
|
Files.createDirectories(contentRoot);
|
||||||
"git init",
|
Map<String, byte[]> removalFiles = createRemovalPage();
|
||||||
"git " + authorConfig + " commit --allow-empty -m 'Remove site'",
|
pushPagesBranch(tempDir, authUrl, branch, contentRoot, provider, removalFiles, "Unpublish site");
|
||||||
"git checkout --orphan " + branch,
|
log.info("Unpublished site {} from {} branch {}", siteId, remote.getProvider(), branch);
|
||||||
"git " + authorConfig + " commit --allow-empty -m 'Remove site'",
|
|
||||||
"git push --force " + authUrl + " HEAD:" + branch
|
|
||||||
);
|
|
||||||
log.info("Removed pages for site {} from {} branch {}", siteId, remote.getProvider(), branch);
|
|
||||||
} finally {
|
} finally {
|
||||||
deleteDirectory(tempDir);
|
deleteDirectory(tempDir);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,14 +142,9 @@ public class LLMResponseParser {
|
|||||||
if (pageNode.has("orderIndex")) {
|
if (pageNode.has("orderIndex")) {
|
||||||
builder.orderIndex(pageNode.get("orderIndex").asInt());
|
builder.orderIndex(pageNode.get("orderIndex").asInt());
|
||||||
}
|
}
|
||||||
|
if (pageNode.has("rawHtml")) {
|
||||||
List<SiteStructure.ComponentStructure> components = new ArrayList<>();
|
builder.rawHtml(pageNode.get("rawHtml").asText());
|
||||||
if (pageNode.has("components") && pageNode.get("components").isArray()) {
|
|
||||||
for (JsonNode compNode : pageNode.get("components")) {
|
|
||||||
components.add(parseComponent(compNode));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
builder.components(components);
|
|
||||||
|
|
||||||
SiteStructure.PageStructure page = builder.build();
|
SiteStructure.PageStructure page = builder.build();
|
||||||
if (page.getTitle() == null || page.getSlug() == null) {
|
if (page.getTitle() == null || page.getSlug() == null) {
|
||||||
@@ -158,34 +153,4 @@ public class LLMResponseParser {
|
|||||||
|
|
||||||
return page;
|
return page;
|
||||||
}
|
}
|
||||||
|
|
||||||
private SiteStructure.ComponentStructure parseComponent(JsonNode compNode) {
|
|
||||||
SiteStructure.ComponentStructure.ComponentStructureBuilder builder = SiteStructure.ComponentStructure.builder();
|
|
||||||
|
|
||||||
if (compNode.has("id")) {
|
|
||||||
builder.id(compNode.get("id").asText());
|
|
||||||
} else {
|
|
||||||
builder.id(UUID.randomUUID().toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (compNode.has("type")) {
|
|
||||||
builder.type(compNode.get("type").asText());
|
|
||||||
}
|
|
||||||
if (compNode.has("orderIndex")) {
|
|
||||||
builder.orderIndex(compNode.get("orderIndex").asInt());
|
|
||||||
}
|
|
||||||
if (compNode.has("config")) {
|
|
||||||
builder.config(objectMapper.convertValue(compNode.get("config"), Map.class));
|
|
||||||
}
|
|
||||||
if (compNode.has("styles")) {
|
|
||||||
builder.styles(objectMapper.convertValue(compNode.get("styles"), Map.class));
|
|
||||||
}
|
|
||||||
|
|
||||||
SiteStructure.ComponentStructure component = builder.build();
|
|
||||||
if (component.getType() == null) {
|
|
||||||
throw new IllegalArgumentException("Component must have a type");
|
|
||||||
}
|
|
||||||
|
|
||||||
return component;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
24
backend/src/main/java/com/krrishg/service/PagesProvider.java
Normal file
24
backend/src/main/java/com/krrishg/service/PagesProvider.java
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package com.krrishg.service;
|
||||||
|
|
||||||
|
import com.krrishg.model.GitRemote;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
interface PagesProvider {
|
||||||
|
|
||||||
|
String branchName();
|
||||||
|
|
||||||
|
Path contentRoot(Path tempDir);
|
||||||
|
|
||||||
|
void writeProviderFiles(Path tempDir) throws IOException;
|
||||||
|
|
||||||
|
String buildPagesUrl(GitRemote remote);
|
||||||
|
|
||||||
|
static PagesProvider forRemote(GitRemote remote) {
|
||||||
|
if (remote.getRemoteUrl().contains("gitlab.com")) {
|
||||||
|
return new GitLabPagesProvider();
|
||||||
|
}
|
||||||
|
return new GitHubPagesProvider();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,8 +7,8 @@ import org.slf4j.Logger;
|
|||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.util.*;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.stream.Collectors;
|
import java.util.Map;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class RenderService {
|
public class RenderService {
|
||||||
@@ -58,52 +58,16 @@ public class RenderService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Section> sections = buildSections(page.getComponents());
|
|
||||||
Set<Integer> lastIndices = sections.stream()
|
|
||||||
.map(s -> s.indices.get(s.indices.size() - 1))
|
|
||||||
.collect(Collectors.toSet());
|
|
||||||
|
|
||||||
StringBuilder sectionsHtml = new StringBuilder();
|
|
||||||
for (Section section : sections) {
|
|
||||||
String bgStyle = section.bg != null && !section.bg.isEmpty()
|
|
||||||
? " style=\"background:" + section.bg + "\"" : "";
|
|
||||||
sectionsHtml.append("<section").append(bgStyle).append(">\n <div class=\"container\">\n");
|
|
||||||
for (int idx : section.indices) {
|
|
||||||
SiteStructure.ComponentStructure comp = page.getComponents().get(idx);
|
|
||||||
sectionsHtml.append(" ").append(renderComponent(comp, idx)).append("\n");
|
|
||||||
}
|
|
||||||
sectionsHtml.append(" </div>\n</section>\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
StringBuilder compStyles = new StringBuilder();
|
|
||||||
for (int i = 0; i < page.getComponents().size(); i++) {
|
|
||||||
SiteStructure.ComponentStructure c = page.getComponents().get(i);
|
|
||||||
if (c.getStyles() == null || c.getStyles().isEmpty()) continue;
|
|
||||||
List<Map.Entry<String, Object>> entries = c.getStyles().entrySet().stream()
|
|
||||||
.filter(e -> !"backgroundColor".equals(e.getKey()))
|
|
||||||
.collect(Collectors.toList());
|
|
||||||
if (lastIndices.contains(i)) {
|
|
||||||
entries = entries.stream()
|
|
||||||
.filter(e -> !"marginBottom".equals(e.getKey()))
|
|
||||||
.collect(Collectors.toList());
|
|
||||||
}
|
|
||||||
if (entries.isEmpty()) continue;
|
|
||||||
compStyles.append(".comp-").append(i).append(" {\n");
|
|
||||||
for (Map.Entry<String, Object> e : entries) {
|
|
||||||
compStyles.append(" ").append(cssProperty(e.getKey())).append(": ").append(e.getValue()).append(";\n");
|
|
||||||
}
|
|
||||||
compStyles.append("}\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
String title = page.getSeoTitle() != null && !page.getSeoTitle().isEmpty()
|
String title = page.getSeoTitle() != null && !page.getSeoTitle().isEmpty()
|
||||||
? page.getSeoTitle() : page.getTitle();
|
? page.getSeoTitle() : page.getTitle();
|
||||||
|
String bodyContent = page.getRawHtml() != null ? page.getRawHtml() : "";
|
||||||
|
|
||||||
return "<!DOCTYPE html>\n"
|
return "<!DOCTYPE html>\n"
|
||||||
+ "<html lang=\"en\">\n"
|
+ "<html lang=\"en\">\n"
|
||||||
+ "<head>\n"
|
+ "<head>\n"
|
||||||
+ " <meta charset=\"UTF-8\">\n"
|
+ " <meta charset=\"UTF-8\">\n"
|
||||||
+ " <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n"
|
+ " <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n"
|
||||||
+ " <title>" + esc(title) + "</title>\n"
|
+ " <title>" + title + "</title>\n"
|
||||||
+ " <link rel=\"stylesheet\" href=\"style.css\">\n"
|
+ " <link rel=\"stylesheet\" href=\"style.css\">\n"
|
||||||
+ " <link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Playfair+Display:wght@400;500;600;700&family=Merriweather:wght@300;400;700&family=Lato:wght@300;400;700&family=Open+Sans:wght@300;400;600;700&family=DM+Sans:wght@300;400;500;600;700&family=Plus+Jakarta+Sans:wght@300;400;500;600;700&display=swap\" rel=\"stylesheet\">\n"
|
+ " <link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Playfair+Display:wght@400;500;600;700&family=Merriweather:wght@300;400;700&family=Lato:wght@300;400;700&family=Open+Sans:wght@300;400;600;700&family=DM+Sans:wght@300;400;500;600;700&family=Plus+Jakarta+Sans:wght@300;400;500;600;700&display=swap\" rel=\"stylesheet\">\n"
|
||||||
+ " <style>\n"
|
+ " <style>\n"
|
||||||
@@ -118,200 +82,14 @@ public class RenderService {
|
|||||||
+ " line-height: 1.6;\n"
|
+ " line-height: 1.6;\n"
|
||||||
+ " -webkit-font-smoothing: antialiased;\n"
|
+ " -webkit-font-smoothing: antialiased;\n"
|
||||||
+ " }\n"
|
+ " }\n"
|
||||||
+ " .container { max-width: var(--spacing-containerWidth, 1100px); margin: 0 auto; padding: 0 1.5rem; }\n"
|
|
||||||
+ " section { padding: var(--spacing-sectionPadding, 3rem) 0; }\n"
|
|
||||||
+ compStyles.toString()
|
|
||||||
+ " </style>\n"
|
+ " </style>\n"
|
||||||
+ "</head>\n"
|
+ "</head>\n"
|
||||||
+ "<body>\n"
|
+ "<body>\n"
|
||||||
+ sectionsHtml.toString()
|
+ bodyContent
|
||||||
+ "</body>\n"
|
+ "\n</body>\n"
|
||||||
+ "</html>";
|
+ "</html>";
|
||||||
}
|
}
|
||||||
|
|
||||||
private String renderComponent(SiteStructure.ComponentStructure c, int index) {
|
|
||||||
Map<String, Object> config = c.getConfig() != null ? c.getConfig() : new HashMap<>();
|
|
||||||
String type = (c.getType() != null ? c.getType() : "").toUpperCase();
|
|
||||||
String cls = "comp-" + index;
|
|
||||||
|
|
||||||
return switch (type) {
|
|
||||||
case "HEADING" -> renderHeading(config, cls);
|
|
||||||
case "TEXT" -> renderText(config, cls);
|
|
||||||
case "IMAGE" -> renderImage(config, cls);
|
|
||||||
case "BUTTON" -> renderButton(config, cls);
|
|
||||||
case "DIVIDER" -> renderDivider(config, cls);
|
|
||||||
case "GALLERY" -> renderGallery(config, cls);
|
|
||||||
case "VIDEO" -> renderVideo(config, cls);
|
|
||||||
case "CONTACT_FORM" -> renderContactForm(config, cls);
|
|
||||||
case "MAP" -> renderMap(config, cls);
|
|
||||||
case "CUSTOM_HTML" -> renderCustomHtml(config, cls);
|
|
||||||
default -> "<div class=\"" + cls + "\">[" + esc(type) + "]</div>";
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private String renderHeading(Map<String, Object> config, String cls) {
|
|
||||||
String level = str(config.get("level"), "h2");
|
|
||||||
String text = str(config.get("text"), "Heading");
|
|
||||||
String tag = List.of("h1", "h2", "h3", "h4", "h5", "h6").contains(level) ? level : "h2";
|
|
||||||
return "<" + tag + " class=\"" + cls + "\">" + esc(text) + "</" + tag + ">";
|
|
||||||
}
|
|
||||||
|
|
||||||
private String renderText(Map<String, Object> config, String cls) {
|
|
||||||
String content = str(config.get("content"), "");
|
|
||||||
return "<div class=\"" + cls + "\">" + content + "</div>";
|
|
||||||
}
|
|
||||||
|
|
||||||
private String renderImage(Map<String, Object> config, String cls) {
|
|
||||||
String src = str(config.get("src"), "");
|
|
||||||
String alt = str(config.get("alt"), "");
|
|
||||||
String caption = str(config.get("caption"), "");
|
|
||||||
String linkTo = str(config.get("linkTo"), "");
|
|
||||||
String img = "<img src=\"" + esc(src) + "\" alt=\"" + esc(alt) + "\" class=\"" + cls + "\" style=\"max-width:100%;height:auto\">";
|
|
||||||
String imgHtml = linkTo.isEmpty() ? img : "<a href=\"" + esc(linkTo) + "\">" + img + "</a>";
|
|
||||||
if (!caption.isEmpty()) {
|
|
||||||
return "<figure style=\"margin:0\">" + imgHtml
|
|
||||||
+ "<figcaption style=\"text-align:center;font-size:0.875rem;color:#6b7280;margin-top:0.5rem\">"
|
|
||||||
+ esc(caption) + "</figcaption></figure>";
|
|
||||||
}
|
|
||||||
return imgHtml;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String renderButton(Map<String, Object> config, String cls) {
|
|
||||||
String text = str(config.get("text"), "Button");
|
|
||||||
String link = str(config.get("link"), "#");
|
|
||||||
String variant = str(config.get("variant"), "primary");
|
|
||||||
String btnStyle = "display:inline-block;text-decoration:none;font-weight:500;font-size:1rem;padding:0.75rem 2rem;border-radius:8px;transition:all 0.2s;cursor:pointer;border:2px solid transparent";
|
|
||||||
btnStyle += switch (variant) {
|
|
||||||
case "outline" -> ";background:transparent;color:var(--color-primary,#4f46e5);border-color:var(--color-primary,#4f46e5)";
|
|
||||||
case "secondary" -> ";background:var(--color-secondary,#6366f1);color:#ffffff";
|
|
||||||
default -> ";background:var(--color-primary,#4f46e5);color:#ffffff";
|
|
||||||
};
|
|
||||||
return "<a href=\"" + esc(link) + "\" class=\"" + cls + "\" style=\"" + btnStyle + "\">" + esc(text) + "</a>";
|
|
||||||
}
|
|
||||||
|
|
||||||
private String renderDivider(Map<String, Object> config, String cls) {
|
|
||||||
String style = str(config.get("style"), "solid");
|
|
||||||
String color = str(config.get("color"), "var(--color-text, #d1d5db)");
|
|
||||||
return "<hr class=\"" + cls + "\" style=\"border:none;border-top:1px " + style + " " + color + "\">";
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
private String renderGallery(Map<String, Object> config, String cls) {
|
|
||||||
List<String> images = (List<String>) config.getOrDefault("images", List.of());
|
|
||||||
int columns = Math.min(Math.max(toInt(config.get("columns"), 3), 1), 6);
|
|
||||||
if (images.isEmpty()) {
|
|
||||||
return "<div class=\"" + cls + "\">Gallery (no images)</div>";
|
|
||||||
}
|
|
||||||
StringBuilder sb = new StringBuilder();
|
|
||||||
sb.append("<div class=\"").append(cls).append("\" style=\"display:grid;grid-template-columns:repeat(")
|
|
||||||
.append(columns).append(",1fr);gap:1rem\">\n");
|
|
||||||
for (String src : images) {
|
|
||||||
sb.append(" <img src=\"").append(esc(src))
|
|
||||||
.append("\" style=\"width:100%;height:250px;object-fit:cover;border-radius:8px\">\n");
|
|
||||||
}
|
|
||||||
sb.append(" </div>");
|
|
||||||
return sb.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private String renderVideo(Map<String, Object> config, String cls) {
|
|
||||||
String src = str(config.get("src"), "");
|
|
||||||
boolean controls = config.get("controls") != Boolean.FALSE;
|
|
||||||
boolean autoplay = config.get("autoplay") == Boolean.TRUE;
|
|
||||||
StringBuilder sb = new StringBuilder();
|
|
||||||
sb.append("<video src=\"").append(esc(src)).append("\" class=\"").append(cls)
|
|
||||||
.append("\"");
|
|
||||||
if (controls) sb.append(" controls");
|
|
||||||
if (autoplay) sb.append(" autoplay");
|
|
||||||
sb.append(" muted style=\"max-width:100%\"></video>");
|
|
||||||
return sb.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
private String renderContactForm(Map<String, Object> config, String cls) {
|
|
||||||
List<String> fields = (List<String>) config.getOrDefault("fields", List.of("name", "email", "message"));
|
|
||||||
String submitText = str(config.get("submitText"), "Send");
|
|
||||||
StringBuilder sb = new StringBuilder();
|
|
||||||
sb.append("<form class=\"").append(cls).append("\">\n");
|
|
||||||
for (String field : fields) {
|
|
||||||
sb.append(" <div style=\"margin-bottom:1rem\">\n");
|
|
||||||
sb.append(" <label style=\"display:block;font-size:0.875rem;font-weight:600;margin-bottom:0.375rem;color:var(--color-text,#374151)\">")
|
|
||||||
.append(esc(capitalize(field))).append("</label>\n");
|
|
||||||
if ("message".equals(field)) {
|
|
||||||
sb.append(" <textarea rows=\"4\" style=\"width:100%;padding:0.75rem;border:1px solid #d1d5db;border-radius:8px;font-family:inherit;font-size:0.95rem;transition:border-color 0.2s;outline:none\" onfocus=\"this.style.borderColor='var(--color-primary,#4f46e5)'\" onblur=\"this.style.borderColor='#d1d5db'\"></textarea>\n");
|
|
||||||
} else {
|
|
||||||
String inputType = "email".equals(field) ? "email" : "text";
|
|
||||||
sb.append(" <input type=\"").append(inputType)
|
|
||||||
.append("\" style=\"width:100%;padding:0.75rem;border:1px solid #d1d5db;border-radius:8px;font-family:inherit;font-size:0.95rem;transition:border-color 0.2s;outline:none\" onfocus=\"this.style.borderColor='var(--color-primary,#4f46e5)'\" onblur=\"this.style.borderColor='#d1d5db'\">\n");
|
|
||||||
}
|
|
||||||
sb.append(" </div>\n");
|
|
||||||
}
|
|
||||||
sb.append(" <button type=\"submit\" style=\"padding:0.75rem 2rem;background:var(--color-primary,#4f46e5);color:#fff;border:none;border-radius:8px;cursor:pointer;font-size:1rem;font-weight:500;transition:opacity 0.2s\" onmouseover=\"this.style.opacity='0.9'\" onmouseout=\"this.style.opacity='1'\">")
|
|
||||||
.append(esc(submitText)).append("</button>\n");
|
|
||||||
sb.append(" </form>");
|
|
||||||
return sb.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private String renderMap(Map<String, Object> config, String cls) {
|
|
||||||
String address = str(config.get("address"), "New York, NY");
|
|
||||||
return "<div class=\"" + cls + "\" style=\"display:flex;align-items:center;justify-content:center;background:#f3f4f6\">\n"
|
|
||||||
+ " <span style=\"color:#9ca3af\">Map: " + esc(address) + "</span>\n"
|
|
||||||
+ " </div>";
|
|
||||||
}
|
|
||||||
|
|
||||||
private String renderCustomHtml(Map<String, Object> config, String cls) {
|
|
||||||
String html = str(config.get("html"), "");
|
|
||||||
return "<div class=\"" + cls + "\">" + html + "</div>";
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<Section> buildSections(List<SiteStructure.ComponentStructure> components) {
|
|
||||||
List<Section> sections = new ArrayList<>();
|
|
||||||
if (components == null) return sections;
|
|
||||||
for (int i = 0; i < components.size(); i++) {
|
|
||||||
SiteStructure.ComponentStructure c = components.get(i);
|
|
||||||
String bg = "";
|
|
||||||
if (c.getStyles() != null && c.getStyles().get("backgroundColor") != null) {
|
|
||||||
bg = String.valueOf(c.getStyles().get("backgroundColor"));
|
|
||||||
}
|
|
||||||
if ((bg != null && !bg.isEmpty()) || i == 0) {
|
|
||||||
sections.add(new Section(bg, new ArrayList<>()));
|
|
||||||
}
|
|
||||||
sections.get(sections.size() - 1).indices.add(i);
|
|
||||||
}
|
|
||||||
return sections;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String cssProperty(String key) {
|
|
||||||
return key.replaceAll("([a-z])([A-Z])", "$1-$2").toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
private String esc(String s) {
|
|
||||||
if (s == null) return "";
|
|
||||||
return s.replace("&", "&")
|
|
||||||
.replace("<", "<")
|
|
||||||
.replace(">", ">")
|
|
||||||
.replace("\"", """)
|
|
||||||
.replace("'", "'");
|
|
||||||
}
|
|
||||||
|
|
||||||
private String capitalize(String s) {
|
|
||||||
if (s == null || s.isEmpty()) return s;
|
|
||||||
return Character.toUpperCase(s.charAt(0)) + s.substring(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
private String str(Object value, String defaultValue) {
|
|
||||||
return value != null ? String.valueOf(value) : defaultValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
private int toInt(Object value, int defaultValue) {
|
|
||||||
if (value instanceof Number n) return n.intValue();
|
|
||||||
if (value instanceof String s) {
|
|
||||||
try { return Integer.parseInt(s); } catch (NumberFormatException e) { return defaultValue; }
|
|
||||||
}
|
|
||||||
return defaultValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
private record Section(String bg, List<Integer> indices) {}
|
|
||||||
|
|
||||||
public record SiteOutput(String css, String js, Map<String, String> pages) {
|
public record SiteOutput(String css, String js, Map<String, String> pages) {
|
||||||
public Map<String, byte[]> asFileMap() {
|
public Map<String, byte[]> asFileMap() {
|
||||||
Map<String, byte[]> files = new LinkedHashMap<>();
|
Map<String, byte[]> files = new LinkedHashMap<>();
|
||||||
@@ -321,7 +99,7 @@ public class RenderService {
|
|||||||
for (Map.Entry<String, String> entry : pages.entrySet()) {
|
for (Map.Entry<String, String> entry : pages.entrySet()) {
|
||||||
String slug = entry.getKey();
|
String slug = entry.getKey();
|
||||||
String html = entry.getValue();
|
String html = entry.getValue();
|
||||||
if (slug == null || slug.isEmpty() || "home".equals(slug)) {
|
if (slug == null || slug.isEmpty() || "home".equals(slug) || "index".equals(slug)) {
|
||||||
files.put("index.html", html.getBytes());
|
files.put("index.html", html.getBytes());
|
||||||
} else {
|
} else {
|
||||||
files.put(slug + "/index.html", html.getBytes());
|
files.put(slug + "/index.html", html.getBytes());
|
||||||
|
|||||||
@@ -72,9 +72,7 @@ class LLMControllerTest {
|
|||||||
.pages(List.of(
|
.pages(List.of(
|
||||||
SiteStructure.PageStructure.builder()
|
SiteStructure.PageStructure.builder()
|
||||||
.title("Home").slug("home")
|
.title("Home").slug("home")
|
||||||
.components(List.of(
|
.rawHtml("<h1>Home</h1>")
|
||||||
SiteStructure.ComponentStructure.builder().type("hero").build()
|
|
||||||
))
|
|
||||||
.build()
|
.build()
|
||||||
))
|
))
|
||||||
.build();
|
.build();
|
||||||
@@ -101,9 +99,7 @@ class LLMControllerTest {
|
|||||||
.pages(List.of(
|
.pages(List.of(
|
||||||
SiteStructure.PageStructure.builder()
|
SiteStructure.PageStructure.builder()
|
||||||
.title("Home").slug("home")
|
.title("Home").slug("home")
|
||||||
.components(List.of(
|
.rawHtml("<h1>Home</h1>")
|
||||||
SiteStructure.ComponentStructure.builder().type("hero").build()
|
|
||||||
))
|
|
||||||
.build()
|
.build()
|
||||||
))
|
))
|
||||||
.build();
|
.build();
|
||||||
@@ -215,9 +211,7 @@ class LLMControllerTest {
|
|||||||
.pages(List.of(
|
.pages(List.of(
|
||||||
SiteStructure.PageStructure.builder()
|
SiteStructure.PageStructure.builder()
|
||||||
.title("Old").slug("old")
|
.title("Old").slug("old")
|
||||||
.components(List.of(
|
.rawHtml("<p>Old</p>")
|
||||||
SiteStructure.ComponentStructure.builder().type("text").build()
|
|
||||||
))
|
|
||||||
.build()
|
.build()
|
||||||
))
|
))
|
||||||
.build();
|
.build();
|
||||||
@@ -225,9 +219,7 @@ class LLMControllerTest {
|
|||||||
.pages(List.of(
|
.pages(List.of(
|
||||||
SiteStructure.PageStructure.builder()
|
SiteStructure.PageStructure.builder()
|
||||||
.title("Refined").slug("refined")
|
.title("Refined").slug("refined")
|
||||||
.components(List.of(
|
.rawHtml("<h1>Refined</h1>")
|
||||||
SiteStructure.ComponentStructure.builder().type("hero").build()
|
|
||||||
))
|
|
||||||
.build()
|
.build()
|
||||||
))
|
))
|
||||||
.build();
|
.build();
|
||||||
@@ -354,9 +346,7 @@ class LLMControllerTest {
|
|||||||
.pages(List.of(
|
.pages(List.of(
|
||||||
SiteStructure.PageStructure.builder()
|
SiteStructure.PageStructure.builder()
|
||||||
.title("Restored").slug("restored")
|
.title("Restored").slug("restored")
|
||||||
.components(List.of(
|
.rawHtml("<h1>Restored</h1>")
|
||||||
SiteStructure.ComponentStructure.builder().type("hero").build()
|
|
||||||
))
|
|
||||||
.build()
|
.build()
|
||||||
))
|
))
|
||||||
.build();
|
.build();
|
||||||
|
|||||||
364
backend/src/test/java/com/krrishg/service/GitServiceTest.java
Normal file
364
backend/src/test/java/com/krrishg/service/GitServiceTest.java
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
package com.krrishg.service;
|
||||||
|
|
||||||
|
import com.krrishg.config.GitConfig;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class GitServiceTest {
|
||||||
|
|
||||||
|
@TempDir
|
||||||
|
Path tempDir;
|
||||||
|
|
||||||
|
private Path remoteDir;
|
||||||
|
private GitService gitService;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() throws Exception {
|
||||||
|
remoteDir = tempDir.resolve("remote");
|
||||||
|
Files.createDirectories(remoteDir);
|
||||||
|
|
||||||
|
GitConfig config = new GitConfig();
|
||||||
|
config.setAuthorName("Test Author");
|
||||||
|
config.setAuthorEmail("test@example.com");
|
||||||
|
gitService = new GitService(config, null, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void initialPublishCreatesBranchOnRemote() throws Exception {
|
||||||
|
initRemote();
|
||||||
|
Path workDir = tempDir.resolve("work");
|
||||||
|
Files.createDirectories(workDir);
|
||||||
|
Path contentRoot = workDir;
|
||||||
|
|
||||||
|
Map<String, byte[]> files = new HashMap<>();
|
||||||
|
files.put("index.html", "<h1>Hello</h1>".getBytes());
|
||||||
|
files.put("style.css", "body { color: red; }".getBytes());
|
||||||
|
|
||||||
|
gitService.pushPagesBranch(workDir, "file://" + remoteDir.toString(), "gh-pages",
|
||||||
|
contentRoot, new GitHubPagesProvider(), files, "Deploy site");
|
||||||
|
|
||||||
|
assertBranchExists("gh-pages");
|
||||||
|
assertRemoteFileContent("gh-pages", "index.html", "<h1>Hello</h1>");
|
||||||
|
assertRemoteFileContent("gh-pages", "style.css", "body { color: red; }");
|
||||||
|
assertRemoteHasNojekyll("gh-pages");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void initialPublishForGitLabCreatesPublicDirectory() throws Exception {
|
||||||
|
initRemote();
|
||||||
|
Path workDir = tempDir.resolve("work");
|
||||||
|
Files.createDirectories(workDir);
|
||||||
|
Path contentRoot = workDir.resolve("public");
|
||||||
|
Files.createDirectories(contentRoot);
|
||||||
|
|
||||||
|
Map<String, byte[]> files = new HashMap<>();
|
||||||
|
files.put("index.html", "<h1>GitLab</h1>".getBytes());
|
||||||
|
|
||||||
|
gitService.pushPagesBranch(workDir, "file://" + remoteDir.toString(), "gl-pages",
|
||||||
|
contentRoot, new GitLabPagesProvider(), files, "Deploy site");
|
||||||
|
|
||||||
|
assertBranchExists("gl-pages");
|
||||||
|
assertRemoteFileContent("gl-pages", "public/index.html", "<h1>GitLab</h1>");
|
||||||
|
assertRemoteHasGitlabCi("gl-pages");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void secondPublishPreservesHistory() throws Exception {
|
||||||
|
initRemote();
|
||||||
|
Path workDir = tempDir.resolve("work");
|
||||||
|
Files.createDirectories(workDir);
|
||||||
|
Path contentRoot = workDir;
|
||||||
|
|
||||||
|
Map<String, byte[]> firstFiles = new HashMap<>();
|
||||||
|
firstFiles.put("index.html", "<h1>First</h1>".getBytes());
|
||||||
|
|
||||||
|
gitService.pushPagesBranch(workDir, "file://" + remoteDir.toString(), "gh-pages",
|
||||||
|
contentRoot, new GitHubPagesProvider(), firstFiles, "Deploy site");
|
||||||
|
|
||||||
|
Path workDir2 = tempDir.resolve("work2");
|
||||||
|
Files.createDirectories(workDir2);
|
||||||
|
Path contentRoot2 = workDir2;
|
||||||
|
|
||||||
|
Map<String, byte[]> secondFiles = new HashMap<>();
|
||||||
|
secondFiles.put("index.html", "<h1>Second</h1>".getBytes());
|
||||||
|
|
||||||
|
gitService.pushPagesBranch(workDir2, "file://" + remoteDir.toString(), "gh-pages",
|
||||||
|
contentRoot2, new GitHubPagesProvider(), secondFiles, "Deploy site");
|
||||||
|
|
||||||
|
assertRemoteFileContent("gh-pages", "index.html", "<h1>Second</h1>");
|
||||||
|
|
||||||
|
int commitCount = countCommits("gh-pages");
|
||||||
|
assertEquals(2, commitCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void divergenceUsesLocalContentOnConflict() throws Exception {
|
||||||
|
initRemote();
|
||||||
|
Path workDir = tempDir.resolve("work");
|
||||||
|
Files.createDirectories(workDir);
|
||||||
|
Path contentRoot = workDir;
|
||||||
|
|
||||||
|
Map<String, byte[]> initialFiles = new HashMap<>();
|
||||||
|
initialFiles.put("index.html", "<h1>Original</h1>".getBytes());
|
||||||
|
gitService.pushPagesBranch(workDir, "file://" + remoteDir.toString(), "gh-pages",
|
||||||
|
contentRoot, new GitHubPagesProvider(), initialFiles, "Deploy site");
|
||||||
|
|
||||||
|
addFileToRemote("gh-pages", "index.html", "<h1>Remote change</h1>");
|
||||||
|
addFileToRemote("gh-pages", "remote-only.html", "<p>Remote only</p>");
|
||||||
|
|
||||||
|
Path workDir2 = tempDir.resolve("work2");
|
||||||
|
Files.createDirectories(workDir2);
|
||||||
|
Path contentRoot2 = workDir2;
|
||||||
|
|
||||||
|
Map<String, byte[]> localFiles = new HashMap<>();
|
||||||
|
localFiles.put("index.html", "<h1>Local new version</h1>".getBytes());
|
||||||
|
localFiles.put("local-only.html", "<p>Local only</p>".getBytes());
|
||||||
|
|
||||||
|
gitService.pushPagesBranch(workDir2, "file://" + remoteDir.toString(), "gh-pages",
|
||||||
|
contentRoot2, new GitHubPagesProvider(), localFiles, "Deploy site");
|
||||||
|
|
||||||
|
assertRemoteFileContent("gh-pages", "index.html", "<h1>Local new version</h1>");
|
||||||
|
assertRemoteFileContent("gh-pages", "local-only.html", "<p>Local only</p>");
|
||||||
|
int commitCount = countCommits("gh-pages");
|
||||||
|
assertEquals(4, commitCount, "Should preserve history from both sides");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void publishWithoutNojekyllForGitHub() throws Exception {
|
||||||
|
initRemote();
|
||||||
|
Path workDir = tempDir.resolve("work");
|
||||||
|
Files.createDirectories(workDir);
|
||||||
|
Path contentRoot = workDir;
|
||||||
|
|
||||||
|
Map<String, byte[]> files = new HashMap<>();
|
||||||
|
files.put("index.html", "<h1>Test</h1>".getBytes());
|
||||||
|
|
||||||
|
gitService.pushPagesBranch(workDir, "file://" + remoteDir.toString(), "gh-pages",
|
||||||
|
contentRoot, new GitHubPagesProvider(), files, "Deploy site");
|
||||||
|
|
||||||
|
assertRemoteFileContent("gh-pages", "index.html", "<h1>Test</h1>");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unpublishReplacesContentWithRemovalPage() throws Exception {
|
||||||
|
initRemote();
|
||||||
|
Path workDir = tempDir.resolve("work");
|
||||||
|
Files.createDirectories(workDir);
|
||||||
|
Path contentRoot = workDir;
|
||||||
|
|
||||||
|
Map<String, byte[]> siteFiles = new HashMap<>();
|
||||||
|
siteFiles.put("index.html", "<h1>My Site</h1>".getBytes());
|
||||||
|
siteFiles.put("about.html", "<h1>About</h1>".getBytes());
|
||||||
|
gitService.pushPagesBranch(workDir, "file://" + remoteDir.toString(), "gh-pages",
|
||||||
|
contentRoot, new GitHubPagesProvider(), siteFiles, "Deploy site");
|
||||||
|
|
||||||
|
Path workDir2 = tempDir.resolve("work2");
|
||||||
|
Files.createDirectories(workDir2);
|
||||||
|
Path contentRoot2 = workDir2;
|
||||||
|
|
||||||
|
Map<String, byte[]> removalFiles = new HashMap<>();
|
||||||
|
removalFiles.put("index.html", "<!DOCTYPE html>".getBytes());
|
||||||
|
gitService.pushPagesBranch(workDir2, "file://" + remoteDir.toString(), "gh-pages",
|
||||||
|
contentRoot2, new GitHubPagesProvider(), removalFiles, "Unpublish site");
|
||||||
|
|
||||||
|
assertRemoteFileContentStart("gh-pages", "index.html", "<!DOCTYPE html>");
|
||||||
|
assertRemoteFileMissing("gh-pages", "about.html");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unpublishPreservesHistory() throws Exception {
|
||||||
|
initRemote();
|
||||||
|
Path workDir = tempDir.resolve("work");
|
||||||
|
Files.createDirectories(workDir);
|
||||||
|
Path contentRoot = workDir;
|
||||||
|
|
||||||
|
Map<String, byte[]> siteFiles = new HashMap<>();
|
||||||
|
siteFiles.put("index.html", "<h1>My Site</h1>".getBytes());
|
||||||
|
gitService.pushPagesBranch(workDir, "file://" + remoteDir.toString(), "gh-pages",
|
||||||
|
contentRoot, new GitHubPagesProvider(), siteFiles, "Deploy site");
|
||||||
|
|
||||||
|
Path workDir2 = tempDir.resolve("work2");
|
||||||
|
Files.createDirectories(workDir2);
|
||||||
|
Path contentRoot2 = workDir2;
|
||||||
|
|
||||||
|
Map<String, byte[]> removalFiles = new HashMap<>();
|
||||||
|
removalFiles.put("index.html", "<!DOCTYPE html>".getBytes());
|
||||||
|
gitService.pushPagesBranch(workDir2, "file://" + remoteDir.toString(), "gh-pages",
|
||||||
|
contentRoot2, new GitHubPagesProvider(), removalFiles, "Unpublish site");
|
||||||
|
|
||||||
|
int commitCount = countCommits("gh-pages");
|
||||||
|
assertEquals(2, commitCount, "Unpublish should add a commit, not replace history");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unpublishForGitLabUsesPublicDirectory() throws Exception {
|
||||||
|
initRemote();
|
||||||
|
Path workDir = tempDir.resolve("work");
|
||||||
|
Files.createDirectories(workDir);
|
||||||
|
Path contentRoot = workDir.resolve("public");
|
||||||
|
Files.createDirectories(contentRoot);
|
||||||
|
|
||||||
|
Map<String, byte[]> siteFiles = new HashMap<>();
|
||||||
|
siteFiles.put("index.html", "<h1>GitLab Site</h1>".getBytes());
|
||||||
|
gitService.pushPagesBranch(workDir, "file://" + remoteDir.toString(), "gl-pages",
|
||||||
|
contentRoot, new GitLabPagesProvider(), siteFiles, "Deploy site");
|
||||||
|
|
||||||
|
Path workDir2 = tempDir.resolve("work2");
|
||||||
|
Files.createDirectories(workDir2);
|
||||||
|
Path contentRoot2 = workDir2.resolve("public");
|
||||||
|
Files.createDirectories(contentRoot2);
|
||||||
|
|
||||||
|
Map<String, byte[]> removalFiles = new HashMap<>();
|
||||||
|
removalFiles.put("index.html", "<!DOCTYPE html>".getBytes());
|
||||||
|
gitService.pushPagesBranch(workDir2, "file://" + remoteDir.toString(), "gl-pages",
|
||||||
|
contentRoot2, new GitLabPagesProvider(), removalFiles, "Unpublish site");
|
||||||
|
|
||||||
|
assertRemoteFileContentStart("gl-pages", "public/index.html", "<!DOCTYPE html>");
|
||||||
|
assertRemoteHasGitlabCi("gl-pages");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assertRemoteFileContentStart(String branch, String path, String expectedPrefix) throws Exception {
|
||||||
|
Path cloneDir = tempDir.resolve("prefix-" + path.hashCode());
|
||||||
|
Files.createDirectories(cloneDir);
|
||||||
|
runGit(cloneDir,
|
||||||
|
"git init",
|
||||||
|
"git remote add origin file://" + remoteDir.toString(),
|
||||||
|
"git fetch origin " + branch,
|
||||||
|
"git checkout -b " + branch + " origin/" + branch
|
||||||
|
);
|
||||||
|
Path filePath = cloneDir.resolve(path);
|
||||||
|
assertTrue(Files.exists(filePath), "File " + path + " should exist in " + branch);
|
||||||
|
String actual = Files.readString(filePath);
|
||||||
|
assertTrue(actual.startsWith(expectedPrefix),
|
||||||
|
"Expected content starting with \"" + expectedPrefix + "\" but got: " + actual);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assertRemoteFileMissing(String branch, String path) throws Exception {
|
||||||
|
Path cloneDir = tempDir.resolve("missing-" + path.hashCode());
|
||||||
|
Files.createDirectories(cloneDir);
|
||||||
|
runGit(cloneDir,
|
||||||
|
"git init",
|
||||||
|
"git remote add origin file://" + remoteDir.toString(),
|
||||||
|
"git fetch origin " + branch,
|
||||||
|
"git checkout -b " + branch + " origin/" + branch
|
||||||
|
);
|
||||||
|
Path filePath = cloneDir.resolve(path);
|
||||||
|
assertFalse(Files.exists(filePath), "File " + path + " should NOT exist in " + branch);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void initRemote() throws Exception {
|
||||||
|
runGit(remoteDir, "git init --bare");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assertBranchExists(String branch) throws Exception {
|
||||||
|
ProcessBuilder pb = new ProcessBuilder("sh", "-c",
|
||||||
|
"git ls-remote --exit-code file://" + remoteDir.toString() + " " + branch);
|
||||||
|
pb.directory(tempDir.toFile());
|
||||||
|
assertEquals(0, pb.start().waitFor(), "Branch " + branch + " should exist on remote");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assertRemoteFileContent(String branch, String path, String expectedContent) throws Exception {
|
||||||
|
Path cloneDir = tempDir.resolve("assert-" + path.hashCode());
|
||||||
|
Files.createDirectories(cloneDir);
|
||||||
|
runGit(cloneDir,
|
||||||
|
"git init",
|
||||||
|
"git remote add origin file://" + remoteDir.toString(),
|
||||||
|
"git fetch origin " + branch,
|
||||||
|
"git checkout -b " + branch + " origin/" + branch
|
||||||
|
);
|
||||||
|
Path filePath = cloneDir.resolve(path);
|
||||||
|
assertTrue(Files.exists(filePath), "File " + path + " should exist in " + branch);
|
||||||
|
String actual = Files.readString(filePath);
|
||||||
|
assertEquals(expectedContent, actual);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assertRemoteHasNojekyll(String branch) throws Exception {
|
||||||
|
try {
|
||||||
|
assertRemoteFileContent(branch, ".nojekyll", "");
|
||||||
|
} catch (AssertionError e) {
|
||||||
|
Path cloneDir = tempDir.resolve("nojekyll-check");
|
||||||
|
Files.createDirectories(cloneDir);
|
||||||
|
runGit(cloneDir,
|
||||||
|
"git init",
|
||||||
|
"git remote add origin file://" + remoteDir.toString(),
|
||||||
|
"git fetch origin " + branch,
|
||||||
|
"git checkout -b " + branch + " origin/" + branch
|
||||||
|
);
|
||||||
|
assertTrue(Files.exists(cloneDir.resolve(".nojekyll")),
|
||||||
|
".nojekyll file should exist on " + branch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assertRemoteHasGitlabCi(String branch) throws Exception {
|
||||||
|
assertRemoteFileContent(branch, ".gitlab-ci.yml",
|
||||||
|
"pages:\n" +
|
||||||
|
" stage: deploy\n" +
|
||||||
|
" script:\n" +
|
||||||
|
" - echo \"Deploying GitLab Pages\"\n" +
|
||||||
|
" artifacts:\n" +
|
||||||
|
" paths:\n" +
|
||||||
|
" - public\n" +
|
||||||
|
" rules:\n" +
|
||||||
|
" - if: '$CI_COMMIT_BRANCH == \"gl-pages\"'\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addFileToRemote(String branch, String path, String content) throws Exception {
|
||||||
|
Path cloneDir = tempDir.resolve("remote-add-" + path.hashCode());
|
||||||
|
Files.createDirectories(cloneDir);
|
||||||
|
runGit(cloneDir,
|
||||||
|
"git init",
|
||||||
|
"git remote add origin file://" + remoteDir.toString(),
|
||||||
|
"git fetch origin " + branch,
|
||||||
|
"git checkout -b " + branch + " origin/" + branch
|
||||||
|
);
|
||||||
|
Path filePath = cloneDir.resolve(path);
|
||||||
|
Files.createDirectories(filePath.getParent());
|
||||||
|
Files.writeString(filePath, content);
|
||||||
|
runGit(cloneDir,
|
||||||
|
"git add -A",
|
||||||
|
"git -c user.name='Remote' -c user.email='remote@example.com' commit -m 'Remote change: " + path + "'",
|
||||||
|
"git push origin " + branch
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int countCommits(String branch) throws Exception {
|
||||||
|
Path cloneDir = tempDir.resolve("count-commits");
|
||||||
|
Files.createDirectories(cloneDir);
|
||||||
|
runGit(cloneDir,
|
||||||
|
"git init",
|
||||||
|
"git remote add origin file://" + remoteDir.toString(),
|
||||||
|
"git fetch origin " + branch
|
||||||
|
);
|
||||||
|
ProcessBuilder pb = new ProcessBuilder("sh", "-c",
|
||||||
|
"git rev-list --count origin/" + branch);
|
||||||
|
pb.directory(cloneDir.toFile());
|
||||||
|
pb.redirectErrorStream(true);
|
||||||
|
Process process = pb.start();
|
||||||
|
String output = new String(process.getInputStream().readAllBytes()).trim();
|
||||||
|
int exitCode = process.waitFor();
|
||||||
|
if (exitCode != 0) return 0;
|
||||||
|
return Integer.parseInt(output);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void runGit(Path workDir, String... commands) throws Exception {
|
||||||
|
for (String cmd : commands) {
|
||||||
|
ProcessBuilder pb = new ProcessBuilder("sh", "-c", cmd);
|
||||||
|
pb.directory(workDir.toFile());
|
||||||
|
pb.redirectErrorStream(true);
|
||||||
|
Process process = pb.start();
|
||||||
|
int exitCode = process.waitFor();
|
||||||
|
if (exitCode != 0) {
|
||||||
|
String output = new String(process.getInputStream().readAllBytes());
|
||||||
|
throw new RuntimeException("git command failed (exit " + exitCode + "): " + cmd + "\n" + output);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -95,9 +95,7 @@ class LLMResponseParserTest {
|
|||||||
{
|
{
|
||||||
"title": "Home",
|
"title": "Home",
|
||||||
"slug": "home",
|
"slug": "home",
|
||||||
"components": [
|
"rawHtml": "<h1>Welcome</h1>"
|
||||||
{"type": "hero", "orderIndex": 0, "config": {"title": "Welcome"}}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -111,8 +109,7 @@ class LLMResponseParserTest {
|
|||||||
assertEquals(1, structure.getPages().size());
|
assertEquals(1, structure.getPages().size());
|
||||||
assertEquals("Home", structure.getPages().get(0).getTitle());
|
assertEquals("Home", structure.getPages().get(0).getTitle());
|
||||||
assertEquals("home", structure.getPages().get(0).getSlug());
|
assertEquals("home", structure.getPages().get(0).getSlug());
|
||||||
assertEquals(1, structure.getPages().get(0).getComponents().size());
|
assertEquals("<h1>Welcome</h1>", structure.getPages().get(0).getRawHtml());
|
||||||
assertEquals("hero", structure.getPages().get(0).getComponents().get(0).getType());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -120,7 +117,7 @@ class LLMResponseParserTest {
|
|||||||
String json = """
|
String json = """
|
||||||
{
|
{
|
||||||
"pages": [
|
"pages": [
|
||||||
{"title": "Home", "slug": "home", "components": [{"type": "text"}]}
|
{"title": "Home", "slug": "home", "rawHtml": "<p>Hello</p>"}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
""";
|
""";
|
||||||
@@ -147,7 +144,7 @@ class LLMResponseParserTest {
|
|||||||
String json = """
|
String json = """
|
||||||
{
|
{
|
||||||
"pages": [
|
"pages": [
|
||||||
{"slug": "home", "components": [{"type": "hero"}]}
|
{"slug": "home", "rawHtml": "<p>test</p>"}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
""";
|
""";
|
||||||
@@ -159,19 +156,7 @@ class LLMResponseParserTest {
|
|||||||
String json = """
|
String json = """
|
||||||
{
|
{
|
||||||
"pages": [
|
"pages": [
|
||||||
{"title": "Home", "components": [{"type": "hero"}]}
|
{"title": "Home", "rawHtml": "<p>test</p>"}
|
||||||
]
|
|
||||||
}
|
|
||||||
""";
|
|
||||||
assertThrows(IllegalArgumentException.class, () -> parser.parse(json));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void throwsOnComponentWithoutType() {
|
|
||||||
String json = """
|
|
||||||
{
|
|
||||||
"pages": [
|
|
||||||
{"title": "Home", "slug": "home", "components": [{"orderIndex": 0}]}
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
""";
|
""";
|
||||||
@@ -183,7 +168,7 @@ class LLMResponseParserTest {
|
|||||||
String json = """
|
String json = """
|
||||||
{
|
{
|
||||||
"pages": [
|
"pages": [
|
||||||
{"title": "Home", "slug": "home", "components": [{"type": "hero"}]}
|
{"title": "Home", "slug": "home", "rawHtml": "<h1>Hello</h1>"}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
""";
|
""";
|
||||||
@@ -192,20 +177,6 @@ class LLMResponseParserTest {
|
|||||||
assertNotNull(structure.getPages().get(0).getId());
|
assertNotNull(structure.getPages().get(0).getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
void assignsGeneratedIdWhenComponentIdMissing() {
|
|
||||||
String json = """
|
|
||||||
{
|
|
||||||
"pages": [
|
|
||||||
{"title": "Home", "slug": "home", "components": [{"type": "hero"}]}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
""";
|
|
||||||
|
|
||||||
SiteStructure structure = parser.parse(json);
|
|
||||||
assertNotNull(structure.getPages().get(0).getComponents().get(0).getId());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void parsesPageWithAllOptionalFields() {
|
void parsesPageWithAllOptionalFields() {
|
||||||
String json = """
|
String json = """
|
||||||
@@ -218,15 +189,7 @@ class LLMResponseParserTest {
|
|||||||
"seoTitle": "About Us",
|
"seoTitle": "About Us",
|
||||||
"seoDescription": "Learn about our company",
|
"seoDescription": "Learn about our company",
|
||||||
"orderIndex": 1,
|
"orderIndex": 1,
|
||||||
"components": [
|
"rawHtml": "<section><h2>About</h2><p>Our story</p></section>"
|
||||||
{
|
|
||||||
"id": "comp-1",
|
|
||||||
"type": "text",
|
|
||||||
"orderIndex": 0,
|
|
||||||
"config": {"body": "Hello"},
|
|
||||||
"styles": {"color": "red"}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -240,13 +203,7 @@ class LLMResponseParserTest {
|
|||||||
assertEquals("About Us", page.getSeoTitle());
|
assertEquals("About Us", page.getSeoTitle());
|
||||||
assertEquals("Learn about our company", page.getSeoDescription());
|
assertEquals("Learn about our company", page.getSeoDescription());
|
||||||
assertEquals(1, page.getOrderIndex());
|
assertEquals(1, page.getOrderIndex());
|
||||||
|
assertEquals("<section><h2>About</h2><p>Our story</p></section>", page.getRawHtml());
|
||||||
SiteStructure.ComponentStructure comp = page.getComponents().get(0);
|
|
||||||
assertEquals("comp-1", comp.getId());
|
|
||||||
assertEquals("text", comp.getType());
|
|
||||||
assertEquals(0, comp.getOrderIndex());
|
|
||||||
assertEquals("Hello", comp.getConfig().get("body"));
|
|
||||||
assertEquals("red", comp.getStyles().get("color"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -255,7 +212,7 @@ class LLMResponseParserTest {
|
|||||||
{
|
{
|
||||||
"unknownField": true,
|
"unknownField": true,
|
||||||
"pages": [
|
"pages": [
|
||||||
{"title": "Home", "slug": "home", "components": [{"type": "hero", "extra": 1}]}
|
{"title": "Home", "slug": "home", "rawHtml": "<p>test</p>", "extra": 1}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
""";
|
""";
|
||||||
@@ -268,5 +225,19 @@ class LLMResponseParserTest {
|
|||||||
String json = "{invalid json here}";
|
String json = "{invalid json here}";
|
||||||
assertThrows(IllegalArgumentException.class, () -> parser.parse(json));
|
assertThrows(IllegalArgumentException.class, () -> parser.parse(json));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parsesPageWithoutRawHtml() {
|
||||||
|
String json = """
|
||||||
|
{
|
||||||
|
"pages": [
|
||||||
|
{"title": "Empty", "slug": "empty"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
SiteStructure structure = parser.parse(json);
|
||||||
|
assertNull(structure.getPages().get(0).getRawHtml());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ class LLMServiceTest {
|
|||||||
activeProvider.withContent("""
|
activeProvider.withContent("""
|
||||||
{
|
{
|
||||||
"pages": [
|
"pages": [
|
||||||
{"title": "Home", "slug": "home", "components": [{"type": "hero"}]}
|
{"title": "Home", "slug": "home", "rawHtml": "<h1>Welcome</h1>"}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
""");
|
""");
|
||||||
@@ -97,7 +97,7 @@ class LLMServiceTest {
|
|||||||
activeProvider.withContent("""
|
activeProvider.withContent("""
|
||||||
{
|
{
|
||||||
"pages": [
|
"pages": [
|
||||||
{"title": "About", "slug": "about", "components": [{"type": "text"}]}
|
{"title": "About", "slug": "about", "rawHtml": "<p>About</p>"}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
""");
|
""");
|
||||||
@@ -122,7 +122,7 @@ class LLMServiceTest {
|
|||||||
activeProvider.withContent("""
|
activeProvider.withContent("""
|
||||||
{
|
{
|
||||||
"pages": [
|
"pages": [
|
||||||
{"title": "Home", "slug": "home", "components": [{"type": "hero"}]}
|
{"title": "Home", "slug": "home", "rawHtml": "<p>Hero</p>"}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
""");
|
""");
|
||||||
@@ -147,7 +147,7 @@ class LLMServiceTest {
|
|||||||
activeProvider.withContent("""
|
activeProvider.withContent("""
|
||||||
{
|
{
|
||||||
"pages": [
|
"pages": [
|
||||||
{"title": "Home", "slug": "home", "components": [{"type": "hero"}]}
|
{"title": "Home", "slug": "home", "rawHtml": "<p>Hero</p>"}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
""");
|
""");
|
||||||
@@ -174,7 +174,7 @@ class LLMServiceTest {
|
|||||||
fallbackProvider.withContent("""
|
fallbackProvider.withContent("""
|
||||||
{
|
{
|
||||||
"pages": [
|
"pages": [
|
||||||
{"title": "Fallback", "slug": "fallback", "components": [{"type": "hero"}]}
|
{"title": "Fallback", "slug": "fallback", "rawHtml": "<p>Hero</p>"}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
""");
|
""");
|
||||||
@@ -204,9 +204,7 @@ class LLMServiceTest {
|
|||||||
SiteStructure.PageStructure.builder()
|
SiteStructure.PageStructure.builder()
|
||||||
.title("Old")
|
.title("Old")
|
||||||
.slug("old")
|
.slug("old")
|
||||||
.components(List.of(
|
.rawHtml("<p>Old content</p>")
|
||||||
SiteStructure.ComponentStructure.builder().type("text").build()
|
|
||||||
))
|
|
||||||
.build()
|
.build()
|
||||||
))
|
))
|
||||||
.build();
|
.build();
|
||||||
@@ -214,7 +212,7 @@ class LLMServiceTest {
|
|||||||
activeProvider.withContent("""
|
activeProvider.withContent("""
|
||||||
{
|
{
|
||||||
"pages": [
|
"pages": [
|
||||||
{"title": "Refined", "slug": "refined", "components": [{"type": "hero"}]}
|
{"title": "Refined", "slug": "refined", "rawHtml": "<p>Hero</p>"}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
""");
|
""");
|
||||||
@@ -258,7 +256,7 @@ class LLMServiceTest {
|
|||||||
GenerationVersion version = GenerationVersion.builder()
|
GenerationVersion version = GenerationVersion.builder()
|
||||||
.id("v1")
|
.id("v1")
|
||||||
.fullStructure("""
|
.fullStructure("""
|
||||||
{"pages": [{"title": "Restored", "slug": "restored", "components": [{"type": "hero"}]}]}
|
{"pages": [{"title": "Restored", "slug": "restored", "rawHtml": "<p>Hero</p>"}]}
|
||||||
""")
|
""")
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
export type ComponentType =
|
|
||||||
| 'HEADING' | 'TEXT' | 'IMAGE' | 'BUTTON' | 'DIVIDER'
|
|
||||||
| 'GALLERY' | 'VIDEO' | 'CONTACT_FORM' | 'MAP' | 'CUSTOM_HTML';
|
|
||||||
|
|
||||||
export interface ComponentItem {
|
|
||||||
id: string;
|
|
||||||
pageId?: string;
|
|
||||||
type: ComponentType;
|
|
||||||
config: Record<string, unknown>;
|
|
||||||
styles: Record<string, unknown>;
|
|
||||||
orderIndex: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDefaultConfig(type: ComponentType): Record<string, unknown> {
|
|
||||||
switch (type) {
|
|
||||||
case 'HEADING': return { text: 'Heading', level: 'h2' };
|
|
||||||
case 'TEXT': return { content: 'Text content here...' };
|
|
||||||
case 'IMAGE': return { src: '', alt: 'Image', linkTo: '' };
|
|
||||||
case 'BUTTON': return { text: 'Click Me', link: '#', variant: 'filled' };
|
|
||||||
case 'DIVIDER': return { style: 'solid' };
|
|
||||||
case 'GALLERY': return { images: [] };
|
|
||||||
case 'VIDEO': return { src: '', autoplay: false, controls: true };
|
|
||||||
case 'CONTACT_FORM': return { fields: ['name', 'email', 'message'], submitText: 'Send', emailTo: '' };
|
|
||||||
case 'MAP': return { address: 'New York, NY', zoom: 14 };
|
|
||||||
case 'CUSTOM_HTML': return { html: '<p>Custom HTML</p>' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDefaultStyles(type: ComponentType): Record<string, unknown> {
|
|
||||||
switch (type) {
|
|
||||||
case 'HEADING': return { color: '#1a1a1a', fontSize: '2rem', fontWeight: '700', textAlign: 'left', margin: '0 0 1rem' };
|
|
||||||
case 'TEXT': return { color: '#4a4a4a', fontSize: '1rem', lineHeight: '1.6', textAlign: 'left' };
|
|
||||||
case 'IMAGE': return { width: '100%', height: 'auto', borderRadius: '0', objectFit: 'cover' };
|
|
||||||
case 'BUTTON': return { backgroundColor: '#1976d2', color: '#ffffff', borderRadius: '4px', padding: '0.75rem 1.5rem' };
|
|
||||||
case 'DIVIDER': return { color: '#e0e0e0', thickness: '1px', margin: '1.5rem 0' };
|
|
||||||
case 'GALLERY': return { columns: '3', gap: '8px', borderRadius: '4px' };
|
|
||||||
case 'VIDEO': return { width: '100%', maxWidth: '800px', borderRadius: '4px' };
|
|
||||||
case 'CONTACT_FORM': return { backgroundColor: '#f5f5f5', padding: '2rem', borderRadius: '8px' };
|
|
||||||
case 'MAP': return { width: '100%', height: '400px', borderRadius: '4px' };
|
|
||||||
case 'CUSTOM_HTML': return { padding: '0', margin: '0' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const LABELS: Record<string, string> = {
|
|
||||||
HEADING: 'Heading',
|
|
||||||
TEXT: 'Text',
|
|
||||||
IMAGE: 'Image',
|
|
||||||
BUTTON: 'Button',
|
|
||||||
DIVIDER: 'Divider',
|
|
||||||
GALLERY: 'Gallery',
|
|
||||||
VIDEO: 'Video',
|
|
||||||
CONTACT_FORM: 'Contact Form',
|
|
||||||
MAP: 'Map',
|
|
||||||
CUSTOM_HTML: 'Custom HTML',
|
|
||||||
};
|
|
||||||
|
|
||||||
const ICONS: Record<string, string> = {
|
|
||||||
HEADING: 'title',
|
|
||||||
TEXT: 'text_fields',
|
|
||||||
IMAGE: 'image',
|
|
||||||
BUTTON: 'smart_button',
|
|
||||||
DIVIDER: 'horizontal_rule',
|
|
||||||
GALLERY: 'photo_library',
|
|
||||||
VIDEO: 'videocam',
|
|
||||||
CONTACT_FORM: 'contact_mail',
|
|
||||||
MAP: 'map',
|
|
||||||
CUSTOM_HTML: 'code',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function getComponentLabel(type: string): string {
|
|
||||||
return LABELS[type] || LABELS[type.toUpperCase()] || type;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getComponentIcon(type: string): string {
|
|
||||||
return ICONS[type] || ICONS[type.toUpperCase()] || 'code';
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
import { ComponentType } from './component';
|
|
||||||
|
|
||||||
export interface LLMGenerateRequest {
|
export interface LLMGenerateRequest {
|
||||||
prompt: string;
|
prompt: string;
|
||||||
siteId: string;
|
siteId: string;
|
||||||
@@ -37,15 +35,7 @@ export interface PageStructure {
|
|||||||
seoTitle?: string;
|
seoTitle?: string;
|
||||||
seoDescription?: string;
|
seoDescription?: string;
|
||||||
orderIndex: number;
|
orderIndex: number;
|
||||||
components: ComponentStructure[];
|
rawHtml?: string;
|
||||||
}
|
|
||||||
|
|
||||||
export interface ComponentStructure {
|
|
||||||
id?: string;
|
|
||||||
type: ComponentType;
|
|
||||||
orderIndex: number;
|
|
||||||
config: Record<string, unknown>;
|
|
||||||
styles: Record<string, unknown>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TokenUsage {
|
export interface TokenUsage {
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import { takeUntil } from 'rxjs/operators';
|
|||||||
import { LlmApiService } from '../core/services/llm-api.service';
|
import { LlmApiService } from '../core/services/llm-api.service';
|
||||||
import { LlmSessionService } from './llm-session.service';
|
import { LlmSessionService } from './llm-session.service';
|
||||||
import { SiteStructure, GenerationVersion } from '../core/models/llm';
|
import { SiteStructure, GenerationVersion } from '../core/models/llm';
|
||||||
import { getComponentLabel } from '../core/models/component';
|
|
||||||
import { PreviewDialogComponent } from './preview-dialog/preview-dialog.component';
|
import { PreviewDialogComponent } from './preview-dialog/preview-dialog.component';
|
||||||
import { DeleteVersionDialog } from './delete-version-dialog/delete-version-dialog';
|
import { DeleteVersionDialog } from './delete-version-dialog/delete-version-dialog';
|
||||||
|
|
||||||
@@ -146,8 +145,6 @@ import { DeleteVersionDialog } from './delete-version-dialog/delete-version-dial
|
|||||||
<h3 class="text-lg font-semibold text-gray-900">Generated Site Structure</h3>
|
<h3 class="text-lg font-semibold text-gray-900">Generated Site Structure</h3>
|
||||||
<p class="text-sm text-gray-500">
|
<p class="text-sm text-gray-500">
|
||||||
{{ result.pages.length }} page{{ result.pages.length !== 1 ? 's' : '' }}
|
{{ result.pages.length }} page{{ result.pages.length !== 1 ? 's' : '' }}
|
||||||
·
|
|
||||||
{{ totalComponents(result) }} component{{ totalComponents(result) !== 1 ? 's' : '' }}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
@@ -236,14 +233,12 @@ import { DeleteVersionDialog } from './delete-version-dialog/delete-version-dial
|
|||||||
<mat-panel-description class="text-xs text-gray-400">
|
<mat-panel-description class="text-xs text-gray-400">
|
||||||
/{{ page.slug }}
|
/{{ page.slug }}
|
||||||
·
|
·
|
||||||
{{ page.components.length }} component{{ page.components.length !== 1 ? 's' : '' }}
|
Raw HTML
|
||||||
</mat-panel-description>
|
</mat-panel-description>
|
||||||
</mat-expansion-panel-header>
|
</mat-expansion-panel-header>
|
||||||
<div class="space-y-1">
|
<div class="flex items-center gap-2 py-1">
|
||||||
<div *ngFor="let comp of page.components" class="flex items-center gap-2 py-1">
|
<mat-icon class="text-gray-400 text-lg">code</mat-icon>
|
||||||
<mat-icon class="text-gray-400 text-lg">circle</mat-icon>
|
<span class="text-sm text-gray-400 italic">Raw HTML content</span>
|
||||||
<span class="text-sm text-gray-700">{{ getLabel(comp.type) }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</mat-expansion-panel>
|
</mat-expansion-panel>
|
||||||
</mat-accordion>
|
</mat-accordion>
|
||||||
@@ -446,14 +441,6 @@ export class LlmWorkspaceComponent implements OnInit, OnDestroy {
|
|||||||
if (this.siteId) this.router.navigate(['/llm', this.siteId, 'settings']);
|
if (this.siteId) this.router.navigate(['/llm', this.siteId, 'settings']);
|
||||||
}
|
}
|
||||||
|
|
||||||
totalComponents(structure: SiteStructure): number {
|
|
||||||
return structure.pages.reduce((sum, p) => sum + p.components.length, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
getLabel(type: string): string {
|
|
||||||
return getComponentLabel(type);
|
|
||||||
}
|
|
||||||
|
|
||||||
isCurrentVersion(versionId: string): boolean {
|
isCurrentVersion(versionId: string): boolean {
|
||||||
return this.session.currentVersionId === versionId;
|
return this.session.currentVersionId === versionId;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { MatIconModule } from '@angular/material/icon';
|
|||||||
import { MatToolbarModule } from '@angular/material/toolbar';
|
import { MatToolbarModule } from '@angular/material/toolbar';
|
||||||
import { NgIf, NgFor, AsyncPipe } from '@angular/common';
|
import { NgIf, NgFor, AsyncPipe } from '@angular/common';
|
||||||
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
||||||
import { SiteStructure, PageStructure, ComponentStructure } from '../../core/models/llm';
|
import { SiteStructure } from '../../core/models/llm';
|
||||||
|
|
||||||
type Device = 'desktop' | 'tablet' | 'mobile';
|
type Device = 'desktop' | 'tablet' | 'mobile';
|
||||||
|
|
||||||
@@ -93,23 +93,6 @@ export class PreviewDialogComponent {
|
|||||||
+ Object.entries(fonts).map(([k, v]) => ` --font-${k}: ${v};`).join('\n')
|
+ Object.entries(fonts).map(([k, v]) => ` --font-${k}: ${v};`).join('\n')
|
||||||
+ Object.entries(spacing).map(([k, v]) => ` --spacing-${k}: ${v};`).join('\n');
|
+ Object.entries(spacing).map(([k, v]) => ` --spacing-${k}: ${v};`).join('\n');
|
||||||
|
|
||||||
const sections: { bg: string; indices: number[] }[] = [];
|
|
||||||
for (let i = 0; i < page.components.length; i++) {
|
|
||||||
const c = page.components[i];
|
|
||||||
const bg = String((c.styles && c.styles['backgroundColor']) || '');
|
|
||||||
if (bg || i === 0) {
|
|
||||||
sections.push({ bg, indices: [i] });
|
|
||||||
} else {
|
|
||||||
sections[sections.length - 1].indices.push(i);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const lastIndices = new Set(sections.map(s => s.indices[s.indices.length - 1]));
|
|
||||||
const sectionsHtml = sections.map(s => {
|
|
||||||
const bgStyle = s.bg ? ` style="background:${s.bg}"` : '';
|
|
||||||
const inner = s.indices.map(idx => this.renderComponent(page.components[idx], idx)).join('\n');
|
|
||||||
return `<section${bgStyle}>\n <div class="container">\n${inner}\n </div>\n</section>`;
|
|
||||||
}).join('\n');
|
|
||||||
|
|
||||||
return `<!DOCTYPE html>
|
return `<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
@@ -129,128 +112,11 @@ ${cssVars}
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
.container { max-width: var(--spacing-containerWidth, 1100px); margin: 0 auto; padding: 0 1.5rem; }
|
|
||||||
section { padding: var(--spacing-sectionPadding, 3rem) 0; }
|
|
||||||
${this.buildPageStyles(page.components, lastIndices)}
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
${sectionsHtml}
|
${page.rawHtml || ''}
|
||||||
</body>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildPageStyles(components: ComponentStructure[], lastIndices: Set<number>): string {
|
|
||||||
return components.map((c, i) => {
|
|
||||||
const s = c.styles || {};
|
|
||||||
let entries = Object.entries(s).filter(([k]) => k !== 'backgroundColor');
|
|
||||||
if (lastIndices.has(i)) {
|
|
||||||
entries = entries.filter(([k]) => k !== 'marginBottom');
|
|
||||||
}
|
|
||||||
if (entries.length === 0) return '';
|
|
||||||
return `.comp-${i} {\n${entries.map(([k, v]) => ` ${this.cssProperty(k)}: ${v};`).join('\n')}\n}`;
|
|
||||||
}).filter(Boolean).join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderComponent(c: ComponentStructure, i?: number): string {
|
|
||||||
const idx = i ?? 0;
|
|
||||||
const config = c.config || {};
|
|
||||||
const type = (c.type || '').toUpperCase();
|
|
||||||
const cls = `comp-${idx}`;
|
|
||||||
|
|
||||||
switch (type) {
|
|
||||||
case 'HEADING': {
|
|
||||||
const level = (config['level'] as string) || 'h2';
|
|
||||||
const text = (config['text'] as string) || 'Heading';
|
|
||||||
const tag = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(level) ? level : 'h2';
|
|
||||||
return `<${tag} class="${cls}">${this.esc(text)}</${tag}>`;
|
|
||||||
}
|
|
||||||
case 'TEXT': {
|
|
||||||
const content = (config['content'] as string) || '';
|
|
||||||
return `<div class="${cls}">${content}</div>`;
|
|
||||||
}
|
|
||||||
case 'IMAGE': {
|
|
||||||
const src = (config['src'] as string) || '';
|
|
||||||
const alt = (config['alt'] as string) || '';
|
|
||||||
const caption = (config['caption'] as string) || '';
|
|
||||||
const linkTo = (config['linkTo'] as string) || '';
|
|
||||||
const img = `<img src="${this.esc(src)}" alt="${this.esc(alt)}" class="${cls}" style="max-width:100%;height:auto">`;
|
|
||||||
const imgHtml = linkTo ? `<a href="${this.esc(linkTo)}">${img}</a>` : img;
|
|
||||||
return caption ? `<figure style="margin:0">${imgHtml}<figcaption style="text-align:center;font-size:0.875rem;color:#6b7280;margin-top:0.5rem">${this.esc(caption)}</figcaption></figure>` : imgHtml;
|
|
||||||
}
|
|
||||||
case 'BUTTON': {
|
|
||||||
const text = (config['text'] as string) || 'Button';
|
|
||||||
const link = (config['link'] as string) || '#';
|
|
||||||
const variant = (config['variant'] as string) || 'primary';
|
|
||||||
let btnStyle = 'display:inline-block;text-decoration:none;font-weight:500;font-size:1rem;padding:0.75rem 2rem;border-radius:8px;transition:all 0.2s;cursor:pointer;border:2px solid transparent';
|
|
||||||
if (variant === 'outline') {
|
|
||||||
btnStyle += `;background:transparent;color:var(--color-primary,#4f46e5);border-color:var(--color-primary,#4f46e5)`;
|
|
||||||
} else if (variant === 'secondary') {
|
|
||||||
btnStyle += `;background:var(--color-secondary,#6366f1);color:#ffffff`;
|
|
||||||
} else {
|
|
||||||
btnStyle += `;background:var(--color-primary,#4f46e5);color:#ffffff`;
|
|
||||||
}
|
|
||||||
return `<a href="${this.esc(link)}" class="${cls}" style="${btnStyle}">${this.esc(text)}</a>`;
|
|
||||||
}
|
|
||||||
case 'DIVIDER': {
|
|
||||||
const style = (config['style'] as string) || 'solid';
|
|
||||||
const color = (config['color'] as string) || 'var(--color-text, #d1d5db)';
|
|
||||||
return `<hr class="${cls}" style="border:none;border-top:1px ${style} ${color}">`;
|
|
||||||
}
|
|
||||||
case 'GALLERY': {
|
|
||||||
const images = (config['images'] as string[]) || [];
|
|
||||||
const columns = Math.min(Math.max((config['columns'] as number) || 3, 1), 6);
|
|
||||||
if (images.length === 0) return `<div class="${cls}">Gallery (no images)</div>`;
|
|
||||||
return `<div class="${cls}" style="display:grid;grid-template-columns:repeat(${columns},1fr);gap:1rem">
|
|
||||||
${images.map(src => `<img src="${this.esc(src)}" style="width:100%;height:250px;object-fit:cover;border-radius:8px">`).join('\n')}
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
case 'VIDEO': {
|
|
||||||
const src = (config['src'] as string) || '';
|
|
||||||
const controls = config['controls'] !== false;
|
|
||||||
const autoplay = config['autoplay'] === true;
|
|
||||||
return `<video src="${this.esc(src)}" class="${cls}" ${controls ? 'controls' : ''} ${autoplay ? 'autoplay' : ''} muted style="max-width:100%"></video>`;
|
|
||||||
}
|
|
||||||
case 'CONTACT_FORM': {
|
|
||||||
const fields = (config['fields'] as string[]) || ['name', 'email', 'message'];
|
|
||||||
const submitText = (config['submitText'] as string) || 'Send';
|
|
||||||
return `<form class="${cls}">
|
|
||||||
${fields.map(f => `
|
|
||||||
<div style="margin-bottom:1rem">
|
|
||||||
<label style="display:block;font-size:0.875rem;font-weight:600;margin-bottom:0.375rem;color:var(--color-text,#374151)">${this.esc(this.capitalize(f))}</label>
|
|
||||||
${f === 'message' ? `<textarea rows="4" style="width:100%;padding:0.75rem;border:1px solid #d1d5db;border-radius:8px;font-family:inherit;font-size:0.95rem;transition:border-color 0.2s;outline:none" onfocus="this.style.borderColor='var(--color-primary,#4f46e5)'" onblur="this.style.borderColor='#d1d5db'"></textarea>`
|
|
||||||
: `<input type="${f === 'email' ? 'email' : 'text'}" style="width:100%;padding:0.75rem;border:1px solid #d1d5db;border-radius:8px;font-family:inherit;font-size:0.95rem;transition:border-color 0.2s;outline:none" onfocus="this.style.borderColor='var(--color-primary,#4f46e5)'" onblur="this.style.borderColor='#d1d5db'">`}
|
|
||||||
</div>
|
|
||||||
`).join('')}
|
|
||||||
<button type="submit" style="padding:0.75rem 2rem;background:var(--color-primary,#4f46e5);color:#fff;border:none;border-radius:8px;cursor:pointer;font-size:1rem;font-weight:500;transition:opacity 0.2s" onmouseover="this.style.opacity='0.9'" onmouseout="this.style.opacity='1'">${this.esc(submitText)}</button>
|
|
||||||
</form>`;
|
|
||||||
}
|
|
||||||
case 'MAP': {
|
|
||||||
const address = (config['address'] as string) || 'New York, NY';
|
|
||||||
return `<div class="${cls}" style="display:flex;align-items:center;justify-content:center;background:#f3f4f6">
|
|
||||||
<span style="color:#9ca3af">Map: ${this.esc(address)}</span>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
case 'CUSTOM_HTML': {
|
|
||||||
const html = (config['html'] as string) || '';
|
|
||||||
return `<div class="${cls}">${html}</div>`;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return `<div class="${cls}">[${this.esc(type)}]</div>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private cssProperty(key: string): string {
|
|
||||||
return key.replace(/([A-Z])/g, '-$1').toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
private esc(s: string): string {
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = s;
|
|
||||||
return div.innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
private capitalize(s: string): string {
|
|
||||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,11 +98,11 @@ import { SiteResponse } from '../../core/models/site';
|
|||||||
Your site is live at <a [href]="liveUrl" target="_blank" class="text-blue-600 underline">{{ liveUrl }}</a>
|
Your site is live at <a [href]="liveUrl" target="_blank" class="text-blue-600 underline">{{ liveUrl }}</a>
|
||||||
</p>
|
</p>
|
||||||
<div class="flex gap-3" *ngIf="!publishing">
|
<div class="flex gap-3" *ngIf="!publishing">
|
||||||
<button mat-flat-button color="primary" *ngIf="siteStatus === 'DRAFT'" [disabled]="publishing" (click)="publishSite()">
|
<button mat-flat-button color="primary" (click)="publishSite()">
|
||||||
<mat-icon>publish</mat-icon>
|
<mat-icon>publish</mat-icon>
|
||||||
Publish
|
{{ siteStatus === 'PUBLISHED' ? 'Update' : 'Publish' }}
|
||||||
</button>
|
</button>
|
||||||
<button mat-stroked-button color="warn" *ngIf="siteStatus === 'PUBLISHED'" [disabled]="publishing" (click)="unpublishSite()">
|
<button mat-stroked-button color="warn" *ngIf="siteStatus === 'PUBLISHED'" (click)="unpublishSite()">
|
||||||
<mat-icon>unpublish</mat-icon>
|
<mat-icon>unpublish</mat-icon>
|
||||||
Unpublish
|
Unpublish
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user