tsdoc icon indicating copy to clipboard operation
tsdoc copied to clipboard

RFC: @includeSnippet tag for embedding code samples from external files

Open stacey-gammon opened this issue 4 years ago • 12 comments

Hello,

I am looking for something similar to the @example tag, except I would like it to encapsulate real code, not something written in a comment.

My goal is to avoid the issue of writing examples that are out of date or inaccurate, which can happen when using the @example tag, because it's not real, tested code.

For example, this could be the package documentation:

/**
 * How to add your own custom recipe to this cook book app.
 * 
 * First, create a class that implements the IRecipe interface
 * @codeReference recipeDemoClass
 *
 * Then, register an instance of your class in your plugin's setup method.
 * @codeReference recipeDemoRegistration
 *
 * @packageDocumentation
 */

// some/path/to/demo_recipe/demo_recipe.ts

/**
 * @codeReferenceStart recipeDemoClass
 */
export class DemoRecipe extends IRecipe {
 ...
}
/**
 * @codeReferenceEnd recipeDemoClass
*/

// some/path/to/demo_recipe/plugin.ts

export class DemoRecipePlugin extends Plugin {
  setup(core, plugins) {
 /**
 * @codeReferenceStart recipeDemoRegistration
 */
  plugins.recipes.registerNewRecipe(new DemoRecipe());
/**
 * @codeReferenceEnd recipeDemoRegistration
*/
 }
}

The benefit of the end block is that you can call out specific snippets inside a function, not just entire exported functions.

Thoughts?

Note I also filed a similar feature request over in the Rushstack repo, but first this kind of tag would have to be part of the official TSDoc: https://github.com/microsoft/rushstack/issues/1640

stacey-gammon avatar Nov 26 '19 14:11 stacey-gammon

When the documentation tool (e.g. API Extractor) processes the directive @codeReference recipeDemoClass, how should it determine the path some/path/to/demo_recipe/demo_recipe.ts? Maybe it would be better for this path to be specified explicitly.

octogonz avatar Nov 27 '19 05:11 octogonz

With my zero knowledge of how the api extractor, or how TSDocs works, I was thinking that it would scan all ts files in a directory, building a mapping of code snippet id to actual code. This could then be used when building the documentation to replace @codeReference id with the actual snippet that was stored, perhaps in some top level codeSnippets: { [id]: string } mapping, inside the api.json file.

But perhaps the api extractor doesn't actually look through all ts files? In which case it would need the file path to go look it up. How do the @link tags work? Those don't include file paths, but I think VSCode can navigate the user to the definition referenced?

stacey-gammon avatar Nov 27 '19 13:11 stacey-gammon

The @link tags normally refer to APIs that are exported from the package entry point. If they needed to refer to some other file, the syntax allows a file path.

My main thought here is that the example files are not shipping code. They probably should not be imported (i.e. using import) by the shipping classes that use them in their documentation. That's why it feels like the path needs to be slightly more explicit.

Also, in a project with potentially thousands of files, it may be inefficient to have to read every file in order to look up a single @codeReference. It would be better if the referenced filename could be determined somewhat directly.

If I get some time I'll see if I can think of some design ideas.

octogonz avatar Nov 27 '19 13:11 octogonz

My main thought here is that the example files are not shipping code.

++

