diff --git a/cms-api/src/main/java/com/condation/cms/api/eventbus/events/CollectionChangedEvent.java b/cms-api/src/main/java/com/condation/cms/api/eventbus/events/CollectionChangedEvent.java
new file mode 100644
index 000000000..d01ef63e2
--- /dev/null
+++ b/cms-api/src/main/java/com/condation/cms/api/eventbus/events/CollectionChangedEvent.java
@@ -0,0 +1,28 @@
+package com.condation.cms.api.eventbus.events;
+
+/*-
+ * #%L
+ * CMS Api
+ * %%
+ * Copyright (C) 2023 - 2026 CondationCMS
+ * %%
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ * #L%
+ */
+
+import com.condation.cms.api.eventbus.Event;
+import java.nio.file.Path;
+
+/** Published after collection metadata is refreshed; a directory requests a subtree refresh. */
+public record CollectionChangedEvent(Path path) implements Event {}
diff --git a/cms-api/src/main/java/com/condation/cms/api/eventbus/events/ContentTypesChangedEvent.java b/cms-api/src/main/java/com/condation/cms/api/eventbus/events/ContentTypesChangedEvent.java
new file mode 100644
index 000000000..bbf7c5521
--- /dev/null
+++ b/cms-api/src/main/java/com/condation/cms/api/eventbus/events/ContentTypesChangedEvent.java
@@ -0,0 +1,27 @@
+package com.condation.cms.api.eventbus.events;
+
+/*-
+ * #%L
+ * CMS Api
+ * %%
+ * Copyright (C) 2023 - 2026 CondationCMS
+ * %%
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ * #L%
+ */
+
+import com.condation.cms.api.eventbus.Event;
+
+/** Requests rebuilding references after editor schemas have changed. */
+public record ContentTypesChangedEvent() implements Event {}
diff --git a/cms-api/src/main/java/com/condation/cms/api/ui/elements/ContentTypeProvider.java b/cms-api/src/main/java/com/condation/cms/api/ui/elements/ContentTypeProvider.java
new file mode 100644
index 000000000..c922ea98e
--- /dev/null
+++ b/cms-api/src/main/java/com/condation/cms/api/ui/elements/ContentTypeProvider.java
@@ -0,0 +1,35 @@
+package com.condation.cms.api.ui.elements;
+
+/*-
+ * #%L
+ * CMS Api
+ * %%
+ * Copyright (C) 2023 - 2026 CondationCMS
+ * %%
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ * #L%
+ */
+
+import com.condation.cms.api.hooks.HookSystem;
+
+/** Shared hook contract for the manager and background content processing. */
+public final class ContentTypeProvider {
+ public static final String REGISTER_HOOK = "manager/contentTypes/register";
+
+ private ContentTypeProvider() {}
+
+ public static ContentTypes load(HookSystem hooks) {
+ return hooks.doFilter(REGISTER_HOOK, new ContentTypes());
+ }
+}
diff --git a/cms-api/src/main/java/com/condation/cms/api/usage/Usage.java b/cms-api/src/main/java/com/condation/cms/api/usage/Usage.java
new file mode 100644
index 000000000..a53389b17
--- /dev/null
+++ b/cms-api/src/main/java/com/condation/cms/api/usage/Usage.java
@@ -0,0 +1,30 @@
+package com.condation.cms.api.usage;
+
+/*-
+ * #%L
+ * CMS Api
+ * %%
+ * Copyright (C) 2023 - 2026 CondationCMS
+ * %%
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ * #L%
+ */
+
+/** A direct editorial reference and its exact source location. */
+public record Usage(UsageResource source, UsageResource target, String location,
+ Origin origin, String originalReference, String sourceTitle, String sourceStatus,
+ TargetStatus targetStatus) {
+ public enum Origin { CONTENT_TYPE, MARKDOWN, HTML }
+ public enum TargetStatus { EXISTS, MISSING, UNRESOLVED }
+}
diff --git a/cms-api/src/main/java/com/condation/cms/api/usage/UsageIndex.java b/cms-api/src/main/java/com/condation/cms/api/usage/UsageIndex.java
new file mode 100644
index 000000000..a66a7ef80
--- /dev/null
+++ b/cms-api/src/main/java/com/condation/cms/api/usage/UsageIndex.java
@@ -0,0 +1,32 @@
+package com.condation.cms.api.usage;
+
+/*-
+ * #%L
+ * CMS Api
+ * %%
+ * Copyright (C) 2023 - 2026 CondationCMS
+ * %%
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ * #L%
+ */
+
+import java.util.List;
+
+/** Site-scoped, rebuildable index of editorial references. Themes and dynamic queries are excluded. */
+public interface UsageIndex {
+ List incoming(UsageResource target);
+ List outgoing(UsageResource source);
+ List problems();
+ void rebuild();
+}
diff --git a/cms-api/src/main/java/com/condation/cms/api/usage/UsageProblem.java b/cms-api/src/main/java/com/condation/cms/api/usage/UsageProblem.java
new file mode 100644
index 000000000..f633407c0
--- /dev/null
+++ b/cms-api/src/main/java/com/condation/cms/api/usage/UsageProblem.java
@@ -0,0 +1,25 @@
+package com.condation.cms.api.usage;
+
+/*-
+ * #%L
+ * CMS Api
+ * %%
+ * Copyright (C) 2023 - 2026 CondationCMS
+ * %%
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ * #L%
+ */
+
+/** An incomplete extraction must never be reported as proof that a resource is unused. */
+public record UsageProblem(String site, String path, String message) {}
diff --git a/cms-api/src/main/java/com/condation/cms/api/usage/UsageResource.java b/cms-api/src/main/java/com/condation/cms/api/usage/UsageResource.java
new file mode 100644
index 000000000..d441b598a
--- /dev/null
+++ b/cms-api/src/main/java/com/condation/cms/api/usage/UsageResource.java
@@ -0,0 +1,45 @@
+package com.condation.cms.api.usage;
+
+/*-
+ * #%L
+ * CMS Api
+ * %%
+ * Copyright (C) 2023 - 2026 CondationCMS
+ * %%
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ * #L%
+ */
+
+import java.util.Objects;
+
+/** Identity within a site's content, assets or collections root; never a public context path. */
+public record UsageResource(String site, Kind kind, String path) {
+ public enum Kind { CONTENT, MEDIA, COLLECTION_ITEM, UNRESOLVED_URL }
+
+ public UsageResource {
+ Objects.requireNonNull(site, "site");
+ Objects.requireNonNull(kind, "kind");
+ Objects.requireNonNull(path, "path");
+ path = path.replace('\\', '/').replaceAll("^/+", "");
+ if (site.isBlank() || path.contains("\u0000")) {
+ throw new IllegalArgumentException("invalid usage resource");
+ }
+ if (kind != Kind.UNRESOLVED_URL) {
+ path = java.nio.file.Path.of(path).normalize().toString().replace('\\', '/');
+ if (path.isBlank() || path.equals("..") || path.startsWith("../")) {
+ throw new IllegalArgumentException("invalid site-local resource path");
+ }
+ }
+ }
+}
diff --git a/cms-content/pom.xml b/cms-content/pom.xml
index fabde89e3..e8c1c4a25 100644
--- a/cms-content/pom.xml
+++ b/cms-content/pom.xml
@@ -25,6 +25,18 @@
+
+ com.google.code.gson
+ gson
+
+
+ org.apache.lucene
+ lucene-core
+
+
+ org.apache.lucene
+ lucene-analysis-common
+
com.condation.cms
cms-api
diff --git a/cms-content/src/main/java/com/condation/cms/content/markdown/rules/inline/ImageLinkInlineRule.java b/cms-content/src/main/java/com/condation/cms/content/markdown/rules/inline/ImageLinkInlineRule.java
index 329dcc654..7e0114e71 100644
--- a/cms-content/src/main/java/com/condation/cms/content/markdown/rules/inline/ImageLinkInlineRule.java
+++ b/cms-content/src/main/java/com/condation/cms/content/markdown/rules/inline/ImageLinkInlineRule.java
@@ -36,6 +36,13 @@
*/
public class ImageLinkInlineRule implements InlineElementRule {
+ private final boolean modifyUrls;
+
+ public ImageLinkInlineRule() { this(true); }
+
+ /** Allows static consumers to inspect the stored URL without request transformations. */
+ public ImageLinkInlineRule(boolean modifyUrls) { this.modifyUrls = modifyUrls; }
+
static final Slugify SLUG = Slugify.builder().build();
static final String IMAGE_PATTERN = "!\\[(?[^\\[\\]]*)\\]\\((?[^\\s\\)]+)(?: \"(?[^\"]*)\")?\\)";
@@ -55,7 +62,7 @@ public InlineBlock next(InlineElementTokenizer tokenizer, String md) {
- if (RequestContextScope.REQUEST_CONTEXT.isBound()
+ if (modifyUrls && RequestContextScope.REQUEST_CONTEXT.isBound()
&& isInternalUrl(href)) {
var requestContext = RequestContextScope.REQUEST_CONTEXT.get();
diff --git a/cms-content/src/main/java/com/condation/cms/content/markdown/rules/inline/LinkInlineRule.java b/cms-content/src/main/java/com/condation/cms/content/markdown/rules/inline/LinkInlineRule.java
index 36f564986..9fdce5bc6 100644
--- a/cms-content/src/main/java/com/condation/cms/content/markdown/rules/inline/LinkInlineRule.java
+++ b/cms-content/src/main/java/com/condation/cms/content/markdown/rules/inline/LinkInlineRule.java
@@ -37,6 +37,13 @@
*/
public class LinkInlineRule implements InlineElementRule {
+ private final boolean modifyUrls;
+
+ public LinkInlineRule() { this(true); }
+
+ /** Allows static consumers to inspect the stored URL without request transformations. */
+ public LinkInlineRule(boolean modifyUrls) { this.modifyUrls = modifyUrls; }
+
static final Slugify SLUG = Slugify.builder().build();
static final Pattern PATTERN = Pattern.compile("\\[(?[^\\]]*)\\]\\((?[^\\s)]+)(?: \"(?[^\"]*)\")?\\)");
@@ -53,7 +60,7 @@ public InlineBlock next(InlineElementTokenizer tokenizer, String md) {
var id = SLUG.slugify(text);
- if (RequestContextScope.REQUEST_CONTEXT.isBound()
+ if (modifyUrls && RequestContextScope.REQUEST_CONTEXT.isBound()
&& isInternalUrl(href)) {
var requestContext = RequestContextScope.REQUEST_CONTEXT.get();
diff --git a/cms-content/src/main/java/com/condation/cms/content/usage/EditorialUsageIndex.java b/cms-content/src/main/java/com/condation/cms/content/usage/EditorialUsageIndex.java
new file mode 100644
index 000000000..390cb8c58
--- /dev/null
+++ b/cms-content/src/main/java/com/condation/cms/content/usage/EditorialUsageIndex.java
@@ -0,0 +1,426 @@
+package com.condation.cms.content.usage;
+
+/*-
+ * #%L
+ * CMS Content
+ * %%
+ * Copyright (C) 2023 - 2026 CondationCMS
+ * %%
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ * #L%
+ */
+import com.condation.cms.api.usage.*;
+import com.condation.cms.api.ui.elements.ContentTypes;
+import com.condation.cms.api.utils.PathUtil;
+import com.condation.cms.content.CollectionRouteTemplate;
+import com.condation.cms.core.content.io.ContentFileParser;
+import com.google.gson.*;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.*;
+
+/**
+ * Persistent usage index for exactly one site.
+ */
+public final class EditorialUsageIndex implements UsageIndex, AutoCloseable {
+
+ private final UsageSite site;
+ private LuceneUsageStore store;
+ private volatile boolean reconciliationPending = true;
+ private volatile UsageProblem operationalProblem;
+
+ public EditorialUsageIndex(UsageSite site) {
+ this.site = Objects.requireNonNull(site);
+ try {
+ store = new LuceneUsageStore(site.root());
+ store.visitSources(source -> {
+ var resource = source.document().resource();
+ if (!resource.site().equals(site.id())) {
+ store.delete(resource);
+ }
+ });
+ var siteProblems = store.siteProblems().stream().filter(problem -> problem.site().equals(site.id())).toList();
+ store.commit(siteProblems);
+
+ } catch (IOException ex) {
+ if (this.store != null) {
+ try {
+ store.close();
+ } catch (IOException ex1) {
+ throw new UncheckedIOException("Cannot closing usage index for " + site.id(), ex);
+ }
+ }
+ throw new UncheckedIOException("Cannot open usage index for " + site.id(), ex);
+ }
+ }
+
+ /**
+ * Startup/configuration reconciliation: unchanged files and schemas reuse
+ * stored extractions.
+ */
+ public synchronized void synchronize() {
+ reconcile(false);
+ }
+
+ @Override
+ public synchronized void rebuild() {
+ reconcile(true);
+ }
+
+ /**
+ * Called after the primary metadata index has processed a file or directory
+ * change.
+ */
+ public synchronized void refresh(Path changed) {
+ Path file = changed.toAbsolutePath().normalize();
+ var root = sourceRoot(site, file);
+ if (root == null) {
+ return;
+ }
+ try {
+ if (!file.toString().endsWith(".md")) {
+ updateSite(false);
+ } else {
+ updateFile(root, file);
+ }
+ operationalProblem = null;
+ } catch (IOException ex) {
+ operationalProblem = problem(site, "", "Usage index update failed", ex);
+ }
+ }
+
+ private void reconcile(boolean force) {
+ try {
+ updateSite(force);
+ reconciliationPending = false;
+ operationalProblem = null;
+ } catch (IOException ex) {
+ operationalProblem = problem(site, "", "Usage index reconciliation failed", ex);
+ }
+ }
+
+ private void updateSite(boolean force) throws IOException {
+ var siteProblems = new ArrayList();
+ var seen = new HashSet();
+ boolean scanCompleted = false;
+ try {
+ ContentTypes types = site.contentTypes().get();
+ String schema = schema(types);
+ for (String folder : List.of("content", "collections")) {
+ Path root = site.root().resolve(folder);
+ if (!Files.isDirectory(root)) {
+ continue;
+ }
+ try (var files = Files.walk(root)) {
+ for (var file : files.filter(Files::isRegularFile).filter(PathUtil::isContentFile).sorted().toList()) {
+ var resource = resource(site, root, file);
+ if (!localSource(site, resource)) {
+ continue;
+ }
+ seen.add(resource);
+ var old = store.source(resource);
+ var extractionProblems = new ArrayList();
+ try {
+ String stamp = fileStamp(file);
+ if (!force && old.isPresent() && stamp.equals(old.get().fileStamp())
+ && schema.equals(old.get().schema())) {
+ updateRoute(old.get());
+ } else {
+ store.update(readFile(types, schema, root, file, old, extractionProblems));
+ }
+ } catch (Exception ex) {
+ extractionProblems.add(problem(site, resource.path(), "Extraction failed", ex));
+ retainLastGood(old, extractionProblems, siteProblems);
+ }
+ }
+ }
+ }
+ scanCompleted = true;
+ } catch (Exception ex) {
+ siteProblems.add(problem(site, "", "Extraction failed", ex));
+ }
+ if (scanCompleted) {
+ store.visitSources(source -> {
+ var resource = source.document().resource();
+ if (resource.site().equals(site.id()) && !seen.contains(resource)) {
+ store.delete(resource);
+ }
+ });
+ }
+ publishCommittedSources(siteProblems);
+ }
+
+ private void updateFile(Path root, Path file) throws IOException {
+ var resource = resource(site, root, file);
+ var siteProblems = new ArrayList<>(store.siteProblems());
+ siteProblems.removeIf(problem -> problem.site().equals(site.id()) && problem.path().equals(resource.path()));
+ var old = store.source(resource);
+ if (!Files.exists(file) || !localSource(site, resource)) {
+ store.delete(resource);
+ } else {
+ var extractionProblems = new ArrayList();
+ try {
+ var types = site.contentTypes().get();
+ store.update(readFile(types, schema(types), root, file, old, extractionProblems));
+ } catch (Exception ex) {
+ extractionProblems.add(problem(site, resource.path(), "Extraction failed", ex));
+ retainLastGood(old, extractionProblems, siteProblems);
+ }
+ }
+ publishCommittedSources(siteProblems);
+ }
+
+ private PersistedUsageSource readFile(ContentTypes types, String schema, Path root, Path file,
+ Optional old, List extractionProblems) throws IOException {
+ if (!file.toRealPath().startsWith(root.toRealPath())) {
+ throw new IOException("Source is outside its site root");
+ }
+ var resource = resource(site, root, file);
+ String before = fileStamp(file);
+ var parser = new ContentFileParser(file.toString());
+ var metadata = parser.getHeader();
+ String publicPath = publicPath(site, resource, metadata);
+ var references = new UsageExtractor(types, site, extractionProblems).extract(resource, metadata, parser.getContent());
+ if (!before.equals(fileStamp(file))) {
+ throw new IOException("Source changed during extraction; retry required");
+ }
+ var document = new UsageDocument(resource, publicPath, metadata, references);
+ var previousUsages = old.map(PersistedUsageSource::usages).orElseGet(List::of);
+ var problems = List.copyOf(extractionProblems);
+ return new PersistedUsageSource(document, before, schema, previousUsages, problems, problems);
+ }
+
+ private void updateRoute(PersistedUsageSource source) throws IOException {
+ var current = currentDocument(source.document());
+ if (!current.equals(source.document())) {
+ store.update(new PersistedUsageSource(current, source.fileStamp(), source.schema(), source.usages(),
+ source.extractionProblems(), source.problems()));
+ }
+ }
+
+ private void retainLastGood(Optional old, List extractionProblems,
+ List siteProblems) throws IOException {
+ if (old.isEmpty()) {
+ siteProblems.addAll(extractionProblems);
+ return;
+ }
+ var source = old.get();
+ var document = currentDocument(source.document());
+ var problems = List.copyOf(extractionProblems);
+ store.update(new PersistedUsageSource(document, source.fileStamp(), source.schema(), source.usages(),
+ problems, problems));
+ }
+
+ private static boolean localSource(UsageSite site, UsageResource resource) {
+ if (resource.kind() != UsageResource.Kind.COLLECTION_ITEM) {
+ return true;
+ }
+ String[] parts = resource.path().split("/");
+ return parts.length == 2 && site.collectionSite(parts[0]).equals(site.id());
+ }
+
+ private static UsageResource resource(UsageSite site, Path root, Path file) {
+ return new UsageResource(site.id(), root.endsWith("collections")
+ ? UsageResource.Kind.COLLECTION_ITEM : UsageResource.Kind.CONTENT,
+ root.relativize(file).toString().replace('\\', '/'));
+ }
+
+ private static String fileStamp(Path file) throws IOException {
+ var attributes = Files.readAttributes(file, BasicFileAttributes.class);
+ return attributes.lastModifiedTime() + ":" + attributes.size() + ":" + attributes.fileKey();
+ }
+
+ private static String schema(ContentTypes types) {
+ try {
+ // Map iteration order differs between JVMs. Canonicalize object keys before hashing.
+ String serialized = canonical(new Gson().toJsonTree(types)).toString();
+ return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
+ .digest(("usage-extractor-1\n" + serialized).getBytes(StandardCharsets.UTF_8)));
+ } catch (NoSuchAlgorithmException ex) {
+ throw new IllegalStateException(ex);
+ }
+ }
+
+ private static JsonElement canonical(JsonElement element) {
+ if (element.isJsonObject()) {
+ var result = new JsonObject();
+ element.getAsJsonObject().keySet().stream().sorted().forEach(key
+ -> result.add(key, canonical(element.getAsJsonObject().get(key))));
+ return result;
+ }
+ if (element.isJsonArray()) {
+ var result = new JsonArray();
+ element.getAsJsonArray().forEach(value -> result.add(canonical(value)));
+ return result;
+ }
+ return element;
+ }
+
+ private String publicPath(UsageSite site, UsageResource resource, Map metadata) {
+ if (resource.kind() == UsageResource.Kind.CONTENT) {
+ String path = resource.path();
+ String name = Path.of(path).getFileName().toString();
+ if (com.condation.cms.api.utils.SectionUtil.isSectionEntry(name)) {
+ path = path.substring(0, path.length() - name.length()) + name.substring(0, name.indexOf('.')) + ".md";
+ }
+ return site.db().getContent().byPath(path).map(node -> node.url()).orElse(PathUtil.toURL(path));
+ }
+ if (site.collections() == null) {
+ return null;
+ }
+ String[] parts = resource.path().split("/", 2);
+ return site.collections().collection(parts[0]).flatMap(definition -> definition.detailPage()).map(detail -> {
+ try {
+ return new CollectionRouteTemplate(detail).render(parts[1].substring(0, parts[1].length() - 3), metadata);
+ } catch (IllegalArgumentException ex) {
+ return null;
+ }
+ }).orElse(null);
+ }
+
+ private void publishCommittedSources(List siteProblems) throws IOException {
+ // Publish source facts first so route lookup sees the new route catalog. Edge fields still
+ // contain their previous batch until the second commit replaces them atomically per source.
+ store.commit(siteProblems);
+ var resolver = resolver();
+ store.visitSources(source -> {
+ var document = source.document();
+ var usages = new ArrayList();
+ var problems = new ArrayList<>(source.extractionProblems());
+ var current = currentDocument(document);
+ for (var ref : document.references()) {
+ try {
+ var target = resolver.resolve(current, ref);
+ if (target.isEmpty()) {
+ continue;
+ }
+ UsageResource resource = target.get();
+ Usage.TargetStatus status = resolver.status(resource);
+ if (resource.kind() == UsageResource.Kind.UNRESOLVED_URL || status == Usage.TargetStatus.MISSING) {
+ var previous = source.usages().stream()
+ .filter(usage -> usage.location().equals(ref.location()) && usage.originalReference().equals(ref.value()))
+ .filter(usage -> usage.target().kind() != UsageResource.Kind.UNRESOLVED_URL).findFirst();
+ if (previous.isPresent()) {
+ resource = previous.get().target();
+ status = Usage.TargetStatus.MISSING;
+ }
+ }
+ usages.add(new Usage(document.resource(), resource, ref.location(), ref.origin(), ref.value(),
+ document.title(), document.status(), status));
+ } catch (Exception ex) {
+ problems.add(new UsageProblem(site.id(), document.resource().path(),
+ "Cannot resolve " + ref.location() + ": " + ref.value() + " (" + ex.getMessage() + ")"));
+ }
+ }
+ var next = new PersistedUsageSource(current, source.fileStamp(), source.schema(), List.copyOf(usages),
+ source.extractionProblems(), List.copyOf(problems));
+ if (!next.equals(source)) {
+ store.update(next);
+ }
+ });
+ store.commit(siteProblems);
+ }
+
+ private UsageDocument currentDocument(UsageDocument document) {
+ return new UsageDocument(document.resource(), publicPath(site, document.resource(), document.metadata()),
+ document.metadata(), document.references());
+ }
+
+ private UsageReferenceResolver resolver() {
+ return new UsageReferenceResolver(site, new UsageReferenceResolver.PublicTargetLookup() {
+ @Override
+ public Optional aliasTarget(String path) throws IOException {
+ return store.aliasTarget(path);
+ }
+
+ @Override
+ public Optional collectionTarget(String path) throws IOException {
+ return store.collectionTarget(path);
+ }
+ });
+ }
+
+ private static Path sourceRoot(UsageSite site, Path file) {
+ for (String folder : List.of("content", "collections")) {
+ Path root = site.root().resolve(folder);
+ if (file.startsWith(root)) {
+ return root;
+ }
+ }
+ return null;
+ }
+
+ private static UsageProblem problem(UsageSite site, String path, String message, Exception ex) {
+ return new UsageProblem(site.id(), path, message + ": " + ex.getMessage());
+ }
+
+ @Override
+ public List incoming(UsageResource target) {
+ try {
+ return withLiveMediaStatus(store.incoming(target));
+ } catch (IOException ex) {
+ throw new UncheckedIOException(ex);
+ }
+ }
+
+ @Override
+ public List outgoing(UsageResource source) {
+ if (!source.site().equals(site.id())) {
+ return List.of();
+ }
+ try {
+ return withLiveMediaStatus(store.outgoing(source));
+ } catch (IOException ex) {
+ throw new UncheckedIOException(ex);
+ }
+ }
+
+ private List withLiveMediaStatus(List usages) {
+ var resolver = resolver();
+ return usages.stream().map(usage -> usage.target().kind() != UsageResource.Kind.MEDIA ? usage
+ : new Usage(usage.source(), usage.target(), usage.location(), usage.origin(), usage.originalReference(),
+ usage.sourceTitle(), usage.sourceStatus(), resolver.status(usage.target()))).toList();
+ }
+
+ @Override
+ public synchronized List problems() {
+ try {
+ var problems = new ArrayList<>(store.problems());
+ if (reconciliationPending) {
+ problems.add(new UsageProblem(site.id(), "", "Startup reconciliation pending"));
+ }
+ if (operationalProblem != null) {
+ problems.add(operationalProblem);
+ }
+ return problems.stream().distinct().toList();
+ } catch (IOException ex) {
+ throw new UncheckedIOException(ex);
+ }
+ }
+
+ @Override
+ public synchronized void close() {
+ try {
+ store.close();
+ } catch (IOException ex) {
+ throw new UncheckedIOException(ex);
+ }
+ }
+}
diff --git a/cms-content/src/main/java/com/condation/cms/content/usage/LuceneUsageStore.java b/cms-content/src/main/java/com/condation/cms/content/usage/LuceneUsageStore.java
new file mode 100644
index 000000000..43a4f2bb2
--- /dev/null
+++ b/cms-content/src/main/java/com/condation/cms/content/usage/LuceneUsageStore.java
@@ -0,0 +1,251 @@
+package com.condation.cms.content.usage;
+
+/*-
+ * #%L
+ * CMS Content
+ * %%
+ * Copyright (C) 2023 - 2026 CondationCMS
+ * %%
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ * #L%
+ */
+
+import com.condation.cms.api.usage.*;
+import com.condation.cms.api.utils.PathUtil;
+import com.google.gson.Gson;
+import com.google.gson.reflect.TypeToken;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.*;
+import org.apache.lucene.analysis.core.KeywordAnalyzer;
+import org.apache.lucene.document.Document;
+import org.apache.lucene.document.Field;
+import org.apache.lucene.document.StoredField;
+import org.apache.lucene.document.StringField;
+import org.apache.lucene.index.DirectoryReader;
+import org.apache.lucene.index.IndexWriter;
+import org.apache.lucene.index.IndexWriterConfig;
+import org.apache.lucene.index.Term;
+import org.apache.lucene.search.*;
+import org.apache.lucene.store.FSDirectory;
+import org.yaml.snakeyaml.LoaderOptions;
+import org.yaml.snakeyaml.Yaml;
+import org.yaml.snakeyaml.constructor.SafeConstructor;
+
+/** Persistent inverted index for one source site. All reference directions share a commit. */
+final class LuceneUsageStore implements AutoCloseable {
+ private static final String VERSION = "2";
+ private static final String SOURCE = "source";
+ private static final String TARGET = "target";
+ private static final String ALIAS = "alias";
+ private static final String COLLECTION_ROUTE = "collection-route";
+ private static final Gson JSON = new Gson();
+ private final FSDirectory directory;
+ private final IndexWriter writer;
+ private final SearcherManager searchers;
+ private boolean dirty;
+
+ LuceneUsageStore(Path siteRoot) throws IOException {
+ directory = FSDirectory.open(siteRoot.resolve("data/usage/index"));
+ IndexWriter openedWriter = null;
+ SearcherManager openedSearchers = null;
+ try {
+ boolean recreate = false;
+ if (DirectoryReader.indexExists(directory)) {
+ try (var existing = DirectoryReader.open(directory)) {
+ recreate = !VERSION.equals(existing.getIndexCommit().getUserData().get("usage-format"));
+ }
+ }
+ openedWriter = new IndexWriter(directory, new IndexWriterConfig(new KeywordAnalyzer())
+ .setOpenMode(recreate ? IndexWriterConfig.OpenMode.CREATE : IndexWriterConfig.OpenMode.CREATE_OR_APPEND));
+ writer = openedWriter;
+ openedSearchers = new SearcherManager(writer, true, true, new SearcherFactory());
+ searchers = openedSearchers;
+ var settings = new HashMap();
+ writer.getLiveCommitData().forEach(entry -> settings.put(entry.getKey(), entry.getValue()));
+ dirty = !VERSION.equals(settings.get("usage-format"));
+ } catch (Exception ex) {
+ if (openedSearchers != null) openedSearchers.close();
+ if (openedWriter != null) openedWriter.close();
+ directory.close();
+ throw ex;
+ }
+ }
+
+ synchronized List sources() throws IOException {
+ var result = new ArrayList();
+ visitSources(result::add);
+ return List.copyOf(result);
+ }
+
+ synchronized Optional source(UsageResource resource) throws IOException {
+ var documents = search(new TermQuery(new Term(SOURCE, key(resource))), 1);
+ return documents.isEmpty() ? Optional.empty() : Optional.of(source(documents.getFirst()));
+ }
+
+ synchronized void visitSources(SourceVisitor visitor) throws IOException {
+ var searcher = searchers.acquire();
+ try {
+ var stored = searcher.storedFields();
+ ScoreDoc after = null;
+ while (true) {
+ var page = searcher.searchAfter(after, MatchAllDocsQuery.INSTANCE, 256);
+ for (var hit : page.scoreDocs) visitor.accept(source(stored.document(hit.doc)));
+ if (page.scoreDocs.length < 256) return;
+ after = page.scoreDocs[page.scoreDocs.length - 1];
+ }
+ } finally {
+ searchers.release(searcher);
+ }
+ }
+
+ synchronized void update(PersistedUsageSource source) throws IOException {
+ var value = source.document();
+ var document = new Document();
+ document.add(new StringField(SOURCE, key(value.resource()), Field.Store.NO));
+ source.usages().stream().map(Usage::target).distinct().forEach(target ->
+ document.add(new StringField(TARGET, key(target), Field.Store.NO)));
+ if (value.resource().kind() == UsageResource.Kind.CONTENT
+ && value.metadata().get("aliases") instanceof Collection> aliases) {
+ aliases.stream().filter(String.class::isInstance).map(String.class::cast)
+ .map(PathUtil::normalizeURL).distinct().forEach(alias ->
+ document.add(new StringField(ALIAS, alias, Field.Store.NO)));
+ }
+ if (value.resource().kind() == UsageResource.Kind.COLLECTION_ITEM && value.publicPath() != null) {
+ document.add(new StringField(COLLECTION_ROUTE, PathUtil.normalizeURL(value.publicPath()), Field.Store.NO));
+ }
+ document.add(new StoredField("resource", JSON.toJson(value.resource())));
+ if (value.publicPath() != null) document.add(new StoredField("public-path", value.publicPath()));
+ // YAML preserves date/number metadata types required by collection route templates.
+ document.add(new StoredField("metadata", new Yaml().dump(value.metadata())));
+ document.add(new StoredField("references", JSON.toJson(value.references())));
+ document.add(new StoredField("usages", JSON.toJson(source.usages())));
+ document.add(new StoredField("problems", JSON.toJson(source.problems())));
+ document.add(new StoredField("extraction-problems", JSON.toJson(source.extractionProblems())));
+ document.add(new StoredField("file-stamp", source.fileStamp()));
+ document.add(new StoredField("schema", source.schema()));
+ writer.updateDocument(new Term(SOURCE, key(value.resource())), document);
+ dirty = true;
+ }
+
+ synchronized void delete(UsageResource source) throws IOException {
+ writer.deleteDocuments(new Term(SOURCE, key(source)));
+ dirty = true;
+ }
+
+ List incoming(UsageResource target) throws IOException {
+ return search(new TermQuery(new Term(TARGET, key(target)))).stream().flatMap(doc -> usages(doc).stream())
+ .filter(usage -> usage.target().equals(target)).toList();
+ }
+
+ List outgoing(UsageResource source) throws IOException {
+ return search(new TermQuery(new Term(SOURCE, key(source)))).stream().flatMap(doc -> usages(doc).stream()).toList();
+ }
+
+ synchronized Optional aliasTarget(String path) throws IOException {
+ return uniqueTarget(ALIAS, path);
+ }
+
+ synchronized Optional collectionTarget(String path) throws IOException {
+ return uniqueTarget(COLLECTION_ROUTE, path);
+ }
+
+ synchronized List siteProblems() {
+ var settings = new HashMap();
+ writer.getLiveCommitData().forEach(entry -> settings.put(entry.getKey(), entry.getValue()));
+ String problems = settings.get("site-problems");
+ return problems == null ? List.of()
+ : JSON.fromJson(problems, new TypeToken>() {}.getType());
+ }
+
+ synchronized List problems() throws IOException {
+ var result = new ArrayList<>(siteProblems());
+ visitSources(source -> result.addAll(source.problems()));
+ return result.stream().distinct().toList();
+ }
+
+ synchronized void commit(List problems) throws IOException {
+ var nextProblems = List.copyOf(problems);
+ if (!siteProblems().equals(nextProblems)) dirty = true;
+ if (!dirty) return;
+ writer.setLiveCommitData(Map.of("usage-format", VERSION, "site-problems", JSON.toJson(nextProblems)).entrySet());
+ writer.commit();
+ searchers.maybeRefreshBlocking();
+ dirty = false;
+ }
+
+ private List search(Query query) throws IOException {
+ return search(query, Integer.MAX_VALUE);
+ }
+
+ private List search(Query query, int limit) throws IOException {
+ var searcher = searchers.acquire();
+ try {
+ var stored = searcher.storedFields();
+ var result = new ArrayList();
+ ScoreDoc after = null;
+ while (result.size() < limit) {
+ int pageSize = Math.min(256, limit - result.size());
+ var page = searcher.searchAfter(after, query, pageSize);
+ for (var hit : page.scoreDocs) result.add(stored.document(hit.doc));
+ if (page.scoreDocs.length < pageSize) break;
+ after = page.scoreDocs[page.scoreDocs.length - 1];
+ }
+ return result;
+ } finally {
+ searchers.release(searcher);
+ }
+ }
+
+ private Optional uniqueTarget(String field, String path) throws IOException {
+ var documents = search(new TermQuery(new Term(field, PathUtil.normalizeURL(path))), 2);
+ return documents.size() == 1
+ ? Optional.of(JSON.fromJson(documents.getFirst().get("resource"), UsageResource.class))
+ : Optional.empty();
+ }
+
+ private static PersistedUsageSource source(Document document) {
+ UsageResource resource = JSON.fromJson(document.get("resource"), UsageResource.class);
+ Map metadata = new Yaml(new SafeConstructor(new LoaderOptions())).load(document.get("metadata"));
+ List references = JSON.fromJson(document.get("references"), new TypeToken>() {}.getType());
+ List problems = JSON.fromJson(document.get("problems"), new TypeToken>() {}.getType());
+ List extractionProblems = JSON.fromJson(document.get("extraction-problems"), new TypeToken>() {}.getType());
+ return new PersistedUsageSource(new UsageDocument(resource, document.get("public-path"), metadata, references),
+ document.get("file-stamp"), document.get("schema"), usages(document), extractionProblems, problems);
+ }
+
+ private static List usages(Document document) {
+ return JSON.fromJson(document.get("usages"), new TypeToken>() {}.getType());
+ }
+
+ private static String key(UsageResource resource) { return JSON.toJson(resource); }
+
+ @FunctionalInterface
+ interface SourceVisitor {
+ void accept(PersistedUsageSource source) throws IOException;
+ }
+
+ @Override
+ public synchronized void close() throws IOException {
+ try {
+ commit(siteProblems());
+ } finally {
+ try { searchers.close(); }
+ finally {
+ try { writer.close(); }
+ finally { directory.close(); }
+ }
+ }
+ }
+}
diff --git a/cms-content/src/main/java/com/condation/cms/content/usage/PersistedUsageSource.java b/cms-content/src/main/java/com/condation/cms/content/usage/PersistedUsageSource.java
new file mode 100644
index 000000000..9be86d8b9
--- /dev/null
+++ b/cms-content/src/main/java/com/condation/cms/content/usage/PersistedUsageSource.java
@@ -0,0 +1,30 @@
+package com.condation.cms.content.usage;
+
+/*-
+ * #%L
+ * CMS Content
+ * %%
+ * Copyright (C) 2023 - 2026 CondationCMS
+ * %%
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ * #L%
+ */
+
+import com.condation.cms.api.usage.Usage;
+import com.condation.cms.api.usage.UsageProblem;
+import java.util.List;
+
+/** One atomic Lucene document contains the extraction, file stamp and resolved outgoing edges. */
+record PersistedUsageSource(UsageDocument document, String fileStamp, String schema,
+ List usages, List extractionProblems, List problems) {}
diff --git a/cms-content/src/main/java/com/condation/cms/content/usage/UsageDocument.java b/cms-content/src/main/java/com/condation/cms/content/usage/UsageDocument.java
new file mode 100644
index 000000000..b0c8ddcfc
--- /dev/null
+++ b/cms-content/src/main/java/com/condation/cms/content/usage/UsageDocument.java
@@ -0,0 +1,32 @@
+package com.condation.cms.content.usage;
+
+/*-
+ * #%L
+ * CMS Content
+ * %%
+ * Copyright (C) 2023 - 2026 CondationCMS
+ * %%
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ * #L%
+ */
+
+import com.condation.cms.api.usage.UsageResource;
+import java.util.List;
+import java.util.Map;
+
+record UsageDocument(UsageResource resource, String publicPath, Map metadata,
+ List references) {
+ String title() { return String.valueOf(metadata.getOrDefault("title", resource.path())); }
+ String status() { return String.valueOf(metadata.getOrDefault("status", "unknown")); }
+}
diff --git a/cms-content/src/main/java/com/condation/cms/content/usage/UsageExtractor.java b/cms-content/src/main/java/com/condation/cms/content/usage/UsageExtractor.java
new file mode 100644
index 000000000..141cd6436
--- /dev/null
+++ b/cms-content/src/main/java/com/condation/cms/content/usage/UsageExtractor.java
@@ -0,0 +1,227 @@
+package com.condation.cms.content.usage;
+
+/*-
+ * #%L
+ * CMS Content
+ * %%
+ * Copyright (C) 2023 - 2026 CondationCMS
+ * %%
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ * #L%
+ */
+
+import com.condation.cms.api.ui.elements.*;
+import com.condation.cms.api.ui.elements.fields.*;
+import com.condation.cms.api.usage.*;
+import com.condation.cms.api.utils.MapUtil;
+import com.condation.cms.api.utils.SectionUtil;
+import com.condation.cms.content.markdown.InlineElementTokenizer;
+import com.condation.cms.content.markdown.Options;
+import com.condation.cms.content.markdown.rules.inline.*;
+import java.io.IOException;
+import java.util.*;
+import java.util.regex.Pattern;
+import org.jsoup.Jsoup;
+import org.jsoup.nodes.TextNode;
+
+/** Static extraction only: no template evaluation, shortcode execution or media rendering. */
+final class UsageExtractor {
+ private final ContentTypes types;
+ private final UsageSite site;
+ private final List problems;
+ private final InlineElementTokenizer tokenizer;
+ private static final Pattern INLINE_CODE = Pattern.compile("(`+)([\\s\\S]*?)(? problems) {
+ this.types = types;
+ this.site = site;
+ this.problems = problems;
+ var options = new Options();
+ options.addInlineRule(new ImageLinkInlineRule(false));
+ options.addInlineRule(new ImageInlineRule());
+ options.addInlineRule(new LinkInlineRule(false));
+ tokenizer = new InlineElementTokenizer(options);
+ }
+
+ List extract(UsageResource source, Map metadata, String body) throws IOException {
+ var result = new ArrayList();
+ var forms = forms(source, metadata);
+ Set listForms = new HashSet<>();
+ forms.values().forEach(form -> fields(form).stream().filter(field -> field instanceof ListField)
+ .forEach(field -> listForms.add(field.getName())));
+ for (var entry : forms.entrySet()) {
+ if (!listForms.contains(entry.getKey())) {
+ extractForm(source, entry.getValue(), forms, metadata, "metadata", result, 0);
+ }
+ }
+ extractText(body, "body", result);
+ return List.copyOf(new LinkedHashSet<>(result));
+ }
+
+ private Map forms(UsageResource source, Map metadata) {
+ if (source.kind() == UsageResource.Kind.COLLECTION_ITEM) {
+ String collection = source.path().split("/", 2)[0];
+ var type = types.getCollection(collection);
+ if (type.isPresent()) return type.get().forms();
+ } else {
+ Object template = metadata.get("template");
+ String name = source.path().substring(source.path().lastIndexOf('/') + 1);
+ List