Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/context-tag-support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@marko/language-tools": minor
"@marko/language-server": minor
---

Support the `<context>` core tag: the `from=` attribute gets tag-name
(`"<my-tag>"`) and relative path completions plus document links, and a
consumer's tag variable is typed from the provided value of the template
`from=` resolves to (including writable assignments).
104 changes: 104 additions & 0 deletions packages/language-server/src/__tests__/context-from-complete.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import assert from "node:assert/strict";

import { Project } from "@marko/language-tools";
import path from "path";
import { CancellationToken, type TextEdit } from "vscode-languageserver";
import { TextDocument } from "vscode-languageserver-textdocument";
import { URI } from "vscode-uri";

import { documents } from "../service";
import MarkoPlugin from "../service/marko";

Project.setDefaultTypePaths({
internalTypesFile:
require.resolve("@marko/language-tools/marko.internal.d.ts"),
markoTypesFile: require.resolve("marko/index.d.ts"),
});

// Reuses a fixture that ships discoverable `components/TestTagA.marko` /
// `TestTagB.marko`; the consumer is an in-memory file in that directory.
const FIXTURE_DIR = path.join(
__dirname,
"fixtures",
"script",
"prefer-local-identifier-tag-name",
);

// Completes at `cursor` characters past the opening quote of the `from=`
// attribute and returns the item with `label` plus the applied line.
let runId = 0;
async function completeFrom(line: string, cursor: number, label: string) {
const uri = URI.file(
path.join(FIXTURE_DIR, `__context-from-${runId++}.marko`),
).toString();
const text = `${line}\n`;
documents.doOpen({
textDocument: { uri, languageId: "marko", version: 1, text },
});
const doc = documents.get(uri)!;
try {
const offset = line.indexOf('from="') + 6 + cursor;
const result = await MarkoPlugin.doComplete!(
doc,
{
textDocument: { uri },
position: doc.positionAt(offset),
context: { triggerKind: 1 },
} as never,
CancellationToken.None,
);
const items = Array.isArray(result) ? result : (result?.items ?? []);
const item = items.find((i) => i.label === label);
return {
item,
applied: item
? TextDocument.applyEdits(doc, [item.textEdit as TextEdit]).split(
"\n",
)[0]
: undefined,
};
} finally {
documents.doClose({ textDocument: { uri } });
}
}

