diff --git a/40ants-doc-full.asd b/40ants-doc-full.asd index 834fa000..be44eda2 100644 --- a/40ants-doc-full.asd +++ b/40ants-doc-full.asd @@ -13,6 +13,7 @@ :pathname "full" :depends-on (;; "40ants-doc/full" "40ants-doc-full/builder" + "40ants-doc-full/assets" "40ants-doc-full/doc" ;; TODO: Все эти locatives надо сделать доступными в минимальном пакете diff --git a/40ants-doc-test.asd b/40ants-doc-test.asd index dc313d33..542fe84a 100644 --- a/40ants-doc-test.asd +++ b/40ants-doc-test.asd @@ -8,6 +8,7 @@ "40ants-doc-full" "40ants-doc-test/test-transcribe" "40ants-doc-test/autodoc" + "40ants-doc-test/assets" "40ants-doc-test/test" "40ants-doc-test/xref" "40ants-doc-test/locatives" diff --git a/AGENTS.md b/AGENTS.md index ace22326..8ab74057 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,10 @@ This is a Common Lisp documentation library split into ASDF package-inferred sys Keep a feature in the smallest appropriate subsystem. For example, add a core locative in `src/locatives/` and a rendering-specific locative in `full/locatives/`. +## Development Workflow + +Use the `atomic-spec-orchestrator` skill for all project work. Follow its Atomic Spec process, including the required role transitions and gate validation, when applicable. + ## Build, Test, and Development Commands Install the locked dependencies once with: diff --git a/full/assets.lisp b/full/assets.lisp new file mode 100644 index 00000000..4e851d5b --- /dev/null +++ b/full/assets.lisp @@ -0,0 +1,156 @@ +(uiop:define-package #:40ants-doc-full/assets + (:use #:cl) + (:import-from #:40ants-doc-full/commondoc/image + #:local-image) + (:import-from #:40ants-doc-full/commondoc/mapper + #:map-nodes) + (:import-from #:40ants-doc-full/commondoc/xref + #:xref + #:xref-locative + #:xref-symbol) + (:export #:defasset)) +(in-package #:40ants-doc-full/assets) + + +(defclass asset () + ((name :initarg :name + :reader asset-name) + (source :initarg :source + :reader asset-source) + (target-filename :initarg :target-filename + :reader asset-target-filename) + (description :initarg :description + :reader asset-description) + (width :initarg :width + :initform nil + :reader asset-width) + (height :initarg :height + :initform nil + :reader asset-height))) + + +(defun make-asset (name source target-filename description &rest restargs + &key width height) + (declare (ignore width height)) + (apply #'make-instance 'asset + :name name + :source source + :target-filename target-filename + :description description + restargs)) + + +(defvar *assets* (make-hash-table :test #'eq)) + + +(defun asset-symbol-p (symbol) + (not (null (gethash symbol *assets*)))) + + +(defmethod 40ants-doc::transform-symbol-entry :around ((entry symbol)) + (or (gethash entry *assets*) + (call-next-method))) + + +(defmethod 40ants-doc/object-package::object-package ((asset asset)) + (symbol-package (asset-name asset))) + + +(defun pathname-designator-string (pathname-designator) + (namestring (pathname pathname-designator))) + + +(defun register-asset (name source &key target-filename description width height) + (check-type name symbol) + (check-type source (or string pathname)) + + (let* ((source (pathname-designator-string source)) + (target-filename (pathname-designator-string + (or target-filename source))) + (description (or description + (symbol-name name))) + (new-asset (make-asset name source target-filename description + :width width + :height height)) + (old-asset (gethash name *assets*))) + (when (and old-asset + (not (and (string= (asset-source old-asset) + (asset-source new-asset)) + (string= (asset-target-filename old-asset) + (asset-target-filename new-asset)) + (string= (asset-description old-asset) + (asset-description new-asset)) + (equal (asset-width old-asset) + (asset-width new-asset)) + (equal (asset-height old-asset) + (asset-height new-asset))))) + (error "Asset ~S is already registered with different properties." + name)) + (setf (gethash name *assets*) new-asset) + new-asset)) + + +(defmacro defasset (name source &rest args) + "Register NAME as a local image used in documentation. + +SOURCE is a pathname designator. By default, the file is copied to the +same relative pathname below the documentation output directory. Use +:TARGET-FILENAME to choose another relative output pathname. + +Every unqualified occurrence of NAME in prose is rendered as an image." + (check-type name symbol) + `(eval-when (:compile-toplevel :load-toplevel :execute) + (register-asset ',name ,source ,@args))) + + +(defun safe-target-filename-p (target-filename) + (let ((pathname (pathname target-filename))) + (and (not (uiop:absolute-pathname-p pathname)) + (not (member :up (pathname-directory pathname)))))) + + +(defun validate-asset (asset) + (unless (probe-file + (40ants-doc-full/commondoc/image::resolve-local-image-path + (asset-source asset))) + (error "Asset ~S source file does not exist: ~A" + (asset-name asset) + (asset-source asset))) + + (unless (safe-target-filename-p (asset-target-filename asset)) + (error "Asset ~S target filename is not below the output directory: ~A" + (asset-name asset) + (asset-target-filename asset))) + + (maphash (lambda (name other-asset) + (when (and (not (eq name (asset-name asset))) + (string= (asset-target-filename other-asset) + (asset-target-filename asset))) + (error "Assets ~S and ~S use the same target filename: ~A" + (asset-name asset) + name + (asset-target-filename asset)))) + *assets*) + asset) + + +(defun asset-to-local-image (asset) + (validate-asset asset) + (local-image (asset-source asset) + :target-filename (asset-target-filename asset) + :description (asset-description asset) + :width (asset-width asset) + :height (asset-height asset))) + + +(defun replace-assets (document) + "Replace XREF nodes naming registered assets with local image nodes." + (map-nodes document + (lambda (node) + (if (and (typep node 'xref) + (null (xref-locative node))) + (let ((asset (gethash (xref-symbol node) *assets*))) + (if asset + (asset-to-local-image asset) + node)) + node)))) diff --git a/full/builder.lisp b/full/builder.lisp index b8881713..de27649c 100644 --- a/full/builder.lisp +++ b/full/builder.lisp @@ -23,6 +23,7 @@ (:import-from #:40ants-doc-full/commondoc/section) (:import-from #:40ants-doc-full/commondoc/xref) (:import-from #:40ants-doc-full/commondoc/image) + (:import-from #:40ants-doc-full/assets) (:import-from #:40ants-doc-full/external-index #:write-references-index) (:import-from #:40ants-doc-full/commondoc/reference) @@ -209,6 +210,11 @@ 40ants-doc-full/builder/vars::*current-asdf-system*) +(defmethod 40ants-doc-full/commondoc/builder:to-commondoc + ((asset 40ants-doc-full/assets::asset)) + (40ants-doc-full/assets::asset-to-local-image asset)) + + ;;; Generate with the default HTML look (defun process-document (document &key base-url) @@ -217,10 +223,13 @@ (document (40ants-doc-full/commondoc/page:warn-on-undocumented-exports document references)) (document (40ants-doc-full/commondoc/transcribe::warn-on-differences-in-transcriptions document)) - (document (if 40ants-doc-full/builder/printer:*document-uppercase-is-code* - (40ants-doc-full/commondoc/xref::extract-symbols document) - document)) + (document (40ants-doc-full/commondoc/xref::extract-symbols + document + :predicate (if 40ants-doc-full/builder/printer:*document-uppercase-is-code* + (constantly t) + #'40ants-doc-full/assets::asset-symbol-p))) (document (40ants-doc-full/commondoc/xref:fill-locatives document)) + (document (40ants-doc-full/assets::replace-assets document)) (document (40ants-doc-full/commondoc/section::fill-html-fragments document)) (document (40ants-doc-full/commondoc/page::warn-on-references-to-internals document)) (document (if 40ants-doc-full/link:*document-link-code* @@ -347,6 +356,9 @@ (ensure-directories-exist absolute-dir) + (40ants-doc-full/commondoc/image::copy-local-images full-document + absolute-dir) + (when (eql 40ants-doc-full/commondoc/format::*current-format* 'common-html:html) (write-references-index (uiop:merge-pathnames* "references.json" absolute-dir) references diff --git a/full/commondoc/image.lisp b/full/commondoc/image.lisp index 7a66ba5a..a6ddc659 100644 --- a/full/commondoc/image.lisp +++ b/full/commondoc/image.lisp @@ -37,7 +37,8 @@ :reader height))) -(defun full-path (path) +(defun resolve-local-image-path (path) + "Resolve PATH according to the current documented ASDF system." (cond ((str:starts-with-p "asdf:" path) (destructuring-bind (prefix asdf-system-name path) @@ -135,7 +136,7 @@ and then makes a paragraph with image, pointing to this temp file. " - (let ((full-path (full-path path))) + (let ((full-path (resolve-local-image-path path))) (unless (probe-file full-path) (error "Image file \"~A\" not found" full-path)) @@ -178,6 +179,41 @@ (t node)))) (map-nodes document #'replacer))) + +(defun target-path (image base-dir) + (uiop:merge-pathnames* (target-filename image) + (uiop:ensure-directory-pathname base-dir))) + + +(defun copy-local-image (image base-dir copied-targets) + (let* ((source-path (common-doc:source image)) + (target-path (target-path image base-dir))) + (unless (gethash target-path copied-targets) + (unless (equal source-path target-path) + (log:info "Copying image from ~A to ~A" source-path target-path) + (ensure-directories-exist target-path) + (uiop:copy-file source-path target-path)) + (setf (gethash target-path copied-targets) t)))) + + +(defun copy-local-images (document base-dir) + "Copy all local images in DOCUMENT to BASE-DIR exactly once per target." + (let ((copied-targets (make-hash-table :test #'equal))) + (map-nodes document + (lambda (node) + (when (typep node 'local-image) + (copy-local-image node base-dir copied-targets)) + node)))) + + +(defun image-source (image) + (let* ((base-dir (uiop:ensure-directory-pathname + 40ants-doc-full/builder/vars::*base-dir*)) + (target-path (target-path image base-dir)) + (page-uri (make-page-uri 40ants-doc-full/builder/vars::*current-page*)) + (relative-path (namestring (uiop:enough-pathname target-path base-dir)))) + (make-relative-path page-uri relative-path))) + (defun extract-width-and-height (path) "Returns 3 values, real path, width and height. Width and height might be NIL. @@ -209,37 +245,13 @@ (define-emitter (obj local-image) - "Emit a local-image and move referenced image into the HTML documentation folder." - (let* ((source-path - ;; This is the path from where we will copy file: - (common-doc:source obj)) - ;; Directory where we render all documentation files: - (base-dir - (uiop:ensure-directory-pathname - 40ants-doc-full/builder/vars::*base-dir*)) - (target-path (uiop:merge-pathnames* (target-filename obj) - base-dir)) - ;; Path of the current page: - (page-uri (make-page-uri 40ants-doc-full/builder/vars::*current-page*)) - ;; This path will be used on the web page to refer the image: - (relative-path (namestring - (uiop:enough-pathname target-path - base-dir))) - (new-source (make-relative-path page-uri - relative-path)) + "Emit a local image already copied to the documentation output folder." + (let* ((new-source (image-source obj)) (src (if common-html.emitter:*image-format-control* (format nil common-html.emitter:*image-format-control* new-source) new-source)) (description (common-doc:description obj))) - - (unless (equal source-path - target-path) - (log:info "Copying image from ~A to ~A" source-path target-path) - (ensure-directories-exist target-path) - (uiop:copy-file source-path - target-path)) - (with-html (:img :src src :alt description @@ -247,3 +259,10 @@ :width (width obj) :height (height obj))))) + +(defmethod common-doc.format:emit-document ((format commondoc-markdown:markdown) + (obj local-image) + stream) + (format stream "![~A](~A)" + (common-doc:description obj) + (image-source obj))) diff --git a/full/commondoc/xref.lisp b/full/commondoc/xref.lisp index 095c1827..24364d8d 100644 --- a/full/commondoc/xref.lisp +++ b/full/commondoc/xref.lisp @@ -166,33 +166,24 @@ thereis (alpha-char-p char))) -(defun extract-symbols-from-text (node) +(defun extract-symbols-from-text (node &key (predicate (constantly t))) ;; TODO: Find if this a replacement for FIND-DEFINITIONS-FIND-SYMBOL-OR-PACKAGE. (let ((text (common-doc:text node)) (new-nodes nil) (processed-to-idx 0)) - (cl-ppcre:do-matches (start end "([A-Z0-9][A-Z0-9-/.]+::?)?[+*@&:]?[A-Z0-9][A-Z0-9-]*[A-Z0-9]+[+*]?" text) - (let ((symbol-name (subseq text start end))) - (when (valid symbol-name) + (cl-ppcre:do-matches (start end "([A-Z0-9][A-Z0-9-/.]+::?)?[+*@&:]?[A-Z0-9][A-Z0-9-.]*[A-Z0-9]+[+*]?" text) + (let* ((symbol-name (subseq text start end)) + (symbol (40ants-doc-full/utils:get-symbol-from-string symbol-name))) + (when (and (valid symbol-name) + (funcall predicate symbol)) (when (> start processed-to-idx) (push (common-doc:make-text (subseq text processed-to-idx start)) new-nodes)) - (let* (;; (*package* (or - ;; ;; For some reason, read-locative-from-string - ;; ;; does now work when current package is COMMON-LISP - ;; (40ants-doc-full/utils:get-package-from-symbol-name symbol-name) - ;; *package*)) - (symbol (40ants-doc-full/utils:get-symbol-from-string symbol-name))) - - ;; (when (string-equal symbol-name - ;; "40ANTS-DOC/DOC:@DOCUMENTATION-PRINTER-VARIABLES") - ;; (break)) - - (push (make-xref symbol-name - :symbol symbol) - new-nodes)) + (push (make-xref symbol-name + :symbol symbol) + new-nodes) (setf processed-to-idx end)))) @@ -226,7 +217,8 @@ (call-next-method)))) -(defun extract-symbols (node &aux inside-code-block inside-inline-code) +(defun extract-symbols (node &key (predicate (constantly t)) + &aux inside-code-block inside-inline-code) "Extracts non marked up symbols from COMMON-DOC:TEXT-NODE and replaces them with XREF objects." (labels @@ -256,7 +248,8 @@ (inside-inline-code ;; If whole content of inline code is recognized ;; as a symbol, then we'll replace it with XREF: - (let ((result (extract-symbols-from-text node))) + (let ((result (extract-symbols-from-text node + :predicate predicate))) (if (and (typep result 'xref) (= (length (common-doc:text node)) (length (common-doc:text result)))) @@ -264,7 +257,8 @@ node)) ) (t - (extract-symbols-from-text node)))) + (extract-symbols-from-text node + :predicate predicate)))) (t node)))) ;; Here we we need to change *package* ;; to make sure, that all symbol mentions are parsed as if we being diff --git a/full/doc.lisp b/full/doc.lisp index 6bc9b99f..73859174 100644 --- a/full/doc.lisp +++ b/full/doc.lisp @@ -66,6 +66,8 @@ #:height #:width #:local-image) + (:import-from #:40ants-doc-full/assets + #:defasset) (:import-from #:40ants-doc/object-package #:object-package) (:export #:@index @@ -127,9 +129,13 @@ ")) +(defasset @logo.png "static/logo.png" + :description "40ants-doc logo") + (defsection @index (:title "40Ants Doc Manual" :ignore-words *ignore-words*) *badges* + "@LOGO.PNG" (@about section) (40ants-doc system) (40ants-doc-full system) @@ -140,6 +146,7 @@ (@basics section) (@generating-documentation section) (@markdown-support section) + (@declaring-assets section) (@documentation-printer-variables section) (@locative-types section) (@extension-api section) @@ -151,6 +158,7 @@ :ignore-words (list* "DEFSECTION" *ignore-words*)) *badges* + "@LOGO.PNG" (@about section) (@full-doc-link section) (@tutorial section) @@ -683,6 +691,28 @@ See full list of changes in the 40ANTS-DOC/CHANGELOG::@CHANGELOG section. (height (reader local-image))) +(defsection @declaring-assets (:title "Declaring Assets") + "Use DEFASSET to declare a local image once and render it anywhere in + prose by writing its name. The asset is copied to the documentation + output directory, and its name becomes an HTML or Markdown image + depending on the target format. + + ```lisp + (defasset @demo.gif #\"static/demo.gif\") + + (defsection @example () + \"Here is @DEMO.GIF.\" + + @demo.gif) + ``` + + By default, the source's relative pathname is retained below the + output directory. Use the :TARGET-FILENAME option when the output + name should differ. The target must stay below the output directory + and must be unique among declared assets." + (defasset macro)) + + (defsection @reference-based-extensions (:title "Reference Based Extensions" :ignore-words ("DEFINE-DIRECTION" diff --git a/specs/document-assets/domain.spec.md b/specs/document-assets/domain.spec.md new file mode 100644 index 00000000..8a9822aa --- /dev/null +++ b/specs/document-assets/domain.spec.md @@ -0,0 +1,91 @@ +--- +id: DOC-ASSET +type: domain +parent: DOC-SYSTEM +title: "Document assets" +status: implemented +change_class: additive +actors: [Library author, Documentation reader] +emits: [AssetRegistered, AssetRendered] +consumes: [AssetDeclaration, DocumentationSource] +owners: + analyst: "@codex" + developer: "@codex" + tester: "@codex" +tags: [assets, images, rendering] +created: 2026-09-03 +updated: 2026-09-04 +--- + +## § Intent + +A library author declares a local visual asset once and refers to it by its symbolic name in documentation so that readers see the image in every rendered document. A declaration that cannot produce a safe, unambiguous output asset prevents a misleading document from being built. + +## § Domain Rules + +- **DR-1**: Each asset name identifies exactly one source file and one relative output path within a documentation build. A repeated name with different data is rejected. +- **DR-2**: An asset source file must exist and be readable when documentation is rendered. Otherwise rendering fails and identifies the asset. +- **DR-3**: An output path must remain inside the output directory and must not be claimed by a different asset. An unsafe or colliding path is rejected. +- **DR-4**: Every resolved asset reference renders as an image, not as a textual cross-reference or a hyperlink. +- **DR-5**: Each referenced asset file is copied at most once per output directory, while every use receives a path relative to its page. +- **DR-6**: Existing local Markdown images and non-asset references retain their present behaviour. + +## § Acceptance Criteria + +```gherkin +Scenario: Declared asset is rendered as an image + Given an author has declared the asset @DEMO.GIF for an existing local file + And documentation contains @DEMO.GIF + When the author renders HTML documentation + Then the output contains an image for @DEMO.GIF + And the declared file is present at its output path + +Scenario: The same asset is rendered in Markdown + Given an author has declared the asset @DEMO.GIF for an existing local file + And documentation contains @DEMO.GIF + When the author renders Markdown documentation + Then the output contains Markdown image markup for @DEMO.GIF + And the declared file is present at its output path + +Scenario: Asset use on a nested page + Given an author has declared an existing local asset + And a documentation page nested below the output root contains its name + When the author renders documentation + Then the image source is relative to the nested page + +Scenario: Missing asset file is rejected + Given an author has declared an asset whose source file is absent + When the author renders documentation containing its name + Then rendering fails and identifies the declared asset + +Scenario: Colliding output paths are rejected + Given two assets are declared for the same output path + When the author renders documentation that uses them + Then rendering fails and identifies the collision +``` + +## § Domain Model Touch + +- **Aggregate**: Asset registry (new) +- **Entities**: Asset declaration { name, source, output path, description } +- **Events emitted**: AssetRegistered, AssetRendered +- **Events consumed**: AssetDeclaration, DocumentationSource +- **Invariants**: unique name; safe and unique output path; source exists; references become images +- **Read Models affected**: HTML documentation, Markdown documentation + +## § Constraints + +- **[COMPAT]** The feature is additive and does not change the public behaviour of existing `local-image` users. +- **[IDMP]** Rendering an asset multiple times or rendering the same document again yields one output file per declared target path. +- **[SEC]** Relative target paths containing traversal outside the output directory are rejected. +- **[PERF]** Asset lookup is constant-time with respect to the number of declarations. + +## § Open Questions + +None. + +## § Decision Log + +- **DL-1** (2026-09-03): A bare asset symbol represents the image itself in all supported formats, per requester confirmation. +- **DL-2** (2026-09-03): The declaration's relative source path is also the default output path; an explicit output-path option may be added only if needed for collision resolution. +- **DL-3** (2026-09-03): Copying occurs in a format-independent build stage so Markdown output does not depend on the HTML emitter's side effects. diff --git a/specs/document-assets/inline-assets/scenarios/asset-rendering.spec.md b/specs/document-assets/inline-assets/scenarios/asset-rendering.spec.md new file mode 100644 index 00000000..3adc486b --- /dev/null +++ b/specs/document-assets/inline-assets/scenarios/asset-rendering.spec.md @@ -0,0 +1,63 @@ +--- +id: DOC-ASSET-INLINE-001-SC-001 +type: scenario +parent: DOC-ASSET-INLINE-001 +title: "Render a declared asset as an inline image" +status: implemented +change_class: additive +actors: [Library author, Documentation reader] +emits: [AssetRendered] +consumes: [AssetDeclaration, DocumentationSource] +owners: + analyst: "@codex" + developer: "@codex" + tester: "@codex" +tags: [assets, regression] +created: 2026-09-03 +updated: 2026-09-04 +--- + +## § Intent + +A library author uses a declared asset name in documentation so a reader sees the declared image in the resulting HTML or Markdown file. + +## § Domain Rules + +- **DR-1**: A resolvable asset name is represented by an image in the selected output format. +- **DR-2**: The image target exists in the output directory and is addressed relative to the page. +- **DR-3**: A declared asset is recognized when implicit uppercase-code formatting is disabled and when used as a direct DEFSECTION entry. + +## § Acceptance Criteria + +```gherkin +Scenario: Output contains a declared image + Given an author has declared @DEMO.GIF for an existing local image + And a documentation page contains @DEMO.GIF + When the author renders the page as HTML or Markdown + Then the page contains image syntax for @DEMO.GIF + And the referenced image file is present in the output directory + +Scenario: Explicit or non-implicit asset inclusion + Given an author has declared @DEMO.GIF for an existing local image + When the author disables implicit uppercase-code formatting or uses @DEMO.GIF as a direct DEFSECTION entry + Then the page contains image syntax for @DEMO.GIF +``` + +## § Domain Model Touch + +- **Aggregate**: Asset registry (query) +- **Events emitted**: AssetRendered +- **Events consumed**: AssetDeclaration, DocumentationSource +- **Invariants**: image representation and copied target agree + +## § Constraints + +- **[COMPAT]** Standard images and standard references are unaffected. + +## § Open Questions + +None. + +## § Decision Log + +- **DL-1** (2026-09-03): HTML and Markdown are both first-class outputs for this scenario. diff --git a/specs/document-assets/inline-assets/scenarios/invalid-asset.spec.md b/specs/document-assets/inline-assets/scenarios/invalid-asset.spec.md new file mode 100644 index 00000000..62e760e3 --- /dev/null +++ b/specs/document-assets/inline-assets/scenarios/invalid-asset.spec.md @@ -0,0 +1,62 @@ +--- +id: DOC-ASSET-INLINE-001-SC-002 +type: scenario +parent: DOC-ASSET-INLINE-001 +title: "Reject an invalid declared asset" +status: implemented +change_class: additive +actors: [Library author] +emits: [] +consumes: [AssetDeclaration, DocumentationSource] +owners: + analyst: "@codex" + developer: "@codex" + tester: "@codex" +tags: [assets, validation] +created: 2026-09-03 +updated: 2026-09-04 +--- + +## § Intent + +A library author receives a clear error before publication when a declared asset has no usable source or cannot be placed safely in the output. + +## § Domain Rules + +- **DR-1**: A missing source file prevents rendering and identifies the asset name and source. +- **DR-2**: A target outside the output root or shared by a different asset prevents rendering and identifies the conflict. + +## § Acceptance Criteria + +```gherkin +Scenario: Missing source + Given an author has declared @MISSING.GIF for an absent file + And documentation contains @MISSING.GIF + When the author renders documentation + Then rendering fails with the name @MISSING.GIF and the missing source + +Scenario: Unsafe or colliding target + Given an author has declared an asset with an unsafe or already claimed target path + And documentation contains its name + When the author renders documentation + Then rendering fails with the asset name and target-path reason +``` + +## § Domain Model Touch + +- **Aggregate**: Asset registry (validate) +- **Events emitted**: [] +- **Events consumed**: AssetDeclaration, DocumentationSource +- **Invariants**: sources exist; targets are safe and unique + +## § Constraints + +- **[SEC]** Rendering cannot write an asset outside the output root. + +## § Open Questions + +None. + +## § Decision Log + +- **DL-1** (2026-09-03): Validation occurs while rendering so declarations can precede generated files, but an invalid referenced asset cannot silently yield broken output. diff --git a/specs/document-assets/inline-assets/usecase.spec.md b/specs/document-assets/inline-assets/usecase.spec.md new file mode 100644 index 00000000..2d26d033 --- /dev/null +++ b/specs/document-assets/inline-assets/usecase.spec.md @@ -0,0 +1,177 @@ +--- +id: DOC-ASSET-INLINE-001 +type: use-case +parent: DOC-ASSET +title: "Render declared asset inline" +status: implemented +change_class: additive +actors: [Library author, Documentation reader] +emits: [AssetRegistered, AssetRendered] +consumes: [AssetDeclaration, DocumentationSource] +owners: + analyst: "@codex" + developer: "@codex" + tester: "@codex" +tags: [assets, inline-image, html, markdown] +created: 2026-09-03 +updated: 2026-09-04 +--- + +## § Intent + +A library author declares an image once and places its name in documentation so a documentation reader sees that image in the rendered format. If the declaration cannot be resolved safely, rendering stops instead of emitting a broken image. + +## § Domain Rules + +- **DR-1**: A declared asset name resolves to exactly one existing source file. A missing source fails rendering and identifies the name. +- **DR-2**: A declared asset name in documentation becomes an image in each supported output format. It must not be rendered as text or a navigation link. +- **DR-3**: The image file is copied to one safe, unique path below the output root. A path collision or traversal attempt fails rendering. +- **DR-4**: Image source paths are relative to the page that contains the image, including nested pages. +- **DR-5**: Repeated occurrences of the same asset do not duplicate its output file. +- **DR-6**: A declared asset name in prose is recognized even when implicit uppercase-code formatting is disabled. +- **DR-7**: A declared asset symbol may be used directly as a DEFSECTION entry and becomes an image. + +## § Acceptance Criteria + +```gherkin +Scenario: Render a declared image in HTML (→ DR-1, DR-2, DR-3) + Given an author has declared an existing asset named @DEMO.GIF + And a documentation page contains @DEMO.GIF + When the author renders HTML documentation + Then the page contains an image for @DEMO.GIF + And its source file exists below the output root + +Scenario: Render a declared image in Markdown (→ DR-2, DR-3) + Given an author has declared an existing asset named @DEMO.GIF + And a documentation page contains @DEMO.GIF + When the author renders Markdown documentation + Then the page contains image markup for @DEMO.GIF + And its source file exists below the output root + +Scenario: Render the image from a nested page (→ DR-4) + Given an author has declared an existing asset named @DEMO.GIF + And a page two directories below the output root contains @DEMO.GIF + When the author renders documentation + Then the emitted image source reaches the copied file using a relative path + +Scenario: Render a repeated image (→ DR-5) + Given an author has declared an existing asset named @DEMO.GIF + And two documentation pages contain @DEMO.GIF + When the author renders documentation + Then one output file is created for @DEMO.GIF + +Scenario: Render with implicit uppercase-code formatting disabled (→ DR-2, DR-6) + Given an author has declared an existing asset named @DEMO.GIF + And a documentation page contains @DEMO.GIF + And implicit uppercase-code formatting is disabled + When the author renders documentation + Then the page contains an image for @DEMO.GIF + +Scenario: Render an explicit asset entry (→ DR-2, DR-7) + Given an author has declared an existing asset named @DEMO.GIF + And a documentation section contains @DEMO.GIF as a direct entry + When the author renders documentation + Then the page contains an image for @DEMO.GIF + +Scenario: Reject an unusable declaration (→ DR-1, DR-3) + Given an author has declared an asset with an absent source or unsafe output path + When the author renders documentation containing its name + Then rendering fails with an error that identifies the asset and reason +``` + +## § Domain Model Touch + +- **Aggregate**: Asset registry (create and query) +- **Events emitted**: AssetRegistered, AssetRendered +- **Events consumed**: AssetDeclaration, DocumentationSource +- **Invariants**: declared source exists; target is safe and unique; rendered reference is an image + +## § Constraints + +- **[COMPAT]** Existing image syntax and regular documentation references remain unchanged. +- **[IDMP]** Copying the same registered asset is idempotent per output directory. +- **[SEC]** No asset declaration can write outside the selected output root. +- **[PERF]** Resolution of a declared asset does not scan the full declaration set. + +## § Open Questions + +None. + +## § Decision Log + +- **DL-1** (2026-09-03): The source-path-derived output path is the v1 default, avoiding an additional public option until a concrete need arises. +- **DL-2** (2026-09-03): Alt text defaults to the asset symbol's name and can be expanded in a follow-up feature if richer metadata is required. + +## § Tech Spec + +### Public API + +`40ants-doc-full/assets:defasset` registers a symbol, a local pathname +designator, and optional `:target-filename`, `:description`, `:width`, and +`:height` metadata. The default target is the source's relative pathname. + +### Rendering pipeline + +1. The existing XREF pass recognizes dotted asset names such as `@DEMO.GIF`. +2. When implicit uppercase-code formatting is disabled, the XREF pass extracts + only registered asset names. +3. `replace-assets` changes an XREF with a registered symbol into a + `local-image` node before normal XREF replacement. +4. A registered symbol used directly as a DEFSECTION entry is transformed into + its asset and then into a `local-image` by the builder. +5. The builder copies all `local-image` nodes once per target before emitting + pages, independent of the selected document format. +6. HTML emits an `img` element and Markdown emits Markdown image syntax, both + calculated relative to the page being rendered. + +### Validation + +Asset resolution checks source existence, rejects absolute or parent-directory +target paths, and rejects targets registered by another asset name. + +## § Implementation Notes + +### File Map + +- `full/assets.lisp` — registry, `defasset`, validation, and XREF replacement. +- `full/commondoc/image.lisp` — shared pre-render copying and HTML/Markdown + image emission. +- `full/builder.lisp` — invokes asset replacement and the common copying step. +- `full/commondoc/xref.lisp` — recognizes dotted symbol names. +- `test/assets.lisp` — HTML, Markdown, missing source, unsafe target, and + collision coverage, plus disabled implicit-code and direct-entry regression + coverage. + +### Trade-offs + +The registry is process-local, matching existing load-time documentation +definitions. Asset names are symbol identities, so declarations are naturally +scoped by package. + +### Known Limitations + +Assets are local images in this version. Rich metadata and non-image files are +outside this use case. + +## § Test Plan + +| Test case | Requirement | Verification | +| --- | --- | --- | +| TC-001 | HTML rendering; DR-1, DR-2, DR-3 | Render an asset page, assert the copied file and its `img` source. | +| TC-002 | Markdown rendering; DR-2, DR-3, DR-4 | Render a nested Markdown page, assert the copied file and `../assets/...` image source. | +| TC-003 | Missing source; DR-1 | Render a page using an absent source and assert an error. | +| TC-004 | Unsafe target; DR-3, [SEC] | Render a page using `../` as a target and assert an error. | +| TC-005 | Target collision; DR-3 | Render a page using one of two assets registered for the same target and assert an error. | +| TC-006 | Repeated asset; DR-5, [IDMP] | Render two occurrences and assert two image nodes sharing one target file. | +| TC-007 | Regression | Run the complete `40ants-doc-test` ASDF suite. | +| TC-008 | Static analysis | Run `40ants-linter` for core, full, and test systems with imports checking. | +| TC-009 | DR-6 | Disable implicit uppercase-code formatting and assert a bare asset name is emitted as an image. | +| TC-010 | DR-7 | Use an asset symbol directly in DEFSECTION and assert Markdown image output. | + +## § QA Review + +- All acceptance criteria are covered by TC-001 through TC-010. +- The negative scenarios cover missing input, unsafe traversal, and a duplicate + output target. +- IDMP is implemented through a target-keyed copy set; TC-006 verifies the + repeated-use case, while TC-001 and TC-002 exercise both output formats. diff --git a/specs/system.spec.md b/specs/system.spec.md new file mode 100644 index 00000000..60247871 --- /dev/null +++ b/specs/system.spec.md @@ -0,0 +1,54 @@ +--- +id: DOC-SYSTEM +type: system +title: "40ants-doc" +status: ready +change_class: compatible +actors: [Library author, Documentation reader] +emits: [DocumentationRendered] +consumes: [DocumentationSource] +owners: + analyst: "@codex" + developer: "@codex" + tester: "@codex" +tags: [documentation, common-lisp] +created: 2026-09-03 +updated: 2026-09-03 +--- + +## § Intent + +40ants-doc lets library authors keep documentation near their source and render it into publishable formats for documentation readers. + +## § Domain Rules + +- **DR-1**: A documented entity has a stable symbolic identity and can be resolved while rendering. An unresolved entity is reported to the author. +- **DR-2**: A rendered document must not reference a local file that was not included in its output. A missing local file fails the build. + +## § Acceptance Criteria + +```gherkin +Scenario: Render documentation + Given an author has documented a library + When the author renders its documentation in a supported format + Then the reader receives a document whose local references resolve within the output +``` + +## § Domain Model Touch + +- **Aggregate**: Documentation source (extend) +- **Events emitted**: DocumentationRendered +- **Events consumed**: DocumentationSource +- **Invariants**: local references resolve to output artifacts + +## § Constraints + +- **[COMPAT]** Existing documented entities and local Markdown images retain their current rendering behaviour. + +## § Open Questions + +None. + +## § Decision Log + +- **DL-1** (2026-09-03): Declarative local assets belong to the documentation-rendering domain because their value is realised only during output generation. diff --git a/src/changelog.lisp b/src/changelog.lisp index 43e219c1..d9d0f8e2 100644 --- a/src/changelog.lisp +++ b/src/changelog.lisp @@ -152,9 +152,14 @@ "*DOCUMENT-MARK-UP-SIGNATURES*" "*DOCUMENT-NORMALIZE-PACKAGES*" "*DOCUMENT-DOWNCASE-UPPERCASE-CODE*" + "*DOCUMENT-UPPERCASE-IS-CODE*" + "DEFASSET" "CLEAN-URLS" ;; These objects are not documented yet: "40ANTS-DOC/COMMONDOC/XREF:XREF")) + (0.27.0 2026-09-04 + "* Added DEFASSET for declaring local image assets that render in HTML and Markdown. + * Declared assets can be included in prose or as standalone DEFSECTION entries, including when *DOCUMENT-UPPERCASE-IS-CODE* is disabled.") (0.26.1 2026-09-03 "* Fixed autodoc support for slots declared with `:writer`.") (0.26.0 2026-04-11 @@ -401,4 +406,3 @@ referenced objects are in different packages. - Allowed to reference objects using keywords. - Fixed docstring extraction for compiler macro.")) - diff --git a/src/core.lisp b/src/core.lisp index 9aa6e04e..6d88acc7 100644 --- a/src/core.lisp +++ b/src/core.lisp @@ -351,15 +351,30 @@ Only first element is checked. (typecase entry (string (add-external-links entry)) (symbol - (let ((value (symbol-value entry))) - (unless (typep value 'string) - (error "~S value should be a string." - entry)) - (add-external-links value))) + (let ((transformed-entry + (transform-symbol-entry entry))) + (typecase transformed-entry + (string (add-external-links transformed-entry)) + (t transformed-entry)))) (t (entry-to-reference entry)))) entries)))) + +(defgeneric transform-symbol-entry (entry) + (:documentation "Transform a symbol used directly as a DEFSECTION entry. + +By default, ENTRY names a special variable holding a documentation string. +Extensions may return another documentation entry, such as an asset.")) + + +(defmethod transform-symbol-entry ((entry symbol)) + (let ((value (symbol-value entry))) + (unless (typep value 'string) + (error "~S value should be a string." + entry)) + value)) + (defun entry-to-reference (entry) (destructuring-bind (symbol locative &key export) entry (declare (ignore export)) diff --git a/static/logo.png b/static/logo.png new file mode 100644 index 00000000..d54125db Binary files /dev/null and b/static/logo.png differ diff --git a/test/assets.lisp b/test/assets.lisp new file mode 100644 index 00000000..2439a6a5 --- /dev/null +++ b/test/assets.lisp @@ -0,0 +1,162 @@ +(uiop:define-package #:40ants-doc-test/assets + (:use #:cl) + (:import-from #:40ants-doc + #:defsection) + (:import-from #:40ants-doc-full/assets + #:defasset) + (:import-from #:40ants-doc-full/builder + #:render-to-files) + (:import-from #:40ants-doc-full/builder/printer + #:*document-uppercase-is-code*) + (:import-from #:rove + #:deftest + #:ok + #:signals + #:testing) + (:import-from #:alexandria + #:read-file-into-string)) +(in-package #:40ants-doc-test/assets) + + +(defasset @test-asset.png + "static/rendering.png" + :target-filename "assets/test-rendering.png") + +(defasset @missing-asset.png + "test/data/does-not-exist.png" + :target-filename "assets/missing.png") + +(defasset @unsafe-asset.png + #.(asdf:system-relative-pathname :40ants-doc "static/rendering.png") + :target-filename "../outside.png") + +(defasset @colliding-asset-a.png + #.(asdf:system-relative-pathname :40ants-doc "static/rendering.png") + :target-filename "assets/collision.png") + +(defasset @colliding-asset-b.png + #.(asdf:system-relative-pathname :40ants-doc "static/rendering.png") + :target-filename "assets/collision.png") + + +(defsection @asset-page (:export nil) + "@TEST-ASSET.PNG") + + +(defsection @repeated-asset-page (:export nil) + "@TEST-ASSET.PNG and @TEST-ASSET.PNG") + + +(defsection @explicit-asset-page (:export nil) + "Here is:" + @test-asset.png) + + +(defsection @missing-asset-page (:export nil) + "@MISSING-ASSET.PNG") + + +(defsection @unsafe-asset-page (:export nil) + "@UNSAFE-ASSET.PNG") + + +(defsection @colliding-asset-page (:export nil) + "@COLLIDING-ASSET-A.PNG") + + +(defun make-test-output-directory () + (uiop:ensure-directory-pathname + (merge-pathnames (format nil "40ants-doc-assets-~A/" (gensym)) + (uiop:temporary-directory)))) + + +(defun render-asset-page (format base-filename) + (render-to-files + (40ants-doc-full/page:make-page @asset-page :base-filename base-filename) + :base-dir (make-test-output-directory) + :format format)) + + +(defun count-occurrences (needle text) + (loop with start = 0 + for position = (search needle text :start2 start) + while position + count 1 + do (setf start (+ position (length needle))))) + + +(deftest test-asset-renders-to-html + (multiple-value-bind (output-dir output-path) + (render-asset-page :html "index") + (testing "The source image is copied before HTML pages are emitted" + (ok (probe-file (merge-pathnames "assets/test-rendering.png" output-dir)))) + (testing "The asset symbol is emitted as an image" + (ok (search "