Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 68 additions & 2 deletions docs/reference/item.md
Original file line number Diff line number Diff line change
Expand Up @@ -487,7 +487,7 @@ Introduced in v2.2, Doorstop can include extended attributes in published output

Edit the document configuration file `.doorstop.yml` by hand to include the desired attributes.

For example, to include the `invented-by` extended attribute key and value in the published output:
For example, to include the `invented-by` extended attribute in the published output:

```yaml
settings:
Expand All @@ -497,4 +497,70 @@ settings:
attributes:
publish:
- invented-by
```
```

For simple scalar attributes (strings, numbers), the value is rendered directly in the output table.

For list attributes, the values are joined with `<br>` as separator:

```yaml
# Item attribute:
verification-method:
- system test
- analysis

# Rendered as:
# | verification-method | system test<br>analysis |
```

### Publishing sub-attributes of structured attributes

When an extended attribute contains a **list of dictionaries** (a structured attribute), you can select specific sub-attributes for publishing instead of rendering the raw object.

Use the attribute name directly as key with a `fields` configuration:

```yaml
attributes:
publish:
- invented-by # simple attribute – unchanged behavior
- spec-refs-from: # structured attribute – select sub-attributes
fields:
- url: section # {url_key: label_key} → renders as a hyperlink
- spec-refs-to:
fields:
- url: section
```

The `fields` list supports three entry formats:

| Format | Example | Result |
| ------------------------------------------- | -------------- | -------------------------------------- |
| `{url_key: label_key}` | `url: section` | single field as link text |
| `{url_key: {label: [...], separator: ...}}` | see below | multiple fields combined as link text |
| `fieldname` | `section` | plain text value of that sub-attribute |

**Simple label (single field):**
```yaml
attributes:
publish:
- spec-refs-from:
fields:
- url: section
# → [Stop Functions](https://...)
```

**Combined label (multiple fields):**
```yaml
attributes:
publish:
- spec-refs-from:
fields:
- url:
label: [file, section]
separator: ": "
# → [System_Safety_Concept: Stop Functions](https://...)
```

The `label` key accepts a list of sub-attribute names. The `separator` key is optional and defaults to `": "` if omitted.

Multiple entries in the structured attribute list are separated by `<br>` in the published output.
100 changes: 95 additions & 5 deletions doorstop/core/publishers/markdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,69 @@ def _generate_heading_from_item(self, item, to_html=False):
result = standard + attr_list
return result

@staticmethod
def _parse_publish_entry(entry):
"""Parse a publish entry from .doorstop.yml.

Backward compatible:
- str → {'attr': entry, 'fields': None}
- dict with single key → {'attr': key, 'fields': config.get('fields')}
"""
if isinstance(entry, str):
return {"attr": entry, "fields": None}
elif isinstance(entry, dict):
if len(entry) == 1:
attr = next(iter(entry))
config = entry[attr]
if isinstance(config, dict):
return {"attr": attr, "fields": config.get("fields", None)}
else:
return {"attr": attr, "fields": None}
return None

@staticmethod
def _render_fields(refs: list, fields: list) -> str:
results = []
for ref in refs:
parts = []
for field_entry in fields:
if isinstance(field_entry, dict):
for url_key, label_spec in field_entry.items():
url = ref.get(url_key, "").strip()

# label_spec may be one of:
# - str: label: section → single field
# - dict: label: [file, section] → combined label
if isinstance(label_spec, str):
# single field
label = ref.get(label_spec, url_key).strip()
elif isinstance(label_spec, dict):
# combined label
label_fields = label_spec.get("label", [])
separator = label_spec.get("separator", ": ")
if isinstance(label_fields, str):
label_fields = [label_fields]
label = separator.join(
str(ref.get(f, "")).strip()
for f in label_fields
if ref.get(f, "").strip()
)
if not label:
label = url_key
else:
label = url_key

if url:
parts.append(f"[{label}]({url})")
else:
parts.append(label)

elif isinstance(field_entry, str):
parts.append(str(ref.get(field_entry, "")).strip())

results.append(" ".join(parts))
return "<br>".join(results)

def _lines_markdown(self, obj, **kwargs):
"""Yield lines for a Markdown report.

Expand All @@ -269,7 +332,7 @@ def _lines_markdown(self, obj, **kwargs):
linkify = kwargs.get("linkify", False)
to_html = kwargs.get("to_html", False)
for item in iter_items(obj):
# Create iten heading.
# Create item heading.
complete_heading = self._generate_heading_from_item(item, to_html=to_html)
yield complete_heading

Expand Down Expand Up @@ -313,17 +376,44 @@ def _lines_markdown(self, obj, **kwargs):
# Add custom publish attributes
if item.document and item.document.publish:
header_printed = False
for attr in item.document.publish:
if not item.attribute(attr):
for entry in item.document.publish:
parsed = self._parse_publish_entry(entry)
attr = parsed.get("attr") if parsed else None
fields = parsed.get("fields") if parsed else None
if not attr: # catches None AND missing 'attr'
continue

value = item.attribute(attr)
if not value:
continue

if not header_printed:
header_printed = True
yield ""
yield "| Attribute | Value |"
yield "| --------- | ----- |"
yield "| {} | {} |".format(attr, item.attribute(attr))
yield ""

# Sub-Attribute-Selection: fields given and value is a list of dicts
if (
fields
and isinstance(fields, list)
and isinstance(value, list)
and value
and isinstance(value[0], dict)
):
rendered = self._render_fields(value, fields)
yield "| {} | {} |".format(attr, rendered)

# Fallback: standard case (backward compatible)
else:
if isinstance(value, list):
yield "| {} | {} |".format(
attr, "<br>".join(str(v) for v in value)
)
else:
yield "| {} | {} |".format(attr, value)
if header_printed:
yield ""
yield "" # break between items


Expand Down
60 changes: 60 additions & 0 deletions doorstop/core/publishers/tests/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
- REQ002: # Hello, world! !["...
- REQ2-001: # Hello, world!
"""

YAML_CUSTOM_ATTRIBUTES = """
settings:
digits: 3
Expand All @@ -26,6 +27,65 @@
- CUSTOM-ATTRIB
- invented-by
"""

YAML_STRUCTURED_ATTRIBUTES = """\
settings:
digits: 3
prefix: REQ
sep: ''
attributes:
publish:
- type
- verification-method
- spec-refs-from:
fields:
- url: section
"""

YAML_LIST_ATTRIBUTE = """\
settings:
digits: 3
prefix: REQ
sep: ''
attributes:
publish:
- verification-method
"""

YAML_INVALID_PUBLISH_ENTRY = """\
settings:
digits: 3
prefix: REQ
sep: ''
attributes:
publish:
- ~
"""

YAML_COMBINED_LABEL_ATTRIBUTES = """\
settings:
digits: 3
prefix: REQ
sep: ''
attributes:
publish:
- spec-refs-from:
fields:
- url:
label: [file, section]
separator: ": "
"""

YAML_SINGLE_ATTRIBUTE = """\
settings:
digits: 3
prefix: REQ
sep: ''
attributes:
publish:
- type
"""

HTML_TEMPLATE_WALK = """
template/
bootstrap.bundle.min.js
Expand Down
Loading
Loading