Skip to content

Commit c9e700d

Browse files
authored
Merge branch 'main' into repo-assist/fix-191-commonmark-atx-headings-26ab2fbf1051d319
2 parents 21ce2b2 + 10930a2 commit c9e700d

8 files changed

Lines changed: 221 additions & 17 deletions

File tree

RELEASE_NOTES.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@
33
## [Unreleased]
44

55
### Added
6+
* Add "Copy" button to all code blocks in generated documentation, making it easy to copy code samples to the clipboard. [#72](https://github.com/fsprojects/FSharp.Formatting/issues/72)
67
* Add `<FsDocsAllowExecutableProject>true</FsDocsAllowExecutableProject>` project file setting to include executable projects (OutputType=Exe/WinExe) in API documentation generation. [#918](https://github.com/fsprojects/FSharp.Formatting/issues/918)
78
* Add `{{fsdocs-logo-alt}}` substitution (configurable via `<FsDocsLogoAlt>` MSBuild property, defaults to `Logo`) for accessible alt text on the header logo image. [#626](https://github.com/fsprojects/FSharp.Formatting/issues/626)
89

910
### Fixed
11+
* Fix doc generation failure for members with 5D/6D+ array parameters by correctly formatting array type signatures in XML doc format (e.g. `System.Double[0:,0:,0:,0:,0:]` for a 5D array). [#702](https://github.com/fsprojects/FSharp.Formatting/issues/702)
1012
* Fix `_menu_template.html` and `_menu-item_template.html` being copied to the output directory. [#803](https://github.com/fsprojects/FSharp.Formatting/issues/803)
1113
* Fix `ApiDocMember.Details.ReturnInfo.ReturnType` returning `None` for properties that have both a getter and a setter. [#734](https://github.com/fsprojects/FSharp.Formatting/issues/734)
1214
* Improve error message when a named code snippet is not found (e.g. `(*** include:name ***)` with undefined name now reports the missing name clearly). [#982](https://github.com/fsprojects/FSharp.Formatting/pull/982)
@@ -15,6 +17,7 @@
1517
* Strip `#if SYMBOL` / `#endif // SYMBOL` marker lines from `LiterateCode` source before syntax-highlighting so they do not appear in formatted output. [#693](https://github.com/fsprojects/FSharp.Formatting/issues/693)
1618
* Fix incorrect column ranges for inline spans (links, images, inline code) in the Markdown parser — spans and subsequent literals now report correct `StartColumn`/`EndColumn` values. [#744](https://github.com/fsprojects/FSharp.Formatting/issues/744)
1719
* Normalize `--projects` paths to absolute paths before passing to the project cracker, fixing failures when relative paths are supplied. [#793](https://github.com/fsprojects/FSharp.Formatting/issues/793)
20+
* Fix incorrect paragraph indentation for loose list items: a paragraph indented at the outer list item's continuation level is now correctly treated as a sibling of surrounding sublists rather than being absorbed into the first sublist item's body. [#347](https://github.com/fsprojects/FSharp.Formatting/issues/347)
1821
* Improve CommonMark compliance for ATX headings: reject `#` not followed by a space (e.g. `#NoSpace` is now a paragraph), reject more than 6 `#` characters as a heading, support 0–3 leading spaces before the opening `#` sequence, and fix empty content when the entire header body is a closing `###` sequence. [#191](https://github.com/fsprojects/FSharp.Formatting/issues/191)
1922

2023
### Changed

docs/_template.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@
9797
<script type="module" src="{{root}}content/fsdocs-details-toggle.js"></script>
9898
<script type="module" src="{{root}}content/fsdocs-theme.js"></script>
9999
<script type="module" src="{{root}}content/fsdocs-search.js"></script>
100+
<script type="module" src="{{root}}content/fsdocs-copy-button.js"></script>
100101
{{fsdocs-body-extra}}
101102
</body>
102103
</html>

docs/content/fsdocs-copy-button.js

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// Adds a "Copy" button to every code block so readers can easily copy snippets.
2+
function createCopyButton() {
3+
const button = document.createElement('button')
4+
button.className = 'copy-code-button'
5+
button.setAttribute('aria-label', 'Copy code to clipboard')
6+
button.textContent = 'Copy'
7+
return button
8+
}
9+
10+
function attachCopyHandler(button, getText) {
11+
button.addEventListener('click', function () {
12+
const text = getText()
13+
if (navigator.clipboard && navigator.clipboard.writeText) {
14+
navigator.clipboard.writeText(text).then(
15+
function () {
16+
button.textContent = 'Copied!'
17+
setTimeout(function () {
18+
button.textContent = 'Copy'
19+
}, 2000)
20+
},
21+
function () {
22+
button.textContent = 'Failed'
23+
setTimeout(function () {
24+
button.textContent = 'Copy'
25+
}, 2000)
26+
}
27+
)
28+
} else {
29+
// Fallback for non-HTTPS environments
30+
const el = document.createElement('textarea')
31+
el.value = text
32+
document.body.appendChild(el)
33+
el.select()
34+
document.execCommand('copy')
35+
document.body.removeChild(el)
36+
button.textContent = 'Copied!'
37+
setTimeout(function () {
38+
button.textContent = 'Copy'
39+
}, 2000)
40+
}
41+
})
42+
}
43+
44+
document.addEventListener('DOMContentLoaded', function () {
45+
// table.pre blocks (F# highlighted code, sometimes with line numbers)
46+
document.querySelectorAll('table.pre').forEach(function (table) {
47+
const wrapper = document.createElement('div')
48+
wrapper.className = 'code-block-wrapper'
49+
table.parentNode.insertBefore(wrapper, table)
50+
wrapper.appendChild(table)
51+
52+
const button = createCopyButton()
53+
wrapper.appendChild(button)
54+
55+
const snippet = table.querySelector('.snippet pre')
56+
attachCopyHandler(button, function () {
57+
return (snippet || table).innerText
58+
})
59+
})
60+
61+
// Standard pre > code blocks (Markdown fenced code, standalone fssnip, etc.)
62+
// Skip those already handled inside table.pre above.
63+
document.querySelectorAll('pre > code').forEach(function (code) {
64+
if (code.closest('table.pre')) return
65+
const pre = code.parentElement
66+
pre.classList.add('has-copy-button')
67+
68+
const button = createCopyButton()
69+
pre.appendChild(button)
70+
71+
attachCopyHandler(button, function () {
72+
return code.innerText
73+
})
74+
})
75+
})

docs/content/fsdocs-default.css

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -731,7 +731,7 @@ h1 code, h2 code, h3 code, h4 code, h5 code, h6 code {
731731
font-size: inherit;
732732
}
733733

734-
table.pre, #content > pre.fssnip {
734+
table.pre, #content > pre.fssnip, .code-block-wrapper > table.pre {
735735
border: 1px solid var(--code-fence-border-color);
736736
}
737737

@@ -740,6 +740,47 @@ table.pre, pre.fssnip.highlighted {
740740
padding: var(--spacing-200);
741741
}
742742

743+
/* Copy code button */
744+
.code-block-wrapper {
745+
position: relative;
746+
display: block;
747+
margin: var(--spacing-300) 0;
748+
}
749+
750+
.code-block-wrapper > table.pre {
751+
margin: 0;
752+
}
753+
754+
pre.has-copy-button {
755+
position: relative;
756+
}
757+
758+
.copy-code-button {
759+
position: absolute;
760+
top: var(--spacing-100);
761+
right: var(--spacing-100);
762+
padding: var(--spacing-50) var(--spacing-100);
763+
background-color: var(--header-background);
764+
border: 1px solid var(--header-border);
765+
border-radius: var(--radius);
766+
color: var(--text-color);
767+
cursor: pointer;
768+
font-family: var(--system-font);
769+
font-size: var(--font-50);
770+
opacity: 0;
771+
transition: opacity 0.2s ease-in-out;
772+
z-index: 10;
773+
774+
&:hover {
775+
background-color: var(--menu-item-hover-background);
776+
}
777+
}
778+
779+
pre.has-copy-button:hover .copy-code-button,
780+
.code-block-wrapper:hover .copy-code-button {
781+
opacity: 1;
782+
}
783+
743784
table.pre .snippet pre.fssnip {
744785
padding: 0;
745786
margin: 0;

docs/content/fsdocs-tips.js

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,16 @@ function showTip(evt, name, unique, owner) {
4545
}
4646

4747
function Clipboard_CopyTo(value) {
48-
const tempInput = document.createElement("input");
49-
tempInput.value = value;
50-
document.body.appendChild(tempInput);
51-
tempInput.select();
52-
document.execCommand("copy");
53-
document.body.removeChild(tempInput);
48+
if (navigator.clipboard) {
49+
navigator.clipboard.writeText(value);
50+
} else {
51+
const tempInput = document.createElement("input");
52+
tempInput.value = value;
53+
document.body.appendChild(tempInput);
54+
tempInput.select();
55+
document.execCommand("copy");
56+
document.body.removeChild(tempInput);
57+
}
5458
}
5559

5660
window.showTip = showTip;

src/FSharp.Formatting.ApiDocs/GenerateModel.fs

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -750,20 +750,41 @@ module internal CrossReferences =
750750
else
751751
""
752752

753+
let rec formatTypeForXmlDocSig (typ: FSharpType) =
754+
if typ.IsGenericParameter then
755+
typeArgsMap.[typ.GenericParameter.Name]
756+
elif typ.HasTypeDefinition && typ.TypeDefinition.IsArrayType then
757+
let elementTypeName = formatTypeForXmlDocSig typ.GenericArguments.[0]
758+
let rank = typ.TypeDefinition.ArrayRank
759+
760+
if rank = 1 then
761+
elementTypeName + "[]"
762+
else
763+
let dims = String.concat "," (Array.create rank "0:")
764+
elementTypeName + "[" + dims + "]"
765+
elif typ.HasTypeDefinition then
766+
let baseName =
767+
match typ.TypeDefinition.TryFullName with
768+
| Some fullName -> fullName
769+
| None -> typ.TypeDefinition.CompiledName
770+
771+
if typ.GenericArguments.Count > 0 then
772+
let args = typ.GenericArguments |> Seq.map formatTypeForXmlDocSig |> String.concat ","
773+
774+
baseName + "{" + args + "}"
775+
else
776+
baseName
777+
else
778+
typ.Format(FSharpDisplayContext.Empty)
779+
753780
let paramList =
754781
if
755782
memb.CurriedParameterGroups.Count > 0
756783
&& memb.CurriedParameterGroups.[0].Count > 0
757784
then
758785
let head = memb.CurriedParameterGroups.[0]
759786

760-
let paramTypeList =
761-
head
762-
|> Seq.map (fun param ->
763-
if param.Type.IsGenericParameter then
764-
typeArgsMap.[param.Type.GenericParameter.Name]
765-
else
766-
param.Type.TypeDefinition.FullName)
787+
let paramTypeList = head |> Seq.map (fun param -> formatTypeForXmlDocSig param.Type)
767788

768789
"(" + System.String.Join(", ", paramTypeList) + ")"
769790
else

src/FSharp.Formatting.Markdown/MarkdownParser.fs

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -819,6 +819,33 @@ let (|LinesUntilListOrUnindented|) lines =
819819
| StringPosition.WhiteSpace :: StringPosition.WhiteSpace :: _ -> true
820820
| _ -> false)
821821

822+
/// Returns the effective number of leading spaces in a string, treating each tab as 4 spaces.
823+
let private tabAwareLeadingSpaces (s: string) =
824+
let mutable spaces = 0
825+
let mutable i = 0
826+
827+
while i < s.Length && (s.[i] = ' ' || s.[i] = '\t') do
828+
spaces <- spaces + (if s.[i] = '\t' then 4 else 1)
829+
i <- i + 1
830+
831+
spaces
832+
833+
/// Splits input into lines for a loose list item continuation (when a blank line follows
834+
/// the first line of the item). Unlike LinesUntilListOrUnindented, this does not stop at
835+
/// indented list starts — it captures all continuation content at or above the item's
836+
/// content column (endIndent). It stops at truly unindented content (0 leading spaces),
837+
/// double blank lines, or a blank line followed by content with fewer leading spaces than
838+
/// endIndent (indicating the content belongs to an outer list item, not this one).
839+
let (|LinesUntilListOrUnindentedLoose|) endIndent lines =
840+
lines
841+
|> List.partitionUntilLookahead (function
842+
| StringPosition.Unindented :: _
843+
| StringPosition.WhiteSpace :: StringPosition.WhiteSpace :: _ -> true
844+
| StringPosition.WhiteSpace :: (s, _) :: _ ->
845+
let leading = tabAwareLeadingSpaces s
846+
leading > 0 && leading < endIndent
847+
| _ -> false)
848+
822849
/// Recognizes a list item until the next list item (possibly nested) or end of a list.
823850
/// The parameter specifies whether the previous line was simple (single-line not
824851
/// separated by a white line - simple items are not wrapped in <p>)
@@ -829,9 +856,19 @@ let (|ListItem|_|) prevSimple lines =
829856
//
830857
// Then take more things that belong to the item -
831858
// the value 'more' will contain indented paragraphs
832-
| (ListStart(kind, startIndent, endIndent, item) as takenLine) :: LinesUntilListOrWhite(continued,
833-
(LinesUntilListOrUnindented(more,
834-
rest) as next)) ->
859+
| (ListStart(kind, startIndent, endIndent, item) as takenLine) :: LinesUntilListOrWhite(continued, next) ->
860+
// For loose items (blank line follows the first content line), use loose indentation
861+
// rules: capture all continuation content at this item's indent level, including any
862+
// nested list starts. For tight items, stop at list starts (original behaviour).
863+
let more, rest =
864+
match next with
865+
| StringPosition.WhiteSpace :: _ ->
866+
match next with
867+
| LinesUntilListOrUnindentedLoose endIndent (m, r) -> m, r
868+
| _ ->
869+
match next with
870+
| LinesUntilListOrUnindented(m, r) -> m, r
871+
835872
let simple =
836873
match item with
837874
| StringPosition.TrimStartAndCount(_, spaces, _) when spaces >= 4 ->

tests/FSharp.Markdown.Tests/Markdown.fs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1285,3 +1285,25 @@ let ``Don't replace links in generated code block in table`` () =
12851285
|> properNewLines
12861286

12871287
Markdown.ToHtml(doc, mdlinkResolver = mdlinkResolver) |> shouldEqual actual
1288+
1289+
[<Test>]
1290+
let ``Paragraph between sublists should not be absorbed into first sublist item (issue 347)`` () =
1291+
// Per CommonMark, a paragraph indented at the outer list item's continuation level
1292+
// should remain a sibling of the surrounding sublists, not be absorbed into the
1293+
// first sublist item's body.
1294+
let html =
1295+
"1. List item\n\n 1. Subone\n\n Paragraph\n\n 7. SubRestart\n\n5. Another list item\n"
1296+
|> Markdown.ToHtml
1297+
1298+
// The paragraph must appear between the two sublists, not inside the first.
1299+
html |> should contain "<p>Paragraph</p>"
1300+
1301+
// There must be two separate ordered sub-lists.
1302+
let firstSublistEnd = html.IndexOf("</ol>")
1303+
let paragraphPos = html.IndexOf("<p>Paragraph</p>")
1304+
let secondSublistStart = html.LastIndexOf("<ol>")
1305+
1306+
// Paragraph comes after first sublist ends.
1307+
paragraphPos |> should be (greaterThan firstSublistEnd)
1308+
// Second sublist starts after paragraph.
1309+
secondSublistStart |> should be (greaterThan paragraphPos)

0 commit comments

Comments
 (0)