Repository: alloy-framework/alloy
Pull request #69 (2025-03-27 01:50:17)

Support Source File header

github-actions[bot]: <!--chronus-github-change-commenter-->
All changed packages have been documented.
 - :white_check_mark: `@alloy-js/core`
 - :white_check_mark: `@alloy-js/typescript`

<details>
<summary>Show changes</summary>

### `@alloy-js/core` - _feature_ [✏️](https://github.com/joheredi/alloy/edit/feature/source-file-header/.chronus/changes/feature-source-file-header-2025-2-27-1-49-36.md?pr=/alloy-framework/alloy/pull/69)
> Support SourceFile header
### `@alloy-js/typescript` - _feature_ [✏️](https://github.com/joheredi/alloy/edit/feature/source-file-header/.chronus/changes/feature-source-file-header-2025-2-27-1-49-36.md?pr=/alloy-framework/alloy/pull/69)
> Support SourceFile header
</details>

bterlson: Oh also make sure to add the Prose element to the changelog.

Patch:
diff --git a/.chronus/changes/feature-source-file-header-2025-2-27-1-49-36.md b/.chronus/changes/feature-source-file-header-2025-2-27-1-49-36.md
new file mode 100644
index 0000000..72e4436
--- /dev/null
+++ b/.chronus/changes/feature-source-file-header-2025-2-27-1-49-36.md
@@ -0,0 +1,10 @@
+---
+changeKind: feature
+packages:
+  - "@alloy-js/core"
+  - "@alloy-js/typescript"
+---
+
+- Support SourceFile header. This PR adds the following components:
+    - Rename `JSDocParagraph` component to `Prose`.
+    - `SingleLineCommentBlock` component to handle single line comments `//`.
\ No newline at end of file
diff --git a/packages/core/src/components/SourceFile.tsx b/packages/core/src/components/SourceFile.tsx
index 9e05952..76724d0 100644
--- a/packages/core/src/components/SourceFile.tsx
+++ b/packages/core/src/components/SourceFile.tsx
@@ -5,6 +5,7 @@ import { SourceFileContext } from "../context/source-file.js";
 import { Children, ComponentDefinition, getContext } from "../jsx-runtime.js";
 import { Refkey } from "../refkey.js";
 import { PrintTreeOptions } from "../render.js";
