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
5 changes: 5 additions & 0 deletions .bumpy/anonymous-only-with-update-source.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
mdcode-ts: minor
---

Breaking: `extract` no longer writes blocks without `file=` by default. A plain `mdcode extract README.md` used to write every untagged block as `block-<N>.<ext>` beside the README; now it writes only the blocks that have `file=`. To extract anonymous blocks, pass `--update-source` (`updateSource: true`), which also adds their generated `file=` to the Markdown. `--ignore-anonymous` is removed from `extract` and `validate`, and so is the library's `ignoreAnonymous` option; drop it, since skipping is now the default. `validate --for extract` reports anonymous blocks with `path: null`. The check-sync GitHub Action drops its `ignore-anonymous` input.
2 changes: 1 addition & 1 deletion .bumpy/library-cli-boundary.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
mdcode-ts: minor
---

Removed `transform()` and `transformWithFunction()`, which printed every block to the console and `transform()` read stdin. `update({ source, transformer, filter })` replaces `transformWithFunction()` but reads each `file=` first, so a missing file is a `read_failed` error (thrown, or collected with `continueOnError`); use `walk()` to transform without reading files. Added `update({ onBlock })` to report each block as it finishes, and `extract()` now throws an `invalid_usage` error for `updateSource` with `ignoreAnonymous`.
Removed `transform()` and `transformWithFunction()`, which printed every block to the console and `transform()` read stdin. `update({ source, transformer, filter })` replaces `transformWithFunction()` but reads each `file=` first, so a missing file is a `read_failed` error (thrown, or collected with `continueOnError`); use `walk()` to transform without reading files. Added `update({ onBlock })` to report each block as it finishes.
4 changes: 0 additions & 4 deletions .github/actions/check-sync/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,6 @@ inputs:
Default: base when set, else each document's own directory, matching
update; with project or config, the configuration's outputRoot.
default: ''
ignore-anonymous:
description: For extract, skip blocks without file= (they have no linked file).
default: 'true'
project:
description: Load mdcode.config.json from the working directory (--project).
default: 'false'
Expand Down Expand Up @@ -69,7 +66,6 @@ runs:
DIRECTIONS: ${{ inputs.directions }}
BASE: ${{ inputs.base }}
DIR: ${{ inputs.dir }}
IGNORE_ANONYMOUS: ${{ inputs.ignore-anonymous }}
PROJECT: ${{ inputs.project }}
CONFIG: ${{ inputs.config }}
WORKING_DIRECTORY: ${{ inputs.working-directory }}
Expand Down
3 changes: 2 additions & 1 deletion .github/actions/check-sync/check-sync.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ function runs(direction, docs) {
}

// --force compares existing whole files instead of reporting them skipped; --check writes nothing.
const extract = [ "extract", "--check", "--force", ...(flag("IGNORE_ANONYMOUS") ? [ "--ignore-anonymous" ] : []), ...common ];
// Blocks without file= link to no file, and extract leaves them out.
const extract = [ "extract", "--check", "--force", ...common ];
// Both directions resolve file= against one root: dir, else update's base. A
// configuration supplies its own outputRoot.
const root = env("DIR") || env("BASE");
Expand Down
142 changes: 89 additions & 53 deletions .intent/review-state.json

Large diffs are not rendered by default.

