@intentius/chant 0.124.20 → 0.124.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant",
3
- "version": "0.124.20",
3
+ "version": "0.124.22",
4
4
  "description": "Declarative infrastructure-as-code toolkit — TypeScript on Node.js",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -16,6 +16,7 @@
16
16
  * - `group`: a nested subgroup label inside the quadrant (e.g. "Vendor Composites").
17
17
  * - `order`: number; lower sorts first within its group. Unordered pages follow, by label.
18
18
  * - `hidden`: `true` keeps the page out of the sidebar (reachable by URL only).
19
+ * - `prompt`: optional `|` block; the agent prompt shown under the title. Kept.
19
20
  *
20
21
  * `group` / `order` / `hidden` / `label` are stripped from the written copy so
21
22
  * Starlight's schema never sees them; `diataxis` stays, and the generated
@@ -8,6 +8,13 @@ import { escapeMdx } from "./docs-file-markers";
8
8
  import { intrinsicFolds } from "../lexicon";
9
9
  import type { DocsConfig, ManifestJSON, MetaEntry, RuleMeta } from "./docs-types";
10
10
 
11
+ /** A `prompt: |` block scalar for a page's front matter, or nothing. */
12
+ export function promptFrontmatter(prompt: string | undefined): string[] {
13
+ const text = prompt?.trim();
14
+ if (!text) return [];
15
+ return ["prompt: |", ...text.split("\n").map((line) => (line ? ` ${line}` : ""))];
16
+ }
17
+
11
18
  export function generateOverview(
12
19
  config: DocsConfig,
13
20
  manifest: ManifestJSON,
@@ -20,6 +27,7 @@ export function generateOverview(
20
27
  "---",
21
28
  `title: "${config.displayName}"`,
22
29
  `description: "${config.description}"`,
30
+ ...promptFrontmatter(config.prompt),
23
31
  "---",
24
32
  "",
25
33
  config.overview ?? `Reference documentation for the **${config.displayName}** lexicon.`,
@@ -0,0 +1,153 @@
1
+ ---
2
+ // "Optional: hand this page to your coding agent": a card under the page title
3
+ // with the first lines of the page's `prompt:` front matter showing, a button
4
+ // that copies the whole prompt, one that copies a link to the card, and a
5
+ // toggle that shows it all. The main docs site and every lexicon docs site
6
+ // render it from their PageTitle override; lexicon sites get a copy of this
7
+ // file from writeDocsSite (docs.ts). The content schema refuses a prompt with
8
+ // no "Never" line. No Starlight import here, so the file works from any site.
9
+ interface Props {
10
+ text?: string;
11
+ }
12
+ const prompt = (Astro.props.text ?? Astro.locals.starlightRoute?.entry.data.prompt)?.trim();
13
+ ---
14
+ {prompt && (
15
+ <figure class="chant-prompt not-content" id="agent-prompt">
16
+ <figcaption>Optional: hand this page to your coding agent</figcaption>
17
+ <details>
18
+ <summary>
19
+ <pre class="peek" aria-hidden="true">{prompt}</pre>
20
+ <span class="more">Show the whole prompt</span>
21
+ </summary>
22
+ <pre class="full"><code>{prompt}</code></pre>
23
+ </details>
24
+ <div class="actions">
25
+ <button type="button" class="copy" data-copy={prompt} data-done="Copied">Copy prompt</button>
26
+ <button type="button" class="link" data-link data-done="Link copied">Copy link</button>
27
+ </div>
28
+ </figure>
29
+ )}
30
+
31
+ <script>
32
+ const flash = (button: HTMLButtonElement, text: string) => {
33
+ const was = button.textContent;
34
+ button.textContent = text;
35
+ setTimeout(() => (button.textContent = was), 1600);
36
+ };
37
+ for (const button of document.querySelectorAll<HTMLButtonElement>('.chant-prompt button')) {
38
+ button.addEventListener('click', async () => {
39
+ const card = button.closest('figure')!;
40
+ const text = button.dataset.link !== undefined ? `${location.origin}${location.pathname}#${card.id}` : (button.dataset.copy ?? '');
41
+ try {
42
+ await navigator.clipboard.writeText(text);
43
+ flash(button, button.dataset.done ?? 'Copied');
44
+ } catch {
45
+ // No clipboard (an insecure origin, a denied permission): open the
46
+ // prompt and select it so the reader can copy it by hand.
47
+ card.querySelector('details')?.setAttribute('open', '');
48
+ const code = card.querySelector('.full code');
49
+ if (code) window.getSelection()?.selectAllChildren(code);
50
+ flash(button, 'Selected');
51
+ }
52
+ });
53
+ }
54
+ </script>
55
+
56
+ <style>
57
+ /* Minimal: zinc greys from Starlight's scale and one blue. */
58
+ .chant-prompt {
59
+ --chant-prompt-blue: #2563eb;
60
+ margin: 1.25rem 0 0;
61
+ width: min(100%, 44rem);
62
+ border: 1px solid var(--sl-color-gray-5);
63
+ border-left: 2px solid var(--chant-prompt-blue);
64
+ border-radius: 0.5rem;
65
+ background: var(--sl-color-gray-7, var(--sl-color-black));
66
+ overflow: hidden;
67
+ }
68
+ :global([data-theme='dark']) .chant-prompt {
69
+ --chant-prompt-blue: #60a5fa;
70
+ }
71
+ figcaption {
72
+ padding: 0.7rem 1rem 0.4rem;
73
+ font-size: 0.85rem;
74
+ font-weight: 600;
75
+ color: var(--sl-color-white);
76
+ }
77
+ summary {
78
+ display: block;
79
+ list-style: none;
80
+ cursor: pointer;
81
+ padding: 0 1rem;
82
+ }
83
+ summary::-webkit-details-marker {
84
+ display: none;
85
+ }
86
+ pre {
87
+ margin: 0;
88
+ font-family: var(--sl-font-mono);
89
+ font-size: 0.75rem;
90
+ line-height: 1.55;
91
+ color: var(--sl-color-gray-2);
92
+ white-space: pre-wrap;
93
+ overflow-wrap: anywhere;
94
+ background: none;
95
+ border: 0;
96
+ padding: 0;
97
+ }
98
+ /* Two lines of the prompt, fading out, say it is there. */
99
+ .peek {
100
+ max-height: 2.4rem;
101
+ overflow: hidden;
102
+ mask-image: linear-gradient(to bottom, #000 40%, transparent);
103
+ }
104
+ .more,
105
+ details[open] summary::after {
106
+ display: inline-block;
107
+ margin-top: 0.2rem;
108
+ font-size: 0.72rem;
109
+ color: var(--chant-prompt-blue);
110
+ }
111
+ details[open] .peek,
112
+ details[open] .more {
113
+ display: none;
114
+ }
115
+ details[open] summary::after {
116
+ content: 'Hide the prompt';
117
+ }
118
+ .full {
119
+ padding: 0.25rem 1rem 0;
120
+ }
121
+ .actions {
122
+ display: flex;
123
+ gap: 0.5rem;
124
+ padding: 0.7rem 1rem 0.8rem;
125
+ }
126
+ button {
127
+ font: inherit;
128
+ font-size: 0.78rem;
129
+ font-weight: 600;
130
+ border-radius: 0.4rem;
131
+ padding: 0.35rem 0.85rem;
132
+ cursor: pointer;
133
+ }
134
+ .copy {
135
+ color: #fff;
136
+ background: var(--chant-prompt-blue);
137
+ border: 1px solid var(--chant-prompt-blue);
138
+ }
139
+ :global([data-theme='dark']) .copy {
140
+ color: #09090b;
141
+ }
142
+ .copy:hover {
143
+ filter: brightness(1.1);
144
+ }
145
+ .link {
146
+ color: var(--sl-color-white);
147
+ background: none;
148
+ border: 1px solid var(--sl-color-gray-5);
149
+ }
150
+ .link:hover {
151
+ border-color: var(--chant-prompt-blue);
152
+ }
153
+ </style>
@@ -21,6 +21,13 @@ export interface DocsConfig {
21
21
  packageJsonPath?: string;
22
22
  /** Lexicon-specific overview content (markdown) */
23
23
  overview?: string;
24
+ /**
25
+ * The overview page's agent prompt: "Optional: hand this page to your coding
26
+ * agent", shown under the title (docs-site/AgentPrompt.astro). Imperative
27
+ * steps, what to report back, and a line starting "Never". An authored page
28
+ * under `pagesDir` carries its own as a `prompt:` front-matter field.
29
+ */
30
+ prompt?: string;
24
31
  /** Output format description (e.g. "CloudFormation JSON template") */
25
32
  outputFormat?: string;
26
33
  /** Custom service grouping from resource type (e.g. "AWS::S3::Bucket" → "S3") */
@@ -390,6 +390,13 @@ export const collections = {
390
390
  extend: z.object({
391
391
  // Diátaxis quadrant (https://diataxis.fr), chant #1731.
392
392
  diataxis: z.enum(['tutorial', 'how-to', 'reference', 'explanation']).optional(),
393
+ // "Optional: hand this page to your coding agent", shown under the
394
+ // title by src/components/AgentPrompt.astro. It has to say what the
395
+ // agent must never do.
396
+ prompt: z
397
+ .string()
398
+ .optional()
399
+ .refine((p) => !p || /^Never\\b/m.test(p), 'a prompt needs a line that starts with "Never" (what the agent must not do)'),
393
400
  }),
394
401
  }),
395
402
  }),
@@ -404,6 +411,25 @@ export const collections = {
404
411
  );
405
412
  copyFileSync(pluginSrcPath, join(outDir, "src", "rehype-base-url.mjs"));
406
413
 
414
+ // src/components/: the agent prompt card from chant core, and a PageTitle
415
+ // override that puts it under Starlight's title on every page with a `prompt:`.
416
+ mkdirSync(join(outDir, "src", "components"), { recursive: true });
417
+ copyFileSync(
418
+ fileURLToPath(new URL("./docs-site/AgentPrompt.astro", import.meta.url)),
419
+ join(outDir, "src", "components", "AgentPrompt.astro"),
420
+ );
421
+ writeFileSync(
422
+ join(outDir, "src", "components", "PageTitle.astro"),
423
+ `---
424
+ // Generated by chant's writeDocsSite. Starlight's page title, then the page's agent prompt.
425
+ import Default from '@astrojs/starlight/components/PageTitle.astro';
426
+ import AgentPrompt from './AgentPrompt.astro';
427
+ ---
428
+ <Default />
429
+ <AgentPrompt />
430
+ `,
431
+ );
432
+
407
433
  // astro.config.mjs
408
434
  const rehypeLine = config.basePath
409
435
  ? `\n markdown: {\n rehypePlugins: [[rehypeBaseUrl, { base: '${config.basePath}', projectBase: '/chant' }]],\n },`
@@ -419,6 +445,7 @@ export default defineConfig({${config.basePath ? `\n base: '${config.basePath}'
419
445
  integrations: [
420
446
  starlight({
421
447
  title: '${config.displayName}',
448
+ components: { PageTitle: './src/components/PageTitle.astro' },
422
449
  sidebar: ${JSON.stringify(sidebar, null, 6).replace(/\n/g, "\n ")},
423
450
  }),
424
451
  ],
@@ -141,7 +141,9 @@ import { intrinsicCallFolds, intrinsicCallFoldsEagerly, type IntrinsicDef } from
141
141
  * (`{...n}`) is shape-valid (`n` is a plain identifier) but `fold()`
142
142
  * rejects it once it discovers `n` folds to a number, not an object.
143
143
  * Only real evaluation catches this; EVL004 independently narrows
144
- * spread sources to a stricter "traceable to a const" shape, which
144
+ * spread sources to a stricter "traceable to a const" shape (a const
145
+ * declared in the file, or an imported binding, which fold() resolves
146
+ * from the exporting module's folded value), which
145
147
  * catches most real-world misuse without evaluating, but is not a
146
148
  * full substitute.
147
149
  *
@@ -36,6 +36,24 @@ describe("EVL004: spread-non-const", () => {
36
36
  expect(evl004SpreadNonConstRule.check(ctx)).toHaveLength(0);
37
37
  });
38
38
 
39
+ test("allows spread from an imported binding (named, default, namespace member)", () => {
40
+ const ctx = createContext(`
41
+ import base, { steps } from "./shared";
42
+ import * as shared from "./shared";
43
+ const x = { ...base, ...shared.preset };
44
+ const y = [...steps, { run: "true" }];
45
+ `);
46
+ expect(evl004SpreadNonConstRule.check(ctx)).toHaveLength(0);
47
+ });
48
+
49
+ test("still flags a call on an imported function", () => {
50
+ const ctx = createContext(`
51
+ import { makeSteps } from "./shared";
52
+ const y = [...makeSteps("web")];
53
+ `);
54
+ expect(evl004SpreadNonConstRule.check(ctx)).toHaveLength(1);
55
+ });
56
+
39
57
  test("flags spread from let variable", () => {
40
58
  const ctx = createContext(`
41
59
  let base = { a: 1 };
@@ -8,6 +8,11 @@ import { isInsideCompositeFactory } from "./composite-scope";
8
8
  * Spread expressions ({...x} or [...x]) must reference a const-declared
9
9
  * variable, object literal, or property access chain from a const.
10
10
  * Spreading from function calls or non-traceable sources is blocked.
11
+ *
12
+ * A binding imported from another module counts as const: an import can't
13
+ * be reassigned, and sharing an exported const (a preset, a list of
14
+ * Dockerfile steps) across modules is the point of exporting it. Whether the
15
+ * exporting module declared it `const` is that module's own EVL concern.
11
16
  */
12
17
 
13
18
  function isTraceableToConst(node: ts.Node, sourceFile: ts.SourceFile): boolean {
@@ -40,8 +45,18 @@ function isTraceableToConst(node: ts.Node, sourceFile: ts.SourceFile): boolean {
40
45
  }
41
46
 
42
47
  function isConstIdentifier(id: ts.Identifier, sourceFile: ts.SourceFile): boolean {
43
- // Walk all statements looking for a const declaration of this identifier
48
+ // Walk all statements looking for a const declaration or an import of this identifier
44
49
  for (const stmt of sourceFile.statements) {
50
+ if (ts.isImportDeclaration(stmt) && stmt.importClause && !stmt.importClause.isTypeOnly) {
51
+ const clause = stmt.importClause;
52
+ if (clause.name?.text === id.text) return true;
53
+ const bindings = clause.namedBindings;
54
+ if (bindings && ts.isNamedImports(bindings)) {
55
+ if (bindings.elements.some((el) => !el.isTypeOnly && el.name.text === id.text)) return true;
56
+ } else if (bindings && ts.isNamespaceImport(bindings) && bindings.name.text === id.text) {
57
+ return true;
58
+ }
59
+ }
45
60
  if (ts.isVariableStatement(stmt)) {
46
61
  if (stmt.declarationList.flags & ts.NodeFlags.Const) {
47
62
  for (const decl of stmt.declarationList.declarations) {