webui: a WYSIWYG Markdown editor in the Console docs view, built from reviewable source (row 154, Q168, #1648)

Jason's Q168: a visual editor for the Console's project documents,
held to row 148's rendering rules.

Library. ProseMirror with prosemirror-markdown and markdown-it, chosen
in the phase 1 library report (LIBRARY.md, REPORT.md in the packet).
packages/webui/third_party/editor/ holds 19 upstream packages as source,
byte for byte, each with its license, plus NOTICE and PROVENANCE.json.

Build. packages/webui/editor/ holds the model, view, entry and
build.mjs. esbuild 0.28.2 is an exact devDependency of packages/webui
with its own lock; editor/toolchain.mjs checks the version and the
binary's sha256 before any build. MOSAIC_REQUIRE_EDITOR_BUILD=1 turns a
missing or different esbuild into a failure, and the rebuild test then
checks the committed src/public/editor.js and editor.css match a fresh
build. The built bundle is committed under Mos's conditions
(mos-0755, mos-0765).

Console. The docs view gets Visual and Source modes. A save still
writes Markdown through the existing project write path, with the same
stale-base and read-only handling. A mode switch reuses the draft and
reads nothing from the project. Links and images keep row 148's
target rules, and no raw HTML runs. serve.mjs serves /editor.js and
/editor.css; index.html loads them.

Owner Researcher, brief docs/plans/2026-10-16_q168-wysiwyg-editor.md.
Round 5 is a rebase onto d88613bac (row 155); only docs/TOOLS.md,
webui/README.md, public/bus.js and serve.mjs differ from the round
before. Reviewers on candidate manifest 3a4bc21c...3b05: Dewey
(security) approve 29027, Filbert approve 29026. Mos's decision 123 go
for public.diff 902f0244 (handwritten part ea59079e): mos-0824. Sage gate
on 72c3fa598 with MOSAIC_REQUIRE_EDITOR_BUILD=1: GATE_PASSED 25/25
(node 2397, selftests 422), 22:07:29Z to 22:13:50Z; the rebuild test
ran and passed.

Nothing live.
This commit is contained in:
jason.woltje committed 2026-10-18 17:14:39 -05:00
1 parent 72c3fa598f
commit 9353675d1c
195 files changed
+49359 -29

No files matched your search

+5 -1
View File
@@ -438,7 +438,11 @@ spec/north-star.md or docs/NAME.md of a project: one local commit authored by th
business's human, refused `stale-base` if the file changed since the editor opened it.
It is the human bus verb `project.file.save` through the human transport; an agent
refuses it. Nothing is pushed. Without `--send` the page reads and previews only. See
`packages/webui/README.md` ("Spec and docs editor"). A save of spec/north-star.md also
`packages/webui/README.md` ("Spec and docs editor"). The page opens in a visual editor
built from reviewable source (row 154): `third_party/editor/` is unmodified upstream
source with its licenses and `PROVENANCE.json`, and `npm run build:editor` in
`packages/webui` rebuilds `src/public/editor.js` and `editor.css` with esbuild 0.28.2;
the save, the verb and the routes are unchanged. See "Visual editor" there. A save of spec/north-star.md also
sends the business's pm and cto one notice naming the commit (row 143); a BLOCK from
either goes to the chief of staff and on to the assistant. See `packages/bus/README.md`
("Spec review notices").
+60 -4
View File
@@ -486,9 +486,11 @@ never loaded. Each render has a look-ahead budget of 32 characters examined per
character of text (at least 1 Mi): an opener with no closer searches at most 2000
characters, and the budget caps the total over a whole file. Once it is spent, every
later link, image, code span and emphasis shows as its source text, so a hostile file
can slow the editor by a fixed multiple of its length and no more. Nothing is vendored
and the Content-Security-Policy is unchanged; the choice against a bundled editor is
recorded in `agents/researcher/work/queue-148/LIBRARY.md`.
can slow the editor by a fixed multiple of its length and no more. The preview vendors
nothing and the Content-Security-Policy is unchanged; the choice against a bundled editor
is recorded in `agents/researcher/work/queue-148/LIBRARY.md`. Row 154 later added one,
for the page's default view; this textarea and preview are now its Source view (see
"Visual editor").
Routes: GET `/api/project-files?project=` and GET `/api/project-file?project=&path=`
run the human transport like the other reads (400 for a bad query, 503 with no bus, 502
@@ -506,7 +508,61 @@ outcome, never the content.
Tests: `project-file.test.mjs` (the routes), `markdown-preview.test.mjs` (the sink
check, each element, the scheme rules, the look-ahead budget) and `docs-browser.test.mjs`
(markdown with `<script>`, `onerror` and `javascript:` runs nothing; the draft, each
save receipt, the stale-base message, sign-out and read-only mode).
save receipt, the stale-base message, sign-out and read-only mode). Those tests open the
Source view first (`source()` in the test file); their assertions are unchanged.
## Visual editor (row 154, #1648, Q168)
A file opens in a visual editor (ProseMirror) when the editor can write the file's own
bytes back with no edit; otherwise it opens in the Source view with a note. Visual and
Source are two buttons above the page. They edit one draft: a change in either shows in
the other, and the read, save, stale-base and read-only rules above are the same for
both. The editor is a view over the same `{project, path, base, content}` save.
The editor is built from reviewable source, not a prebuilt bundle:
- `third_party/editor/` is unmodified upstream TypeScript source of 19 packages
(ProseMirror, markdown-it and their dependencies), copied byte for byte from tagged
trees. Each package keeps its own license file and `package.json` beside its source;
`NOTICE` lists them, and `PROVENANCE.json` records the origin, tag, commit, upstream
tree and the git blob and sha256 of every file, the tree hash, and `localChanges`
(empty). `agents/researcher/work/queue-154/repro/import-sources.mjs` makes the
directory.
- `editor/` is the editor's own code: `model.mjs` (schema, parse, serialise), `view.mjs`
(toolbar, link bar, keys), `entry.mjs`, `editor.css` and `build.mjs`.
- `node editor/build.mjs` (`npm run build:editor`) bundles them with esbuild 0.28.2,
asserted exact, into `src/public/editor.js` and `editor.css`, one unminified script
with no source map, and writes `editor/BUILD.json` with the inputs and the sha256 of
each output. Two builds from different absolute paths give identical bytes. The built
files are committed, so a pinned copy needs no build step and no esbuild.
Rules the editor keeps from the preview: no raw HTML runs (it is kept as source text in a
tinted block), a link is made only for a target `window.MosaicMarkdown.linkTarget`
accepts (`http:`, `https:`, relative) and gets `rel="noopener noreferrer"`, and an image
is text (`[image: alt] url`), never an `<img>`, on the load path and on paste. The editor
adds no `innerHTML`-family call (a test reads `editor/*.mjs` for them; the bundle's
upstream code uses the DOM APIs and is not scanned) and no `eval`. The Content-Security-
Policy is unchanged: `editor.js` and `editor.css` are served from `'self'`.
Open and save with no edit writes the same bytes: the editor keeps each top-level block's
source and writes an untouched block back as it was. A block you change is written in
the editor's own form (`-` bullets, soft breaks as new lines, reference links as inline
links, `<` and `&` escaped). Tables, HTML blocks, front matter, indented code, setext
headings, strikethrough, link reference definitions, and a block that holds a link target
the gate refuses are kept as source blocks and not edited visually. Known limits: CRLF
saves as LF (as in row 148); an unedited file keeps Save off; an empty paragraph is
dropped when its neighbour is edited; copying an image inside the editor does not keep
it; two adjacent lists may merge when the file is read again; a redraw or a switch
between Visual and Source starts a new undo history.
Routes: GET `/editor.js` and GET `/editor.css`, static like the other public files.
Tests: `editor.test.mjs` (routes, the round trip over the goals review, a document with
every element and every repository doc, the source rules), `editor-provenance.test.mjs`
(the imported tree against `PROVENANCE.json` and its recorded local changes, license
files, the build record, and a rebuild that skips with a reason when esbuild 0.28.2 is
not installed) and `editor-browser.test.mjs` (hostile content on the load and the paste
path, the Console's CSP, edit and save, the link bar, read-only).
## Assistant settings (row 155, #1649)
+150
View File
@@ -0,0 +1,150 @@
{
"schema": 1,
"esbuild": "0.28.2",
"options": "bundle, iife, es2022, browser, legal-comments inline, unminified; aliases in editor/build.mjs",
"outputs": {
"src/public/editor.js": {
"sha256": "9c881b13740fae2408717ad75fa1d85bfcfe3fa8b3af0f1b3f2e706c90e80fee",
"bytes": 758420
},
"src/public/editor.css": {
"sha256": "8654ac57d1e7a561110138ecd7f701c34a074017ff8c4cd2768d8083908ee61f",
"bytes": 4462
}
},
"inputs": [
"editor/entry.mjs",
"editor/model.mjs",
"editor/view.mjs",
"third_party/editor/entities/src/decode.ts",
"third_party/editor/entities/src/decode_codepoint.ts",
"third_party/editor/entities/src/encode.ts",
"third_party/editor/entities/src/escape.ts",
"third_party/editor/entities/src/generated/decode-data-html.ts",
"third_party/editor/entities/src/generated/decode-data-xml.ts",
"third_party/editor/entities/src/generated/encode-html.ts",
"third_party/editor/entities/src/index.ts",
"third_party/editor/linkify-it/index.mjs",
"third_party/editor/linkify-it/lib/re.mjs",
"third_party/editor/markdown-it/index.mjs",
"third_party/editor/markdown-it/lib/common/html_blocks.mjs",
"third_party/editor/markdown-it/lib/common/html_re.mjs",
"third_party/editor/markdown-it/lib/common/utils.mjs",
"third_party/editor/markdown-it/lib/helpers/index.mjs",
"third_party/editor/markdown-it/lib/helpers/parse_link_destination.mjs",
"third_party/editor/markdown-it/lib/helpers/parse_link_label.mjs",
"third_party/editor/markdown-it/lib/helpers/parse_link_title.mjs",
"third_party/editor/markdown-it/lib/index.mjs",
"third_party/editor/markdown-it/lib/parser_block.mjs",
"third_party/editor/markdown-it/lib/parser_core.mjs",
"third_party/editor/markdown-it/lib/parser_inline.mjs",
"third_party/editor/markdown-it/lib/presets/commonmark.mjs",
"third_party/editor/markdown-it/lib/presets/default.mjs",
"third_party/editor/markdown-it/lib/presets/zero.mjs",
"third_party/editor/markdown-it/lib/renderer.mjs",
"third_party/editor/markdown-it/lib/ruler.mjs",
"third_party/editor/markdown-it/lib/rules_block/blockquote.mjs",
"third_party/editor/markdown-it/lib/rules_block/code.mjs",
"third_party/editor/markdown-it/lib/rules_block/fence.mjs",
"third_party/editor/markdown-it/lib/rules_block/heading.mjs",
"third_party/editor/markdown-it/lib/rules_block/hr.mjs",
"third_party/editor/markdown-it/lib/rules_block/html_block.mjs",
"third_party/editor/markdown-it/lib/rules_block/lheading.mjs",
"third_party/editor/markdown-it/lib/rules_block/list.mjs",
"third_party/editor/markdown-it/lib/rules_block/paragraph.mjs",
"third_party/editor/markdown-it/lib/rules_block/reference.mjs",
"third_party/editor/markdown-it/lib/rules_block/state_block.mjs",
"third_party/editor/markdown-it/lib/rules_block/table.mjs",
"third_party/editor/markdown-it/lib/rules_core/block.mjs",
"third_party/editor/markdown-it/lib/rules_core/inline.mjs",
"third_party/editor/markdown-it/lib/rules_core/linkify.mjs",
"third_party/editor/markdown-it/lib/rules_core/normalize.mjs",
"third_party/editor/markdown-it/lib/rules_core/replacements.mjs",
"third_party/editor/markdown-it/lib/rules_core/smartquotes.mjs",
"third_party/editor/markdown-it/lib/rules_core/state_core.mjs",
"third_party/editor/markdown-it/lib/rules_core/text_join.mjs",
"third_party/editor/markdown-it/lib/rules_inline/autolink.mjs",
"third_party/editor/markdown-it/lib/rules_inline/backticks.mjs",
"third_party/editor/markdown-it/lib/rules_inline/balance_pairs.mjs",
"third_party/editor/markdown-it/lib/rules_inline/emphasis.mjs",
"third_party/editor/markdown-it/lib/rules_inline/entity.mjs",
"third_party/editor/markdown-it/lib/rules_inline/escape.mjs",
"third_party/editor/markdown-it/lib/rules_inline/fragments_join.mjs",
"third_party/editor/markdown-it/lib/rules_inline/html_inline.mjs",
"third_party/editor/markdown-it/lib/rules_inline/image.mjs",
"third_party/editor/markdown-it/lib/rules_inline/link.mjs",
"third_party/editor/markdown-it/lib/rules_inline/linkify.mjs",
"third_party/editor/markdown-it/lib/rules_inline/newline.mjs",
"third_party/editor/markdown-it/lib/rules_inline/state_inline.mjs",
"third_party/editor/markdown-it/lib/rules_inline/strikethrough.mjs",
"third_party/editor/markdown-it/lib/rules_inline/text.mjs",
"third_party/editor/markdown-it/lib/token.mjs",
"third_party/editor/mdurl/index.mjs",
"third_party/editor/mdurl/lib/decode.mjs",
"third_party/editor/mdurl/lib/encode.mjs",
"third_party/editor/mdurl/lib/format.mjs",
"third_party/editor/mdurl/lib/parse.mjs",
"third_party/editor/orderedmap/index.js",
"third_party/editor/prosemirror-commands/src/commands.ts",
"third_party/editor/prosemirror-history/src/history.ts",
"third_party/editor/prosemirror-inputrules/src/index.ts",
"third_party/editor/prosemirror-inputrules/src/inputrules.ts",
"third_party/editor/prosemirror-inputrules/src/rulebuilders.ts",
"third_party/editor/prosemirror-inputrules/src/rules.ts",
"third_party/editor/prosemirror-keymap/src/keymap.ts",
"third_party/editor/prosemirror-markdown/src/from_markdown.ts",
"third_party/editor/prosemirror-markdown/src/index.ts",
"third_party/editor/prosemirror-markdown/src/schema.ts",
"third_party/editor/prosemirror-markdown/src/to_markdown.ts",
"third_party/editor/prosemirror-model/src/comparedeep.ts",
"third_party/editor/prosemirror-model/src/content.ts",
"third_party/editor/prosemirror-model/src/diff.ts",
"third_party/editor/prosemirror-model/src/fragment.ts",
"third_party/editor/prosemirror-model/src/from_dom.ts",
"third_party/editor/prosemirror-model/src/index.ts",
"third_party/editor/prosemirror-model/src/mark.ts",
"third_party/editor/prosemirror-model/src/node.ts",
"third_party/editor/prosemirror-model/src/replace.ts",
"third_party/editor/prosemirror-model/src/resolvedpos.ts",
"third_party/editor/prosemirror-model/src/schema.ts",
"third_party/editor/prosemirror-model/src/to_dom.ts",
"third_party/editor/prosemirror-schema-list/src/schema-list.ts",
"third_party/editor/prosemirror-state/src/index.ts",
"third_party/editor/prosemirror-state/src/plugin.ts",
"third_party/editor/prosemirror-state/src/selection.ts",
"third_party/editor/prosemirror-state/src/state.ts",
"third_party/editor/prosemirror-state/src/transaction.ts",
"third_party/editor/prosemirror-transform/src/attr_step.ts",
"third_party/editor/prosemirror-transform/src/index.ts",
"third_party/editor/prosemirror-transform/src/map.ts",
"third_party/editor/prosemirror-transform/src/mark.ts",
"third_party/editor/prosemirror-transform/src/mark_step.ts",
"third_party/editor/prosemirror-transform/src/replace.ts",
"third_party/editor/prosemirror-transform/src/replace_step.ts",
"third_party/editor/prosemirror-transform/src/step.ts",
"third_party/editor/prosemirror-transform/src/structure.ts",
"third_party/editor/prosemirror-transform/src/transform.ts",
"third_party/editor/prosemirror-view/src/browser.ts",
"third_party/editor/prosemirror-view/src/capturekeys.ts",
"third_party/editor/prosemirror-view/src/clipboard.ts",
"third_party/editor/prosemirror-view/src/decoration.ts",
"third_party/editor/prosemirror-view/src/dom.ts",
"third_party/editor/prosemirror-view/src/domchange.ts",
"third_party/editor/prosemirror-view/src/domcoords.ts",
"third_party/editor/prosemirror-view/src/domobserver.ts",
"third_party/editor/prosemirror-view/src/index.ts",
"third_party/editor/prosemirror-view/src/input.ts",
"third_party/editor/prosemirror-view/src/selection.ts",
"third_party/editor/prosemirror-view/src/viewdesc.ts",
"third_party/editor/punycode.js/punycode.js",
"third_party/editor/rope-sequence/index.js",
"third_party/editor/uc.micro/categories/Cc/regex.mjs",
"third_party/editor/uc.micro/categories/Cf/regex.mjs",
"third_party/editor/uc.micro/categories/P/regex.mjs",
"third_party/editor/uc.micro/categories/S/regex.mjs",
"third_party/editor/uc.micro/categories/Z/regex.mjs",
"third_party/editor/uc.micro/index.mjs",
"third_party/editor/uc.micro/properties/Any/regex.mjs",
"third_party/editor/w3c-keyname/index.js"
]
}
+61
View File
@@ -0,0 +1,61 @@
// usage: node editor/build.mjs [--out <dir>]
// Row 154 (#1648, Q168): builds src/public/editor.js and src/public/editor.css from the reviewed source
// (editor/*.mjs and third_party/editor/) with the pinned esbuild. The output is one unminified IIFE.
// It also writes editor/BUILD.json: the esbuild version, every input, and the sha256 of each output.
// Paths in the bundle are relative to packages/webui, so a build from any copy of the tree is the same bytes.
import { createHash } from 'node:crypto';
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { ESBUILD, toolchain } from './toolchain.mjs';
export { ESBUILD };
const root = dirname(dirname(fileURLToPath(import.meta.url)));
const T = './third_party/editor';
const alias = {};
for (const p of ['model', 'state', 'view', 'transform', 'inputrules', 'markdown']) alias[`prosemirror-${p}`] = `${T}/prosemirror-${p}/src/index.ts`;
for (const p of ['commands', 'history', 'keymap', 'schema-list']) alias[`prosemirror-${p}`] = `${T}/prosemirror-${p}/src/${p}.ts`;
for (const p of ['orderedmap', 'rope-sequence', 'w3c-keyname']) alias[p] = `${T}/${p}/index.js`;
for (const p of ['markdown-it', 'mdurl', 'uc.micro', 'linkify-it']) alias[p] = `${T}/${p}/index.mjs`;
alias['punycode.js'] = `${T}/punycode.js/punycode.js`;
alias.entities = `${T}/entities/src/index.ts`;
const sha256 = buf => createHash('sha256').update(buf).digest('hex');
export async function run(outDir = join(root, 'src/public'), buildJson = join(root, 'editor/BUILD.json')) {
const tool = toolchain();
if (!tool.ok) throw new Error(`refusing to build: ${tool.reason}`);
const { build, version } = await import('esbuild');
const result = await build({
absWorkingDir: root,
entryPoints: ['./editor/entry.mjs'],
outfile: './src/public/editor.js',
bundle: true, format: 'iife', target: 'es2022', platform: 'browser',
legalComments: 'inline', logLevel: 'warning', metafile: true, write: false,
alias,
});
const js = Buffer.from(result.outputFiles[0].contents);
const css = Buffer.from(
'/* Built by editor/build.mjs: prosemirror-view/style/prosemirror.css, then editor/editor.css. Do not edit. */\n'
+ readFileSync(join(root, 'third_party/editor/prosemirror-view/style/prosemirror.css'), 'utf8')
+ '\n' + readFileSync(join(root, 'editor/editor.css'), 'utf8'));
mkdirSync(outDir, { recursive: true });
writeFileSync(join(outDir, 'editor.js'), js);
writeFileSync(join(outDir, 'editor.css'), css);
const info = {
schema: 1, esbuild: version,
options: 'bundle, iife, es2022, browser, legal-comments inline, unminified; aliases in editor/build.mjs',
outputs: { 'src/public/editor.js': { sha256: sha256(js), bytes: js.length }, 'src/public/editor.css': { sha256: sha256(css), bytes: css.length } },
inputs: Object.keys(result.metafile.inputs).sort(),
};
writeFileSync(buildJson, JSON.stringify(info, null, 2) + '\n');
return info;
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const i = process.argv.indexOf('--out');
const out = i > 0 ? process.argv[i + 1] : undefined;
const info = await run(out, out ? join(out, 'BUILD.json') : undefined);
console.log(`editor.js ${info.outputs['src/public/editor.js'].sha256} (${info.inputs.length} inputs)`);
console.log(`editor.css ${info.outputs['src/public/editor.css'].sha256}`);
}
+34
View File
@@ -0,0 +1,34 @@
/* Row 154 (#1648, Q168): the Console's visual editor. Colours come from the Console's own variables. */
.ed-wrap { display: flex; flex-direction: column; min-width: 0; border: 1px solid var(--line); border-radius: 6px; background: var(--surface); }
.ed-toolbar { display: flex; flex-wrap: wrap; gap: 4px; padding: 6px; border-bottom: 1px solid var(--line); }
.ed-btn { font: inherit; font-size: 12px; padding: 3px 8px; border-radius: 4px; border: 1px solid var(--line); background: transparent; color: inherit; cursor: pointer; }
.ed-btn:hover:not(:disabled) { background: rgba(127, 127, 127, 0.18); }
.ed-btn.on { background: rgba(127, 127, 127, 0.32); font-weight: 600; }
.ed-btn:disabled { opacity: 0.4; cursor: default; }
.ed-linkbar { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; padding: 6px; border-bottom: 1px solid var(--line); }
.ed-linkbar[hidden] { display: none; }
.ed-link-input { flex: 1 1 18em; min-width: 8em; font: inherit; padding: 3px 6px; }
.ed-link-note { font-size: 12px; opacity: 0.85; }
.ed-surface { min-width: 0; overflow: auto; }
.ed-surface .ProseMirror { min-height: 16em; max-height: 70vh; overflow: auto; padding: 10px 14px; outline: none; line-height: 1.5; overflow-wrap: anywhere; white-space: pre-wrap; }
.ed-surface .ProseMirror p { margin: 0 0 0.8em; }
.ed-surface .ProseMirror h1, .ed-surface .ProseMirror h2, .ed-surface .ProseMirror h3,
.ed-surface .ProseMirror h4, .ed-surface .ProseMirror h5, .ed-surface .ProseMirror h6 { margin: 0.9em 0 0.4em; line-height: 1.25; }
.ed-surface .ProseMirror h1 { font-size: 1.6em; }
.ed-surface .ProseMirror h2 { font-size: 1.35em; }
.ed-surface .ProseMirror h3 { font-size: 1.15em; }
.ed-surface .ProseMirror blockquote { margin: 0 0 0.8em; padding-left: 12px; border-left: 3px solid var(--line); opacity: 0.92; }
.ed-surface .ProseMirror ul, .ed-surface .ProseMirror ol { margin: 0 0 0.8em; padding-left: 1.6em; }
.ed-surface .ProseMirror li > p { margin-bottom: 0.3em; }
.ed-surface .ProseMirror code { font-family: ui-monospace, monospace; font-size: 0.92em; padding: 0 3px; border-radius: 3px; background: rgba(127, 127, 127, 0.2); }
.ed-surface .ProseMirror pre { margin: 0 0 0.8em; padding: 8px 10px; border-radius: 4px; background: rgba(127, 127, 127, 0.16); overflow: auto; white-space: pre; }
.ed-surface .ProseMirror pre code { padding: 0; background: none; }
.ed-surface .ProseMirror pre.md-raw { border-left: 3px solid #b8860b; }
.ed-surface .ProseMirror hr { border: 0; border-top: 1px solid var(--line); margin: 1em 0; }
.ed-surface .ProseMirror a { color: inherit; text-decoration: underline; }
.ed-surface .ProseMirror .md-image { font-family: ui-monospace, monospace; font-size: 0.9em; padding: 0 3px; border: 1px dashed var(--line); border-radius: 3px; }
.ed-surface .ProseMirror .md-bad-link { text-decoration: line-through; }
.ed-surface .ProseMirror-selectednode { outline: 2px solid #5b9bd5; }
.doc-mode { display: flex; gap: 6px; margin: 8px 0; }
.doc-mode .btn[aria-pressed="true"] { background: var(--raised); font-weight: 600; }
.doc-edit.one { grid-template-columns: minmax(0, 1fr); }
+8
View File
@@ -0,0 +1,8 @@
// Row 154 (#1648, Q168): the bundle's entry. Its only product is window.MosaicEditor, frozen.
import { mount } from './view.mjs';
import { parse, serialise } from './model.mjs';
// roundTrip is for tests: what a save would write for a document opened as `src` and not edited.
const roundTrip = src => serialise(parse(String(src)));
globalThis.MosaicEditor = Object.freeze({ mount, roundTrip });
+306
View File
@@ -0,0 +1,306 @@
// Row 154 (#1648, Q168): the Markdown model of the Console's visual editor.
//
// A document is split into its top-level blocks (markdown-it token maps). Each block that has a
// structure the editor knows becomes a ProseMirror node; anything else (a table, an HTML block, front
// matter, indented code, a paragraph with ~~strikethrough~~, link reference definitions) becomes a
// `raw_block`, a node that holds its source lines and writes them back as they are.
//
// Every top-level node remembers `raw` (its source lines), `lead` (the blank lines before it) and `sig`
// (what the serialiser wrote for it when it was read). A save writes `lead + raw` for a node whose
// serialisation still equals `sig`, and the serialiser's output for one that changed. So a file opened
// and saved with no edit comes back byte for byte, and an edit rewrites only the blocks it touched.
//
// Rendering rules are row 148's: nothing here makes markup from text. A link is a mark only when
// `window.MosaicMarkdown.linkTarget` (markdown-preview.js, the one gate) accepts its target; an image is
// a node drawn as text and never loaded; raw HTML in a file is text.
import { Schema } from 'prosemirror-model';
import { MarkdownParser, MarkdownSerializer, defaultMarkdownSerializer } from 'prosemirror-markdown';
import MarkdownIt from 'markdown-it';
// The one gate for a link target. Without markdown-preview.js nothing passes.
export const linkTarget = raw => {
const gate = globalThis.MosaicMarkdown?.linkTarget;
return typeof gate === 'function' ? gate(raw) : null;
};
const kept = { raw: { default: null }, sig: { default: null }, lead: { default: '' } };
const block = attrs => ({ ...kept, ...attrs });
export const schema = new Schema({
nodes: {
doc: { content: 'block+', attrs: { tail: { default: '' } } },
paragraph: { content: 'inline*', group: 'block', attrs: block({}), parseDOM: [{ tag: 'p' }], toDOM() { return ['p', 0]; } },
blockquote: { content: 'block+', group: 'block', attrs: block({}), parseDOM: [{ tag: 'blockquote' }], toDOM() { return ['blockquote', 0]; } },
horizontal_rule: { group: 'block', attrs: block({}), parseDOM: [{ tag: 'hr' }], toDOM() { return ['div', ['hr']]; } },
heading: {
attrs: block({ level: { default: 1 } }),
content: '(text | image)*', group: 'block', defining: true,
parseDOM: [1, 2, 3, 4, 5, 6].map(level => ({ tag: 'h' + level, attrs: { level } })),
toDOM(node) { return ['h' + +node.attrs.level, 0]; },
},
code_block: {
content: 'text*', group: 'block', code: true, defining: true, marks: '',
attrs: block({ params: { default: '' } }),
parseDOM: [{ tag: 'pre', preserveWhitespace: 'full', getAttrs: dom => ({ params: dom.getAttribute('data-params') || '' }) }],
toDOM(node) { return ['pre', node.attrs.params ? { 'data-params': node.attrs.params } : {}, ['code', 0]]; },
},
// Source the editor does not model. Edited as plain text, written back as typed.
raw_block: {
content: 'text*', group: 'block', code: true, defining: true, marks: '', attrs: block({}),
parseDOM: [{ tag: 'pre.md-raw', preserveWhitespace: 'full' }],
toDOM() { return ['pre', { class: 'md-raw', title: 'Markdown the visual editor keeps as written' }, ['code', 0]]; },
},
ordered_list: {
content: 'list_item+', group: 'block', attrs: block({ order: { default: 1 }, tight: { default: false } }),
parseDOM: [{ tag: 'ol', getAttrs: dom => ({ order: dom.hasAttribute('start') ? +dom.getAttribute('start') : 1, tight: dom.hasAttribute('data-tight') }) }],
toDOM(node) { return ['ol', { start: node.attrs.order === 1 ? null : node.attrs.order, 'data-tight': node.attrs.tight ? 'true' : null }, 0]; },
},
bullet_list: {
content: 'list_item+', group: 'block', attrs: block({ tight: { default: false } }),
parseDOM: [{ tag: 'ul', getAttrs: dom => ({ tight: dom.hasAttribute('data-tight') }) }],
toDOM(node) { return ['ul', { 'data-tight': node.attrs.tight ? 'true' : null }, 0]; },
},
list_item: { content: 'block+', defining: true, parseDOM: [{ tag: 'li' }], toDOM() { return ['li', 0]; } },
text: { group: 'inline' },
// An image is never loaded: it is an atom drawn as the text `[image: alt] address`, as row 148's
// preview draws it. There is no <img> in the schema and no rule that makes one from pasted HTML.
image: {
inline: true, group: 'inline', atom: true, draggable: true,
attrs: { src: {}, alt: { default: null }, title: { default: null } },
parseDOM: [{
tag: 'span.md-image[data-src]',
// Pasted HTML gets an image atom only for a target the link gate accepts; a file's own image, whatever
// its target, stays on the load path, where it is drawn as text and never fetched.
getAttrs: dom => (linkTarget(dom.getAttribute('data-src')) === null ? false
: { src: dom.getAttribute('data-src'), alt: dom.getAttribute('data-alt'), title: dom.getAttribute('data-title') }),
}],
toDOM(node) {
const a = node.attrs, shown = `[image: ${a.alt ?? ''}] ${a.src}`;
return ['span', { class: 'md-image', 'data-src': a.src, 'data-alt': a.alt, 'data-title': a.title }, shown];
},
},
hard_break: { inline: true, group: 'inline', selectable: false, parseDOM: [{ tag: 'br' }], toDOM() { return ['br']; } },
},
marks: {
em: {
parseDOM: [{ tag: 'i' }, { tag: 'em' }, { style: 'font-style=italic' }, { style: 'font-style=normal', clearMark: m => m.type.name === 'em' }],
toDOM() { return ['em']; },
},
strong: {
parseDOM: [
{ tag: 'strong' },
{ tag: 'b', getAttrs: dom => dom.style.fontWeight !== 'normal' && null },
{ style: 'font-weight=400', clearMark: m => m.type.name === 'strong' },
{ style: 'font-weight', getAttrs: v => /^(bold(er)?|[5-9]\d{2,})$/.test(v) && null },
],
toDOM() { return ['strong']; },
},
// A link mark exists only for a target the gate accepts, on load, on paste and in the link bar.
link: {
attrs: { href: {}, title: { default: null } },
inclusive: false,
parseDOM: [{
tag: 'a[href]',
getAttrs: dom => { const href = linkTarget(dom.getAttribute('href')); return href ? { href, title: dom.getAttribute('title') } : false; },
}],
toDOM(mark) {
const href = linkTarget(mark.attrs.href);
if (!href) return ['span', { class: 'md-bad-link' }, 0];
const attrs = { href, rel: 'noopener noreferrer', target: '_blank' };
if (mark.attrs.title) attrs.title = mark.attrs.title;
return ['a', attrs, 0];
},
},
code: { code: true, parseDOM: [{ tag: 'code' }], toDOM() { return ['code']; } },
},
});
// ------------------------------------------------------------------ Markdown to nodes
const splitter = MarkdownIt('commonmark', { html: true }).enable('table').enable('strikethrough');
const tokenizer = MarkdownIt('commonmark', { html: false }).enable('table').enable('strikethrough');
// Soft breaks stay line breaks in the text, so a wrapped paragraph keeps its wrapping. A link whose
// target the gate refuses makes the whole block source text (`refused`), so nothing rewrites it.
let refused = false;
const prepared = {
parse(text, env) {
const tokens = tokenizer.parse(text, env);
for (const t of tokens) {
for (const c of t.children ?? []) {
if (c.type === 'softbreak') { c.type = 'text'; c.content = '\n'; }
else if (c.type === 'link_open' && !linkTarget(c.attrGet('href'))) refused = true;
}
}
return tokens;
},
};
const tight = (tokens, i) => {
while (++i < tokens.length) if (tokens[i].type !== 'list_item_open') return tokens[i].hidden;
return false;
};
const parser = new MarkdownParser(schema, prepared, {
blockquote: { block: 'blockquote' },
paragraph: { block: 'paragraph' },
list_item: { block: 'list_item' },
bullet_list: { block: 'bullet_list', getAttrs: (_, tokens, i) => ({ tight: tight(tokens, i) }) },
ordered_list: { block: 'ordered_list', getAttrs: (tok, tokens, i) => ({ order: +tok.attrGet('start') || 1, tight: tight(tokens, i) }) },
heading: { block: 'heading', getAttrs: tok => ({ level: +tok.tag.slice(1) }) },
code_block: { block: 'code_block', noCloseToken: true },
// A tilde fence may carry a backtick in its info string; the serialiser would drop that info, so the block stays source.
fence: { block: 'code_block', getAttrs: tok => { if (tok.info.includes('`')) refused = true; return { params: tok.info || '' }; }, noCloseToken: true },
hr: { node: 'horizontal_rule' },
image: { node: 'image', getAttrs: tok => ({ src: tok.attrGet('src'), title: tok.attrGet('title') || null, alt: tok.content || null }) },
hardbreak: { node: 'hard_break' },
em: { mark: 'em' },
strong: { mark: 'strong' },
link: { mark: 'link', getAttrs: tok => ({ href: tok.attrGet('href'), title: tok.attrGet('title') || null }) },
code_inline: { mark: 'code', noCloseToken: true },
});
// ------------------------------------------------------------------ nodes to Markdown
// `<` and `&` in text are escaped so a rewritten block never turns typed text into markup or an entity
// in some other Markdown renderer. (The serialiser's own `esc` leaves both alone.)
// A link title, an image alt or title, and a code block's info string are written inside one line of Markdown, but a
// paste can set any of them. A line break in one would end the line early and let the rest start a block of its own
// (a blank line, then `<img onerror=...>`, is an HTML block in another renderer), so a run of line breaks is one
// space, and an info string stops at its first line break and is dropped if it holds a backtick (that ends a fence).
// Row 148's Console renders with html:false, so this is for the files other renderers show.
const oneLine = value => (typeof value === 'string' ? value.replace(/[\r\n]+/g, ' ') : value);
// A title is written between double quotes, so a backslash in it is doubled as well as the quote: left alone, a title
// ending in `\` swallows its own closing quote, and `x\") <img ...>` ends the title early. The vendored link writer
// escapes only `"`, so the backslashes are doubled before it sees the title.
const titleBackslash = value => (typeof value === 'string' ? oneLine(value).replace(/\\/g, '\\\\') : value);
const fenceInfo = value => {
const info = typeof value === 'string' ? value.split(/[\r\n]/, 1)[0] : '';
return info.includes('`') ? '' : info;
};
const link = defaultMarkdownSerializer.marks.link;
const serializer = new MarkdownSerializer({
...defaultMarkdownSerializer.nodes,
code_block(state, node) {
const params = fenceInfo(node.attrs.params);
defaultMarkdownSerializer.nodes.code_block(state, params === node.attrs.params ? node : node.type.create({ ...node.attrs, params }, node.content));
},
text(state, node) {
const esc = state.esc;
state.esc = (str, start) => esc.call(state, str, start).replace(/[<&]/g, '\\$&');
try { state.text(node.text, !state.inAutolink); } finally { delete state.esc; }
},
bullet_list(state, node) { state.renderList(node, ' ', () => '- '); },
image(state, node) {
const a = node.attrs;
const title = oneLine(a.title);
state.write('![' + state.esc(oneLine(a.alt) || '') + '](' + a.src.replace(/[()]/g, '\\$&') + (title ? ' "' + title.replace(/[\\"]/g, '\\$&') + '"' : '') + ')');
},
raw_block(state, node) { state.text(node.textContent, false); state.closeBlock(node); },
}, {
...defaultMarkdownSerializer.marks,
// Only `close` writes the title; `open` reads it just to tell an autolink, which a title rules out either way.
link: { ...link, close: (state, mark, parent, index) => {
const title = titleBackslash(mark.attrs.title);
return link.close(state, title === mark.attrs.title ? mark : mark.type.create({ ...mark.attrs, title }), parent, index);
} },
});
const written = new WeakMap(); // nodes are immutable, so what the serialiser wrote for one never changes
function canonical(node) {
let text = written.get(node);
if (text === undefined) {
text = node.type.name === 'raw_block' ? node.textContent : serializer.serialize(schema.nodes.doc.create(null, [node]));
written.set(node, text);
}
return text;
}
const STRUCTURED = new Set(['paragraph', 'heading', 'blockquote', 'bullet_list', 'ordered_list', 'fence', 'hr']);
// The span of lines `a` up to `b` with their line ends; joined over a partition of the lines it is the source.
const span = (lines, a, b) => (a >= b ? '' : lines.slice(a, b).join('\n') + (b < lines.length ? '\n' : ''));
// Front matter: a first line of `---` and a later line of `---` or `...`. Returns the end line, or 0.
function frontMatter(lines) {
if (lines[0] !== '---') return 0;
for (let i = 1; i < lines.length; i++) if (lines[i] === '---' || lines[i] === '...') return i + 1;
return 0;
}
// The blocks of a source as {from, to, kind} line spans in order, covering every non-blank line.
function blocks(lines) {
const found = [];
const fm = frontMatter(lines);
if (fm) found.push({ from: 0, to: fm, kind: 'raw' });
const rest = lines.slice(fm).join('\n'), env = {};
const tokens = splitter.parse(rest, env);
for (const t of tokens) {
if (t.level !== 0 || !t.map || t.nesting === -1) continue;
const kind = t.type.replace(/_open$/, '');
const from = t.map[0] + fm, to = t.map[1] + fm;
const plain = STRUCTURED.has(kind) && (kind !== 'heading' || to - from <= 1); // setext headings stay source
found.push({ from, to, kind: plain ? kind : 'raw' });
}
// Non-blank lines no token covers (link reference definitions) are raw blocks of their own.
const all = [];
let cursor = 0;
const gap = to => {
for (let i = cursor; i < to; i++) {
if (!lines[i].trim()) continue;
let j = i;
while (j < to && lines[j].trim()) j++;
all.push({ from: i, to: j, kind: 'raw' });
i = j;
}
};
for (const b of found) {
if (b.from < cursor) return null; // overlapping spans: the caller keeps the whole file as one raw block
gap(b.from);
all.push(b);
cursor = b.to;
}
gap(lines.length);
return { all, env };
}
// A block the parser has no node for (a table, ~~strikethrough~~, anything inside a list or quote that the
// schema lacks) makes it throw; that block stays source.
function nodeOf(kind, text, env) {
if (kind !== 'raw') {
refused = false;
try {
const doc = parser.parse(text, env);
if (doc.childCount === 1 && !refused) return doc.firstChild;
} catch { /* the block stays source */ }
}
return schema.nodes.raw_block.create(null, text ? schema.text(text) : null);
}
/// The ProseMirror document of a Markdown source (LF line ends).
export function parse(src) {
const lines = String(src).split('\n');
const found = blocks(lines) ?? { all: [{ from: 0, to: lines.length, kind: 'raw' }], env: {} };
const children = [];
let cursor = 0;
for (const b of found.all) {
const text = lines.slice(b.from, b.to).join('\n');
const node = nodeOf(b.kind, text, found.env);
const attrs = { ...node.attrs, raw: span(lines, b.from, b.to), lead: span(lines, cursor, b.from) };
let made = node.type.create(attrs, node.content, node.marks);
made = node.type.create({ ...attrs, sig: canonical(made) }, node.content, node.marks);
children.push(made);
cursor = b.to;
}
return schema.nodes.doc.create({ tail: span(lines, cursor, lines.length) }, children.length ? children : [schema.nodes.paragraph.create()]);
}
/// The Markdown source of a document: unchanged blocks as they were read, changed blocks as written now.
export function serialise(doc) {
let out = '';
doc.forEach(node => {
const text = canonical(node), lead = node.attrs.lead;
if (out && !out.endsWith('\n')) out += '\n';
if (node.attrs.raw !== null && node.attrs.sig === text) { out += lead + node.attrs.raw; return; }
if (!text.trim()) return; // an emptied block writes nothing
// A changed block keeps the blank lines it had, but always at least one so it cannot merge into its neighbour.
out += (out && !lead.includes('\n') ? '\n' : lead) + text + '\n';
});
return out + doc.attrs.tail;
}
+60
View File
@@ -0,0 +1,60 @@
// usage: node editor/toolchain.mjs
// Row 154 (#1648, Q168): is the pinned esbuild installed, and is it the pinned binary?
// The editor bundle is only worth re-checking with the esbuild it was built with. Install it in packages/webui with
// `npm ci --ignore-scripts --no-audit --no-fund` (package-lock.json is the record). This file never imports esbuild, so
// it runs, and says why, when esbuild is not installed.
// absent esbuild is not installed here, or it is another version (the root install has one that comes in through
// pi-coding-agent and changes when pi changes). A routine run skips the rebuild and prints the reason.
// With MOSAIC_REQUIRE_EDITOR_BUILD=1 it is a failure instead; the landing gate sets that.
// refused esbuild is the pinned version but not the pinned binary (sha256 differs, no sha recorded for this
// platform, or ESBUILD_BINARY_PATH points somewhere else). Always a failure.
// Exit: 0 usable, or absent without the switch (prints "skip: <reason>"); 3 absent with the switch; 4 refused.
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
export const ESBUILD = '0.28.2';
// sha256 of node_modules/@esbuild/<platform>-<arch>/bin/esbuild in 0.28.2. Add a platform only after checking it
// against the registry tarball; a platform with no entry here is refused, not trusted.
export const BINARY_SHA256 = {
'linux-x64': 'e1698a3d5c6c0798fee4fd3b5cc816651f460c63d390a7a26ea4beb0b1884100',
};
const sha256 = buf => createHash('sha256').update(buf).digest('hex');
export function toolchain(from = import.meta.url) {
const here = createRequire(from);
let pkgPath;
try { pkgPath = here.resolve('esbuild/package.json'); } catch { return { ok: false, kind: 'absent', reason: `esbuild is not installed; the editor rebuild needs exactly ${ESBUILD} (npm ci --ignore-scripts in packages/webui)` }; }
const { version } = JSON.parse(readFileSync(pkgPath, 'utf8'));
if (version !== ESBUILD) return { ok: false, kind: 'absent', reason: `esbuild ${version} is installed at ${dirname(pkgPath)}; the editor rebuild needs exactly ${ESBUILD} (npm ci --ignore-scripts in packages/webui)` };
if (process.env.ESBUILD_BINARY_PATH) return { ok: false, kind: 'refused', reason: 'ESBUILD_BINARY_PATH is set, so esbuild would run a binary that was not checked' };
const platform = `${process.platform}-${process.arch}`, expected = BINARY_SHA256[platform];
if (!expected) return { ok: false, kind: 'refused', reason: `no binary sha256 is recorded for ${platform}` };
let binary;
try { binary = createRequire(pkgPath).resolve(`@esbuild/${platform}/bin/esbuild`); } catch { return { ok: false, kind: 'refused', reason: `esbuild ${version} is installed without its @esbuild/${platform} binary` }; }
const actual = sha256(readFileSync(binary));
if (actual !== expected) return { ok: false, kind: 'refused', reason: `the esbuild binary ${binary} is sha256 ${actual}; the pinned binary for ${platform} is ${expected}` };
return { ok: true, version, binary, sha256: actual };
}
// MOSAIC_REQUIRE_EDITOR_BUILD: "1" requires the rebuild; unset, "" and "0" do not. Anything else throws, so a typo
// such as "true" cannot quietly turn the requirement off.
export function required(env = process.env) {
const v = env.MOSAIC_REQUIRE_EDITOR_BUILD;
if (v === undefined || v === '' || v === '0') return false;
if (v === '1') return true;
throw new Error(`MOSAIC_REQUIRE_EDITOR_BUILD is "${v}"; it is 1 (require the editor rebuild) or unset`);
}
// run: rebuild and compare. skip: say why and go on. fail: stop.
export const verdict = (t, req) => (t.ok ? 'run' : t.kind === 'absent' && !req ? 'skip' : 'fail');
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const t = toolchain(), v = verdict(t, required());
if (v === 'run') console.log(`esbuild ${t.version} ${t.binary} sha256 ${t.sha256}`);
else if (v === 'skip') console.log(`skip: ${t.reason}`);
else { console.error(`${t.kind === 'absent' ? 'missing' : 'refused'}: ${t.reason}`); process.exit(t.kind === 'absent' ? 3 : 4); }
}
+209
View File
@@ -0,0 +1,209 @@
// Row 154 (#1648, Q168): the editor view. A ProseMirror EditorView over model.mjs's schema, a toolbar, a
// link bar, and the keymap. Everything is built with DOM calls (createElement, textContent, setAttribute);
// there is no string-to-markup sink in this file.
import { EditorState } from 'prosemirror-state';
import { EditorView } from 'prosemirror-view';
import { history, undo, redo } from 'prosemirror-history';
import { keymap } from 'prosemirror-keymap';
import { baseKeymap, toggleMark, setBlockType, wrapIn, chainCommands, exitCode } from 'prosemirror-commands';
import { wrapInList, splitListItem, liftListItem, sinkListItem } from 'prosemirror-schema-list';
import { InputRule, inputRules, textblockTypeInputRule, wrappingInputRule, undoInputRule } from 'prosemirror-inputrules';
import { schema, parse, serialise, linkTarget } from './model.mjs';
const { paragraph, heading, code_block, blockquote, bullet_list, ordered_list, list_item, hard_break, horizontal_rule } = schema.nodes;
const { strong, em, code, link } = schema.marks;
const inCode = state => state.selection.$from.parent.type.spec.code;
function markActive(state, type) {
const { from, $from, to, empty } = state.selection;
return empty ? !!type.isInSet(state.storedMarks || $from.marks()) : state.doc.rangeHasMark(from, to, type);
}
// -------------------------------------------------------------------------------- commands
const insertBreak = chainCommands(exitCode, (state, dispatch) => {
if (dispatch) dispatch(state.tr.replaceSelectionWith(hard_break.create()).scrollIntoView());
return true;
});
const insertRule = (state, dispatch) => {
if (dispatch) dispatch(state.tr.replaceSelectionWith(horizontal_rule.create()).scrollIntoView());
return true;
};
const heading_ = level => setBlockType(heading, { level });
// The link bar: set the target on the selection, or remove the link. The gate decides; a refused target
// changes nothing and the bar says so.
function applyLink(view, raw) {
const { state } = view, { from, to, empty } = state.selection;
if (raw === '') {
if (!empty) view.dispatch(state.tr.removeMark(from, to, link));
return true;
}
const href = linkTarget(raw);
if (!href || empty) return false;
view.dispatch(state.tr.removeMark(from, to, link).addMark(from, to, link.create({ href })));
return true;
}
// -------------------------------------------------------------------------------- input rules
const rules = [
textblockTypeInputRule(/^(#{1,6})\s$/, heading, m => ({ level: m[1].length })),
textblockTypeInputRule(/^```$/, code_block),
wrappingInputRule(/^\s*>\s$/, blockquote),
wrappingInputRule(/^\s*([-+*])\s$/, bullet_list),
wrappingInputRule(/^(\d+)\.\s$/, ordered_list, m => ({ order: +m[1] }), (m, node) => node.childCount + node.attrs.order === +m[1]),
new InputRule(/^(?:---|___)$/, (state, match, start, end) => state.tr.replaceRangeWith(start, end, horizontal_rule.create()).scrollIntoView()),
];
// -------------------------------------------------------------------------------- toolbar
const BUTTONS = [
['strong', 'B', 'Bold (Ctrl+B)', toggleMark(strong), s => markActive(s, strong)],
['em', 'I', 'Italic (Ctrl+I)', toggleMark(em), s => markActive(s, em)],
['code', '</>', 'Code (Ctrl+`)', toggleMark(code), s => markActive(s, code)],
['link', 'Link', 'Link (Ctrl+K)', null, s => markActive(s, link)],
['h1', 'H1', 'Heading 1', heading_(1), s => s.selection.$from.parent.hasMarkup(heading, { level: 1 })],
['h2', 'H2', 'Heading 2', heading_(2), s => s.selection.$from.parent.hasMarkup(heading, { level: 2 })],
['h3', 'H3', 'Heading 3', heading_(3), s => s.selection.$from.parent.hasMarkup(heading, { level: 3 })],
['p', 'Text', 'Paragraph', setBlockType(paragraph), s => s.selection.$from.parent.type === paragraph],
['ul', '• List', 'Bulleted list', wrapInList(bullet_list), () => false],
['ol', '1. List', 'Numbered list', wrapInList(ordered_list), () => false],
['quote', '❝', 'Quote', wrapIn(blockquote), () => false],
['codeblock', '{ }', 'Code block', setBlockType(code_block), s => s.selection.$from.parent.type === code_block],
['rule', '―', 'Horizontal rule', insertRule, () => false],
['undo', 'Undo', 'Undo (Ctrl+Z)', undo, () => false],
['redo', 'Redo', 'Redo (Ctrl+Y)', redo, () => false],
];
function toolbar(getView, openLink) {
const bar = document.createElement('div');
bar.className = 'ed-toolbar';
bar.setAttribute('role', 'toolbar');
bar.setAttribute('aria-label', 'Formatting');
const items = BUTTONS.map(([id, label, title, command, active]) => {
const b = document.createElement('button');
b.type = 'button';
b.className = 'ed-btn';
b.dataset.cmd = id;
b.title = title;
b.setAttribute('aria-label', title);
b.textContent = label;
// mousedown keeps the editor selection; the command runs on click
b.addEventListener('mousedown', e => e.preventDefault());
b.addEventListener('click', () => {
const view = getView();
if (!view || view.editable === false) return;
if (id === 'link') openLink(); else command(view.state, view.dispatch, view);
if (id !== 'link') view.focus();
});
bar.appendChild(b);
return { b, command, active };
});
const sync = state => {
for (const { b, command, active } of items) {
b.classList.toggle('on', !!active(state));
b.disabled = command ? !command(state) : false;
b.setAttribute('aria-pressed', b.classList.contains('on') ? 'true' : 'false');
}
};
return { bar, sync };
}
function linkBar(getView) {
const form = document.createElement('form');
form.className = 'ed-linkbar';
form.hidden = true;
const input = document.createElement('input');
input.type = 'text';
input.className = 'ed-link-input';
input.setAttribute('aria-label', 'Link target (http, https or a relative path)');
input.placeholder = 'https://… or a relative path; empty removes the link';
input.autocomplete = 'off';
input.spellcheck = false;
const ok = document.createElement('button');
ok.type = 'submit';
ok.className = 'ed-btn';
ok.textContent = 'Set';
const cancel = document.createElement('button');
cancel.type = 'button';
cancel.className = 'ed-btn';
cancel.textContent = 'Cancel';
const note = document.createElement('span');
note.className = 'ed-link-note';
note.setAttribute('role', 'status');
form.append(input, ok, cancel, note);
const close = () => { form.hidden = true; note.textContent = ''; getView()?.focus(); };
cancel.addEventListener('click', close);
input.addEventListener('keydown', e => { if (e.key === 'Escape') { e.preventDefault(); close(); } });
form.addEventListener('submit', e => {
e.preventDefault();
e.stopPropagation(); // the Console's own submit handler is for the save form
const view = getView();
if (!view) return;
if (applyLink(view, input.value.trim())) close();
else note.textContent = 'That target is not allowed. Use http, https or a relative path, and select the text first.';
});
const open = () => {
const view = getView();
if (!view) return;
const { from, to, empty, $from } = view.state.selection;
if (empty && !markActive(view.state, link)) { form.hidden = false; input.value = ''; note.textContent = 'Select the text to link first.'; return; }
const at = view.state.doc.rangeHasMark(from, to, link) ? view.state.doc.nodeAt(from)?.marks.find(m => m.type === link) : null;
input.value = at ? at.attrs.href : '';
note.textContent = '';
form.hidden = false;
input.focus();
input.select();
};
return { form, open };
}
// -------------------------------------------------------------------------------- the editor
/// mount(host, {text, editable, onChange}) -> { text(), setText(text), destroy(), focus(), view }
export function mount(host, { text = '', editable = true, onChange = () => {} } = {}) {
let view = null;
const getView = () => view;
const wrap = document.createElement('div');
wrap.className = 'ed-wrap';
const link_ = linkBar(getView);
const tools = toolbar(getView, link_.open);
const surface = document.createElement('div');
surface.className = 'ed-surface';
if (editable) wrap.append(tools.bar, link_.form);
wrap.appendChild(surface);
host.appendChild(wrap);
const keys = {
'Mod-z': undo, 'Mod-y': redo, 'Shift-Mod-z': redo, Backspace: undoInputRule,
'Mod-b': toggleMark(strong), 'Mod-i': toggleMark(em), 'Mod-`': toggleMark(code),
'Mod-k': () => { link_.open(); return true; },
'Shift-Enter': insertBreak, 'Mod-Enter': insertBreak,
Enter: splitListItem(list_item),
Tab: sinkListItem(list_item), 'Shift-Tab': liftListItem(list_item),
};
const fresh = src => EditorState.create({
schema, doc: parse(src),
plugins: [inputRules({ rules }), keymap(keys), keymap(baseKeymap), history()],
});
view = new EditorView(surface, {
state: fresh(text),
editable: () => editable,
attributes: { 'aria-label': 'Document', 'aria-multiline': 'true', role: 'textbox', spellcheck: 'true', 'data-field': 'doc-visual' },
dispatchTransaction(tr) {
const next = view.state.apply(tr);
view.updateState(next);
tools.sync(next);
if (tr.docChanged) onChange();
},
// Pasted and dropped content goes through the schema's parse rules only; nothing here adds to them.
});
tools.sync(view.state);
return {
view,
text: () => serialise(view.state.doc),
setText(src) { view.updateState(fresh(src)); tools.sync(view.state); },
focus: () => view.focus(),
destroy() { view.destroy(); wrap.remove(); },
};
}
+503
View File
@@ -0,0 +1,503 @@
{
"name": "@mosaic/webui",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@mosaic/webui",
"version": "0.1.0",
"license": "UNLICENSED",
"devDependencies": {
"esbuild": "0.28.2"
},
"engines": {
"node": ">=24"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
"integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
"integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
"integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
"integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
"integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
"integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
"integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
"integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
"integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
"integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
"integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
"integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
"integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
"integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
"integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
"integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
"integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
"integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
"integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
"integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
"integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
"integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
"integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
"integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
"integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
"integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/esbuild": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.2",
"@esbuild/android-arm": "0.28.2",
"@esbuild/android-arm64": "0.28.2",
"@esbuild/android-x64": "0.28.2",
"@esbuild/darwin-arm64": "0.28.2",
"@esbuild/darwin-x64": "0.28.2",
"@esbuild/freebsd-arm64": "0.28.2",
"@esbuild/freebsd-x64": "0.28.2",
"@esbuild/linux-arm": "0.28.2",
"@esbuild/linux-arm64": "0.28.2",
"@esbuild/linux-ia32": "0.28.2",
"@esbuild/linux-loong64": "0.28.2",
"@esbuild/linux-mips64el": "0.28.2",
"@esbuild/linux-ppc64": "0.28.2",
"@esbuild/linux-riscv64": "0.28.2",
"@esbuild/linux-s390x": "0.28.2",
"@esbuild/linux-x64": "0.28.2",
"@esbuild/netbsd-arm64": "0.28.2",
"@esbuild/netbsd-x64": "0.28.2",
"@esbuild/openbsd-arm64": "0.28.2",
"@esbuild/openbsd-x64": "0.28.2",
"@esbuild/openharmony-arm64": "0.28.2",
"@esbuild/sunos-x64": "0.28.2",
"@esbuild/win32-arm64": "0.28.2",
"@esbuild/win32-ia32": "0.28.2",
"@esbuild/win32-x64": "0.28.2"
}
}
}
}
+5 -1
View File
@@ -8,6 +8,10 @@
"engines": { "node": ">=24" },
"scripts": {
"test": "node --test tests/",
"start": "node src/cli.mjs serve"
"start": "node src/cli.mjs serve",
"build:editor": "node editor/build.mjs"
},
"devDependencies": {
"esbuild": "0.28.2"
}
}
+53 -17
View File
@@ -730,6 +730,19 @@
// by the page. A textarea reads CRLF as LF, so the draft and the save are LF.
let canEdit = false, docKey = null;
const docStates = new Map();
// Row 154 (#1648, Q168): the visual editor (editor.js, window.MosaicEditor) edits the same draft text.
// Its view is destroyed whenever the page is redrawn, since it holds a document-level listener.
// docMode is the view asked for; docShown is the view on screen. They differ when a file opens as Source because
// the visual editor cannot write it back byte for byte, and a click on a button compares with what is on screen.
let docMode = 'visual', docShown = 'visual', editor = null;
function unmountEditor() { if (editor) { try { editor.api.destroy(); } catch { /* the page is being replaced */ } editor = null; } }
function mountEditor(s, host) {
unmountEditor();
try {
const api = window.MosaicEditor.mount(host, { text: s.text, editable: canEdit && !out(), onChange() { s.text = api.text(); s.status = ''; paintDoc(); } });
editor = { host, api };
} catch { host.textContent = 'The visual editor did not start. Use Source to edit this file. The draft is unchanged.'; }
}
const dirty = s => s.text !== s.original || s.saving;
const lf = v => String(v).replace(/\r\n?/g, '\n');
const DOC_PATH = /^(spec\/north-star\.md|docs\/[A-Za-z0-9][A-Za-z0-9._-]{0,127}\.md)$/;
@@ -746,13 +759,14 @@
return `${head('Docs', `${readLine()} The spec and the files under docs/ of each project. ${canEdit ? 'Open one to edit it; a save makes one commit in the project with you as the author.' : 'Saving needs a Console started with <code>--send</code> behind the sign-in proxy; this one only reads.'}`)}
${blocks.join('') || empty('This business declares no projects.', 'A project shows here when the business file names it.')}`;
}
async function docView(get, project, file) {
async function docView(get, project, file, { reuseDraft = false } = {}) {
if (!SAFE.test(project)) return notFound(`${txt(project)} is not a project name.`);
if (!DOC_PATH.test(file)) return notFound(`${txt(file)} is not a spec or docs file.`);
docKey = JSON.stringify([project, file]);
for (const [k, v] of docStates) if (k !== docKey && !dirty(v)) docStates.delete(k);
let s = docStates.get(docKey);
if (!s || !dirty(s)) {
// A switch between Visual and Source passes reuseDraft: the draft, clean or not, is already the file as opened.
if (!s || (!dirty(s) && !reuseDraft)) {
const r = await get.api(`project-file?project=${enc(project)}&path=${enc(file)}`), now = docStates.get(docKey);
// Typing during the read wins over what was read.
s = now && dirty(now) ? now : { project, path: file, commit: r.commit, raw: r.content, original: lf(r.content), text: lf(r.content), status: '', saving: false, at: r.at };
@@ -762,22 +776,38 @@
const unsafe = s.raw.includes('\0'); // a textarea can't hold it, so a save would not write what was read
const stateNote = unsafe ? 'This file holds a NUL character, which the editor cannot keep. Edit it where it lives.'
: `${s.raw.includes('\r') ? 'This file uses CRLF line endings; a save writes LF. ' : ''}${canEdit ? '' : 'Saving needs a Console started with <code>--send</code> behind the sign-in proxy; this one only reads.'}`;
return `${head(`Edit ${txt(s.path)}`, `Project ${txt(s.project)}, opened at commit <code id="doc-commit">${esc(s.commit.slice(0, 12))}</code>. ${readLine()}`)}
<p class="small"><a href="#/docs">All documents</a></p>
// Row 154 (#1648, Q168): the visual editor is the default view and Source is row 148's textarea. The visual
// editor opens a file only when it writes the text back byte for byte; otherwise this opens Source.
let visualOk = false;
if (docMode === 'visual' && !unsafe && window.MosaicEditor && window.MosaicMarkdown) { try { visualOk = window.MosaicEditor.roundTrip(s.text) === s.text; } catch { /* Source */ } }
docShown = visualOk ? 'visual' : 'source';
const modeNote = docMode === 'visual' && !visualOk && !unsafe ?'The visual editor is not available for this file, so it is open as source. ' : '';
const modes = `<div class="doc-mode" role="group" aria-label="Editor view"><button type="button" class="btn" id="doc-mode-visual" data-field="doc-mode-visual" aria-pressed="${visualOk}">Visual</button> <button type="button" class="btn" id="doc-mode-source" data-field="doc-mode-source" aria-pressed="${!visualOk}">Source</button></div>`;
const actions = `<button type="submit" id="doc-save" disabled>Save to the project</button> <button type="button" class="btn" id="doc-reload">Reload from the project and discard the draft</button>
<p role="status" id="doc-status">${txt(s.status)}</p>`;
const top = `${head(`Edit ${txt(s.path)}`, `Project ${txt(s.project)}, opened at commit <code id="doc-commit">${esc(s.commit.slice(0, 12))}</code>. ${readLine()}`)}
<p class="small"><a href="#/docs">All documents</a></p>${modes}`;
if (visualOk) return `${top}
<div class="doc-edit one"><section class="panel" aria-labelledby="doc-src-title"><h2 id="doc-src-title">Document</h2>
<p class="small muted">A save writes Markdown. A block you do not touch is saved exactly as it was; a block you edit is rewritten in a standard form. Source the editor does not draw (tables, HTML, front matter) shows as a tinted block you edit as text. A link shows its target when you select it; an image shows its address and is never loaded.</p>
<div id="doc-visual" class="doc-visual"></div>
<form id="doc-form"><p class="small muted">${stateNote}</p>
${actions}</form></section></div>`;
return `${top}
<div class="doc-edit"><section class="panel" aria-labelledby="doc-src-title"><h2 id="doc-src-title">Markdown source</h2>
<form id="doc-form"><label for="doc-text" class="sr-only">Markdown source of ${txt(s.path)}</label>
<textarea id="doc-text" class="doc-text" data-field="doc-text" rows="26" spellcheck="false" autocomplete="off" aria-describedby="doc-status"${canEdit && !unsafe && !out() ? '' : ' readonly'}>\n${unsafe ? '' : esc(s.text)}</textarea>
<p class="small muted">${stateNote}</p>
<button type="submit" id="doc-save" disabled>Save to the project</button> <button type="button" class="btn" id="doc-reload">Reload from the project and discard the draft</button>
<p role="status" id="doc-status">${txt(s.status)}</p></form></section>
<p class="small muted">${modeNote}${stateNote}</p>
${actions}</form></section>
<section class="panel" aria-labelledby="doc-prev-title"><h2 id="doc-prev-title">Preview</h2><p class="small muted">Headings, lists, quotes, code and links are drawn. Raw HTML and anything else shows as text. A link shows where it goes; an image shows its address and is never loaded.</p><div id="doc-preview" class="md-preview"></div></section></div>`;
}
// The parts of the editor that follow the draft, set in place so typing never redraws the page.
function paintDoc() {
const s = docStates.get(docKey), box = $('doc-preview');
if (!s || !box) return;
const s = docStates.get(docKey), box = $('doc-preview'), host = $('doc-visual');
if (!s || !(box || host)) return;
if (host && editor?.host !== host) mountEditor(s, host);
// A preview that throws must not skip the lines below, or leave a save stuck on "Saving…".
try {
if (box) try {
if (window.MosaicMarkdown) box.replaceChildren(window.MosaicMarkdown.render(s.text));
else box.textContent = 'The preview did not load. The source is unchanged.';
} catch { box.textContent = 'The preview failed. The source is unchanged.'; }
@@ -788,6 +818,11 @@
}
const saveable = s => canEdit && !out() && !s.saving && s.text !== s.original && !s.raw.includes('\0');
document.addEventListener('input', e => { const s = docStates.get(docKey); if (e.target.id !== 'doc-text' || !s) return; s.text = e.target.value; s.status = ''; paintDoc(); });
document.addEventListener('click', e => {
const mode = { 'doc-mode-visual': 'visual', 'doc-mode-source': 'source' }[e.target.id], s = docStates.get(docKey);
if (!mode || !s || s.saving || mode === docShown) return;
docMode = mode; render(false, { reuseDraft: true });
});
document.addEventListener('click', e => { if (e.target.id !== 'doc-reload') return; const s = docStates.get(docKey); if (s?.saving) return; docStates.delete(docKey); render(true); });
document.addEventListener('submit', async e => {
if (e.target.id !== 'doc-form') return;
@@ -863,7 +898,7 @@
// ---------------------------------------------------------------- router
// #/ and anything that is not a #/ route is the control board; the rest is this file's.
const routeOf = () => location.hash.startsWith('#/') ? location.hash.slice(1) : '/';
function view(get, path, q) {
function view(get, path, q, opts) {
const parts = path.split('/').filter(Boolean).map(p => { try { return decodeURIComponent(p); } catch { return null; } });
if (parts.includes(null)) return notFound();
const [a, b, c] = parts, n = parts.length;
@@ -878,7 +913,7 @@
if (a === 'business' && n === 1) return businessView(get);
if (a === 'settings' && n === 1) return settingsView(get);
if (a === 'docs' && n === 1) return docsView(get);
if (a === 'docs' && n === 3) return docView(get, b, c);
if (a === 'docs' && n === 3) return docView(get, b, c, opts);
if (a === 'trail' && n === 3 && b === 'task') return TASK.test(c) ? trailView(get, b, c, q) : notFound(`${txt(c)} is not a task reference.`);
if (a === 'trail' && n === 3 && b === 'decision') return SAFE.test(c) ? trailView(get, b, c, q) : notFound(`${txt(c)} is not a decision id.`);
if (a === 'trail' && n === 3 && b === 'message') return SAFE.test(c) ? trailView(get, b, c, q) : notFound(`${txt(c)} is not a message id.`);
@@ -908,7 +943,7 @@
}
else if (k.h1) $('bus-view').querySelector('h1')?.focus({ preventScroll: true });
}
async function render(navigated) {
async function render(navigated, opts = {}) {
// A refresh due while this render reads would replace it: a navigation would lose its heading focus.
clearTimeout(timer);
if (out()) return;
@@ -925,7 +960,7 @@
}
if (board) {
// The board page keeps its own title; the Inbox count still follows the bus.
if (shown !== null) { shown = null; $('bus-view').innerHTML = ''; document.title = 'Mosaic Console'; if (navigated) $('main').focus(); }
if (shown !== null) { shown = null; unmountEditor(); $('bus-view').innerHTML = ''; document.title = 'Mosaic Console'; if (navigated) $('main').focus(); }
// The held roles (row 95) are read with it. A refusal of either forgets both, as on the bus pages.
const [inbox, agents, messages] = await Promise.allSettled([fetchRead('inbox'), fetchRead('agents'), fetchRead('messages')]);
const failed = [inbox, agents, messages].filter(s => s.status === 'rejected').map(s => s.reason);
@@ -942,7 +977,7 @@
sections(path); schedule();
return;
}
if (navigated || shown !== route) { $('bus-view').innerHTML = loadingView(SOURCES[path.split('/')[1]]); }
if (navigated || shown !== route) { unmountEditor(); $('bus-view').innerHTML = loadingView(SOURCES[path.split('/')[1]]); }
let html, state = 'fresh';
const live = reader(true);
try {
@@ -950,17 +985,18 @@
try { await live('messages'); }
catch (err) { messageCount = null; if (err.status === 403 || err.code === 'signed-out') throw err; }
}
html = await view(live, path, new URLSearchParams(query)); readAt = live.oldest(); }
html = await view(live, path, new URLSearchParams(query), opts); readAt = live.oldest(); }
catch (err) {
if (err.code === 'signed-out') return;
const kept = reader(false);
try { if (refusal(err)) throw err; html = await view(kept, path, new URLSearchParams(query)); readAt = kept.oldest(); html = staleBanner(err) + html; state = 'stale'; }
try { if (refusal(err)) throw err; html = await view(kept, path, new URLSearchParams(query), opts); readAt = kept.oldest(); html = staleBanner(err) + html; state = 'stale'; }
catch { html = failedView(err) + (path === '/settings' ? appearance() : ''); state = refusal(err) ? 'refused' : 'failed'; }
if (refusal(err)) forget();
}
if (g !== gen) return;
freshBus(state, SOURCES[path.split('/')[1]]);
const k = !navigated && shown === route ? keep() : null;
unmountEditor();
$('bus-view').innerHTML = html; shown = route;
if (path.startsWith('/docs/')) paintDoc();
sections(path);
+90
View File
@@ -0,0 +1,90 @@
/* Built by editor/build.mjs: prosemirror-view/style/prosemirror.css, then editor/editor.css. Do not edit. */
.ProseMirror {
position: relative;
}
.ProseMirror {
word-wrap: break-word;
white-space: pre-wrap;
white-space: break-spaces;
-webkit-font-variant-ligatures: none;
font-variant-ligatures: none;
font-feature-settings: "liga" 0; /* the above doesn't seem to work in Edge */
}
.ProseMirror pre {
white-space: pre-wrap;
}
.ProseMirror li {
position: relative;
}
.ProseMirror-hideselection *::selection { background: transparent; }
.ProseMirror-hideselection *::-moz-selection { background: transparent; }
.ProseMirror-hideselection { caret-color: transparent; }
/* See https://code.haverbeke.berlin/prosemirror/prosemirror/issues/1421#issuecomment-1759320191 */
.ProseMirror [draggable][contenteditable=false] { user-select: text }
.ProseMirror-selectednode {
outline: 2px solid #8cf;
}
/* Make sure li selections wrap around markers */
li.ProseMirror-selectednode {
outline: none;
}
li.ProseMirror-selectednode:after {
content: "";
position: absolute;
left: -32px;
right: -2px; top: -2px; bottom: -2px;
border: 2px solid #8cf;
pointer-events: none;
}
/* Protect against generic img rules */
img.ProseMirror-separator {
display: inline !important;
border: none !important;
margin: 0 !important;
}
/* Row 154 (#1648, Q168): the Console's visual editor. Colours come from the Console's own variables. */
.ed-wrap { display: flex; flex-direction: column; min-width: 0; border: 1px solid var(--line); border-radius: 6px; background: var(--surface); }
.ed-toolbar { display: flex; flex-wrap: wrap; gap: 4px; padding: 6px; border-bottom: 1px solid var(--line); }
.ed-btn { font: inherit; font-size: 12px; padding: 3px 8px; border-radius: 4px; border: 1px solid var(--line); background: transparent; color: inherit; cursor: pointer; }
.ed-btn:hover:not(:disabled) { background: rgba(127, 127, 127, 0.18); }
.ed-btn.on { background: rgba(127, 127, 127, 0.32); font-weight: 600; }
.ed-btn:disabled { opacity: 0.4; cursor: default; }
.ed-linkbar { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; padding: 6px; border-bottom: 1px solid var(--line); }
.ed-linkbar[hidden] { display: none; }
.ed-link-input { flex: 1 1 18em; min-width: 8em; font: inherit; padding: 3px 6px; }
.ed-link-note { font-size: 12px; opacity: 0.85; }
.ed-surface { min-width: 0; overflow: auto; }
.ed-surface .ProseMirror { min-height: 16em; max-height: 70vh; overflow: auto; padding: 10px 14px; outline: none; line-height: 1.5; overflow-wrap: anywhere; white-space: pre-wrap; }
.ed-surface .ProseMirror p { margin: 0 0 0.8em; }
.ed-surface .ProseMirror h1, .ed-surface .ProseMirror h2, .ed-surface .ProseMirror h3,
.ed-surface .ProseMirror h4, .ed-surface .ProseMirror h5, .ed-surface .ProseMirror h6 { margin: 0.9em 0 0.4em; line-height: 1.25; }
.ed-surface .ProseMirror h1 { font-size: 1.6em; }
.ed-surface .ProseMirror h2 { font-size: 1.35em; }
.ed-surface .ProseMirror h3 { font-size: 1.15em; }
.ed-surface .ProseMirror blockquote { margin: 0 0 0.8em; padding-left: 12px; border-left: 3px solid var(--line); opacity: 0.92; }
.ed-surface .ProseMirror ul, .ed-surface .ProseMirror ol { margin: 0 0 0.8em; padding-left: 1.6em; }
.ed-surface .ProseMirror li > p { margin-bottom: 0.3em; }
.ed-surface .ProseMirror code { font-family: ui-monospace, monospace; font-size: 0.92em; padding: 0 3px; border-radius: 3px; background: rgba(127, 127, 127, 0.2); }
.ed-surface .ProseMirror pre { margin: 0 0 0.8em; padding: 8px 10px; border-radius: 4px; background: rgba(127, 127, 127, 0.16); overflow: auto; white-space: pre; }
.ed-surface .ProseMirror pre code { padding: 0; background: none; }
.ed-surface .ProseMirror pre.md-raw { border-left: 3px solid #b8860b; }
.ed-surface .ProseMirror hr { border: 0; border-top: 1px solid var(--line); margin: 1em 0; }
.ed-surface .ProseMirror a { color: inherit; text-decoration: underline; }
.ed-surface .ProseMirror .md-image { font-family: ui-monospace, monospace; font-size: 0.9em; padding: 0 3px; border: 1px dashed var(--line); border-radius: 3px; }
.ed-surface .ProseMirror .md-bad-link { text-decoration: line-through; }
.ed-surface .ProseMirror-selectednode { outline: 2px solid #5b9bd5; }
.doc-mode { display: flex; gap: 6px; margin: 8px 0; }
.doc-mode .btn[aria-pressed="true"] { background: var(--raised); font-weight: 600; }
.doc-edit.one { grid-template-columns: minmax(0, 1fr); }
File diff suppressed because it is too large. Load diff
+1
View File
@@ -2,6 +2,7 @@
<html lang="en" data-design="console" data-palette="harbor">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Mosaic Console</title>
<link rel="stylesheet" href="/shared/app.css"><link rel="stylesheet" href="/tokens.css"><link rel="stylesheet" href="/console.css"><link rel="stylesheet" href="/live.css"><link rel="stylesheet" href="/bus.css"><link rel="stylesheet" href="/chat.css"><link rel="stylesheet" href="/shell.css">
<link rel="stylesheet" href="/editor.css"><script src="/editor.js" defer></script>
<script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/markdown-preview.js" defer></script><script src="/chat.js" defer></script><script src="/bus.js" defer></script></head>
<body>
<a class="skip" href="#main">Skip to content</a>
+2
View File
@@ -24,6 +24,8 @@ const files = new Map([
['/', ['index.html', 'text/html; charset=utf-8']],
...['app.js', 'brand.js', 'bus.js', 'markdown-preview.js', 'chat.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
...['shared/app.css', 'tokens.css', 'console.css', 'live.css', 'bus.css', 'shell.css', 'chat.css'].map(f => ['/' + f, [f, 'text/css; charset=utf-8']]),
['/editor.js', ['editor.js', 'text/javascript; charset=utf-8']],
['/editor.css', ['editor.css', 'text/css; charset=utf-8']],
['/icons.svg', ['icons.svg', 'image/svg+xml']],
...[400, 500, 600, 700].map(w => [`/assets/fonts/manrope-${w}.woff2`, [`assets/fonts/manrope-${w}.woff2`, 'font/woff2']]),
...[400, 500, 700].map(w => [`/assets/fonts/jetbrains-mono-${w}.woff2`, [`assets/fonts/jetbrains-mono-${w}.woff2`, 'font/woff2']]),
+2 -1
View File
@@ -18,7 +18,8 @@ test('served Console browser: real board fixtures, keyboard, drafts, receipts, t
assert.equal(await b.evaluate('document.querySelector("#session-count").textContent'), '3 of 4');
await b.evaluate('document.querySelector("#hide-offline").click()');
assert.equal(await b.evaluate('document.querySelectorAll("table.sessions tbody tr").length'), 4);
assert.equal(await b.evaluate('document.querySelectorAll("script").length'), 5);
// Rows 129 and 154: chat.js and editor.js are the scripts added, same origin; the list is the pin, so another script is a visible change.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/brand.js', '/app.js', '/markdown-preview.js', '/chat.js', '/bus.js']);
assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/chat.js"]').length`), 1);
assert.equal(await b.evaluate('!!window.injected'), false);
// Project filtering does not hide another project's waiting requests.
+10 -4
View File
@@ -60,7 +60,13 @@ test('the editor: the list, hostile markdown that runs nothing, the draft, and e
const set = (sel, v) => b.evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(sel)});e.value=${JSON.stringify(v)};e.dispatchEvent(new Event('input',{bubbles:true}));return true})()`);
const answer = a => b.evaluate(`window.__answers.push(${JSON.stringify(a)}), true`);
const saves = () => b.evaluate('window.__saves');
const open = async path => { await b.evaluate(`location.hash = '#/docs/stack/' + encodeURIComponent(${JSON.stringify(path)}), true`); await wait(`document.querySelector("#doc-text") && document.querySelector("#doc-commit") && !document.querySelector("#bus-view [aria-busy]")`); };
// Row 154: a page opens in the visual editor; every row 148 check below drives the Source view.
const source = async () => {
await wait('document.querySelector("#doc-commit")?.textContent !== "" && document.querySelector("#doc-commit") && !document.querySelector("#bus-view [aria-busy]")');
if (!await b.evaluate('!!document.querySelector("#doc-text")')) await b.evaluate('document.querySelector("#doc-mode-source").click(), true');
await wait('document.querySelector("#doc-text") && document.querySelector("#doc-commit") && document.querySelector("#doc-commit").textContent !== "" && !document.querySelector("#bus-view [aria-busy]")');
};
const open = async path => { await b.evaluate(`location.hash = '#/docs/stack/' + encodeURIComponent(${JSON.stringify(path)}), true`); await source(); };
const save = async () => { await b.evaluate('document.querySelector("#doc-save").click(), true'); await wait('document.querySelector("#doc-status").textContent !== "" && document.querySelector("#doc-status").textContent !== "Saving…"'); };
const PATH = 'spec/north-star.md';
try {
@@ -74,7 +80,7 @@ test('the editor: the list, hostile markdown that runs nothing, the draft, and e
Object.keys(files).map(p => [p, `#/docs/stack/${encodeURIComponent(p)}`]));
assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page]").textContent.trim()'), 'Docs');
await b.evaluate('document.querySelector("#bus-view ul a").click(), true');
await wait('document.querySelector("#doc-text")');
await source();
assert.equal(await text('#bus-view h1'), `Edit ${PATH}`);
assert.deepEqual(reads.file, [{ project: 'stack', path: PATH }]);
@@ -232,7 +238,7 @@ test('the editor: the list, hostile markdown that runs nothing, the draft, and e
// Without edit in /api/config the source is read-only and Save is off.
await b.navigate(`${base}?noedit#/docs/stack/${encodeURIComponent('docs/plain.md')}`);
await wait('document.querySelector("#doc-text") && !document.querySelector("#bus-view [aria-busy]")');
await source();
assert.equal(await b.evaluate('document.querySelector("#doc-text").readOnly'), true);
assert.equal(await b.evaluate('document.querySelector("#doc-save").disabled'), true);
assert.match(await text('#bus-view'), /Saving needs a Console started with --send/);
@@ -240,7 +246,7 @@ test('the editor: the list, hostile markdown that runs nothing, the draft, and e
// The first paint of a page whose preview throws: the same message, the source shown, no unhandled rejection.
await b.navigate(`${base}?throwrender#/docs/stack/${encodeURIComponent(PATH)}`);
await wait('document.querySelector("#doc-text") && document.querySelector("#doc-commit")?.textContent !== "" && !document.querySelector("#bus-view [aria-busy]")');
await source();
assert.equal(await text('#doc-preview'), FAILED);
assert.equal(await value('#doc-text'), HOSTILE);
assert.equal(await text('#doc-commit'), C1.slice(0, 12));
@@ -0,0 +1,503 @@
// Row 154 (#1648, Q168): the visual editor in a browser, under the Console's own Content-Security-Policy.
// Same harness as docs-browser.test.mjs: a script added before the page loads answers /api/config with edit
// on and records each POST /api/project-file, and the reads go to the real routes with stubbed bus answers.
// Row 148's checks (the Source view, the save receipts) are docs-browser.test.mjs's and run unchanged there.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { startServer } from '../src/serve.mjs';
import { browser } from './browser.mjs';
import { fixture, close } from './fixture.mjs';
import { EVERY } from './helpers/every-element.mjs';
const C1 = '1'.repeat(40), C2 = '2'.repeat(40);
const HOSTILE = [
'# Title', '',
'<script>window.injected=1</script>',
'<img src=x onerror="window.injected=2">', '',
'[click](javascript:window.injected=3) [data](data:text/html,<script>window.injected=4</script>) [ok](https://example.com/a)', '',
'[evil](//evil.example/x)', '',
'[sp](https://a b.example/)', '',
'![pixel](https://tracker.example/p.png)', '',
'![svg](data:image/svg+xml,<svg onload=window.injected=7>)', '',
'<svg onload="window.injected=8"></svg>', '',
'`<script>window.injected=5</script>`', '',
'```', '<iframe src="javascript:window.injected=6"></iframe>', '```', '',
].join('\n');
const MIX = '# Title\n\nFirst para.\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\nLast para.\n';
const files = { 'docs/hostile.md': HOSTILE, 'docs/every.md': EVERY, 'docs/mix.md': MIX, 'docs/empty.md': '', 'docs/tilde.md': '~~~a`b\ncode\n~~~\n' };
const STUB = `(() => {
const real = window.fetch.bind(window);
window.__saves = []; window.__answers = []; window.__rejections = []; window.__csp = []; window.__errors = [];
addEventListener('unhandledrejection', e => window.__rejections.push(String(e.reason?.message ?? e.reason)));
addEventListener('securitypolicyviolation', e => window.__csp.push(e.violatedDirective + ' ' + e.blockedURI));
addEventListener('error', e => window.__errors.push(String(e.message)));
window.fetch = async (url, init = {}) => {
const u = String(url);
if (u === '/api/config') {
const c = await (await real(url, init)).json();
return new Response(JSON.stringify({ ...c, send: !location.search.includes('noedit'), edit: !location.search.includes('noedit') }), { headers: { 'content-type': 'application/json' } });
}
if (u !== '/api/project-file' || init.method !== 'POST') return real(url, init);
window.__saves.push({ body: init.body });
const a = window.__answers.shift();
if (!a) throw new TypeError('no answer queued');
return new Response(JSON.stringify(a.body), { status: a.status, headers: { 'content-type': 'application/json' } });
};
})();`;
// One page over the fixture server, with the helpers every test here uses.
async function session(run) {
const reads = [];
const f = await fixture(), b = await browser();
const web = await startServer({
port: 0, board: f.boardURL,
reads: { business: async () => ({ business: { id: 'demo', human: 'jason', projects: ['stack'] } }) },
projectFiles: async () => ({ head: C1, files: Object.keys(files).map(path => ({ path })) }),
projectFile: async args => { reads.push(args.path); return { path: args.path, head: C1, commit: C1, content: files[args.path], author: 'jason', at: '2026-10-16T00:00:00Z' }; },
});
const base = `http://127.0.0.1:${web.address().port}/`;
const wait = (expr, tries = 100) => b.evaluate(`(async()=>{for(let i=0;i<${tries};i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw new Error('Condition timed out: '+${JSON.stringify(expr)})})()`);
const h = {
b, base, wait,
reads,
q: expr => b.evaluate(expr),
open: async (path, query = '') => {
await b.navigate(`${base}${query}#/docs/stack/${encodeURIComponent(path)}`);
await wait('document.querySelector("#doc-commit") && !document.querySelector("#bus-view [aria-busy]")');
},
pm: () => wait('document.querySelector("#doc-visual .ProseMirror")'),
answer: a => b.evaluate(`window.__answers.push(${JSON.stringify(a)}), true`),
// What the page holds that must stay empty, whatever else a test does.
quiet: async () => assert.deepEqual(await b.evaluate('({ csp: window.__csp, errors: window.__errors, rejections: window.__rejections, injected: window.injected ?? null })'),
{ csp: [], errors: [], rejections: [], injected: null }),
// The editor's inert-ness: nothing in it can run or load.
inert: () => b.evaluate(`(() => { const p = document.querySelector('#doc-visual .ProseMirror'); return {
// prosemirror-view's own src-less <img class="ProseMirror-separator"> (a cursor hack after a non-editable node) is not content
bad: [...p.querySelectorAll('script, img, iframe, object, embed, style, link, form, svg, video, audio, source, base, meta')].filter(e => !e.matches('img.ProseMirror-separator:not([src])')).map(e => e.localName),
handlers: [...p.querySelectorAll('*')].filter(e => [...e.attributes].some(a => /^on/i.test(a.name))).length,
links: [...p.querySelectorAll('a')].map(a => [a.getAttribute('href'), a.getAttribute('rel'), a.getAttribute('target')]),
hrefs: [...p.querySelectorAll('[href], [src], [srcset], [data], [action], [formaction]')].filter(e => e.localName !== 'a').length,
fetched: performance.getEntriesByType('resource').map(r => r.name).filter(n => /tracker\\.example|evil\\.example/.test(n)),
injected: window.injected ?? null }; })()`),
save: async () => {
await b.evaluate('document.querySelector("#doc-save").click(), true');
await wait('document.querySelector("#doc-status").textContent.startsWith("Saved")');
},
saved: async () => (await b.evaluate('window.__saves')).map(s => JSON.parse(s.body)),
done: async () => { await b.close(); await close(web); await f.close(); },
};
try { await b.call('Page.addScriptToEvaluateOnNewDocument', { source: STUB }); await b.viewport(1280); await run(h); } finally { await h.done(); }
}
test('the Console\'s CSP stands and the editor runs under it; hostile markdown on the load path runs and loads nothing', { timeout: 120000 }, async () => {
await session(async h => {
const csp = (await fetch(h.base)).headers.get('content-security-policy');
assert.match(csp, /(^|; )script-src 'self'(;|$)/);
assert.doesNotMatch(csp, /unsafe-eval|script-src[^;]*unsafe-inline|data:|blob:/);
await h.open('docs/hostile.md');
await h.pm();
assert.equal(await h.q('document.querySelector("#doc-mode-visual").getAttribute("aria-pressed")'), 'true', 'a page opens in the visual editor');
assert.equal(await h.q('!!document.querySelector("#doc-text")'), false);
assert.equal(await h.q('typeof window.MosaicEditor.mount'), 'function', 'editor.js ran under script-src self');
assert.equal(await h.q('getComputedStyle(document.querySelector(".ed-toolbar")).display'), 'flex', 'editor.css applied');
const view = await h.inert();
assert.deepEqual(view.bad, []);
assert.equal(view.handlers, 0);
assert.equal(view.hrefs, 0);
assert.deepEqual(view.fetched, []);
assert.equal(view.injected, null);
// markdown-it itself makes no link of a javascript: or a data: target, so those stay text; the paragraph holding them
// is not a refused block, and its one good link is a real link. A target markdown-it accepts but the gate refuses
// (`//evil.example/x`) makes its whole block a source block; a target with a space is no link to markdown-it at all.
assert.deepEqual(view.links, [['https://example.com/a', 'noopener noreferrer', '_blank']], 'the one good link is the only anchor on the page');
const shown = await h.q('document.querySelector("#doc-visual .ProseMirror").textContent');
assert.ok(shown.includes('<script>window.injected=1</script>') && shown.includes('javascript:window.injected=3') && shown.includes('data:text/html') && shown.includes('https://tracker.example/p.png'), 'what the file says shows as text');
assert.equal(await h.q('[...document.querySelectorAll("#doc-visual .ProseMirror pre.md-raw")].some(e => e.textContent.includes("[evil](//evil.example/x)"))'), true, 'a target the gate refuses leaves its block as source text');
assert.ok(shown.includes('[sp](https://a b.example/)'), 'a target with a space stays text');
// typing works under the policy: an edit changes the draft and turns Save on
await h.q('(() => { const p = document.querySelector("#doc-visual .ProseMirror"); p.focus(); getSelection().selectAllChildren(p); getSelection().collapseToEnd(); return true; })()');
await h.b.call('Input.insertText', { text: 'typed' });
await h.wait('!document.querySelector("#doc-save").disabled');
await h.quiet();
// a doc with every element: nothing in it runs, and a file's own links the gate accepts are real links
await h.open('docs/every.md');
await h.pm();
const every = await h.inert();
assert.deepEqual([every.bad, every.handlers, every.hrefs, every.fetched, every.injected], [[], 0, 0, [], null]);
for (const [href, rel, target] of every.links) {
assert.match(href, /^(https:\/\/|http:\/\/|[^:]*$)/, `a link target the gate accepts: ${href}`);
assert.equal(rel, 'noopener noreferrer');
assert.equal(target, '_blank');
}
assert.ok(every.links.some(([href]) => href === 'https://example.com/a'), 'a plain https link is a link');
assert.equal(await h.q('!document.querySelector("#doc-visual .ProseMirror a[href^=javascript i], #doc-visual .ProseMirror a[href^=data i]")'), true);
// open and save with no edit: nothing to save, and the editor holds the file's own bytes
assert.equal(await h.q('document.querySelector("#doc-save").disabled'), true);
assert.equal(await h.q(`window.MosaicEditor.roundTrip(${JSON.stringify(files['docs/every.md'])}) === ${JSON.stringify(files['docs/every.md'])}`), true);
await h.quiet();
// an empty file opens, and the same page read-only has no toolbar, no editing and no save
await h.open('docs/empty.md');
await h.pm();
await h.open('docs/mix.md', '?noedit');
await h.pm();
assert.equal(await h.q('document.querySelector("#doc-visual .ProseMirror").getAttribute("contenteditable")'), 'false');
assert.equal(await h.q('!!document.querySelector(".ed-toolbar")'), false);
assert.equal(await h.q('document.querySelector("#doc-save").disabled'), true);
await h.quiet();
});
});
test('hostile content pasted into the editor runs and loads nothing, and a bad link target is refused in the link bar', { timeout: 120000 }, async () => {
await session(async h => {
await h.open('docs/mix.md');
await h.pm();
const paste = (html, plain) => h.q(`(() => {
const p = document.querySelector('#doc-visual .ProseMirror'); p.focus();
getSelection().selectAllChildren(p); getSelection().collapseToEnd();
const dt = new DataTransfer();
${html == null ? '' : `dt.setData('text/html', ${JSON.stringify(html)});`}
${plain == null ? '' : `dt.setData('text/plain', ${JSON.stringify(plain)});`}
p.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }));
return true; })()`);
const HTML = [
'<p>pasted</p>',
'<script>window.injected=11</script>',
'<img src="https://tracker.example/p.png" onerror="window.injected=12">',
'<p><a href="javascript:window.injected=13" onclick="window.injected=14">js</a>',
'<a href="data:text/html,<script>window.injected=15</script>">data</a>',
'<a href=" javascript:window.injected=16">space</a>',
'<a href="//evil.example/x">proto-relative</a>',
'<a href="https://ok.example/p" onmouseover="window.injected=17">ok</a>',
'<a href="../docs/rel.md">rel</a></p>',
'<p><span class="md-image" data-src="data:image/png;base64,AAAA" data-alt="d">img</span>',
'<span class="md-image" data-src="javascript:window.injected=18" data-alt="j">img</span>',
'<span class="md-image" data-src="https://tracker.example/q.png" data-alt="t">img</span></p>',
'<iframe src="javascript:window.injected=19"></iframe><object data="https://evil.example/o"></object>',
'<svg onload="window.injected=20"><script>window.injected=21</script></svg>',
'<style>body{background:url(https://evil.example/css)}</style>',
'<form action="https://evil.example/f"><input name=x></form>',
'<div style="background:url(https://evil.example/bg)">styled</div>',
].join('');
await paste(HTML, '[x](javascript:window.injected=22) <script>window.injected=23</script>');
await h.wait('document.querySelector("#doc-visual .ProseMirror").textContent.includes("pasted")');
const view = await h.inert();
assert.deepEqual(view.bad, [], 'no script, image, frame, object, style or form came in');
assert.equal(view.handlers, 0);
assert.equal(view.hrefs, 0);
assert.deepEqual(view.fetched, []);
assert.equal(view.injected, null);
assert.deepEqual(view.links.map(l => l[0]).sort(), ['../docs/rel.md', 'https://ok.example/p'], 'only targets the gate accepts became links');
for (const [, rel, target] of view.links) assert.deepEqual([rel, target], ['noopener noreferrer', '_blank']);
assert.equal(await h.q('document.querySelector("#doc-visual .ProseMirror [style*=url]")'), null, 'no style attribute came in');
// pasting plain text that looks like markup or a link leaves it as text
await paste(null, '[x](javascript:window.injected=24) <script>window.injected=25</script> ![i](https://tracker.example/z.png)');
await h.wait('document.querySelector("#doc-visual .ProseMirror").textContent.includes("window.injected=25")');
const plain = await h.inert();
assert.deepEqual([plain.bad, plain.handlers, plain.fetched, plain.injected], [[], 0, [], null]);
assert.ok(!plain.links.some(l => /javascript/i.test(l[0])));
// what the save writes holds no live script, frame or image from the paste: only text, escaped
await h.answer({ status: 200, body: { path: 'docs/mix.md', commit: C2, at: '2026-10-16T01:00:00Z' } });
await h.save();
const [{ content }] = await h.saved();
// A bracket that survives is escaped (`\<`), so it is text on the next load. A link or image survives only with a
// target the gate accepts: the two links and the one https image the paste carried (written as text, never
// fetched), and no javascript: or data: target anywhere.
assert.doesNotMatch(content.replace(/\\</g, ''), /<(script|iframe|object|svg|style|form|img)/i, 'every angle bracket pasted is escaped');
assert.deepEqual([...content.matchAll(/(?<!\\)\]\(([^)\s]*)\)/g)].map(m => m[1]).sort(), ['../docs/rel.md', 'https://ok.example/p', 'https://tracker.example/q.png']);
assert.doesNotMatch(content, /(?<!\\)\]\(\s*(javascript|data):/i);
await h.quiet();
// the link bar: select a word, set a refused target, then an accepted one. Same URL and hash, so load about:blank first:
// the page must really reload, and the pasted draft must be gone before the link bar is tried.
await h.b.navigate('about:blank');
await h.open('docs/mix.md');
await h.pm();
assert.equal(await h.q('document.querySelectorAll("#doc-visual .ProseMirror a").length'), 0, 'a fresh page: the pasted links are gone');
assert.equal(await h.q('document.querySelector("#doc-visual .ProseMirror").textContent.includes("pasted")'), false, 'a fresh page: the pasted text is gone');
await h.q(`(() => { const p = [...document.querySelectorAll('#doc-visual .ProseMirror p')].find(e => e.textContent === 'First para.');
p.parentElement.focus(); const r = document.createRange(); r.setStart(p.firstChild, 0); r.setEnd(p.firstChild, 5); const s = getSelection(); s.removeAllRanges(); s.addRange(r); return true; })()`);
await h.wait('(() => { const s = getSelection(); return s.toString() === "First"; })()');
await h.q('document.querySelector(".ed-btn[data-cmd=link]").click(), true');
await h.wait('!document.querySelector(".ed-linkbar").hidden');
const setLink = async target => {
await h.q(`(() => { const i = document.querySelector('.ed-link-input'); i.value = ${JSON.stringify(target)}; i.form.requestSubmit(); return true; })()`);
};
for (const bad of ['javascript:window.injected=30', 'JaVaScRiPt:window.injected=31', 'data:text/html,x', ' javascript:x', '//evil.example/x', 'vbscript:x', 'https://a b.example/']) {
await setLink(bad);
await h.wait('document.querySelector(".ed-link-note").textContent.includes("not allowed")');
assert.equal(await h.q('document.querySelectorAll("#doc-visual .ProseMirror a").length'), 0, `${bad} made no link`);
assert.equal(await h.q('document.querySelector(".ed-linkbar").hidden'), false, 'the bar stays open and says why');
await h.q('document.querySelector(".ed-link-note").textContent = "", true');
}
await setLink('https://ok.example/page');
await h.wait('document.querySelectorAll("#doc-visual .ProseMirror a").length === 1');
assert.deepEqual(await h.q('[...document.querySelectorAll("#doc-visual .ProseMirror a")].map(a => [a.getAttribute("href"), a.getAttribute("rel"), a.getAttribute("target"), a.textContent])'),
[['https://ok.example/page', 'noopener noreferrer', '_blank', 'First']]);
await h.quiet();
});
});
// Mos's text said an image becomes text; this row also gates a pasted image span (span.md-image[data-src], the editor's own
// copy format) on the link gate, so a target the gate refuses never becomes an image node, and none can reach a save.
test('a pasted image span is kept only for a target the link gate accepts, and a save writes only those', { timeout: 120000 }, async () => {
await session(async h => {
await h.open('docs/mix.md');
await h.pm();
const ACCEPTED = [['a', 'https://ok.example/a.png'], ['r', '../img/r.png']];
const REFUSED = ['data:image/png;base64,AAAA', 'javascript:window.injected=40', '//evil.example/i.png', ' javascript:window.injected=41', 'vbscript:x', 'https://a b.example/i.png', 'DATA:image/gif;base64,AAAA'];
const HTML = [...ACCEPTED.map(([alt, src]) => `<p>kept-${alt} <span class="md-image" data-src="${src}" data-alt="${alt}">x</span></p>`),
...REFUSED.map((src, i) => `<p>refused-${i} <span class="md-image" data-src="${src}" data-alt="n${i}">x</span></p>`)].join('');
await h.q(`(() => {
const p = document.querySelector('#doc-visual .ProseMirror'); p.focus();
getSelection().selectAllChildren(p); getSelection().collapseToEnd();
const dt = new DataTransfer(); dt.setData('text/html', ${JSON.stringify(HTML)});
p.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }));
return true; })()`);
await h.wait(`document.querySelector("#doc-visual .ProseMirror").textContent.includes("refused-${REFUSED.length - 1}")`);
// in the editor: an image node for each accepted target, drawn as text, and for no refused one
const atoms = await h.q('[...document.querySelectorAll("#doc-visual .ProseMirror .md-image")].map(e => [e.getAttribute("data-src"), e.getAttribute("data-alt"), e.localName, e.textContent])');
assert.deepEqual(atoms.map(a => a.slice(0, 3)), ACCEPTED.map(([alt, src]) => [src, alt, 'span']), 'image nodes for the accepted targets only');
assert.deepEqual(atoms.map(a => a[3]), ACCEPTED.map(([alt, src]) => `[image: ${alt}] ${src}`), 'drawn as text');
const refusedText = await h.q('document.querySelector("#doc-visual .ProseMirror").textContent');
for (let i = 0; i < REFUSED.length; i++) assert.ok(refusedText.includes(`refused-${i}`), `the text around refused target ${i} is kept`);
for (const src of REFUSED) assert.ok(!refusedText.includes(src.trim()), `${src} is nowhere in the editor`);
const view = await h.inert();
assert.deepEqual([view.bad, view.handlers, view.hrefs, view.fetched, view.injected], [[], 0, 0, [], null]);
// a save: an image for each accepted target, written as markdown, and no refused target anywhere
await h.answer({ status: 200, body: { path: 'docs/mix.md', commit: C2, at: '2026-10-16T01:00:00Z' } });
await h.save();
const [{ content }] = await h.saved();
assert.deepEqual([...content.matchAll(/!\[([^\]]*)\]\(([^)\s]*)\)/g)].map(m => [m[1], m[2]]), ACCEPTED, 'saved images: the accepted targets, in order');
for (const src of REFUSED) assert.ok(!content.includes(src.trim()), `${src} is not in the saved file`);
assert.doesNotMatch(content, /javascript:|vbscript:|data:image|evil\.example/i);
for (let i = 0; i < REFUSED.length; i++) assert.ok(content.includes(`refused-${i}`), `the text around refused target ${i} is saved as text`);
await h.quiet();
});
});
// A switch of view reuses the draft it holds; everything else that opens a file still reads it, once each.
test('a switch of view reads nothing; a reload, and a trip away and back, each read the file exactly once', { timeout: 120000 }, async () => {
await session(async h => {
const settle = () => h.q('new Promise(r => setTimeout(r, 300)).then(() => true)');
const mix = () => h.reads.filter(p => p === 'docs/mix.md').length;
await h.open('docs/mix.md');
await h.pm();
// a cold load renders twice (the page draws again when /api/config answers), so count from what opening cost
await settle();
const opened = mix();
assert.ok(opened >= 1, 'opening reads the file');
for (let i = 0; i < 2; i++) {
await h.q('document.querySelector("#doc-mode-source").click(), true');
await h.wait('document.querySelector("#doc-text")');
await h.q('document.querySelector("#doc-mode-visual").click(), true');
await h.pm();
}
await settle();
assert.equal(mix(), opened, 'two round trips between the views read nothing');
// Reload from the project: one read, and a switch after it reads nothing more
await h.q('document.querySelector("#doc-reload").click(), true');
await h.wait('document.querySelector("#doc-commit") && !document.querySelector("#bus-view [aria-busy]")');
await h.pm();
await settle();
assert.equal(mix(), opened + 1, 'Reload reads once');
await h.q('document.querySelector("#doc-mode-source").click(), true');
await h.wait('document.querySelector("#doc-text")');
await settle();
assert.equal(mix(), opened + 1, 'a switch after Reload reads nothing');
// away and back: the draft is clean, so opening the file again reads it once
await h.q('location.hash = "#/docs", true');
await h.wait('document.querySelector("#bus-view h1")?.textContent === "Docs" && !document.querySelector("#bus-view [aria-busy]")');
await h.open('docs/mix.md');
await settle();
assert.equal(mix(), opened + 2, 'going away and back reads once');
await h.quiet();
});
});
test('an edit rewrites only the block touched; a save sends the rest byte for byte; the Source view carries the draft',{ timeout: 120000 }, async () => {
await session(async h => {
await h.open('docs/mix.md');
await h.pm();
assert.equal(await h.q('document.querySelector("#doc-save").disabled'), true, 'open with no edit: nothing to save');
// a table is drawn as a tinted source block, not a table
assert.equal(await h.q('document.querySelectorAll("#doc-visual .ProseMirror table").length'), 0);
assert.equal(await h.q('document.querySelectorAll("#doc-visual .ProseMirror pre.md-raw").length'), 1);
// type at the end of the first paragraph
await h.q(`(() => { const p = [...document.querySelectorAll('#doc-visual .ProseMirror p')].find(e => e.textContent === 'First para.');
p.parentElement.focus(); const r = document.createRange(); r.setStart(p.firstChild, p.firstChild.length); r.collapse(true); const s = getSelection(); s.removeAllRanges(); s.addRange(r); return true; })()`);
await h.b.call('Input.insertText', { text: ' More **not bold**.' });
await h.wait('!document.querySelector("#doc-save").disabled');
// Save is on because the draft differs, so the editor must hold the typed text, and keep holding it until the click.
const typed = () => h.q('document.querySelector("#doc-visual .ProseMirror").textContent');
assert.ok((await typed()).includes('First para. More **not bold**.'), 'the typed text is in the editor');
await h.q('new Promise(r => setTimeout(r, 300)).then(() => true)');
assert.equal(await h.q('document.querySelector("#doc-save").disabled'), false, 'Save stays on');
assert.ok((await typed()).includes('First para. More **not bold**.'), 'the typed text is still in the editor');
await h.answer({ status: 200, body: { path: 'docs/mix.md', commit: C2, at: '2026-10-16T01:00:00Z' } });
await h.save();
const [sent] = await h.saved();
assert.deepEqual(Object.keys(sent).sort(), ['base', 'content', 'path', 'project'], 'the body row 148 sends');
assert.deepEqual([sent.project, sent.path, sent.base], ['stack', 'docs/mix.md', C1]);
// the typed `**` is text, so it is escaped; the table, the heading and the last paragraph are untouched
assert.equal(sent.content, '# Title\n\nFirst para. More \\*\\*not bold\\*\\*.\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\nLast para.\n');
assert.equal(await h.q('document.querySelector("#doc-save").disabled'), true, 'saved, so nothing left to save');
// the Source view shows the draft as the editor would save it, with no new read of the project, and an edit there
// comes back to the visual view
const readsBefore = h.reads.length;
await h.q('document.querySelector("#doc-mode-source").click(), true');
await h.wait('document.querySelector("#doc-text")');
assert.equal(await h.q('document.querySelector("#doc-text").value'), sent.content);
assert.equal(h.reads.length, readsBefore, 'a switch of view reads nothing');
await h.q(`(() => { const t = document.querySelector('#doc-text'); t.value = t.value.replace('Last para.', 'Final para.'); t.dispatchEvent(new Event('input', { bubbles: true })); return true; })()`);
await h.q('document.querySelector("#doc-mode-visual").click(), true');
await h.pm();
assert.ok((await h.q('document.querySelector("#doc-visual .ProseMirror").textContent')).includes('Final para.'));
assert.equal(await h.q('document.querySelector("#doc-save").disabled'), false);
assert.equal(h.reads.length, readsBefore, 'the switch back reads nothing either');
// toolbar and keys: bold with the key, a heading from the toolbar, a list from the markdown shortcut
await h.q(`(() => { const p = [...document.querySelectorAll('#doc-visual .ProseMirror p')].find(e => e.textContent === 'Final para.');
p.parentElement.focus(); const r = document.createRange(); r.setStart(p.firstChild, 0); r.setEnd(p.firstChild, 5); const s = getSelection(); s.removeAllRanges(); s.addRange(r); return true; })()`);
await h.wait('getSelection().toString() === "Final"');
await h.q('document.querySelector(".ed-btn[data-cmd=strong]").click(), true');
await h.wait('document.querySelectorAll("#doc-visual .ProseMirror strong").length === 1');
await h.q('document.querySelector(".ed-btn[data-cmd=h2]").click(), true');
await h.wait('document.querySelectorAll("#doc-visual .ProseMirror h2").length === 1');
await h.answer({ status: 200, body: { path: 'docs/mix.md', commit: '3'.repeat(40), at: '2026-10-16T02:00:00Z' } });
await h.save();
const second = (await h.saved())[1];
assert.equal(second.content, '# Title\n\nFirst para. More \\*\\*not bold\\*\\*.\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\n## **Final** para.\n');
assert.equal(second.base, C2, 'the second save is based on the commit the first one made');
await h.quiet();
});
});
test('a file the editor cannot write back byte for byte opens as Source, says so, cannot save, and the Visual button works once it can', { timeout: 120000 }, async () => {
await session(async h => {
const settle = () => h.q('new Promise(r => setTimeout(r, 300)).then(() => true)');
const shown = () => h.q(`({ pm: !!document.querySelector('#doc-visual .ProseMirror'), text: document.querySelector('#doc-text')?.value ?? null,
note: document.querySelector('#bus-view')?.textContent.includes('The visual editor is not available for this file, so it is open as source.'),
pressed: [document.querySelector('#doc-mode-visual')?.getAttribute('aria-pressed'), document.querySelector('#doc-mode-source')?.getAttribute('aria-pressed')],
saveOff: document.querySelector('#doc-save')?.disabled })`);
const reload = async () => { await h.q('document.querySelector("#doc-reload").click(), true'); await h.wait('document.querySelector("#doc-commit")?.textContent !== "" && !document.querySelector("#bus-view [aria-busy]")'); await settle(); };
await h.open('docs/mix.md');
await h.pm();
assert.deepEqual((await shown()).pressed, ['true', 'false'], 'a plain file opens in the visual editor');
// roundTrip disagrees with the file: the gate refuses, whatever else the editor could do
await h.q(`(() => { const real = window.MosaicEditor; window.__rt = false; window.MosaicEditor = { mount: real.mount, roundTrip: t => window.__rt ? real.roundTrip(t) : t + ' ' }; return true; })()`);
await reload();
assert.deepEqual(await shown(), { pm: false, text: MIX, note: true, pressed: ['false', 'true'], saveOff: true }, 'a failed round trip shows the source, the note and a Save that is off');
// the screen shows Source, so the Visual button must act: once the file round-trips it opens the editor with no new read
await h.q('window.__rt = true, true');
const reads = h.reads.length;
await h.q('document.querySelector("#doc-mode-visual").click(), true');
await h.pm();
assert.deepEqual((await shown()).pressed, ['true', 'false'], 'Visual works from a fallback');
assert.equal(h.reads.length, reads, 'the switch reads nothing');
// the preference is not lost: the next fallback is a fallback again, not the Source the user never picked
await h.q('window.__rt = false, true');
await reload();
assert.equal((await shown()).pm, false);
// no editor script at all: the same page, the same note
await h.q('window.MosaicEditor = undefined, true');
await reload();
assert.deepEqual(await shown(), { pm: false, text: MIX, note: true, pressed: ['false', 'true'], saveOff: true }, 'without window.MosaicEditor the page is Source with the note');
await h.q('document.querySelector("#doc-mode-visual").click(), true');
await settle();
assert.deepEqual(await shown(), { pm: false, text: MIX, note: true, pressed: ['false', 'true'], saveOff: true }, 'Visual with no editor stays Source');
await h.quiet();
});
});
// Dewey's r1 finding: a link title, an image alt or title and a code block's info string are written into one line of
// Markdown, a paste can set any of them, and a line break in one saved a raw HTML block. Each of the four, on the paste path.
// (The editor's own reading of a file can give a single line break in a title or alt, never a blank line; see BUILD.md.)
const BREAK = 'T\n\n<img src=x onerror=alert(1)>\n\n';
// Each test first waits for the pasted node, holding the hostile value, to be in the editor's document (read from its DOM),
// so a paste that was dropped fails there and not as a missing string after Save. The <pre> comes between two paragraphs:
// prosemirror-view opens the first and last node of a pasted slice (Slice.maxOpen), so a lone <pre> joins the block the
// cursor is in as plain text and sets no params; the paste here lands in the first block, the heading.
const attrOf = v => v.replace(/&/g, '&amp;').replace(/"/g, '&quot;');
// One paste, then Save: `value` is what the pasted node must hold in the document, `written` what the file must then contain.
// With `reread`, the saved text is also opened by the editor's own reader (a second, read-only mount): the title must come
// back as pasted and no tag may have come out as text of the document, which is what a renderer with raw HTML would run.
function pasteTest(name, html, value, written, node, attr, headline = `${name} pasted with line breaks in it is written on one line, so no HTML block comes of it`, reread = false) {
test(headline, { timeout: 120000 }, async () => {
await session(async h => {
await h.open('docs/mix.md');
await h.pm();
await h.q(`(() => {
const p = document.querySelector('#doc-visual .ProseMirror'); p.focus();
getSelection().selectAllChildren(p); getSelection().collapseToEnd();
const dt = new DataTransfer(); dt.setData('text/html', ${JSON.stringify(html)});
p.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }));
return true; })()`);
const sel = `#doc-visual .ProseMirror ${node}[${attr}]`;
await h.wait(`document.querySelector(${JSON.stringify(sel)})`);
assert.equal(await h.q(`document.querySelector(${JSON.stringify(sel)}).getAttribute(${JSON.stringify(attr)})`), value, 'the pasted node holds the hostile value in the document');
await h.wait('!document.querySelector("#doc-save").disabled');
await h.answer({ status: 200, body: { path: 'docs/mix.md', commit: C2, at: '2026-10-16T01:00:00Z' } });
await h.save();
const [{ content }] = await h.saved();
assert.ok(content.includes(written), `the value is written as ${JSON.stringify(written)} in ${JSON.stringify(content)}`);
assert.doesNotMatch(content, /^[ \t]*<img/m, 'no line of the file starts an HTML block');
assert.doesNotMatch(content, /\n[ \t]*\n[ \t]*<img/, 'no blank line then a tag');
if (reread) {
const back = await h.q(`(() => {
const host = document.createElement('div'); document.body.appendChild(host);
const ed = window.MosaicEditor.mount(host, { text: ${JSON.stringify(content)}, editable: false });
const titles = [];
ed.view.state.doc.descendants(n => { if (n.type.name === 'image') titles.push(n.attrs.title); for (const m of n.marks) if (m.type.name === 'link') titles.push(m.attrs.title); });
const text = ed.view.state.doc.textContent;
ed.destroy(); host.remove();
return { titles, text }; })()`);
assert.deepEqual(back.titles, [value], 'the saved title reads back as it was pasted');
assert.ok(!back.text.includes('<img'), `no tag in the text of the re-read document: ${JSON.stringify(back.text)}`);
}
await h.quiet();
});
});
}
for (const [name, html, written, node, attr] of [
['a link title', `<p><a href="https://ok.example/p" title="${BREAK}">word</a></p>`, '[word](https://ok.example/p "T <img src=x onerror=alert(1)> ")', 'a[href="https://ok.example/p"]', 'title'],
['an image alt', `<p><span class="md-image" data-src="https://ok.example/a.png" data-alt="${BREAK}">x</span></p>`, '![T <img src=x onerror=alert(1)> ](https://ok.example/a.png)', 'span.md-image', 'data-alt'],
['an image title', `<p><span class="md-image" data-src="https://ok.example/a.png" data-alt="a" data-title="${BREAK}">x</span></p>`, '![a](https://ok.example/a.png "T <img src=x onerror=alert(1)> ")', 'span.md-image', 'data-title'],
['a code block info string', `<p>x</p><pre data-params="${BREAK}">code</pre><p>y</p>`, '```T\ncode\n```', 'pre[data-params]', 'data-params'],
]) pasteTest(name, html, BREAK, written, node, attr);
// Dewey's r2 finding: the title writers escaped `"` and not `\`. A title `x\") <img ...>` then ended its quotes early
// (the saved line re-read as a link titled `x\` followed by live inline HTML), and a title ending in `\` swallowed its own
// closing quote. Both shapes, for a link title and an image title, on the paste path. Written, each backslash is doubled
// and each quote gets its own backslash.
const ESCAPE = 'x\\") <img src=x onerror=alert(1)> ';
const TRAILING = 'end\\';
for (const [what, value, written] of [['a quote after a backslash then a tag', ESCAPE, 'x\\\\\\") <img src=x onerror=alert(1)> '], ['a trailing backslash', TRAILING, 'end\\\\']]) {
pasteTest('a link title', `<p><a href="https://ok.example/p" title="${attrOf(value)}">word</a></p>`, value, `[word](https://ok.example/p "${written}")`, 'a[href="https://ok.example/p"]', 'title',
`a link title holding ${what} is written with the backslash escaped, so the title stays closed`, true);
pasteTest('an image title', `<p><span class="md-image" data-src="https://ok.example/a.png" data-alt="a" data-title="${attrOf(value)}">x</span></p>`, value, `![a](https://ok.example/a.png "${written}")`, 'span.md-image', 'data-title',
`an image title holding ${what} is written with the backslash escaped, so the title stays closed`, true);
}
// Filbert's g5: a backtick in a code block's info string ends the fence if written; the load side refuses it first (tilde
// fence, below), so only a paste reaches the save side.
pasteTest('a code block info string', '<p>x</p><pre data-params="a`b">code</pre><p>y</p>', 'a`b', '```\ncode\n```', 'pre[data-params]', 'data-params',
'a code block info string with a backtick, pasted, is dropped on Save, so the fence stays closed');
test('a code block info string with a backtick, as a tilde fence can have, is kept as source, not rewritten without it', { timeout: 120000 }, async () => {
await session(async h => {
await h.open('docs/tilde.md');
await h.pm();
assert.equal(await h.q('document.querySelectorAll("#doc-visual .ProseMirror pre.md-raw").length'), 1, 'the block is a source block');
assert.ok((await h.q('document.querySelector("#doc-visual .ProseMirror pre.md-raw").textContent')).startsWith('~~~a`b'));
await h.quiet();
});
});
@@ -0,0 +1,173 @@
// Row 154 (#1648, Q168): the imported editor source and the build. third_party/editor/ is upstream source
// copied byte for byte; this fails if one byte of it changes without a recorded local change, if a file is
// added or removed, or if the built bundle is not the build of that source.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { spawnSync } from 'node:child_process';
import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const webui = join(dirname(fileURLToPath(import.meta.url)), '..');
const imported = join(webui, 'third_party/editor');
const sha = buf => createHash('sha256').update(buf).digest('hex');
const gitBlob = buf => createHash('sha1').update(Buffer.concat([Buffer.from(`blob ${buf.length}\0`), buf])).digest('hex');
const provenance = JSON.parse(readFileSync(join(imported, 'PROVENANCE.json'), 'utf8'));
const build = JSON.parse(readFileSync(join(webui, 'editor/BUILD.json'), 'utf8'));
const filesOnDisk = () => readdirSync(imported, { withFileTypes: true, recursive: true })
.filter(e => e.isFile()).map(e => join(e.parentPath, e.name).slice(imported.length + 1)).filter(p => p !== 'PROVENANCE.json').sort();
const treeOf = entries => sha(entries.sort((a, b) => (a[0] < b[0] ? -1 : 1)).map(([path, h]) => `${h} ${path}\n`).join(''));
test('the imported source is the tagged upstream source plus the recorded local changes, and nothing else', () => {
assert.equal(provenance.schema, 1);
const changes = new Map(provenance.localChanges.map(c => [c.path, c]));
for (const c of changes.values()) assert.ok(c.path && /^[0-9a-f]{64}$/.test(c.sha256) && c.reason, `a local change names its path, its new sha256 and why: ${JSON.stringify(c)}`);
// 1. The record names every file and the disk has exactly those.
const recorded = new Map([['NOTICE', provenance.notice.sha256]]);
for (const p of provenance.packages) {
assert.match(p.commit, /^[0-9a-f]{40}$/, `${p.name} commit`);
assert.match(p.upstreamTree, /^[0-9a-f]{40}$/, `${p.name} upstream tree`);
assert.ok(p.files[p.license], `${p.name} ships its license file ${p.license}`);
assert.ok(p.files['package.json'], `${p.name} ships its package.json`);
for (const [path, f] of Object.entries(p.files)) recorded.set(`${p.name}/${path}`, f.sha256);
}
assert.deepEqual(filesOnDisk(), [...recorded.keys()].sort(), 'files on disk against files recorded');
// 2. Each file is the recorded bytes: the upstream bytes, or the recorded local change.
const expected = [];
for (const p of provenance.packages) {
for (const [path, f] of Object.entries(p.files)) {
const key = `${p.name}/${path}`, buf = readFileSync(join(imported, key));
const change = changes.get(key);
if (change) assert.equal(sha(buf), change.sha256, `${key} differs from its recorded local change`);
else {
assert.equal(sha(buf), f.sha256, `${key} differs from upstream and has no recorded local change`);
assert.equal(gitBlob(buf), f.blob, `${key} is not the git blob ${f.blob} of ${p.tag}`);
assert.equal(buf.length, f.bytes, key);
}
expected.push([key, change ? change.sha256 : f.sha256]);
}
}
for (const key of changes.keys()) assert.ok(recorded.has(key), `local change for ${key}, which is not an imported file`);
const notice = readFileSync(join(imported, 'NOTICE'));
assert.equal(sha(notice), provenance.notice.sha256, 'NOTICE');
expected.push(['NOTICE', provenance.notice.sha256]);
// 3. The tree hash: from the record, and from the bytes on disk.
assert.equal(treeOf(expected), provenance.tree.sha256, 'tree hash from the record');
const fromDisk = filesOnDisk().map(p => [p, sha(readFileSync(join(imported, p)))]);
assert.equal(fromDisk.length, provenance.tree.files);
assert.equal(treeOf(fromDisk), provenance.tree.sha256, 'tree hash from the files on disk');
});
test('every package names its origin and license, and NOTICE lists each', () => {
const notice = readFileSync(join(imported, 'NOTICE'), 'utf8');
for (const p of provenance.packages) {
assert.match(p.origin, /^https:\/\//, p.name);
assert.ok(p.spdx && p.copyright.length, `${p.name} license id and copyright line`);
assert.ok(notice.includes(`${p.name} ${p.version}`) && notice.includes(p.commit) && notice.includes(`${p.name}/${p.license}`), `NOTICE lists ${p.name}`);
for (const line of p.copyright) assert.ok(notice.includes(line), `NOTICE carries "${line}"`);
assert.ok(readFileSync(join(imported, p.name, p.license), 'utf8').includes(p.copyright[0]), `${p.name} license file carries its copyright`);
}
assert.equal(new Set(provenance.packages.map(p => p.name)).size, provenance.packages.length);
});
test('the build reads only imported source and the editor\'s own files; the bundle is the recorded build', () => {
const imp = new Set(provenance.packages.flatMap(p => Object.keys(p.files).map(f => `third_party/editor/${p.name}/${f}`)));
for (const input of build.inputs) {
if (input.startsWith('third_party/editor/')) assert.ok(imp.has(input), `${input} is read by the build but is not in PROVENANCE.json`);
else assert.match(input, /^editor\/[a-z]+\.mjs$/, input);
assert.ok(!/(^|\/)(dist|node_modules)\//.test(input), `${input}: no prebuilt output goes in`);
}
for (const input of build.inputs) readFileSync(join(webui, input)); // every input exists
for (const [path, out] of Object.entries(build.outputs)) {
const buf = readFileSync(join(webui, path));
assert.equal(sha(buf), out.sha256, `${path} is not the recorded build`);
assert.equal(buf.length, out.bytes, path);
}
const bundle = readFileSync(join(webui, 'src/public/editor.js'), 'utf8');
assert.doesNotMatch(bundle, /sourceMappingURL/);
assert.match(bundle, /^\(\(\) => \{/, 'one unminified IIFE');
assert.ok(bundle.split('\n').length > 5000, 'unminified');
});
// A fake install: node_modules/esbuild/package.json with a version, and the platform binary with the given bytes.
const fakeInstall = (version, bytes) => {
const dir = mkdtempSync(join(tmpdir(), 'editor-toolchain-'));
mkdirSync(join(dir, 'node_modules/esbuild'), { recursive: true });
writeFileSync(join(dir, 'node_modules/esbuild/package.json'), JSON.stringify({ name: 'esbuild', version }));
if (bytes !== undefined) {
const bin = join(dir, `node_modules/@esbuild/${process.platform}-${process.arch}/bin`);
mkdirSync(bin, { recursive: true });
writeFileSync(join(bin, 'esbuild'), bytes); writeFileSync(join(dir, `node_modules/@esbuild/${process.platform}-${process.arch}/package.json`), '{}');
}
return { dir, from: pathToFileURL(join(dir, 'x.mjs')) };
};
test('MOSAIC_REQUIRE_EDITOR_BUILD: a missing or wrong esbuild skips without it and fails with it; a wrong binary always fails', async () => {
const { ESBUILD, toolchain, required, verdict } = await import('../editor/toolchain.mjs');
const cases = [];
try {
for (const [label, install, kind] of [
['not installed', fakeInstall('0.0.0'), null],
['another version', fakeInstall('0.28.1', 'x'), 'absent'],
['the pinned version, another binary', fakeInstall(ESBUILD, 'not the esbuild binary'), 'refused'],
['the pinned version, no binary', fakeInstall(ESBUILD), 'refused'],
]) {
cases.push(install.dir);
if (label === 'not installed') rmSync(join(install.dir, 'node_modules'), { recursive: true });
const tool = toolchain(install.from);
assert.equal(tool.ok, false, label);
assert.equal(tool.kind, kind ?? 'absent', label);
assert.ok(tool.reason.length > 20, `${label}: the reason is printed`);
assert.equal(verdict(tool, false), kind === 'refused' ? 'fail' : 'skip', `${label} without the switch`);
assert.equal(verdict(tool, true), 'fail', `${label} with the switch`);
}
// The switch itself: "1" requires; unset, empty and "0" do not; anything else throws.
assert.equal(required({}), false); assert.equal(required({ MOSAIC_REQUIRE_EDITOR_BUILD: '' }), false); assert.equal(required({ MOSAIC_REQUIRE_EDITOR_BUILD: '0' }), false);
assert.equal(required({ MOSAIC_REQUIRE_EDITOR_BUILD: '1' }), true);
for (const v of ['true', 'yes', ' 1', '2']) assert.throws(() => required({ MOSAIC_REQUIRE_EDITOR_BUILD: v }), /MOSAIC_REQUIRE_EDITOR_BUILD/);
// ESBUILD_BINARY_PATH replaces the binary esbuild runs, so it is refused whatever the install holds.
const fake = fakeInstall(ESBUILD, 'x'); cases.push(fake.dir);
process.env.ESBUILD_BINARY_PATH = '/bin/true';
try { assert.equal(toolchain(fake.from).kind, 'refused'); } finally { delete process.env.ESBUILD_BINARY_PATH; }
} finally { for (const dir of cases) rmSync(dir, { recursive: true, force: true }); }
});
test('the toolchain check, run as a program, exits non-zero without esbuild when the build is required', () => {
// A copy of the check in a directory with no esbuild above it, so it cannot find one.
const dir = mkdtempSync(join(tmpdir(), 'editor-toolchain-cli-'));
try {
const copy = join(dir, 'toolchain.mjs');
copyFileSync(join(webui, 'editor/toolchain.mjs'), copy);
const run = switchValue => {
const env = { ...process.env }; delete env.MOSAIC_REQUIRE_EDITOR_BUILD; delete env.NODE_PATH; delete env.ESBUILD_BINARY_PATH;
if (switchValue !== undefined) env.MOSAIC_REQUIRE_EDITOR_BUILD = switchValue;
return spawnSync(process.execPath, [copy], { encoding: 'utf8', env, cwd: dir });
};
const routine = run(); assert.equal(routine.status, 0, routine.stderr); assert.match(routine.stdout, /^skip: esbuild /);
assert.equal(run('0').status, 0);
const required = run('1'); assert.equal(required.status, 3); assert.match(required.stderr, /^missing: esbuild /); assert.equal(required.stdout, '');
assert.notEqual(run('true').status, 0, 'a mistyped value is a failure, not a silent skip');
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test('rebuilding from the source gives the same bytes (needs the pinned esbuild; MOSAIC_REQUIRE_EDITOR_BUILD=1 makes its absence a failure)', async t => {
const { toolchain, required, verdict } = await import('../editor/toolchain.mjs');
const tool = toolchain(), action = verdict(tool, required());
if (action === 'skip') { t.skip(`${tool.reason} (set MOSAIC_REQUIRE_EDITOR_BUILD=1 to fail instead of skip)`); return; }
assert.equal(action, 'run', tool.reason);
const { run } = await import('../editor/build.mjs');
const out = mkdtempSync(join(tmpdir(), 'editor-rebuild-'));
try {
const info = await run(out, join(out, 'BUILD.json'));
assert.equal(info.esbuild, build.esbuild);
assert.deepEqual(info.inputs, build.inputs);
for (const path of Object.keys(build.outputs)) assert.equal(sha(readFileSync(join(out, path.split('/').pop()))), build.outputs[path].sha256, `${path} rebuilt`);
assert.equal(readFileSync(join(out, 'BUILD.json'), 'utf8'), readFileSync(join(webui, 'editor/BUILD.json'), 'utf8'));
} finally { rmSync(out, { recursive: true, force: true }); }
});
+75
View File
@@ -0,0 +1,75 @@
// Row 154 (#1648, Q168): the Console's visual editor, the parts that need no browser. The routes, the
// round trip (open and save with no edit writes the same bytes), and the rules the editor source keeps.
// The imported source and the build are editor-provenance.test.mjs's; the page is editor-browser.test.mjs's.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, readdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadEditor } from './helpers/editor-bundle.mjs';
import { EVERY } from './helpers/every-element.mjs';
import { fixture } from './fixture.mjs';
const webui = join(dirname(fileURLToPath(import.meta.url)), '..');
const repo = join(webui, '../..');
const editor = loadEditor();
test('the editor routes serve the built files with their types and no cache', async t => {
const f = await fixture();
t.after(() => f.close());
for (const [path, type] of [['/editor.js', 'text/javascript'], ['/editor.css', 'text/css']]) {
const response = await fetch(f.base + path);
assert.equal(response.status, 200, path);
assert.ok(response.headers.get('content-type').startsWith(type), path);
assert.equal(response.headers.get('cache-control'), 'no-store', path);
assert.equal(await response.text(), readFileSync(join(webui, 'src/public', path), 'utf8'), `${path} is the built file`);
}
const index = await (await fetch(f.base + '/')).text();
assert.match(index, /<link rel="stylesheet" href="\/editor\.css">/);
assert.ok(index.indexOf('src="/editor.js"') < index.indexOf('src="/bus.js"'), 'the editor loads before the page script, so bus.js can mount it');
});
test('open and save with no edit writes the same bytes: the goals review and a doc with every element', () => {
const goals = readFileSync(join(repo, 'docs/plans/2026-09-27_goals-review.md'), 'utf8');
assert.equal(editor.roundTrip(goals), goals);
assert.equal(editor.roundTrip(EVERY), EVERY);
// the same with the other line ending conventions a file can end in
for (const text of ['', '\n', '\n\n\n', 'no newline', '# T\n', '# T\n\n\n\ntext\n\n\n', '\n\n indented first line\n']) assert.equal(editor.roundTrip(text), text, JSON.stringify(text));
});
test('open and save with no edit writes the same bytes: every repository doc under docs/ and spec/', () => {
const found = [];
const walk = dir => {
for (const e of readdirSync(dir, { withFileTypes: true })) {
if (e.isDirectory()) { if (!['node_modules', '.git', 'v1'].includes(e.name)) walk(join(dir, e.name)); }
else if (e.name.endsWith('.md')) found.push(join(dir, e.name));
}
};
for (const d of ['docs', 'spec']) { try { walk(join(repo, d)); } catch (e) { if (e.code !== 'ENOENT') throw e; } }
assert.ok(found.length > 20, `found ${found.length} docs`);
const changed = [];
for (const file of found) {
const text = readFileSync(file, 'utf8').replace(/\r\n/g, '\n'); // CRLF is saved as LF, as row 148's save does
if (editor.roundTrip(text) !== text) changed.push(file.slice(repo.length + 1));
}
assert.deepEqual(changed, [], 'files the editor would change on an open and save with no edit');
});
test('the editor source builds markup only with DOM calls, and the bundle has no eval', () => {
const sinks = /\binnerHTML\b|\bouterHTML\b|\binsertAdjacentHTML\b|\bdocument\.write\b|\beval\s*\(|\bnew\s+Function\b|\bsrcdoc\b|\bcreateContextualFragment\b|\bDOMParser\b/;
for (const file of ['entry.mjs', 'model.mjs', 'view.mjs']) {
const source = readFileSync(join(webui, 'editor', file), 'utf8').split('\n').filter(l => !l.trim().startsWith('//')).join('\n');
assert.doesNotMatch(source, sinks, `editor/${file}`);
}
const bundle = readFileSync(join(webui, 'src/public/editor.js'), 'utf8');
assert.doesNotMatch(bundle, /\beval\s*\(|\bnew\s+Function\b/, 'src/public/editor.js');
});
test('the link gate and the image rule are the preview\'s', () => {
const model = readFileSync(join(webui, 'editor/model.mjs'), 'utf8');
assert.match(model, /globalThis\.MosaicMarkdown\?\.linkTarget/, 'the link target goes through the one gate');
assert.doesNotMatch(model, /tag:\s*'img/, 'no schema rule makes an <img>');
// a file's link the gate refuses stays source text, byte for byte, on a round trip and an edit-free save
const hostile = '[a](javascript:alert(1)) [b](data:text/html,x) [c](vbscript:x) [d](//evil.example/x) [e](https://ok.example/)\n';
assert.equal(editor.roundTrip(hostile), hostile);
});
@@ -0,0 +1,17 @@
// Loads src/public/editor.js (and markdown-preview.js, the link gate it needs) into a vm context with a
// bare `window`, so model-level tests can call MosaicEditor.roundTrip without a browser.
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import vm from 'node:vm';
const pub = join(dirname(fileURLToPath(import.meta.url)), '../../src/public');
export function loadEditor() {
const sandbox = { console };
sandbox.window = sandbox;
sandbox.globalThis = sandbox;
vm.createContext(sandbox);
for (const f of ['markdown-preview.js', 'editor.js']) vm.runInContext(readFileSync(join(pub, f), 'utf8'), sandbox, { filename: f });
return sandbox.MosaicEditor;
}
@@ -0,0 +1,30 @@
// Every element row 148's preview draws, and the ones it shows as source text.
export const EVERY = [
'---', 'title: front matter', '---', '',
'# One', '', '## Two', '', '### Three', '', '#### Four', '', '##### Five', '', '###### Six', '',
'Setext one', '==========', '',
'Plain paragraph with *emphasis*, _emphasis_, **strong**, ***both***, `code`, a [link](https://example.com/a "title"),',
'a [relative](../docs/x.md), a [fragment](#top), and a hard break\\', 'on the next line.', '',
'> A quote', '> with two lines', '>', '> > and a nested quote', '',
'- one', '- two', ' - nested', ' - nested too', '- three', '',
'1. first', '2. second', ' 1. inner', '3. third', '',
'* star list', '* second', '',
'+ plus list', '',
'```js', 'const x = "<b>not bold</b>";', '```', '',
'~~~', 'tilde fence', '~~~', '',
' indented code', '',
'---', '',
'***', '',
'| a | b |', '|---|---|', '| 1 | 2 |', '',
'<div class="x">raw html</div>', '',
'<script>window.injected=1</script>', '',
'<img src=x onerror="window.injected=2">', '',
'![alt text](https://tracker.example/p.png "t")', '',
'[click](javascript:window.injected=3) [data](data:text/html,<script>window.injected=4</script>)', '',
'~~struck~~ text', '',
'[ref]: https://example.com/ref', '',
'Text with a [reference link][ref] and an autolink <https://example.com/auto>.', '',
'\\# not a heading and \\* not a list and a literal \\<tag\\> and &amp; entity', '',
'',
'two blank lines above, no newline at end',
].join('\n');
+1 -1
View File
@@ -33,7 +33,7 @@ test('real board fixture passes through WebUI; assets and isolated seen/reply wo
const reply = await post(f.base, '/api/reply', { agent: 'proj/agent1', text: 'fixture reply' });
assert.equal((await reply.json()).delivered, true); assert.equal(f.captures.length, 1);
assert.match(f.captures[0].args.at(-1), /^fixture reply\n\(control-board:/);
for (const path of ['/', '/app.js', '/brand.js', '/console.css', '/live.css', '/shared/app.css', '/assets/fonts/manrope-400.woff2']) {
for (const path of ['/', '/app.js', '/brand.js', '/console.css', '/live.css', '/editor.js', '/editor.css', '/shared/app.css', '/assets/fonts/manrope-400.woff2']) {
const response = await fetch(f.base + path); assert.equal(response.status, 200, path); assert.equal(response.headers.get('cache-control'), 'no-store');
}
// #1542: the shell's tokens, sprite, stylesheet and mono fonts, each with its type.
+102
View File
@@ -0,0 +1,102 @@
Third-party source used by the Console editor (row 154, #1648, Q168).
The source under this directory is unmodified upstream source, imported from tagged trees by
agents/researcher/work/queue-154/repro/import-sources.mjs and described in PROVENANCE.json. Each package
below keeps its own license file next to its source; the SPDX id is the one its package.json declares.
The editor bundle src/public/editor.js is built from this source by editor/build.mjs.
prosemirror-model 1.25.12
source: https://code.haverbeke.berlin/prosemirror/prosemirror-model at hb-1.25.12 (4211aeeaa3d738454a5d1428a95c1a210e86d787)
license: MIT, prosemirror-model/LICENSE
notice: Copyright (C) 2015-2017 by Marijn Haverbeke <[email protected]> and others
prosemirror-state 1.4.4
source: https://code.haverbeke.berlin/prosemirror/prosemirror-state at hb-1.4.4 (d6fdcd19c4f7f68206b0a8d49649860365672585)
license: MIT, prosemirror-state/LICENSE
notice: Copyright (C) 2015-2017 by Marijn Haverbeke <[email protected]> and others
prosemirror-view 1.42.6
source: https://code.haverbeke.berlin/prosemirror/prosemirror-view at hb-1.42.6 (f5a5b93a78f6f723c1fbf1a2eec1556a022cd0ee)
license: MIT, prosemirror-view/LICENSE
notice: Copyright (C) 2015-2017 by Marijn Haverbeke <[email protected]> and others
prosemirror-transform 1.12.2
source: https://code.haverbeke.berlin/prosemirror/prosemirror-transform at hb-1.12.2 (a93e228a10b76698e0878df2e1f881389e3a94ec)
license: MIT, prosemirror-transform/LICENSE
notice: Copyright (C) 2015-2017 by Marijn Haverbeke <[email protected]> and others
prosemirror-inputrules 1.5.1
source: https://code.haverbeke.berlin/prosemirror/prosemirror-inputrules at hb-1.5.1 (089aa3852a55d551211af90aae2560c554ccf476)
license: MIT, prosemirror-inputrules/LICENSE
notice: Copyright (C) 2015-2017 by Marijn Haverbeke <[email protected]> and others
prosemirror-markdown 1.13.8
source: https://code.haverbeke.berlin/prosemirror/prosemirror-markdown at hb-1.13.8 (e3a5926aa3236af2a463d824f4f22455460b008f)
license: MIT, prosemirror-markdown/LICENSE
notice: Copyright (C) 2015-2017 by Marijn Haverbeke <[email protected]> and others
prosemirror-commands 1.7.2
source: https://code.haverbeke.berlin/prosemirror/prosemirror-commands at hb-1.7.2 (59fee8d89be2d033b28c2bac68e6736e2989bf9d)
license: MIT, prosemirror-commands/LICENSE
notice: Copyright (C) 2015-2017 by Marijn Haverbeke <[email protected]> and others
prosemirror-history 1.5.1
source: https://code.haverbeke.berlin/prosemirror/prosemirror-history at hb-1.5.1 (44956b6a1929d7bc99aca9a8ec01c790260fa45f)
license: MIT, prosemirror-history/LICENSE
notice: Copyright (C) 2015-2017 by Marijn Haverbeke <[email protected]> and others
prosemirror-keymap 1.2.3
source: https://code.haverbeke.berlin/prosemirror/prosemirror-keymap at hb-1.2.3 (081f24f65b6a394174a4bdf07b9a55a00361f1b7)
license: MIT, prosemirror-keymap/LICENSE
notice: Copyright (C) 2015-2017 by Marijn Haverbeke <[email protected]> and others
prosemirror-schema-list 1.5.1
source: https://code.haverbeke.berlin/prosemirror/prosemirror-schema-list at hb-1.5.1 (e65a144f2285178a20cbdea5b0ee16925be7d6c5)
license: MIT, prosemirror-schema-list/LICENSE
notice: Copyright (C) 2015-2017 by Marijn Haverbeke <[email protected]> and others
markdown-it 14.3.3
source: https://github.com/markdown-it/markdown-it at 14.3.3 (d372f0d8a68bef0b0acaa68868ce8ae8ba85b384)
license: MIT, markdown-it/LICENSE
notice: Copyright (c) 2014 Vitaly Puzrin, Alex Kocharin.
linkify-it 5.0.2
source: https://github.com/markdown-it/linkify-it at 5.0.2 (50a0c914f834b201cab25ff4faefd1f832b37332)
license: MIT, linkify-it/LICENSE
notice: Copyright (c) 2015 Vitaly Puzrin.
uc.micro 2.1.0
source: https://github.com/markdown-it/uc.micro at 2.1.0 (d91cec10974f125be7accd4dd870900fd18fdec7)
license: MIT, uc.micro/LICENSE.txt
notice: Copyright Mathias Bynens <https://mathiasbynens.be/>
mdurl 2.1.0
source: https://github.com/markdown-it/mdurl at 2.1.0 (dd913f6290a4d8772240a401aab993eb65c5786d)
license: MIT, mdurl/LICENSE
notice: Copyright (c) 2015 Vitaly Puzrin, Alex Kocharin.
notice: Copyright Joyent, Inc. and other Node contributors. All rights reserved.
punycode.js 2.3.1
source: https://github.com/mathiasbynens/punycode.js at v2.3.1 (9e1b2cda98d215d3a73fcbfe93c62e021f4ba768)
license: MIT, punycode.js/LICENSE-MIT.txt
notice: Copyright Mathias Bynens <https://mathiasbynens.be/>
entities 4.5.0
source: https://github.com/fb55/entities at v4.5.0 (61afd4701eaa736978b13c7351cd3de9a96b04bc)
license: BSD-2-Clause, entities/LICENSE
notice: Copyright (c) Felix Böhm
orderedmap 2.1.1
source: https://github.com/marijnh/orderedmap at 2.1.1 (6a30ebbbecd30fed8bda884c7acf4ca9c6a02405)
license: MIT, orderedmap/LICENSE
notice: Copyright (C) 2016 by Marijn Haverbeke <[email protected]> and others
rope-sequence 1.3.4
source: https://github.com/marijnh/rope-sequence at 1.3.4 (11db1ba5aa2abf31ff8040bdf01fabdb9db34c7a)
license: MIT, rope-sequence/LICENSE
notice: Copyright (C) 2016 by Marijn Haverbeke <[email protected]>
w3c-keyname 2.2.8
source: https://github.com/marijnh/w3c-keyname at 2.2.8 (5ea93b04bb62230c7de4165e3a9b5c840f05cd44)
license: MIT, w3c-keyname/LICENSE
notice: Copyright (C) 2016 by Marijn Haverbeke <[email protected]> and others
File diff suppressed because it is too large. Load diff
+11
View File
@@ -0,0 +1,11 @@
Copyright (c) Felix Böhm
All rights reserved.
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
THIS IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS,
EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+90
View File
@@ -0,0 +1,90 @@
{
"name": "entities",
"version": "4.5.0",
"description": "Encode & decode XML and HTML entities with ease & speed",
"author": "Felix Boehm <[email protected]>",
"funding": "https://github.com/fb55/entities?sponsor=1",
"sideEffects": false,
"keywords": [
"entity",
"decoding",
"encoding",
"html",
"xml",
"html entities"
],
"directories": {
"lib": "lib/"
},
"main": "lib/index.js",
"types": "lib/index.d.ts",
"module": "lib/esm/index.js",
"exports": {
".": {
"require": "./lib/index.js",
"import": "./lib/esm/index.js"
},
"./lib/decode.js": {
"require": "./lib/decode.js",
"import": "./lib/esm/decode.js"
},
"./lib/escape.js": {
"require": "./lib/escape.js",
"import": "./lib/esm/escape.js"
}
},
"files": [
"lib/**/*"
],
"engines": {
"node": ">=0.12"
},
"devDependencies": {
"@types/jest": "^28.1.8",
"@types/node": "^18.15.11",
"@typescript-eslint/eslint-plugin": "^5.58.0",
"@typescript-eslint/parser": "^5.58.0",
"eslint": "^8.38.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-node": "^11.1.0",
"jest": "^28.1.3",
"prettier": "^2.8.7",
"ts-jest": "^28.0.8",
"typedoc": "^0.24.1",
"typescript": "^5.0.4"
},
"scripts": {
"test": "npm run test:jest && npm run lint",
"test:jest": "jest",
"lint": "npm run lint:es && npm run lint:prettier",
"lint:es": "eslint .",
"lint:prettier": "npm run prettier -- --check",
"format": "npm run format:es && npm run format:prettier",
"format:es": "npm run lint:es -- --fix",
"format:prettier": "npm run prettier -- --write",
"prettier": "prettier '**/*.{ts,md,json,yml}'",
"build": "npm run build:cjs && npm run build:esm",
"build:cjs": "tsc --sourceRoot https://raw.githubusercontent.com/fb55/entities/$(git rev-parse HEAD)/src/",
"build:esm": "npm run build:cjs -- --module esnext --target es2019 --outDir lib/esm && echo '{\"type\":\"module\"}' > lib/esm/package.json",
"build:docs": "typedoc --hideGenerator src/index.ts",
"build:trie": "ts-node scripts/write-decode-map.ts",
"build:encode-trie": "ts-node scripts/write-encode-map.ts",
"prepare": "npm run build"
},
"repository": {
"type": "git",
"url": "git://github.com/fb55/entities.git"
},
"license": "BSD-2-Clause",
"jest": {
"preset": "ts-jest",
"coverageProvider": "v8",
"moduleNameMapper": {
"^(.*)\\.js$": "$1"
}
},
"prettier": {
"tabWidth": 4,
"proseWrap": "always"
}
}
+614
View File
@@ -0,0 +1,614 @@
import htmlDecodeTree from "./generated/decode-data-html.js";
import xmlDecodeTree from "./generated/decode-data-xml.js";
import decodeCodePoint, {
replaceCodePoint,
fromCodePoint,
} from "./decode_codepoint.js";
// Re-export for use by eg. htmlparser2
export { htmlDecodeTree, xmlDecodeTree, decodeCodePoint };
export { replaceCodePoint, fromCodePoint } from "./decode_codepoint.js";
const enum CharCodes {
NUM = 35, // "#"
SEMI = 59, // ";"
EQUALS = 61, // "="
ZERO = 48, // "0"
NINE = 57, // "9"
LOWER_A = 97, // "a"
LOWER_F = 102, // "f"
LOWER_X = 120, // "x"
LOWER_Z = 122, // "z"
UPPER_A = 65, // "A"
UPPER_F = 70, // "F"
UPPER_Z = 90, // "Z"
}
/** Bit that needs to be set to convert an upper case ASCII character to lower case */
const TO_LOWER_BIT = 0b100000;
export enum BinTrieFlags {
VALUE_LENGTH = 0b1100_0000_0000_0000,
BRANCH_LENGTH = 0b0011_1111_1000_0000,
JUMP_TABLE = 0b0000_0000_0111_1111,
}
function isNumber(code: number): boolean {
return code >= CharCodes.ZERO && code <= CharCodes.NINE;
}
function isHexadecimalCharacter(code: number): boolean {
return (
(code >= CharCodes.UPPER_A && code <= CharCodes.UPPER_F) ||
(code >= CharCodes.LOWER_A && code <= CharCodes.LOWER_F)
);
}
function isAsciiAlphaNumeric(code: number): boolean {
return (
(code >= CharCodes.UPPER_A && code <= CharCodes.UPPER_Z) ||
(code >= CharCodes.LOWER_A && code <= CharCodes.LOWER_Z) ||
isNumber(code)
);
}
/**
* Checks if the given character is a valid end character for an entity in an attribute.
*
* Attribute values that aren't terminated properly aren't parsed, and shouldn't lead to a parser error.
* See the example in https://html.spec.whatwg.org/multipage/parsing.html#named-character-reference-state
*/
function isEntityInAttributeInvalidEnd(code: number): boolean {
return code === CharCodes.EQUALS || isAsciiAlphaNumeric(code);
}
const enum EntityDecoderState {
EntityStart,
NumericStart,
NumericDecimal,
NumericHex,
NamedEntity,
}
export enum DecodingMode {
/** Entities in text nodes that can end with any character. */
Legacy = 0,
/** Only allow entities terminated with a semicolon. */
Strict = 1,
/** Entities in attributes have limitations on ending characters. */
Attribute = 2,
}
/**
* Producers for character reference errors as defined in the HTML spec.
*/
export interface EntityErrorProducer {
missingSemicolonAfterCharacterReference(): void;
absenceOfDigitsInNumericCharacterReference(
consumedCharacters: number
): void;
validateNumericCharacterReference(code: number): void;
}
/**
* Token decoder with support of writing partial entities.
*/
export class EntityDecoder {
constructor(
/** The tree used to decode entities. */
private readonly decodeTree: Uint16Array,
/**
* The function that is called when a codepoint is decoded.
*
* For multi-byte named entities, this will be called multiple times,
* with the second codepoint, and the same `consumed` value.
*
* @param codepoint The decoded codepoint.
* @param consumed The number of bytes consumed by the decoder.
*/
private readonly emitCodePoint: (cp: number, consumed: number) => void,
/** An object that is used to produce errors. */
private readonly errors?: EntityErrorProducer
) {}
/** The current state of the decoder. */
private state = EntityDecoderState.EntityStart;
/** Characters that were consumed while parsing an entity. */
private consumed = 1;
/**
* The result of the entity.
*
* Either the result index of a numeric entity, or the codepoint of a
* numeric entity.
*/
private result = 0;
/** The current index in the decode tree. */
private treeIndex = 0;
/** The number of characters that were consumed in excess. */
private excess = 1;
/** The mode in which the decoder is operating. */
private decodeMode = DecodingMode.Strict;
/** Resets the instance to make it reusable. */
startEntity(decodeMode: DecodingMode): void {
this.decodeMode = decodeMode;
this.state = EntityDecoderState.EntityStart;
this.result = 0;
this.treeIndex = 0;
this.excess = 1;
this.consumed = 1;
}
/**
* Write an entity to the decoder. This can be called multiple times with partial entities.
* If the entity is incomplete, the decoder will return -1.
*
* Mirrors the implementation of `getDecoder`, but with the ability to stop decoding if the
* entity is incomplete, and resume when the next string is written.
*
* @param string The string containing the entity (or a continuation of the entity).
* @param offset The offset at which the entity begins. Should be 0 if this is not the first call.
* @returns The number of characters that were consumed, or -1 if the entity is incomplete.
*/
write(str: string, offset: number): number {
switch (this.state) {
case EntityDecoderState.EntityStart: {
if (str.charCodeAt(offset) === CharCodes.NUM) {
this.state = EntityDecoderState.NumericStart;
this.consumed += 1;
return this.stateNumericStart(str, offset + 1);
}
this.state = EntityDecoderState.NamedEntity;
return this.stateNamedEntity(str, offset);
}
case EntityDecoderState.NumericStart: {
return this.stateNumericStart(str, offset);
}
case EntityDecoderState.NumericDecimal: {
return this.stateNumericDecimal(str, offset);
}
case EntityDecoderState.NumericHex: {
return this.stateNumericHex(str, offset);
}
case EntityDecoderState.NamedEntity: {
return this.stateNamedEntity(str, offset);
}
}
}
/**
* Switches between the numeric decimal and hexadecimal states.
*
* Equivalent to the `Numeric character reference state` in the HTML spec.
*
* @param str The string containing the entity (or a continuation of the entity).
* @param offset The current offset.
* @returns The number of characters that were consumed, or -1 if the entity is incomplete.
*/
private stateNumericStart(str: string, offset: number): number {
if (offset >= str.length) {
return -1;
}
if ((str.charCodeAt(offset) | TO_LOWER_BIT) === CharCodes.LOWER_X) {
this.state = EntityDecoderState.NumericHex;
this.consumed += 1;
return this.stateNumericHex(str, offset + 1);
}
this.state = EntityDecoderState.NumericDecimal;
return this.stateNumericDecimal(str, offset);
}
private addToNumericResult(
str: string,
start: number,
end: number,
base: number
): void {
if (start !== end) {
const digitCount = end - start;
this.result =
this.result * Math.pow(base, digitCount) +
parseInt(str.substr(start, digitCount), base);
this.consumed += digitCount;
}
}
/**
* Parses a hexadecimal numeric entity.
*
* Equivalent to the `Hexademical character reference state` in the HTML spec.
*
* @param str The string containing the entity (or a continuation of the entity).
* @param offset The current offset.
* @returns The number of characters that were consumed, or -1 if the entity is incomplete.
*/
private stateNumericHex(str: string, offset: number): number {
const startIdx = offset;
while (offset < str.length) {
const char = str.charCodeAt(offset);
if (isNumber(char) || isHexadecimalCharacter(char)) {
offset += 1;
} else {
this.addToNumericResult(str, startIdx, offset, 16);
return this.emitNumericEntity(char, 3);
}
}
this.addToNumericResult(str, startIdx, offset, 16);
return -1;
}
/**
* Parses a decimal numeric entity.
*
* Equivalent to the `Decimal character reference state` in the HTML spec.
*
* @param str The string containing the entity (or a continuation of the entity).
* @param offset The current offset.
* @returns The number of characters that were consumed, or -1 if the entity is incomplete.
*/
private stateNumericDecimal(str: string, offset: number): number {
const startIdx = offset;
while (offset < str.length) {
const char = str.charCodeAt(offset);
if (isNumber(char)) {
offset += 1;
} else {
this.addToNumericResult(str, startIdx, offset, 10);
return this.emitNumericEntity(char, 2);
}
}
this.addToNumericResult(str, startIdx, offset, 10);
return -1;
}
/**
* Validate and emit a numeric entity.
*
* Implements the logic from the `Hexademical character reference start
* state` and `Numeric character reference end state` in the HTML spec.
*
* @param lastCp The last code point of the entity. Used to see if the
* entity was terminated with a semicolon.
* @param expectedLength The minimum number of characters that should be
* consumed. Used to validate that at least one digit
* was consumed.
* @returns The number of characters that were consumed.
*/
private emitNumericEntity(lastCp: number, expectedLength: number): number {
// Ensure we consumed at least one digit.
if (this.consumed <= expectedLength) {
this.errors?.absenceOfDigitsInNumericCharacterReference(
this.consumed
);
return 0;
}
// Figure out if this is a legit end of the entity
if (lastCp === CharCodes.SEMI) {
this.consumed += 1;
} else if (this.decodeMode === DecodingMode.Strict) {
return 0;
}
this.emitCodePoint(replaceCodePoint(this.result), this.consumed);
if (this.errors) {
if (lastCp !== CharCodes.SEMI) {
this.errors.missingSemicolonAfterCharacterReference();
}
this.errors.validateNumericCharacterReference(this.result);
}
return this.consumed;
}
/**
* Parses a named entity.
*
* Equivalent to the `Named character reference state` in the HTML spec.
*
* @param str The string containing the entity (or a continuation of the entity).
* @param offset The current offset.
* @returns The number of characters that were consumed, or -1 if the entity is incomplete.
*/
private stateNamedEntity(str: string, offset: number): number {
const { decodeTree } = this;
let current = decodeTree[this.treeIndex];
// The mask is the number of bytes of the value, including the current byte.
let valueLength = (current & BinTrieFlags.VALUE_LENGTH) >> 14;
for (; offset < str.length; offset++, this.excess++) {
const char = str.charCodeAt(offset);
this.treeIndex = determineBranch(
decodeTree,
current,
this.treeIndex + Math.max(1, valueLength),
char
);
if (this.treeIndex < 0) {
return this.result === 0 ||
// If we are parsing an attribute
(this.decodeMode === DecodingMode.Attribute &&
// We shouldn't have consumed any characters after the entity,
(valueLength === 0 ||
// And there should be no invalid characters.
isEntityInAttributeInvalidEnd(char)))
? 0
: this.emitNotTerminatedNamedEntity();
}
current = decodeTree[this.treeIndex];
valueLength = (current & BinTrieFlags.VALUE_LENGTH) >> 14;
// If the branch is a value, store it and continue
if (valueLength !== 0) {
// If the entity is terminated by a semicolon, we are done.
if (char === CharCodes.SEMI) {
return this.emitNamedEntityData(
this.treeIndex,
valueLength,
this.consumed + this.excess
);
}
// If we encounter a non-terminated (legacy) entity while parsing strictly, then ignore it.
if (this.decodeMode !== DecodingMode.Strict) {
this.result = this.treeIndex;
this.consumed += this.excess;
this.excess = 0;
}
}
}
return -1;
}
/**
* Emit a named entity that was not terminated with a semicolon.
*
* @returns The number of characters consumed.
*/
private emitNotTerminatedNamedEntity(): number {
const { result, decodeTree } = this;
const valueLength =
(decodeTree[result] & BinTrieFlags.VALUE_LENGTH) >> 14;
this.emitNamedEntityData(result, valueLength, this.consumed);
this.errors?.missingSemicolonAfterCharacterReference();
return this.consumed;
}
/**
* Emit a named entity.
*
* @param result The index of the entity in the decode tree.
* @param valueLength The number of bytes in the entity.
* @param consumed The number of characters consumed.
*
* @returns The number of characters consumed.
*/
private emitNamedEntityData(
result: number,
valueLength: number,
consumed: number
): number {
const { decodeTree } = this;
this.emitCodePoint(
valueLength === 1
? decodeTree[result] & ~BinTrieFlags.VALUE_LENGTH
: decodeTree[result + 1],
consumed
);
if (valueLength === 3) {
// For multi-byte values, we need to emit the second byte.
this.emitCodePoint(decodeTree[result + 2], consumed);
}
return consumed;
}
/**
* Signal to the parser that the end of the input was reached.
*
* Remaining data will be emitted and relevant errors will be produced.
*
* @returns The number of characters consumed.
*/
end(): number {
switch (this.state) {
case EntityDecoderState.NamedEntity: {
// Emit a named entity if we have one.
return this.result !== 0 &&
(this.decodeMode !== DecodingMode.Attribute ||
this.result === this.treeIndex)
? this.emitNotTerminatedNamedEntity()
: 0;
}
// Otherwise, emit a numeric entity if we have one.
case EntityDecoderState.NumericDecimal: {
return this.emitNumericEntity(0, 2);
}
case EntityDecoderState.NumericHex: {
return this.emitNumericEntity(0, 3);
}
case EntityDecoderState.NumericStart: {
this.errors?.absenceOfDigitsInNumericCharacterReference(
this.consumed
);
return 0;
}
case EntityDecoderState.EntityStart: {
// Return 0 if we have no entity.
return 0;
}
}
}
}
/**
* Creates a function that decodes entities in a string.
*
* @param decodeTree The decode tree.
* @returns A function that decodes entities in a string.
*/
function getDecoder(decodeTree: Uint16Array) {
let ret = "";
const decoder = new EntityDecoder(
decodeTree,
(str) => (ret += fromCodePoint(str))
);
return function decodeWithTrie(
str: string,
decodeMode: DecodingMode
): string {
let lastIndex = 0;
let offset = 0;
while ((offset = str.indexOf("&", offset)) >= 0) {
ret += str.slice(lastIndex, offset);
decoder.startEntity(decodeMode);
const len = decoder.write(
str,
// Skip the "&"
offset + 1
);
if (len < 0) {
lastIndex = offset + decoder.end();
break;
}
lastIndex = offset + len;
// If `len` is 0, skip the current `&` and continue.
offset = len === 0 ? lastIndex + 1 : lastIndex;
}
const result = ret + str.slice(lastIndex);
// Make sure we don't keep a reference to the final string.
ret = "";
return result;
};
}
/**
* Determines the branch of the current node that is taken given the current
* character. This function is used to traverse the trie.
*
* @param decodeTree The trie.
* @param current The current node.
* @param nodeIdx The index right after the current node and its value.
* @param char The current character.
* @returns The index of the next node, or -1 if no branch is taken.
*/
export function determineBranch(
decodeTree: Uint16Array,
current: number,
nodeIdx: number,
char: number
): number {
const branchCount = (current & BinTrieFlags.BRANCH_LENGTH) >> 7;
const jumpOffset = current & BinTrieFlags.JUMP_TABLE;
// Case 1: Single branch encoded in jump offset
if (branchCount === 0) {
return jumpOffset !== 0 && char === jumpOffset ? nodeIdx : -1;
}
// Case 2: Multiple branches encoded in jump table
if (jumpOffset) {
const value = char - jumpOffset;
return value < 0 || value >= branchCount
? -1
: decodeTree[nodeIdx + value] - 1;
}
// Case 3: Multiple branches encoded in dictionary
// Binary search for the character.
let lo = nodeIdx;
let hi = lo + branchCount - 1;
while (lo <= hi) {
const mid = (lo + hi) >>> 1;
const midVal = decodeTree[mid];
if (midVal < char) {
lo = mid + 1;
} else if (midVal > char) {
hi = mid - 1;
} else {
return decodeTree[mid + branchCount];
}
}
return -1;
}
const htmlDecoder = getDecoder(htmlDecodeTree);
const xmlDecoder = getDecoder(xmlDecodeTree);
/**
* Decodes an HTML string.
*
* @param str The string to decode.
* @param mode The decoding mode.
* @returns The decoded string.
*/
export function decodeHTML(str: string, mode = DecodingMode.Legacy): string {
return htmlDecoder(str, mode);
}
/**
* Decodes an HTML string in an attribute.
*
* @param str The string to decode.
* @returns The decoded string.
*/
export function decodeHTMLAttribute(str: string): string {
return htmlDecoder(str, DecodingMode.Attribute);
}
/**
* Decodes an HTML string, requiring all entities to be terminated by a semicolon.
*
* @param str The string to decode.
* @returns The decoded string.
*/
export function decodeHTMLStrict(str: string): string {
return htmlDecoder(str, DecodingMode.Strict);
}
/**
* Decodes an XML string, requiring all entities to be terminated by a semicolon.
*
* @param str The string to decode.
* @returns The decoded string.
*/
export function decodeXML(str: string): string {
return xmlDecoder(str, DecodingMode.Strict);
}
@@ -0,0 +1,78 @@
// Adapted from https://github.com/mathiasbynens/he/blob/36afe179392226cf1b6ccdb16ebbb7a5a844d93a/src/he.js#L106-L134
const decodeMap = new Map([
[0, 65533],
// C1 Unicode control character reference replacements
[128, 8364],
[130, 8218],
[131, 402],
[132, 8222],
[133, 8230],
[134, 8224],
[135, 8225],
[136, 710],
[137, 8240],
[138, 352],
[139, 8249],
[140, 338],
[142, 381],
[145, 8216],
[146, 8217],
[147, 8220],
[148, 8221],
[149, 8226],
[150, 8211],
[151, 8212],
[152, 732],
[153, 8482],
[154, 353],
[155, 8250],
[156, 339],
[158, 382],
[159, 376],
]);
/**
* Polyfill for `String.fromCodePoint`. It is used to create a string from a Unicode code point.
*/
export const fromCodePoint =
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition, node/no-unsupported-features/es-builtins
String.fromCodePoint ??
function (codePoint: number): string {
let output = "";
if (codePoint > 0xffff) {
codePoint -= 0x10000;
output += String.fromCharCode(
((codePoint >>> 10) & 0x3ff) | 0xd800
);
codePoint = 0xdc00 | (codePoint & 0x3ff);
}
output += String.fromCharCode(codePoint);
return output;
};
/**
* Replace the given code point with a replacement character if it is a
* surrogate or is outside the valid range. Otherwise return the code
* point unchanged.
*/
export function replaceCodePoint(codePoint: number) {
if ((codePoint >= 0xd800 && codePoint <= 0xdfff) || codePoint > 0x10ffff) {
return 0xfffd;
}
return decodeMap.get(codePoint) ?? codePoint;
}
/**
* Replace the code point if relevant, then convert it to a string.
*
* @deprecated Use `fromCodePoint(replaceCodePoint(codePoint))` instead.
* @param codePoint The code point to decode.
* @returns The decoded code point.
*/
export default function decodeCodePoint(codePoint: number): string {
return fromCodePoint(replaceCodePoint(codePoint));
}
@@ -0,0 +1,77 @@
import htmlTrie from "./generated/encode-html.js";
import { xmlReplacer, getCodePoint } from "./escape.js";
const htmlReplacer = /[\t\n!-,./:-@[-`\f{-}$\x80-\uFFFF]/g;
/**
* Encodes all characters in the input using HTML entities. This includes
* characters that are valid ASCII characters in HTML documents, such as `#`.
*
* To get a more compact output, consider using the `encodeNonAsciiHTML`
* function, which will only encode characters that are not valid in HTML
* documents, as well as non-ASCII characters.
*
* If a character has no equivalent entity, a numeric hexadecimal reference
* (eg. `&#xfc;`) will be used.
*/
export function encodeHTML(data: string): string {
return encodeHTMLTrieRe(htmlReplacer, data);
}
/**
* Encodes all non-ASCII characters, as well as characters not valid in HTML
* documents using HTML entities. This function will not encode characters that
* are valid in HTML documents, such as `#`.
*
* If a character has no equivalent entity, a numeric hexadecimal reference
* (eg. `&#xfc;`) will be used.
*/
export function encodeNonAsciiHTML(data: string): string {
return encodeHTMLTrieRe(xmlReplacer, data);
}
function encodeHTMLTrieRe(regExp: RegExp, str: string): string {
let ret = "";
let lastIdx = 0;
let match;
while ((match = regExp.exec(str)) !== null) {
const i = match.index;
ret += str.substring(lastIdx, i);
const char = str.charCodeAt(i);
let next = htmlTrie.get(char);
if (typeof next === "object") {
// We are in a branch. Try to match the next char.
if (i + 1 < str.length) {
const nextChar = str.charCodeAt(i + 1);
const value =
typeof next.n === "number"
? next.n === nextChar
? next.o
: undefined
: next.n.get(nextChar);
if (value !== undefined) {
ret += value;
lastIdx = regExp.lastIndex += 1;
continue;
}
}
next = next.v;
}
// We might have a tree node without a value; skip and use a numeric entity.
if (next !== undefined) {
ret += next;
lastIdx = i + 1;
} else {
const cp = getCodePoint(str, i);
ret += `&#x${cp.toString(16)};`;
// Increase by 1 if we have a surrogate pair
lastIdx = regExp.lastIndex += Number(cp !== char);
}
}
return ret + str.substr(lastIdx);
}
+144
View File
@@ -0,0 +1,144 @@
export const xmlReplacer = /["&'<>$\x80-\uFFFF]/g;
const xmlCodeMap = new Map([
[34, "&quot;"],
[38, "&amp;"],
[39, "&apos;"],
[60, "&lt;"],
[62, "&gt;"],
]);
// For compatibility with node < 4, we wrap `codePointAt`
export const getCodePoint =
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
String.prototype.codePointAt != null
? (str: string, index: number): number => str.codePointAt(index)!
: // http://mathiasbynens.be/notes/javascript-encoding#surrogate-formulae
(c: string, index: number): number =>
(c.charCodeAt(index) & 0xfc00) === 0xd800
? (c.charCodeAt(index) - 0xd800) * 0x400 +
c.charCodeAt(index + 1) -
0xdc00 +
0x10000
: c.charCodeAt(index);
/**
* Encodes all non-ASCII characters, as well as characters not valid in XML
* documents using XML entities.
*
* If a character has no equivalent entity, a
* numeric hexadecimal reference (eg. `&#xfc;`) will be used.
*/
export function encodeXML(str: string): string {
let ret = "";
let lastIdx = 0;
let match;
while ((match = xmlReplacer.exec(str)) !== null) {
const i = match.index;
const char = str.charCodeAt(i);
const next = xmlCodeMap.get(char);
if (next !== undefined) {
ret += str.substring(lastIdx, i) + next;
lastIdx = i + 1;
} else {
ret += `${str.substring(lastIdx, i)}&#x${getCodePoint(
str,
i
).toString(16)};`;
// Increase by 1 if we have a surrogate pair
lastIdx = xmlReplacer.lastIndex += Number(
(char & 0xfc00) === 0xd800
);
}
}
return ret + str.substr(lastIdx);
}
/**
* Encodes all non-ASCII characters, as well as characters not valid in XML
* documents using numeric hexadecimal reference (eg. `&#xfc;`).
*
* Have a look at `escapeUTF8` if you want a more concise output at the expense
* of reduced transportability.
*
* @param data String to escape.
*/
export const escape = encodeXML;
/**
* Creates a function that escapes all characters matched by the given regular
* expression using the given map of characters to escape to their entities.
*
* @param regex Regular expression to match characters to escape.
* @param map Map of characters to escape to their entities.
*
* @returns Function that escapes all characters matched by the given regular
* expression using the given map of characters to escape to their entities.
*/
function getEscaper(
regex: RegExp,
map: Map<number, string>
): (data: string) => string {
return function escape(data: string): string {
let match;
let lastIdx = 0;
let result = "";
while ((match = regex.exec(data))) {
if (lastIdx !== match.index) {
result += data.substring(lastIdx, match.index);
}
// We know that this character will be in the map.
result += map.get(match[0].charCodeAt(0))!;
// Every match will be of length 1
lastIdx = match.index + 1;
}
return result + data.substring(lastIdx);
};
}
/**
* Encodes all characters not valid in XML documents using XML entities.
*
* Note that the output will be character-set dependent.
*
* @param data String to escape.
*/
export const escapeUTF8 = getEscaper(/[&<>'"]/g, xmlCodeMap);
/**
* Encodes all characters that have to be escaped in HTML attributes,
* following {@link https://html.spec.whatwg.org/multipage/parsing.html#escapingString}.
*
* @param data String to escape.
*/
export const escapeAttribute = getEscaper(
/["&\u00A0]/g,
new Map([
[34, "&quot;"],
[38, "&amp;"],
[160, "&nbsp;"],
])
);
/**
* Encodes all characters that have to be escaped in HTML text,
* following {@link https://html.spec.whatwg.org/multipage/parsing.html#escapingString}.
*
* @param data String to escape.
*/
export const escapeText = getEscaper(
/[&<>\u00A0]/g,
new Map([
[38, "&amp;"],
[60, "&lt;"],
[62, "&gt;"],
[160, "&nbsp;"],
])
);
@@ -0,0 +1,8 @@
// Generated using scripts/write-decode-map.ts
export default new Uint16Array(
// prettier-ignore
"\u1d41<\xd5\u0131\u028a\u049d\u057b\u05d0\u0675\u06de\u07a2\u07d6\u080f\u0a4a\u0a91\u0da1\u0e6d\u0f09\u0f26\u10ca\u1228\u12e1\u1415\u149d\u14c3\u14df\u1525\0\0\0\0\0\0\u156b\u16cd\u198d\u1c12\u1ddd\u1f7e\u2060\u21b0\u228d\u23c0\u23fb\u2442\u2824\u2912\u2d08\u2e48\u2fce\u3016\u32ba\u3639\u37ac\u38fe\u3a28\u3a71\u3ae0\u3b2e\u0800EMabcfglmnoprstu\\bfms\x7f\x84\x8b\x90\x95\x98\xa6\xb3\xb9\xc8\xcflig\u803b\xc6\u40c6P\u803b&\u4026cute\u803b\xc1\u40c1reve;\u4102\u0100iyx}rc\u803b\xc2\u40c2;\u4410r;\uc000\ud835\udd04rave\u803b\xc0\u40c0pha;\u4391acr;\u4100d;\u6a53\u0100gp\x9d\xa1on;\u4104f;\uc000\ud835\udd38plyFunction;\u6061ing\u803b\xc5\u40c5\u0100cs\xbe\xc3r;\uc000\ud835\udc9cign;\u6254ilde\u803b\xc3\u40c3ml\u803b\xc4\u40c4\u0400aceforsu\xe5\xfb\xfe\u0117\u011c\u0122\u0127\u012a\u0100cr\xea\xf2kslash;\u6216\u0176\xf6\xf8;\u6ae7ed;\u6306y;\u4411\u0180crt\u0105\u010b\u0114ause;\u6235noullis;\u612ca;\u4392r;\uc000\ud835\udd05pf;\uc000\ud835\udd39eve;\u42d8c\xf2\u0113mpeq;\u624e\u0700HOacdefhilorsu\u014d\u0151\u0156\u0180\u019e\u01a2\u01b5\u01b7\u01ba\u01dc\u0215\u0273\u0278\u027ecy;\u4427PY\u803b\xa9\u40a9\u0180cpy\u015d\u0162\u017aute;\u4106\u0100;i\u0167\u0168\u62d2talDifferentialD;\u6145leys;\u612d\u0200aeio\u0189\u018e\u0194\u0198ron;\u410cdil\u803b\xc7\u40c7rc;\u4108nint;\u6230ot;\u410a\u0100dn\u01a7\u01adilla;\u40b8terDot;\u40b7\xf2\u017fi;\u43a7rcle\u0200DMPT\u01c7\u01cb\u01d1\u01d6ot;\u6299inus;\u6296lus;\u6295imes;\u6297o\u0100cs\u01e2\u01f8kwiseContourIntegral;\u6232eCurly\u0100DQ\u0203\u020foubleQuote;\u601duote;\u6019\u0200lnpu\u021e\u0228\u0247\u0255on\u0100;e\u0225\u0226\u6237;\u6a74\u0180git\u022f\u0236\u023aruent;\u6261nt;\u622fourIntegral;\u622e\u0100fr\u024c\u024e;\u6102oduct;\u6210nterClockwiseContourIntegral;\u6233oss;\u6a2fcr;\uc000\ud835\udc9ep\u0100;C\u0284\u0285\u62d3ap;\u624d\u0580DJSZacefios\u02a0\u02ac\u02b0\u02b4\u02b8\u02cb\u02d7\u02e1\u02e6\u0333\u048d\u0100;o\u0179\u02a5trahd;\u6911cy;\u4402cy;\u4405cy;\u440f\u0180grs\u02bf\u02c4\u02c7ger;\u6021r;\u61a1hv;\u6ae4\u0100ay\u02d0\u02d5ron;\u410e;\u4414l\u0100;t\u02dd\u02de\u6207a;\u4394r;\uc000\ud835\udd07\u0100af\u02eb\u0327\u0100cm\u02f0\u0322ritical\u0200ADGT\u0300\u0306\u0316\u031ccute;\u40b4o\u0174\u030b\u030d;\u42d9bleAcute;\u42ddrave;\u4060ilde;\u42dcond;\u62c4ferentialD;\u6146\u0470\u033d\0\0\0\u0342\u0354\0\u0405f;\uc000\ud835\udd3b\u0180;DE\u0348\u0349\u034d\u40a8ot;\u60dcqual;\u6250ble\u0300CDLRUV\u0363\u0372\u0382\u03cf\u03e2\u03f8ontourIntegra\xec\u0239o\u0274\u0379\0\0\u037b\xbb\u0349nArrow;\u61d3\u0100eo\u0387\u03a4ft\u0180ART\u0390\u0396\u03a1rrow;\u61d0ightArrow;\u61d4e\xe5\u02cang\u0100LR\u03ab\u03c4eft\u0100AR\u03b3\u03b9rrow;\u67f8ightArrow;\u67faightArrow;\u67f9ight\u0100AT\u03d8\u03derrow;\u61d2ee;\u62a8p\u0241\u03e9\0\0\u03efrrow;\u61d1ownArrow;\u61d5erticalBar;\u6225n\u0300ABLRTa\u0412\u042a\u0430\u045e\u047f\u037crrow\u0180;BU\u041d\u041e\u0422\u6193ar;\u6913pArrow;\u61f5reve;\u4311eft\u02d2\u043a\0\u0446\0\u0450ightVector;\u6950eeVector;\u695eector\u0100;B\u0459\u045a\u61bdar;\u6956ight\u01d4\u0467\0\u0471eeVector;\u695fector\u0100;B\u047a\u047b\u61c1ar;\u6957ee\u0100;A\u0486\u0487\u62a4rrow;\u61a7\u0100ct\u0492\u0497r;\uc000\ud835\udc9frok;\u4110\u0800NTacdfglmopqstux\u04bd\u04c0\u04c4\u04cb\u04de\u04e2\u04e7\u04ee\u04f5\u0521\u052f\u0536\u0552\u055d\u0560\u0565G;\u414aH\u803b\xd0\u40d0cute\u803b\xc9\u40c9\u0180aiy\u04d2\u04d7\u04dcron;\u411arc\u803b\xca\u40ca;\u442dot;\u4116r;\uc000\ud835\udd08rave\u803b\xc8\u40c8ement;\u6208\u0100ap\u04fa\u04fecr;\u4112ty\u0253\u0506\0\0\u0512mallSquare;\u65fberySmallSquare;\u65ab\u0100gp\u0526\u052aon;\u4118f;\uc000\ud835\udd3csilon;\u4395u\u0100ai\u053c\u0549l\u0100;T\u0542\u0543\u6a75ilde;\u6242librium;\u61cc\u0100ci\u0557\u055ar;\u6130m;\u6a73a;\u4397ml\u803b\xcb\u40cb\u0100ip\u056a\u056fsts;\u6203onentialE;\u6147\u0280cfios\u0585\u0588\u058d\u05b2\u05ccy;\u4424r;\uc000\ud835\udd09lled\u0253\u0597\0\0\u05a3mallSquare;\u65fcerySmallSquare;\u65aa\u0370\u05ba\0\u05bf\0\0\u05c4f;\uc000\ud835\udd3dAll;\u6200riertrf;\u6131c\xf2\u05cb\u0600JTabcdfgorst\u05e8\u05ec\u05ef\u05fa\u0600\u0612\u0616\u061b\u061d\u0623\u066c\u0672cy;\u4403\u803b>\u403emma\u0100;d\u05f7\u05f8\u4393;\u43dcreve;\u411e\u0180eiy\u0607\u060c\u0610dil;\u4122rc;\u411c;\u4413ot;\u4120r;\uc000\ud835\udd0a;\u62d9pf;\uc000\ud835\udd3eeater\u0300EFGLST\u0635\u0644\u064e\u0656\u065b\u0666qual\u0100;L\u063e\u063f\u6265ess;\u62dbullEqual;\u6267reater;\u6aa2ess;\u6277lantEqual;\u6a7eilde;\u6273cr;\uc000\ud835\udca2;\u626b\u0400Aacfiosu\u0685\u068b\u0696\u069b\u069e\u06aa\u06be\u06caRDcy;\u442a\u0100ct\u0690\u0694ek;\u42c7;\u405eirc;\u4124r;\u610clbertSpace;\u610b\u01f0\u06af\0\u06b2f;\u610dizontalLine;\u6500\u0100ct\u06c3\u06c5\xf2\u06a9rok;\u4126mp\u0144\u06d0\u06d8ownHum\xf0\u012fqual;\u624f\u0700EJOacdfgmnostu\u06fa\u06fe\u0703\u0707\u070e\u071a\u071e\u0721\u0728\u0744\u0778\u078b\u078f\u0795cy;\u4415lig;\u4132cy;\u4401cute\u803b\xcd\u40cd\u0100iy\u0713\u0718rc\u803b\xce\u40ce;\u4418ot;\u4130r;\u6111rave\u803b\xLine truncated
.split("")
.map((c) => c.charCodeAt(0))
);
@@ -0,0 +1,8 @@
// Generated using scripts/write-decode-map.ts
export default new Uint16Array(
// prettier-ignore
"\u0200aglq\t\x15\x18\x1b\u026d\x0f\0\0\x12p;\u4026os;\u4027t;\u403et;\u403cuot;\u4022"
.split("")
.map((c) => c.charCodeAt(0))
);
@@ -0,0 +1,17 @@
// Generated using scripts/write-encode-map.ts
type EncodeTrieNode =
| string
| { v?: string; n: number | Map<number, EncodeTrieNode>; o?: string };
function restoreDiff<T extends ReadonlyArray<[number, EncodeTrieNode]>>(
arr: T
): T {
for (let i = 1; i < arr.length; i++) {
arr[i][0] += arr[i - 1][0] + 1;
}
return arr;
}
// prettier-ignore
export default new Map<number,EncodeTrieNode>(/* #__PURE__ */restoreDiff([[9,"&Tab;"],[0,"&NewLine;"],[22,"&excl;"],[0,"&quot;"],[0,"&num;"],[0,"&dollar;"],[0,"&percnt;"],[0,"&amp;"],[0,"&apos;"],[0,"&lpar;"],[0,"&rpar;"],[0,"&ast;"],[0,"&plus;"],[0,"&comma;"],[1,"&period;"],[0,"&sol;"],[10,"&colon;"],[0,"&semi;"],[0,{v:"&lt;",n:8402,o:"&nvlt;"}],[0,{v:"&equals;",n:8421,o:"&bne;"}],[0,{v:"&gt;",n:8402,o:"&nvgt;"}],[0,"&quest;"],[0,"&commat;"],[26,"&lbrack;"],[0,"&bsol;"],[0,"&rbrack;"],[0,"&Hat;"],[0,"&lowbar;"],[0,"&DiacriticalGrave;"],[5,{n:106,o:"&fjlig;"}],[20,"&lbrace;"],[0,"&verbar;"],[0,"&rbrace;"],[34,"&nbsp;"],[0,"&iexcl;"],[0,"&cent;"],[0,"&pound;"],[0,"&curren;"],[0,"&yen;"],[0,"&brvbar;"],[0,"&sect;"],[0,"&die;"],[0,"&copy;"],[0,"&ordf;"],[0,"&laquo;"],[0,"&not;"],[0,"&shy;"],[0,"&circledR;"],[0,"&macr;"],[0,"&deg;"],[0,"&PlusMinus;"],[0,"&sup2;"],[0,"&sup3;"],[0,"&acute;"],[0,"&micro;"],[0,"&para;"],[0,"&centerdot;"],[0,"&cedil;"],[0,"&sup1;"],[0,"&ordm;"],[0,"&raquo;"],[0,"&frac14;"],[0,"&frac12;"],[0,"&frac34;"],[0,"&iquest;"],[0,"&Agrave;"],[0,"&Aacute;"],[0,"&Acirc;"],[0,"&Atilde;"],[0,"&Auml;"],[0,"&angst;"],[0,"&AElig;"],[0,"&Ccedil;"],[0,"&Egrave;"],[0,"&Eacute;"],[0,"&Ecirc;"],[0,"&Euml;"],[0,"&Igrave;"],[0,"&Iacute;"],[0,"&Icirc;"],[0,"&Iuml;"],[0,"&ETH;"],[0,"&Ntilde;"],[0,"&Ograve;"],[0,"&Oacute;"],[0,"&Ocirc;"],[0,"&Otilde;"],[0,"&Ouml;"],[0,"&times;"],[0,"&Oslash;"],[0,"&Ugrave;"],[0,"&Uacute;"],[0,"&Ucirc;"],[0,"&Uuml;"],[0,"&Yacute;"],[0,"&THORN;"],[0,"&szlig;"],[0,"&agrave;"],[0,"&aacute;"],[0,"&acirc;"],[0,"&atilde;"],[0,"&auml;"],[0,"&aring;"],[0,"&aelig;"],[0,"&ccedil;"],[0,"&egrave;"],[0,"&eacute;"],[0,"&ecirc;"],[0,"&euml;"],[0,"&igrave;"],[0,"&iacute;"],[0,"&icirc;"],[0,"&iuml;"],[0,"&eth;"],[0,"&ntilde;"],[0,"&ograve;"],[0,"&oacute;"],[0,"&ocirc;"],[0,"&otilde;"],[0,"&ouml;"],[0,"&div;"],[0,"&oslash;"],[0,"&ugrave;"],[0,"&uacute;"],[0,"&ucirc;"],[0,"&uuml;"],[0,"&yacute;"],[0,"&thorn;"],[0,"&yuml;"],[0,"&Amacr;"],[0,"&amacr;"],[0,"&Abreve;"],[0,"&abreve;"],[0,"&Aogon;"],[0,"&aogon;"],[0,"&Cacute;"],[0,"&cacute;"],[0,"&Ccirc;"],[0,"&ccirc;"],[0,"&Cdot;"],[0,"&cdot;"],[0,"&Ccaron;"],[0,"&ccaron;"],[0,"&Dcaron;"],[0,"&dcaron;"],[0,"&Dstrok;"],[0,"&dstrok;"],[0,"&Emacr;"],[0,"&emacr;"],[2,"&Edot;"],[0,"&edot;"],[0,"&Eogon;"],[0,"&eogon;"],[0,"&Ecaron;"],[0,"&ecaron;"],[0,"&Gcirc;"],[0,"&gcirc;"],[0,"&Gbreve;"],[0,"&gbreve;"],[0,"&Gdot;"],[0,"&gdot;"],[0,"&Gcedil;"],[1,"&Hcirc;"],[0,"&hcirc;"],[0,"&Hstrok;"],[0,"&hstrok;"],[0,"&Itilde;"],[0,"&itilde;"],[0,"&Imacr;"],[0,"&imacr;"],[2,"&Iogon;"],[0,"&iogon;"],[0,"&Idot;"],[0,"&imath;"],[0,"&IJlig;"],[0,"&ijlig;"],[0,"&Jcirc;"],[0,"&jcirc;"],[0,"&Kcedil;"],[0,"&kcedil;"],[0,"&kgreen;"],[0,"&Lacute;"],[0,"&lacute;"],[0,"&Lcedil;"],[0,"&lcedil;"],[0,"&Lcaron;"],[0,"&lcaron;"],[0,"&Lmidot;"],[0,"&lmidot;"],[0,"&Lstrok;"],[0,"&lstrok;"],[0,"&Nacute;"],[0,"&nacute;"],[0,"&Ncedil;"],[0,"&ncedil;"],[0,"&Ncaron;"],[0,"&ncaron;"],[0,"&napos;"],[0,"&ENG;"],[0,"&eng;"],[0,"&Omacr;"],[0,"&omacr;"],[2,"&Odblac;"],[0,"&odblac;"],[0,"&OElig;"],[0,"&oelig;"],[0,"&Racute;"],[0,"&racute;"],[0,"&Rcedil;"],[0,"&rcedil;"],[0,"&Rcaron;"],[0,"&rcaron;"],[0,"&Sacute;"],[0,"&sacute;"],[0,"&Scirc;"],[0,"&scirc;"],[0,"&Scedil;"],[0,"&scedil;"],[0,"&Scaron;"],[0,"&scaron;"],[0,"&Tcedil;"],[0,"&tcedil;"],[0,"&Tcaron;"],[0,"&tcaron;"],[0,"&Tstrok;"],[0,"&tstrok;"],[0,"&Utilde;"],[0,"&utilde;"],[0,"&Umacr;"],[0,"&umacr;"],[0,"&Ubreve;"],[0,"&ubreve;"],[0,"&Uring;"],[0,"&uring;"],[0,"&Udblac;"],[0,"&udblac;"],[0,"&Uogon;"],[0,"&uogon;"],[0,"&Wcirc;"],[0,"&wcirc;"],[0,"&Ycirc;"],[0,"&ycirc;"],[0,"&Yuml;"],[0,"&Zacute;"],[0,"&zacute;"],[0,"&Zdot;"],[0,"&zdot;"],[0,"&Zcaron;"],[0,"&zcaron;"],[19,"&fnof;"],[34,"&imped;"],[63,"&gacute;"],[65,"&jmath;"],[142,"&circ;"],[0,"&caron;"],[16,"&breve;"],[0,"&DiacriticalDot;"],[0,"&ring;"],[0,"&ogon;"],[0,"&DiacriticalTilde;"],[0,"&dblac;"],[51,"&DownBreve;"],[127,"&Alpha;"],[0,"&Beta;"],[0,"&Gamma;"],[0,"&Delta;"],[0,"&Epsilon;"],[0,"&Zeta;"],[0,"&Eta;"],[0,"&Theta;"],[0,"&Iota;"],[0,"&Kappa;"],[0,"&Lambda;"],[0,"&Mu;"],[0,"&Nu;"],[0,"&Xi;"],[0,"&Omicron;"],[0,"&Pi;"],[0,"&Rho;"],[1,"&Sigma;"],[0,"&Tau;"],[0,"&Upsilon;"],[0,"&Phi;"],[0,"&Chi;"],[0,"&Psi;"],[0,"&ohm;"],[7,"&alpha;"],[0,"&beta;"],[0,"&gamma;"],[0,"&delta;"],[0,"&epsi;"],[0,"&zeta;"],[0,"&eta;"],[0,"&theta;"],[0,"&iota;"],[0,"&kappa;"],[0,"&lambda;"],[0,"&mu;"],[0,"&nu;"],[0,"&xi;"],[0,"&omicron;"],[0,"&pi;"],[0,"&rho;"],[0,"&sigmaf;"],[0,"&sigma;"],[0,"&tau;"],[0,"&upsi;"],[0,"&phi;"],[0,"&chi;"],[0,"&psi;"],[0,"&omega;"],[7,"&thetasym;"],[0,"&Upsi;"],[2,"&phiv;"],[0,"&piv;"],[5,"&Gammad;"],[0,"&digamma;"],[18,"&kappav;"],[0,"&rhov;"],[3,"&epsiv;"],[0,"&backepsilon;"],[10,"&IOcy;"],[0,"&DJcy;"],[0,"&GJcy;"],[0,"&Jukcy;"],[0,"&DScy;"],[0,"&Iukcy;"],[0,"&YIcy;"],[0,"&Jsercy;"],[0,"&LJcy;"],[0,"&NJcy;"],[0,"&TSHcy;"],[0,"&KJcy;"],[1,"&Ubrcy;"],[0,"&DZcy;"],[0,"&Acy;"],[0,"&Bcy;"],[0,"&Vcy;"],[0,"&Gcy;"],[0,"&Dcy;"],[0,"&IEcy;"],[0,"&ZHcy;"],[0,"&Zcy;"],[0,"&IcyLine truncated
+178
View File
@@ -0,0 +1,178 @@
import { decodeXML, decodeHTML, DecodingMode } from "./decode.js";
import { encodeHTML, encodeNonAsciiHTML } from "./encode.js";
import {
encodeXML,
escapeUTF8,
escapeAttribute,
escapeText,
} from "./escape.js";
/** The level of entities to support. */
export enum EntityLevel {
/** Support only XML entities. */
XML = 0,
/** Support HTML entities, which are a superset of XML entities. */
HTML = 1,
}
export enum EncodingMode {
/**
* The output is UTF-8 encoded. Only characters that need escaping within
* XML will be escaped.
*/
UTF8,
/**
* The output consists only of ASCII characters. Characters that need
* escaping within HTML, and characters that aren't ASCII characters will
* be escaped.
*/
ASCII,
/**
* Encode all characters that have an equivalent entity, as well as all
* characters that are not ASCII characters.
*/
Extensive,
/**
* Encode all characters that have to be escaped in HTML attributes,
* following {@link https://html.spec.whatwg.org/multipage/parsing.html#escapingString}.
*/
Attribute,
/**
* Encode all characters that have to be escaped in HTML text,
* following {@link https://html.spec.whatwg.org/multipage/parsing.html#escapingString}.
*/
Text,
}
export interface DecodingOptions {
/**
* The level of entities to support.
* @default {@link EntityLevel.XML}
*/
level?: EntityLevel;
/**
* Decoding mode. If `Legacy`, will support legacy entities not terminated
* with a semicolon (`;`).
*
* Always `Strict` for XML. For HTML, set this to `true` if you are parsing
* an attribute value.
*
* The deprecated `decodeStrict` function defaults this to `Strict`.
*
* @default {@link DecodingMode.Legacy}
*/
mode?: DecodingMode | undefined;
}
/**
* Decodes a string with entities.
*
* @param data String to decode.
* @param options Decoding options.
*/
export function decode(
data: string,
options: DecodingOptions | EntityLevel = EntityLevel.XML
): string {
const level = typeof options === "number" ? options : options.level;
if (level === EntityLevel.HTML) {
const mode = typeof options === "object" ? options.mode : undefined;
return decodeHTML(data, mode);
}
return decodeXML(data);
}
/**
* Decodes a string with entities. Does not allow missing trailing semicolons for entities.
*
* @param data String to decode.
* @param options Decoding options.
* @deprecated Use `decode` with the `mode` set to `Strict`.
*/
export function decodeStrict(
data: string,
options: DecodingOptions | EntityLevel = EntityLevel.XML
): string {
const opts = typeof options === "number" ? { level: options } : options;
opts.mode ??= DecodingMode.Strict;
return decode(data, opts);
}
/**
* Options for `encode`.
*/
export interface EncodingOptions {
/**
* The level of entities to support.
* @default {@link EntityLevel.XML}
*/
level?: EntityLevel;
/**
* Output format.
* @default {@link EncodingMode.Extensive}
*/
mode?: EncodingMode;
}
/**
* Encodes a string with entities.
*
* @param data String to encode.
* @param options Encoding options.
*/
export function encode(
data: string,
options: EncodingOptions | EntityLevel = EntityLevel.XML
): string {
const opts = typeof options === "number" ? { level: options } : options;
// Mode `UTF8` just escapes XML entities
if (opts.mode === EncodingMode.UTF8) return escapeUTF8(data);
if (opts.mode === EncodingMode.Attribute) return escapeAttribute(data);
if (opts.mode === EncodingMode.Text) return escapeText(data);
if (opts.level === EntityLevel.HTML) {
if (opts.mode === EncodingMode.ASCII) {
return encodeNonAsciiHTML(data);
}
return encodeHTML(data);
}
// ASCII and Extensive are equivalent
return encodeXML(data);
}
export {
encodeXML,
escape,
escapeUTF8,
escapeAttribute,
escapeText,
} from "./escape.js";
export {
encodeHTML,
encodeNonAsciiHTML,
// Legacy aliases (deprecated)
encodeHTML as encodeHTML4,
encodeHTML as encodeHTML5,
} from "./encode.js";
export {
EntityDecoder,
DecodingMode,
decodeXML,
decodeHTML,
decodeHTMLStrict,
decodeHTMLAttribute,
// Legacy aliases (deprecated)
decodeHTML as decodeHTML4,
decodeHTML as decodeHTML5,
decodeHTMLStrict as decodeHTML4Strict,
decodeHTMLStrict as decodeHTML5Strict,
decodeXML as decodeXMLStrict,
} from "./decode.js";
+22
View File
@@ -0,0 +1,22 @@
Copyright (c) 2015 Vitaly Puzrin.
Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation
files (the "Software"), to deal in the Software without
restriction, including without limitation the rights to use,
copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
+646
View File
@@ -0,0 +1,646 @@
import reFactory from './lib/re.mjs'
//
// Helpers
//
// Merge objects
//
function assign (obj /* from1, from2, from3, ... */) {
const sources = Array.prototype.slice.call(arguments, 1)
sources.forEach(function (source) {
if (!source) { return }
Object.keys(source).forEach(function (key) {
obj[key] = source[key]
})
})
return obj
}
function _class (obj) { return Object.prototype.toString.call(obj) }
function isString (obj) { return _class(obj) === '[object String]' }
function isObject (obj) { return _class(obj) === '[object Object]' }
function isRegExp (obj) { return _class(obj) === '[object RegExp]' }
function isFunction (obj) { return _class(obj) === '[object Function]' }
function escapeRE (str) { return str.replace(/[.?*+^$[\]\\(){}|-]/g, '\\$&') }
//
const defaultOptions = {
fuzzyLink: true,
fuzzyEmail: true,
fuzzyIP: false
}
function isOptionsObj (obj) {
return Object.keys(obj || {}).reduce(function (acc, k) {
/* eslint-disable-next-line no-prototype-builtins */
return acc || defaultOptions.hasOwnProperty(k)
}, false)
}
const defaultSchemas = {
'http:': {
validate: function (text, pos, self) {
const tail = text.slice(pos)
if (!self.re.http) {
// compile lazily, because "host"-containing variables can change on tlds update.
self.re.http = new RegExp(
`^\\/\\/${self.re.src_auth}${self.re.src_host_port_strict}${self.re.src_path}`, 'i'
)
}
if (self.re.http.test(tail)) {
return tail.match(self.re.http)[0].length
}
return 0
}
},
'https:': 'http:',
'ftp:': 'http:',
'//': {
validate: function (text, pos, self) {
const tail = text.slice(pos)
if (!self.re.no_http) {
// compile lazily, because "host"-containing variables can change on tlds update.
self.re.no_http = new RegExp(
'^' +
self.re.src_auth +
// Don't allow single-level domains, because of false positives like '//test'
// with code comments
`(?:localhost|(?:(?:${self.re.src_domain})\\.)+${self.re.src_domain_root})` +
self.re.src_port +
self.re.src_host_terminator +
self.re.src_path,
'i'
)
}
if (self.re.no_http.test(tail)) {
// should not be `://` & `///`, that protects from errors in protocol name
if (pos >= 3 && text[pos - 3] === ':') { return 0 }
if (pos >= 3 && text[pos - 3] === '/') { return 0 }
return tail.match(self.re.no_http)[0].length
}
return 0
}
},
'mailto:': {
validate: function (text, pos, self) {
const tail = text.slice(pos)
if (!self.re.mailto) {
self.re.mailto = new RegExp(
`^${self.re.src_email_name}@${self.re.src_host_strict}`, 'i'
)
}
if (self.re.mailto.test(tail)) {
return tail.match(self.re.mailto)[0].length
}
return 0
}
}
}
// RE pattern for 2-character tlds (autogenerated by ./support/tlds_2char_gen.js)
const tlds_2ch_src_re = 'a[cdefgilmnoqrstuwxz]|b[abdefghijmnorstvwyz]|c[acdfghiklmnoruvwxyz]|d[ejkmoz]|e[cegrstu]|f[ijkmor]|g[abdefghilmnpqrstuwy]|h[kmnrtu]|i[delmnoqrst]|j[emop]|k[eghimnprwyz]|l[abcikrstuvy]|m[acdeghklmnopqrstuvwxyz]|n[acefgilopruz]|om|p[aefghklmnrstwy]|qa|r[eosuw]|s[abcdeghijklmnortuvxyz]|t[cdfghjklmnortvwz]|u[agksyz]|v[aceginu]|w[fs]|y[et]|z[amw]'
// DON'T try to make PRs with changes. Extend TLDs with LinkifyIt.tlds() instead
const tlds_default = 'biz|com|edu|gov|net|org|pro|web|xxx|aero|asia|coop|info|museum|name|shop|рф'.split('|')
function createValidator (re) {
return function (text, pos) {
const tail = text.slice(pos)
if (re.test(tail)) {
return tail.match(re)[0].length
}
return 0
}
}
function createNormalizer () {
return function (match, self) {
self.normalize(match)
}
}
// Schemas compiler. Build regexps.
//
function compile (self) {
// Load & clone RE patterns.
const re = self.re = reFactory(self.__opts__)
// Define dynamic patterns
const tlds = self.__tlds__.slice()
self.onCompile()
if (!self.__tlds_replaced__) {
tlds.push(tlds_2ch_src_re)
}
tlds.push(re.src_xn)
re.src_tlds = tlds.join('|')
function untpl (tpl) { return tpl.replace('%TLDS%', re.src_tlds) }
re.email_fuzzy = RegExp(untpl(re.tpl_email_fuzzy), 'i')
re.email_fuzzy_global = RegExp(untpl(re.tpl_email_fuzzy), 'ig')
re.link_fuzzy = RegExp(untpl(re.tpl_link_fuzzy), 'i')
re.link_fuzzy_global = RegExp(untpl(re.tpl_link_fuzzy), 'ig')
re.link_no_ip_fuzzy = RegExp(untpl(re.tpl_link_no_ip_fuzzy), 'i')
re.link_no_ip_fuzzy_global = RegExp(untpl(re.tpl_link_no_ip_fuzzy), 'ig')
re.host_fuzzy_test = RegExp(untpl(re.tpl_host_fuzzy_test), 'i')
//
// Compile each schema
//
const aliases = []
self.__compiled__ = {} // Reset compiled data
function schemaError (name, val) {
throw new Error(`(LinkifyIt) Invalid schema "${name}": ${val}`)
}
Object.keys(self.__schemas__).forEach(function (name) {
const val = self.__schemas__[name]
// skip disabled methods
if (val === null) { return }
const compiled = { validate: null, link: null }
self.__compiled__[name] = compiled
if (isObject(val)) {
if (isRegExp(val.validate)) {
compiled.validate = createValidator(val.validate)
} else if (isFunction(val.validate)) {
compiled.validate = val.validate
} else {
schemaError(name, val)
}
if (isFunction(val.normalize)) {
compiled.normalize = val.normalize
} else if (!val.normalize) {
compiled.normalize = createNormalizer()
} else {
schemaError(name, val)
}
return
}
if (isString(val)) {
aliases.push(name)
return
}
schemaError(name, val)
})
//
// Compile postponed aliases
//
aliases.forEach(function (alias) {
if (!self.__compiled__[self.__schemas__[alias]]) {
// Silently fail on missed schemas to avoid errons on disable.
// schemaError(alias, self.__schemas__[alias]);
return
}
self.__compiled__[alias].validate =
self.__compiled__[self.__schemas__[alias]].validate
self.__compiled__[alias].normalize =
self.__compiled__[self.__schemas__[alias]].normalize
})
//
// Fake record for guessed links
//
self.__compiled__[''] = { validate: null, normalize: createNormalizer() }
//
// Build schema condition
//
const slist = Object.keys(self.__compiled__)
.filter(function (name) {
// Filter disabled & fake schemas
return name.length > 0 && self.__compiled__[name]
})
.map(escapeRE)
.join('|')
// (?!_) cause 1.5x slowdown
self.re.schema_test = RegExp(`(^|(?!_)(?:[><\uff5c]|${re.src_ZPCc}))(${slist})`, 'i')
self.re.schema_search = RegExp(`(^|(?!_)(?:[><\uff5c]|${re.src_ZPCc}))(${slist})`, 'ig')
self.re.schema_at_start = RegExp(`^${self.re.schema_search.source}`, 'i')
self.re.pretest = RegExp(
`(${self.re.schema_test.source})|(${self.re.host_fuzzy_test.source})|@`,
'i'
)
}
/**
* class Match
*
* Match result. Single element of array, returned by [[LinkifyIt#match]]
**/
function Match (text, schema, index, lastIndex) {
const raw = text.slice(index, lastIndex)
/**
* Match#schema -> String
*
* Prefix (protocol) for matched string.
**/
this.schema = schema.toLowerCase()
/**
* Match#index -> Number
*
* First position of matched string.
**/
this.index = index
/**
* Match#lastIndex -> Number
*
* Next position after matched string.
**/
this.lastIndex = lastIndex
/**
* Match#raw -> String
*
* Matched string.
**/
this.raw = raw
/**
* Match#text -> String
*
* Notmalized text of matched string.
**/
this.text = raw
/**
* Match#url -> String
*
* Normalized url of matched string.
**/
this.url = raw
}
/**
* class LinkifyIt
**/
/**
* new LinkifyIt(schemas, options)
* - schemas (Object): Optional. Additional schemas to validate (prefix/validator)
* - options (Object): { fuzzyLink|fuzzyEmail|fuzzyIP: true|false }
*
* Creates new linkifier instance with optional additional schemas.
* Can be called without `new` keyword for convenience.
*
* By default understands:
*
* - `http(s)://...` , `ftp://...`, `mailto:...` & `//...` links
* - "fuzzy" links and emails (example.com, foo@bar.com).
*
* `schemas` is an object, where each key/value describes protocol/rule:
*
* - __key__ - link prefix (usually, protocol name with `:` at the end, `skype:`
* for example). `linkify-it` makes shure that prefix is not preceeded with
* alphanumeric char and symbols. Only whitespaces and punctuation allowed.
* - __value__ - rule to check tail after link prefix
* - _String_ - just alias to existing rule
* - _Object_
* - _validate_ - validator function (should return matched length on success),
* or `RegExp`.
* - _normalize_ - optional function to normalize text & url of matched result
* (for example, for @twitter mentions).
*
* `options`:
*
* - __fuzzyLink__ - recognige URL-s without `http(s):` prefix. Default `true`.
* - __fuzzyIP__ - allow IPs in fuzzy links above. Can conflict with some texts
* like version numbers. Default `false`.
* - __fuzzyEmail__ - recognize emails without `mailto:` prefix.
*
**/
function LinkifyIt (schemas, options) {
if (!(this instanceof LinkifyIt)) {
return new LinkifyIt(schemas, options)
}
if (!options) {
if (isOptionsObj(schemas)) {
options = schemas
schemas = {}
}
}
this.__opts__ = assign({}, defaultOptions, options)
this.__schemas__ = assign({}, defaultSchemas, schemas)
this.__compiled__ = {}
this.__tlds__ = tlds_default
this.__tlds_replaced__ = false
this.re = {}
compile(this)
}
/** chainable
* LinkifyIt#add(schema, definition)
* - schema (String): rule name (fixed pattern prefix)
* - definition (String|RegExp|Object): schema definition
*
* Add new rule definition. See constructor description for details.
**/
LinkifyIt.prototype.add = function add (schema, definition) {
this.__schemas__[schema] = definition
compile(this)
return this
}
/** chainable
* LinkifyIt#set(options)
* - options (Object): { fuzzyLink|fuzzyEmail|fuzzyIP: true|false }
*
* Set recognition options for links without schema.
**/
LinkifyIt.prototype.set = function set (options) {
this.__opts__ = assign(this.__opts__, options)
return this
}
/**
* LinkifyIt#test(text) -> Boolean
*
* Searches linkifiable pattern and returns `true` on success or `false` on fail.
**/
LinkifyIt.prototype.test = function test (text) {
if (!text.length) { return false }
let m, re
// try to scan for link with schema - that's the most simple rule
if (this.re.schema_test.test(text)) {
re = this.re.schema_search
re.lastIndex = 0
while ((m = re.exec(text)) !== null) {
if (this.testSchemaAt(text, m[2], re.lastIndex)) { return true }
}
}
if (this.__opts__.fuzzyLink && this.__compiled__['http:']) {
// guess schemaless links
if (text.search(this.re.host_fuzzy_test) >= 0) {
if (text.match(this.__opts__.fuzzyIP ? this.re.link_fuzzy : this.re.link_no_ip_fuzzy) !== null) {
return true
}
}
}
if (this.__opts__.fuzzyEmail && this.__compiled__['mailto:']) {
// guess schemaless emails
if (text.indexOf('@') >= 0) {
// We can't skip this check, because this cases are possible:
// [email protected], [email protected]
if (text.match(this.re.email_fuzzy) !== null) { return true }
}
}
return false
}
/**
* LinkifyIt#pretest(text) -> Boolean
*
* Very quick check, that can give false positives. Returns true if link MAY BE
* can exists. Can be used for speed optimization, when you need to check that
* link NOT exists.
**/
LinkifyIt.prototype.pretest = function pretest (text) {
return this.re.pretest.test(text)
}
/**
* LinkifyIt#testSchemaAt(text, name, position) -> Number
* - text (String): text to scan
* - name (String): rule (schema) name
* - position (Number): text offset to check from
*
* Similar to [[LinkifyIt#test]] but checks only specific protocol tail exactly
* at given position. Returns length of found pattern (0 on fail).
**/
LinkifyIt.prototype.testSchemaAt = function testSchemaAt (text, schema, pos) {
// If not supported schema check requested - terminate
if (!this.__compiled__[schema.toLowerCase()]) {
return 0
}
return this.__compiled__[schema.toLowerCase()].validate(text, pos, this)
}
/**
* LinkifyIt#match(text) -> Array|null
*
* Returns array of found link descriptions or `null` on fail. We strongly
* recommend to use [[LinkifyIt#test]] first, for best speed.
*
* ##### Result match description
*
* - __schema__ - link schema, can be empty for fuzzy links, or `//` for
* protocol-neutral links.
* - __index__ - offset of matched text
* - __lastIndex__ - index of next char after mathch end
* - __raw__ - matched text
* - __text__ - normalized text
* - __url__ - link, generated from matched text
**/
LinkifyIt.prototype.match = function match (text) {
const result = []
const type_schemed = []
const type_fuzzy_link = []
const type_fuzzy_email = []
let m, len, re
function choose (a, b) {
if (!a) { return b }
if (!b) { return a }
if (a.index !== b.index) { return a.index < b.index ? a : b }
return a.lastIndex >= b.lastIndex ? a : b
}
if (!text.length) { return null }
// scan for links with schema
if (this.re.schema_test.test(text)) {
re = this.re.schema_search
re.lastIndex = 0
while ((m = re.exec(text)) !== null) {
len = this.testSchemaAt(text, m[2], re.lastIndex)
if (len) {
type_schemed.push({
schema: m[2],
index: m.index + m[1].length,
lastIndex: m.index + m[0].length + len
})
}
}
}
if (this.__opts__.fuzzyLink && this.__compiled__['http:']) {
re = this.__opts__.fuzzyIP ? this.re.link_fuzzy_global : this.re.link_no_ip_fuzzy_global
re.lastIndex = 0
while ((m = re.exec(text)) !== null) {
type_fuzzy_link.push({
schema: '',
index: m.index + m[1].length,
lastIndex: m.index + m[0].length
})
}
}
if (this.__opts__.fuzzyEmail && this.__compiled__['mailto:']) {
re = this.re.email_fuzzy_global
re.lastIndex = 0
while ((m = re.exec(text)) !== null) {
type_fuzzy_email.push({
schema: 'mailto:',
index: m.index + m[1].length,
lastIndex: m.index + m[0].length
})
}
}
const indexes = [0, 0, 0]
let lastIndex = 0
for (;;) {
const candidates = [
type_schemed[indexes[0]],
type_fuzzy_email[indexes[1]],
type_fuzzy_link[indexes[2]]
]
const candidate = choose(choose(candidates[0], candidates[1]), candidates[2])
if (!candidate) { break }
if (candidate === candidates[0]) {
indexes[0]++
} else if (candidate === candidates[1]) {
indexes[1]++
} else {
indexes[2]++
}
if (candidate.index < lastIndex) { continue }
const match = new Match(text, candidate.schema, candidate.index, candidate.lastIndex)
this.__compiled__[match.schema].normalize(match, this)
result.push(match)
lastIndex = candidate.lastIndex
}
if (result.length) {
return result
}
return null
}
/**
* LinkifyIt#matchAtStart(text) -> Match|null
*
* Returns fully-formed (not fuzzy) link if it starts at the beginning
* of the string, and null otherwise.
**/
LinkifyIt.prototype.matchAtStart = function matchAtStart (text) {
if (!text.length) return null
const m = this.re.schema_at_start.exec(text)
if (!m) return null
const len = this.testSchemaAt(text, m[2], m[0].length)
if (!len) return null
const match = new Match(text, m[2], m.index + m[1].length, m.index + m[0].length + len)
this.__compiled__[match.schema].normalize(match, this)
return match
}
/** chainable
* LinkifyIt#tlds(list [, keepOld]) -> this
* - list (Array): list of tlds
* - keepOld (Boolean): merge with current list if `true` (`false` by default)
*
* Load (or merge) new tlds list. Those are user for fuzzy links (without prefix)
* to avoid false positives. By default this algorythm used:
*
* - hostname with any 2-letter root zones are ok.
* - biz|com|edu|gov|net|org|pro|web|xxx|aero|asia|coop|info|museum|name|shop|рф
* are ok.
* - encoded (`xn--...`) root zones are ok.
*
* If list is replaced, then exact match for 2-chars root zones will be checked.
**/
LinkifyIt.prototype.tlds = function tlds (list, keepOld) {
list = Array.isArray(list) ? list : [list]
if (!keepOld) {
this.__tlds__ = list.slice()
this.__tlds_replaced__ = true
compile(this)
return this
}
this.__tlds__ = this.__tlds__.concat(list)
.sort()
.filter(function (el, idx, arr) {
return el !== arr[idx - 1]
})
.reverse()
compile(this)
return this
}
/**
* LinkifyIt#normalize(match)
*
* Default normalizer (if schema does not define it's own).
**/
LinkifyIt.prototype.normalize = function normalize (match) {
// Do minimal possible changes by default. Need to collect feedback prior
// to move forward https://github.com/markdown-it/linkify-it/issues/1
if (!match.schema) { match.url = `http://${match.url}` }
if (match.schema === 'mailto:' && !/^mailto:/i.test(match.url)) {
match.url = `mailto:${match.url}`
}
}
/**
* LinkifyIt#onCompile()
*
* Override to modify basic RegExp-s.
**/
LinkifyIt.prototype.onCompile = function onCompile () {
}
export default LinkifyIt
+193
View File
@@ -0,0 +1,193 @@
import { Any, Cc, Z, P } from 'uc.micro'
export default function (opts) {
const re = {}
opts = opts || {}
re.src_Any = Any.source
re.src_Cc = Cc.source
re.src_Z = Z.source
re.src_P = P.source
// \p{\Z\P\Cc\CF} (white spaces + control + format + punctuation)
re.src_ZPCc = [re.src_Z, re.src_P, re.src_Cc].join('|')
// \p{\Z\Cc} (white spaces + control)
re.src_ZCc = [re.src_Z, re.src_Cc].join('|')
// Experimental. List of chars, completely prohibited in links
// because can separate it from other part of text
const text_separators = '[><\uff5c]'
// All possible word characters (everything without punctuation, spaces & controls)
// Defined via punctuation & spaces to save space
// Should be something like \p{\L\N\S\M} (\w but without `_`)
re.src_pseudo_letter = `(?:(?!${text_separators}|${re.src_ZPCc})${re.src_Any})`
// The same as abothe but without [0-9]
// var src_pseudo_letter_non_d = '(?:(?![0-9]|' + src_ZPCc + ')' + src_Any + ')';
re.src_ip4 =
'(?:(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)'
// Prohibit any of "@/[]()" in user/pass to avoid wrong domain fetch.
// Length is capped to exclude possible rescans till the end and avoid O(n^2)
// DoS. No standard limit, just take something reasonable.
re.src_auth = `(?:(?:(?!${re.src_ZCc}|[@/\\[\\]()]).){1,50}@)?`
re.src_port =
'(?::(?:6(?:[0-4]\\d{3}|5(?:[0-4]\\d{2}|5(?:[0-2]\\d|3[0-5])))|[1-5]?\\d{1,4}))?'
re.src_host_terminator =
`(?=$|${text_separators}|${re.src_ZPCc})` +
`(?!${opts['---'] ? '-(?!--)|' : '-|'}_|:\\d|\\.-|\\.(?!$|${re.src_ZPCc}))`
re.src_path =
'(?:' +
'[/?#]' +
'(?:' +
`(?!${re.src_ZCc}|${text_separators}|[()[\\]{}.,"'?!\\-;]).|` +
`\\[(?:(?!${re.src_ZCc}|\\]).)*\\]|` +
`\\((?:(?!${re.src_ZCc}|[)]).)*\\)|` +
`\\{(?:(?!${re.src_ZCc}|[}]).)*\\}|` +
`\\"(?:(?!${re.src_ZCc}|["]).)+\\"|` +
`\\'(?:(?!${re.src_ZCc}|[']).)+\\'|` +
// allow `I'm_king` if no pair found
`\\'(?=${re.src_pseudo_letter}|[-])|` +
// google has many dots in "google search" links (#66, #81).
// github has ... in commit range links,
// Restrict to
// - english
// - percent-encoded
// - parts of file path
// - params separator
// until more examples found.
'\\.{2,}[a-zA-Z0-9%/&]|' +
`\\.(?!${re.src_ZCc}|[.]|$)|` +
(opts['---']
? '\\-(?!--(?:[^-]|$))(?:-*)|' // `---` => long dash, terminate
: '\\-+|'
) +
// allow `,,,` in paths
`,(?!${re.src_ZCc}|$)|` +
// allow `;` if not followed by space-like char
`;(?!${re.src_ZCc}|$)|` +
// allow `!!!` in paths, but not at the end
`\\!+(?!${re.src_ZCc}|[!]|$)|` +
`\\?(?!${re.src_ZCc}|[?]|$)` +
')+' +
'|\\/' +
')?'
// Allow anything in markdown spec, forbid quote (") at the first position
// because emails enclosed in quotes are far more common
// Max name length capped to 64 chars (RFC 5321). This also prevents O(n^2)
// rescans to the end on inputs like `mailto:mailto:...`
re.src_email_name =
'[\\-;:&=\\+\\$,\\.a-zA-Z0-9_][\\-;:&=\\+\\$,\\"\\.a-zA-Z0-9_]{0,63}'
re.src_xn =
'xn--[a-z0-9\\-]{1,59}'
// More to read about domain names
// http://serverfault.com/questions/638260/
re.src_domain_root =
// Allow letters & digits (http://test1)
'(?:' +
re.src_xn +
'|' +
`${re.src_pseudo_letter}{1,63}` +
')'
re.src_domain =
'(?:' +
re.src_xn +
'|' +
`(?:${re.src_pseudo_letter})` +
'|' +
`(?:${re.src_pseudo_letter}(?:-|${re.src_pseudo_letter}){0,61}${re.src_pseudo_letter})` +
')'
re.src_host =
'(?:' +
// Don't need IP check, because digits are already allowed in normal domain names
// src_ip4 +
// '|' +
`(?:(?:(?:${re.src_domain})\\.)*${re.src_domain})`/* _root */ +
')'
re.tpl_host_fuzzy =
'(?:' +
re.src_ip4 +
'|' +
`(?:(?:(?:${re.src_domain})\\.)+(?:%TLDS%))` +
')'
re.tpl_host_no_ip_fuzzy =
`(?:(?:(?:${re.src_domain})\\.)+(?:%TLDS%))`
re.src_host_strict =
re.src_host + re.src_host_terminator
re.tpl_host_fuzzy_strict =
re.tpl_host_fuzzy + re.src_host_terminator
re.src_host_port_strict =
re.src_host + re.src_port + re.src_host_terminator
re.tpl_host_port_fuzzy_strict =
re.tpl_host_fuzzy + re.src_port + re.src_host_terminator
re.tpl_host_port_no_ip_fuzzy_strict =
re.tpl_host_no_ip_fuzzy + re.src_port + re.src_host_terminator
//
// Main rules
//
// Rude test fuzzy links by host, for quick deny
re.tpl_host_fuzzy_test =
`localhost|www\\.|\\.\\d{1,3}\\.|(?:\\.(?:%TLDS%)(?:${re.src_ZPCc}|>|$))`
re.tpl_email_fuzzy =
`(^|${text_separators}|"|\\(|${re.src_ZCc})` +
`(${re.src_email_name}@${re.tpl_host_fuzzy_strict})`
re.tpl_link_fuzzy =
// Fuzzy link can't be prepended with .:/\- and non punctuation.
// but can start with > (markdown blockquote)
`(^|(?![.:/\\-_@])(?:[$+<=>^\`|\uff5c]|${re.src_ZPCc}))` +
`((?![$+<=>^\`|\uff5c])${re.tpl_host_port_fuzzy_strict}${re.src_path})`
re.tpl_link_no_ip_fuzzy =
// Fuzzy link can't be prepended with .:/\- and non punctuation.
// but can start with > (markdown blockquote)
`(^|(?![.:/\\-_@])(?:[$+<=>^\`|\uff5c]|${re.src_ZPCc}))` +
`((?![$+<=>^\`|\uff5c])${re.tpl_host_port_no_ip_fuzzy_strict}${re.src_path})`
return re
}
@@ -0,0 +1,63 @@
{
"name": "linkify-it",
"version": "5.0.2",
"description": "Links recognition library with FULL unicode support",
"keywords": [
"linkify",
"linkifier",
"autolink",
"autolinker"
],
"repository": "markdown-it/linkify-it",
"main": "build/index.cjs.js",
"module": "index.mjs",
"exports": {
".": {
"require": "./build/index.cjs.js",
"import": "./index.mjs"
},
"./*": {
"require": "./*",
"import": "./*"
}
},
"files": [
"index.mjs",
"lib/",
"build/"
],
"license": "MIT",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/markdown-it"
}
],
"scripts": {
"lint": "eslint .",
"test": "npm run lint && npm run build && c8 --exclude build --exclude test -r text -r html -r lcov node --test",
"demo": "npm run lint && node support/build_demo.mjs",
"doc": "node support/build_doc.mjs",
"build": "rollup -c support/rollup.config.mjs",
"gh-pages": "npm run demo && npm run doc && gh-pages -d demo -f",
"prepack": "npm test && npm run build && npm run demo && npm run doc",
"postpublish": "npm run gh-pages"
},
"dependencies": {
"uc.micro": "^2.0.0"
},
"devDependencies": {
"@rollup/plugin-node-resolve": "^15.2.3",
"c8": "^11.0.0",
"eslint": "^9.39.3",
"gh-pages": "^6.1.0",
"mitata": "^1.0.34",
"neostandard": "^0.13.0",
"ndoc": "^6.0.0",
"rollup": "^4.6.1"
}
}
+22
View File
@@ -0,0 +1,22 @@
Copyright (c) 2014 Vitaly Puzrin, Alex Kocharin.
Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation
files (the "Software"), to deal in the Software without
restriction, including without limitation the rights to use,
copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1 @@
export { default } from './lib/index.mjs'
@@ -0,0 +1,67 @@
// List of valid html blocks names, according to commonmark spec
// https://spec.commonmark.org/0.30/#html-blocks
export default [
'address',
'article',
'aside',
'base',
'basefont',
'blockquote',
'body',
'caption',
'center',
'col',
'colgroup',
'dd',
'details',
'dialog',
'dir',
'div',
'dl',
'dt',
'fieldset',
'figcaption',
'figure',
'footer',
'form',
'frame',
'frameset',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'head',
'header',
'hr',
'html',
'iframe',
'legend',
'li',
'link',
'main',
'menu',
'menuitem',
'nav',
'noframes',
'ol',
'optgroup',
'option',
'p',
'param',
'search',
'section',
'summary',
'table',
'tbody',
'td',
'tfoot',
'th',
'thead',
'title',
'tr',
'track',
'ul'
]
@@ -0,0 +1,25 @@
// Regexps to match html elements
const attr_name = '[a-zA-Z_:][a-zA-Z0-9:._-]*'
const unquoted = '[^"\'=<>`\\x00-\\x20]+'
const single_quoted = "'[^']*'"
const double_quoted = '"[^"]*"'
const attr_value = '(?:' + unquoted + '|' + single_quoted + '|' + double_quoted + ')'
const attribute = '(?:\\s+' + attr_name + '(?:\\s*=\\s*' + attr_value + ')?)'
const open_tag = '<[A-Za-z][A-Za-z0-9\\-]*' + attribute + '*\\s*\\/?>'
const close_tag = '<\\/[A-Za-z][A-Za-z0-9\\-]*\\s*>'
const comment = '<!---?>|<!--(?:[^-]|-[^-]|--[^>])*-->'
const processing = '<[?][\\s\\S]*?[?]>'
const declaration = '<![A-Za-z][^>]*>'
const cdata = '<!\\[CDATA\\[[\\s\\S]*?\\]\\]>'
const HTML_TAG_RE = new RegExp('^(?:' + open_tag + '|' + close_tag + '|' + comment +
'|' + processing + '|' + declaration + '|' + cdata + ')')
const HTML_OPEN_CLOSE_TAG_RE = new RegExp('^(?:' + open_tag + '|' + close_tag + ')')
export { HTML_TAG_RE, HTML_OPEN_CLOSE_TAG_RE }
@@ -0,0 +1,324 @@
// Utilities
//
import * as mdurl from 'mdurl'
import * as ucmicro from 'uc.micro'
import { decodeHTML } from 'entities'
function _class (obj) { return Object.prototype.toString.call(obj) }
function isString (obj) { return _class(obj) === '[object String]' }
const _hasOwnProperty = Object.prototype.hasOwnProperty
function has (object, key) {
return _hasOwnProperty.call(object, key)
}
// Merge objects
//
function assign (obj /* from1, from2, from3, ... */) {
const sources = Array.prototype.slice.call(arguments, 1)
sources.forEach(function (source) {
if (!source) { return }
if (typeof source !== 'object') {
throw new TypeError(source + 'must be object')
}
Object.keys(source).forEach(function (key) {
obj[key] = source[key]
})
})
return obj
}
// Remove element from array and put another array at those position.
// Useful for some operations with tokens
function arrayReplaceAt (src, pos, newElements) {
return [].concat(src.slice(0, pos), newElements, src.slice(pos + 1))
}
function isValidEntityCode (c) {
// broken sequence
if (c >= 0xD800 && c <= 0xDFFF) { return false }
// never used
if (c >= 0xFDD0 && c <= 0xFDEF) { return false }
if ((c & 0xFFFF) === 0xFFFF || (c & 0xFFFF) === 0xFFFE) { return false }
// control codes
if (c >= 0x00 && c <= 0x08) { return false }
if (c === 0x0B) { return false }
if (c >= 0x0E && c <= 0x1F) { return false }
if (c >= 0x7F && c <= 0x9F) { return false }
// out of range
if (c > 0x10FFFF) { return false }
return true
}
function fromCodePoint (c) {
/* eslint no-bitwise:0 */
if (c > 0xffff) {
c -= 0x10000
const surrogate1 = 0xd800 + (c >> 10)
const surrogate2 = 0xdc00 + (c & 0x3ff)
return String.fromCharCode(surrogate1, surrogate2)
}
return String.fromCharCode(c)
}
const UNESCAPE_MD_RE = /\\([!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~])/g
const ENTITY_RE = /&([a-z#][a-z0-9]{1,31});/gi
const UNESCAPE_ALL_RE = new RegExp(UNESCAPE_MD_RE.source + '|' + ENTITY_RE.source, 'gi')
const DIGITAL_ENTITY_TEST_RE = /^#((?:x[a-f0-9]{1,8}|[0-9]{1,8}))$/i
function replaceEntityPattern (match, name) {
if (name.charCodeAt(0) === 0x23/* # */ && DIGITAL_ENTITY_TEST_RE.test(name)) {
const code = name[1].toLowerCase() === 'x'
? parseInt(name.slice(2), 16)
: parseInt(name.slice(1), 10)
if (isValidEntityCode(code)) {
return fromCodePoint(code)
}
return match
}
const decoded = decodeHTML(match)
if (decoded !== match) {
return decoded
}
return match
}
function unescapeMd (str) {
if (str.indexOf('\\') < 0) { return str }
return str.replace(UNESCAPE_MD_RE, '$1')
}
function unescapeAll (str) {
if (str.indexOf('\\') < 0 && str.indexOf('&') < 0) { return str }
return str.replace(UNESCAPE_ALL_RE, function (match, escaped, entity) {
if (escaped) { return escaped }
return replaceEntityPattern(match, entity)
})
}
const HTML_ESCAPE_TEST_RE = /[&<>"]/
const HTML_ESCAPE_REPLACE_RE = /[&<>"]/g
const HTML_REPLACEMENTS = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;'
}
function replaceUnsafeChar (ch) {
return HTML_REPLACEMENTS[ch]
}
function escapeHtml (str) {
if (HTML_ESCAPE_TEST_RE.test(str)) {
return str.replace(HTML_ESCAPE_REPLACE_RE, replaceUnsafeChar)
}
return str
}
const REGEXP_ESCAPE_RE = /[.?*+^$[\]\\(){}|-]/g
function escapeRE (str) {
return str.replace(REGEXP_ESCAPE_RE, '\\$&')
}
function isSpace (code) {
switch (code) {
case 0x09:
case 0x20:
return true
}
return false
}
// Zs (unicode class) || [\t\f\v\r\n]
function isWhiteSpace (code) {
if (code >= 0x2000 && code <= 0x200A) { return true }
switch (code) {
case 0x09: // \t
case 0x0A: // \n
case 0x0B: // \v
case 0x0C: // \f
case 0x0D: // \r
case 0x20:
case 0xA0:
case 0x1680:
case 0x202F:
case 0x205F:
case 0x3000:
return true
}
return false
}
// Currently without astral characters support.
function isPunctChar (ch) {
return ucmicro.P.test(ch) || ucmicro.S.test(ch)
}
function isPunctCharCode (code) {
return isPunctChar(fromCodePoint(code))
}
// Markdown ASCII punctuation characters.
//
// !, ", #, $, %, &, ', (, ), *, +, ,, -, ., /, :, ;, <, =, >, ?, @, [, \, ], ^, _, `, {, |, }, or ~
// http://spec.commonmark.org/0.15/#ascii-punctuation-character
//
// Don't confuse with unicode punctuation !!! It lacks some chars in ascii range.
//
function isMdAsciiPunct (ch) {
switch (ch) {
case 0x21/* ! */:
case 0x22/* " */:
case 0x23/* # */:
case 0x24/* $ */:
case 0x25/* % */:
case 0x26/* & */:
case 0x27/* ' */:
case 0x28/* ( */:
case 0x29/* ) */:
case 0x2A/* * */:
case 0x2B/* + */:
case 0x2C/* , */:
case 0x2D/* - */:
case 0x2E/* . */:
case 0x2F/* / */:
case 0x3A/* : */:
case 0x3B/* ; */:
case 0x3C/* < */:
case 0x3D/* = */:
case 0x3E/* > */:
case 0x3F/* ? */:
case 0x40/* @ */:
case 0x5B/* [ */:
case 0x5C/* \ */:
case 0x5D/* ] */:
case 0x5E/* ^ */:
case 0x5F/* _ */:
case 0x60/* ` */:
case 0x7B/* { */:
case 0x7C/* | */:
case 0x7D/* } */:
case 0x7E/* ~ */:
return true
default:
return false
}
}
// Hepler to unify [reference labels].
//
function normalizeReference (str) {
// Trim and collapse whitespace
//
str = str.trim().replace(/\s+/g, ' ')
// In node v10 'ẞ'.toLowerCase() === 'Ṿ', which is presumed to be a bug
// fixed in v12 (couldn't find any details).
//
// So treat this one as a special case
// (remove this when node v10 is no longer supported).
//
if ('ẞ'.toLowerCase() === 'Ṿ') {
/* c8 ignore next 2 */
str = str.replace(/ẞ/g, 'ß')
}
// .toLowerCase().toUpperCase() should get rid of all differences
// between letter variants.
//
// Simple .toLowerCase() doesn't normalize 125 code points correctly,
// and .toUpperCase doesn't normalize 6 of them (list of exceptions:
// İ, ϴ, ẞ, Ω, K, Å - those are already uppercased, but have differently
// uppercased versions).
//
// Here's an example showing how it happens. Lets take greek letter omega:
// uppercase U+0398 (Θ), U+03f4 (ϴ) and lowercase U+03b8 (θ), U+03d1 (ϑ)
//
// Unicode entries:
// 0398;GREEK CAPITAL LETTER THETA;Lu;0;L;;;;;N;;;;03B8;
// 03B8;GREEK SMALL LETTER THETA;Ll;0;L;;;;;N;;;0398;;0398
// 03D1;GREEK THETA SYMBOL;Ll;0;L;<compat> 03B8;;;;N;GREEK SMALL LETTER SCRIPT THETA;;0398;;0398
// 03F4;GREEK CAPITAL THETA SYMBOL;Lu;0;L;<compat> 0398;;;;N;;;;03B8;
//
// Case-insensitive comparison should treat all of them as equivalent.
//
// But .toLowerCase() doesn't change ϑ (it's already lowercase),
// and .toUpperCase() doesn't change ϴ (already uppercase).
//
// Applying first lower then upper case normalizes any character:
// '\u0398\u03f4\u03b8\u03d1'.toLowerCase().toUpperCase() === '\u0398\u0398\u0398\u0398'
//
// Note: this is equivalent to unicode case folding; unicode normalization
// is a different step that is not required here.
//
// Final result should be uppercased, because it's later stored in an object
// (this avoid a conflict with Object.prototype members,
// most notably, `__proto__`)
//
return str.toLowerCase().toUpperCase()
}
function isAsciiTrimmable (c) {
return c === 0x20 || c === 0x09 || c === 0x0a || c === 0x0d
}
// "Light" .trim() for blocks (headers, paragraphs), where unicode spaces
// should be preserved.
function asciiTrim (str) {
let start = 0
for (; start < str.length; start++) {
if (!isAsciiTrimmable(str.charCodeAt(start))) {
break
}
}
let end = str.length - 1
for (; end >= start; end--) {
if (!isAsciiTrimmable(str.charCodeAt(end))) {
break
}
}
return str.slice(start, end + 1)
}
// Re-export libraries commonly used in both markdown-it and its plugins,
// so plugins won't have to depend on them explicitly, which reduces their
// bundled size (e.g. a browser build).
//
const lib = { mdurl, ucmicro }
export {
lib,
assign,
isString,
has,
unescapeMd,
unescapeAll,
isValidEntityCode,
fromCodePoint,
escapeHtml,
arrayReplaceAt,
isSpace,
isWhiteSpace,
isMdAsciiPunct,
isPunctChar,
isPunctCharCode,
escapeRE,
normalizeReference,
asciiTrim
}
@@ -0,0 +1,11 @@
// Just a shortcut for bulk export
import parseLinkLabel from './parse_link_label.mjs'
import parseLinkDestination from './parse_link_destination.mjs'
import parseLinkTitle from './parse_link_title.mjs'
export {
parseLinkLabel,
parseLinkDestination,
parseLinkTitle
}
@@ -0,0 +1,77 @@
// Parse link destination
//
import { unescapeAll } from '../common/utils.mjs'
export default function parseLinkDestination (str, start, max) {
let code
let pos = start
const result = {
ok: false,
pos: 0,
str: ''
}
if (str.charCodeAt(pos) === 0x3C /* < */) {
pos++
while (pos < max) {
code = str.charCodeAt(pos)
if (code === 0x0A /* \n */) { return result }
if (code === 0x3C /* < */) { return result }
if (code === 0x3E /* > */) {
result.pos = pos + 1
result.str = unescapeAll(str.slice(start + 1, pos))
result.ok = true
return result
}
if (code === 0x5C /* \ */ && pos + 1 < max) {
pos += 2
continue
}
pos++
}
// no closing '>'
return result
}
// this should be ... } else { ... branch
let level = 0
while (pos < max) {
code = str.charCodeAt(pos)
if (code === 0x20) { break }
// ascii control characters
if (code < 0x20 || code === 0x7F) { break }
if (code === 0x5C /* \ */ && pos + 1 < max) {
if (str.charCodeAt(pos + 1) === 0x20) { pos++; continue }
pos += 2
continue
}
if (code === 0x28 /* ( */) {
level++
if (level > 32) { return result }
}
if (code === 0x29 /* ) */) {
if (level === 0) { break }
level--
}
pos++
}
if (start === pos) { return result }
if (level !== 0) { return result }
result.str = unescapeAll(str.slice(start, pos))
result.pos = pos
result.ok = true
return result
}
@@ -0,0 +1,49 @@
// Parse link label
//
// this function assumes that first character ("[") already matches;
// returns the end of the label
//
export default function parseLinkLabel (state, start, disableNested) {
let level, found, marker, prevPos
const max = state.posMax
const oldPos = state.pos
state.pos = start + 1
level = 1
while (state.pos < max) {
marker = state.src.charCodeAt(state.pos)
if (marker === 0x5D /* ] */) {
level--
if (level === 0) {
found = true
break
}
}
prevPos = state.pos
state.md.inline.skipToken(state)
if (marker === 0x5B /* [ */) {
if (prevPos === state.pos - 1) {
// increase level if we find text `[`, which is not a part of any token
level++
} else if (disableNested) {
state.pos = oldPos
return -1
}
}
}
let labelEnd = -1
if (found) {
labelEnd = state.pos
}
// restore old state
state.pos = oldPos
return labelEnd
}
@@ -0,0 +1,66 @@
// Parse link title
//
import { unescapeAll } from '../common/utils.mjs'
// Parse link title within `str` in [start, max] range,
// or continue previous parsing if `prev_state` is defined (equal to result of last execution).
//
export default function parseLinkTitle (str, start, max, prev_state) {
let code
let pos = start
const state = {
// if `true`, this is a valid link title
ok: false,
// if `true`, this link can be continued on the next line
can_continue: false,
// if `ok`, it's the position of the first character after the closing marker
pos: 0,
// if `ok`, it's the unescaped title
str: '',
// expected closing marker character code
marker: 0
}
if (prev_state) {
// this is a continuation of a previous parseLinkTitle call on the next line,
// used in reference links only
state.str = prev_state.str
state.marker = prev_state.marker
} else {
if (pos >= max) { return state }
let marker = str.charCodeAt(pos)
if (marker !== 0x22 /* " */ && marker !== 0x27 /* ' */ && marker !== 0x28 /* ( */) { return state }
start++
pos++
// if opening marker is "(", switch it to closing marker ")"
if (marker === 0x28) { marker = 0x29 }
state.marker = marker
}
while (pos < max) {
code = str.charCodeAt(pos)
if (code === state.marker) {
state.pos = pos + 1
state.str += unescapeAll(str.slice(start, pos))
state.ok = true
return state
} else if (code === 0x28 /* ( */ && state.marker === 0x29 /* ) */) {
return state
} else if (code === 0x5C /* \ */ && pos + 1 < max) {
pos++
}
pos++
}
// no closing marker found, but this link title may continue on the next line (for references)
state.can_continue = true
state.str += unescapeAll(str.slice(start, pos))
return state
}
@@ -0,0 +1,565 @@
// Main parser class
import * as utils from './common/utils.mjs'
import * as helpers from './helpers/index.mjs'
import Renderer from './renderer.mjs'
import ParserCore from './parser_core.mjs'
import ParserBlock from './parser_block.mjs'
import ParserInline from './parser_inline.mjs'
import LinkifyIt from 'linkify-it'
import * as mdurl from 'mdurl'
import punycode from 'punycode.js'
import cfg_default from './presets/default.mjs'
import cfg_zero from './presets/zero.mjs'
import cfg_commonmark from './presets/commonmark.mjs'
const config = {
default: cfg_default,
zero: cfg_zero,
commonmark: cfg_commonmark
}
//
// This validator can prohibit more than really needed to prevent XSS. It's a
// tradeoff to keep code simple and to be secure by default.
//
// If you need different setup - override validator method as you wish. Or
// replace it with dummy function and use external sanitizer.
//
const BAD_PROTO_RE = /^(vbscript|javascript|file|data):/
const GOOD_DATA_RE = /^data:image\/(gif|png|jpeg|webp);/
function validateLink (url) {
// url should be normalized at this point, and existing entities are decoded
const str = url.trim().toLowerCase()
return BAD_PROTO_RE.test(str) ? GOOD_DATA_RE.test(str) : true
}
const RECODE_HOSTNAME_FOR = ['http:', 'https:', 'mailto:']
function normalizeLink (url) {
const parsed = mdurl.parse(url, true)
if (parsed.hostname) {
// Encode hostnames in urls like:
// `http://host/`, `https://host/`, `mailto:user@host`, `//host/`
//
// We don't encode unknown schemas, because it's likely that we encode
// something we shouldn't (e.g. `skype:name` treated as `skype:host`)
//
if (!parsed.protocol || RECODE_HOSTNAME_FOR.indexOf(parsed.protocol) >= 0) {
try {
parsed.hostname = punycode.toASCII(parsed.hostname)
} catch (er) { /**/ }
}
}
return mdurl.encode(mdurl.format(parsed))
}
function normalizeLinkText (url) {
const parsed = mdurl.parse(url, true)
if (parsed.hostname) {
// Encode hostnames in urls like:
// `http://host/`, `https://host/`, `mailto:user@host`, `//host/`
//
// We don't encode unknown schemas, because it's likely that we encode
// something we shouldn't (e.g. `skype:name` treated as `skype:host`)
//
if (!parsed.protocol || RECODE_HOSTNAME_FOR.indexOf(parsed.protocol) >= 0) {
try {
parsed.hostname = punycode.toUnicode(parsed.hostname)
} catch (er) { /**/ }
}
}
// add '%' to exclude list because of https://github.com/markdown-it/markdown-it/issues/720
return mdurl.decode(mdurl.format(parsed), mdurl.decode.defaultChars + '%')
}
/**
* class MarkdownIt
*
* Main parser/renderer class.
*
* ##### Usage
*
* ```javascript
* // node.js, "classic" way:
* var MarkdownIt = require('markdown-it'),
* md = new MarkdownIt();
* var result = md.render('# markdown-it rulezz!');
*
* // node.js, the same, but with sugar:
* var md = require('markdown-it')();
* var result = md.render('# markdown-it rulezz!');
*
* // browser without AMD, added to "window" on script load
* // Note, there are no dash.
* var md = window.markdownit();
* var result = md.render('# markdown-it rulezz!');
* ```
*
* Single line rendering, without paragraph wrap:
*
* ```javascript
* var md = require('markdown-it')();
* var result = md.renderInline('__markdown-it__ rulezz!');
* ```
**/
/**
* new MarkdownIt([presetName, options])
* - presetName (String): optional, `commonmark` / `zero`
* - options (Object)
*
* Creates parser instanse with given config. Can be called without `new`.
*
* ##### presetName
*
* MarkdownIt provides named presets as a convenience to quickly
* enable/disable active syntax rules and options for common use cases.
*
* - ["commonmark"](https://github.com/markdown-it/markdown-it/blob/master/lib/presets/commonmark.mjs) -
* configures parser to strict [CommonMark](http://commonmark.org/) mode.
* - [default](https://github.com/markdown-it/markdown-it/blob/master/lib/presets/default.mjs) -
* similar to GFM, used when no preset name given. Enables all available rules,
* but still without html, typographer & autolinker.
* - ["zero"](https://github.com/markdown-it/markdown-it/blob/master/lib/presets/zero.mjs) -
* all rules disabled. Useful to quickly setup your config via `.enable()`.
* For example, when you need only `bold` and `italic` markup and nothing else.
*
* ##### options:
*
* - __html__ - `false`. Set `true` to enable HTML tags in source. Be careful!
* That's not safe! You may need external sanitizer to protect output from XSS.
* It's better to extend features via plugins, instead of enabling HTML.
* - __xhtmlOut__ - `false`. Set `true` to add '/' when closing single tags
* (`<br />`). This is needed only for full CommonMark compatibility. In real
* world you will need HTML output.
* - __breaks__ - `false`. Set `true` to convert `\n` in paragraphs into `<br>`.
* - __langPrefix__ - `language-`. CSS language class prefix for fenced blocks.
* Can be useful for external highlighters.
* - __linkify__ - `false`. Set `true` to autoconvert URL-like text to links.
* - __typographer__ - `false`. Set `true` to enable [some language-neutral
* replacement](https://github.com/markdown-it/markdown-it/blob/master/lib/rules_core/replacements.mjs) +
* quotes beautification (smartquotes).
* - __quotes__ - `“”‘’`, String or Array. Double + single quotes replacement
* pairs, when typographer enabled and smartquotes on. For example, you can
* use `'«»„“'` for Russian, `'„“‚‘'` for German, and
* `['«\xA0', '\xA0»', '‹\xA0', '\xA0›']` for French (including nbsp).
* - __highlight__ - `null`. Highlighter function for fenced code blocks.
* Highlighter `function (str, lang)` should return escaped HTML. It can also
* return empty string if the source was not changed and should be escaped
* externaly. If result starts with <pre... internal wrapper is skipped.
*
* ##### Example
*
* ```javascript
* // commonmark mode
* var md = require('markdown-it')('commonmark');
*
* // default mode
* var md = require('markdown-it')();
*
* // enable everything
* var md = require('markdown-it')({
* html: true,
* linkify: true,
* typographer: true
* });
* ```
*
* ##### Syntax highlighting
*
* ```js
* var hljs = require('highlight.js') // https://highlightjs.org/
*
* var md = require('markdown-it')({
* highlight: function (str, lang) {
* if (lang && hljs.getLanguage(lang)) {
* try {
* return hljs.highlight(str, { language: lang, ignoreIllegals: true }).value;
* } catch (__) {}
* }
*
* return ''; // use external default escaping
* }
* });
* ```
*
* Or with full wrapper override (if you need assign class to `<pre>` or `<code>`):
*
* ```javascript
* var hljs = require('highlight.js') // https://highlightjs.org/
*
* // Actual default values
* var md = require('markdown-it')({
* highlight: function (str, lang) {
* if (lang && hljs.getLanguage(lang)) {
* try {
* return '<pre><code class="hljs">' +
* hljs.highlight(str, { language: lang, ignoreIllegals: true }).value +
* '</code></pre>';
* } catch (__) {}
* }
*
* return '<pre><code class="hljs">' + md.utils.escapeHtml(str) + '</code></pre>';
* }
* });
* ```
*
**/
function MarkdownIt (presetName, options) {
if (!(this instanceof MarkdownIt)) {
return new MarkdownIt(presetName, options)
}
if (!options) {
if (!utils.isString(presetName)) {
options = presetName || {}
presetName = 'default'
}
}
/**
* MarkdownIt#inline -> ParserInline
*
* Instance of [[ParserInline]]. You may need it to add new rules when
* writing plugins. For simple rules control use [[MarkdownIt.disable]] and
* [[MarkdownIt.enable]].
**/
this.inline = new ParserInline()
/**
* MarkdownIt#block -> ParserBlock
*
* Instance of [[ParserBlock]]. You may need it to add new rules when
* writing plugins. For simple rules control use [[MarkdownIt.disable]] and
* [[MarkdownIt.enable]].
**/
this.block = new ParserBlock()
/**
* MarkdownIt#core -> Core
*
* Instance of [[Core]] chain executor. You may need it to add new rules when
* writing plugins. For simple rules control use [[MarkdownIt.disable]] and
* [[MarkdownIt.enable]].
**/
this.core = new ParserCore()
/**
* MarkdownIt#renderer -> Renderer
*
* Instance of [[Renderer]]. Use it to modify output look. Or to add rendering
* rules for new token types, generated by plugins.
*
* ##### Example
*
* ```javascript
* var md = require('markdown-it')();
*
* function myToken(tokens, idx, options, env, self) {
* //...
* return result;
* };
*
* md.renderer.rules['my_token'] = myToken
* ```
*
* See [[Renderer]] docs and [source code](https://github.com/markdown-it/markdown-it/blob/master/lib/renderer.mjs).
**/
this.renderer = new Renderer()
/**
* MarkdownIt#linkify -> LinkifyIt
*
* [linkify-it](https://github.com/markdown-it/linkify-it) instance.
* Used by [linkify](https://github.com/markdown-it/markdown-it/blob/master/lib/rules_core/linkify.mjs)
* rule.
**/
this.linkify = new LinkifyIt()
/**
* MarkdownIt#validateLink(url) -> Boolean
*
* Link validation function. CommonMark allows too much in links. By default
* we disable `javascript:`, `vbscript:`, `file:` schemas, and almost all `data:...` schemas
* except some embedded image types.
*
* You can change this behaviour:
*
* ```javascript
* var md = require('markdown-it')();
* // enable everything
* md.validateLink = function () { return true; }
* ```
**/
this.validateLink = validateLink
/**
* MarkdownIt#normalizeLink(url) -> String
*
* Function used to encode link url to a machine-readable format,
* which includes url-encoding, punycode, etc.
**/
this.normalizeLink = normalizeLink
/**
* MarkdownIt#normalizeLinkText(url) -> String
*
* Function used to decode link url to a human-readable format`
**/
this.normalizeLinkText = normalizeLinkText
// Expose utils & helpers for easy acces from plugins
/**
* MarkdownIt#utils -> utils
*
* Assorted utility functions, useful to write plugins. See details
* [here](https://github.com/markdown-it/markdown-it/blob/master/lib/common/utils.mjs).
**/
this.utils = utils
/**
* MarkdownIt#helpers -> helpers
*
* Link components parser functions, useful to write plugins. See details
* [here](https://github.com/markdown-it/markdown-it/blob/master/lib/helpers).
**/
this.helpers = utils.assign({}, helpers)
this.options = {}
this.configure(presetName)
if (options) { this.set(options) }
}
/** chainable
* MarkdownIt.set(options)
*
* Set parser options (in the same format as in constructor). Probably, you
* will never need it, but you can change options after constructor call.
*
* ##### Example
*
* ```javascript
* var md = require('markdown-it')()
* .set({ html: true, breaks: true })
* .set({ typographer: true });
* ```
*
* __Note:__ To achieve the best possible performance, don't modify a
* `markdown-it` instance options on the fly. If you need multiple configurations
* it's best to create multiple instances and initialize each with separate
* config.
**/
MarkdownIt.prototype.set = function (options) {
utils.assign(this.options, options)
return this
}
/** chainable, internal
* MarkdownIt.configure(presets)
*
* Batch load of all options and compenent settings. This is internal method,
* and you probably will not need it. But if you will - see available presets
* and data structure [here](https://github.com/markdown-it/markdown-it/tree/master/lib/presets)
*
* We strongly recommend to use presets instead of direct config loads. That
* will give better compatibility with next versions.
**/
MarkdownIt.prototype.configure = function (presets) {
const self = this
if (utils.isString(presets)) {
const presetName = presets
presets = config[presetName]
if (!presets) { throw new Error('Wrong `markdown-it` preset "' + presetName + '", check name') }
}
if (!presets) { throw new Error('Wrong `markdown-it` preset, can\'t be empty') }
if (presets.options) { self.set(presets.options) }
if (presets.components) {
Object.keys(presets.components).forEach(function (name) {
if (presets.components[name].rules) {
self[name].ruler.enableOnly(presets.components[name].rules)
}
if (presets.components[name].rules2) {
self[name].ruler2.enableOnly(presets.components[name].rules2)
}
})
}
return this
}
/** chainable
* MarkdownIt.enable(list, ignoreInvalid)
* - list (String|Array): rule name or list of rule names to enable
* - ignoreInvalid (Boolean): set `true` to ignore errors when rule not found.
*
* Enable list or rules. It will automatically find appropriate components,
* containing rules with given names. If rule not found, and `ignoreInvalid`
* not set - throws exception.
*
* ##### Example
*
* ```javascript
* var md = require('markdown-it')()
* .enable(['sub', 'sup'])
* .disable('smartquotes');
* ```
**/
MarkdownIt.prototype.enable = function (list, ignoreInvalid) {
let result = []
if (!Array.isArray(list)) { list = [list] }
['core', 'block', 'inline'].forEach(function (chain) {
result = result.concat(this[chain].ruler.enable(list, true))
}, this)
result = result.concat(this.inline.ruler2.enable(list, true))
const missed = list.filter(function (name) { return result.indexOf(name) < 0 })
if (missed.length && !ignoreInvalid) {
throw new Error('MarkdownIt. Failed to enable unknown rule(s): ' + missed)
}
return this
}
/** chainable
* MarkdownIt.disable(list, ignoreInvalid)
* - list (String|Array): rule name or list of rule names to disable.
* - ignoreInvalid (Boolean): set `true` to ignore errors when rule not found.
*
* The same as [[MarkdownIt.enable]], but turn specified rules off.
**/
MarkdownIt.prototype.disable = function (list, ignoreInvalid) {
let result = []
if (!Array.isArray(list)) { list = [list] }
['core', 'block', 'inline'].forEach(function (chain) {
result = result.concat(this[chain].ruler.disable(list, true))
}, this)
result = result.concat(this.inline.ruler2.disable(list, true))
const missed = list.filter(function (name) { return result.indexOf(name) < 0 })
if (missed.length && !ignoreInvalid) {
throw new Error('MarkdownIt. Failed to disable unknown rule(s): ' + missed)
}
return this
}
/** chainable
* MarkdownIt.use(plugin, params)
*
* Load specified plugin with given params into current parser instance.
* It's just a sugar to call `plugin(md, params)` with curring.
*
* ##### Example
*
* ```javascript
* var iterator = require('markdown-it-for-inline');
* var md = require('markdown-it')()
* .use(iterator, 'foo_replace', 'text', function (tokens, idx) {
* tokens[idx].content = tokens[idx].content.replace(/foo/g, 'bar');
* });
* ```
**/
MarkdownIt.prototype.use = function (plugin /*, params, ... */) {
const args = [this].concat(Array.prototype.slice.call(arguments, 1))
plugin.apply(plugin, args)
return this
}
/** internal
* MarkdownIt.parse(src, env) -> Array
* - src (String): source string
* - env (Object): environment sandbox
*
* Parse input string and return list of block tokens (special token type
* "inline" will contain list of inline tokens). You should not call this
* method directly, until you write custom renderer (for example, to produce
* AST).
*
* `env` is used to pass data between "distributed" rules and return additional
* metadata like reference info, needed for the renderer. It also can be used to
* inject data in specific cases. Usually, you will be ok to pass `{}`,
* and then pass updated object to renderer.
**/
MarkdownIt.prototype.parse = function (src, env) {
if (typeof src !== 'string') {
throw new Error('Input data should be a String')
}
const state = new this.core.State(src, this, env)
this.core.process(state)
return state.tokens
}
/**
* MarkdownIt.render(src [, env]) -> String
* - src (String): source string
* - env (Object): environment sandbox
*
* Render markdown string into html. It does all magic for you :).
*
* `env` can be used to inject additional metadata (`{}` by default).
* But you will not need it with high probability. See also comment
* in [[MarkdownIt.parse]].
**/
MarkdownIt.prototype.render = function (src, env) {
env = env || {}
return this.renderer.render(this.parse(src, env), this.options, env)
}
/** internal
* MarkdownIt.parseInline(src, env) -> Array
* - src (String): source string
* - env (Object): environment sandbox
*
* The same as [[MarkdownIt.parse]] but skip all block rules. It returns the
* block tokens list with the single `inline` element, containing parsed inline
* tokens in `children` property. Also updates `env` object.
**/
MarkdownIt.prototype.parseInline = function (src, env) {
const state = new this.core.State(src, this, env)
state.inlineMode = true
this.core.process(state)
return state.tokens
}
/**
* MarkdownIt.renderInline(src [, env]) -> String
* - src (String): source string
* - env (Object): environment sandbox
*
* Similar to [[MarkdownIt.render]] but for single paragraph content. Result
* will NOT be wrapped into `<p>` tags.
**/
MarkdownIt.prototype.renderInline = function (src, env) {
env = env || {}
return this.renderer.render(this.parseInline(src, env), this.options, env)
}
export default MarkdownIt
@@ -0,0 +1,134 @@
/** internal
* class ParserBlock
*
* Block-level tokenizer.
**/
import Ruler from './ruler.mjs'
import StateBlock from './rules_block/state_block.mjs'
import r_table from './rules_block/table.mjs'
import r_code from './rules_block/code.mjs'
import r_fence from './rules_block/fence.mjs'
import r_blockquote from './rules_block/blockquote.mjs'
import r_hr from './rules_block/hr.mjs'
import r_list from './rules_block/list.mjs'
import r_reference from './rules_block/reference.mjs'
import r_html_block from './rules_block/html_block.mjs'
import r_heading from './rules_block/heading.mjs'
import r_lheading from './rules_block/lheading.mjs'
import r_paragraph from './rules_block/paragraph.mjs'
const _rules = [
// First 2 params - rule name & source. Secondary array - list of rules,
// which can be terminated by this one.
['table', r_table, ['paragraph', 'reference']],
['code', r_code],
['fence', r_fence, ['paragraph', 'reference', 'blockquote', 'list']],
['blockquote', r_blockquote, ['paragraph', 'reference', 'blockquote', 'list']],
['hr', r_hr, ['paragraph', 'reference', 'blockquote', 'list']],
['list', r_list, ['paragraph', 'reference', 'blockquote']],
['reference', r_reference],
['html_block', r_html_block, ['paragraph', 'reference', 'blockquote']],
['heading', r_heading, ['paragraph', 'reference', 'blockquote']],
['lheading', r_lheading],
['paragraph', r_paragraph]
]
/**
* new ParserBlock()
**/
function ParserBlock () {
/**
* ParserBlock#ruler -> Ruler
*
* [[Ruler]] instance. Keep configuration of block rules.
**/
this.ruler = new Ruler()
for (let i = 0; i < _rules.length; i++) {
this.ruler.push(_rules[i][0], _rules[i][1], { alt: (_rules[i][2] || []).slice() })
}
}
// Generate tokens for input range
//
ParserBlock.prototype.tokenize = function (state, startLine, endLine) {
const rules = this.ruler.getRules('')
const len = rules.length
const maxNesting = state.md.options.maxNesting
let line = startLine
let hasEmptyLines = false
while (line < endLine) {
state.line = line = state.skipEmptyLines(line)
if (line >= endLine) { break }
// Termination condition for nested calls.
// Nested calls currently used for blockquotes & lists
if (state.sCount[line] < state.blkIndent) { break }
// If nesting level exceeded - skip tail to the end. That's not ordinary
// situation and we should not care about content.
if (state.level >= maxNesting) {
state.line = endLine
break
}
// Try all possible rules.
// On success, rule should:
//
// - update `state.line`
// - update `state.tokens`
// - return true
const prevLine = state.line
let ok = false
for (let i = 0; i < len; i++) {
ok = rules[i](state, line, endLine, false)
if (ok) {
if (prevLine >= state.line) {
throw new Error("block rule didn't increment state.line")
}
break
}
}
// this can only happen if user disables paragraph rule
if (!ok) throw new Error('none of the block rules matched')
// set state.tight if we had an empty line before current tag
// i.e. latest empty line should not count
state.tight = !hasEmptyLines
// paragraph might "eat" one newline after it in nested lists
if (state.isEmpty(state.line - 1)) {
hasEmptyLines = true
}
line = state.line
if (line < endLine && state.isEmpty(line)) {
hasEmptyLines = true
line++
state.line = line
}
}
}
/**
* ParserBlock.parse(str, md, env, outTokens)
*
* Process input string and push block tokens into `outTokens`
**/
ParserBlock.prototype.parse = function (src, md, env, outTokens) {
if (!src) { return }
const state = new this.State(src, md, env, outTokens)
this.tokenize(state, state.line, state.lineMax)
}
ParserBlock.prototype.State = StateBlock
export default ParserBlock
@@ -0,0 +1,62 @@
/** internal
* class Core
*
* Top-level rules executor. Glues block/inline parsers and does intermediate
* transformations.
**/
import Ruler from './ruler.mjs'
import StateCore from './rules_core/state_core.mjs'
import r_normalize from './rules_core/normalize.mjs'
import r_block from './rules_core/block.mjs'
import r_inline from './rules_core/inline.mjs'
import r_linkify from './rules_core/linkify.mjs'
import r_replacements from './rules_core/replacements.mjs'
import r_smartquotes from './rules_core/smartquotes.mjs'
import r_text_join from './rules_core/text_join.mjs'
const _rules = [
['normalize', r_normalize],
['block', r_block],
['inline', r_inline],
['linkify', r_linkify],
['replacements', r_replacements],
['smartquotes', r_smartquotes],
// `text_join` finds `text_special` tokens (for escape sequences)
// and joins them with the rest of the text
['text_join', r_text_join]
]
/**
* new Core()
**/
function Core () {
/**
* Core#ruler -> Ruler
*
* [[Ruler]] instance. Keep configuration of core rules.
**/
this.ruler = new Ruler()
for (let i = 0; i < _rules.length; i++) {
this.ruler.push(_rules[i][0], _rules[i][1])
}
}
/**
* Core.process(state)
*
* Executes core chain rules.
**/
Core.prototype.process = function (state) {
const rules = this.ruler.getRules('')
for (let i = 0, l = rules.length; i < l; i++) {
rules[i](state)
}
}
Core.prototype.State = StateCore
export default Core
@@ -0,0 +1,197 @@
/** internal
* class ParserInline
*
* Tokenizes paragraph content.
**/
import Ruler from './ruler.mjs'
import StateInline from './rules_inline/state_inline.mjs'
import r_text from './rules_inline/text.mjs'
import r_linkify from './rules_inline/linkify.mjs'
import r_newline from './rules_inline/newline.mjs'
import r_escape from './rules_inline/escape.mjs'
import r_backticks from './rules_inline/backticks.mjs'
import r_strikethrough from './rules_inline/strikethrough.mjs'
import r_emphasis from './rules_inline/emphasis.mjs'
import r_link from './rules_inline/link.mjs'
import r_image from './rules_inline/image.mjs'
import r_autolink from './rules_inline/autolink.mjs'
import r_html_inline from './rules_inline/html_inline.mjs'
import r_entity from './rules_inline/entity.mjs'
import r_balance_pairs from './rules_inline/balance_pairs.mjs'
import r_fragments_join from './rules_inline/fragments_join.mjs'
// Parser rules
const _rules = [
['text', r_text],
['linkify', r_linkify],
['newline', r_newline],
['escape', r_escape],
['backticks', r_backticks],
['strikethrough', r_strikethrough.tokenize],
['emphasis', r_emphasis.tokenize],
['link', r_link],
['image', r_image],
['autolink', r_autolink],
['html_inline', r_html_inline],
['entity', r_entity]
]
// `rule2` ruleset was created specifically for emphasis/strikethrough
// post-processing and may be changed in the future.
//
// Don't use this for anything except pairs (plugins working with `balance_pairs`).
//
const _rules2 = [
['balance_pairs', r_balance_pairs],
['strikethrough', r_strikethrough.postProcess],
['emphasis', r_emphasis.postProcess],
// rules for pairs separate '**' into its own text tokens, which may be left unused,
// rule below merges unused segments back with the rest of the text
['fragments_join', r_fragments_join]
]
/**
* new ParserInline()
**/
function ParserInline () {
/**
* ParserInline#ruler -> Ruler
*
* [[Ruler]] instance. Keep configuration of inline rules.
**/
this.ruler = new Ruler()
for (let i = 0; i < _rules.length; i++) {
this.ruler.push(_rules[i][0], _rules[i][1])
}
/**
* ParserInline#ruler2 -> Ruler
*
* [[Ruler]] instance. Second ruler used for post-processing
* (e.g. in emphasis-like rules).
**/
this.ruler2 = new Ruler()
for (let i = 0; i < _rules2.length; i++) {
this.ruler2.push(_rules2[i][0], _rules2[i][1])
}
}
// Skip single token by running all rules in validation mode;
// returns `true` if any rule reported success
//
ParserInline.prototype.skipToken = function (state) {
const pos = state.pos
const rules = this.ruler.getRules('')
const len = rules.length
const maxNesting = state.md.options.maxNesting
const cache = state.cache
if (typeof cache[pos] !== 'undefined') {
state.pos = cache[pos]
return
}
let ok = false
if (state.level < maxNesting) {
for (let i = 0; i < len; i++) {
// Increment state.level and decrement it later to limit recursion.
// It's harmless to do here, because no tokens are created. But ideally,
// we'd need a separate private state variable for this purpose.
//
state.level++
ok = rules[i](state, true)
state.level--
if (ok) {
if (pos >= state.pos) { throw new Error("inline rule didn't increment state.pos") }
break
}
}
} else {
// Too much nesting, just skip until the end of the paragraph.
//
// NOTE: this will cause links to behave incorrectly in the following case,
// when an amount of `[` is exactly equal to `maxNesting + 1`:
//
// [[[[[[[[[[[[[[[[[[[[[foo]()
//
// TODO: remove this workaround when CM standard will allow nested links
// (we can replace it by preventing links from being parsed in
// validation mode)
//
state.pos = state.posMax
}
if (!ok) { state.pos++ }
cache[pos] = state.pos
}
// Generate tokens for input range
//
ParserInline.prototype.tokenize = function (state) {
const rules = this.ruler.getRules('')
const len = rules.length
const end = state.posMax
const maxNesting = state.md.options.maxNesting
while (state.pos < end) {
// Try all possible rules.
// On success, rule should:
//
// - update `state.pos`
// - update `state.tokens`
// - return true
const prevPos = state.pos
let ok = false
if (state.level < maxNesting) {
for (let i = 0; i < len; i++) {
ok = rules[i](state, false)
if (ok) {
if (prevPos >= state.pos) { throw new Error("inline rule didn't increment state.pos") }
break
}
}
}
if (ok) {
if (state.pos >= end) { break }
continue
}
state.pending += state.src[state.pos++]
}
if (state.pending) {
state.pushPending()
}
}
/**
* ParserInline.parse(str, md, env, outTokens)
*
* Process input string and push inline tokens into `outTokens`
**/
ParserInline.prototype.parse = function (str, md, env, outTokens) {
const state = new this.State(str, md, env, outTokens)
this.tokenize(state)
const rules = this.ruler2.getRules('')
const len = rules.length
for (let i = 0; i < len; i++) {
rules[i](state)
}
}
ParserInline.prototype.State = StateInline
export default ParserInline
@@ -0,0 +1,88 @@
// Commonmark default options
export default {
options: {
// Enable HTML tags in source
html: true,
// Use '/' to close single tags (<br />)
xhtmlOut: true,
// Convert '\n' in paragraphs into <br>
breaks: false,
// CSS language prefix for fenced blocks
langPrefix: 'language-',
// autoconvert URL-like texts to links
linkify: false,
// Enable some language-neutral replacements + quotes beautification
typographer: false,
// Double + single quotes replacement pairs, when typographer enabled,
// and smartquotes on. Could be either a String or an Array.
//
// For example, you can use '«»„“' for Russian, '„“‚‘' for German,
// and ['«\xA0', '\xA0»', '‹\xA0', '\xA0›'] for French (including nbsp).
quotes: '\u201c\u201d\u2018\u2019', /* “”‘’ */
// Highlighter function. Should return escaped HTML,
// or '' if the source string is not changed and should be escaped externaly.
// If result starts with <pre... internal wrapper is skipped.
//
// function (/*str, lang*/) { return ''; }
//
highlight: null,
// Internal protection, recursion limit
maxNesting: 20
},
components: {
core: {
rules: [
'normalize',
'block',
'inline',
'text_join'
]
},
block: {
rules: [
'blockquote',
'code',
'fence',
'heading',
'hr',
'html_block',
'lheading',
'list',
'reference',
'paragraph'
]
},
inline: {
rules: [
'autolink',
'backticks',
'emphasis',
'entity',
'escape',
'html_inline',
'image',
'link',
'newline',
'text'
],
rules2: [
'balance_pairs',
'emphasis',
'fragments_join'
]
}
}
}
@@ -0,0 +1,47 @@
// markdown-it default options
export default {
options: {
// Enable HTML tags in source
html: false,
// Use '/' to close single tags (<br />)
xhtmlOut: false,
// Convert '\n' in paragraphs into <br>
breaks: false,
// CSS language prefix for fenced blocks
langPrefix: 'language-',
// autoconvert URL-like texts to links
linkify: false,
// Enable some language-neutral replacements + quotes beautification
typographer: false,
// Double + single quotes replacement pairs, when typographer enabled,
// and smartquotes on. Could be either a String or an Array.
//
// For example, you can use '«»„“' for Russian, '„“‚‘' for German,
// and ['«\xA0', '\xA0»', '‹\xA0', '\xA0›'] for French (including nbsp).
quotes: '\u201c\u201d\u2018\u2019', /* “”‘’ */
// Highlighter function. Should return escaped HTML,
// or '' if the source string is not changed and should be escaped externaly.
// If result starts with <pre... internal wrapper is skipped.
//
// function (/*str, lang*/) { return ''; }
//
highlight: null,
// Internal protection, recursion limit
maxNesting: 100
},
components: {
core: {},
block: {},
inline: {}
}
}
@@ -0,0 +1,70 @@
// "Zero" preset, with nothing enabled. Useful for manual configuring of simple
// modes. For example, to parse bold/italic only.
export default {
options: {
// Enable HTML tags in source
html: false,
// Use '/' to close single tags (<br />)
xhtmlOut: false,
// Convert '\n' in paragraphs into <br>
breaks: false,
// CSS language prefix for fenced blocks
langPrefix: 'language-',
// autoconvert URL-like texts to links
linkify: false,
// Enable some language-neutral replacements + quotes beautification
typographer: false,
// Double + single quotes replacement pairs, when typographer enabled,
// and smartquotes on. Could be either a String or an Array.
//
// For example, you can use '«»„“' for Russian, '„“‚‘' for German,
// and ['«\xA0', '\xA0»', '‹\xA0', '\xA0›'] for French (including nbsp).
quotes: '\u201c\u201d\u2018\u2019', /* “”‘’ */
// Highlighter function. Should return escaped HTML,
// or '' if the source string is not changed and should be escaped externaly.
// If result starts with <pre... internal wrapper is skipped.
//
// function (/*str, lang*/) { return ''; }
//
highlight: null,
// Internal protection, recursion limit
maxNesting: 20
},
components: {
core: {
rules: [
'normalize',
'block',
'inline',
'text_join'
]
},
block: {
rules: [
'paragraph'
]
},
inline: {
rules: [
'text'
],
rules2: [
'balance_pairs',
'fragments_join'
]
}
}
}
@@ -0,0 +1,322 @@
/**
* class Renderer
*
* Generates HTML from parsed token stream. Each instance has independent
* copy of rules. Those can be rewritten with ease. Also, you can add new
* rules if you create plugin and adds new token types.
**/
import { assign, unescapeAll, escapeHtml } from './common/utils.mjs'
const default_rules = {}
default_rules.code_inline = function (tokens, idx, options, env, slf) {
const token = tokens[idx]
return '<code' + slf.renderAttrs(token) + '>' +
escapeHtml(token.content) +
'</code>'
}
default_rules.code_block = function (tokens, idx, options, env, slf) {
const token = tokens[idx]
return '<pre' + slf.renderAttrs(token) + '><code>' +
escapeHtml(tokens[idx].content) +
'</code></pre>\n'
}
default_rules.fence = function (tokens, idx, options, env, slf) {
const token = tokens[idx]
const info = token.info ? unescapeAll(token.info).trim() : ''
let langName = ''
let langAttrs = ''
if (info) {
const arr = info.split(/(\s+)/g)
langName = arr[0]
langAttrs = arr.slice(2).join('')
}
let highlighted
if (options.highlight) {
highlighted = options.highlight(token.content, langName, langAttrs) || escapeHtml(token.content)
} else {
highlighted = escapeHtml(token.content)
}
if (highlighted.indexOf('<pre') === 0) {
return highlighted + '\n'
}
// If language exists, inject class gently, without modifying original token.
// May be, one day we will add .deepClone() for token and simplify this part, but
// now we prefer to keep things local.
if (info) {
const i = token.attrIndex('class')
const tmpAttrs = token.attrs ? token.attrs.slice() : []
if (i < 0) {
tmpAttrs.push(['class', options.langPrefix + langName])
} else {
tmpAttrs[i] = tmpAttrs[i].slice()
tmpAttrs[i][1] += ' ' + options.langPrefix + langName
}
// Fake token just to render attributes
const tmpToken = {
attrs: tmpAttrs
}
return `<pre><code${slf.renderAttrs(tmpToken)}>${highlighted}</code></pre>\n`
}
return `<pre><code${slf.renderAttrs(token)}>${highlighted}</code></pre>\n`
}
default_rules.image = function (tokens, idx, options, env, slf) {
const token = tokens[idx]
// "alt" attr MUST be set, even if empty. Because it's mandatory and
// should be placed on proper position for tests.
//
// Replace content with actual value
token.attrs[token.attrIndex('alt')][1] =
slf.renderInlineAsText(token.children, options, env)
return slf.renderToken(tokens, idx, options)
}
default_rules.hardbreak = function (tokens, idx, options /*, env */) {
return options.xhtmlOut ? '<br />\n' : '<br>\n'
}
default_rules.softbreak = function (tokens, idx, options /*, env */) {
return options.breaks ? (options.xhtmlOut ? '<br />\n' : '<br>\n') : '\n'
}
default_rules.text = function (tokens, idx /*, options, env */) {
return escapeHtml(tokens[idx].content)
}
default_rules.html_block = function (tokens, idx /*, options, env */) {
return tokens[idx].content
}
default_rules.html_inline = function (tokens, idx /*, options, env */) {
return tokens[idx].content
}
/**
* new Renderer()
*
* Creates new [[Renderer]] instance and fill [[Renderer#rules]] with defaults.
**/
function Renderer () {
/**
* Renderer#rules -> Object
*
* Contains render rules for tokens. Can be updated and extended.
*
* ##### Example
*
* ```javascript
* var md = require('markdown-it')();
*
* md.renderer.rules.strong_open = function () { return '<b>'; };
* md.renderer.rules.strong_close = function () { return '</b>'; };
*
* var result = md.renderInline(...);
* ```
*
* Each rule is called as independent static function with fixed signature:
*
* ```javascript
* function my_token_render(tokens, idx, options, env, renderer) {
* // ...
* return renderedHTML;
* }
* ```
*
* See [source code](https://github.com/markdown-it/markdown-it/blob/master/lib/renderer.mjs)
* for more details and examples.
**/
this.rules = assign({}, default_rules)
}
/**
* Renderer.renderAttrs(token) -> String
*
* Render token attributes to string.
**/
Renderer.prototype.renderAttrs = function renderAttrs (token) {
let i, l, result
if (!token.attrs) { return '' }
result = ''
for (i = 0, l = token.attrs.length; i < l; i++) {
result += ' ' + escapeHtml(token.attrs[i][0]) + '="' + escapeHtml(token.attrs[i][1]) + '"'
}
return result
}
/**
* Renderer.renderToken(tokens, idx, options) -> String
* - tokens (Array): list of tokens
* - idx (Numbed): token index to render
* - options (Object): params of parser instance
*
* Default token renderer. Can be overriden by custom function
* in [[Renderer#rules]].
**/
Renderer.prototype.renderToken = function renderToken (tokens, idx, options) {
const token = tokens[idx]
let result = ''
// Tight list paragraphs
if (token.hidden) {
return ''
}
// Insert a newline between hidden paragraph and subsequent opening
// block-level tag.
//
// For example, here we should insert a newline before blockquote:
// - a
// >
//
if (token.block && token.nesting !== -1 && idx && tokens[idx - 1].hidden) {
result += '\n'
}
// Add token name, e.g. `<img`
result += (token.nesting === -1 ? '</' : '<') + token.tag
// Encode attributes, e.g. `<img src="foo"`
result += this.renderAttrs(token)
// Add a slash for self-closing tags, e.g. `<img src="foo" /`
if (token.nesting === 0 && options.xhtmlOut) {
result += ' /'
}
// Check if we need to add a newline after this tag
let needLf = false
if (token.block) {
needLf = true
if (token.nesting === 1) {
if (idx + 1 < tokens.length) {
const nextToken = tokens[idx + 1]
if (nextToken.type === 'inline' || nextToken.hidden) {
// Block-level tag containing an inline tag.
//
needLf = false
} else if (nextToken.nesting === -1 && nextToken.tag === token.tag) {
// Opening tag + closing tag of the same type. E.g. `<li></li>`.
//
needLf = false
}
}
}
}
result += needLf ? '>\n' : '>'
return result
}
/**
* Renderer.renderInline(tokens, options, env) -> String
* - tokens (Array): list on block tokens to render
* - options (Object): params of parser instance
* - env (Object): additional data from parsed input (references, for example)
*
* The same as [[Renderer.render]], but for single token of `inline` type.
**/
Renderer.prototype.renderInline = function (tokens, options, env) {
let result = ''
const rules = this.rules
for (let i = 0, len = tokens.length; i < len; i++) {
const type = tokens[i].type
if (typeof rules[type] !== 'undefined') {
result += rules[type](tokens, i, options, env, this)
} else {
result += this.renderToken(tokens, i, options)
}
}
return result
}
/** internal
* Renderer.renderInlineAsText(tokens, options, env) -> String
* - tokens (Array): list on block tokens to render
* - options (Object): params of parser instance
* - env (Object): additional data from parsed input (references, for example)
*
* Special kludge for image `alt` attributes to conform CommonMark spec.
* Don't try to use it! Spec requires to show `alt` content with stripped markup,
* instead of simple escaping.
**/
Renderer.prototype.renderInlineAsText = function (tokens, options, env) {
let result = ''
for (let i = 0, len = tokens.length; i < len; i++) {
switch (tokens[i].type) {
case 'text':
result += tokens[i].content
break
case 'image':
result += this.renderInlineAsText(tokens[i].children, options, env)
break
case 'html_inline':
case 'html_block':
result += tokens[i].content
break
case 'softbreak':
case 'hardbreak':
result += '\n'
break
default:
// all other tokens are skipped
}
}
return result
}
/**
* Renderer.render(tokens, options, env) -> String
* - tokens (Array): list on block tokens to render
* - options (Object): params of parser instance
* - env (Object): additional data from parsed input (references, for example)
*
* Takes token stream and generates HTML. Probably, you will never need to call
* this method directly.
**/
Renderer.prototype.render = function (tokens, options, env) {
let result = ''
const rules = this.rules
for (let i = 0, len = tokens.length; i < len; i++) {
const type = tokens[i].type
if (type === 'inline') {
result += this.renderInline(tokens[i].children, options, env)
} else if (typeof rules[type] !== 'undefined') {
result += rules[type](tokens, i, options, env, this)
} else {
result += this.renderToken(tokens, i, options, env)
}
}
return result
}
export default Renderer
@@ -0,0 +1,340 @@
/**
* class Ruler
*
* Helper class, used by [[MarkdownIt#core]], [[MarkdownIt#block]] and
* [[MarkdownIt#inline]] to manage sequences of functions (rules):
*
* - keep rules in defined order
* - assign the name to each rule
* - enable/disable rules
* - add/replace rules
* - allow assign rules to additional named chains (in the same)
* - cacheing lists of active rules
*
* You will not need use this class directly until write plugins. For simple
* rules control use [[MarkdownIt.disable]], [[MarkdownIt.enable]] and
* [[MarkdownIt.use]].
**/
/**
* new Ruler()
**/
function Ruler () {
// List of added rules. Each element is:
//
// {
// name: XXX,
// enabled: Boolean,
// fn: Function(),
// alt: [ name2, name3 ]
// }
//
this.__rules__ = []
// Cached rule chains.
//
// First level - chain name, '' for default.
// Second level - diginal anchor for fast filtering by charcodes.
//
this.__cache__ = null
}
// Helper methods, should not be used directly
// Find rule index by name
//
Ruler.prototype.__find__ = function (name) {
for (let i = 0; i < this.__rules__.length; i++) {
if (this.__rules__[i].name === name) {
return i
}
}
return -1
}
// Build rules lookup cache
//
Ruler.prototype.__compile__ = function () {
const self = this
const chains = ['']
// collect unique names
self.__rules__.forEach(function (rule) {
if (!rule.enabled) { return }
rule.alt.forEach(function (altName) {
if (chains.indexOf(altName) < 0) {
chains.push(altName)
}
})
})
self.__cache__ = {}
chains.forEach(function (chain) {
self.__cache__[chain] = []
self.__rules__.forEach(function (rule) {
if (!rule.enabled) { return }
if (chain && rule.alt.indexOf(chain) < 0) { return }
self.__cache__[chain].push(rule.fn)
})
})
}
/**
* Ruler.at(name, fn [, options])
* - name (String): rule name to replace.
* - fn (Function): new rule function.
* - options (Object): new rule options (not mandatory).
*
* Replace rule by name with new function & options. Throws error if name not
* found.
*
* ##### Options:
*
* - __alt__ - array with names of "alternate" chains.
*
* ##### Example
*
* Replace existing typographer replacement rule with new one:
*
* ```javascript
* var md = require('markdown-it')();
*
* md.core.ruler.at('replacements', function replace(state) {
* //...
* });
* ```
**/
Ruler.prototype.at = function (name, fn, options) {
const index = this.__find__(name)
const opt = options || {}
if (index === -1) { throw new Error('Parser rule not found: ' + name) }
this.__rules__[index].fn = fn
this.__rules__[index].alt = opt.alt || []
this.__cache__ = null
}
/**
* Ruler.before(beforeName, ruleName, fn [, options])
* - beforeName (String): new rule will be added before this one.
* - ruleName (String): name of added rule.
* - fn (Function): rule function.
* - options (Object): rule options (not mandatory).
*
* Add new rule to chain before one with given name. See also
* [[Ruler.after]], [[Ruler.push]].
*
* ##### Options:
*
* - __alt__ - array with names of "alternate" chains.
*
* ##### Example
*
* ```javascript
* var md = require('markdown-it')();
*
* md.block.ruler.before('paragraph', 'my_rule', function replace(state) {
* //...
* });
* ```
**/
Ruler.prototype.before = function (beforeName, ruleName, fn, options) {
const index = this.__find__(beforeName)
const opt = options || {}
if (index === -1) { throw new Error('Parser rule not found: ' + beforeName) }
this.__rules__.splice(index, 0, {
name: ruleName,
enabled: true,
fn,
alt: opt.alt || []
})
this.__cache__ = null
}
/**
* Ruler.after(afterName, ruleName, fn [, options])
* - afterName (String): new rule will be added after this one.
* - ruleName (String): name of added rule.
* - fn (Function): rule function.
* - options (Object): rule options (not mandatory).
*
* Add new rule to chain after one with given name. See also
* [[Ruler.before]], [[Ruler.push]].
*
* ##### Options:
*
* - __alt__ - array with names of "alternate" chains.
*
* ##### Example
*
* ```javascript
* var md = require('markdown-it')();
*
* md.inline.ruler.after('text', 'my_rule', function replace(state) {
* //...
* });
* ```
**/
Ruler.prototype.after = function (afterName, ruleName, fn, options) {
const index = this.__find__(afterName)
const opt = options || {}
if (index === -1) { throw new Error('Parser rule not found: ' + afterName) }
this.__rules__.splice(index + 1, 0, {
name: ruleName,
enabled: true,
fn,
alt: opt.alt || []
})
this.__cache__ = null
}
/**
* Ruler.push(ruleName, fn [, options])
* - ruleName (String): name of added rule.
* - fn (Function): rule function.
* - options (Object): rule options (not mandatory).
*
* Push new rule to the end of chain. See also
* [[Ruler.before]], [[Ruler.after]].
*
* ##### Options:
*
* - __alt__ - array with names of "alternate" chains.
*
* ##### Example
*
* ```javascript
* var md = require('markdown-it')();
*
* md.core.ruler.push('my_rule', function replace(state) {
* //...
* });
* ```
**/
Ruler.prototype.push = function (ruleName, fn, options) {
const opt = options || {}
this.__rules__.push({
name: ruleName,
enabled: true,
fn,
alt: opt.alt || []
})
this.__cache__ = null
}
/**
* Ruler.enable(list [, ignoreInvalid]) -> Array
* - list (String|Array): list of rule names to enable.
* - ignoreInvalid (Boolean): set `true` to ignore errors when rule not found.
*
* Enable rules with given names. If any rule name not found - throw Error.
* Errors can be disabled by second param.
*
* Returns list of found rule names (if no exception happened).
*
* See also [[Ruler.disable]], [[Ruler.enableOnly]].
**/
Ruler.prototype.enable = function (list, ignoreInvalid) {
if (!Array.isArray(list)) { list = [list] }
const result = []
// Search by name and enable
list.forEach(function (name) {
const idx = this.__find__(name)
if (idx < 0) {
if (ignoreInvalid) { return }
throw new Error('Rules manager: invalid rule name ' + name)
}
this.__rules__[idx].enabled = true
result.push(name)
}, this)
this.__cache__ = null
return result
}
/**
* Ruler.enableOnly(list [, ignoreInvalid])
* - list (String|Array): list of rule names to enable (whitelist).
* - ignoreInvalid (Boolean): set `true` to ignore errors when rule not found.
*
* Enable rules with given names, and disable everything else. If any rule name
* not found - throw Error. Errors can be disabled by second param.
*
* See also [[Ruler.disable]], [[Ruler.enable]].
**/
Ruler.prototype.enableOnly = function (list, ignoreInvalid) {
if (!Array.isArray(list)) { list = [list] }
this.__rules__.forEach(function (rule) { rule.enabled = false })
this.enable(list, ignoreInvalid)
}
/**
* Ruler.disable(list [, ignoreInvalid]) -> Array
* - list (String|Array): list of rule names to disable.
* - ignoreInvalid (Boolean): set `true` to ignore errors when rule not found.
*
* Disable rules with given names. If any rule name not found - throw Error.
* Errors can be disabled by second param.
*
* Returns list of found rule names (if no exception happened).
*
* See also [[Ruler.enable]], [[Ruler.enableOnly]].
**/
Ruler.prototype.disable = function (list, ignoreInvalid) {
if (!Array.isArray(list)) { list = [list] }
const result = []
// Search by name and disable
list.forEach(function (name) {
const idx = this.__find__(name)
if (idx < 0) {
if (ignoreInvalid) { return }
throw new Error('Rules manager: invalid rule name ' + name)
}
this.__rules__[idx].enabled = false
result.push(name)
}, this)
this.__cache__ = null
return result
}
/**
* Ruler.getRules(chainName) -> Array
*
* Return array of active functions (rules) for given chain name. It analyzes
* rules configuration, compiles caches if not exists and returns result.
*
* Default chain name is `''` (empty string). It can't be skipped. That's
* done intentionally, to keep signature monomorphic for high speed.
**/
Ruler.prototype.getRules = function (chainName) {
if (this.__cache__ === null) {
this.__compile__()
}
// Chain can be empty, if rules disabled. But we still have to return Array.
return this.__cache__[chainName] || []
}
export default Ruler
@@ -0,0 +1,209 @@
// Block quotes
import { isSpace } from '../common/utils.mjs'
export default function blockquote (state, startLine, endLine, silent) {
let pos = state.bMarks[startLine] + state.tShift[startLine]
let max = state.eMarks[startLine]
const oldLineMax = state.lineMax
// if it's indented more than 3 spaces, it should be a code block
if (state.sCount[startLine] - state.blkIndent >= 4) { return false }
// check the block quote marker
if (state.src.charCodeAt(pos) !== 0x3E/* > */) { return false }
// we know that it's going to be a valid blockquote,
// so no point trying to find the end of it in silent mode
if (silent) { return true }
const oldBMarks = []
const oldBSCount = []
const oldSCount = []
const oldTShift = []
const terminatorRules = state.md.block.ruler.getRules('blockquote')
const oldParentType = state.parentType
state.parentType = 'blockquote'
let lastLineEmpty = false
let nextLine
// Search the end of the block
//
// Block ends with either:
// 1. an empty line outside:
// ```
// > test
//
// ```
// 2. an empty line inside:
// ```
// >
// test
// ```
// 3. another tag:
// ```
// > test
// - - -
// ```
for (nextLine = startLine; nextLine < endLine; nextLine++) {
// check if it's outdented, i.e. it's inside list item and indented
// less than said list item:
//
// ```
// 1. anything
// > current blockquote
// 2. checking this line
// ```
const isOutdented = state.sCount[nextLine] < state.blkIndent
pos = state.bMarks[nextLine] + state.tShift[nextLine]
max = state.eMarks[nextLine]
if (pos >= max) {
// Case 1: line is not inside the blockquote, and this line is empty.
break
}
if (state.src.charCodeAt(pos++) === 0x3E/* > */ && !isOutdented) {
// This line is inside the blockquote.
// set offset past spaces and ">"
let initial = state.sCount[nextLine] + 1
let spaceAfterMarker
let adjustTab
// skip one optional space after '>'
if (state.src.charCodeAt(pos) === 0x20 /* space */) {
// ' > test '
// ^ -- position start of line here:
pos++
initial++
adjustTab = false
spaceAfterMarker = true
} else if (state.src.charCodeAt(pos) === 0x09 /* tab */) {
spaceAfterMarker = true
if ((state.bsCount[nextLine] + initial) % 4 === 3) {
// ' >\t test '
// ^ -- position start of line here (tab has width===1)
pos++
initial++
adjustTab = false
} else {
// ' >\t test '
// ^ -- position start of line here + shift bsCount slightly
// to make extra space appear
adjustTab = true
}
} else {
spaceAfterMarker = false
}
let offset = initial
oldBMarks.push(state.bMarks[nextLine])
state.bMarks[nextLine] = pos
while (pos < max) {
const ch = state.src.charCodeAt(pos)
if (isSpace(ch)) {
if (ch === 0x09) {
offset += 4 - (offset + state.bsCount[nextLine] + (adjustTab ? 1 : 0)) % 4
} else {
offset++
}
} else {
break
}
pos++
}
lastLineEmpty = pos >= max
oldBSCount.push(state.bsCount[nextLine])
state.bsCount[nextLine] = state.sCount[nextLine] + 1 + (spaceAfterMarker ? 1 : 0)
oldSCount.push(state.sCount[nextLine])
state.sCount[nextLine] = offset - initial
oldTShift.push(state.tShift[nextLine])
state.tShift[nextLine] = pos - state.bMarks[nextLine]
continue
}
// Case 2: line is not inside the blockquote, and the last line was empty.
if (lastLineEmpty) { break }
// Case 3: another tag found.
let terminate = false
for (let i = 0, l = terminatorRules.length; i < l; i++) {
if (terminatorRules[i](state, nextLine, endLine, true)) {
terminate = true
break
}
}
if (terminate) {
// Quirk to enforce "hard termination mode" for paragraphs;
// normally if you call `tokenize(state, startLine, nextLine)`,
// paragraphs will look below nextLine for paragraph continuation,
// but if blockquote is terminated by another tag, they shouldn't
state.lineMax = nextLine
if (state.blkIndent !== 0) {
// state.blkIndent was non-zero, we now set it to zero,
// so we need to re-calculate all offsets to appear as
// if indent wasn't changed
oldBMarks.push(state.bMarks[nextLine])
oldBSCount.push(state.bsCount[nextLine])
oldTShift.push(state.tShift[nextLine])
oldSCount.push(state.sCount[nextLine])
state.sCount[nextLine] -= state.blkIndent
}
break
}
oldBMarks.push(state.bMarks[nextLine])
oldBSCount.push(state.bsCount[nextLine])
oldTShift.push(state.tShift[nextLine])
oldSCount.push(state.sCount[nextLine])
// A negative indentation means that this is a paragraph continuation
//
state.sCount[nextLine] = -1
}
const oldIndent = state.blkIndent
state.blkIndent = 0
const token_o = state.push('blockquote_open', 'blockquote', 1)
token_o.markup = '>'
const lines = [startLine, 0]
token_o.map = lines
state.md.block.tokenize(state, startLine, nextLine)
const token_c = state.push('blockquote_close', 'blockquote', -1)
token_c.markup = '>'
state.lineMax = oldLineMax
state.parentType = oldParentType
lines[1] = state.line
// Restore original tShift; this might not be necessary since the parser
// has already been here, but just to make sure we can do that.
for (let i = 0; i < oldTShift.length; i++) {
state.bMarks[i + startLine] = oldBMarks[i]
state.tShift[i + startLine] = oldTShift[i]
state.sCount[i + startLine] = oldSCount[i]
state.bsCount[i + startLine] = oldBSCount[i]
}
state.blkIndent = oldIndent
return true
}
@@ -0,0 +1,30 @@
// Code block (4 spaces padded)
export default function code (state, startLine, endLine/*, silent */) {
if (state.sCount[startLine] - state.blkIndent < 4) { return false }
let nextLine = startLine + 1
let last = nextLine
while (nextLine < endLine) {
if (state.isEmpty(nextLine)) {
nextLine++
continue
}
if (state.sCount[nextLine] - state.blkIndent >= 4) {
nextLine++
last = nextLine
continue
}
break
}
state.line = last
const token = state.push('code_block', 'code', 0)
token.content = state.getLines(startLine, last, 4 + state.blkIndent, false) + '\n'
token.map = [startLine, state.line]
return true
}
@@ -0,0 +1,94 @@
// fences (``` lang, ~~~ lang)
export default function fence (state, startLine, endLine, silent) {
let pos = state.bMarks[startLine] + state.tShift[startLine]
let max = state.eMarks[startLine]
// if it's indented more than 3 spaces, it should be a code block
if (state.sCount[startLine] - state.blkIndent >= 4) { return false }
if (pos + 3 > max) { return false }
const marker = state.src.charCodeAt(pos)
if (marker !== 0x7E/* ~ */ && marker !== 0x60 /* ` */) {
return false
}
// scan marker length
let mem = pos
pos = state.skipChars(pos, marker)
let len = pos - mem
if (len < 3) { return false }
const markup = state.src.slice(mem, pos)
const params = state.src.slice(pos, max)
if (marker === 0x60 /* ` */) {
if (params.indexOf(String.fromCharCode(marker)) >= 0) {
return false
}
}
// Since start is found, we can report success here in validation mode
if (silent) { return true }
// search end of block
let nextLine = startLine
let haveEndMarker = false
for (;;) {
nextLine++
if (nextLine >= endLine) {
// unclosed block should be autoclosed by end of document.
// also block seems to be autoclosed by end of parent
break
}
pos = mem = state.bMarks[nextLine] + state.tShift[nextLine]
max = state.eMarks[nextLine]
if (pos < max && state.sCount[nextLine] < state.blkIndent) {
// non-empty line with negative indent should stop the list:
// - ```
// test
break
}
if (state.src.charCodeAt(pos) !== marker) { continue }
if (state.sCount[nextLine] - state.blkIndent >= 4) {
// closing fence should be indented less than 4 spaces
continue
}
pos = state.skipChars(pos, marker)
// closing code fence must be at least as long as the opening one
if (pos - mem < len) { continue }
// make sure tail has spaces only
pos = state.skipSpaces(pos)
if (pos < max) { continue }
haveEndMarker = true
// found!
break
}
// If a fence has heading spaces, they should be removed from its inner block
len = state.sCount[startLine]
state.line = nextLine + (haveEndMarker ? 1 : 0)
const token = state.push('fence', 'code', 0)
token.info = params
token.content = state.getLines(startLine + 1, nextLine, len, true)
token.markup = markup
token.map = [startLine, state.line]
return true
}
@@ -0,0 +1,51 @@
// heading (#, ##, ...)
import { isSpace, asciiTrim } from '../common/utils.mjs'
export default function heading (state, startLine, endLine, silent) {
let pos = state.bMarks[startLine] + state.tShift[startLine]
let max = state.eMarks[startLine]
// if it's indented more than 3 spaces, it should be a code block
if (state.sCount[startLine] - state.blkIndent >= 4) { return false }
let ch = state.src.charCodeAt(pos)
if (ch !== 0x23/* # */ || pos >= max) { return false }
// count heading level
let level = 1
ch = state.src.charCodeAt(++pos)
while (ch === 0x23/* # */ && pos < max && level <= 6) {
level++
ch = state.src.charCodeAt(++pos)
}
if (level > 6 || (pos < max && !isSpace(ch))) { return false }
if (silent) { return true }
// Let's cut tails like ' ### ' from the end of string
max = state.skipSpacesBack(max, pos)
const tmp = state.skipCharsBack(max, 0x23, pos) // #
if (tmp > pos && isSpace(state.src.charCodeAt(tmp - 1))) {
max = tmp
}
state.line = startLine + 1
const token_o = state.push('heading_open', 'h' + String(level), 1)
token_o.markup = '########'.slice(0, level)
token_o.map = [startLine, state.line]
const token_i = state.push('inline', '', 0)
token_i.content = asciiTrim(state.src.slice(pos, max))
token_i.map = [startLine, state.line]
token_i.children = []
const token_c = state.push('heading_close', 'h' + String(level), -1)
token_c.markup = '########'.slice(0, level)
return true
}
@@ -0,0 +1,40 @@
// Horizontal rule
import { isSpace } from '../common/utils.mjs'
export default function hr (state, startLine, endLine, silent) {
const max = state.eMarks[startLine]
// if it's indented more than 3 spaces, it should be a code block
if (state.sCount[startLine] - state.blkIndent >= 4) { return false }
let pos = state.bMarks[startLine] + state.tShift[startLine]
const marker = state.src.charCodeAt(pos++)
// Check hr marker
if (marker !== 0x2A/* * */ &&
marker !== 0x2D/* - */ &&
marker !== 0x5F/* _ */) {
return false
}
// markers can be mixed with spaces, but there should be at least 3 of them
let cnt = 1
while (pos < max) {
const ch = state.src.charCodeAt(pos++)
if (ch !== marker && !isSpace(ch)) { return false }
if (ch === marker) { cnt++ }
}
if (cnt < 3) { return false }
if (silent) { return true }
state.line = startLine + 1
const token = state.push('hr', 'hr', 0)
token.map = [startLine, state.line]
token.markup = Array(cnt + 1).join(String.fromCharCode(marker))
return true
}
@@ -0,0 +1,80 @@
// HTML block
import block_names from '../common/html_blocks.mjs'
import { HTML_OPEN_CLOSE_TAG_RE } from '../common/html_re.mjs'
// An array of opening and corresponding closing sequences for html tags,
// last argument defines whether it can terminate a paragraph or not
//
const HTML_SEQUENCES = [
[/^<(script|pre|style|textarea)(?=(\s|>|$))/i, /<\/(script|pre|style|textarea)>/i, true],
[/^<!--/, /-->/, true],
[/^<\?/, /\?>/, true],
[/^<![A-Za-z]/, />/, true],
[/^<!\[CDATA\[/, /\]\]>/, true],
[new RegExp('^</?(' + block_names.join('|') + ')(?=(\\s|/?>|$))', 'i'), /^$/, true],
[new RegExp(HTML_OPEN_CLOSE_TAG_RE.source + '\\s*$'), /^$/, false]
]
export default function html_block (state, startLine, endLine, silent) {
let pos = state.bMarks[startLine] + state.tShift[startLine]
let max = state.eMarks[startLine]
// if it's indented more than 3 spaces, it should be a code block
if (state.sCount[startLine] - state.blkIndent >= 4) { return false }
if (!state.md.options.html) { return false }
if (state.src.charCodeAt(pos) !== 0x3C/* < */) { return false }
let lineText = state.src.slice(pos, max)
let i = 0
for (; i < HTML_SEQUENCES.length; i++) {
if (HTML_SEQUENCES[i][0].test(lineText)) { break }
}
if (i === HTML_SEQUENCES.length) { return false }
if (silent) {
// true if this sequence can be a terminator, false otherwise
return HTML_SEQUENCES[i][2]
}
let nextLine = startLine + 1
// Block types 6 and 7 (the only ones whose end condition is a blank line)
// have `/^$/` as their closing regexp. For all other types (1-5, e.g.
// `<!--` comments), a blank line is regular content and must not terminate
// the block - it ends only when its closing sequence is found.
const endsOnBlankLine = HTML_SEQUENCES[i][1].test('')
// If we are here - we detected HTML block.
// Let's roll down till block end.
if (!HTML_SEQUENCES[i][1].test(lineText)) {
for (; nextLine < endLine; nextLine++) {
if (state.sCount[nextLine] < state.blkIndent) {
// An outdented blank line shouldn't end a block that doesn't end on a
// blank line (e.g. a `<!--` comment inside a list item). Such blocks
// must continue until their closing sequence regardless of indent.
if (endsOnBlankLine || !state.isEmpty(nextLine)) { break }
}
pos = state.bMarks[nextLine] + state.tShift[nextLine]
max = state.eMarks[nextLine]
lineText = state.src.slice(pos, max)
if (HTML_SEQUENCES[i][1].test(lineText)) {
if (lineText.length !== 0) { nextLine++ }
break
}
}
}
state.line = nextLine
const token = state.push('html_block', '', 0)
token.map = [startLine, nextLine]
token.content = state.getLines(startLine, nextLine, state.blkIndent, true)
return true
}
@@ -0,0 +1,85 @@
// lheading (---, ===)
import { asciiTrim } from '../common/utils.mjs'
export default function lheading (state, startLine, endLine/*, silent */) {
const terminatorRules = state.md.block.ruler.getRules('paragraph')
// if it's indented more than 3 spaces, it should be a code block
if (state.sCount[startLine] - state.blkIndent >= 4) { return false }
const oldParentType = state.parentType
state.parentType = 'paragraph' // use paragraph to match terminatorRules
// jump line-by-line until empty one or EOF
let level = 0
let marker
let nextLine = startLine + 1
for (; nextLine < endLine && !state.isEmpty(nextLine); nextLine++) {
// this would be a code block normally, but after paragraph
// it's considered a lazy continuation regardless of what's there
if (state.sCount[nextLine] - state.blkIndent > 3) { continue }
//
// Check for underline in setext header
//
if (state.sCount[nextLine] >= state.blkIndent) {
let pos = state.bMarks[nextLine] + state.tShift[nextLine]
const max = state.eMarks[nextLine]
if (pos < max) {
marker = state.src.charCodeAt(pos)
if (marker === 0x2D/* - */ || marker === 0x3D/* = */) {
pos = state.skipChars(pos, marker)
pos = state.skipSpaces(pos)
if (pos >= max) {
level = (marker === 0x3D/* = */ ? 1 : 2)
break
}
}
}
}
// quirk for blockquotes, this line should already be checked by that rule
if (state.sCount[nextLine] < 0) { continue }
// Some tags can terminate paragraph without empty line.
let terminate = false
for (let i = 0, l = terminatorRules.length; i < l; i++) {
if (terminatorRules[i](state, nextLine, endLine, true)) {
terminate = true
break
}
}
if (terminate) { break }
}
if (!level) {
// Didn't find valid underline
state.parentType = oldParentType
return false
}
const content = asciiTrim(state.getLines(startLine, nextLine, state.blkIndent, false))
state.line = nextLine + 1
const token_o = state.push('heading_open', 'h' + String(level), 1)
token_o.markup = String.fromCharCode(marker)
token_o.map = [startLine, state.line]
const token_i = state.push('inline', '', 0)
token_i.content = content
token_i.map = [startLine, state.line - 1]
token_i.children = []
const token_c = state.push('heading_close', 'h' + String(level), -1)
token_c.markup = String.fromCharCode(marker)
state.parentType = oldParentType
return true
}
@@ -0,0 +1,331 @@
// Lists
import { isSpace } from '../common/utils.mjs'
// Search `[-+*][\n ]`, returns next pos after marker on success
// or -1 on fail.
function skipBulletListMarker (state, startLine) {
const max = state.eMarks[startLine]
let pos = state.bMarks[startLine] + state.tShift[startLine]
const marker = state.src.charCodeAt(pos++)
// Check bullet
if (marker !== 0x2A/* * */ &&
marker !== 0x2D/* - */ &&
marker !== 0x2B/* + */) {
return -1
}
if (pos < max) {
const ch = state.src.charCodeAt(pos)
if (!isSpace(ch)) {
// " -test " - is not a list item
return -1
}
}
return pos
}
// Search `\d+[.)][\n ]`, returns next pos after marker on success
// or -1 on fail.
function skipOrderedListMarker (state, startLine) {
const start = state.bMarks[startLine] + state.tShift[startLine]
const max = state.eMarks[startLine]
let pos = start
// List marker should have at least 2 chars (digit + dot)
if (pos + 1 >= max) { return -1 }
let ch = state.src.charCodeAt(pos++)
if (ch < 0x30/* 0 */ || ch > 0x39/* 9 */) { return -1 }
for (;;) {
// EOL -> fail
if (pos >= max) { return -1 }
ch = state.src.charCodeAt(pos++)
if (ch >= 0x30/* 0 */ && ch <= 0x39/* 9 */) {
// List marker should have no more than 9 digits
// (prevents integer overflow in browsers)
if (pos - start >= 10) { return -1 }
continue
}
// found valid marker
if (ch === 0x29/* ) */ || ch === 0x2e/* . */) {
break
}
return -1
}
if (pos < max) {
ch = state.src.charCodeAt(pos)
if (!isSpace(ch)) {
// " 1.test " - is not a list item
return -1
}
}
return pos
}
function markTightParagraphs (state, idx) {
const level = state.level + 2
for (let i = idx + 2, l = state.tokens.length - 2; i < l; i++) {
if (state.tokens[i].level === level && state.tokens[i].type === 'paragraph_open') {
state.tokens[i + 2].hidden = true
state.tokens[i].hidden = true
i += 2
}
}
}
export default function list (state, startLine, endLine, silent) {
let max, pos, start, token
let nextLine = startLine
let tight = true
// if it's indented more than 3 spaces, it should be a code block
if (state.sCount[nextLine] - state.blkIndent >= 4) { return false }
// Special case:
// - item 1
// - item 2
// - item 3
// - item 4
// - this one is a paragraph continuation
if (state.listIndent >= 0 &&
state.sCount[nextLine] - state.listIndent >= 4 &&
state.sCount[nextLine] < state.blkIndent) {
return false
}
let isTerminatingParagraph = false
// limit conditions when list can interrupt
// a paragraph (validation mode only)
if (silent && state.parentType === 'paragraph') {
// Next list item should still terminate previous list item;
//
// This code can fail if plugins use blkIndent as well as lists,
// but I hope the spec gets fixed long before that happens.
//
if (state.sCount[nextLine] >= state.blkIndent) {
isTerminatingParagraph = true
}
}
// Detect list type and position after marker
let isOrdered
let markerValue
let posAfterMarker
if ((posAfterMarker = skipOrderedListMarker(state, nextLine)) >= 0) {
isOrdered = true
start = state.bMarks[nextLine] + state.tShift[nextLine]
markerValue = Number(state.src.slice(start, posAfterMarker - 1))
// If we're starting a new ordered list right after
// a paragraph, it should start with 1.
if (isTerminatingParagraph && markerValue !== 1) return false
} else if ((posAfterMarker = skipBulletListMarker(state, nextLine)) >= 0) {
isOrdered = false
} else {
return false
}
// If we're starting a new unordered list right after
// a paragraph, first line should not be empty.
if (isTerminatingParagraph) {
if (state.skipSpaces(posAfterMarker) >= state.eMarks[nextLine]) return false
}
// For validation mode we can terminate immediately
if (silent) { return true }
// We should terminate list on style change. Remember first one to compare.
const markerCharCode = state.src.charCodeAt(posAfterMarker - 1)
// Start list
const listTokIdx = state.tokens.length
if (isOrdered) {
token = state.push('ordered_list_open', 'ol', 1)
if (markerValue !== 1) {
token.attrs = [['start', markerValue]]
}
} else {
token = state.push('bullet_list_open', 'ul', 1)
}
const listLines = [nextLine, 0]
token.map = listLines
token.markup = String.fromCharCode(markerCharCode)
//
// Iterate list items
//
let prevEmptyEnd = false
const terminatorRules = state.md.block.ruler.getRules('list')
const oldParentType = state.parentType
state.parentType = 'list'
while (nextLine < endLine) {
pos = posAfterMarker
max = state.eMarks[nextLine]
const initial = state.sCount[nextLine] + posAfterMarker - (state.bMarks[nextLine] + state.tShift[nextLine])
let offset = initial
while (pos < max) {
const ch = state.src.charCodeAt(pos)
if (ch === 0x09) {
offset += 4 - (offset + state.bsCount[nextLine]) % 4
} else if (ch === 0x20) {
offset++
} else {
break
}
pos++
}
const contentStart = pos
let indentAfterMarker
if (contentStart >= max) {
// trimming space in "- \n 3" case, indent is 1 here
indentAfterMarker = 1
} else {
indentAfterMarker = offset - initial
}
// If we have more than 4 spaces, the indent is 1
// (the rest is just indented code block)
if (indentAfterMarker > 4) { indentAfterMarker = 1 }
// " - test"
// ^^^^^ - calculating total length of this thing
const indent = initial + indentAfterMarker
// Run subparser & write tokens
token = state.push('list_item_open', 'li', 1)
token.markup = String.fromCharCode(markerCharCode)
const itemLines = [nextLine, 0]
token.map = itemLines
if (isOrdered) {
token.info = state.src.slice(start, posAfterMarker - 1)
}
// change current state, then restore it after parser subcall
const oldTight = state.tight
const oldTShift = state.tShift[nextLine]
const oldSCount = state.sCount[nextLine]
// - example list
// ^ listIndent position will be here
// ^ blkIndent position will be here
//
const oldListIndent = state.listIndent
state.listIndent = state.blkIndent
state.blkIndent = indent
state.tight = true
state.tShift[nextLine] = contentStart - state.bMarks[nextLine]
state.sCount[nextLine] = offset
if (contentStart >= max && state.isEmpty(nextLine + 1)) {
// workaround for this case
// (list item is empty, list terminates before "foo"):
// ~~~~~~~~
// -
//
// foo
// ~~~~~~~~
state.line = Math.min(state.line + 2, endLine)
} else {
state.md.block.tokenize(state, nextLine, endLine, true)
}
// If any of list item is tight, mark list as tight
if (!state.tight || prevEmptyEnd) {
tight = false
}
// Item become loose if finish with empty line,
// but we should filter last element, because it means list finish
prevEmptyEnd = (state.line - nextLine) > 1 && state.isEmpty(state.line - 1)
state.blkIndent = state.listIndent
state.listIndent = oldListIndent
state.tShift[nextLine] = oldTShift
state.sCount[nextLine] = oldSCount
state.tight = oldTight
token = state.push('list_item_close', 'li', -1)
token.markup = String.fromCharCode(markerCharCode)
nextLine = state.line
itemLines[1] = nextLine
if (nextLine >= endLine) { break }
//
// Try to check if list is terminated or continued.
//
if (state.sCount[nextLine] < state.blkIndent) { break }
// if it's indented more than 3 spaces, it should be a code block
if (state.sCount[nextLine] - state.blkIndent >= 4) { break }
// fail if terminating block found
let terminate = false
for (let i = 0, l = terminatorRules.length; i < l; i++) {
if (terminatorRules[i](state, nextLine, endLine, true)) {
terminate = true
break
}
}
if (terminate) { break }
// fail if list has another type
if (isOrdered) {
posAfterMarker = skipOrderedListMarker(state, nextLine)
if (posAfterMarker < 0) { break }
start = state.bMarks[nextLine] + state.tShift[nextLine]
} else {
posAfterMarker = skipBulletListMarker(state, nextLine)
if (posAfterMarker < 0) { break }
}
if (markerCharCode !== state.src.charCodeAt(posAfterMarker - 1)) { break }
}
// Finalize list
if (isOrdered) {
token = state.push('ordered_list_close', 'ol', -1)
} else {
token = state.push('bullet_list_close', 'ul', -1)
}
token.markup = String.fromCharCode(markerCharCode)
listLines[1] = nextLine
state.line = nextLine
state.parentType = oldParentType
// mark paragraphs tight if needed
if (tight) {
markTightParagraphs(state, listTokIdx)
}
return true
}
@@ -0,0 +1,48 @@
// Paragraph
import { asciiTrim } from '../common/utils.mjs'
export default function paragraph (state, startLine, endLine) {
const terminatorRules = state.md.block.ruler.getRules('paragraph')
const oldParentType = state.parentType
let nextLine = startLine + 1
state.parentType = 'paragraph'
// jump line-by-line until empty one or EOF
for (; nextLine < endLine && !state.isEmpty(nextLine); nextLine++) {
// this would be a code block normally, but after paragraph
// it's considered a lazy continuation regardless of what's there
if (state.sCount[nextLine] - state.blkIndent > 3) { continue }
// quirk for blockquotes, this line should already be checked by that rule
if (state.sCount[nextLine] < 0) { continue }
// Some tags can terminate paragraph without empty line.
let terminate = false
for (let i = 0, l = terminatorRules.length; i < l; i++) {
if (terminatorRules[i](state, nextLine, endLine, true)) {
terminate = true
break
}
}
if (terminate) { break }
}
const content = asciiTrim(state.getLines(startLine, nextLine, state.blkIndent, false))
state.line = nextLine
const token_o = state.push('paragraph_open', 'p', 1)
token_o.map = [startLine, state.line]
const token_i = state.push('inline', '', 0)
token_i.content = content
token_i.map = [startLine, state.line]
token_i.children = []
state.push('paragraph_close', 'p', -1)
state.parentType = oldParentType
return true
}
@@ -0,0 +1,236 @@
import { isSpace, normalizeReference } from '../common/utils.mjs'
// CommonMark limits reference labels to 999 Unicode code points.
const MAX_LABEL_LENGTH = 999
function countCodePoints (str) {
let length = str.length
for (let i = 0; i < str.length - 1; i++) {
const high = str.charCodeAt(i)
if (high < 0xD800 || high > 0xDBFF) { continue }
const low = str.charCodeAt(i + 1)
if (low >= 0xDC00 && low <= 0xDFFF) {
length--
i++
}
}
return length
}
// Exclusive end offset of the line, after its newline if present
function lineEnd (state, line) {
return Math.min(state.eMarks[line] + 1, state.src.length)
}
// Returns -1 if the reference can't continue on this line
function getNextLinePos (state, nextLine) {
const endLine = state.lineMax
if (nextLine >= endLine || state.isEmpty(nextLine)) {
// empty line or end of input
return -1
}
let isContinuation = false
// this would be a code block normally, but after paragraph
// it's considered a lazy continuation regardless of what's there
if (state.sCount[nextLine] - state.blkIndent > 3) { isContinuation = true }
// quirk for blockquotes, this line should already be checked by that rule
if (state.sCount[nextLine] < 0) { isContinuation = true }
if (!isContinuation) {
const terminatorRules = state.md.block.ruler.getRules('reference')
const oldParentType = state.parentType
state.parentType = 'reference'
// Some tags can terminate paragraph without empty line.
let terminate = false
for (let i = 0, l = terminatorRules.length; i < l; i++) {
if (terminatorRules[i](state, nextLine, endLine, true)) {
terminate = true
break
}
}
state.parentType = oldParentType
if (terminate) {
// terminated by another block
return -1
}
}
return state.bMarks[nextLine] + state.tShift[nextLine]
}
export default function reference (state, startLine, _endLine, silent) {
const src = state.src
let pos = state.bMarks[startLine] + state.tShift[startLine]
let nextLine = startLine + 1
// if it's indented more than 3 spaces, it should be a code block
if (state.sCount[startLine] - state.blkIndent >= 4) { return false }
if (src.charCodeAt(pos) !== 0x5B/* [ */) { return false }
// Scan the source directly: indexing a string after each appended line
// can make V8 copy the accumulated prefix on every continuation.
let max = lineEnd(state, startLine)
let rawLabel = ''
let labelEnd = -1
let segmentStart = ++pos
while (pos < max) {
if (rawLabel.length + pos - segmentStart > MAX_LABEL_LENGTH * 2) { return false }
let ch = src.charCodeAt(pos)
if (ch === 0x5B /* [ */) { return false }
if (ch === 0x5D /* ] */) {
labelEnd = pos
break
}
if (ch === 0x5C /* \ */) { ch = src.charCodeAt(++pos) }
if (ch === 0x0A /* \n */ && pos < max) {
const nextPos = getNextLinePos(state, nextLine)
if (nextPos < 0) { return false }
rawLabel += src.slice(segmentStart, max)
pos = segmentStart = nextPos
max = lineEnd(state, nextLine++)
continue
}
pos++
}
if (labelEnd < 0 || src.charCodeAt(labelEnd + 1) !== 0x3A/* : */) { return false }
rawLabel += src.slice(segmentStart, labelEnd)
if (rawLabel.length > MAX_LABEL_LENGTH && countCodePoints(rawLabel) > MAX_LABEL_LENGTH) {
return false
}
// [label]: destination 'title'
// ^^^ skip optional whitespace here
pos = labelEnd + 2
while (pos < max) {
const ch = src.charCodeAt(pos)
if (ch === 0x0A) {
const nextPos = getNextLinePos(state, nextLine)
if (nextPos < 0) { break }
pos = nextPos
max = lineEnd(state, nextLine++)
} else if (isSpace(ch)) {
pos++
} else {
break
}
}
// [label]: destination 'title'
// ^^^^^^^^^^^ parse this
const destRes = state.md.helpers.parseLinkDestination(src, pos, max)
if (!destRes.ok) { return false }
const href = state.md.normalizeLink(destRes.str)
if (!state.md.validateLink(href)) { return false }
pos = destRes.pos
// save cursor state, we could require to rollback later
const destEndPos = pos
const destEndMax = max
const destEndLineNo = nextLine
// [label]: destination 'title'
// ^^^ skipping those spaces
while (pos < max) {
const ch = src.charCodeAt(pos)
if (ch === 0x0A) {
const nextPos = getNextLinePos(state, nextLine)
if (nextPos < 0) { break }
pos = nextPos
max = lineEnd(state, nextLine++)
} else if (isSpace(ch)) {
pos++
} else {
break
}
}
// [label]: destination 'title'
// ^^^^^^^ parse this
const titleStart = pos
let titleRes = state.md.helpers.parseLinkTitle(src, pos, max)
while (titleRes.can_continue) {
const nextPos = getNextLinePos(state, nextLine)
if (nextPos < 0) break
pos = nextPos
max = lineEnd(state, nextLine++)
titleRes = state.md.helpers.parseLinkTitle(src, pos, max, titleRes)
}
let title
if (titleStart !== destEndPos && titleRes.ok) {
title = titleRes.str
pos = titleRes.pos
} else {
title = ''
pos = destEndPos
max = destEndMax
nextLine = destEndLineNo
}
// skip trailing spaces until the rest of the line
while (pos < max) {
const ch = src.charCodeAt(pos)
if (!isSpace(ch)) { break }
pos++
}
if (pos < max && src.charCodeAt(pos) !== 0x0A) {
if (title) {
// garbage at the end of the line after title,
// but it could still be a valid reference if we roll back
title = ''
pos = destEndPos
max = destEndMax
nextLine = destEndLineNo
while (pos < max) {
const ch = src.charCodeAt(pos)
if (!isSpace(ch)) { break }
pos++
}
}
}
if (pos < max && src.charCodeAt(pos) !== 0x0A) {
// garbage at the end of the line
return false
}
const label = normalizeReference(rawLabel)
if (!label) {
// CommonMark 0.20 disallows empty labels
return false
}
// Reference can not terminate anything. This check is for safety only.
/* istanbul ignore if */
if (silent) { return true }
if (typeof state.env.references === 'undefined') {
state.env.references = {}
}
if (typeof state.env.references[label] === 'undefined') {
state.env.references[label] = { title, href }
}
state.line = nextLine
return true
}
@@ -0,0 +1,220 @@
// Parser state class
import Token from '../token.mjs'
import { isSpace } from '../common/utils.mjs'
function StateBlock (src, md, env, tokens) {
this.src = src
// link to parser instance
this.md = md
this.env = env
//
// Internal state vartiables
//
this.tokens = tokens
this.bMarks = [] // line begin offsets for fast jumps
this.eMarks = [] // line end offsets for fast jumps
this.tShift = [] // offsets of the first non-space characters (tabs not expanded)
this.sCount = [] // indents for each line (tabs expanded)
// An amount of virtual spaces (tabs expanded) between beginning
// of each line (bMarks) and real beginning of that line.
//
// It exists only as a hack because blockquotes override bMarks
// losing information in the process.
//
// It's used only when expanding tabs, you can think about it as
// an initial tab length, e.g. bsCount=21 applied to string `\t123`
// means first tab should be expanded to 4-21%4 === 3 spaces.
//
this.bsCount = []
// block parser variables
// required block content indent (for example, if we are
// inside a list, it would be positioned after list marker)
this.blkIndent = 0
this.line = 0 // line index in src
this.lineMax = 0 // lines count
this.tight = false // loose/tight mode for lists
this.ddIndent = -1 // indent of the current dd block (-1 if there isn't any)
this.listIndent = -1 // indent of the current list block (-1 if there isn't any)
// can be 'blockquote', 'list', 'root', 'paragraph' or 'reference'
// used in lists to determine if they interrupt a paragraph
this.parentType = 'root'
this.level = 0
// Create caches
// Generate markers.
const s = this.src
for (let start = 0, pos = 0, indent = 0, offset = 0, len = s.length, indent_found = false; pos < len; pos++) {
const ch = s.charCodeAt(pos)
if (!indent_found) {
if (isSpace(ch)) {
indent++
if (ch === 0x09) {
offset += 4 - offset % 4
} else {
offset++
}
continue
} else {
indent_found = true
}
}
if (ch === 0x0A || pos === len - 1) {
if (ch !== 0x0A) { pos++ }
this.bMarks.push(start)
this.eMarks.push(pos)
this.tShift.push(indent)
this.sCount.push(offset)
this.bsCount.push(0)
indent_found = false
indent = 0
offset = 0
start = pos + 1
}
}
// Push fake entry to simplify cache bounds checks
this.bMarks.push(s.length)
this.eMarks.push(s.length)
this.tShift.push(0)
this.sCount.push(0)
this.bsCount.push(0)
this.lineMax = this.bMarks.length - 1 // don't count last fake line
}
// Push new token to "stream".
//
StateBlock.prototype.push = function (type, tag, nesting) {
const token = new Token(type, tag, nesting)
token.block = true
if (nesting < 0) this.level-- // closing tag
token.level = this.level
if (nesting > 0) this.level++ // opening tag
this.tokens.push(token)
return token
}
StateBlock.prototype.isEmpty = function isEmpty (line) {
return this.bMarks[line] + this.tShift[line] >= this.eMarks[line]
}
StateBlock.prototype.skipEmptyLines = function skipEmptyLines (from) {
for (let max = this.lineMax; from < max; from++) {
if (this.bMarks[from] + this.tShift[from] < this.eMarks[from]) {
break
}
}
return from
}
// Skip spaces from given position.
StateBlock.prototype.skipSpaces = function skipSpaces (pos) {
for (let max = this.src.length; pos < max; pos++) {
const ch = this.src.charCodeAt(pos)
if (!isSpace(ch)) { break }
}
return pos
}
// Skip spaces from given position in reverse.
StateBlock.prototype.skipSpacesBack = function skipSpacesBack (pos, min) {
if (pos <= min) { return pos }
while (pos > min) {
if (!isSpace(this.src.charCodeAt(--pos))) { return pos + 1 }
}
return pos
}
// Skip char codes from given position
StateBlock.prototype.skipChars = function skipChars (pos, code) {
for (let max = this.src.length; pos < max; pos++) {
if (this.src.charCodeAt(pos) !== code) { break }
}
return pos
}
// Skip char codes reverse from given position - 1
StateBlock.prototype.skipCharsBack = function skipCharsBack (pos, code, min) {
if (pos <= min) { return pos }
while (pos > min) {
if (code !== this.src.charCodeAt(--pos)) { return pos + 1 }
}
return pos
}
// cut lines range from source.
StateBlock.prototype.getLines = function getLines (begin, end, indent, keepLastLF) {
if (begin >= end) {
return ''
}
const queue = new Array(end - begin)
for (let i = 0, line = begin; line < end; line++, i++) {
let lineIndent = 0
const lineStart = this.bMarks[line]
let first = lineStart
let last
if (line + 1 < end || keepLastLF) {
// No need for bounds check because we have fake entry on tail.
last = this.eMarks[line] + 1
} else {
last = this.eMarks[line]
}
while (first < last && lineIndent < indent) {
const ch = this.src.charCodeAt(first)
if (isSpace(ch)) {
if (ch === 0x09) {
lineIndent += 4 - (lineIndent + this.bsCount[line]) % 4
} else {
lineIndent++
}
} else if (first - lineStart < this.tShift[line]) {
// patched tShift masked characters to look like spaces (blockquotes, list markers)
lineIndent++
} else {
break
}
first++
}
if (lineIndent > indent) {
// partially expanding tabs in code blocks, e.g '\t\tfoobar'
// with indent=2 becomes ' \tfoobar'
queue[i] = new Array(lineIndent - indent + 1).join(' ') + this.src.slice(first, last)
} else {
queue[i] = this.src.slice(first, last)
}
}
return queue.join('')
}
// re-export Token class to use in block rules
StateBlock.prototype.Token = Token
export default StateBlock
@@ -0,0 +1,228 @@
// GFM table, https://github.github.com/gfm/#tables-extension-
import { isSpace } from '../common/utils.mjs'
// Limit the amount of empty autocompleted cells in a table,
// see https://github.com/markdown-it/markdown-it/issues/1000,
//
// Both pulldown-cmark and commonmark-hs limit the number of cells this way to ~200k.
// We set it to 65k, which can expand user input by a factor of x370
// (256x256 square is 1.8kB expanded into 650kB).
const MAX_AUTOCOMPLETED_CELLS = 0x10000
function getLine (state, line) {
const pos = state.bMarks[line] + state.tShift[line]
const max = state.eMarks[line]
return state.src.slice(pos, max)
}
function escapedSplit (str) {
const result = []
const max = str.length
let pos = 0
let ch = str.charCodeAt(pos)
let isEscaped = false
let lastPos = 0
let current = ''
while (pos < max) {
if (ch === 0x7c/* | */) {
if (!isEscaped) {
// pipe separating cells, '|'
result.push(current + str.substring(lastPos, pos))
current = ''
lastPos = pos + 1
} else {
// escaped pipe, '\|'
current += str.substring(lastPos, pos - 1)
lastPos = pos
}
}
isEscaped = (ch === 0x5c/* \ */)
pos++
ch = str.charCodeAt(pos)
}
result.push(current + str.substring(lastPos))
return result
}
export default function table (state, startLine, endLine, silent) {
// should have at least two lines
if (startLine + 2 > endLine) { return false }
let nextLine = startLine + 1
if (state.sCount[nextLine] < state.blkIndent) { return false }
// if it's indented more than 3 spaces, it should be a code block
if (state.sCount[nextLine] - state.blkIndent >= 4) { return false }
// first character of the second line should be '|', '-', ':',
// and no other characters are allowed but spaces;
// basically, this is the equivalent of /^[-:|][-:|\s]*$/ regexp
let pos = state.bMarks[nextLine] + state.tShift[nextLine]
if (pos >= state.eMarks[nextLine]) { return false }
const firstCh = state.src.charCodeAt(pos++)
if (firstCh !== 0x7C/* | */ && firstCh !== 0x2D/* - */ && firstCh !== 0x3A/* : */) { return false }
if (pos >= state.eMarks[nextLine]) { return false }
const secondCh = state.src.charCodeAt(pos++)
if (secondCh !== 0x7C/* | */ && secondCh !== 0x2D/* - */ && secondCh !== 0x3A/* : */ && !isSpace(secondCh)) {
return false
}
// if first character is '-', then second character must not be a space
// (due to parsing ambiguity with list)
if (firstCh === 0x2D/* - */ && isSpace(secondCh)) { return false }
while (pos < state.eMarks[nextLine]) {
const ch = state.src.charCodeAt(pos)
if (ch !== 0x7C/* | */ && ch !== 0x2D/* - */ && ch !== 0x3A/* : */ && !isSpace(ch)) { return false }
pos++
}
let lineText = getLine(state, startLine + 1)
let columns = lineText.split('|')
const aligns = []
for (let i = 0; i < columns.length; i++) {
const t = columns[i].trim()
if (!t) {
// allow empty columns before and after table, but not in between columns;
// e.g. allow ` |---| `, disallow ` ---||--- `
if (i === 0 || i === columns.length - 1) {
continue
} else {
return false
}
}
if (!/^:?-+:?$/.test(t)) { return false }
if (t.charCodeAt(t.length - 1) === 0x3A/* : */) {
aligns.push(t.charCodeAt(0) === 0x3A/* : */ ? 'center' : 'right')
} else if (t.charCodeAt(0) === 0x3A/* : */) {
aligns.push('left')
} else {
aligns.push('')
}
}
lineText = getLine(state, startLine).trim()
if (lineText.indexOf('|') === -1) { return false }
if (state.sCount[startLine] - state.blkIndent >= 4) { return false }
columns = escapedSplit(lineText)
if (columns.length && columns[0] === '') columns.shift()
if (columns.length && columns[columns.length - 1] === '') columns.pop()
// header row will define an amount of columns in the entire table,
// and align row should be exactly the same (the rest of the rows can differ)
const columnCount = columns.length
if (columnCount === 0 || columnCount !== aligns.length) { return false }
if (silent) { return true }
const oldParentType = state.parentType
state.parentType = 'table'
// use 'blockquote' lists for termination because it's
// the most similar to tables
const terminatorRules = state.md.block.ruler.getRules('blockquote')
const token_to = state.push('table_open', 'table', 1)
const tableLines = [startLine, 0]
token_to.map = tableLines
const token_tho = state.push('thead_open', 'thead', 1)
token_tho.map = [startLine, startLine + 1]
const token_htro = state.push('tr_open', 'tr', 1)
token_htro.map = [startLine, startLine + 1]
for (let i = 0; i < columns.length; i++) {
const token_ho = state.push('th_open', 'th', 1)
if (aligns[i]) {
token_ho.attrs = [['style', 'text-align:' + aligns[i]]]
}
const token_il = state.push('inline', '', 0)
token_il.content = columns[i].trim()
token_il.children = []
state.push('th_close', 'th', -1)
}
state.push('tr_close', 'tr', -1)
state.push('thead_close', 'thead', -1)
let tbodyLines
let autocompletedCells = 0
for (nextLine = startLine + 2; nextLine < endLine; nextLine++) {
if (state.sCount[nextLine] < state.blkIndent) { break }
let terminate = false
for (let i = 0, l = terminatorRules.length; i < l; i++) {
if (terminatorRules[i](state, nextLine, endLine, true)) {
terminate = true
break
}
}
if (terminate) { break }
lineText = getLine(state, nextLine).trim()
if (!lineText) { break }
if (state.sCount[nextLine] - state.blkIndent >= 4) { break }
columns = escapedSplit(lineText)
if (columns.length && columns[0] === '') columns.shift()
if (columns.length && columns[columns.length - 1] === '') columns.pop()
// note: autocomplete count can be negative if user specifies more columns than header,
// but that does not affect intended use (which is limiting expansion)
autocompletedCells += columnCount - columns.length
if (autocompletedCells > MAX_AUTOCOMPLETED_CELLS) { break }
if (nextLine === startLine + 2) {
const token_tbo = state.push('tbody_open', 'tbody', 1)
token_tbo.map = tbodyLines = [startLine + 2, 0]
}
const token_tro = state.push('tr_open', 'tr', 1)
token_tro.map = [nextLine, nextLine + 1]
for (let i = 0; i < columnCount; i++) {
const token_tdo = state.push('td_open', 'td', 1)
if (aligns[i]) {
token_tdo.attrs = [['style', 'text-align:' + aligns[i]]]
}
const token_il = state.push('inline', '', 0)
token_il.content = columns[i] ? columns[i].trim() : ''
token_il.children = []
state.push('td_close', 'td', -1)
}
state.push('tr_close', 'tr', -1)
}
if (tbodyLines) {
state.push('tbody_close', 'tbody', -1)
tbodyLines[1] = nextLine
}
state.push('table_close', 'table', -1)
tableLines[1] = nextLine
state.parentType = oldParentType
state.line = nextLine
return true
}
@@ -0,0 +1,13 @@
export default function block (state) {
let token
if (state.inlineMode) {
token = new state.Token('inline', '', 0)
token.content = state.src
token.map = [0, 1]
token.children = []
state.tokens.push(token)
} else {
state.md.block.parse(state.src, state.md, state.env, state.tokens)
}
}
@@ -0,0 +1,11 @@
export default function inline (state) {
const tokens = state.tokens
// Parse inlines
for (let i = 0, l = tokens.length; i < l; i++) {
const tok = tokens[i]
if (tok.type === 'inline') {
state.md.inline.parse(tok.content, state.md, state.env, tok.children)
}
}
}
@@ -0,0 +1,160 @@
// Replace link-like texts with link nodes.
//
// Currently restricted by `md.validateLink()` to http/https/ftp
//
function isLinkOpen (str) {
return /^<a[>\s]/i.test(str)
}
function isLinkClose (str) {
return /^<\/a\s*>/i.test(str)
}
export default function linkify (state) {
const blockTokens = state.tokens
if (!state.md.options.linkify) { return }
for (let j = 0, l = blockTokens.length; j < l; j++) {
if (blockTokens[j].type !== 'inline' ||
!state.md.linkify.pretest(blockTokens[j].content)) {
continue
}
const tokens = blockTokens[j].children
const replacements = []
let htmlLinkLevel = 0
// We scan from the end, to keep position when new tags added.
// Use reversed logic in links start/end match
for (let i = tokens.length - 1; i >= 0; i--) {
const currentToken = tokens[i]
// Skip content of markdown links
if (currentToken.type === 'link_close') {
i--
while (tokens[i].level !== currentToken.level && tokens[i].type !== 'link_open') {
i--
}
continue
}
// Skip content of html tag links
if (currentToken.type === 'html_inline') {
if (isLinkOpen(currentToken.content) && htmlLinkLevel > 0) {
htmlLinkLevel--
}
if (isLinkClose(currentToken.content)) {
htmlLinkLevel++
}
}
if (htmlLinkLevel > 0) { continue }
if (currentToken.type === 'text' && state.md.linkify.test(currentToken.content)) {
const text = currentToken.content
let links = state.md.linkify.match(text)
// Now split string to nodes
const nodes = []
let level = currentToken.level
let lastPos = 0
// forbid escape sequence at the start of the string,
// this avoids http\://example.com/ from being linkified as
// http:<a href="//example.com/">//example.com/</a>
if (links.length > 0 &&
links[0].index === 0 &&
i > 0 &&
tokens[i - 1].type === 'text_special') {
links = links.slice(1)
}
for (let ln = 0; ln < links.length; ln++) {
const url = links[ln].url
const fullUrl = state.md.normalizeLink(url)
if (!state.md.validateLink(fullUrl)) { continue }
let urlText = links[ln].text
// Linkifier might send raw hostnames like "example.com", where url
// starts with domain name. So we prepend http:// in those cases,
// and remove it afterwards.
//
if (!links[ln].schema) {
urlText = state.md.normalizeLinkText('http://' + urlText).replace(/^http:\/\//, '')
} else if (links[ln].schema === 'mailto:' && !/^mailto:/i.test(urlText)) {
urlText = state.md.normalizeLinkText('mailto:' + urlText).replace(/^mailto:/, '')
} else {
urlText = state.md.normalizeLinkText(urlText)
}
const pos = links[ln].index
if (pos > lastPos) {
const token = new state.Token('text', '', 0)
token.content = text.slice(lastPos, pos)
token.level = level
nodes.push(token)
}
const token_o = new state.Token('link_open', 'a', 1)
token_o.attrs = [['href', fullUrl]]
token_o.level = level++
token_o.markup = 'linkify'
token_o.info = 'auto'
nodes.push(token_o)
const token_t = new state.Token('text', '', 0)
token_t.content = urlText
token_t.level = level
nodes.push(token_t)
const token_c = new state.Token('link_close', 'a', -1)
token_c.level = --level
token_c.markup = 'linkify'
token_c.info = 'auto'
nodes.push(token_c)
lastPos = links[ln].lastIndex
}
if (lastPos < text.length) {
const token = new state.Token('text', '', 0)
token.content = text.slice(lastPos)
token.level = level
nodes.push(token)
}
replacements.push({ index: i, nodes })
}
}
if (replacements.length > 0) {
let newTokensLength = tokens.length
for (const replacement of replacements) {
newTokensLength += replacement.nodes.length - 1
}
const newTokens = new Array(newTokensLength)
let replacementIndex = 0
let newTokenIndex = 0
replacements.reverse()
for (let i = 0; i < tokens.length; i++) {
const replacement = replacements[replacementIndex]
if (replacement?.index === i) {
for (const node of replacement.nodes) {
newTokens[newTokenIndex++] = node
}
replacementIndex++
} else {
newTokens[newTokenIndex++] = tokens[i]
}
}
blockTokens[j].children = newTokens
}
}
}
@@ -0,0 +1,17 @@
// Normalize input string
// https://spec.commonmark.org/0.29/#line-ending
const NEWLINES_RE = /\r\n?|\n/g
const NULL_RE = /\0/g
export default function normalize (state) {
let str
// Normalize newlines
str = state.src.replace(NEWLINES_RE, '\n')
// Replace NULL characters
str = str.replace(NULL_RE, '\uFFFD')
state.src = str
}
@@ -0,0 +1,101 @@
// Simple typographic replacements
//
// (c) (C) → ©
// (tm) (TM) → ™
// (r) (R) → ®
// +- → ±
// ... → … (also ?.... → ?.., !.... → !..)
// ???????? → ???, !!!!! → !!!, `,,` → `,`
// -- → &ndash;, --- → &mdash;
//
// TODO:
// - fractionals 1/2, 1/4, 3/4 -> ½, ¼, ¾
// - multiplications 2 x 4 -> 2 × 4
const RARE_RE = /\+-|\.\.|\?\?\?\?|!!!!|,,|--/
// Workaround for phantomjs - need regex without /g flag,
// or root check will fail every second time
const SCOPED_ABBR_TEST_RE = /\((c|tm|r)\)/i
const SCOPED_ABBR_RE = /\((c|tm|r)\)/ig
const SCOPED_ABBR = {
c: '©',
r: '®',
tm: '™'
}
function replaceFn (match, name) {
return SCOPED_ABBR[name.toLowerCase()]
}
function replace_scoped (inlineTokens) {
let inside_autolink = 0
for (let i = inlineTokens.length - 1; i >= 0; i--) {
const token = inlineTokens[i]
if (token.type === 'text' && !inside_autolink) {
token.content = token.content.replace(SCOPED_ABBR_RE, replaceFn)
}
if (token.type === 'link_open' && token.info === 'auto') {
inside_autolink--
}
if (token.type === 'link_close' && token.info === 'auto') {
inside_autolink++
}
}
}
function replace_rare (inlineTokens) {
let inside_autolink = 0
for (let i = inlineTokens.length - 1; i >= 0; i--) {
const token = inlineTokens[i]
if (token.type === 'text' && !inside_autolink) {
if (RARE_RE.test(token.content)) {
token.content = token.content
.replace(/\+-/g, '±')
// .., ..., ....... -> …
// but ?..... & !..... -> ?.. & !..
.replace(/\.{2,}/g, '…').replace(/([?!])…/g, '$1..')
.replace(/([?!]){4,}/g, '$1$1$1').replace(/,{2,}/g, ',')
// em-dash
.replace(/(^|[^-])---(?=[^-]|$)/mg, '$1\u2014')
// en-dash
.replace(/(^|\s)--(?=\s|$)/mg, '$1\u2013')
.replace(/(^|[^-\s])--(?=[^-\s]|$)/mg, '$1\u2013')
}
}
if (token.type === 'link_open' && token.info === 'auto') {
inside_autolink--
}
if (token.type === 'link_close' && token.info === 'auto') {
inside_autolink++
}
}
}
export default function replace (state) {
let blkIdx
if (!state.md.options.typographer) { return }
for (blkIdx = state.tokens.length - 1; blkIdx >= 0; blkIdx--) {
if (state.tokens[blkIdx].type !== 'inline') { continue }
if (SCOPED_ABBR_TEST_RE.test(state.tokens[blkIdx].content)) {
replace_scoped(state.tokens[blkIdx].children)
}
if (RARE_RE.test(state.tokens[blkIdx].content)) {
replace_rare(state.tokens[blkIdx].children)
}
}
}
@@ -0,0 +1,230 @@
// Convert straight quotation marks to typographic ones
//
import { isWhiteSpace, isPunctCharCode, isMdAsciiPunct } from '../common/utils.mjs'
const QUOTE_TEST_RE = /['"]/
const QUOTE_RE = /['"]/g
const APOSTROPHE = '\u2019' /* ’ */
// Caps memory on malicious input
const MAX_OPENERS = 1000
function truncateStack (stack, heads, length) {
while (stack.length > length) {
const item = stack.pop()
if (item.isSingleQuote) {
heads.single = item.prevSameQuoteIdx
} else {
heads.double = item.prevSameQuoteIdx
}
}
}
function addReplacement (replacements, tokenIdx, pos, ch) {
if (!replacements[tokenIdx]) {
replacements[tokenIdx] = []
}
replacements[tokenIdx].push({ pos, ch })
}
function applyReplacements (str, replacements) {
let result = ''
let lastPos = 0
replacements.sort((a, b) => a.pos - b.pos)
for (let i = 0; i < replacements.length; i++) {
const replacement = replacements[i]
result += str.slice(lastPos, replacement.pos) + replacement.ch
lastPos = replacement.pos + 1
}
return result + str.slice(lastPos)
}
function process_inlines (tokens, state) {
let j
const stack = []
// stack indexes of topmost openers of each type (-1 if none)
const heads = { single: -1, double: -1 }
// token index -> list of replacements in the original token content
const replacements = {}
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i]
const thisLevel = tokens[i].level
for (j = stack.length - 1; j >= 0; j--) {
if (stack[j].level <= thisLevel) { break }
}
truncateStack(stack, heads, j + 1)
if (token.type !== 'text') { continue }
const text = token.content
let pos = 0
const max = text.length
/* eslint no-labels:0,block-scoped-var:0 */
OUTER:
while (pos < max) {
QUOTE_RE.lastIndex = pos
const t = QUOTE_RE.exec(text)
if (!t) { break }
let canOpen = true
let canClose = true
pos = t.index + 1
const isSingle = (t[0] === "'")
// Find previous character,
// default to space if it's the beginning of the line
//
let lastChar = 0x20
if (t.index - 1 >= 0) {
lastChar = text.charCodeAt(t.index - 1)
} else {
for (j = i - 1; j >= 0; j--) {
if (tokens[j].type === 'softbreak' || tokens[j].type === 'hardbreak') break // lastChar defaults to 0x20
if (!tokens[j].content) continue // should skip all tokens except 'text', 'html_inline' or 'code_inline'
lastChar = tokens[j].content.charCodeAt(tokens[j].content.length - 1)
break
}
}
// Find next character,
// default to space if it's the end of the line
//
let nextChar = 0x20
if (pos < max) {
nextChar = text.charCodeAt(pos)
} else {
for (j = i + 1; j < tokens.length; j++) {
if (tokens[j].type === 'softbreak' || tokens[j].type === 'hardbreak') break // nextChar defaults to 0x20
if (!tokens[j].content) continue // should skip all tokens except 'text', 'html_inline' or 'code_inline'
nextChar = tokens[j].content.charCodeAt(0)
break
}
}
const isLastPunctChar = isMdAsciiPunct(lastChar) || isPunctCharCode(lastChar)
const isNextPunctChar = isMdAsciiPunct(nextChar) || isPunctCharCode(nextChar)
const isLastWhiteSpace = isWhiteSpace(lastChar)
const isNextWhiteSpace = isWhiteSpace(nextChar)
if (isNextWhiteSpace) {
canOpen = false
} else if (isNextPunctChar) {
if (!(isLastWhiteSpace || isLastPunctChar)) {
canOpen = false
}
}
if (isLastWhiteSpace) {
canClose = false
} else if (isLastPunctChar) {
if (!(isNextWhiteSpace || isNextPunctChar)) {
canClose = false
}
}
if (nextChar === 0x22 /* " */ && t[0] === '"') {
if (lastChar >= 0x30 /* 0 */ && lastChar <= 0x39 /* 9 */) {
// special case: 1"" - count first quote as an inch
canClose = canOpen = false
}
}
if (canOpen && canClose) {
// Replace quotes in the middle of punctuation sequence, but not
// in the middle of the words, i.e.:
//
// 1. foo " bar " baz - not replaced
// 2. foo-"-bar-"-baz - replaced
// 3. foo"bar"baz - not replaced
//
canOpen = isLastPunctChar
canClose = isNextPunctChar
}
if (!canOpen && !canClose) {
// middle of word
if (isSingle) {
addReplacement(replacements, i, t.index, APOSTROPHE)
}
continue
}
if (canClose) {
// Stack levels never decrease, so an opener of this type below
// the current level means there is no match
j = isSingle ? heads.single : heads.double
if (j >= 0 && stack[j].level === thisLevel) {
const item = stack[j]
let openQuote
let closeQuote
if (isSingle) {
openQuote = state.md.options.quotes[2]
closeQuote = state.md.options.quotes[3]
} else {
openQuote = state.md.options.quotes[0]
closeQuote = state.md.options.quotes[1]
}
addReplacement(replacements, i, t.index, closeQuote)
addReplacement(replacements, item.tokenIdx, item.contentPos, openQuote)
truncateStack(stack, heads, j)
continue OUTER
}
}
if (canOpen) {
if (stack.length >= MAX_OPENERS) { return }
stack.push({
tokenIdx: i,
contentPos: t.index,
isSingleQuote: isSingle,
level: thisLevel,
// stack index of the previous opener of the same quote type, -1 if none
prevSameQuoteIdx: isSingle ? heads.single : heads.double
})
if (isSingle) {
heads.single = stack.length - 1
} else {
heads.double = stack.length - 1
}
} else if (canClose && isSingle) {
addReplacement(replacements, i, t.index, APOSTROPHE)
}
}
}
Object.keys(replacements).forEach(function (tokenIdx) {
tokens[tokenIdx].content = applyReplacements(tokens[tokenIdx].content, replacements[tokenIdx])
})
}
export default function smartquotes (state) {
/* eslint max-depth:0 */
if (!state.md.options.typographer) { return }
for (let blkIdx = state.tokens.length - 1; blkIdx >= 0; blkIdx--) {
if (state.tokens[blkIdx].type !== 'inline' ||
!QUOTE_TEST_RE.test(state.tokens[blkIdx].content)) {
continue
}
process_inlines(state.tokens[blkIdx].children, state)
}
}
@@ -0,0 +1,17 @@
// Core state object
//
import Token from '../token.mjs'
function StateCore (src, md, env) {
this.src = src
this.env = env
this.tokens = []
this.inlineMode = false
this.md = md // link to parser instance
}
// re-export Token class to use in core rules
StateCore.prototype.Token = Token
export default StateCore
@@ -0,0 +1,43 @@
// Join raw text tokens with the rest of the text
//
// This is set as a separate rule to provide an opportunity for plugins
// to run text replacements after text join, but before escape join.
//
// For example, `\:)` shouldn't be replaced with an emoji.
//
export default function text_join (state) {
let curr, last
const blockTokens = state.tokens
const l = blockTokens.length
for (let j = 0; j < l; j++) {
if (blockTokens[j].type !== 'inline') continue
const tokens = blockTokens[j].children
const max = tokens.length
for (curr = 0; curr < max; curr++) {
if (tokens[curr].type === 'text_special') {
tokens[curr].type = 'text'
}
}
for (curr = last = 0; curr < max; curr++) {
if (tokens[curr].type === 'text' &&
curr + 1 < max &&
tokens[curr + 1].type === 'text') {
// collapse two adjacent text nodes
tokens[curr + 1].content = tokens[curr].content + tokens[curr + 1].content
} else {
if (curr !== last) { tokens[last] = tokens[curr] }
last++
}
}
if (curr !== last) {
tokens.length = last
}
}
}
@@ -0,0 +1,72 @@
// Process autolinks '<protocol:...>'
/* eslint max-len:0 */
const EMAIL_RE = /^([a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*)$/
/* eslint-disable-next-line no-control-regex */
const AUTOLINK_RE = /^([a-zA-Z][a-zA-Z0-9+.-]{1,31}):([^<>\x00-\x20]*)$/
export default function autolink (state, silent) {
let pos = state.pos
if (state.src.charCodeAt(pos) !== 0x3C/* < */) { return false }
const start = state.pos
const max = state.posMax
for (;;) {
if (++pos >= max) return false
const ch = state.src.charCodeAt(pos)
if (ch === 0x3C /* < */) return false
if (ch === 0x3E /* > */) break
}
const url = state.src.slice(start + 1, pos)
if (AUTOLINK_RE.test(url)) {
const fullUrl = state.md.normalizeLink(url)
if (!state.md.validateLink(fullUrl)) { return false }
if (!silent) {
const token_o = state.push('link_open', 'a', 1)
token_o.attrs = [['href', fullUrl]]
token_o.markup = 'autolink'
token_o.info = 'auto'
const token_t = state.push('text', '', 0)
token_t.content = state.md.normalizeLinkText(url)
const token_c = state.push('link_close', 'a', -1)
token_c.markup = 'autolink'
token_c.info = 'auto'
}
state.pos += url.length + 2
return true
}
if (EMAIL_RE.test(url)) {
const fullUrl = state.md.normalizeLink('mailto:' + url)
if (!state.md.validateLink(fullUrl)) { return false }
if (!silent) {
const token_o = state.push('link_open', 'a', 1)
token_o.attrs = [['href', fullUrl]]
token_o.markup = 'autolink'
token_o.info = 'auto'
const token_t = state.push('text', '', 0)
token_t.content = state.md.normalizeLinkText(url)
const token_c = state.push('link_close', 'a', -1)
token_c.markup = 'autolink'
token_c.info = 'auto'
}
state.pos += url.length + 2
return true
}
return false
}
@@ -0,0 +1,60 @@
// Parse backticks
export default function backtick (state, silent) {
let pos = state.pos
const ch = state.src.charCodeAt(pos)
if (ch !== 0x60/* ` */) { return false }
const start = pos
pos++
const max = state.posMax
// scan marker length
while (pos < max && state.src.charCodeAt(pos) === 0x60/* ` */) { pos++ }
const marker = state.src.slice(start, pos)
const openerLength = marker.length
if (state.backticksScanned && (state.backticks[openerLength] || 0) <= start) {
if (!silent) state.pending += marker
state.pos += openerLength
return true
}
let matchEnd = pos
let matchStart
// Nothing found in the cache, scan until the end of the line (or until marker is found)
while ((matchStart = state.src.indexOf('`', matchEnd)) !== -1) {
matchEnd = matchStart + 1
// scan marker length
while (matchEnd < max && state.src.charCodeAt(matchEnd) === 0x60/* ` */) { matchEnd++ }
const closerLength = matchEnd - matchStart
if (closerLength === openerLength) {
// Found matching closer length.
if (!silent) {
const token = state.push('code_inline', 'code', 0)
token.markup = marker
token.content = state.src.slice(pos, matchStart)
.replace(/\n/g, ' ')
.replace(/^ (.+) $/, '$1')
}
state.pos = matchEnd
return true
}
// Some different length found, put it in cache as upper limit of where closer can be found
state.backticks[closerLength] = matchStart
}
// Scanned through the end, didn't find anything
state.backticksScanned = true
if (!silent) state.pending += marker
state.pos += openerLength
return true
}
@@ -0,0 +1,124 @@
// For each opening emphasis-like marker find a matching closing one
//
function processDelimiters (delimiters) {
const openersBottom = {}
const max = delimiters.length
if (!max) return
// headerIdx is the first delimiter of the current (where closer is) delimiter run
let headerIdx = 0
let lastTokenIdx = -2 // needs any value lower than -1
const jumps = []
for (let closerIdx = 0; closerIdx < max; closerIdx++) {
const closer = delimiters[closerIdx]
jumps.push(0)
// markers belong to same delimiter run if:
// - they have adjacent tokens
// - AND markers are the same
//
if (delimiters[headerIdx].marker !== closer.marker || lastTokenIdx !== closer.token - 1) {
headerIdx = closerIdx
}
lastTokenIdx = closer.token
// Length is only used for emphasis-specific "rule of 3",
// if it's not defined (in strikethrough or 3rd party plugins),
// we can default it to 0 to disable those checks.
//
closer.length = closer.length || 0
if (!closer.close) continue
// Previously calculated lower bounds (previous fails)
// for each marker, each delimiter length modulo 3,
// and for whether this closer can be an opener;
// https://github.com/commonmark/cmark/commit/34250e12ccebdc6372b8b49c44fab57c72443460
/* eslint-disable-next-line no-prototype-builtins */
if (!openersBottom.hasOwnProperty(closer.marker)) {
openersBottom[closer.marker] = [-1, -1, -1, -1, -1, -1]
}
const minOpenerIdx = openersBottom[closer.marker][(closer.open ? 3 : 0) + (closer.length % 3)]
let openerIdx = headerIdx - jumps[headerIdx] - 1
let newMinOpenerIdx = openerIdx
for (; openerIdx > minOpenerIdx; openerIdx -= jumps[openerIdx] + 1) {
const opener = delimiters[openerIdx]
if (opener.marker !== closer.marker) continue
if (opener.open && opener.end < 0) {
let isOddMatch = false
// from spec:
//
// If one of the delimiters can both open and close emphasis, then the
// sum of the lengths of the delimiter runs containing the opening and
// closing delimiters must not be a multiple of 3 unless both lengths
// are multiples of 3.
//
if (opener.close || closer.open) {
if ((opener.length + closer.length) % 3 === 0) {
if (opener.length % 3 !== 0 || closer.length % 3 !== 0) {
isOddMatch = true
}
}
}
if (!isOddMatch) {
// If previous delimiter cannot be an opener, we can safely skip
// the entire sequence in future checks. This is required to make
// sure algorithm has linear complexity (see *_*_*_*_*_... case).
//
const lastJump = openerIdx > 0 && !delimiters[openerIdx - 1].open
? jumps[openerIdx - 1] + 1
: 0
jumps[closerIdx] = closerIdx - openerIdx + lastJump
jumps[openerIdx] = lastJump
closer.open = false
opener.end = closerIdx
opener.close = false
newMinOpenerIdx = -1
// treat next token as start of run,
// it optimizes skips in **<...>**a**<...>** pathological case
lastTokenIdx = -2
break
}
}
}
if (newMinOpenerIdx !== -1) {
// If match for this delimiter run failed, we want to set lower bound for
// future lookups. This is required to make sure algorithm has linear
// complexity.
//
// See details here:
// https://github.com/commonmark/cmark/issues/178#issuecomment-270417442
//
openersBottom[closer.marker][(closer.open ? 3 : 0) + ((closer.length || 0) % 3)] = newMinOpenerIdx
}
}
}
export default function link_pairs (state) {
const tokens_meta = state.tokens_meta
const max = state.tokens_meta.length
processDelimiters(state.delimiters)
for (let curr = 0; curr < max; curr++) {
if (tokens_meta[curr] && tokens_meta[curr].delimiters) {
processDelimiters(tokens_meta[curr].delimiters)
}
}
}
@@ -0,0 +1,123 @@
// Process *this* and _that_
//
// Insert each marker as a separate text token, and add it to delimiter list
//
function emphasis_tokenize (state, silent) {
const start = state.pos
const marker = state.src.charCodeAt(start)
if (silent) { return false }
if (marker !== 0x5F /* _ */ && marker !== 0x2A /* * */) { return false }
const scanned = state.scanDelims(state.pos, marker === 0x2A)
for (let i = 0; i < scanned.length; i++) {
const token = state.push('text', '', 0)
token.content = String.fromCharCode(marker)
state.delimiters.push({
// Char code of the starting marker (number).
//
marker,
// Total length of these series of delimiters.
//
length: scanned.length,
// A position of the token this delimiter corresponds to.
//
token: state.tokens.length - 1,
// If this delimiter is matched as a valid opener, `end` will be
// equal to its position, otherwise it's `-1`.
//
end: -1,
// Boolean flags that determine if this delimiter could open or close
// an emphasis.
//
open: scanned.can_open,
close: scanned.can_close
})
}
state.pos += scanned.length
return true
}
function postProcess (state, delimiters) {
const max = delimiters.length
for (let i = max - 1; i >= 0; i--) {
const startDelim = delimiters[i]
if (startDelim.marker !== 0x5F/* _ */ && startDelim.marker !== 0x2A/* * */) {
continue
}
// Process only opening markers
if (startDelim.end === -1) {
continue
}
const endDelim = delimiters[startDelim.end]
// If the previous delimiter has the same marker and is adjacent to this one,
// merge those into one strong delimiter.
//
// `<em><em>whatever</em></em>` -> `<strong>whatever</strong>`
//
const isStrong = i > 0 &&
delimiters[i - 1].end === startDelim.end + 1 &&
// check that first two markers match and adjacent
delimiters[i - 1].marker === startDelim.marker &&
delimiters[i - 1].token === startDelim.token - 1 &&
// check that last two markers are adjacent (we can safely assume they match)
delimiters[startDelim.end + 1].token === endDelim.token + 1
const ch = String.fromCharCode(startDelim.marker)
const token_o = state.tokens[startDelim.token]
token_o.type = isStrong ? 'strong_open' : 'em_open'
token_o.tag = isStrong ? 'strong' : 'em'
token_o.nesting = 1
token_o.markup = isStrong ? ch + ch : ch
token_o.content = ''
const token_c = state.tokens[endDelim.token]
token_c.type = isStrong ? 'strong_close' : 'em_close'
token_c.tag = isStrong ? 'strong' : 'em'
token_c.nesting = -1
token_c.markup = isStrong ? ch + ch : ch
token_c.content = ''
if (isStrong) {
state.tokens[delimiters[i - 1].token].content = ''
state.tokens[delimiters[startDelim.end + 1].token].content = ''
i--
}
}
}
// Walk through delimiter list and replace text tokens with tags
//
function emphasis_post_process (state) {
const tokens_meta = state.tokens_meta
const max = state.tokens_meta.length
postProcess(state, state.delimiters)
for (let curr = 0; curr < max; curr++) {
if (tokens_meta[curr] && tokens_meta[curr].delimiters) {
postProcess(state, tokens_meta[curr].delimiters)
}
}
}
export default {
tokenize: emphasis_tokenize,
postProcess: emphasis_post_process
}
@@ -0,0 +1,51 @@
// Process html entity - &#123;, &#xAF;, &quot;, ...
import { decodeHTMLStrict } from 'entities'
import { isValidEntityCode, fromCodePoint } from '../common/utils.mjs'
const DIGITAL_RE = /^&#((?:x[a-f0-9]{1,6}|[0-9]{1,7}));/i
const NAMED_RE = /^&([a-z][a-z0-9]{1,31});/i
export default function entity (state, silent) {
const pos = state.pos
const max = state.posMax
if (state.src.charCodeAt(pos) !== 0x26/* & */) return false
if (pos + 1 >= max) return false
const ch = state.src.charCodeAt(pos + 1)
if (ch === 0x23 /* # */) {
const match = state.src.slice(pos).match(DIGITAL_RE)
if (match) {
if (!silent) {
const code = match[1][0].toLowerCase() === 'x' ? parseInt(match[1].slice(1), 16) : parseInt(match[1], 10)
const token = state.push('text_special', '', 0)
token.content = isValidEntityCode(code) ? fromCodePoint(code) : fromCodePoint(0xFFFD)
token.markup = match[0]
token.info = 'entity'
}
state.pos += match[0].length
return true
}
} else {
const match = state.src.slice(pos).match(NAMED_RE)
if (match) {
const decoded = decodeHTMLStrict(match[0])
if (decoded !== match[0]) {
if (!silent) {
const token = state.push('text_special', '', 0)
token.content = decoded
token.markup = match[0]
token.info = 'entity'
}
state.pos += match[0].length
return true
}
}
}
return false
}
@@ -0,0 +1,83 @@
// Process escaped chars and hardbreaks
import { isSpace } from '../common/utils.mjs'
const ESCAPED = []
for (let i = 0; i < 256; i++) { ESCAPED.push(0) }
'\\!"#$%&\'()*+,./:;<=>?@[]^_`{|}~-'
.split('').forEach(function (ch) { ESCAPED[ch.charCodeAt(0)] = 1 })
export default function escape (state, silent) {
let pos = state.pos
const max = state.posMax
if (state.src.charCodeAt(pos) !== 0x5C/* \ */) return false
pos++
// '\' at the end of the inline block
if (pos >= max) return false
let ch1 = state.src.charCodeAt(pos)
if (ch1 === 0x0A) {
if (!silent) {
state.push('hardbreak', 'br', 0)
}
pos++
// skip leading whitespaces from next line
while (pos < max) {
ch1 = state.src.charCodeAt(pos)
if (!isSpace(ch1)) break
pos++
}
state.pos = pos
return true
}
// '\' before a space is a literal backslash. Don't consume the space, so a
// trailing two-space hard line break is still detected by the newline rule.
if (ch1 === 0x20) {
if (!silent) {
const token = state.push('text_special', '', 0)
token.content = '\\'
token.markup = '\\'
token.info = 'escape'
}
state.pos = pos
return true
}
let escapedStr = state.src[pos]
if (ch1 >= 0xD800 && ch1 <= 0xDBFF && pos + 1 < max) {
const ch2 = state.src.charCodeAt(pos + 1)
if (ch2 >= 0xDC00 && ch2 <= 0xDFFF) {
escapedStr += state.src[pos + 1]
pos++
}
}
const origStr = '\\' + escapedStr
if (!silent) {
const token = state.push('text_special', '', 0)
if (ch1 < 256 && ESCAPED[ch1] !== 0) {
token.content = escapedStr
} else {
token.content = origStr
}
token.markup = origStr
token.info = 'escape'
}
state.pos = pos + 1
return true
}
@@ -0,0 +1,38 @@
// Clean up tokens after emphasis and strikethrough postprocessing:
// merge adjacent text nodes into one and re-calculate all token levels
//
// This is necessary because initially emphasis delimiter markers (*, _, ~)
// are treated as their own separate text tokens. Then emphasis rule either
// leaves them as text (needed to merge with adjacent text) or turns them
// into opening/closing tags (which messes up levels inside).
//
export default function fragments_join (state) {
let curr, last
let level = 0
const tokens = state.tokens
const max = state.tokens.length
for (curr = last = 0; curr < max; curr++) {
// re-calculate levels after emphasis/strikethrough turns some text nodes
// into opening/closing tags
if (tokens[curr].nesting < 0) level-- // closing tag
tokens[curr].level = level
if (tokens[curr].nesting > 0) level++ // opening tag
if (tokens[curr].type === 'text' &&
curr + 1 < max &&
tokens[curr + 1].type === 'text') {
// collapse two adjacent text nodes
tokens[curr + 1].content = tokens[curr].content + tokens[curr + 1].content
} else {
if (curr !== last) { tokens[last] = tokens[curr] }
last++
}
}
if (curr !== last) {
tokens.length = last
}
}
@@ -0,0 +1,89 @@
// Process html tags
import { HTML_OPEN_CLOSE_TAG_RE } from '../common/html_re.mjs'
function isLinkOpen (str) {
return /^<a[>\s]/i.test(str)
}
function isLinkClose (str) {
return /^<\/a\s*>/i.test(str)
}
function isLetter (ch) {
/* eslint no-bitwise:0 */
const lc = ch | 0x20 // to lower case
return (lc >= 0x61/* a */) && (lc <= 0x7a/* z */)
}
// Comment, processing instruction, declaration or CDATA: fixed opener,
// then everything up to the first terminator. Returns end position or -1.
function scanSpecial (state, pos) {
const src = state.src
let start
let terminator
if (src.startsWith('<!--', pos)) {
if (src.startsWith('>', pos + 4)) { return pos + 5 }
if (src.startsWith('->', pos + 4)) { return pos + 6 }
start = pos + 4
terminator = '-->'
} else if (src.startsWith('<![CDATA[', pos)) {
start = pos + 9
terminator = ']]>'
} else if (src.charCodeAt(pos + 1) === 0x3F/* ? */) {
start = pos + 2
terminator = '?>'
} else if (isLetter(src.charCodeAt(pos + 2))) {
start = pos + 3
terminator = '>'
} else {
return -1
}
if (state.skipHtmlSpecial) { return -1 }
const end = src.indexOf(terminator, start)
if (end < 0) {
// One flag for all constructs to keep code simple. Valid constructs after
// an unclosed one in the same paragraph become text, that's acceptable.
state.skipHtmlSpecial = true
return -1
}
return end + terminator.length
}
export default function html_inline (state, silent) {
if (!state.md.options.html) { return false }
// Check start
const max = state.posMax
const pos = state.pos
if (state.src.charCodeAt(pos) !== 0x3C/* < */ ||
pos + 2 >= max) {
return false
}
let end
const ch = state.src.charCodeAt(pos + 1)
if (ch === 0x21/* ! */ || ch === 0x3F/* ? */) {
end = scanSpecial(state, pos)
} else if (ch === 0x2F/* / */ || isLetter(ch)) {
const match = state.src.slice(pos).match(HTML_OPEN_CLOSE_TAG_RE)
end = match ? pos + match[0].length : -1
} else {
return false
}
if (end < 0) { return false }
if (!silent) {
const token = state.push('html_inline', '', 0)
token.content = state.src.slice(pos, end)
if (isLinkOpen(token.content)) state.linkLevel++
if (isLinkClose(token.content)) state.linkLevel--
}
state.pos = end
return true
}
@@ -0,0 +1,138 @@
// Process ![image](<src> "title")
import { normalizeReference, isSpace } from '../common/utils.mjs'
export default function image (state, silent) {
let code, content, label, pos, ref, res, title, start
let href = ''
const oldPos = state.pos
const max = state.posMax
if (state.src.charCodeAt(state.pos) !== 0x21/* ! */) { return false }
if (state.src.charCodeAt(state.pos + 1) !== 0x5B/* [ */) { return false }
const labelStart = state.pos + 2
const labelEnd = state.md.helpers.parseLinkLabel(state, state.pos + 1, false)
// parser failed to find ']', so it's not a valid link
if (labelEnd < 0) { return false }
pos = labelEnd + 1
if (pos < max && state.src.charCodeAt(pos) === 0x28/* ( */) {
//
// Inline link
//
// [link]( <href> "title" )
// ^^ skipping these spaces
pos++
for (; pos < max; pos++) {
code = state.src.charCodeAt(pos)
if (!isSpace(code) && code !== 0x0A) { break }
}
if (pos >= max) { return false }
// [link]( <href> "title" )
// ^^^^^^ parsing link destination
start = pos
res = state.md.helpers.parseLinkDestination(state.src, pos, state.posMax)
if (res.ok) {
href = state.md.normalizeLink(res.str)
if (state.md.validateLink(href)) {
pos = res.pos
} else {
href = ''
}
}
// [link]( <href> "title" )
// ^^ skipping these spaces
start = pos
for (; pos < max; pos++) {
code = state.src.charCodeAt(pos)
if (!isSpace(code) && code !== 0x0A) { break }
}
// [link]( <href> "title" )
// ^^^^^^^ parsing link title
res = state.md.helpers.parseLinkTitle(state.src, pos, state.posMax)
if (pos < max && start !== pos && res.ok) {
title = res.str
pos = res.pos
// [link]( <href> "title" )
// ^^ skipping these spaces
for (; pos < max; pos++) {
code = state.src.charCodeAt(pos)
if (!isSpace(code) && code !== 0x0A) { break }
}
} else {
title = ''
}
if (pos >= max || state.src.charCodeAt(pos) !== 0x29/* ) */) {
state.pos = oldPos
return false
}
pos++
} else {
//
// Link reference
//
if (typeof state.env.references === 'undefined') { return false }
if (pos < max && state.src.charCodeAt(pos) === 0x5B/* [ */) {
start = pos + 1
pos = state.md.helpers.parseLinkLabel(state, pos)
if (pos >= 0) {
label = state.src.slice(start, pos++)
} else {
pos = labelEnd + 1
}
} else {
pos = labelEnd + 1
}
// covers label === '' and label === undefined
// (collapsed reference link and shortcut reference link respectively)
if (!label) { label = state.src.slice(labelStart, labelEnd) }
ref = state.env.references[normalizeReference(label)]
if (!ref) {
state.pos = oldPos
return false
}
href = ref.href
title = ref.title
}
//
// We found the end of the link, and know for a fact it's a valid link;
// so all that's left to do is to call tokenizer.
//
if (!silent) {
content = state.src.slice(labelStart, labelEnd)
const tokens = []
state.md.inline.parse(
content,
state.md,
state.env,
tokens
)
const token = state.push('image', 'img', 0)
const attrs = [['src', href], ['alt', '']]
token.attrs = attrs
token.children = tokens
token.content = content
if (title) {
attrs.push(['title', title])
}
}
state.pos = pos
state.posMax = max
return true
}
@@ -0,0 +1,139 @@
// Process [link](<to> "stuff")
import { normalizeReference, isSpace } from '../common/utils.mjs'
export default function link (state, silent) {
let code, label, res, ref
let href = ''
let title = ''
let start = state.pos
let parseReference = true
if (state.src.charCodeAt(state.pos) !== 0x5B/* [ */) { return false }
const oldPos = state.pos
const max = state.posMax
const labelStart = state.pos + 1
const labelEnd = state.md.helpers.parseLinkLabel(state, state.pos, true)
// parser failed to find ']', so it's not a valid link
if (labelEnd < 0) { return false }
let pos = labelEnd + 1
if (pos < max && state.src.charCodeAt(pos) === 0x28/* ( */) {
//
// Inline link
//
// might have found a valid shortcut link, disable reference parsing
parseReference = false
// [link]( <href> "title" )
// ^^ skipping these spaces
pos++
for (; pos < max; pos++) {
code = state.src.charCodeAt(pos)
if (!isSpace(code) && code !== 0x0A) { break }
}
if (pos >= max) { return false }
// [link]( <href> "title" )
// ^^^^^^ parsing link destination
start = pos
res = state.md.helpers.parseLinkDestination(state.src, pos, state.posMax)
if (res.ok) {
href = state.md.normalizeLink(res.str)
if (state.md.validateLink(href)) {
pos = res.pos
} else {
href = ''
}
// [link]( <href> "title" )
// ^^ skipping these spaces
start = pos
for (; pos < max; pos++) {
code = state.src.charCodeAt(pos)
if (!isSpace(code) && code !== 0x0A) { break }
}
// [link]( <href> "title" )
// ^^^^^^^ parsing link title
res = state.md.helpers.parseLinkTitle(state.src, pos, state.posMax)
if (pos < max && start !== pos && res.ok) {
title = res.str
pos = res.pos
// [link]( <href> "title" )
// ^^ skipping these spaces
for (; pos < max; pos++) {
code = state.src.charCodeAt(pos)
if (!isSpace(code) && code !== 0x0A) { break }
}
}
}
if (pos >= max || state.src.charCodeAt(pos) !== 0x29/* ) */) {
// parsing a valid shortcut link failed, fallback to reference
parseReference = true
}
pos++
}
if (parseReference) {
//
// Link reference
//
if (typeof state.env.references === 'undefined') { return false }
if (pos < max && state.src.charCodeAt(pos) === 0x5B/* [ */) {
start = pos + 1
pos = state.md.helpers.parseLinkLabel(state, pos)
if (pos >= 0) {
label = state.src.slice(start, pos++)
} else {
pos = labelEnd + 1
}
} else {
pos = labelEnd + 1
}
// covers label === '' and label === undefined
// (collapsed reference link and shortcut reference link respectively)
if (!label) { label = state.src.slice(labelStart, labelEnd) }
ref = state.env.references[normalizeReference(label)]
if (!ref) {
state.pos = oldPos
return false
}
href = ref.href
title = ref.title
}
//
// We found the end of the link, and know for a fact it's a valid link;
// so all that's left to do is to call tokenizer.
//
if (!silent) {
state.pos = labelStart
state.posMax = labelEnd
const token_o = state.push('link_open', 'a', 1)
const attrs = [['href', href]]
token_o.attrs = attrs
if (title) {
attrs.push(['title', title])
}
state.linkLevel++
state.md.inline.tokenize(state)
state.linkLevel--
state.push('link_close', 'a', -1)
}
state.pos = pos
state.posMax = max
return true
}
@@ -0,0 +1,81 @@
// Early process links like https://example.org/ to have priority
// over emphasis, etc.
// RFC3986: scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )
function isAsciiAlpha (code) {
return (code >= 0x41 && code <= 0x5A) || (code >= 0x61 && code <= 0x7A)
}
function isSchemeChar (code) {
return (code >= 0x41 && code <= 0x5A) ||
(code >= 0x61 && code <= 0x7A) ||
(code >= 0x30 && code <= 0x39) ||
code === 0x2B || code === 0x2D || code === 0x2E
}
export default function linkify (state, silent) {
if (!state.md.options.linkify) return false
if (state.linkLevel > 0) return false
const pos = state.pos
const max = state.posMax
if (pos + 3 > max) return false
if (state.src.charCodeAt(pos) !== 0x3A/* : */) return false
if (state.src.charCodeAt(pos + 1) !== 0x2F/* / */) return false
if (state.src.charCodeAt(pos + 2) !== 0x2F/* / */) return false
// Search backwards for the scheme, but no more than 10 characters, the length
// of the pending string, or the length of the source prefix.
// Use state.src instead of state.pending because it is faster to access.
const protoMin = pos - Math.min(10, state.pending.length, pos)
let protoStart = pos
while (protoStart > protoMin && isSchemeChar(state.src.charCodeAt(protoStart - 1))) {
protoStart--
}
if (protoStart === pos || !isAsciiAlpha(state.src.charCodeAt(protoStart))) return false
const protoLength = pos - protoStart
const link = state.md.linkify.matchAtStart(state.src.slice(protoStart))
if (!link) return false
let url = link.url
// invalid link, but still detected by linkify somehow;
// need to check to prevent infinite loop below
if (url.length <= protoLength) return false
// disallow '*' at the end of the link (conflicts with emphasis)
// do manual backsearch to avoid perf issues with regex /\*+$/ on "****...****a".
let urlEnd = url.length
while (urlEnd > 0 && url.charCodeAt(urlEnd - 1) === 0x2A/* * */) {
urlEnd--
}
if (urlEnd !== url.length) {
url = url.slice(0, urlEnd)
}
const fullUrl = state.md.normalizeLink(url)
if (!state.md.validateLink(fullUrl)) return false
if (!silent) {
state.pending = state.pending.slice(0, -protoLength)
const token_o = state.push('link_open', 'a', 1)
token_o.attrs = [['href', fullUrl]]
token_o.markup = 'linkify'
token_o.info = 'auto'
const token_t = state.push('text', '', 0)
token_t.content = state.md.normalizeLinkText(url)
const token_c = state.push('link_close', 'a', -1)
token_c.markup = 'linkify'
token_c.info = 'auto'
}
state.pos += url.length - protoLength
return true
}
@@ -0,0 +1,42 @@
// Proceess '\n'
import { isSpace } from '../common/utils.mjs'
export default function newline (state, silent) {
let pos = state.pos
if (state.src.charCodeAt(pos) !== 0x0A/* \n */) { return false }
const pmax = state.pending.length - 1
const max = state.posMax
// ' \n' -> hardbreak
// Lookup in pending chars is bad practice! Don't copy to other rules!
// Pending string is stored in concat mode, indexed lookups will cause
// convertion to flat mode.
if (!silent) {
if (pmax >= 0 && state.pending.charCodeAt(pmax) === 0x20) {
if (pmax >= 1 && state.pending.charCodeAt(pmax - 1) === 0x20) {
// Find whitespaces tail of pending chars.
let ws = pmax - 1
while (ws >= 1 && state.pending.charCodeAt(ws - 1) === 0x20) ws--
state.pending = state.pending.slice(0, ws)
state.push('hardbreak', 'br', 0)
} else {
state.pending = state.pending.slice(0, -1)
state.push('softbreak', 'br', 0)
}
} else {
state.push('softbreak', 'br', 0)
}
}
pos++
// skip heading spaces for next line
while (pos < max && isSpace(state.src.charCodeAt(pos))) { pos++ }
state.pos = pos
return true
}
@@ -0,0 +1,158 @@
// Inline parser state
import Token from '../token.mjs'
import { isWhiteSpace, isPunctCharCode, isMdAsciiPunct } from '../common/utils.mjs'
function StateInline (src, md, env, outTokens) {
this.src = src
this.env = env
this.md = md
this.tokens = outTokens
this.tokens_meta = Array(outTokens.length)
this.pos = 0
this.posMax = this.src.length
this.level = 0
this.pending = ''
this.pendingLevel = 0
// Stores { start: end } pairs. Useful for backtrack
// optimization of pairs parse (emphasis, strikes).
this.cache = {}
// List of emphasis-like delimiters for current tag
this.delimiters = []
// Stack of delimiter lists for upper level tags
this._prev_delimiters = []
// backtick length => last seen position
this.backticks = {}
this.backticksScanned = false
// Set when html comment / PI / declaration / CDATA has no terminator,
// to skip further scans of these constructs
this.skipHtmlSpecial = false
// Counter used to disable inline linkify-it execution
// inside <a> and markdown links
this.linkLevel = 0
}
// Flush pending text
//
StateInline.prototype.pushPending = function () {
const token = new Token('text', '', 0)
token.content = this.pending
token.level = this.pendingLevel
this.tokens.push(token)
this.pending = ''
return token
}
// Push new token to "stream".
// If pending text exists - flush it as text token
//
StateInline.prototype.push = function (type, tag, nesting) {
if (this.pending) {
this.pushPending()
}
const token = new Token(type, tag, nesting)
let token_meta = null
if (nesting < 0) {
// closing tag
this.level--
this.delimiters = this._prev_delimiters.pop()
}
token.level = this.level
if (nesting > 0) {
// opening tag
this.level++
this._prev_delimiters.push(this.delimiters)
this.delimiters = []
token_meta = { delimiters: this.delimiters }
}
this.pendingLevel = this.level
this.tokens.push(token)
this.tokens_meta.push(token_meta)
return token
}
// Scan a sequence of emphasis-like markers, and determine whether
// it can start an emphasis sequence or end an emphasis sequence.
//
// - start - position to scan from (it should point at a valid marker);
// - canSplitWord - determine if these markers can be found inside a word
//
StateInline.prototype.scanDelims = function (start, canSplitWord) {
const max = this.posMax
const marker = this.src.charCodeAt(start)
// Astral characters below are combined manually, because .codePointAt()
// does not guarantee numeric type output. And we don't wish JIT cache issues.
// The broken surrogate pairs are evaluated as U+FFFD to prevent possible
// crashes.
let lastChar
if (start === 0) {
// treat beginning of the line as a whitespace
lastChar = 0x20
} else if (start === 1) {
lastChar = this.src.charCodeAt(0)
if ((lastChar & 0xF800) === 0xD800) { lastChar = 0xFFFD }
} else {
lastChar = this.src.charCodeAt(start - 1)
if ((lastChar & 0xFC00) === 0xDC00) {
// low surrogate => add high one, replace broken pair with U+FFFD
const highSurr = this.src.charCodeAt(start - 2)
lastChar = (highSurr & 0xFC00) === 0xD800
? 0x10000 + ((highSurr - 0xD800) << 10) + (lastChar - 0xDC00)
: 0xFFFD
} else if ((lastChar & 0xFC00) === 0xD800) {
lastChar = 0xFFFD
}
}
let pos = start
while (pos < max && this.src.charCodeAt(pos) === marker) { pos++ }
const count = pos - start
// treat end of the line as a whitespace
let nextChar = pos < max ? this.src.charCodeAt(pos) : 0x20
if ((nextChar & 0xFC00) === 0xD800) {
// high surrogate => add low one, replace broken pair with U+FFFD
const lowSurr = this.src.charCodeAt(pos + 1)
nextChar = (lowSurr & 0xFC00) === 0xDC00
? 0x10000 + ((nextChar - 0xD800) << 10) + (lowSurr - 0xDC00)
: 0xFFFD
} else if ((nextChar & 0xFC00) === 0xDC00) {
nextChar = 0xFFFD
}
const isLastPunctChar = isMdAsciiPunct(lastChar) || isPunctCharCode(lastChar)
const isNextPunctChar = isMdAsciiPunct(nextChar) || isPunctCharCode(nextChar)
const isLastWhiteSpace = isWhiteSpace(lastChar)
const isNextWhiteSpace = isWhiteSpace(nextChar)
const left_flanking =
!isNextWhiteSpace && (!isNextPunctChar || isLastWhiteSpace || isLastPunctChar)
const right_flanking =
!isLastWhiteSpace && (!isLastPunctChar || isNextWhiteSpace || isNextPunctChar)
const can_open = left_flanking && (canSplitWord || !right_flanking || isLastPunctChar)
const can_close = right_flanking && (canSplitWord || !left_flanking || isNextPunctChar)
return { can_open, can_close, length: count }
}
// re-export Token class to use in block rules
StateInline.prototype.Token = Token
export default StateInline
@@ -0,0 +1,127 @@
// ~~strike through~~
//
// Insert each marker as a separate text token, and add it to delimiter list
//
function strikethrough_tokenize (state, silent) {
const start = state.pos
const marker = state.src.charCodeAt(start)
if (silent) { return false }
if (marker !== 0x7E/* ~ */) { return false }
const scanned = state.scanDelims(state.pos, true)
let len = scanned.length
const ch = String.fromCharCode(marker)
if (len < 2) { return false }
let token
if (len % 2) {
token = state.push('text', '', 0)
token.content = ch
len--
}
for (let i = 0; i < len; i += 2) {
token = state.push('text', '', 0)
token.content = ch + ch
state.delimiters.push({
marker,
length: 0, // disable "rule of 3" length checks meant for emphasis
token: state.tokens.length - 1,
end: -1,
open: scanned.can_open,
close: scanned.can_close
})
}
state.pos += scanned.length
return true
}
function postProcess (state, delimiters) {
let token
const loneMarkers = []
const max = delimiters.length
for (let i = 0; i < max; i++) {
const startDelim = delimiters[i]
if (startDelim.marker !== 0x7E/* ~ */) {
continue
}
if (startDelim.end === -1) {
continue
}
const endDelim = delimiters[startDelim.end]
token = state.tokens[startDelim.token]
token.type = 's_open'
token.tag = 's'
token.nesting = 1
token.markup = '~~'
token.content = ''
token = state.tokens[endDelim.token]
token.type = 's_close'
token.tag = 's'
token.nesting = -1
token.markup = '~~'
token.content = ''
if (state.tokens[endDelim.token - 1].type === 'text' &&
state.tokens[endDelim.token - 1].content === '~') {
loneMarkers.push(endDelim.token - 1)
}
}
// If a marker sequence has an odd number of characters, it's splitted
// like this: `~~~~~` -> `~` + `~~` + `~~`, leaving one marker at the
// start of the sequence.
//
// So, we have to move all those markers after subsequent s_close tags.
//
while (loneMarkers.length) {
const i = loneMarkers.pop()
let j = i + 1
while (j < state.tokens.length && state.tokens[j].type === 's_close') {
j++
}
j--
if (i !== j) {
token = state.tokens[j]
state.tokens[j] = state.tokens[i]
state.tokens[i] = token
}
}
}
// Walk through delimiter list and replace text tokens with tags
//
function strikethrough_postProcess (state) {
const tokens_meta = state.tokens_meta
const max = state.tokens_meta.length
postProcess(state, state.delimiters)
for (let curr = 0; curr < max; curr++) {
if (tokens_meta[curr] && tokens_meta[curr].delimiters) {
postProcess(state, tokens_meta[curr].delimiters)
}
}
}
export default {
tokenize: strikethrough_tokenize,
postProcess: strikethrough_postProcess
}
@@ -0,0 +1,86 @@
// Skip text characters for text token, place those to pending buffer
// and increment current pos
// Rule to skip pure text
// '{}$%@~+=:' reserved for extentions
// !, ", #, $, %, &, ', (, ), *, +, ,, -, ., /, :, ;, <, =, >, ?, @, [, \, ], ^, _, `, {, |, }, or ~
// !!!! Don't confuse with "Markdown ASCII Punctuation" chars
// http://spec.commonmark.org/0.15/#ascii-punctuation-character
function isTerminatorChar (ch) {
switch (ch) {
case 0x0A/* \n */:
case 0x21/* ! */:
case 0x23/* # */:
case 0x24/* $ */:
case 0x25/* % */:
case 0x26/* & */:
case 0x2A/* * */:
case 0x2B/* + */:
case 0x2D/* - */:
case 0x3A/* : */:
case 0x3C/* < */:
case 0x3D/* = */:
case 0x3E/* > */:
case 0x40/* @ */:
case 0x5B/* [ */:
case 0x5C/* \ */:
case 0x5D/* ] */:
case 0x5E/* ^ */:
case 0x5F/* _ */:
case 0x60/* ` */:
case 0x7B/* { */:
case 0x7D/* } */:
case 0x7E/* ~ */:
return true
default:
return false
}
}
export default function text (state, silent) {
let pos = state.pos
while (pos < state.posMax && !isTerminatorChar(state.src.charCodeAt(pos))) {
pos++
}
if (pos === state.pos) { return false }
if (!silent) { state.pending += state.src.slice(state.pos, pos) }
state.pos = pos
return true
}
// Alternative implementation, for memory.
//
// It costs 10% of performance, but allows extend terminators list, if place it
// to `ParserInline` property. Probably, will switch to it sometime, such
// flexibility required.
/*
var TERMINATOR_RE = /[\n!#$%&*+\-:<=>@[\\\]^_`{}~]/;
module.exports = function text(state, silent) {
var pos = state.pos,
idx = state.src.slice(pos).search(TERMINATOR_RE);
// first char is terminator -> empty text
if (idx === 0) { return false; }
// no terminator -> text till end of string
if (idx < 0) {
if (!silent) { state.pending += state.src.slice(pos); }
state.pos = state.src.length;
return true;
}
if (!silent) { state.pending += state.src.slice(pos, pos + idx); }
state.pos += idx;
return true;
}; */
@@ -0,0 +1,191 @@
// Token class
/**
* class Token
**/
/**
* new Token(type, tag, nesting)
*
* Create new token and fill passed properties.
**/
function Token (type, tag, nesting) {
/**
* Token#type -> String
*
* Type of the token (string, e.g. "paragraph_open")
**/
this.type = type
/**
* Token#tag -> String
*
* html tag name, e.g. "p"
**/
this.tag = tag
/**
* Token#attrs -> Array
*
* Html attributes. Format: `[ [ name1, value1 ], [ name2, value2 ] ]`
**/
this.attrs = null
/**
* Token#map -> Array
*
* Source map info. Format: `[ line_begin, line_end ]`
**/
this.map = null
/**
* Token#nesting -> Number
*
* Level change (number in {-1, 0, 1} set), where:
*
* - `1` means the tag is opening
* - `0` means the tag is self-closing
* - `-1` means the tag is closing
**/
this.nesting = nesting
/**
* Token#level -> Number
*
* nesting level, the same as `state.level`
**/
this.level = 0
/**
* Token#children -> Array
*
* An array of child nodes (inline and img tokens)
**/
this.children = null
/**
* Token#content -> String
*
* In a case of self-closing tag (code, html, fence, etc.),
* it has contents of this tag.
**/
this.content = ''
/**
* Token#markup -> String
*
* '*' or '_' for emphasis, fence string for fence, etc.
**/
this.markup = ''
/**
* Token#info -> String
*
* Additional information:
*
* - Info string for "fence" tokens
* - The value "auto" for autolink "link_open" and "link_close" tokens
* - The string value of the item marker for ordered-list "list_item_open" tokens
**/
this.info = ''
/**
* Token#meta -> Object
*
* A place for plugins to store an arbitrary data
**/
this.meta = null
/**
* Token#block -> Boolean
*
* True for block-level tokens, false for inline tokens.
* Used in renderer to calculate line breaks
**/
this.block = false
/**
* Token#hidden -> Boolean
*
* If it's true, ignore this element when rendering. Used for tight lists
* to hide paragraphs.
**/
this.hidden = false
}
/**
* Token.attrIndex(name) -> Number
*
* Search attribute index by name.
**/
Token.prototype.attrIndex = function attrIndex (name) {
if (!this.attrs) { return -1 }
const attrs = this.attrs
for (let i = 0, len = attrs.length; i < len; i++) {
if (attrs[i][0] === name) { return i }
}
return -1
}
/**
* Token.attrPush(attrData)
*
* Add `[ name, value ]` attribute to list. Init attrs if necessary
**/
Token.prototype.attrPush = function attrPush (attrData) {
if (this.attrs) {
this.attrs.push(attrData)
} else {
this.attrs = [attrData]
}
}
/**
* Token.attrSet(name, value)
*
* Set `name` attribute to `value`. Override old value if exists.
**/
Token.prototype.attrSet = function attrSet (name, value) {
const idx = this.attrIndex(name)
const attrData = [name, value]
if (idx < 0) {
this.attrPush(attrData)
} else {
this.attrs[idx] = attrData
}
}
/**
* Token.attrGet(name)
*
* Get the value of attribute `name`, or null if it does not exist.
**/
Token.prototype.attrGet = function attrGet (name) {
const idx = this.attrIndex(name)
let value = null
if (idx >= 0) {
value = this.attrs[idx][1]
}
return value
}
/**
* Token.attrJoin(name, value)
*
* Join value to existing attribute via space. Or create new attribute if not
* exists. Useful to operate with token classes.
**/
Token.prototype.attrJoin = function attrJoin (name, value) {
const idx = this.attrIndex(name)
if (idx < 0) {
this.attrPush([name, value])
} else {
this.attrs[idx][1] = this.attrs[idx][1] + ' ' + value
}
}
export default Token
@@ -0,0 +1,96 @@
{
"name": "markdown-it",
"version": "14.3.3",
"description": "Markdown-it - modern pluggable markdown parser.",
"keywords": [
"markdown",
"parser",
"commonmark",
"markdown-it",
"markdown-it-plugin"
],
"repository": "markdown-it/markdown-it",
"license": "MIT",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/markdown-it"
}
],
"main": "dist/index.cjs.js",
"module": "index.mjs",
"exports": {
".": {
"import": "./index.mjs",
"require": "./dist/index.cjs.js"
},
"./*": {
"require": "./*",
"import": "./*"
}
},
"bin": {
"markdown-it": "bin/markdown-it.mjs"
},
"scripts": {
"lint": "eslint .",
"test": "npm run lint && npm run test:cmspec && npm run test:markdown-it && npm run test:build",
"test:cmspec": "node --test --test-reporter=dot test/cmspec/**/*.test.mjs",
"test:markdown-it": "node --test test/markdown-it/**/*.test.mjs",
"test:build": "npm run build && node --test test/build/**/*.test.mjs",
"pathological:track-ref": "node support/track-ref-pathological.mjs",
"pathological:update-hash": "node support/track-ref-pathological.mjs --update",
"coverage": "npm run build && c8 --include 'lib/**' -r text -r html -r lcov node --test --test-reporter=dot",
"doc": "node support/build_doc.mjs",
"gh-doc": "npm run doc && gh-pages -d apidoc -f",
"demo": "npm run lint && node support/build_demo.mjs",
"gh-demo": "npm run demo && gh-pages -d demo -f -b master -r [email protected]:markdown-it/markdown-it.github.io.git",
"build": "node support/build-dist.mjs",
"benchmark-deps": "npm install --prefix benchmark/extra/",
"specsplit": "support/specsplit.mjs good -o test/fixtures/commonmark/good.txt && support/specsplit.mjs bad -o test/fixtures/commonmark/bad.txt && support/specsplit.mjs",
"todo": "grep 'TODO' -n -r ./lib 2>/dev/null",
"prepack": "npm test && npm run build"
},
"files": [
"index.mjs",
"lib/",
"dist/"
],
"publishConfig": {
"tag": "v14-legacy"
},
"dependencies": {
"argparse": "^2.0.1",
"entities": "^4.5.0",
"linkify-it": "^5.0.2",
"mdurl": "^2.0.0",
"punycode.js": "^2.3.1",
"uc.micro": "^2.1.0"
},
"devDependencies": {
"c8": "^11.0.0",
"eslint": "^9.39.4",
"gh-pages": "^6.3.0",
"highlight.js": "^11.11.1",
"markdown-it-abbr": "^2.0.0",
"markdown-it-container": "^4.0.0",
"markdown-it-deflist": "^3.0.1",
"markdown-it-emoji": "^3.0.0",
"markdown-it-footnote": "^4.0.0",
"markdown-it-for-inline": "^2.0.1",
"markdown-it-ins": "^4.0.0",
"markdown-it-mark": "^4.0.0",
"markdown-it-sub": "^2.0.0",
"markdown-it-sup": "^2.0.0",
"ndoc": "^6.0.1",
"neostandard": "^0.13.0",
"terser": "^5.48.0",
"tinybench": "^6.0.2",
"vite": "^8.1.2",
"vite-plugin-singlefile": "^2.3.3"
}
}
+45
View File
@@ -0,0 +1,45 @@
Copyright (c) 2015 Vitaly Puzrin, Alex Kocharin.
Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation
files (the "Software"), to deal in the Software without
restriction, including without limitation the rights to use,
copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
--------------------------------------------------------------------------------
.parse() is based on Joyent's node.js `url` code:
Copyright Joyent, Inc. and other Node contributors. All rights reserved.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to
deal in the Software without restriction, including without limitation the
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
sell copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
IN THE SOFTWARE.
+11
View File
@@ -0,0 +1,11 @@
import decode from './lib/decode.mjs'
import encode from './lib/encode.mjs'
import format from './lib/format.mjs'
import parse from './lib/parse.mjs'
export {
decode,
encode,
format,
parse
}
+112
View File
@@ -0,0 +1,112 @@
/* eslint-disable no-bitwise */
const decodeCache = {}
function getDecodeCache (exclude) {
let cache = decodeCache[exclude]
if (cache) { return cache }
cache = decodeCache[exclude] = []
for (let i = 0; i < 128; i++) {
const ch = String.fromCharCode(i)
cache.push(ch)
}
for (let i = 0; i < exclude.length; i++) {
const ch = exclude.charCodeAt(i)
cache[ch] = '%' + ('0' + ch.toString(16).toUpperCase()).slice(-2)
}
return cache
}
// Decode percent-encoded string.
//
function decode (string, exclude) {
if (typeof exclude !== 'string') {
exclude = decode.defaultChars
}
const cache = getDecodeCache(exclude)
return string.replace(/(%[a-f0-9]{2})+/gi, function (seq) {
let result = ''
for (let i = 0, l = seq.length; i < l; i += 3) {
const b1 = parseInt(seq.slice(i + 1, i + 3), 16)
if (b1 < 0x80) {
result += cache[b1]
continue
}
if ((b1 & 0xE0) === 0xC0 && (i + 3 < l)) {
// 110xxxxx 10xxxxxx
const b2 = parseInt(seq.slice(i + 4, i + 6), 16)
if ((b2 & 0xC0) === 0x80) {
const chr = ((b1 << 6) & 0x7C0) | (b2 & 0x3F)
if (chr < 0x80) {
result += '\ufffd\ufffd'
} else {
result += String.fromCharCode(chr)
}
i += 3
continue
}
}
if ((b1 & 0xF0) === 0xE0 && (i + 6 < l)) {
// 1110xxxx 10xxxxxx 10xxxxxx
const b2 = parseInt(seq.slice(i + 4, i + 6), 16)
const b3 = parseInt(seq.slice(i + 7, i + 9), 16)
if ((b2 & 0xC0) === 0x80 && (b3 & 0xC0) === 0x80) {
const chr = ((b1 << 12) & 0xF000) | ((b2 << 6) & 0xFC0) | (b3 & 0x3F)
if (chr < 0x800 || (chr >= 0xD800 && chr <= 0xDFFF)) {
result += '\ufffd\ufffd\ufffd'
} else {
result += String.fromCharCode(chr)
}
i += 6
continue
}
}
if ((b1 & 0xF8) === 0xF0 && (i + 9 < l)) {
// 111110xx 10xxxxxx 10xxxxxx 10xxxxxx
const b2 = parseInt(seq.slice(i + 4, i + 6), 16)
const b3 = parseInt(seq.slice(i + 7, i + 9), 16)
const b4 = parseInt(seq.slice(i + 10, i + 12), 16)
if ((b2 & 0xC0) === 0x80 && (b3 & 0xC0) === 0x80 && (b4 & 0xC0) === 0x80) {
let chr = ((b1 << 18) & 0x1C0000) | ((b2 << 12) & 0x3F000) | ((b3 << 6) & 0xFC0) | (b4 & 0x3F)
if (chr < 0x10000 || chr > 0x10FFFF) {
result += '\ufffd\ufffd\ufffd\ufffd'
} else {
chr -= 0x10000
result += String.fromCharCode(0xD800 + (chr >> 10), 0xDC00 + (chr & 0x3FF))
}
i += 9
continue
}
}
result += '\ufffd'
}
return result
})
}
decode.defaultChars = ';/?:@&=+$,#'
decode.componentChars = ''
export default decode
+89
View File
@@ -0,0 +1,89 @@
const encodeCache = {}
// Create a lookup array where anything but characters in `chars` string
// and alphanumeric chars is percent-encoded.
//
function getEncodeCache (exclude) {
let cache = encodeCache[exclude]
if (cache) { return cache }
cache = encodeCache[exclude] = []
for (let i = 0; i < 128; i++) {
const ch = String.fromCharCode(i)
if (/^[0-9a-z]$/i.test(ch)) {
// always allow unencoded alphanumeric characters
cache.push(ch)
} else {
cache.push('%' + ('0' + i.toString(16).toUpperCase()).slice(-2))
}
}
for (let i = 0; i < exclude.length; i++) {
cache[exclude.charCodeAt(i)] = exclude[i]
}
return cache
}
// Encode unsafe characters with percent-encoding, skipping already
// encoded sequences.
//
// - string - string to encode
// - exclude - list of characters to ignore (in addition to a-zA-Z0-9)
// - keepEscaped - don't encode '%' in a correct escape sequence (default: true)
//
function encode (string, exclude, keepEscaped) {
if (typeof exclude !== 'string') {
// encode(string, keepEscaped)
keepEscaped = exclude
exclude = encode.defaultChars
}
if (typeof keepEscaped === 'undefined') {
keepEscaped = true
}
const cache = getEncodeCache(exclude)
let result = ''
for (let i = 0, l = string.length; i < l; i++) {
const code = string.charCodeAt(i)
if (keepEscaped && code === 0x25 /* % */ && i + 2 < l) {
if (/^[0-9a-f]{2}$/i.test(string.slice(i + 1, i + 3))) {
result += string.slice(i, i + 3)
i += 2
continue
}
}
if (code < 128) {
result += cache[code]
continue
}
if (code >= 0xD800 && code <= 0xDFFF) {
if (code >= 0xD800 && code <= 0xDBFF && i + 1 < l) {
const nextCode = string.charCodeAt(i + 1)
if (nextCode >= 0xDC00 && nextCode <= 0xDFFF) {
result += encodeURIComponent(string[i] + string[i + 1])
i++
continue
}
}
result += '%EF%BF%BD'
continue
}
result += encodeURIComponent(string[i])
}
return result
}
encode.defaultChars = ";/?:@&=+$,-_.!~*'()#"
encode.componentChars = "-_.!~*'()"
export default encode
+21
View File
@@ -0,0 +1,21 @@
export default function format (url) {
let result = ''
result += url.protocol || ''
result += url.slashes ? '//' : ''
result += url.auth ? url.auth + '@' : ''
if (url.hostname && url.hostname.indexOf(':') !== -1) {
// ipv6 address
result += '[' + url.hostname + ']'
} else {
result += url.hostname || ''
}
result += url.port ? ':' + url.port : ''
result += url.pathname || ''
result += url.search || ''
result += url.hash || ''
return result
};
Loaded 100 of 195 files, more files were not shown because too many files have changed in this diff. Show more