-
Notifications
You must be signed in to change notification settings - Fork 122
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Support relative imports #40
Conversation
Ref. #30. |
66ad9d5
to
640db4f
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks, Cindy! Here’s a summary of my suggested changes:
-
Let’s fold the generation of FileAttachment features for imports into
findImports
instead of doing it in two passes (first infindImports
and second infindFeatures
). This should fix the cross-product logic bug. -
Instead of “baking” the source root into the file paths, let’s favor paths within the root. This should eliminate the need to check that the
sourcePath
starts with theroot
ingetPathFromRoot
(it should be required for any local path), and the subsequent need to slice off theroot
prefix, and the need to pass theroot
down to the client etc. -
Let’s make the
sourcePath
argument required instead of optional. It’s needed to compute the correct output, so we shouldn’t allow it to be missing. -
Tests! I highly, highly encourage you to write unit tests during the development of this feature, not just as a chore after you’ve written it. Writing tests alongside code will give you better feedback as you make changes, and will ensure that the logic is fully covered by tests going forward.
src/javascript.ts
Outdated
const features = findFeatures(body, references, input); | ||
const imports = findImports(body, root); | ||
const imports = findImports(body, root, sourcePath); | ||
const features = findFeatures(body, root, sourcePath, imports, references, input); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a lot of positional arguments. Especially in JavaScript where we lack type protection, we want to avoid too many positional arguments because it’s easy to get the order wrong or skip one, and cause unexpectede behavior. It probably makes sense to switch to an options object with named arguments at this point.
But see also the comment below: I think we probably don’t need to pass root
if we instead redefine sourcePath
so that it is a subpath of the root
. I.e., instead of passing root
= docs
and sourcePath
= docs/index.md
, we say sourcePath
= index.md
which is within docs
. (There should never be a source path outside of the root.) And I recommend we fold the imports file attachments logic into findImports
anyway.
src/javascript/features.js
Outdated
features.push({type: "FileAttachment", name: pathFromRoot}); | ||
// add transitive imports | ||
features.push( | ||
...imports.filter((im) => im.name !== pathFromRoot).map((im) => ({type: "FileAttachment", name: im.name})) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It looks like this will generate a lot of duplicate file attachment features. That may not ultimately matter since the files will subsequently get de-duplicated, but I think we can fix this logic.
The imports
array is generated by findImports
and passed in. This array consists of all the (statically-analyzable) imported modules, either as bare specifiers (e.g., npm:d3
) or URLs (e.g., https://cdn.jsdelivr.net/npm/d3/+esm
) or relative paths (e.g., ./foo/bar.js
). And it includes both top-level imports from the JavaScript and transitive imports from imported local modules.
Here, we’re walking over the AST looking for ImportExpression and ImportDeclaration nodes. We’re only considering the top-level imports. And we’re outputting everything in the passed-in imports
except for that one that matches the computed pathFromRoot
(i.e., the current top-level import). But if the code has multiple imports, and each of those imports has transitive imports, this effectively computes the cross product of imports so there will be many redundant entries in the output.
Also, stepping back for a second, this is fixing an unfiled bug: our current logic for promoting local imports to file attachments only considers top-level imports, not transitive imports. I filed #76 to track this bug.
I think the logic would be simpler if we consolidated this logic into findImports
, i.e., we should have findImports
compute the array of imports
but also append file attachments to features
at the same time so that we don’t have to traverse twice.
src/render.ts
Outdated
.concat(parseResult.imports.filter(({name}) => name.startsWith("./")).map(({name}) => `/_file/${name.slice(2)}`)) | ||
.concat( | ||
parseResult.imports | ||
.filter(({name}) => name.startsWith(root)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We don’t want to push this root
down to the client — this is the relative path from the cwd to the source root, and it doesn’t need to be exposed to the client. (It’s only needed by the server.) Instead, we should have the server normalize the local paths so that they always start with a /
and are relative to the root. For example, if the root is docs
and we’re rendering docs/eng/github.md
and the code imports ../chart.js
, the path should be normalized to /chart.js
. Then here we just need to check name.startsWith("/")
for local imports.
@@ -105,8 +117,8 @@ export function parseJavaScript(input: string, options: ParseOptions) { | |||
const body = expression ?? (Parser.parse(input, parseOptions) as any); | |||
const references = findReferences(body, globals, input); | |||
const declarations = expression ? null : findDeclarations(body, globals, input); | |||
const features = findFeatures(body, references, input); | |||
const imports = findImports(body, root); | |||
const {imports, features: importFeatures} = findImports(body, root, sourcePath); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
don't really like how it returns features and imports in a function called "findImports" 🤔
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We will also have additional features like Secrets soon, so we should anticipate that if we can.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Filed a ticket to address the confusion around adding local fetches and local imports as file attachments: #87
@@ -105,8 +117,8 @@ export function parseJavaScript(input: string, options: ParseOptions) { | |||
const body = expression ?? (Parser.parse(input, parseOptions) as any); | |||
const references = findReferences(body, globals, input); | |||
const declarations = expression ? null : findDeclarations(body, globals, input); | |||
const features = findFeatures(body, references, input); | |||
const imports = findImports(body, root); | |||
const {imports, features: importFeatures} = findImports(body, root, sourcePath); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We will also have additional features like Secrets soon, so we should anticipate that if we can.
src/javascript/features.js
Outdated
|
||
export function findFeatures(node, references, input) { | ||
export function findFeatures(node, sourcePath, references, input) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In the higher level code with have "root" and "path", how does "sourcePath" relate to those? Maybe there's a more descriptive term?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
the definition of sourcePath
should be global to the codebase. @cinxmo document it
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we should have a larger discussion around documentation, especially how to document function arguments. I like using docstrings
src/javascript/imports.ts
Outdated
@@ -83,7 +90,9 @@ export function rewriteImports(output, root) { | |||
: node.specifiers.some(isNamespaceSpecifier) | |||
? node.specifiers.find(isNamespaceSpecifier).local.name | |||
: "{}" | |||
} = await import(${value.startsWith("./") ? JSON.stringify("/_file/" + value.slice(2)) : node.source.raw});` | |||
} = await import(${ | |||
isLocalImport(value) ? JSON.stringify(join("/_file/", join(dirname(sourcePath), value))) : node.source.raw |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would define a constant for "/_file" somewhere and use that so it is easier to find all the places where we use it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, and eventually we’ll have to support a serving root prefix #42 too.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd like to address this in a separate PR along with #42
src/render.ts
Outdated
p.path.replace(/\/index$/, "/") | ||
)}">${escapeData(p.name)}</a></li>` | ||
<li class="observablehq-link${ | ||
p.path === sourcePath ? " observablehq-link-active" : "" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think either this comparison is wrong or the name sourcePath
is inappropriate here.
The p.path
here comes from readPages
and represents the serving path. We use sourcePath
to refer to a source path within the source root. The difference is that a serving path does not include the file extension .md
at the end whereas a source path does.
Is sourcePath
a source path here, or is it the serving path?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
sorry I lazily changed it without realizing it broke the highlighting. it should be the serving path (so source path without the .md
extension)
src/render.ts
Outdated
@@ -63,7 +64,7 @@ ${ | |||
${JSON.stringify({imports: Object.fromEntries(Array.from(imports, ([name, href]) => [name, href]))}, null, 2)} | |||
</script> | |||
${Array.from(imports.values()) | |||
.concat(parseResult.imports.filter(({name}) => name.startsWith("./")).map(({name}) => `/_file/${name.slice(2)}`)) | |||
.concat(parseResult.imports.filter(({type}) => type === "local").map(({name}) => join("/_file/", name))) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The behavior of path.join depends on the operating system: it uses the platform-specific path separator. When constructing a URL, we always want to use a forward slash, so we should avoid using path.join here. Is the name
here normalized so that it always starts with a leading forward slash?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
name
isn't normalized to start with a /
. in fact, it shouldn't have a leading /
because of how sourcePath
is constructed. Changing to .map(({name}) => `/_file/${name}`)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
actually no my previous statement was wrong. I think it's safer to remove the leading `/ if it exists
@wiltsecarpenter incremental reloading after making changes to a local ES module isn't working (unfortunately), but this is existing behavior. I'd like to merge this PR first and fix that in a separate one |
with the latest changes to data loaders, this PR is broken. |
src/render.ts
Outdated
@@ -127,7 +126,7 @@ function getImportPreloads(parseResult: ParseResult): Iterable<string> { | |||
const preloads: string[] = []; | |||
for (const specifier of specifiers) { | |||
const resolved = resolveImport(specifier); | |||
if (resolved.startsWith("/") || resolved.startsWith("https://")) { | |||
if (resolved.startsWith("/_observablehq") || resolved.startsWith("https://")) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don’t think we have a test for preloads yet, but this change looks like it will break module preloading of imported local ES modules (whose paths start with /_file/
).
Also, we would want to use /_observablehq/
here with a terminal slash to avoid a false positive on another (non-special) directory that coincidentally happens to start with _observablehq
such as _observablehq2
. (I don’t expect that to ever happen in practice, but we should write the code precisely just the same.)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think it'll break the module preloading of imported local ES modules since they should be handled by this concatenation:
https://github.com/observablehq/cli/blob/b1cdf2f4982244b7b74d331b0dfaca90f2d3bc78/src/render.ts#L62-L69
This function could be renamed to getGlobalImportPreloads
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, sorry, I missed that. I would prefer to keep it getImportPreloads
and move the local preloads back into the function.
return join(root + "/", dirname(sourcePath), value); | ||
} | ||
|
||
describe("isLocalImport", () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@wiltsecarpenter I modified isLocalImport
and added some test cases. let me know what you think
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Very close! Just one blocker on the getImportPreloads.
src/render.ts
Outdated
@@ -122,7 +118,7 @@ function getImportPreloads(parseResult: ParseResult): Iterable<string> { | |||
const preloads: string[] = []; | |||
for (const specifier of specifiers) { | |||
const resolved = resolveImport(specifier); | |||
if (resolved.startsWith("/") || resolved.startsWith("https://")) { | |||
if (resolved.startsWith("/_observablehq/") || resolved.startsWith("https://")) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we still want to remove this change. We added the local import to specifiers
above, but it needs to be added to preloads
here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
fixed
const IMPORT_TEST_CASES = "./test/input/imports"; | ||
const IMPORT_TEST_RESULTS = "./test/output/imports"; | ||
for (const name of readdirSync(IMPORT_TEST_CASES)) { | ||
if (!isJsFile(IMPORT_TEST_CASES, name) || !name.includes("import")) continue; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If it’s not too much work, could we consolidate code with the other tests above? This looks like they’re doing almost the same thing.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This code was broken out to test nested/transitive imports. We can look at combining the test cases in the future
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
One of the things missing from the copied version is generating the -changed.js when the test fails. When a test fails, you’ll see an error, e.g.,
AssertionError [ERR_ASSERTION]: dynamic-import.js must match snapshot
but there’s no way to debug the error because the actual output of the test isn’t saved to disk or shown in the console.
Co-authored-by: Mike Bostock <[email protected]>
I found (yet another) bug when testing with the existing code in
The issue is that in a Markdown cell, we import: import {navio} from "./navio.js"; and in
but we don't rewrite transitive imports as noted in one of our pairing sessions. Transitive imports need to be rewritten for this use case. I think we should handle this in a separate PR |
This is a regression I introduced in #101. To summarize:
I’ll file an issue #115 for this. Thanks for finding this. |
Resolves #59, resolves #30
Description
The purpose of this PR is to:
1. Support relative imports
Users want to be able to define their own JS modules and use them throughout a JS cell. For example, if we have:
/docs/subDocs/javascript.md
)/docs/subDocs/bar.js
/docs/subDocs/javascript.md
we want toimport {bar} from "./bar.js"
Prior to this change, it assumed "./bar.js" is relative to the root directory (
/docs
). With this change, it will recognize that "./" is relative to/docs/subDocs/
To have this work, we pass in the
sourcePath
, i.e. the path relative to the root excluding the root. For example, in/docs/subDocs/javascript.md
theroot
isdocs
and thesourcePath
issubDocs/javascript.md
. The resulting import "path" would besubDocs/bar.js
(as opposed to/bar.js
since we no longer assume it is sourced relative to the root). This ensures that as we're building the list of imports, the path is always going to be relative to the root, but excludes the root.2. Promote local imports and local fetches to file attachment references
At the same time, we want to promote local imports (and fetches) to file attachment references. These files are copied over as part of the source files for the production build.
They are also watched as part of incremental reloading.Testing