12 changes: 6 additions & 6 deletions _artifacts/domain_map.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ skills:
- update plan, --diff, --apply, --check, --stdout, --base
- list and validate for inspection
- block selection with --name, --lang, --file, --meta
- extract with region splicing, --force, --ignore-anonymous, --dir, --check
- extract with region splicing, --force, --update-source, --dir, --check
- run --allow-shell and dump
- --transform modules and the update(), parse(), extract() library functions
- --json envelope and exit codes
Expand Down Expand Up @@ -65,13 +65,13 @@ skills:
source: src/cli.ts (UPDATE_MODES); packages/usage/tests/skill-sync-markdown-code-blocks.test.ts
priority: HIGH
status: active
- mistake: Plain extract on a README
mechanism: every block without file= is written as block-N.<ext> into --dir
- mistake: extract --update-source on a README
mechanism: every block without file= is written as block-N.<ext> into --dir, and the Markdown is rewritten to point at it
wrong_pattern: |
mdcode extract README.md
mdcode extract --update-source README.md
correct_pattern: |
mdcode validate --for extract README.md && mdcode extract --ignore-anonymous README.md
source: src/commands/validate.ts (planExtract)
mdcode validate --for extract README.md && mdcode extract README.md
source: src/commands/extract.ts (updateSource)
priority: HIGH
status: active
- mistake: Overwriting existing files with --force
Expand Down
6 changes: 6 additions & 0 deletions _artifacts/skill_spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,9 @@ ships the `mdcode` CLI and a library API from the workspace package `packages/md
- **Change:** new `extract --check` compares each target with what `extract` would write and writes nothing. `extract --force` keeps an overwritten file's final newline and refuses symlinked targets. A spliced region whose markers are indented is no longer indented a second time. New consumer GitHub Action `.github/actions/check-sync`.
- **Guidance:** the skill's Extract section adds `extract --check --force --ignore-anonymous` as the read-only preview, and notes that without `--force` existing whole files are reported as skipped (exit 2). The safety table lists `extract --check` among the commands that write nothing. The GitHub Action is not part of the skill; the package README documents it.
- **Checks:** `extract.test.ts` "extract: check" covers in sync, per-region drift, missing files and regions, trailing newlines, LF and CRLF round trips and symlinks. `region.test.ts` adds a read-then-splice round trip for indented markers. The skill task check still passes.

### Batch 4 — 2026-10-08, mdcode-ts 0.0.4 (issue #26)

- **Change:** `extract` skips blocks without `file=` unless `--update-source` is given, which writes them as `block-<N>.<ext>` and adds that `file=` to the Markdown. `--ignore-anonymous` and the library's `ignoreAnonymous` are removed. `validate --for extract` reports anonymous blocks with `path: null`. The check-sync action drops its `ignore-anonymous` input.
- **Guidance:** the Extract section's examples drop `--ignore-anonymous` and describe `--update-source` as the only way anonymous blocks become files. The "plain extract on a README" common mistake is replaced by "extract --update-source on a README". The domain map's failure mode is updated to match.
- **Checks:** `extract.test.ts` "skips blocks without file= unless updateSource names them in the markdown"; `validate.test.ts` counts generated names only with `updateSource`; usage tests for the JSON contract, `validate --for extract`, `--meta` parsing and `validate-snippets` (which now pipes each document to `extract --update-source` so its Markdown is never rewritten). The skill task check still passes.
2 changes: 1 addition & 1 deletion examples/CLI_EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ mdcode extract README.md
mdcode extract --force README.md

# Would extracting change anything? Writes nothing; exit 1 lists each block that differs
mdcode extract --check --force --ignore-anonymous README.md
mdcode extract --check --force README.md
```

### Basic Usage
Expand Down
18 changes: 16 additions & 2 deletions examples/ci/validate-snippets.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
// 2 at least one document could not be validated (extract failed, no
// runnable=true blocks anywhere, <command> or mdcode not found, bad arguments)
import { spawn, spawnSync } from "node:child_process";
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { constants, tmpdir } from "node:os";
import { basename, join, relative, resolve } from "node:path";

