Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,16 @@ docs(
data = [
"@score_process//:needs_json",
],
bundles = [
{
"bundle": "//src/extensions/score_mounts/docs:concept",
"mount_at": "concepts/mounts",
},
{
"bundle": "//src/extensions/score_mounts/docs:howto",
"mount_at": "how-to/mounts",
},
],
scan_code = [
"//scripts_bazel:sources",
"//src:all_sources",
Expand Down
120 changes: 120 additions & 0 deletions MODULE.bazel.lock

Large diffs are not rendered by default.

45 changes: 45 additions & 0 deletions bzl/basics.bzl
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# *******************************************************************************
# Copyright (c) 2026 Contributors to the Eclipse Foundation
#
# See the NOTICE file(s) distributed with this work for additional
# information regarding copyright ownership.
#
# This program and the accompanying materials are made available under the
# terms of the Apache License Version 2.0 which is available at
# https://www.apache.org/licenses/LICENSE-2.0
#
# SPDX-License-Identifier: Apache-2.0
# *******************************************************************************
def join_path(prefix, rest):
"""Compose two docname segments with `/`.

Args:
prefix: Leading docname segment, possibly empty.
rest: Trailing docname segment, possibly empty.

Returns:
The combined docname.
"""
if not prefix or prefix == ".":
return rest
if not rest:
return prefix
return prefix + "/" + rest

def dirname(path):
idx = path.rfind("/")
return "" if idx < 0 else path[:idx]

def glob_doc_sources(prefix):
"""Return glob patterns for documentation sources below ``prefix``."""
extensions = [
"png", "svg", "md", "rst", "html", "css",
"puml", "need", "yaml", "json", "csv", "inc",
]
if prefix == ".":
prefix = ""
elif prefix and not prefix.endswith("/"):
prefix += "/"
param = [prefix + "**/*." + ext for ext in extensions]
srcs = native.glob(param, allow_empty = True)
return srcs
306 changes: 306 additions & 0 deletions bzl/bundle_rules.bzl
Original file line number Diff line number Diff line change
@@ -0,0 +1,306 @@
# *******************************************************************************
# Copyright (c) 2026 Contributors to the Eclipse Foundation
#
# See the NOTICE file(s) distributed with this work for additional
# information regarding copyright ownership.
#
# This program and the accompanying materials are made available under the
# terms of the Apache License Version 2.0 which is available at
# https://www.apache.org/licenses/LICENSE-2.0
#
# SPDX-License-Identifier: Apache-2.0
# *******************************************************************************
"""Internal Bazel support for composing reusable documentation bundles."""

# `docs_bundle` and `sphinx_docs_library` operate at a similar architectural level:
# both describe reusable, transitively composable collections of documentation sources
# that are later assembled into a Sphinx source tree.

# However, their data models and responsibilities differ significantly.

# `sphinx_docs_library` primarily models file placement. Each library contributes files
# together with a `strip_prefix` and a `prefix`, allowing the final Sphinx rule to map
# every source file to a new location in the generated source tree.

# `docs_bundle` instead models documentation structure at the bundle level. In
# addition to the source files, it propagates information such as:

# * where a bundle is mounted, * which document it is attached to, * which document acts
# as its entry point, * which repository owns its sources, * whether it is an internal
# or external bundle, * and how nested
# bundles are rebased when composed.

# It also performs bundle-specific validation and conflict detection. The propagated unit
# is therefore not just a set of files with path transformations, but a structured
# documentation component with composition semantics.

# Using `sphinx_docs_library` directly would not preserve the metadata required by this
# model. We would need a second provider alongside it and would still have to implement
# most of the bundle traversal, rebasing, validation, and composition logic ourselves.

# Extending `sphinx_docs_library` is also not a good fit. Its provider represents
# individual file mappings, while our provider represents complete mounted bundles. Adding
# the required metadata would therefore not be a small extension of the existing
# abstraction; it would change its propagated unit and its semantics. It would also couple
# SCORE-specific composition rules to the generic `rules_sphinxdocs` implementation.

# We therefore reimplement the relatively small overlapping partβ€”transitive source
# collectionβ€”while keeping the richer bundle model explicit and independent.

# The name `docs_bundle` reflects that relationship: it fills the same general role
# as `sphinx_docs_library`, but uses a SCORE-specific data model for composing structured
# documentation bundles.



load("@score_docs_as_code//:bzl/basics.bzl", "join_path")

# Internal data passed between bundle targets and eventually consumed by an
# adapter such as the Sphinx mounts manifest. Users configure bundles through
# `docs_bundle()` and `docs()`; they do not need to reference this provider.
DocsBundleInfo = provider(
doc = "A documentation bundle with its source and placement metadata.",
fields = {
"entries": "Ordered entries, one per source directory, including its final documentation-tree location.",
"sourcelinks": "Source-code-link JSON files together with their owning repository.",
},
)

def _parent_index_docname(mount_at):
"""Choose the page that links to a bundled subtree by default."""
parent = mount_at.rsplit("/", 1)[0] if "/" in mount_at else ""
return join_path(parent, "index")

def _validate_and_deduplicate_entries(entries):
"""Keep one entry per source directory and reject conflicting metadata."""
seen = {}
out = []
for entry in entries:
key = entry.runtime_path
if key in seen:
differing_fields = [
field
for field in ["mount_at", "attach_to", "entry_doc", "src_root", "external", "repository"]
if getattr(seen[key], field) != getattr(entry, field)
]
if differing_fields:
fail(("bundle conflict: source directory %r has conflicting %s; " +
"a bundle must resolve to one complete placement declaration") %
(key, differing_fields))
continue
seen[key] = entry
out.append(entry)
return out

def _bundle_runtime_path(ctx):
"""Return this bundle source directory's Bazel runtime path."""
source_file = ctx.files.srcs[0].short_path
external_prefix = ""
if source_file.startswith("../"):
path_parts = source_file.split("/")
external_prefix = path_parts[0] + "/" + path_parts[1] + "/"
return external_prefix + ctx.attr.strip_prefix.rstrip("/")

def _bundle_execroot_path(runtime_path):
"""Return the execroot-relative spelling of an external runtime path."""
if runtime_path.startswith("../"):
return "external/" + runtime_path[3:]
return runtime_path

def _rebase_bundle_entry(entry, mount_at, attach_to, entry_doc):
"""Place a bundle entry below a requested documentation-tree location."""
is_bundle_root = not entry.mount_at
if is_bundle_root:
rebased_attach_to = attach_to or _parent_index_docname(mount_at)
else:
rebased_attach_to = join_path(mount_at, entry.attach_to)

return struct(
runtime_path = entry.runtime_path,
src_root = entry.src_root,
mount_at = join_path(mount_at, entry.mount_at),
attach_to = rebased_attach_to,
entry_doc = entry_doc if is_bundle_root else entry.entry_doc,
external = entry.external,
repository = entry.repository,
)

def _entries_visible_through(ctx, child):
"""Keep an external module's own docs, but not its foreign mounts."""
entries = child[DocsBundleInfo].entries
child_repository = child.label.workspace_name
if child_repository == ctx.label.workspace_name:
return entries
return [entry for entry in entries if entry.repository == child_repository]

def _sourcelinks_visible_through(ctx, child):
"""Apply the same external-module boundary to traceability metadata."""
sourcelinks = child[DocsBundleInfo].sourcelinks
child_repository = child.label.workspace_name
if child_repository == ctx.label.workspace_name:
return sourcelinks
return [link for link in sourcelinks if link.repository == child_repository]

def _validate_docname(value, field_name, allow_empty = False):
"""Validate a relative documentation docname used in a bundle declaration."""
if type(value) != "string":
fail("%s must be a string, got %r" % (field_name, value))
if not value:
if allow_empty:
return
fail("%s must not be empty" % field_name)
invalid_segments = [segment for segment in value.split("/") if not segment or segment in [".", ".."]]
if invalid_segments:
fail("%s must be a relative docname without empty, '.' or '..' segments; got %r" %
(field_name, value))

def _parse_bundle_declaration(bundle):
"""Read one nested-bundle declaration and fill in optional values."""
if type(bundle) != "dict":
fail("each bundle declaration must be a dict, got %r" % bundle)

allowed_keys = ["bundle", "mount_at", "attach_to", "entry_doc"]
unknown = [key for key in bundle if key not in allowed_keys]
if unknown:
fail("unknown key(s) %r in %r; allowed keys: %r" %
(unknown, bundle, allowed_keys))
if "bundle" not in bundle or "mount_at" not in bundle:
fail("each entry needs 'bundle' and 'mount_at'; got %r" % bundle)

mount_at = bundle["mount_at"]
attach_to = bundle.get("attach_to", "")
entry_doc = bundle.get("entry_doc", "index")
_validate_docname(mount_at, "mount_at", allow_empty = True)
_validate_docname(attach_to, "attach_to", allow_empty = True)
_validate_docname(entry_doc, "entry_doc")

return struct(
bundle = bundle["bundle"],
mount_at = mount_at,
attach_to = attach_to,
entry_doc = entry_doc,
)

def _docs_bundle_impl(ctx):
"""Compose source files and nested bundles into a reusable bundle."""
entries = []
direct_files = []

if ctx.files.srcs:
runtime_path = _bundle_runtime_path(ctx)
external = runtime_path.startswith("../")
entries.append(struct(
runtime_path = runtime_path,
# The execution root and runfiles tree spell external repositories
# differently. Keep both locations so every public docs() target can
# resolve them in its own context.
src_root = _bundle_execroot_path(runtime_path),
mount_at = "",
attach_to = "",
entry_doc = "index",
external = external,
repository = ctx.label.workspace_name,
))
direct_files.extend(ctx.files.srcs)

transitive_files = []
sourcelinks = [
struct(file = source_link, repository = ctx.label.workspace_name)
for source_link in ctx.files.sourcelinks
]
for index, child in enumerate(ctx.attr.bundles):
entries.extend([
_rebase_bundle_entry(
entry,
ctx.attr.bundle_mount_ats[index],
ctx.attr.bundle_attach_tos[index],
ctx.attr.bundle_entry_docs[index],
)
for entry in _entries_visible_through(ctx, child)
])
transitive_files.append(child[DefaultInfo].files)
sourcelinks.extend(_sourcelinks_visible_through(ctx, child))

deduplicated_entries = _validate_and_deduplicate_entries(entries)
files = depset(direct = direct_files, transitive = transitive_files)
return [
DefaultInfo(files = files),
DocsBundleInfo(
entries = deduplicated_entries,
sourcelinks = sourcelinks,
),
]

_docs_bundle = rule(
implementation = _docs_bundle_impl,
attrs = {
"srcs": attr.label_list(allow_files = True),
"sourcelinks": attr.label_list(allow_files = True),
"strip_prefix": attr.string(default = ""),
"bundles": attr.label_list(providers = [DocsBundleInfo]),
"bundle_mount_ats": attr.string_list(),
"bundle_attach_tos": attr.string_list(),
"bundle_entry_docs": attr.string_list(),
},
doc = "Internal rule that carries bundle files and their documentation-tree locations.",
)

def create_bundle(name, bundles, srcs = [], sourcelinks = [], strip_prefix = "", visibility = None, **kwargs):
"""Create a reusable documentation bundle from files and child declarations."""
parsed_bundles = [_parse_bundle_declaration(declaration) for declaration in bundles]
_docs_bundle(
name = name,
srcs = srcs,
sourcelinks = sourcelinks,
strip_prefix = strip_prefix,
bundles = [bundle.bundle for bundle in parsed_bundles],
bundle_mount_ats = [bundle.mount_at for bundle in parsed_bundles],
bundle_attach_tos = [bundle.attach_to for bundle in parsed_bundles],
bundle_entry_docs = [bundle.entry_doc for bundle in parsed_bundles],
visibility = visibility,
**kwargs
)
return ":" + name

def _merge_bundle_sourcelinks_impl(ctx):
"""Merge source-code links propagated by a documentation bundle."""
sourcelinks = [link.file for link in ctx.attr.bundle[DocsBundleInfo].sourcelinks]
out = ctx.actions.declare_file(ctx.label.name + ".json")
args = ctx.actions.args()
args.add("--output", out.path)
if ctx.file.known_good:
args.add("--known_good", ctx.file.known_good.path)
args.add_all(sourcelinks)
inputs = [depset(sourcelinks)]
if ctx.file.known_good:
inputs.append(depset([ctx.file.known_good]))
ctx.actions.run(
executable = ctx.executable._merge_sourcelinks,
arguments = [args],
inputs = depset(transitive = inputs),
outputs = [out],
mnemonic = "MergeBundleSourcelinks",
)
return [DefaultInfo(files = depset([out]))]

_merge_bundle_sourcelinks = rule(
implementation = _merge_bundle_sourcelinks_impl,
attrs = {
"bundle": attr.label(providers = [DocsBundleInfo]),
"known_good": attr.label(allow_single_file = True),
"_merge_sourcelinks": attr.label(
default = Label("//scripts_bazel:merge_sourcelinks"),
cfg = "exec",
executable = True,
),
},
)

def merge_bundle_sourcelinks(name, bundle, known_good = None, visibility = None):
"""Create one source-code-link JSON file for a complete docs bundle."""
_merge_bundle_sourcelinks(
name = name,
bundle = bundle,
known_good = known_good,
visibility = visibility,
)
Loading
Loading