The card body's resting state: swift-markdown (pinned 0.8.0, smart typography off — Preview renders the bytes on disk) parsed into a pure BodyMarkup model with UTF-8 source offsets, rendered on one hosted TextKit 1 NSTextView — chosen because find-in-text is NSTextFinder, checkbox clicks reuse AppKit character hit-testing, links are .link attributes, and NSTextTable's automatic layout is exactly the columns-sized-to-contents rule. The GFM subset renders per 05; HTML stays verbatim code-styled text; relative images resolve against the card folder while remote URLs are never fetched, drawing a quiet chip instead. Task checkboxes are live: a click flips exactly one byte through a fresh-read, refuse-uneditable, stamp, atomic-replace write — the app's only offset-addressed write, so a moved target refuses as staleTarget and what the user saw decides the direction, netting one toggle on a double-click. Empty bodies open in Edit per CardBodyMode's opening rule, applied once; the Edit surface itself stays an honest read-only stub until its card. FindCommand prefers the card body's find over board search when a card window is focused. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
518 lines
21 KiB
Swift
518 lines
21 KiB
Swift
import Foundation
|
|
import Testing
|
|
@testable import Kanban
|
|
|
|
/// The executable spec for the card body's parse → render **model** (05-card-window.md ▸ Preview).
|
|
///
|
|
/// The renderer beneath it draws pixels a unit test cannot see; what it draws *from* is this model,
|
|
/// and every rule 05 settles about Preview is a fact about the model rather than about the drawing:
|
|
/// which constructs are in the subset, that HTML is literal text rather than markup, that an image
|
|
/// with a scheme is never fetched, that a link knows whether it is the browser's or the Finder's,
|
|
/// and — the one that reaches disk — where a task checkbox's single byte lives in the source.
|
|
///
|
|
/// So this file is where those hold still. A regression here is a rendering that would be *wrong*;
|
|
/// a regression in the renderer is one that would be ugly.
|
|
|
|
// MARK: - Reading the model
|
|
|
|
/// The first block of a parsed body — most cases here are one construct, and naming the unwrap once
|
|
/// keeps each test to its claim.
|
|
private func firstBlock(_ source: String) -> BodyBlock? {
|
|
BodyMarkup.parse(source).blocks.first
|
|
}
|
|
|
|
/// Every `BodyInline` flattened to the plain text it carries, containers descended into — what the
|
|
/// reader ends up seeing, with the structure taken away.
|
|
private func plainText(_ inlines: [BodyInline]) -> String {
|
|
inlines.map { inline in
|
|
switch inline {
|
|
case let .text(text): text
|
|
case let .code(code): code
|
|
case let .html(raw): raw
|
|
case let .emphasis(children), let .strong(children), let .strikethrough(children):
|
|
plainText(children)
|
|
case let .link(_, children): plainText(children)
|
|
case let .image(image): image.alt
|
|
case .lineBreak: "\n"
|
|
case .softBreak: " "
|
|
}
|
|
}.joined()
|
|
}
|
|
|
|
/// The body slice a block's source range names — the assertion that a range is *the* range rather
|
|
/// than merely plausible.
|
|
private func slice(_ body: String, _ span: BodySpan?) -> String? {
|
|
guard let span else { return nil }
|
|
let bytes = Array(body.utf8)
|
|
guard span.start <= span.end, span.end <= bytes.count else { return nil }
|
|
return String(decoding: bytes[span.start ..< span.end], as: UTF8.self)
|
|
}
|
|
|
|
private func span(of block: BodyBlock?) -> BodySpan? {
|
|
switch block {
|
|
case let .heading(_, _, range), let .paragraph(_, range), let .code(_, _, range),
|
|
let .html(_, range), let .thematicBreak(range), let .quote(_, range),
|
|
let .list(_, range), let .table(_, range):
|
|
range
|
|
case nil:
|
|
nil
|
|
}
|
|
}
|
|
|
|
// MARK: - The subset, construct by construct
|
|
|
|
@Suite("Body markup — the GFM subset")
|
|
struct BodyMarkupSubsetTests {
|
|
|
|
@Test("Headings carry their level and their inlines")
|
|
func headings() {
|
|
guard case let .heading(level, inlines, _)? = firstBlock("## Fix *login*\n") else {
|
|
Issue.record("expected a heading")
|
|
return
|
|
}
|
|
#expect(level == 2)
|
|
#expect(plainText(inlines) == "Fix login")
|
|
// The emphasis is structure, not a string: a renderer that lost it would still pass a
|
|
// plain-text check.
|
|
#expect(inlines.contains { if case .emphasis = $0 { true } else { false } })
|
|
}
|
|
|
|
@Test("Bold, italic, inline code and strikethrough are each their own inline")
|
|
func inlineRuns() {
|
|
guard case let .paragraph(inlines, _)? = firstBlock("**a** _b_ `c` ~~d~~\n") else {
|
|
Issue.record("expected a paragraph")
|
|
return
|
|
}
|
|
#expect(inlines.contains { if case .strong = $0 { true } else { false } })
|
|
#expect(inlines.contains { if case .emphasis = $0 { true } else { false } })
|
|
#expect(inlines.contains { if case .code("c") = $0 { true } else { false } })
|
|
#expect(inlines.contains { if case .strikethrough = $0 { true } else { false } })
|
|
}
|
|
|
|
@Test("A fenced block keeps its language; an indented one is code all the same")
|
|
func codeBlocks() {
|
|
guard case let .code(fenced, language, _)? = firstBlock("```swift\nlet x = 1\n```\n") else {
|
|
Issue.record("expected a fenced code block")
|
|
return
|
|
}
|
|
#expect(language == "swift")
|
|
#expect(fenced.hasPrefix("let x = 1"))
|
|
|
|
// The indented form is the same construct with nothing to label it — 05 lists "fenced +
|
|
// indented code" as one line for a reason.
|
|
guard case let .code(indented, indentedLanguage, _)? = firstBlock(" let y = 2\n") else {
|
|
Issue.record("expected an indented code block")
|
|
return
|
|
}
|
|
#expect(indentedLanguage == nil)
|
|
#expect(indented.hasPrefix("let y = 2"))
|
|
}
|
|
|
|
@Test("Quotes nest")
|
|
func nestedQuotes() {
|
|
guard case let .quote(outer, _)? = firstBlock("> outer\n>\n> > inner\n") else {
|
|
Issue.record("expected a block quote")
|
|
return
|
|
}
|
|
let inner = outer.compactMap { block -> [BodyBlock]? in
|
|
if case let .quote(blocks, _) = block { return blocks }
|
|
return nil
|
|
}
|
|
#expect(inner.count == 1, "a quote inside a quote is a quote inside a quote, not a flattened one")
|
|
if case let .paragraph(inlines, _)? = inner.first?.first {
|
|
#expect(plainText(inlines) == "inner")
|
|
} else {
|
|
Issue.record("expected the nested quote's paragraph")
|
|
}
|
|
}
|
|
|
|
@Test("An ordered list remembers where it starts; a bullet list does not pretend to")
|
|
func lists() {
|
|
guard case let .list(ordered, _)? = firstBlock("3. three\n4. four\n") else {
|
|
Issue.record("expected an ordered list")
|
|
return
|
|
}
|
|
#expect(ordered.isOrdered)
|
|
#expect(ordered.start == 3)
|
|
#expect(ordered.items.count == 2)
|
|
|
|
guard case let .list(bullets, _)? = firstBlock("- one\n- two\n") else {
|
|
Issue.record("expected a bullet list")
|
|
return
|
|
}
|
|
#expect(!bullets.isOrdered)
|
|
#expect(bullets.items.allSatisfy { $0.task == nil })
|
|
}
|
|
|
|
@Test("A thematic break is its own block")
|
|
func thematicBreak() {
|
|
guard case .thematicBreak? = firstBlock("---\n") else {
|
|
Issue.record("expected a thematic break")
|
|
return
|
|
}
|
|
}
|
|
|
|
@Test("A GFM table carries per-column alignment, its header, and its rows")
|
|
func tables() {
|
|
let source = """
|
|
| Left | Middle | Right |
|
|
| :--- | :----: | ----: |
|
|
| a | b | c |
|
|
| d | e | f |
|
|
|
|
"""
|
|
guard case let .table(table, _)? = firstBlock(source) else {
|
|
Issue.record("expected a table")
|
|
return
|
|
}
|
|
#expect(table.columnCount == 3)
|
|
#expect(table.alignments == [.leading, .center, .trailing])
|
|
#expect(table.header.map { plainText($0.inlines) } == ["Left", "Middle", "Right"])
|
|
#expect(table.rows.count == 2)
|
|
#expect(table.rows.first?.map { plainText($0.inlines) } == ["a", "b", "c"])
|
|
}
|
|
|
|
@Test("A table with no alignment row markers leaves its columns unspecified")
|
|
func tableWithoutAlignments() {
|
|
let source = """
|
|
| One | Two |
|
|
| --- | --- |
|
|
| a | b |
|
|
|
|
"""
|
|
guard case let .table(table, _)? = firstBlock(source) else {
|
|
Issue.record("expected a table")
|
|
return
|
|
}
|
|
#expect(table.alignments == [.unspecified, .unspecified])
|
|
// One entry per column, always — a renderer indexes this array by column and must not have
|
|
// to guard every access.
|
|
#expect(table.alignments.count == table.columnCount)
|
|
}
|
|
}
|
|
|
|
// MARK: - HTML is text
|
|
|
|
@Suite("Body markup — HTML is never interpreted")
|
|
struct BodyMarkupHTMLTests {
|
|
|
|
@Test("An HTML block is carried verbatim, as literal text")
|
|
func htmlBlockIsVerbatim() {
|
|
guard case let .html(raw, _)? = firstBlock("<div class=\"x\">\n <b>bold</b>\n</div>\n") else {
|
|
Issue.record("expected an HTML block")
|
|
return
|
|
}
|
|
// Verbatim: the angle brackets, the attribute quoting and the inner tag are all still
|
|
// characters. Nothing here is a `<b>` the app is about to honour (05 ▸ Preview; 00-vision's
|
|
// no-web-tech stance).
|
|
#expect(raw.contains("<div class=\"x\">"))
|
|
#expect(raw.contains("<b>bold</b>"))
|
|
}
|
|
|
|
@Test("Inline HTML is an inline of its own, never a rendered tag")
|
|
func inlineHTMLIsVerbatim() {
|
|
guard case let .paragraph(inlines, _)? = firstBlock("before <b>x</b> after\n") else {
|
|
Issue.record("expected a paragraph")
|
|
return
|
|
}
|
|
let html = inlines.compactMap { inline -> String? in
|
|
if case let .html(raw) = inline { return raw }
|
|
return nil
|
|
}
|
|
#expect(html == ["<b>", "</b>"])
|
|
// And crucially *not* a strong run: the tags are text and "x" is text beside them.
|
|
#expect(!inlines.contains { if case .strong = $0 { true } else { false } })
|
|
#expect(plainText(inlines) == "before <b>x</b> after")
|
|
}
|
|
}
|
|
|
|
// MARK: - Where a link or an image points
|
|
|
|
@Suite("Body markup — link and image classification")
|
|
struct BodyTargetTests {
|
|
|
|
@Test("A path with no scheme is relative; anything with one is absolute")
|
|
func classification() {
|
|
#expect(BodyTarget.classify("attachments/shot.png") == .relative("attachments/shot.png"))
|
|
#expect(BodyTarget.classify("./notes.md") == .relative("./notes.md"))
|
|
#expect(BodyTarget.classify("../sibling/index.md") == .relative("../sibling/index.md"))
|
|
#expect(BodyTarget.classify("https://example.com/x.png") == .absolute("https://example.com/x.png"))
|
|
#expect(BodyTarget.classify("mailto:[email protected]") == .absolute("mailto:[email protected]"))
|
|
#expect(BodyTarget.classify("file:///tmp/x.png") == .absolute("file:///tmp/x.png"))
|
|
// Nothing to point at is a relative nothing, not an absolute one.
|
|
#expect(BodyTarget.classify(nil) == .relative(""))
|
|
#expect(BodyTarget.classify("") == .relative(""))
|
|
}
|
|
|
|
@Test("A relative image resolves against the card's own folder; a remote one is never fetched")
|
|
func imageClassification() {
|
|
guard case let .paragraph(local, _)? = firstBlock("\n"),
|
|
case let .image(localImage)? = local.first
|
|
else {
|
|
Issue.record("expected a local image")
|
|
return
|
|
}
|
|
#expect(localImage.target == .relative("attachments/shot.png"))
|
|
#expect(localImage.alt == "a shot")
|
|
|
|
guard case let .paragraph(remote, _)? = firstBlock("\n"),
|
|
case let .image(remoteImage)? = remote.first
|
|
else {
|
|
Issue.record("expected a remote image")
|
|
return
|
|
}
|
|
// `.absolute` is the whole of "Preview does no networking": nothing downstream is given a
|
|
// loader for this case at all — it renders as a chip carrying the alt text (05 ▸ Preview).
|
|
#expect(remoteImage.target == .absolute("https://example.com/x.png"))
|
|
#expect(remoteImage.alt == "alt text")
|
|
}
|
|
|
|
@Test("A link keeps both its destination's kind and its own text")
|
|
func linkClassification() {
|
|
guard case let .paragraph(inlines, _)? = firstBlock("see [the notes](notes.md) and [home](https://example.com)\n")
|
|
else {
|
|
Issue.record("expected a paragraph")
|
|
return
|
|
}
|
|
let links = inlines.compactMap { inline -> (BodyTarget, String)? in
|
|
if case let .link(target, children) = inline { return (target, plainText(children)) }
|
|
return nil
|
|
}
|
|
#expect(links.count == 2)
|
|
#expect(links.first?.0 == .relative("notes.md"))
|
|
#expect(links.first?.1 == "the notes")
|
|
#expect(links.last?.0 == .absolute("https://example.com"))
|
|
}
|
|
|
|
@Test("Resolution is against the card folder, and a rooted path is taken as written")
|
|
func resolution() {
|
|
let folder = URL(fileURLWithPath: "/tmp/board/lane/card", isDirectory: true)
|
|
|
|
#expect(
|
|
BodyTarget.relative("attachments/shot.png").resolve(inCardFolder: folder)?.path
|
|
== "/tmp/board/lane/card/attachments/shot.png"
|
|
)
|
|
// Percent-encoding is the author's escaping of a filename, not part of the filename.
|
|
#expect(
|
|
BodyTarget.relative("attachments/my%20shot.png").resolve(inCardFolder: folder)?.path
|
|
== "/tmp/board/lane/card/attachments/my shot.png"
|
|
)
|
|
// A rooted path names itself; gluing it onto the card folder would name a file nobody meant.
|
|
#expect(BodyTarget.relative("/etc/hosts").resolve(inCardFolder: folder)?.path == "/etc/hosts")
|
|
#expect(BodyTarget.absolute("https://example.com/x").resolve(inCardFolder: folder)?.scheme == "https")
|
|
// Nothing to resolve, and nothing to resolve against.
|
|
#expect(BodyTarget.relative("").resolve(inCardFolder: folder) == nil)
|
|
#expect(BodyTarget.relative("notes.md").resolve(inCardFolder: nil) == nil)
|
|
}
|
|
}
|
|
|
|
// MARK: - Task checkboxes
|
|
|
|
@Suite("Body markup — task checkboxes")
|
|
struct BodyTaskTests {
|
|
|
|
/// Two items, one of each state, with a nested one under the second.
|
|
private static let source = """
|
|
- [ ] first
|
|
- [x] second
|
|
- [ ] nested
|
|
|
|
"""
|
|
|
|
private func tasks(of blocks: [BodyBlock]) -> [BodyTask] {
|
|
blocks.flatMap { block -> [BodyTask] in
|
|
switch block {
|
|
case let .list(list, _):
|
|
list.items.flatMap { item in (item.task.map { [$0] } ?? []) + tasks(of: item.blocks) }
|
|
case let .quote(children, _):
|
|
tasks(of: children)
|
|
default:
|
|
[]
|
|
}
|
|
}
|
|
}
|
|
|
|
@Test("A task item's checkbox state and its source byte both survive the parse")
|
|
func markersAreLocated() {
|
|
let markup = BodyMarkup.parse(Self.source)
|
|
let found = tasks(of: markup.blocks)
|
|
|
|
#expect(found.map(\.isChecked) == [false, true, false])
|
|
|
|
// The offsets, counted by hand from the source above — the one number in this model that a
|
|
// write will aim at, so it is asserted as a number rather than as "somewhere in the line":
|
|
// "- [ ] first\n" → `[` at 2, marker at 3
|
|
// "- [x] second\n" → line starts at 12, marker at 15
|
|
// " - [ ] nested\n"→ line starts at 25, marker at 30
|
|
#expect(found.map(\.markerOffset) == [3, 15, 30])
|
|
|
|
// And each one really is the character between the brackets.
|
|
let bytes = Array(Self.source.utf8)
|
|
for task in found {
|
|
guard let offset = task.markerOffset else {
|
|
Issue.record("a parsed checkbox with no located marker")
|
|
continue
|
|
}
|
|
#expect(bytes[offset - 1] == UInt8(ascii: "["))
|
|
#expect(bytes[offset + 1] == UInt8(ascii: "]"))
|
|
#expect(BodyMarkup.markerState(bytes[offset]) == task.isChecked)
|
|
}
|
|
}
|
|
|
|
@Test("An ordinary list item has no checkbox, and a literal [x] in prose is not one")
|
|
func nonTasksAreNotTasks() {
|
|
#expect(tasks(of: BodyMarkup.parse("- plain\n- also plain\n").blocks).isEmpty)
|
|
// The scan is line-scoped and starts at a *task item*: prose that happens to contain the
|
|
// three characters is prose (`taskMarkerOffset`).
|
|
#expect(tasks(of: BodyMarkup.parse("A sentence with a [x] in it.\n").blocks).isEmpty)
|
|
}
|
|
|
|
@Test("A mixed list keeps its plain items plain")
|
|
func mixedList() {
|
|
guard case let .list(list, _)? = firstBlock("- [ ] a task\n- not a task\n") else {
|
|
Issue.record("expected a list")
|
|
return
|
|
}
|
|
#expect(list.items.count == 2)
|
|
#expect(list.items.first?.task?.isChecked == false)
|
|
#expect(list.items.last?.task == nil)
|
|
}
|
|
}
|
|
|
|
// MARK: - The flip
|
|
|
|
@Suite("Body markup — flipping a task marker")
|
|
struct BodyMarkerFlipTests {
|
|
|
|
private static let body = "- [ ] first\n- [x] second\n"
|
|
|
|
@Test("A flip changes exactly one byte, in each direction")
|
|
func flipsOneByte() {
|
|
guard let ticked = BodyMarkup.flippingTaskMarker(in: Self.body, at: 3, expecting: false) else {
|
|
Issue.record("expected the flip to land")
|
|
return
|
|
}
|
|
#expect(ticked == "- [x] first\n- [x] second\n")
|
|
|
|
guard let unticked = BodyMarkup.flippingTaskMarker(in: Self.body, at: 15, expecting: true) else {
|
|
Issue.record("expected the flip to land")
|
|
return
|
|
}
|
|
#expect(unticked == "- [ ] first\n- [ ] second\n")
|
|
|
|
// The one-byte claim, stated as a byte count rather than inferred from the strings.
|
|
let before = Array(Self.body.utf8)
|
|
let after = Array(ticked.utf8)
|
|
#expect(before.count == after.count)
|
|
#expect(zip(before, after).filter { $0 != $1 }.count == 1)
|
|
}
|
|
|
|
@Test("A flip round-trips")
|
|
func roundTrips() {
|
|
let ticked = BodyMarkup.flippingTaskMarker(in: Self.body, at: 3, expecting: false)
|
|
let back = ticked.flatMap { BodyMarkup.flippingTaskMarker(in: $0, at: 3, expecting: true) }
|
|
#expect(back == Self.body)
|
|
}
|
|
|
|
@Test("An uppercase X reads as checked and unticks to a space")
|
|
func uppercaseMarker() {
|
|
#expect(BodyMarkup.flippingTaskMarker(in: "- [X] one\n", at: 3, expecting: true) == "- [ ] one\n")
|
|
}
|
|
|
|
@Test("A stale offset, a stale state, or a byte that is not a marker all refuse")
|
|
func refusals() {
|
|
// The state the user saw no longer matches disk — an external edit ticked it first.
|
|
#expect(BodyMarkup.flippingTaskMarker(in: Self.body, at: 3, expecting: true) == nil)
|
|
// Not between brackets at all.
|
|
#expect(BodyMarkup.flippingTaskMarker(in: Self.body, at: 7, expecting: false) == nil)
|
|
// Past the end, and before the beginning.
|
|
#expect(BodyMarkup.flippingTaskMarker(in: Self.body, at: 9_999, expecting: false) == nil)
|
|
#expect(BodyMarkup.flippingTaskMarker(in: Self.body, at: 0, expecting: false) == nil)
|
|
}
|
|
|
|
@Test("Multibyte text before the marker does not move it")
|
|
func multibyteIsCountedInBytes() {
|
|
// "é" is two UTF-8 bytes and "→" is three: an offset counted in Characters would be wrong
|
|
// by five here, which is exactly the class of bug that puts an `x` in the middle of a word.
|
|
let body = "prosé →\n\n- [ ] task\n"
|
|
let markup = BodyMarkup.parse(body)
|
|
let task = markup.blocks.compactMap { block -> BodyTask? in
|
|
if case let .list(list, _) = block { return list.items.first?.task }
|
|
return nil
|
|
}.first
|
|
|
|
guard let offset = task?.markerOffset else {
|
|
Issue.record("expected a located marker")
|
|
return
|
|
}
|
|
#expect(Array(body.utf8)[offset] == UInt8(ascii: " "))
|
|
#expect(BodyMarkup.flippingTaskMarker(in: body, at: offset, expecting: false) == "prosé →\n\n- [x] task\n")
|
|
}
|
|
}
|
|
|
|
// MARK: - Source ranges
|
|
|
|
@Suite("Body markup — source ranges")
|
|
struct BodySpanTests {
|
|
|
|
@Test("A block's range names the bytes it was parsed from")
|
|
func blockRangesSlice() {
|
|
let source = "# Title\n\nA paragraph.\n"
|
|
let blocks = BodyMarkup.parse(source).blocks
|
|
#expect(blocks.count == 2)
|
|
#expect(slice(source, span(of: blocks.first)) == "# Title")
|
|
#expect(slice(source, span(of: blocks.last)) == "A paragraph.")
|
|
}
|
|
|
|
@Test("Ranges are byte offsets, so multibyte text does not shift them")
|
|
func rangesAreBytes() {
|
|
let source = "# Café\n\n→ next\n"
|
|
let blocks = BodyMarkup.parse(source).blocks
|
|
#expect(slice(source, span(of: blocks.first)) == "# Café")
|
|
#expect(slice(source, span(of: blocks.last)) == "→ next")
|
|
}
|
|
}
|
|
|
|
// MARK: - The empty-body question
|
|
|
|
@Suite("Body markup — emptiness")
|
|
struct BodyEmptinessTests {
|
|
|
|
@Test("Whitespace is empty; anything else is not")
|
|
func emptiness() {
|
|
// The input to 05's opening rule: "a card opens in Preview — unless its body is empty".
|
|
#expect(BodyMarkup.isEmpty(""))
|
|
#expect(BodyMarkup.isEmpty("\n"))
|
|
#expect(BodyMarkup.isEmpty(" \n\n\t"))
|
|
#expect(!BodyMarkup.isEmpty("x"))
|
|
#expect(!BodyMarkup.isEmpty("\n# Heading\n"))
|
|
}
|
|
}
|
|
|
|
// MARK: - The clickable-run codec
|
|
|
|
@Suite("Body markup — the checkbox link codec")
|
|
@MainActor
|
|
struct CardBodyLinkTests {
|
|
|
|
@Test("A checkbox round-trips through its URL, in both states")
|
|
func taskURLsRoundTrip() {
|
|
for (offset, checked) in [(0, false), (3, true), (12_345, false)] {
|
|
guard let url = CardBodyLink.task(offset: offset, isChecked: checked) else {
|
|
Issue.record("expected a URL for \(offset)/\(checked)")
|
|
continue
|
|
}
|
|
let parsed = CardBodyLink.parseTask(url)
|
|
#expect(parsed?.offset == offset)
|
|
#expect(parsed?.isChecked == checked)
|
|
}
|
|
}
|
|
|
|
@Test("An ordinary link is not a checkbox")
|
|
func ordinaryLinksAreNotTasks() {
|
|
// How the delegate tells the two apart — a body link must never be mistaken for a write.
|
|
#expect(CardBodyLink.parseTask(URL(string: "https://example.com")!) == nil)
|
|
#expect(CardBodyLink.parseTask(URL(fileURLWithPath: "/tmp/x.md")) == nil)
|
|
}
|
|
}
|