describe("<context> from= completion", () => {
it("completes a tag name inside the shorthand", async () => {
const { item, applied } = await completeFrom(
'<context/theme from="<TestTagA>"/>',
1,
"TestTagA",
);
assert.ok(item, "expected a TestTagA completion");
assert.ok(item.sortText?.startsWith("0"), "should be prioritized");
assert.equal(applied, '<context/theme from="<TestTagA>"/>');
});

it("adds the closing `>` when not typed yet", async () => {
const { applied } = await completeFrom(
'<context/theme from="<Te"/>',
3,
"TestTagA",
);
assert.equal(applied, '<context/theme from="<TestTagA>"/>');
});

it("offers the full shorthand for an empty value", async () => {
const { applied } = await completeFrom(
'<context/theme from=""/>',
0,
"TestTagA",
);
assert.equal(applied, '<context/theme from="<TestTagA>"/>');
});

it("completes relative paths after a slash", async () => {
const { item, applied } = await completeFrom(
'<context/theme from="./"/>',
2,
"index.marko",
);
assert.ok(item, "expected a file completion");
assert.equal(applied, '<context/theme from="./index.marko"/>');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<div>



<div data-marko-node-id="3">placeholder</div>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
## Hovers
### Ln 9, Col 12
```marko
7 | <context=input.label>
8 | <${input.content}/>
> 9 | <context/label from="<labeled-region>"/>
| ^ const label: string
10 | // ^?
11 | <div>${label.toUpperCase()}</div>
12 | </context>
```

Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
export interface Input {
label: string;
content?: Marko.Body;
}
function __marko_internal_template(this: void) {
const input = Marko._.any as Input;
const $signal = Marko._.any as AbortSignal;
const $global = Marko._.getGlobal(
// @ts-expect-error We expect the compiler to error because we are checking if the MarkoRun.Context is defined.
(Marko._.error, Marko._.any as MarkoRun.Context),
);
const label = Marko._.hoist(() => __marko_internal_hoist__label);
// Self-referencing consume: `from=` resolves to this same template.
var __marko_internal_context = () => input.label;
{
const __marko_internal_tag_1 = input.content;
Marko._.renderDynamicTag(__marko_internal_tag_1)()()({});
{
const label = Marko._.contextTag(() =>
Marko._.renderTemplate(
Marko._.resolveTemplate(import("./labeled-region.marko")),
)()()(Marko._.any),
);
Marko._.renderNativeTag("div")()()(
// ^?
{
[Marko._.content]: (() => {
label.toUpperCase();
return () => {
return Marko._.voidReturn;
};
})(),
},
);
}
}
var { label: __marko_internal_hoist__label } = undefined;
Marko._.noop({ label, input, $global, $signal });
return Marko._.withContext(__marko_internal_context, undefined);
}
const __marko_internal_api = "tags";
export { __marko_internal_api as "~api" };
const LabeledRegion = new (class Template extends Marko._.Template<{
render(
input: Marko.TemplateInput<Input>,
stream?: {
write: (chunk: string) => void;
end: (chunk?: string) => void;
},
): Marko.Out<never>;

render(
input: Marko.TemplateInput<Input>,
cb?: (err: Error | null, result: Marko.RenderResult<never>) => void,
): Marko.Out<never>;

renderSync(input: Marko.TemplateInput<Input>): Marko.RenderResult<never>;

renderToString(input: Marko.TemplateInput<Input>): string;

stream(
input: Marko.TemplateInput<Input>,
): ReadableStream<string> & NodeJS.ReadableStream;

mount(
input: Marko.TemplateInput<Input>,
reference: Node,
position?: "afterbegin" | "afterend" | "beforebegin" | "beforeend",
): Marko.MountedTemplate<typeof input>;

api: typeof __marko_internal_api;
_(): () => <__marko_internal_input extends unknown>(
input: Marko.Directives &
Input &
Marko._.Relate<__marko_internal_input, Marko.Directives & Input>,
) => Marko._.ReturnWithScope<
__marko_internal_input,
typeof __marko_internal_template extends () => infer Return ? Return : never
>;
}> {})();
export default LabeledRegion;
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<div>
<div>

</div>
</div><div>

</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
export interface Input {
detailed?: boolean;
content?: Marko.Body;
}
abstract class Component extends Marko.Component<Input> {}
export { type Component };
function __marko_internal_template(this: void) {
const input = Marko._.any as Input;
const component = Marko._.any as Component;
const state = Marko._.state(component);
const out = Marko._.any as Marko.Out;
const $signal = Marko._.any as AbortSignal;
const $global = Marko._.getGlobal(
// @ts-expect-error We expect the compiler to error because we are checking if the MarkoRun.Context is defined.
(Marko._.error, Marko._.any as MarkoRun.Context),
);
// The provide sits inside control flow: the consumed type must still be
// exactly the provided type, with no extractor-added `undefined` (reading
// an unprovided context is a runtime error, not a valid `undefined`).
if (input.detailed) {
var __marko_internal_context = () =>
({ mode: "detailed", level: 2 }) as const;
{
const __marko_internal_tag_1 = input.content;
Marko._.renderDynamicTag(__marko_internal_tag_1)()()({});
}
} else {
const __marko_internal_tag_2 = input.content;
Marko._.renderDynamicTag(__marko_internal_tag_2)()()({});
}
Marko._.noop({ component, state, out, input, $global, $signal });
return Marko._.withContext(__marko_internal_context, undefined);
}
const __marko_internal_api = "class";
export { __marko_internal_api as "~api" };
const ModeProvider = new (class Template extends Marko._.Template<{
render(
input: Marko.TemplateInput<Input>,
stream?: {
write: (chunk: string) => void;
end: (chunk?: string) => void;
},
): Marko.Out<Component>;

render(
input: Marko.TemplateInput<Input>,
cb?: (err: Error | null, result: Marko.RenderResult<Component>) => void,
): Marko.Out<Component>;

renderSync(input: Marko.TemplateInput<Input>): Marko.RenderResult<Component>;

renderToString(input: Marko.TemplateInput<Input>): string;

stream(
input: Marko.TemplateInput<Input>,
): ReadableStream<string> & NodeJS.ReadableStream;

mount(
input: Marko.TemplateInput<Input>,
reference: Node,
position?: "afterbegin" | "afterend" | "beforebegin" | "beforeend",
): Marko.MountedTemplate<typeof input>;

api: typeof __marko_internal_api;
_(): () => <__marko_internal_input extends unknown>(
input: Marko.Directives &
Input &
Marko._.Relate<__marko_internal_input, Marko.Directives & Input>,
) => Marko._.ReturnWithScope<
__marko_internal_input,
typeof __marko_internal_template extends () => infer Return ? Return : never
>;
}> {})();
export default ModeProvider;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<span data-marko-node-id="1">placeholder</span>
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
## Hovers
### Ln 1, Col 10
```marko
> 1 | <context/settings from="<mode-provider>"/>
| ^ const settings: {
readonly mode: "detailed";
readonly level: 2;
}
2 | // ^?
3 | <span>${settings.mode}</span>
4 |
```

Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
export interface Input {}
(function (this: void) {
const input = Marko._.any as Input;
const $signal = Marko._.any as AbortSignal;
const $global = Marko._.getGlobal(
// @ts-expect-error We expect the compiler to error because we are checking if the MarkoRun.Context is defined.
(Marko._.error, Marko._.any as MarkoRun.Context),
);
const settings = Marko._.hoist(() => __marko_internal_hoist__settings);
{
const settings = Marko._.contextTag(() =>
Marko._.renderTemplate(
Marko._.resolveTemplate(import("./mode-provider.marko")),
)()()(Marko._.any),
);
Marko._.renderNativeTag("span")()()(
// ^?
{
[Marko._.content]: (() => {
settings.mode;
return () => {
return Marko._.voidReturn;
};
})(),
},
);
var __marko_internal_hoist__settings = settings;
}
Marko._.noop({ settings, input, $global, $signal });
return;
})();
const __marko_internal_api = "tags";
export { __marko_internal_api as "~api" };
const ShowMode = new (class Template extends Marko._.Template<{
render(
input: Marko.TemplateInput<Input>,
stream?: {
write: (chunk: string) => void;
end: (chunk?: string) => void;
},
): Marko.Out<never>;

render(
input: Marko.TemplateInput<Input>,
cb?: (err: Error | null, result: Marko.RenderResult<never>) => void,
): Marko.Out<never>;

renderSync(input: Marko.TemplateInput<Input>): Marko.RenderResult<never>;

renderToString(input: Marko.TemplateInput<Input>): string;

stream(
input: Marko.TemplateInput<Input>,
): ReadableStream<string> & NodeJS.ReadableStream;

mount(
input: Marko.TemplateInput<Input>,
reference: Node,
position?: "afterbegin" | "afterend" | "beforebegin" | "beforeend",
): Marko.MountedTemplate<typeof input>;

api: typeof __marko_internal_api;
_(): () => <__marko_internal_input extends unknown>(
input: Marko.Directives &
Input &
Marko._.Relate<__marko_internal_input, Marko.Directives & Input>,
) => Marko._.ReturnWithScope<__marko_internal_input, void>;
}> {})();
export default ShowMode;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<button data-marko-node-id="1" onClick>placeholder</button>
Loading