+import { Show } from "./Show.jsx";
 
 export interface SourceFileProps extends PrintTreeOptions {
   /**
@@ -24,6 +25,11 @@ export interface SourceFileProps extends PrintTreeOptions {
    * contents.
    */
   reference?: ComponentDefinition<{ refkey: Refkey }>;
+  /**
+   * The header of the file. This is rendered before the contents of the file.
+   * This is useful for adding license headers or other metadata to the file.
+   */
+  header?: Children;
 }
 
 export function SourceFile(props: SourceFileProps) {
@@ -45,6 +51,10 @@ export function SourceFile(props: SourceFileProps) {
 
   return (
     <SourceFileContext.Provider value={context}>
+      <Show when={props.header !== undefined}>
+        {props.header}
+        <hbr />
+      </Show>
       {props.children}
     </SourceFileContext.Provider>
   );
diff --git a/packages/typescript/src/components/JSDocParagraph.tsx b/packages/typescript/src/components/Prose.tsx
similarity index 72%
rename from packages/typescript/src/components/JSDocParagraph.tsx
rename to packages/typescript/src/components/Prose.tsx
index 845fcec..b51a67f 100644
--- a/packages/typescript/src/components/JSDocParagraph.tsx
+++ b/packages/typescript/src/components/Prose.tsx
@@ -1,15 +1,15 @@
 import { childrenArray, computed } from "@alloy-js/core";
 import { Children } from "@alloy-js/core/jsx-runtime";
 
-export interface JSDocParagraphProps {
+export interface Prose {
   children: Children;
 }
 
 /**
- * Create a paragraph in a JSDoc comment. The text of this component is word
- * wrapped.
+ * Create a block of text which will break once a word exceeds the configured line width.
+ * The children are expected to be strings, and a <br /> is added between each word.
  */
-export function JSDocParagraph(props: JSDocParagraphProps) {
+export function Prose(props: Prose) {
   const brokenChildren = computed(() => {
     const children = childrenArray(() => props.children);
     return children
diff --git a/packages/typescript/src/components/SingleLineCommentBlock.tsx b/packages/typescript/src/components/SingleLineCommentBlock.tsx
new file mode 100644
index 0000000..038ab69
--- /dev/null
+++ b/packages/typescript/src/components/SingleLineCommentBlock.tsx
@@ -0,0 +1,21 @@
+import { Children } from "@alloy-js/core/jsx-runtime";
+import { Prose } from "./Prose.jsx";
+
+export interface SingleLineCommentBlockProps {
+  children: Children;
+}
+
+/**
+ * A single line comment block. The children are rendered as a prose element, which means that they
+ * are broken into multiple lines
+ */
+export function SingleLineCommentBlock(props: SingleLineCommentBlockProps) {
+  return (
+    <>
+      //{" "}
+      <align string="// ">
+        <Prose>{props.children}</Prose>
+      </align>
+    </>
+  );
+}
diff --git a/packages/typescript/src/components/SourceFile.tsx b/packages/typescript/src/components/SourceFile.tsx
index fc3efae..d599044 100644
--- a/packages/typescript/src/components/SourceFile.tsx
+++ b/packages/typescript/src/components/SourceFile.tsx
@@ -17,6 +17,7 @@ import { createTSModuleScope, TSModuleScope } from "../symbols/index.js";
 import { ImportStatements } from "./ImportStatement.js";
 import { PackageContext } from "./PackageDirectory.js";
 import { Reference } from "./Reference.js";
+import { SingleLineCommentBlock } from "./SingleLineCommentBlock.jsx";
 export interface SourceFileContext {
   scope: TSModuleScope;
 }
@@ -30,6 +31,8 @@ export function useSourceFile() {
 export interface SourceFileProps {
   path: string;
   children?: Children;
+  header?: Children;
+  headerComment?: string;
   export?: boolean | string;
 }
 
@@ -59,11 +62,20 @@ export function SourceFile(props: SourceFileProps) {
     }
   }
 
+  const header =
+    props.header || props.headerComment ?
+      <SourceFileHeader
+        header={props.header}
+        headerComment={props.headerComment}
+      />
+    : undefined;
+
   return (
     <CoreSourceFile
       path={props.path}
       filetype="typescript"
       reference={Reference}
+      header={header}
     >
       <Show when={scope.importedModules.size > 0}>
         <ImportStatements records={scope.importedModules} />
@@ -76,3 +88,20 @@ export function SourceFile(props: SourceFileProps) {
     </CoreSourceFile>
   );
 }
+
+export interface SourceFileHeaderProps {
+  header?: Children;
+  headerComment?: string;
+}
+
+function SourceFileHeader(props: SourceFileHeaderProps) {
+  return (
+    <>
+      <Show when={props.headerComment !== undefined}>
+        <SingleLineCommentBlock>{props.headerComment}</SingleLineCommentBlock>
+      </Show>
+      <Show when={props.header !== undefined}>{props.header}</Show>
+      <hbr />
+    </>
+  );
+}
diff --git a/packages/typescript/src/components/index.ts b/packages/typescript/src/components/index.ts
index c1bf045..f2c17af 100644
--- a/packages/typescript/src/components/index.ts
+++ b/packages/typescript/src/components/index.ts
@@ -14,11 +14,11 @@ export * from "./Interface.js";
 export * from "./JSDoc.jsx";
 export * from "./JSDocComment.jsx";
 export * from "./JSDocExample.jsx";
-export * from "./JSDocParagraph.jsx";
 export * from "./MemberChainExpression.jsx";
 export * from "./ObjectExpression.js";
 export * from "./PackageDirectory.js";
 export * from "./PackageJson.js";
+export * from "./Prose.jsx";
 export * from "./Reference.js";
 export * from "./SourceFile.js";
 export * from "./SwitchStatement.jsx";
diff --git a/packages/typescript/src/components/stc/index.ts b/packages/typescript/src/components/stc/index.ts
index 961da94..05e9e4b 100644
--- a/packages/typescript/src/components/stc/index.ts
+++ b/packages/typescript/src/components/stc/index.ts
@@ -22,7 +22,7 @@ export const InterfaceMember = stc(base.InterfaceMember);
 export const JSDoc = stc(base.JSDoc);
 export const JSDocExample = stc(base.JSDocExample);
 export const JSDocComment = stc(base.JSDocComment);
-export const JSDocParagraph = stc(base.JSDocParagraph);
+export const JSDocParagraph = stc(base.Prose);
 export const MemberChainExpression = stc(base.MemberChainExpression);
 export const ObjectExpression = stc(base.ObjectExpression);
 export const ObjectProperty = stc(base.ObjectProperty);


Test patch:
diff --git a/packages/core/test/components/source-file.test.tsx b/packages/core/test/components/source-file.test.tsx
index 5f0f124..27754ff 100644
--- a/packages/core/test/components/source-file.test.tsx
+++ b/packages/core/test/components/source-file.test.tsx
@@ -9,6 +9,7 @@ import {
 import { expect, it } from "vitest";
 import { SourceDirectoryContext } from "../../src/context/source-directory.js";
 import "../../testing/extend-expect.js";
+import { d } from "../../testing/render.js";
 
 it("tracks its content", () => {
   let context;
@@ -51,3 +52,19 @@ it("has reactive context", () => {
 
   expect(tree.contents[1].contents).toEqual("hi.txt contents.txt");
 });
+
+it("Includes header", () => {
+  const header = <># This is a header</>;
+  const tree = render(
+    <Output>
+      <SourceFile path="hi.txt" filetype="text" header={header}>
+        hello!
+      </SourceFile>
+    </Output>,
+  );
+
+  expect(tree.contents[0].contents).toEqual(d`
+    # This is a header
+    hello!
+    `);
+});
diff --git a/packages/typescript/test/jscomment.test.tsx b/packages/typescript/test/jscomment.test.tsx
new file mode 100644
index 0000000..ba47d68
--- /dev/null
+++ b/packages/typescript/test/jscomment.test.tsx
@@ -0,0 +1,104 @@
+import { List } from "@alloy-js/core";
+import "@alloy-js/core/testing";
+import { d } from "@alloy-js/core/testing";
+import { describe, expect, it } from "vitest";
+import { SingleLineCommentBlock } from "../src/components/SingleLineCommentBlock.jsx";
+
+describe("JSComment", () => {
+  it("formats properly a simple comment", () => {
+    const template = (
+      <>
+        <SingleLineCommentBlock>Hello!</SingleLineCommentBlock>
+        <hbr />
+        Should not be part of the comment
+      </>
+    );
+
+    expect(template).toRenderTo(
+      d`
+      // Hello!
+      Should not be part of the comment
+      `,
+      { printWidth: 40 },
+    );
+  });
+
+  it("should not add extra line breaks", () => {
+    const template = (
+      <>
+        <List hardline>
+          <SingleLineCommentBlock>
+            Hello!
+            <hbr />
+            <hbr />
+            This is another line
+          </SingleLineCommentBlock>
+        </List>
+        <hbr />
+        Hello
+      </>
+    );
+
+    expect(template).toRenderTo(
+      d`
+       // Hello!
+       //
+       // This is another line
+       Hello
+       `,
+      { printWidth: 40 },
+    );
+  });
+
+  it("formats properly multiple children", () => {
+    const template = (
+      <>
+        <SingleLineCommentBlock>
+          Hello!
+          <hbr />
+          <hbr />
+          This is another line
+        </SingleLineCommentBlock>
+        <hbr />
+        foo should be outside the comment
+      </>
+    );
+
+    expect(template).toRenderTo(
+      d`
+       // Hello!
+       //
+       // This is another line
+       foo should be outside the comment
+       `,
+      { printWidth: 40 },
+    );
+  });
+
+  it("It correctly do word wrapping", () => {
+    const template = (
+      <>
+        <SingleLineCommentBlock>
+          This is a very long line that should be broken into multiple lines. It
+          should also be aligned properly. The line breaks in this paragraph
+          should not be carried over into the JSDoc comment.
+        </SingleLineCommentBlock>
+        <hbr />
+        <>This should not be part of the comment</>
+      </>
+    );
+
+    expect(template).toRenderTo(
+      d`
+       // This is a very long line that should
+       // be broken into multiple lines. It
+       // should also be aligned properly. The
+       // line breaks in this paragraph should
+       // not be carried over into the JSDoc
+       // comment.
+       This should not be part of the comment
+    `,
+      { printWidth: 40 },
+    );
+  });
+});
diff --git a/packages/typescript/test/jsdoc.test.tsx b/packages/typescript/test/jsdoc.test.tsx
index 99779a3..b18a14b 100644
--- a/packages/typescript/test/jsdoc.test.tsx
+++ b/packages/typescript/test/jsdoc.test.tsx
@@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest";
 import { JSDoc } from "../src/components/JSDoc.jsx";
 import { JSDocComment } from "../src/components/JSDocComment.jsx";
 import { JSDocExample } from "../src/components/JSDocExample.jsx";
-import { JSDocParagraph } from "../src/components/JSDocParagraph.jsx";
+import { Prose } from "../src/components/Prose.jsx";
 import { FunctionCallExpression } from "../src/index.js";
 
 describe("JSDoc", () => {
@@ -35,15 +35,15 @@ describe("JSDocParagraph", () => {
   it("formats properly", () => {
     const template = (
       <JSDoc>
-        <JSDocParagraph>
+        <Prose>
           This is a long paragraph that should end up being broken into multiple
           paragraphs. Moreover, the line breaks in this paragraph should not be
           carried over into the JSDoc comment.
-        </JSDocParagraph>
-        <JSDocParagraph>
+        </Prose>
+        <Prose>
           This is another paragraph. There should be a couple breaks in front of
           it.
-        </JSDocParagraph>
+        </Prose>
       </JSDoc>
     );
 
@@ -70,9 +70,7 @@ describe("JSDocExample", () => {
   it("creates unfenced code samples", () => {
     const template = (
       <JSDoc>
-        <JSDocParagraph>
-          This API is awesome, and it comes with an example!
-        </JSDocParagraph>
+        <Prose>This API is awesome, and it comes with an example!</Prose>
         <JSDocExample>console.log("Hello world!")</JSDocExample>
       </JSDoc>
     );
@@ -94,9 +92,7 @@ describe("JSDocExample", () => {
   it("creates fenced code samples", () => {
     const template = (
       <JSDoc>
-        <JSDocParagraph>
-          This API is awesome, and it comes with an example!
-        </JSDocParagraph>
+        <Prose>This API is awesome, and it comes with an example!</Prose>
         <JSDocExample fenced language="ts">
           console.log("Hello world!")
         </JSDocExample>
@@ -122,9 +118,7 @@ describe("JSDocExample", () => {
   it("creates code samples using other components", () => {
     const template = (
       <JSDoc>
-        <JSDocParagraph>
-          This API is awesome, and it comes with an example!
-        </JSDocParagraph>
+        <Prose>This API is awesome, and it comes with an example!</Prose>
         <JSDocExample language="ts">
           // make a function call.
           <hbr />
diff --git a/packages/typescript/test/source-file.test.tsx b/packages/typescript/test/source-file.test.tsx
new file mode 100644
index 0000000..2e65cd5
--- /dev/null
+++ b/packages/typescript/test/source-file.test.tsx
@@ -0,0 +1,38 @@
+import { Output, render } from "@alloy-js/core";
+import { d } from "@alloy-js/core/testing";
+import { expect, it } from "vitest";
+import { SourceFile } from "../src/components/SourceFile.jsx";
+
+it("Includes header", () => {
+  const tree = render(
+    <Output>
+      <SourceFile path="hi.ts" headerComment="This is a header">
+        hello!
+      </SourceFile>
+    </Output>,
+  );
+
+  expect(tree.contents[0].contents).toEqual(d`
+    // This is a header
+    
+    hello!`);
+});
+
+it("Line wraps a header", () => {
+  const tree = render(
+    <Output>
+      <SourceFile
+        path="hi.ts"
+        headerComment="This is a very long header that is very long and should be wrapped. Meaning, it should be split into multiple lines."
+      >
+        hello!
+      </SourceFile>
+    </Output>,
+  );
+
+  expect(tree.contents[0].contents).toEqual(d`
+    // This is a very long header that is very long and should be wrapped. Meaning,
+    // it should be split into multiple lines.
+
+    hello!`);
+});