Additionally, in my use case, the example files are completely separate from the source files. e.g. the source file is in something like src/plugins/recipes/recipeBookPlugin.ts and the code snippet is in test/plugin_functional/plugins/demoRecipe/... (although ideally I'd like to see it in a top level demo folder). But yea, this example code is not included in release builds and is not imported anywhere from the package entry point, onward.

Using a filepath reference sgtm.

stacey-gammon avatar Nov 27 '19 14:11 stacey-gammon

I thought about it some more. Here's some possible design alternatives we could consider:

  • Maybe we could call the tag @includeSnippet instead of @codeReference. This might better convey the idea that we're inserting content from another file, and give the feature a more distinctive name. (I've seen a couple other projects using the term "snippets" for this sort of thing, but I'm open to other ideas.)

  • It should probably be an inline tag like {@includeSnippet} instead of a block tag @includeSnippet. TSDoc doesn't allow block tags to be nested inside other block tags, whereas we want snippets to be embeddable in any kind of documentation of block (e.g. @remarks, @param, etc.).

  • If we want to support a file path, the syntax could be like {@includeSnippet <path> <marker>}.

  • Following the convention of the {@link} tag, we can support several syntaxes for the <path> part:

    • For a path that is relative to the current file, it would start with a period, e.g. ../../folder/file.ts or ./folder/file.ts
    • If the path is relative to the containing package folder (i.e. the folder of package.json), it would be e.g. !folder/file.ts
    • If the path is relative to another package, then it would be e.g. other-package!folder/file.ts
  • For the (perhaps most common?) case where a file contains only one snippet, the <marker> part could be omitted.

  • The marker lines (e.g. @codeReferenceStart recipeDemoRegistration) should maybe be a //-style comment. To me they don't feel like /** */ comments, since they won't contain any actual documentation content, and actually should not contain any other text besides the marker identifier.

With these changes, your example might look like this:

my-package/src/index.ts

/**
 * How to add your own custom recipe to this cook book app.
 * 
 * First, create a class that implements the `IRecipe` interface
 * {@includeSnippet ../test/demoRecipe.ts recipeDemoClass}
 *
 * Then, register an instance of your class in your plugin's setup method.
 * {@includeSnippet !test/plugin.ts}
 *
 * @packageDocumentation
 */

...and then the referenced source files might look like this:

my-package/test/demoRecipe.ts

// @snippetStart recipeDemoClass
/**
 * Here's an example class
 */
export class DemoRecipe extends IRecipe {
 ...
}
// @snippetEnd recipeDemoClass

// @snippetStart blahFunction
/**
 * Some other snippet.
 */
function blah(): void { }
// @snippetEnd blahFunction

my-package/test/plugin.ts

export class DemoRecipePlugin extends Plugin {
  setup(core, plugins) {
  // @snippetStart
  plugins.recipes.registerNewRecipe(new DemoRecipe());
  // @snippetEnd
 }
}

We could also specify that if the file doesn't contain any @snippetStart/@snippetEnd markers at all, then the tag {@includeSnippet !test/plugin.ts} would simply include the entire file contents. (?)

@stacey-gammon Let me know what you think about these suggestions.

octogonz avatar Nov 29 '19 13:11 octogonz

@rbuckton @EisenbergEffect FYI

octogonz avatar Nov 29 '19 13:11 octogonz

@octogonz This would work for most of the cases that I'm aware. I've also seen the term "snippet" used and think it's a better fit. Would this potentially enable inserting Markdown snippets from another file? I assume you could use this as a general text insertion mechanism but wasn't sure if the content would be processed as Markdown after inserted.

I think my preference would be to disallow @snippetStart from not containing a label and have @includeSnippet with no label always include the whole file. The reason for this is that adding a second snippet in a file that used @snippetStart with no label could accidentally change the generated output without the author of the original document or possibly the person adding the new snippet realizing it.

EisenbergEffect avatar Nov 29 '19 19:11 EisenbergEffect

Would this potentially enable inserting Markdown snippets from another file? I assume you could use this as a general text insertion mechanism but wasn't sure if the content would be processed as Markdown after inserted.

If we require that the @includeSnippet path always includes the file extension, then this should work with any file. The syntax highlighting can be inferred, rather than explicitly specified as with a Markdown code fence (```ts).

Maybe part of the definition of a "snippet" is that it gets rendered as code. If we want to insert content from a .md file and have it rendered as Markdown, that seems like a separate scenario. Perhaps it should have its own tag (@include?) and be designed separately. The requirements are probably somewhat different.

I think my preference would be to disallow @snippetStart from not containing a label and have @includeSnippet with no label always include the whole file. The reason for this is that adding a second snippet in a file that used @snippetStart with no label could accidentally change the generated output without the author of the original document or possibly the person adding the new snippet realizing it.

👍 Makes sense.

@stacey-gammon What do you think?

octogonz avatar Nov 30 '19 01:11 octogonz

Thanks @octogonz that all sounds good to me. Although... I've started to think about some different scenarios for using this that might require us to still build our own parser (though I would still use whatever the official syntax you come up with for this).

The reason being is that this is not just api documentation we are building, but things like tutorials and FAQs. Taking the tutorial piece - there is not really a good place in tsdoc to put documentation like this. From what I know, there should only be one @packageDocumentation comment, but we will likely have many tutorials per package because a single package can have a lot of functionality/services provided. I don't want to stuff all of this content inside one comment and have it rendered in a single markdown file. I'm not aware of any other good place to put this kind of content using tsdoc.

One option we are considering is to have developers write this tutorial documentation in markdown, allowing these snippet tags to be used as references (that is, the markdown would be pre-parsed, not post-parsed, like in this flow, where the markdown would already have the code examples embedded), then we'd parse the markdown files into another structure, which could then be used to, well, create markdown files with the code embedded, or html files, etc. It's a bit awkward though - ideally the markdown files that would be showing up in github would be in the post-parsed stage, with the code embedded, rather than some markdown files just showing these snippet reference tags, and other markdown files in the repo showing the stuff embedded.

I'm also considering, as a short term option, just parsing these code snippets out using raw file loader, and a javascript helper function, and letting developers register tutorial documentation written in react. Certainly not as portable this way though - it would not be parseable into other forms, and that would be an ideal situation so we have a single source of documentation truth, but multiple ways to display it - whether it's markdown inside our git repo, html pages hosted on the web somewhere, or react components built inside Kibana.

It's like this documentation type belongs next to, not within, the api documentation. It's complementary, and cross linking would be great, but it's not really within the hierarchy of api documentation - there is not necessarily a single function this belongs with, but instead it could be an overview of possibly many exposed functions. A tutorial is best if it starts really simple, hello world style, then builds more examples. This is something that doesn't feel like it fits inside typescript comments.

So, I really appreciate you all for jumping on this so quickly, but I may end up actually not using it, at least not for the initial project I had in mind. I still think it's a great effort though!

cc @clintandrewhall

stacey-gammon avatar Dec 02 '19 20:12 stacey-gammon

Right, TSDoc is designed for generating the "API Reference" section of your documentation. This is the section that shows all the classes/enums/etc in a big index. The API Reference is often authored by software engineers, or at least they write the initial draft of the content at the same time as they are designing the APIs. That's why it makes sense to store it in code comments. Updating this documentation usually requires creating a pull request for the actual code base.

Typically, the documentation system will also have a lot of free-form content such as walkthroughs, best practices, etc. This part may be authored by technical writers who would find it inconvenient to have to make pull requests into the product code base (and maybe won't even have permissions). Thus these pages are usually authored as standalone .md files, maybe in a completely different repository.

Here's an example from Microsoft SharePoint which uses DocFX. These API Reference pages were generated from the code comments using API Extractor:

...whereas these free-form pages were manually authored as DocFX .md files (and note how the table of contents seamlessly integrates these two sections):

And here's an example from Rush Stack which uses Jekyll documentation system. These API Reference pages were generated by API Extractor:

...whereas these pages were manually authored as Jekyll .md files:

Code snippets are useful in both the API Reference and the free-form sections. I think in both cases it would be desirable to compile the code to ensure there are no mistakes. Thus...

If you are writing long-form tutorials for big scenarios, it would make sense to model that as regular .md files. Your documentation system probably provides some way to extract excerpts from files. (For example, DocFX !code directives.)

If you are providing brief code samples illustrating a specific API, it would make sense to embed that in the API reference, in which case the proposed TSDoc @includeSnippet tag would be useful.

octogonz avatar Dec 02 '19 21:12 octogonz

That is awesome, thanks so much for the links and well written explanation of the two types of documentation. 🙇‍♀

stacey-gammon avatar Dec 02 '19 21:12 stacey-gammon

If we require that the @includeSnippet path always includes the file extension, then this should work with any file. The syntax highlighting can be inferred, rather than explicitly specified as with a Markdown code fence (```ts).

Maybe part of the definition of a "snippet" is that it gets rendered as code. If we want to insert content from a .md file and have it rendered as Markdown, that seems like a separate scenario. Perhaps it should have its own tag (@include?) and be designed separately. The requirements are probably somewhat different.

I think it's reasonable to assume "snippets" are always code, and it would be great for syntax highlighting to be inferred from the file extension. 👍

This design for @includeSnippet would be perfect for my use case – has any progress been made on a PR? Is this a good issue for first-time contributors to try implementing?

bitjson avatar Aug 12 '20 18:08 bitjson