Expand Down Expand Up @@ -78,7 +78,21 @@ function parseArguments(argv) {

/** Extract one document's runnable blocks into workspace; returns the files written, or why it could not. */
function extractRunnable(document, workspace) {
const run = spawnSync("mdcode", [ "extract", "--meta", "runnable=true", "--dir", workspace, "--json", document ], {
let markdown;

try {
markdown = readFileSync(document, "utf8");
}
catch (error) {
// The same form as mdcode's own io_error, which it reported when it read the document itself.
return { problems: [ `io_error: ${error.message}` ] };
}

// Runnable blocks rarely have file=, and extract writes such a block only with
// --update-source. The document goes in on stdin so that mode never rewrites
// it: the Markdown with file= added comes back in the JSON result instead.
const run = spawnSync("mdcode", [ "extract", "--update-source", "--meta", "runnable=true", "--dir", workspace, "--json" ], {
input: markdown,
encoding: "utf8",
maxBuffer: Infinity,
});
Expand Down
62 changes: 30 additions & 32 deletions packages/mdcode/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,9 @@ mdcode list -l js -f app.test.js docs/guide.md

## Extract Command

Extract code blocks to files based on their `file` metadata.
Extract code blocks to files based on their `file` metadata. A block without `file=` (an anonymous
block) links to no file, so `extract` skips it; pass [`--update-source`](#update-source-with-generated-filenames)
to extract anonymous blocks too.

Extract is non-destructive. Before it writes anything, it checks every target (see
[Validate Command](#validate-command)). When any block breaks a rule, nothing is written and `extract`
Expand All @@ -350,8 +352,8 @@ exits 1 with an error per block:
as `ambiguous_target`. Two spellings of one file (`./a.ts` and `a.ts`, or a symlinked directory
inside `--dir`) count as one file. With several documents, the same rule applies to blocks in
different documents that write one file, so `docs/a.md` and `docs/b.md` cannot both write
`src/x.ts` whole, nor both write region `one` of it. Anonymous blocks count too: `block-1.sh` from
two documents is one file.
`src/x.ts` whole, nor both write region `one` of it. With `--update-source`, anonymous blocks count
too: `block-1.sh` from two documents is one file.
- **An existing file's markers for a declared region are broken** → a region that is never closed or
overlaps another is `malformed_region`, one opened more than once is `duplicate_region`, and one
marked only in another language's comment syntax is `region_language_mismatch`. An invalid
Expand Down Expand Up @@ -380,8 +382,9 @@ Otherwise, files that don't exist yet are created.
as an `unsafe_path` error. Every target is checked before anything is written, so when any is
refused, nothing is written and `extract` exits 1. To write into `../../shared-tests`, point
`--dir` higher and write `file=` relative to it.
- **No `file=`** → written as `block-N.<ext>` directly inside `--dir`. An existing symlink of that
name that leads out of `--dir` is refused too.
- **No `file=`, with `--update-source`** → written as `block-N.<ext>` directly inside `--dir`. An
existing symlink of that name that leads out of `--dir` is refused too. Without `--update-source`,
the block is skipped.

Whenever a file is skipped (an existing file without `--force`, a symlinked target, or a target that is
not valid UTF-8), `extract` prints a summary (even under `--quiet`) and exits with status 2.
Expand Down Expand Up @@ -452,7 +455,9 @@ mdcode extract -l python -m type=example -d ./examples docs/TUTORIAL.md

### Update Source with Generated Filenames

When extracting anonymous blocks (blocks without `file` metadata), automatically add the generated filename back to the markdown source:
`--update-source` also extracts anonymous blocks (blocks without `file` metadata), each as
`block-<N>.<ext>`, and adds that generated filename back to the markdown, so every extracted file is
linked to its block. Without it, anonymous blocks are skipped:

```bash
# Extract and update README with file metadata
Expand Down Expand Up @@ -484,19 +489,7 @@ This enables bidirectional sync workflow:
2. Modify extracted files: `nano block-1.sh`
3. Update markdown: `mdcode update --apply README.md`

### Skip Anonymous Blocks

Only extract blocks that have explicit `file` metadata, ignoring anonymous blocks:

```bash
# Only extract blocks with file= attribute
mdcode extract --ignore-anonymous README.md

# With filters and custom directory
mdcode extract --ignore-anonymous -l js -d ./src docs/API.md
```

**Note:** The flags `--update-source` and `--ignore-anonymous` are mutually exclusive. Using both will result in an error.
`--update-source` cannot be combined with `--check`, which writes nothing.

### Force Overwrite

Expand All @@ -522,7 +515,7 @@ including with `--force`.

```bash
# Would extracting change any file? Pass --force so existing whole files are compared, not skipped
mdcode extract --check --force --ignore-anonymous README.md
mdcode extract --check --force README.md
```

```text
Expand Down Expand Up @@ -1560,7 +1553,6 @@ last release, which may lack a flag the action needs; pin a release commit.
| `directions` | `update extract` | Which directions to check |
| `base` | | For update, the directory `file=` resolves against (`--base`); default each document's own directory |
| `dir` | | For extract, the same (`--dir`); default `base`, else each document's own directory, so both directions read the same files |
| `ignore-anonymous` | `true` | For extract, skip blocks without `file=`, which link to no file |
| `project` | `false` | Use `mdcode.config.json` (`--project`): its documents when `documents` is empty, its `sourceRoot` and `outputRoot`, its filters |
| `config` | | Use this configuration file instead (`--config`) |
| `working-directory` | `.` | Where to run |
Expand Down Expand Up @@ -1673,8 +1665,8 @@ Additional flags by command:
- `-d, --dir <dir>` - Directory that `file=` paths resolve against and must stay inside (default: the
configuration's `outputRoot`, else the current directory)
- `-q, --quiet` - Suppress status messages
- `--update-source` - Add file metadata to anonymous code blocks and update source
- `--ignore-anonymous` - Skip blocks without file metadata (mutually exclusive with --update-source)
- `--update-source` - Also extract blocks without `file=`, each as `block-<N>.<ext>`, and add that
`file=` to them in the markdown. Without it, those blocks are skipped
- `--force` - Overwrite existing files whose blocks have no `region=` (skipped by default)
- `--check` - Write nothing; exit 1 when a target differs from what `extract` would write. See
[Check Without Writing](#check-without-writing)
Expand Down Expand Up @@ -1704,7 +1696,6 @@ Additional flags by command:
- `--strict` - Require `file=` metadata on every selected block
- `--base <dir>` - With `--for update`: as for `update`
- `-d, --dir <dir>` - With `--for extract`: as for `extract`
- `--ignore-anonymous` - With `--for extract`: skip blocks without `file=`, as `extract` does
- `--project`, `--config <path>` - As for `update` and `extract`

**watch:**
Expand Down Expand Up @@ -1739,8 +1730,8 @@ document an agent produced. It treats the markdown as untrusted and whoever runs

- `update` reads a `file=` only when it resolves inside the base: the markdown file's directory by
default, the current directory for stdin, or the directory given with `--base`.
- `extract` writes a target only when it resolves inside `--dir`, and that includes the generated
`block-N.<ext>` names.
- `extract` writes a target only when it resolves inside `--dir`, and that includes the
`block-N.<ext>` names `--update-source` generates.
- For `update` and `extract`, an absolute `file=`, one that climbs out with `..`, and one that leads
out through an existing symlink anywhere along the path, the file itself included, are refused as
`unsafe_path`. The check runs before anything is read or written.
Expand Down Expand Up @@ -2045,8 +2036,8 @@ type DumpEnvelope = Envelope<{
nothing, and a document that never got a result has no entry.
- `validate` - `operation` is the command the documents were checked for. One entry in `documents` per
document, each with one entry per selected block, in document order. `path` is the file the block
maps to: for `extract`, the target joined onto `--dir`, with the generated `block-N` name for a
block without `file=`; for `update`, its `file=` as written; `null` when it maps to no file. `valid`
maps to: for `extract`, the target joined onto `--dir`, or `null` for a block without `file=`, which
`extract` skips; for `update`, its `file=` as written; `null` when it maps to no file. `valid`
is `false` when an error concerns the block, and each problem adds an error whose code names the
rule.
- `run` - One entry per selected block, with the command's exit code and output. Each block whose
Expand Down Expand Up @@ -2297,6 +2288,12 @@ Exit codes are the same with and without `--json`:
- `extract --force` refuses a target that is a symlink or not valid UTF-8, as region splices already
did, instead of replacing the link with a regular file or re-encoding the bytes. The target is
skipped and `extract` exits 2.
- `extract` no longer writes blocks without `file=` by default. A plain `mdcode extract README.md`
used to write every untagged block as `block-<N>.<ext>` beside the README; now it writes only
blocks that have `file=`, as `--ignore-anonymous` did. `--ignore-anonymous` is removed from
`extract` and `validate`, and so is the library's `ignoreAnonymous` option: drop it. To extract
anonymous blocks, pass `--update-source` (`updateSource: true`), which also writes their generated
`file=` into the markdown. `validate --for extract` reports such blocks with `path: null`.

---

Expand Down Expand Up @@ -2560,8 +2557,8 @@ Write code blocks to files based on their `file` metadata.
- **options.filter** - Optional filter criteria
- **options.outputDir** - Directory that `file=` paths resolve against and must stay inside (default:
'.')
- **options.updateSource** - Add `file=` to anonymous blocks
- **options.ignoreAnonymous** - Skip blocks without `file=`
- **options.updateSource** - Also extract blocks without `file=`, each as `block-<N>.<ext>`, and add
that `file=` to them in `updatedSource`. Without it, those blocks are skipped
- **options.force** - Overwrite existing files whose blocks have no `region=`
- **options.check** - Write nothing; compare each target with what `extract` would write instead. See
[Check Without Writing](#check-without-writing)
Expand All @@ -2573,7 +2570,7 @@ Write code blocks to files based on their `file` metadata.
mapping rule checked by `validate()` for extract, it throws an `Error` whose `errors` array holds
one `ResultError` per block (`unsafe_path`, `ambiguous_target`, `malformed_region`,
`duplicate_region` or `region_language_mismatch`), and nothing is written. `updateSource` with
`ignoreAnonymous` or with `check` throws an `Error` whose `code` is `invalid_usage`.
`check` throws an `Error` whose `code` is `invalid_usage`.

#### `validate(options: ValidateOptions): Promise<ValidateResult>`

Expand All @@ -2585,7 +2582,8 @@ name, where they exist, but never writes. See [Validate Command](#validate-comma
- **options.filter** - Optional filter criteria
- **options.base** - `extract`'s `outputDir`, or `update`'s `basePath` (default: '.')
- **options.strict** - Require `file=` on every selected block
- **options.ignoreAnonymous** - For `"extract"`, leave out blocks without `file=`
- **options.updateSource** - For `"extract"`, also map blocks without `file=` to the `block-<N>`
files `extract` writes for them with `updateSource`. Without it, they map to no file (`path: null`)
- **Returns** - Promise of `{ blocks, errors }`: one `ValidatedBlock` (`name`, `line`, `lang`, `path`,
`region?`, `valid`) per selected block, and one `ResultError` per broken rule, in document order
- **Throws** - `MetadataError` when the document's metadata is invalid
Expand Down
Loading
Loading